mini2fa 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mini2fa/__init__.py +9 -0
- mini2fa/__main__.py +4 -0
- mini2fa/_cli.py +779 -0
- mini2fa/config.py +50 -0
- mini2fa/crypto.py +328 -0
- mini2fa/models.py +33 -0
- mini2fa/scanner.py +189 -0
- mini2fa/storage.py +414 -0
- mini2fa/totp.py +119 -0
- mini2fa-0.1.0.dist-info/METADATA +61 -0
- mini2fa-0.1.0.dist-info/RECORD +15 -0
- mini2fa-0.1.0.dist-info/WHEEL +5 -0
- mini2fa-0.1.0.dist-info/entry_points.txt +2 -0
- mini2fa-0.1.0.dist-info/licenses/LICENSE +674 -0
- mini2fa-0.1.0.dist-info/top_level.txt +1 -0
mini2fa/storage.py
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
"""
|
|
2
|
+
数据存储模块
|
|
3
|
+
|
|
4
|
+
使用 SQLite 存储账号数据,支持 JSON 导入导出
|
|
5
|
+
"""
|
|
6
|
+
import sqlite3
|
|
7
|
+
import json
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
|
|
11
|
+
from .models import Account
|
|
12
|
+
from .crypto import CryptoManager
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class StorageCorruptedError(Exception):
|
|
16
|
+
"""数据库文件损坏或无法读取时抛出"""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _connect(db_path: str):
|
|
20
|
+
"""
|
|
21
|
+
连接数据库,损坏/锁定异常统一转 StorageCorruptedError
|
|
22
|
+
"""
|
|
23
|
+
try:
|
|
24
|
+
return sqlite3.connect(db_path)
|
|
25
|
+
except sqlite3.Error as e:
|
|
26
|
+
raise StorageCorruptedError(f"数据库无法打开: {e}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _guard_errors(func):
|
|
30
|
+
"""装饰器:把 sqlite3.Error(损坏/锁定/表异常)统一转 StorageCorruptedError"""
|
|
31
|
+
def wrapper(*args, **kwargs):
|
|
32
|
+
try:
|
|
33
|
+
return func(*args, **kwargs)
|
|
34
|
+
except sqlite3.Error as e:
|
|
35
|
+
raise StorageCorruptedError(f"数据库损坏或操作失败: {e}")
|
|
36
|
+
return wrapper
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# 数据库表结构
|
|
40
|
+
CREATE_TABLE_SQL = '''
|
|
41
|
+
CREATE TABLE IF NOT EXISTS accounts (
|
|
42
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
43
|
+
issuer TEXT NOT NULL,
|
|
44
|
+
account TEXT NOT NULL,
|
|
45
|
+
secret_encrypted TEXT NOT NULL,
|
|
46
|
+
algorithm TEXT DEFAULT 'SHA1',
|
|
47
|
+
digits INTEGER DEFAULT 6,
|
|
48
|
+
period INTEGER DEFAULT 30,
|
|
49
|
+
category TEXT DEFAULT 'default',
|
|
50
|
+
notes TEXT,
|
|
51
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
52
|
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
53
|
+
UNIQUE(issuer, account)
|
|
54
|
+
);
|
|
55
|
+
'''
|
|
56
|
+
|
|
57
|
+
CREATE_INDEX1_SQL = '''
|
|
58
|
+
CREATE INDEX IF NOT EXISTS idx_accounts_issuer ON accounts(issuer);
|
|
59
|
+
'''
|
|
60
|
+
|
|
61
|
+
CREATE_INDEX2_SQL = '''
|
|
62
|
+
CREATE INDEX IF NOT EXISTS idx_accounts_category ON accounts(category);
|
|
63
|
+
'''
|
|
64
|
+
|
|
65
|
+
CREATE_TRIGGER_SQL = '''
|
|
66
|
+
CREATE TRIGGER IF NOT EXISTS update_accounts_timestamp
|
|
67
|
+
AFTER UPDATE ON accounts
|
|
68
|
+
BEGIN
|
|
69
|
+
UPDATE accounts SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
|
70
|
+
END;
|
|
71
|
+
'''
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class StorageManager:
|
|
75
|
+
"""存储管理器"""
|
|
76
|
+
|
|
77
|
+
def __init__(self, db_path: str, crypto_manager: CryptoManager):
|
|
78
|
+
"""
|
|
79
|
+
Args:
|
|
80
|
+
db_path: 数据库文件路径
|
|
81
|
+
crypto_manager: 加密管理器实例
|
|
82
|
+
"""
|
|
83
|
+
self.db_path = db_path
|
|
84
|
+
self.crypto = crypto_manager
|
|
85
|
+
self._init_db()
|
|
86
|
+
|
|
87
|
+
def _init_db(self):
|
|
88
|
+
"""初始化数据库"""
|
|
89
|
+
try:
|
|
90
|
+
with _connect(self.db_path) as conn:
|
|
91
|
+
conn.execute(CREATE_TABLE_SQL)
|
|
92
|
+
conn.execute(CREATE_INDEX1_SQL)
|
|
93
|
+
conn.execute(CREATE_INDEX2_SQL)
|
|
94
|
+
conn.execute(CREATE_TRIGGER_SQL)
|
|
95
|
+
conn.commit()
|
|
96
|
+
except StorageCorruptedError:
|
|
97
|
+
raise
|
|
98
|
+
except sqlite3.Error as e:
|
|
99
|
+
raise StorageCorruptedError(f"数据库损坏或无法初始化: {e}")
|
|
100
|
+
|
|
101
|
+
@_guard_errors
|
|
102
|
+
def add_account(
|
|
103
|
+
self,
|
|
104
|
+
issuer: str,
|
|
105
|
+
account: str,
|
|
106
|
+
secret: str,
|
|
107
|
+
algorithm: str = 'SHA1',
|
|
108
|
+
digits: int = 6,
|
|
109
|
+
period: int = 30,
|
|
110
|
+
category: str = 'default',
|
|
111
|
+
notes: str = ''
|
|
112
|
+
) -> int:
|
|
113
|
+
"""
|
|
114
|
+
添加新账号
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
issuer: 服务提供商
|
|
118
|
+
account: 账号名
|
|
119
|
+
secret: OTP 密钥(明文,将自动加密)
|
|
120
|
+
algorithm: 哈希算法
|
|
121
|
+
digits: 验证码位数
|
|
122
|
+
period: 时间步长
|
|
123
|
+
category: 分类
|
|
124
|
+
notes: 备注
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
新账号 ID
|
|
128
|
+
|
|
129
|
+
Raises:
|
|
130
|
+
ValueError: 账号已存在
|
|
131
|
+
"""
|
|
132
|
+
# 加密密钥
|
|
133
|
+
encrypted_secret = self.crypto.encrypt(secret)
|
|
134
|
+
|
|
135
|
+
with _connect(self.db_path) as conn:
|
|
136
|
+
try:
|
|
137
|
+
cursor = conn.execute('''
|
|
138
|
+
INSERT INTO accounts
|
|
139
|
+
(issuer, account, secret_encrypted, algorithm, digits, period, category, notes)
|
|
140
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
141
|
+
''', (issuer, account, encrypted_secret, algorithm, digits, period, category, notes))
|
|
142
|
+
conn.commit()
|
|
143
|
+
return cursor.lastrowid
|
|
144
|
+
except sqlite3.IntegrityError:
|
|
145
|
+
raise ValueError(f"账号已存在: {issuer}:{account}")
|
|
146
|
+
|
|
147
|
+
@_guard_errors
|
|
148
|
+
def get_account(self, account_id: int) -> Optional[Account]:
|
|
149
|
+
"""获取单个账号"""
|
|
150
|
+
with _connect(self.db_path) as conn:
|
|
151
|
+
conn.row_factory = sqlite3.Row
|
|
152
|
+
row = conn.execute('SELECT * FROM accounts WHERE id = ?', (account_id,)).fetchone()
|
|
153
|
+
if row:
|
|
154
|
+
return Account(**dict(row))
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
@_guard_errors
|
|
158
|
+
def get_all_accounts(self, category: str = None) -> List[Account]:
|
|
159
|
+
"""获取所有账号"""
|
|
160
|
+
with _connect(self.db_path) as conn:
|
|
161
|
+
conn.row_factory = sqlite3.Row
|
|
162
|
+
if category:
|
|
163
|
+
rows = conn.execute(
|
|
164
|
+
'SELECT * FROM accounts WHERE category = ? ORDER BY issuer, account',
|
|
165
|
+
(category,)
|
|
166
|
+
).fetchall()
|
|
167
|
+
else:
|
|
168
|
+
rows = conn.execute(
|
|
169
|
+
'SELECT * FROM accounts ORDER BY issuer, account'
|
|
170
|
+
).fetchall()
|
|
171
|
+
return [Account(**dict(row)) for row in rows]
|
|
172
|
+
|
|
173
|
+
def get_secret(self, account_id: int) -> str:
|
|
174
|
+
"""解密并获取密钥"""
|
|
175
|
+
account = self.get_account(account_id)
|
|
176
|
+
if not account:
|
|
177
|
+
raise ValueError(f"账号不存在: {account_id}")
|
|
178
|
+
return self.crypto.decrypt(account.secret_encrypted)
|
|
179
|
+
|
|
180
|
+
@_guard_errors
|
|
181
|
+
def find_by_identity(self, issuer: str, account: str) -> Optional[Account]:
|
|
182
|
+
"""按 (issuer, account) 精确查找账号"""
|
|
183
|
+
with _connect(self.db_path) as conn:
|
|
184
|
+
conn.row_factory = sqlite3.Row
|
|
185
|
+
row = conn.execute(
|
|
186
|
+
'SELECT * FROM accounts WHERE issuer = ? AND account = ?',
|
|
187
|
+
(issuer, account)
|
|
188
|
+
).fetchone()
|
|
189
|
+
if row:
|
|
190
|
+
return Account(**dict(row))
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
@_guard_errors
|
|
194
|
+
def overwrite_account(self, account_id: int, secret_encrypted: str,
|
|
195
|
+
algorithm: str, digits: int, period: int,
|
|
196
|
+
category: str, notes: str) -> bool:
|
|
197
|
+
"""用备份数据整体覆盖账号(含密钥),供导入冲突时使用"""
|
|
198
|
+
with _connect(self.db_path) as conn:
|
|
199
|
+
cursor = conn.execute('''
|
|
200
|
+
UPDATE accounts SET
|
|
201
|
+
secret_encrypted = ?,
|
|
202
|
+
algorithm = ?,
|
|
203
|
+
digits = ?,
|
|
204
|
+
period = ?,
|
|
205
|
+
category = ?,
|
|
206
|
+
notes = ?
|
|
207
|
+
WHERE id = ?
|
|
208
|
+
''', (secret_encrypted, algorithm, digits, period, category, notes, account_id))
|
|
209
|
+
conn.commit()
|
|
210
|
+
return cursor.rowcount > 0
|
|
211
|
+
|
|
212
|
+
@_guard_errors
|
|
213
|
+
def update_account(self, account_id: int, **kwargs) -> bool:
|
|
214
|
+
"""更新账号信息"""
|
|
215
|
+
# 检查账号是否存在
|
|
216
|
+
if not self.get_account(account_id):
|
|
217
|
+
return False
|
|
218
|
+
|
|
219
|
+
allowed_fields = {'category', 'notes'}
|
|
220
|
+
updates = {k: v for k, v in kwargs.items() if k in allowed_fields}
|
|
221
|
+
|
|
222
|
+
if not updates:
|
|
223
|
+
return False
|
|
224
|
+
|
|
225
|
+
set_clause = ', '.join(f'{k} = ?' for k in updates.keys())
|
|
226
|
+
values = list(updates.values()) + [account_id]
|
|
227
|
+
|
|
228
|
+
with _connect(self.db_path) as conn:
|
|
229
|
+
conn.execute(f'UPDATE accounts SET {set_clause} WHERE id = ?', values)
|
|
230
|
+
conn.commit()
|
|
231
|
+
return True
|
|
232
|
+
|
|
233
|
+
@_guard_errors
|
|
234
|
+
def delete_account(self, account_id: int) -> bool:
|
|
235
|
+
"""删除账号"""
|
|
236
|
+
with _connect(self.db_path) as conn:
|
|
237
|
+
cursor = conn.execute('DELETE FROM accounts WHERE id = ?', (account_id,))
|
|
238
|
+
conn.commit()
|
|
239
|
+
return cursor.rowcount > 0
|
|
240
|
+
|
|
241
|
+
def export_json(self, output_path: str) -> int:
|
|
242
|
+
"""
|
|
243
|
+
导出为 JSON 文件(密钥仍加密,内嵌主密钥文件以支持跨机器迁移)
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
output_path: 输出文件路径
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
导出的账号数量
|
|
250
|
+
"""
|
|
251
|
+
accounts = self.get_all_accounts()
|
|
252
|
+
|
|
253
|
+
export_data = {
|
|
254
|
+
'version': 1,
|
|
255
|
+
'exported_at': datetime.now().isoformat(),
|
|
256
|
+
'master_key': self.crypto.get_key_data(), # 内嵌主密钥文件,支持跨机恢复
|
|
257
|
+
'accounts': [
|
|
258
|
+
{
|
|
259
|
+
'issuer': a.issuer,
|
|
260
|
+
'account': a.account,
|
|
261
|
+
'secret_encrypted': a.secret_encrypted,
|
|
262
|
+
'algorithm': a.algorithm,
|
|
263
|
+
'digits': a.digits,
|
|
264
|
+
'period': a.period,
|
|
265
|
+
'category': a.category,
|
|
266
|
+
'notes': a.notes
|
|
267
|
+
}
|
|
268
|
+
for a in accounts
|
|
269
|
+
]
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
with open(output_path, 'w', encoding='utf-8') as f:
|
|
273
|
+
json.dump(export_data, f, indent=2, ensure_ascii=False)
|
|
274
|
+
|
|
275
|
+
return len(accounts)
|
|
276
|
+
|
|
277
|
+
def import_json(self, input_path: str, external_key: bytes = None,
|
|
278
|
+
decisions: dict = None) -> dict:
|
|
279
|
+
"""
|
|
280
|
+
从 JSON 文件导入(密钥仍加密)
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
input_path: 输入文件路径
|
|
284
|
+
external_key: 备份对应的主密钥(跨机导入时用外部 key 解密再重加密);
|
|
285
|
+
None 时用当前 self.key(同机导入)
|
|
286
|
+
decisions: 冲突决策映射 {(issuer, account): 'current' | 'backup'};
|
|
287
|
+
'backup' 用备份整体覆盖,'current' 或缺失时保留当前(跳过)
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
{'imported': 新增数, 'updated': 覆盖数,
|
|
291
|
+
'conflict_skipped': 冲突保留当前数, 'damaged_skipped': 数据损坏跳过数}
|
|
292
|
+
"""
|
|
293
|
+
with open(input_path, 'r', encoding='utf-8') as f:
|
|
294
|
+
data = json.load(f)
|
|
295
|
+
|
|
296
|
+
imported = 0
|
|
297
|
+
updated = 0
|
|
298
|
+
conflict_skipped = 0
|
|
299
|
+
damaged_skipped = 0
|
|
300
|
+
decisions = decisions or {}
|
|
301
|
+
|
|
302
|
+
for acc in data.get('accounts', []):
|
|
303
|
+
issuer = acc['issuer']
|
|
304
|
+
account = acc['account']
|
|
305
|
+
|
|
306
|
+
# 解密备份中的明文密钥
|
|
307
|
+
try:
|
|
308
|
+
if external_key is not None:
|
|
309
|
+
decrypted_secret = self.crypto.decrypt_with_key(external_key, acc['secret_encrypted'])
|
|
310
|
+
else:
|
|
311
|
+
decrypted_secret = self.crypto.decrypt(acc['secret_encrypted'])
|
|
312
|
+
except ValueError:
|
|
313
|
+
damaged_skipped += 1 # 密钥无法解密(损坏),跳过
|
|
314
|
+
continue
|
|
315
|
+
|
|
316
|
+
existing = self.find_by_identity(issuer, account)
|
|
317
|
+
|
|
318
|
+
if existing is None:
|
|
319
|
+
# 新增账号
|
|
320
|
+
self.add_account(
|
|
321
|
+
issuer=issuer,
|
|
322
|
+
account=account,
|
|
323
|
+
secret=decrypted_secret,
|
|
324
|
+
algorithm=acc.get('algorithm', 'SHA1'),
|
|
325
|
+
digits=acc.get('digits', 6),
|
|
326
|
+
period=acc.get('period', 30),
|
|
327
|
+
category=acc.get('category', 'default'),
|
|
328
|
+
notes=acc.get('notes', '')
|
|
329
|
+
)
|
|
330
|
+
imported += 1
|
|
331
|
+
elif decisions.get((issuer, account)) == 'backup':
|
|
332
|
+
# 用户选择用备份覆盖(整体覆盖,含密钥和全部字段)
|
|
333
|
+
new_secret = self.crypto.encrypt(decrypted_secret)
|
|
334
|
+
self.overwrite_account(
|
|
335
|
+
existing.id,
|
|
336
|
+
new_secret,
|
|
337
|
+
acc.get('algorithm', 'SHA1'),
|
|
338
|
+
acc.get('digits', 6),
|
|
339
|
+
acc.get('period', 30),
|
|
340
|
+
acc.get('category', 'default'),
|
|
341
|
+
acc.get('notes', '')
|
|
342
|
+
)
|
|
343
|
+
updated += 1
|
|
344
|
+
else:
|
|
345
|
+
# 用户选择保留当前(或无决策,默认跳过)
|
|
346
|
+
conflict_skipped += 1
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
'imported': imported,
|
|
350
|
+
'updated': updated,
|
|
351
|
+
'conflict_skipped': conflict_skipped,
|
|
352
|
+
'damaged_skipped': damaged_skipped
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
def preview_import(self, input_path: str, external_key: bytes = None) -> dict:
|
|
356
|
+
"""
|
|
357
|
+
预览导入内容,不实际导入
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
input_path: 输入文件路径
|
|
361
|
+
external_key: 备份对应的主密钥(跨机导入时用于解密校验);
|
|
362
|
+
None 时用当前 self.key(同机导入)
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
{
|
|
366
|
+
'total': 3,
|
|
367
|
+
'to_import': [{'issuer': 'Google', ...}, ...],
|
|
368
|
+
'to_skip': [{'issuer': 'GitHub', ...}, ...], # 已存在账号
|
|
369
|
+
'damaged': [{'issuer': 'X', ...}, ...] # 无法解密的损坏条目
|
|
370
|
+
}
|
|
371
|
+
"""
|
|
372
|
+
# 获取现有账号集合
|
|
373
|
+
existing = self.get_all_accounts()
|
|
374
|
+
existing_keys = {(acc.issuer, acc.account) for acc in existing}
|
|
375
|
+
|
|
376
|
+
with open(input_path, 'r', encoding='utf-8') as f:
|
|
377
|
+
data = json.load(f)
|
|
378
|
+
|
|
379
|
+
# 校验版本号
|
|
380
|
+
version = data.get('version', 0)
|
|
381
|
+
if version < 1:
|
|
382
|
+
raise ValueError("不支持的备份文件格式")
|
|
383
|
+
|
|
384
|
+
to_import = []
|
|
385
|
+
to_skip = []
|
|
386
|
+
damaged = []
|
|
387
|
+
|
|
388
|
+
for acc in data.get('accounts', []):
|
|
389
|
+
# 校验必要字段
|
|
390
|
+
if 'issuer' not in acc or 'account' not in acc or 'secret_encrypted' not in acc:
|
|
391
|
+
raise ValueError("备份文件中存在字段不完整的账号数据")
|
|
392
|
+
|
|
393
|
+
# 校验该条目能否解密(无法解密的归入 damaged,不会真正导入)
|
|
394
|
+
try:
|
|
395
|
+
if external_key is not None:
|
|
396
|
+
self.crypto.decrypt_with_key(external_key, acc['secret_encrypted'])
|
|
397
|
+
else:
|
|
398
|
+
self.crypto.decrypt(acc['secret_encrypted'])
|
|
399
|
+
except ValueError:
|
|
400
|
+
damaged.append(acc)
|
|
401
|
+
continue
|
|
402
|
+
|
|
403
|
+
key = (acc['issuer'], acc['account'])
|
|
404
|
+
if key in existing_keys:
|
|
405
|
+
to_skip.append(acc)
|
|
406
|
+
else:
|
|
407
|
+
to_import.append(acc)
|
|
408
|
+
|
|
409
|
+
return {
|
|
410
|
+
'total': len(data.get('accounts', [])),
|
|
411
|
+
'to_import': to_import,
|
|
412
|
+
'to_skip': to_skip,
|
|
413
|
+
'damaged': damaged
|
|
414
|
+
}
|
mini2fa/totp.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TOTP (Time-based One-Time Password) 算法实现
|
|
3
|
+
|
|
4
|
+
基于 RFC 6238 标准
|
|
5
|
+
"""
|
|
6
|
+
import hmac
|
|
7
|
+
import hashlib
|
|
8
|
+
import struct
|
|
9
|
+
import time
|
|
10
|
+
import base64
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def generate_totp(
|
|
14
|
+
secret: str,
|
|
15
|
+
algorithm: str = 'SHA1',
|
|
16
|
+
digits: int = 6,
|
|
17
|
+
period: int = 30
|
|
18
|
+
) -> str:
|
|
19
|
+
"""
|
|
20
|
+
生成 TOTP 验证码
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
secret: Base32 编码的密钥
|
|
24
|
+
algorithm: 哈希算法 (SHA1/SHA256/SHA512)
|
|
25
|
+
digits: 验证码位数
|
|
26
|
+
period: 时间步长(秒)
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
验证码字符串(如 '123456')
|
|
30
|
+
"""
|
|
31
|
+
# Base32 解码
|
|
32
|
+
key = base64.b32decode(secret, casefold=True)
|
|
33
|
+
|
|
34
|
+
# 计算时间步数
|
|
35
|
+
counter = int(time.time()) // period
|
|
36
|
+
|
|
37
|
+
# 打包为 8 字节大端序
|
|
38
|
+
msg = struct.pack('>Q', counter)
|
|
39
|
+
|
|
40
|
+
# 选择哈希算法
|
|
41
|
+
hash_func = {
|
|
42
|
+
'SHA1': hashlib.sha1,
|
|
43
|
+
'SHA256': hashlib.sha256,
|
|
44
|
+
'SHA512': hashlib.sha512
|
|
45
|
+
}[algorithm]
|
|
46
|
+
|
|
47
|
+
# HMAC 计算
|
|
48
|
+
hmac_result = hmac.new(key, msg, hash_func).digest()
|
|
49
|
+
|
|
50
|
+
# 动态截断
|
|
51
|
+
offset = hmac_result[-1] & 0x0F
|
|
52
|
+
truncated = struct.unpack('>I', hmac_result[offset:offset+4])[0]
|
|
53
|
+
truncated &= 0x7FFFFFFF
|
|
54
|
+
|
|
55
|
+
# 取模得到验证码
|
|
56
|
+
code = truncated % (10 ** digits)
|
|
57
|
+
|
|
58
|
+
# 左侧补零
|
|
59
|
+
return str(code).zfill(digits)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_remaining_seconds(period: int = 30) -> int:
|
|
63
|
+
"""
|
|
64
|
+
获取当前周期剩余秒数
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
period: 时间步长(秒)
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
剩余秒数(1-30)
|
|
71
|
+
"""
|
|
72
|
+
return period - int(time.time()) % period
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def verify_totp(
|
|
76
|
+
code: str,
|
|
77
|
+
secret: str,
|
|
78
|
+
algorithm: str = 'SHA1',
|
|
79
|
+
digits: int = 6,
|
|
80
|
+
period: int = 30,
|
|
81
|
+
window: int = 1
|
|
82
|
+
) -> bool:
|
|
83
|
+
"""
|
|
84
|
+
验证 TOTP 验证码
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
code: 用户输入的验证码
|
|
88
|
+
secret: Base32 编码的密钥
|
|
89
|
+
algorithm: 哈希算法
|
|
90
|
+
digits: 验证码位数
|
|
91
|
+
period: 时间步长
|
|
92
|
+
window: 验证窗口(允许前/后 N 个周期)
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
验证是否成功
|
|
96
|
+
"""
|
|
97
|
+
current_counter = int(time.time()) // period
|
|
98
|
+
key = base64.b32decode(secret, casefold=True)
|
|
99
|
+
hash_func = {
|
|
100
|
+
'SHA1': hashlib.sha1,
|
|
101
|
+
'SHA256': hashlib.sha256,
|
|
102
|
+
'SHA512': hashlib.sha512
|
|
103
|
+
}[algorithm]
|
|
104
|
+
|
|
105
|
+
for offset in range(-window, window + 1):
|
|
106
|
+
counter = current_counter + offset
|
|
107
|
+
msg = struct.pack('>Q', counter)
|
|
108
|
+
|
|
109
|
+
hmac_result = hmac.new(key, msg, hash_func).digest()
|
|
110
|
+
|
|
111
|
+
offset_byte = hmac_result[-1] & 0x0F
|
|
112
|
+
truncated = struct.unpack('>I', hmac_result[offset_byte:offset_byte+4])[0]
|
|
113
|
+
truncated &= 0x7FFFFFFF
|
|
114
|
+
expected = str(truncated % (10 ** digits)).zfill(digits)
|
|
115
|
+
|
|
116
|
+
if code == expected:
|
|
117
|
+
return True
|
|
118
|
+
|
|
119
|
+
return False
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mini2fa
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 安全的本地 TOTP 双因素认证管理工具
|
|
5
|
+
License-Expression: GPL-3.0-or-later
|
|
6
|
+
Project-URL: Homepage, https://github.com/Duroxi/Mini2FA
|
|
7
|
+
Project-URL: Repository, https://github.com/Duroxi/Mini2FA
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: pyzbar>=0.1.9
|
|
12
|
+
Requires-Dist: Pillow>=10.0.0
|
|
13
|
+
Requires-Dist: cryptography>=41.0.0
|
|
14
|
+
Requires-Dist: wcwidth>=0.2.0
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest; extra == "dev"
|
|
17
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
18
|
+
Requires-Dist: qrcode[pil]; extra == "dev"
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# Mini2FA 🔑
|
|
22
|
+
|
|
23
|
+
安全的本地 TOTP 双因素认证管理工具 — Python 包。
|
|
24
|
+
|
|
25
|
+
## 安装
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install mini2fa
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## 使用
|
|
32
|
+
|
|
33
|
+
### 命令行
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
mini2fa
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### 作为库
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from mini2fa import generate_totp, verify_totp
|
|
43
|
+
|
|
44
|
+
code = generate_totp('JBSWY3DPEHPK3PXP')
|
|
45
|
+
print(code) # 123456
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## 功能
|
|
49
|
+
|
|
50
|
+
- 📱 从图片扫码添加 2FA 账号(仅支持 TOTP)
|
|
51
|
+
- 🔑 生成符合 RFC 6238 的 TOTP 验证码
|
|
52
|
+
- 🔐 AES-256-GCM 加密存储(PBKDF2 密钥派生)
|
|
53
|
+
- 📂 分类分组显示 + 关键字搜索
|
|
54
|
+
- 💾 JSON 导入/导出备份(内嵌主密钥,支持跨机器迁移)
|
|
55
|
+
- 🔒 修改主密码、密保提示
|
|
56
|
+
- 🖥️ 跨平台(Windows/Linux/macOS)
|
|
57
|
+
|
|
58
|
+
## 项目
|
|
59
|
+
|
|
60
|
+
- GitHub: https://github.com/Duroxi/Mini2FA
|
|
61
|
+
- License: MIT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
mini2fa/__init__.py,sha256=tTaE4A9dYtiqxeEh-PRXU5jNRKbbG37KqenD2Bh3ziw,388
|
|
2
|
+
mini2fa/__main__.py,sha256=XiH2CDCAVtTjBxbnGGkT8kWQHMAdGpAxVHTdKtB2P0U,61
|
|
3
|
+
mini2fa/_cli.py,sha256=LXtg0zkx1gHLRtkXiTT5TzxnrT-WwpCaHzon_eG_89c,27744
|
|
4
|
+
mini2fa/config.py,sha256=l56s4BQ-4b8nBvZhqXNTg37N5n6__qFQOGRjNFn65Ok,1071
|
|
5
|
+
mini2fa/crypto.py,sha256=1IwUlrblcGYHnrk9ovTInQ9OBo4VY_TVsQI2F-psg2E,10685
|
|
6
|
+
mini2fa/models.py,sha256=oUKQjLj3-u8DmvwqA_2QLUaRFpC4iqM2pFeD6yzwnM0,1043
|
|
7
|
+
mini2fa/scanner.py,sha256=2YmOrAZLXw_7Mifsx3Wynq0xifRQxc_ZcLL1O-nGuKc,5277
|
|
8
|
+
mini2fa/storage.py,sha256=juBYBRVcVwMoH-3fBfZ6D2ue7rYVMCwCOssdsIuXnrc,14700
|
|
9
|
+
mini2fa/totp.py,sha256=jmUql05IqbziWtBrNDtHxF7hENYGsEUS9l-_j0gsbsE,2705
|
|
10
|
+
mini2fa-0.1.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
11
|
+
mini2fa-0.1.0.dist-info/METADATA,sha256=XDG1J-KtVe01lCYDQfUauOZI_8agWfg8-j5i8rECwwo,1467
|
|
12
|
+
mini2fa-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
13
|
+
mini2fa-0.1.0.dist-info/entry_points.txt,sha256=AE6A8UiTiFfNfiR6Sy7qpYAM_ECHxPSpEOtKo2QYADw,46
|
|
14
|
+
mini2fa-0.1.0.dist-info/top_level.txt,sha256=SYmyRSkg9CICPgiarVZblgiknAwXztTA6UlbOl0NFTY,8
|
|
15
|
+
mini2fa-0.1.0.dist-info/RECORD,,
|