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/_cli.py ADDED
@@ -0,0 +1,779 @@
1
+ """
2
+ Mini2FA 脚本版 - 主程序
3
+
4
+ 安全的本地 TOTP 验证码管理工具
5
+ """
6
+ import os
7
+ import sys
8
+ import getpass
9
+ from pathlib import Path
10
+ from datetime import datetime
11
+ import json
12
+ from wcwidth import wcswidth
13
+
14
+ from .scanner import scan_qrcode, UnsupportedOTPTypeError
15
+ from .crypto import CryptoManager
16
+ from .storage import StorageManager, StorageCorruptedError
17
+ from .totp import generate_totp, get_remaining_seconds
18
+ from .config import (
19
+ init_data_dir, get_data_dir, get_db_path,
20
+ get_key_path, get_backup_dir
21
+ )
22
+
23
+ PASSWORD_REQUIREMENTS = "至少 6 位,且包含大写字母、小写字母和数字"
24
+ MAX_PASSWORD_ATTEMPTS = 5 # 首次设置主密码的重试上限
25
+ MAX_NOTES_LENGTH = 20 # 备注输入长度上限
26
+
27
+
28
+ def _validate_password_strength(pwd: str) -> tuple:
29
+ """验证主密码强度
30
+
31
+ Returns:
32
+ (True, '') 或 (False, 错误描述)
33
+ """
34
+ if len(pwd) < 6:
35
+ return False, "密码长度至少 6 位!"
36
+ if not any(c.isupper() for c in pwd):
37
+ return False, "密码必须包含至少一个大写字母!"
38
+ if not any(c.islower() for c in pwd):
39
+ return False, "密码必须包含至少一个小写字母!"
40
+ if not any(c.isdigit() for c in pwd):
41
+ return False, "密码必须包含至少一个数字!"
42
+ return True, ''
43
+
44
+
45
+ def display_account_with_code(account, secret: str, code: str = None):
46
+ """
47
+ 显示账号信息和验证码
48
+
49
+ 框宽按内容自适应(不截断任何信息),右边缘始终对齐。
50
+
51
+ Args:
52
+ account: Account 对象
53
+ secret: 明文密钥
54
+ code: 可选的验证码(如果已计算,避免重复计算)
55
+ """
56
+ code = code or generate_totp(secret, account.algorithm, account.digits, account.period)
57
+ remaining = get_remaining_seconds(account.period)
58
+
59
+ # 进度条
60
+ period = account.period
61
+ progress = '█' * (period - remaining) + '░' * remaining
62
+
63
+ # 收集所有内容行(完整显示,不截断)
64
+ lines = [
65
+ f'│ {account.issuer} {account.account}',
66
+ f'│ 验证码: {code[:3]} {code[3:]}',
67
+ f'│ 有效期: [{progress}] {remaining:2d}s',
68
+ ]
69
+ if account.notes:
70
+ lines.insert(2, f'│ 备注: {account.notes}')
71
+ lines.append('│ 按 Enter 返回列表')
72
+
73
+ # 框宽 = 内容最大显示宽度 + 左右边距
74
+ content_w = max(wcswidth(line) for line in lines)
75
+ box_w = content_w + 4
76
+
77
+ top = '┌' + '─' * (box_w - 2) + '┐'
78
+ bottom = '└' + '─' * (box_w - 2) + '┘'
79
+ blank = '│' + ' ' * (box_w - 2) + '│'
80
+
81
+ # 内容行交错空白行,右边缘对齐
82
+ body = []
83
+ for line in lines:
84
+ body.append(blank)
85
+ body.append(line + ' ' * (box_w - wcswidth(line) - 1) + '│')
86
+
87
+ print(top)
88
+ print('\n'.join(body))
89
+ print(bottom)
90
+
91
+
92
+ def display_banner():
93
+ """显示程序横幅"""
94
+ from . import __version__
95
+ print(f"""
96
+ ╔═══════════════════════════════════════════════════════════╗
97
+ ║ Mini2FA v{__version__} ║
98
+ ║ 安全的本地 TOTP 验证码管理工具 ║
99
+ ║ ║
100
+ ║ 📱 扫码添加 | 🔑 验证码生成 | 🔐 AES-256 加密 ║
101
+ ╚═══════════════════════════════════════════════════════════╝
102
+ """)
103
+
104
+
105
+ def display_menu():
106
+ """显示主菜单"""
107
+ print("""
108
+ ╔═══════════════════════════════════════════════════════════╗
109
+ ║ 主 菜 单 ║
110
+ ╠═══════════════════════════════════════════════════════════╣
111
+ ║ 1. 📱 添加账号(扫码图片) ║
112
+ ║ 2. 🔑 查看验证码 ║
113
+ ║ 3. ✏️ 编辑账号 ║
114
+ ║ 4. 🗑️ 删除账号 ║
115
+ ║ 5. 💾 导出备份 ║
116
+ ║ 6. 📥 导入备份 ║
117
+ ║ 7. 🔒 修改主密码 ║
118
+ ║ 0. 🚪 退出 ║
119
+ ╚═══════════════════════════════════════════════════════════╝
120
+ """)
121
+
122
+
123
+ def handle_add_account(storage: StorageManager):
124
+ """处理添加账号"""
125
+ print("\n📱 添加新账号")
126
+ print("-" * 40)
127
+
128
+ image_path = input("请输入图片文件路径: ").strip()
129
+
130
+ # 移除路径两端的引号(Windows 拖拽文件时会带引号)
131
+ if image_path.startswith('"') and image_path.endswith('"'):
132
+ image_path = image_path[1:-1]
133
+ elif image_path.startswith("'") and image_path.endswith("'"):
134
+ image_path = image_path[1:-1]
135
+
136
+ if not os.path.exists(image_path):
137
+ print("✗ 文件不存在!请检查路径。")
138
+ return
139
+
140
+ print("正在扫描二维码...")
141
+
142
+ try:
143
+ result = scan_qrcode(image_path)
144
+ except UnsupportedOTPTypeError as e:
145
+ print(f"✗ 该二维码是 {e.otp_type.upper()} 协议,本工具仅支持 TOTP。")
146
+ return
147
+ except Exception as e:
148
+ print(f"✗ 扫描失败: {e}")
149
+ return
150
+
151
+ if not result:
152
+ print("✗ 未识别到有效的二维码!")
153
+ print(" 提示:请确保图片包含清晰的 OTP 二维码。")
154
+ return
155
+
156
+ # 查重:已存在则直接提示,不进入填字段流程
157
+ existing = storage.find_by_identity(result.issuer, result.account)
158
+ if existing is not None:
159
+ print(f"✗ 账号已存在: {result.issuer} - {result.account}")
160
+ print(" 该账号已在库中(分类: {0}, ID: {1})。".format(
161
+ existing.category, existing.id))
162
+ return
163
+
164
+ # 显示识别结果
165
+ print(f"""
166
+ ✓ 识别到账号信息:
167
+ ─────────────────────────────────────
168
+ 服务提供商: {result.issuer}
169
+ 账号: {result.account}
170
+ 算法: {result.algorithm}
171
+ 位数: {result.digits}
172
+ 周期: {result.period}秒
173
+ 类型: {result.otp_type}
174
+ """)
175
+
176
+ # 先收集分类和备注
177
+ category = input("分类 (直接回车使用默认): ").strip()[:50] or 'default'
178
+ notes = input(f"备注 (直接回车跳过, 最多 {MAX_NOTES_LENGTH} 字): ").strip()[:MAX_NOTES_LENGTH]
179
+
180
+ # 展示完整摘要,一次性确认
181
+ print(f"""
182
+ 确认添加以下账号?
183
+ ─────────────────────────────────────
184
+ 服务提供商: {result.issuer}
185
+ 账号: {result.account}
186
+ 分类: {category}
187
+ 备注: {notes or '(空)'}
188
+ ─────────────────────────────────────
189
+ """)
190
+
191
+ confirm = input("确认添加?[Y/n]: ").strip().lower()
192
+ if confirm not in ('', 'y', 'yes'):
193
+ print("已取消。")
194
+ return
195
+
196
+ try:
197
+ account_id = storage.add_account(
198
+ issuer=result.issuer,
199
+ account=result.account,
200
+ secret=result.secret,
201
+ algorithm=result.algorithm,
202
+ digits=result.digits,
203
+ period=result.period,
204
+ category=category,
205
+ notes=notes
206
+ )
207
+ print(f"✓ 账号添加成功!(ID: {account_id})")
208
+ except ValueError as e:
209
+ print(f"✗ {e}")
210
+
211
+
212
+ def list_accounts_grouped(accounts) -> list:
213
+ """
214
+ 按分类分组打印账号列表(default 排最前,其余按名称排序)
215
+ 编号全局连续,返回【按显示顺序重排】的账号列表。
216
+
217
+ Args:
218
+ accounts: Account 对象列表
219
+
220
+ Returns:
221
+ 按显示编号顺序排列的账号列表(调用方必须用此返回值选号,
222
+ 即 listed[idx-1],不能用原 accounts[idx-1])
223
+ """
224
+ groups = {}
225
+ for acc in accounts:
226
+ groups.setdefault(acc.category, []).append(acc)
227
+
228
+ # default 排最前,其余按名称排序
229
+ categories = sorted(groups.keys(), key=lambda c: (c != 'default', c))
230
+
231
+ ordered = []
232
+ idx = 0
233
+ for category in categories:
234
+ print(f"\n [{category}]")
235
+ for acc in groups[category]:
236
+ idx += 1
237
+ print(f" {idx:2d}. {acc.issuer} - {acc.account}")
238
+ ordered.append(acc)
239
+ print()
240
+ return ordered
241
+
242
+
243
+ def handle_view_code(storage: StorageManager):
244
+ """统一查看验证码(列表 + 详情 + 复制 + 搜索)"""
245
+ all_accounts = storage.get_all_accounts()
246
+ if not all_accounts:
247
+ print("\n暂无账号,请先添加。")
248
+ return
249
+
250
+ accounts = all_accounts # 当前显示的列表(全量或搜索结果)
251
+ searching = False
252
+
253
+ while True:
254
+ # 显示账号列表(按分类分组),返回按显示顺序排列的列表
255
+ print(f"\n共 {len(accounts)} 个账号:")
256
+ accounts = list_accounts_grouped(accounts)
257
+ print("─" * 50)
258
+ if searching:
259
+ print(" 输入编号查看详情 | 0 返回全量列表 | 输入文字搜索")
260
+ else:
261
+ print(" 输入编号查看详情 | 0 返回主菜单 | 输入文字搜索")
262
+
263
+ raw = input("\n>>> ").strip()
264
+
265
+ if raw == '0':
266
+ if searching:
267
+ searching = False
268
+ accounts = all_accounts # 返回全量列表
269
+ continue
270
+ break # 返回主菜单
271
+
272
+ if raw == '':
273
+ continue # 空输入不操作
274
+
275
+ if raw.isdigit():
276
+ idx = int(raw)
277
+ if idx < 1 or idx > len(accounts):
278
+ print("✗ 无效的选择!")
279
+ continue
280
+ account = accounts[idx - 1]
281
+ else:
282
+ # 非数字 → 搜索 issuer/account/notes
283
+ keyword = raw.lower()
284
+ accounts = [
285
+ a for a in all_accounts
286
+ if keyword in a.issuer.lower()
287
+ or keyword in a.account.lower()
288
+ or keyword in (a.notes or '').lower()
289
+ ]
290
+ searching = True
291
+ if not accounts:
292
+ print("✗ 未找到匹配账号,已返回全量列表。")
293
+ accounts = all_accounts
294
+ searching = False
295
+ continue
296
+ try:
297
+ secret = storage.get_secret(account.id)
298
+ except Exception as e:
299
+ print(f"✗ 获取密钥失败: {e}")
300
+ continue
301
+
302
+ # 进入详情页,等待用户查看后返回列表
303
+ code = generate_totp(secret, account.algorithm, account.digits, account.period)
304
+ display_account_with_code(account, secret, code)
305
+ input(">>> 按 Enter 返回列表 ")
306
+
307
+
308
+ def handle_edit_account(storage: StorageManager):
309
+ """处理编辑账号"""
310
+ accounts = storage.get_all_accounts()
311
+ if not accounts:
312
+ print("\n暂无账号。")
313
+ return
314
+
315
+ print("\n选择要编辑的账号(按分类分组):")
316
+ accounts = list_accounts_grouped(accounts)
317
+
318
+ try:
319
+ idx = input("输入账号编号 (0 取消): ").strip()
320
+ if idx == '0':
321
+ return
322
+
323
+ idx = int(idx)
324
+ if idx < 1 or idx > len(accounts):
325
+ print("✗ 无效的选择!")
326
+ return
327
+ except ValueError:
328
+ print("✗ 请输入数字!")
329
+ return
330
+
331
+ account = accounts[idx - 1]
332
+
333
+ print(f"\n当前信息:")
334
+ print(f" 服务提供商: {account.issuer}")
335
+ print(f" 账号: {account.account}")
336
+ print(f" 分类: {account.category}")
337
+ print(f" 备注: {account.notes or '(空)'}")
338
+
339
+ print("\n输入新值(直接回车保持不变,输入 . 清空):")
340
+
341
+ category = input(f" 分类 [{account.category}]: ").strip()
342
+ notes = input(f" 备注 [{account.notes or ''}]: ").strip()
343
+
344
+ updates = {}
345
+ changes = []
346
+ if category == '.':
347
+ # 清空分类 → 回 default
348
+ if account.category != 'default':
349
+ updates['category'] = 'default'
350
+ changes.append(f"分类: {account.category} → default")
351
+ elif category and category != account.category:
352
+ updates['category'] = category
353
+ changes.append(f"分类: {account.category} → {category}")
354
+
355
+ if notes == '.':
356
+ # 清空备注
357
+ if account.notes:
358
+ updates['notes'] = ''
359
+ changes.append(f"备注: {account.notes or '(空)'} → (空)")
360
+ elif notes and notes != account.notes:
361
+ updates['notes'] = notes
362
+ changes.append(f"备注: {account.notes or '(空)'} → {notes}")
363
+
364
+ if updates:
365
+ if storage.update_account(account.id, **updates):
366
+ print("✓ 更新成功!")
367
+ for c in changes:
368
+ print(f" {c}")
369
+ else:
370
+ print("✗ 更新失败!")
371
+ else:
372
+ print("未做任何修改。")
373
+
374
+
375
+ def handle_delete_account(storage: StorageManager):
376
+ """处理删除账号"""
377
+ accounts = storage.get_all_accounts()
378
+ if not accounts:
379
+ print("\n暂无账号。")
380
+ return
381
+
382
+ print("\n选择要删除的账号(按分类分组):")
383
+ accounts = list_accounts_grouped(accounts)
384
+
385
+ try:
386
+ idx = input("输入账号编号 (0 取消): ").strip()
387
+ if idx == '0':
388
+ return
389
+
390
+ idx = int(idx)
391
+ if idx < 1 or idx > len(accounts):
392
+ print("✗ 无效的选择!")
393
+ return
394
+ except ValueError:
395
+ print("✗ 请输入数字!")
396
+ return
397
+
398
+ account = accounts[idx - 1]
399
+ confirm_phrase = f"YES, delete {account.issuer} - {account.account}"
400
+
401
+ print(f"\n确定要删除以下账号吗?")
402
+ print(f" 服务提供商: {account.issuer}")
403
+ print(f" 账号: {account.account}")
404
+ print("\n⚠️ 此操作不可恢复!")
405
+ print(f"请输入以下内容确认删除:")
406
+ print(f" {confirm_phrase}")
407
+
408
+ confirm = input("\n>>> ").strip()
409
+ if confirm != confirm_phrase:
410
+ print("已取消。")
411
+ return
412
+
413
+ if storage.delete_account(account.id):
414
+ print(f"✓ 已删除: {account.issuer} - {account.account}")
415
+ else:
416
+ print("✗ 删除失败!")
417
+
418
+
419
+ def handle_export(storage: StorageManager):
420
+ """处理导出备份"""
421
+ backup_dir = get_backup_dir()
422
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
423
+ default_path = backup_dir / f'mini2fa_backup_{timestamp}.json'
424
+
425
+ user_path = input(f"\n导出路径(直接回车使用默认):\n [{default_path}]: ").strip()
426
+
427
+ # 移除路径两端的引号
428
+ if user_path.startswith('"') and user_path.endswith('"'):
429
+ user_path = user_path[1:-1]
430
+ elif user_path.startswith("'") and user_path.endswith("'"):
431
+ user_path = user_path[1:-1]
432
+
433
+ if not user_path:
434
+ backup_path = default_path
435
+ elif os.path.isdir(user_path):
436
+ # 用户指定了已有目录,使用默认文件名
437
+ backup_path = Path(user_path) / default_path.name
438
+ else:
439
+ backup_path = Path(user_path)
440
+
441
+ # 检查文件名是否包含非法字符
442
+ invalid_chars = set('*?<>|"')
443
+ if user_path and any(c in os.path.basename(backup_path) for c in invalid_chars):
444
+ print("✗ 文件名包含非法字符(* ? < > | \"),请重新输入。")
445
+ return
446
+
447
+ # 目标文件已存在时确认覆盖
448
+ if backup_path.exists():
449
+ confirm = input(f"\n文件已存在:{backup_path}\n覆盖?[y/N]: ").strip().lower()
450
+ if confirm not in ('y', 'yes'):
451
+ print("已取消。")
452
+ return
453
+
454
+ # 确保父目录存在
455
+ backup_path.parent.mkdir(parents=True, exist_ok=True)
456
+
457
+ try:
458
+ count = storage.export_json(str(backup_path))
459
+ print(f"""
460
+ ✓ 导出成功!
461
+
462
+ 备份文件: {backup_path}
463
+ 账号数量: {count}
464
+
465
+ 注意:备份文件仍需主密码才能解密。
466
+ """)
467
+ except Exception as e:
468
+ print(f"✗ 导出失败: {e}")
469
+
470
+
471
+ def handle_change_password(crypto: CryptoManager):
472
+ """处理修改主密码(验证旧密码 → 新密码 → 重设提示)"""
473
+ print("\n🔒 修改主密码")
474
+
475
+ # 1. 验证旧密码(最多 3 次,验证失败不污染当前会话)
476
+ for attempt in range(3):
477
+ old_pwd = getpass.getpass("输入当前主密码: ")
478
+ if crypto.verify_password(old_pwd):
479
+ break
480
+ print("✗ 旧密码错误!")
481
+ if attempt < 2:
482
+ print(f" 剩余 {2 - attempt} 次尝试。\n")
483
+ else:
484
+ print("✗ 验证失败次数过多,已返回主菜单。")
485
+ return
486
+
487
+ print("✓ 旧密码验证通过!\n")
488
+
489
+ # 2. 输入新密码(复用密码强度校验)
490
+ for attempt in range(MAX_PASSWORD_ATTEMPTS):
491
+ new_pwd = getpass.getpass("输入新主密码: ")
492
+ valid, msg = _validate_password_strength(new_pwd)
493
+ if not valid:
494
+ print(f"✗ {msg}")
495
+ remaining = MAX_PASSWORD_ATTEMPTS - attempt - 1
496
+ if remaining > 0:
497
+ print(f" 还需满足要求,剩余 {remaining} 次尝试。\n")
498
+ continue
499
+
500
+ new_confirm = getpass.getpass("确认新主密码: ")
501
+ if new_pwd == new_confirm:
502
+ break
503
+ print("✗ 两次密码不一致!")
504
+ remaining = MAX_PASSWORD_ATTEMPTS - attempt - 1
505
+ if remaining > 0:
506
+ print(f" 请重新输入,剩余 {remaining} 次尝试。\n")
507
+ else:
508
+ print(f"\n✗ 新密码设置失败:已尝试 {MAX_PASSWORD_ATTEMPTS} 次。")
509
+ print(" 请重新运行 mini2fa 重试。")
510
+ return
511
+
512
+ # 3. 重设密保提示(回车保留原提示)
513
+ hint = input("设置新的密码提示(直接回车保留原提示): ").strip()
514
+ if not hint:
515
+ hint = crypto.get_hint()
516
+
517
+ # 4. 执行修改
518
+ if crypto.change_password(old_pwd, new_pwd, hint):
519
+ print("✓ 主密码已修改成功!请记住新密码。")
520
+ else:
521
+ print("✗ 修改失败!")
522
+
523
+
524
+ def _print_import_result(result: dict):
525
+ """打印导入结果统计"""
526
+ parts = [f"新增 {result['imported']} 个"]
527
+ if result['updated']:
528
+ parts.append(f"更新 {result['updated']} 个")
529
+ if result['conflict_skipped']:
530
+ parts.append(f"保留当前 {result['conflict_skipped']} 个")
531
+ if result['damaged_skipped']:
532
+ parts.append(f"损坏跳过 {result['damaged_skipped']} 个")
533
+ print("✓ 导入完成:" + ",".join(parts))
534
+
535
+
536
+ def handle_import(storage: StorageManager):
537
+ """处理导入备份"""
538
+ input_path = input("\n请输入备份文件路径: ").strip()
539
+
540
+ if not input_path:
541
+ print("✗ 路径不能为空!")
542
+ return
543
+
544
+ # 移除路径两端的引号
545
+ if input_path.startswith('"') and input_path.endswith('"'):
546
+ input_path = input_path[1:-1]
547
+ elif input_path.startswith("'") and input_path.endswith("'"):
548
+ input_path = input_path[1:-1]
549
+
550
+ # 检查文件名是否包含非法字符
551
+ invalid_chars = set('*?<>|"')
552
+ if any(c in os.path.basename(input_path) for c in invalid_chars):
553
+ print("✗ 文件名包含非法字符(* ? < > | \"),请重新输入。")
554
+ return
555
+
556
+ if not os.path.exists(input_path):
557
+ print("✗ 文件不存在!")
558
+ return
559
+
560
+ try:
561
+ # 判断备份是否内嵌主密钥(跨机备份)
562
+ with open(input_path, 'r', encoding='utf-8') as f:
563
+ backup_data = json.load(f)
564
+ embedded_key = backup_data.get('master_key')
565
+
566
+ # 跨机备份:验证备份密码并解出备份主密钥
567
+ external_key = None
568
+ if embedded_key is not None:
569
+ print("\n检测到备份来自其他安装(内嵌主密钥)。")
570
+ bk_pwd = getpass.getpass("请输入备份对应的主密码: ")
571
+ try:
572
+ external_key = storage.crypto.load_external_key(embedded_key, bk_pwd)
573
+ except ValueError as e:
574
+ print(f"✗ {e}")
575
+ return
576
+ if external_key is None:
577
+ print("✗ 备份主密码错误!")
578
+ return
579
+ print("✓ 备份主密码验证通过!")
580
+
581
+ # 预览(同机/跨机都用 external_key 做解密校验)
582
+ preview = storage.preview_import(input_path, external_key=external_key)
583
+
584
+ if preview['total'] == 0:
585
+ print("✗ 备份文件中没有账号数据。")
586
+ return
587
+
588
+ print(f"\n📋 备份文件预览:")
589
+ print("─" * 60)
590
+
591
+ if preview['to_import']:
592
+ print(f" 将导入({len(preview['to_import'])} 个):")
593
+ for acc in preview['to_import']:
594
+ print(f" ✓ {acc['issuer']} - {acc['account']}")
595
+
596
+ if preview['to_skip']:
597
+ print(f" 已存在({len(preview['to_skip'])} 个):")
598
+ for acc in preview['to_skip']:
599
+ print(f" - {acc['issuer']} - {acc['account']}")
600
+
601
+ if preview['damaged']:
602
+ print(f" 损坏无法导入({len(preview['damaged'])} 个):")
603
+ for acc in preview['damaged']:
604
+ print(f" ✗ {acc['issuer']} - {acc['account']}")
605
+
606
+ print("─" * 60)
607
+ print(f" 共 {preview['total']} 个,将导入 {len(preview['to_import'])} 个")
608
+
609
+ if not preview['to_import']:
610
+ print(" 没有新账号需要导入。")
611
+ return
612
+
613
+ confirm = input("\n确认导入?[Y/n]: ").strip().lower()
614
+ if confirm not in ('', 'y', 'yes'):
615
+ print("已取消。")
616
+ return
617
+
618
+ # 冲突账号(库里已存在)逐账号询问:用当前的还是备份的
619
+ decisions = {}
620
+ if preview['to_skip']:
621
+ print(f"\n以下 {len(preview['to_skip'])} 个账号在库中已存在,请选择使用哪个版本:")
622
+ for acc in preview['to_skip']:
623
+ print(f" {acc['issuer']} - {acc['account']}")
624
+ choice = input(" 用当前的还是备份的?[当前/备份,默认当前]: ").strip().lower()
625
+ if choice in ('备份', 'backup', 'b'):
626
+ decisions[(acc['issuer'], acc['account'])] = 'backup'
627
+ # 其余(含回车)保留当前
628
+
629
+ # 按本机状态分流导入
630
+ local_has_accounts = bool(storage.get_all_accounts())
631
+
632
+ if external_key is not None and not local_has_accounts:
633
+ # 情况 A:本机空库。先采用备份主密钥,再原样入库
634
+ # (此时 self.key 已是备份 key,能解密备份数据)
635
+ storage.crypto.adopt_external_key(external_key, embedded_key)
636
+ result = storage.import_json(input_path, decisions=decisions)
637
+ _print_import_result(result)
638
+ print("✓ 已采用备份的主密钥,请用备份对应的主密码登录本工具。")
639
+ elif external_key is not None:
640
+ # 情况 B:本机已有账号,用备份密钥解密重加密,本机密码不变
641
+ result = storage.import_json(input_path, external_key=external_key, decisions=decisions)
642
+ _print_import_result(result)
643
+ else:
644
+ # 同机备份:现有逻辑
645
+ result = storage.import_json(input_path, decisions=decisions)
646
+ _print_import_result(result)
647
+ except json.JSONDecodeError:
648
+ print("✗ 导入失败:备份文件格式错误,不是有效的 JSON 文件。")
649
+ except Exception as e:
650
+ err_msg = str(e)
651
+ if 'InvalidTag' in err_msg or 'decrypt' in err_msg.lower():
652
+ print("✗ 导入失败:无法解密备份文件,可能是主密码不匹配或文件已损坏。")
653
+ else:
654
+ print(f"✗ 导入失败: {e}")
655
+
656
+
657
+ def main():
658
+ """主程序"""
659
+ display_banner()
660
+
661
+ # 初始化数据目录
662
+ init_data_dir()
663
+
664
+ # 获取配置路径
665
+ db_path = str(get_db_path())
666
+ key_path = str(get_key_path())
667
+
668
+ # 初始化加密管理器
669
+ crypto = CryptoManager(key_path)
670
+
671
+ # 主密码验证
672
+ if not os.path.exists(key_path):
673
+ print("首次使用,请设置主密码:")
674
+ print("⚠️ 请牢记此密码,丢失将无法恢复数据!\n")
675
+ print(f"密码要求:{PASSWORD_REQUIREMENTS}\n")
676
+
677
+ pwd = None
678
+ for attempt in range(MAX_PASSWORD_ATTEMPTS):
679
+ pwd = getpass.getpass("输入主密码: ")
680
+ valid, msg = _validate_password_strength(pwd)
681
+ if not valid:
682
+ print(f"✗ {msg}")
683
+ remaining = MAX_PASSWORD_ATTEMPTS - attempt - 1
684
+ if remaining > 0:
685
+ print(f" 还需满足要求,剩余 {remaining} 次尝试。\n")
686
+ continue
687
+
688
+ pwd_confirm = getpass.getpass("确认主密码: ")
689
+ if pwd == pwd_confirm:
690
+ break
691
+ print("✗ 两次密码不一致!")
692
+ remaining = MAX_PASSWORD_ATTEMPTS - attempt - 1
693
+ if remaining > 0:
694
+ print(f" 请重新输入,剩余 {remaining} 次尝试。\n")
695
+ else:
696
+ print(f"\n✗ 主密码设置失败:已尝试 {MAX_PASSWORD_ATTEMPTS} 次。")
697
+ print(" 请重新运行 mini2fa 重新设置。")
698
+ sys.exit(1)
699
+
700
+ # 设置密保提示
701
+ hint = getpass.getpass("设置密码提示(用于忘记密码时提醒,可留空): ").strip()
702
+
703
+ if crypto.initialize(pwd, hint):
704
+ print("✓ 主密码已设置成功!\n")
705
+ else:
706
+ print("✗ 初始化失败!")
707
+ sys.exit(1)
708
+ else:
709
+ # 获取密保提示
710
+ hint = crypto.get_hint()
711
+
712
+ print("请输入主密码:")
713
+
714
+ for attempt in range(3):
715
+ pwd = getpass.getpass(">>> ")
716
+ if crypto.initialize(pwd):
717
+ print("✓ 密码验证成功!\n")
718
+ break
719
+ if attempt < 2:
720
+ print("✗ 密码错误")
721
+ if hint:
722
+ print(f"💡 密码提示:{hint}")
723
+ else:
724
+ print("✗ 密码错误")
725
+ sys.exit(1)
726
+
727
+ # 初始化存储管理器
728
+ storage = StorageManager(db_path, crypto)
729
+
730
+ # 主循环
731
+ while True:
732
+ display_menu()
733
+
734
+ choice = input("请选择操作 [0-7]: ").strip()
735
+
736
+ if choice == '0':
737
+ print("\n再见!🔒")
738
+ break
739
+
740
+ elif choice == '1':
741
+ handle_add_account(storage)
742
+
743
+ elif choice == '2':
744
+ handle_view_code(storage)
745
+
746
+ elif choice == '3':
747
+ handle_edit_account(storage)
748
+
749
+ elif choice == '4':
750
+ handle_delete_account(storage)
751
+
752
+ elif choice == '5':
753
+ handle_export(storage)
754
+
755
+ elif choice == '6':
756
+ handle_import(storage)
757
+
758
+ elif choice == '7':
759
+ handle_change_password(crypto)
760
+
761
+ else:
762
+ print("✗ 无效的选择,请重试!")
763
+
764
+
765
+ def _run():
766
+ """程序入口:捕获 Ctrl-C 优雅退出、数据库损坏提示"""
767
+ try:
768
+ main()
769
+ except KeyboardInterrupt:
770
+ print("\n再见!🔒")
771
+ sys.exit(0)
772
+ except StorageCorruptedError as e:
773
+ print(f"\n✗ {e}")
774
+ print(" 请检查数据文件是否完整,或从备份恢复。")
775
+ sys.exit(1)
776
+
777
+
778
+ if __name__ == '__main__':
779
+ _run()