mini2fa 0.2.1__tar.gz → 0.2.3__tar.gz

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.
Files changed (29) hide show
  1. {mini2fa-0.2.1/src/mini2fa.egg-info → mini2fa-0.2.3}/PKG-INFO +1 -1
  2. {mini2fa-0.2.1 → mini2fa-0.2.3}/pyproject.toml +1 -1
  3. {mini2fa-0.2.1 → mini2fa-0.2.3}/src/mini2fa/__init__.py +1 -1
  4. {mini2fa-0.2.1 → mini2fa-0.2.3}/src/mini2fa/_cli.py +145 -2
  5. {mini2fa-0.2.1 → mini2fa-0.2.3/src/mini2fa.egg-info}/PKG-INFO +1 -1
  6. {mini2fa-0.2.1 → mini2fa-0.2.3}/tests/test_cli.py +4 -2
  7. {mini2fa-0.2.1 → mini2fa-0.2.3}/LICENSE +0 -0
  8. {mini2fa-0.2.1 → mini2fa-0.2.3}/README.md +0 -0
  9. {mini2fa-0.2.1 → mini2fa-0.2.3}/setup.cfg +0 -0
  10. {mini2fa-0.2.1 → mini2fa-0.2.3}/src/mini2fa/__main__.py +0 -0
  11. {mini2fa-0.2.1 → mini2fa-0.2.3}/src/mini2fa/config.py +0 -0
  12. {mini2fa-0.2.1 → mini2fa-0.2.3}/src/mini2fa/crypto.py +0 -0
  13. {mini2fa-0.2.1 → mini2fa-0.2.3}/src/mini2fa/models.py +0 -0
  14. {mini2fa-0.2.1 → mini2fa-0.2.3}/src/mini2fa/scanner.py +0 -0
  15. {mini2fa-0.2.1 → mini2fa-0.2.3}/src/mini2fa/storage.py +0 -0
  16. {mini2fa-0.2.1 → mini2fa-0.2.3}/src/mini2fa/totp.py +0 -0
  17. {mini2fa-0.2.1 → mini2fa-0.2.3}/src/mini2fa/ui.py +0 -0
  18. {mini2fa-0.2.1 → mini2fa-0.2.3}/src/mini2fa.egg-info/SOURCES.txt +0 -0
  19. {mini2fa-0.2.1 → mini2fa-0.2.3}/src/mini2fa.egg-info/dependency_links.txt +0 -0
  20. {mini2fa-0.2.1 → mini2fa-0.2.3}/src/mini2fa.egg-info/entry_points.txt +0 -0
  21. {mini2fa-0.2.1 → mini2fa-0.2.3}/src/mini2fa.egg-info/requires.txt +0 -0
  22. {mini2fa-0.2.1 → mini2fa-0.2.3}/src/mini2fa.egg-info/top_level.txt +0 -0
  23. {mini2fa-0.2.1 → mini2fa-0.2.3}/tests/test_config.py +0 -0
  24. {mini2fa-0.2.1 → mini2fa-0.2.3}/tests/test_crypto.py +0 -0
  25. {mini2fa-0.2.1 → mini2fa-0.2.3}/tests/test_models.py +0 -0
  26. {mini2fa-0.2.1 → mini2fa-0.2.3}/tests/test_scanner.py +0 -0
  27. {mini2fa-0.2.1 → mini2fa-0.2.3}/tests/test_storage.py +0 -0
  28. {mini2fa-0.2.1 → mini2fa-0.2.3}/tests/test_totp.py +0 -0
  29. {mini2fa-0.2.1 → mini2fa-0.2.3}/tests/test_ui.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mini2fa
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: 安全的本地 TOTP 双因素认证管理工具
5
5
  License-Expression: GPL-3.0-or-later
6
6
  Project-URL: Homepage, https://github.com/Duroxi/Mini2FA
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mini2fa"
7
- version = "0.2.1"
7
+ version = "0.2.3"
8
8
  description = "安全的本地 TOTP 双因素认证管理工具"
9
9
  readme = "README.md"
10
10
  license = "GPL-3.0-or-later"
@@ -6,4 +6,4 @@ from .storage import StorageManager
6
6
  from .scanner import scan_qrcode, scan_qrcode_from_raw_data, parse_otp_uri, preprocess_image, UnsupportedOTPTypeError
7
7
  from .models import Account, OTPAccountInfo
8
8
 
9
- __version__ = "0.2.1"
9
+ __version__ = "0.2.3"
@@ -156,6 +156,7 @@ def handle_add_account(storage: StorageManager):
156
156
 
157
157
  if not os.path.exists(image_path):
158
158
  ui.print_line("✗ 文件不存在!请检查路径。")
159
+ ui.prompt("\n按 Enter 返回主菜单 ")
159
160
  return
160
161
 
161
162
  ui.print_line("正在扫描二维码...")
@@ -164,14 +165,17 @@ def handle_add_account(storage: StorageManager):
164
165
  result = scan_qrcode(image_path)
165
166
  except UnsupportedOTPTypeError as e:
166
167
  ui.print_line(f"✗ 该二维码是 {e.otp_type.upper()} 协议,本工具仅支持 TOTP。")
168
+ ui.prompt("\n按 Enter 返回主菜单 ")
167
169
  return
168
170
  except Exception as e:
169
171
  ui.print_line(f"✗ 扫描失败: {e}")
172
+ ui.prompt("\n按 Enter 返回主菜单 ")
170
173
  return
171
174
 
172
175
  if not result:
173
176
  ui.print_line("✗ 未识别到有效的二维码!")
174
177
  ui.print_line(" 提示:请确保图片包含清晰的 OTP 二维码。")
178
+ ui.prompt("\n按 Enter 返回主菜单 ")
175
179
  return
176
180
 
177
181
  # 查重:已存在则直接提示,不进入填字段流程
@@ -180,6 +184,7 @@ def handle_add_account(storage: StorageManager):
180
184
  ui.print_line(f"✗ 账号已存在: {result.issuer} - {result.account}")
181
185
  ui.print_line(" 该账号已在库中(分类: {0}, ID: {1})。".format(
182
186
  existing.category, existing.id))
187
+ ui.prompt("\n按 Enter 返回主菜单 ")
183
188
  return
184
189
 
185
190
  # 显示识别结果
@@ -296,6 +301,7 @@ def handle_view_code(storage: StorageManager):
296
301
  idx = int(raw)
297
302
  if idx < 1 or idx > len(accounts):
298
303
  ui.print_line("✗ 无效的选择!")
304
+ ui.prompt("\n按 Enter 继续 ")
299
305
  continue
300
306
  account = accounts[idx - 1]
301
307
  else:
@@ -310,6 +316,7 @@ def handle_view_code(storage: StorageManager):
310
316
  searching = True
311
317
  if not accounts:
312
318
  ui.print_line("✗ 未找到匹配账号,已返回全量列表。")
319
+ ui.prompt("\n按 Enter 继续 ")
313
320
  accounts = all_accounts
314
321
  searching = False
315
322
  continue
@@ -317,6 +324,7 @@ def handle_view_code(storage: StorageManager):
317
324
  secret = storage.get_secret(account.id)
318
325
  except Exception as e:
319
326
  ui.print_line(f"✗ 获取密钥失败: {e}")
327
+ ui.prompt("\n按 Enter 继续 ")
320
328
  continue
321
329
 
322
330
  # 进入详情页
@@ -353,9 +361,11 @@ def handle_edit_account(storage: StorageManager):
353
361
  idx = int(idx)
354
362
  if idx < 1 or idx > len(accounts):
355
363
  ui.print_line("✗ 无效的选择!")
364
+ ui.prompt("\n按 Enter 返回主菜单 ")
356
365
  return
357
366
  except ValueError:
358
367
  ui.print_line("✗ 请输入数字!")
368
+ ui.prompt("\n按 Enter 返回主菜单 ")
359
369
  return
360
370
 
361
371
  account = accounts[idx - 1]
@@ -421,9 +431,11 @@ def handle_delete_account(storage: StorageManager):
421
431
  idx = int(idx)
422
432
  if idx < 1 or idx > len(accounts):
423
433
  ui.print_line("✗ 无效的选择!")
434
+ ui.prompt("\n按 Enter 返回主菜单 ")
424
435
  return
425
436
  except ValueError:
426
437
  ui.print_line("✗ 请输入数字!")
438
+ ui.prompt("\n按 Enter 返回主菜单 ")
427
439
  return
428
440
 
429
441
  account = accounts[idx - 1]
@@ -474,6 +486,7 @@ def handle_export(storage: StorageManager):
474
486
  invalid_chars = set('*?<>|"')
475
487
  if user_path and any(c in os.path.basename(backup_path) for c in invalid_chars):
476
488
  ui.print_line("✗ 文件名包含非法字符(* ? < > | \"),请重新输入。")
489
+ ui.prompt("\n按 Enter 返回主菜单 ")
477
490
  return
478
491
 
479
492
  # 目标文件已存在时确认覆盖
@@ -513,6 +526,7 @@ def handle_change_password(crypto: CryptoManager):
513
526
  ui.print_line(f" 剩余 {2 - attempt} 次尝试。")
514
527
  else:
515
528
  ui.print_line("✗ 验证失败次数过多,已返回主菜单。")
529
+ ui.prompt("\n按 Enter 返回主菜单 ")
516
530
  return
517
531
 
518
532
  ui.print_line("✓ 旧密码验证通过!")
@@ -538,6 +552,7 @@ def handle_change_password(crypto: CryptoManager):
538
552
  else:
539
553
  ui.print_line(f"\n✗ 新密码设置失败:已尝试 {MAX_PASSWORD_ATTEMPTS} 次。")
540
554
  ui.print_line(" 请重新运行 mini2fa 重试。")
555
+ ui.prompt("\n按 Enter 返回主菜单 ")
541
556
  return
542
557
 
543
558
  # 3. 重设密保提示(回车保留原提示)
@@ -571,6 +586,7 @@ def handle_import(storage: StorageManager):
571
586
 
572
587
  if not input_path:
573
588
  ui.print_line("✗ 路径不能为空!")
589
+ ui.prompt("\n按 Enter 返回主菜单 ")
574
590
  return
575
591
 
576
592
  # 移除路径两端的引号
@@ -583,10 +599,12 @@ def handle_import(storage: StorageManager):
583
599
  invalid_chars = set('*?<>|"')
584
600
  if any(c in os.path.basename(input_path) for c in invalid_chars):
585
601
  ui.print_line("✗ 文件名包含非法字符(* ? < > | \"),请重新输入。")
602
+ ui.prompt("\n按 Enter 返回主菜单 ")
586
603
  return
587
604
 
588
605
  if not os.path.exists(input_path):
589
606
  ui.print_line("✗ 文件不存在!")
607
+ ui.prompt("\n按 Enter 返回主菜单 ")
590
608
  return
591
609
 
592
610
  try:
@@ -604,9 +622,11 @@ def handle_import(storage: StorageManager):
604
622
  external_key = storage.crypto.load_external_key(embedded_key, bk_pwd)
605
623
  except ValueError as e:
606
624
  ui.print_line(f"✗ {e}")
625
+ ui.prompt("\n按 Enter 返回主菜单 ")
607
626
  return
608
627
  if external_key is None:
609
628
  ui.print_line("✗ 备份主密码错误!")
629
+ ui.prompt("\n按 Enter 返回主菜单 ")
610
630
  return
611
631
  ui.print_line("✓ 备份主密码验证通过!")
612
632
 
@@ -615,6 +635,7 @@ def handle_import(storage: StorageManager):
615
635
 
616
636
  if preview['total'] == 0:
617
637
  ui.print_line("✗ 备份文件中没有账号数据。")
638
+ ui.prompt("\n按 Enter 返回主菜单 ")
618
639
  return
619
640
 
620
641
  ui.print_line("\n📋 备份文件预览:")
@@ -678,12 +699,14 @@ def handle_import(storage: StorageManager):
678
699
  _print_import_result(result)
679
700
  except json.JSONDecodeError:
680
701
  ui.print_line("✗ 导入失败:备份文件格式错误,不是有效的 JSON 文件。")
702
+ ui.prompt("\n按 Enter 返回主菜单 ")
681
703
  except Exception as e:
682
704
  err_msg = str(e)
683
705
  if 'InvalidTag' in err_msg or 'decrypt' in err_msg.lower():
684
706
  ui.print_line("✗ 导入失败:无法解密备份文件,可能是主密码不匹配或文件已损坏。")
685
707
  else:
686
708
  ui.print_line(f"✗ 导入失败: {e}")
709
+ ui.prompt("\n按 Enter 返回主菜单 ")
687
710
 
688
711
 
689
712
  def _build_parser() -> argparse.ArgumentParser:
@@ -701,6 +724,25 @@ def _build_parser() -> argparse.ArgumentParser:
701
724
  '--version', action='version', version=f'mini2fa {__version__}',
702
725
  help='显示版本号并退出',
703
726
  )
727
+
728
+ # 子命令
729
+ sub = parser.add_subparsers(dest='command', help='子命令')
730
+
731
+ # list 子命令(-p 在子命令后面)
732
+ list_cmd = sub.add_parser('list', help='列出所有账号(ID + issuer + account)')
733
+ list_cmd.add_argument(
734
+ '-p', '--password', metavar='PASSWORD', required=True,
735
+ help='主密码',
736
+ )
737
+
738
+ # get 子命令(-p 在子命令后面)
739
+ get_cmd = sub.add_parser('get', help='获取验证码')
740
+ get_cmd.add_argument(
741
+ '-p', '--password', metavar='PASSWORD', required=True,
742
+ help='主密码',
743
+ )
744
+ get_cmd.add_argument('id', type=int, help='账号 ID(从 list 获取)')
745
+
704
746
  return parser
705
747
 
706
748
 
@@ -709,11 +751,22 @@ def main(argv=None):
709
751
 
710
752
  Args:
711
753
  argv: 命令行参数列表(None 时用 sys.argv[1:])。
712
- 由 argparse 解析,支持 --no-tui、--version。
754
+ 由 argparse 解析,支持 --no-tui、-p、子命令 list。
713
755
 
714
756
  Ctrl-C 在任意阶段(含主密码输入)都被捕获,优雅退出。
715
757
  """
716
758
  args = _build_parser().parse_args(argv)
759
+
760
+ # 子命令模式:直接执行,不进 TUI
761
+ if args.command == 'list':
762
+ _handle_list(args.password)
763
+ return
764
+
765
+ if args.command == 'get':
766
+ _handle_get(args.password, args.id)
767
+ return
768
+
769
+ # 交互模式(TUI)
717
770
  ui.configure(no_tui=args.no_tui)
718
771
  ui.enter()
719
772
  try:
@@ -726,6 +779,88 @@ def main(argv=None):
726
779
  ui.leave()
727
780
 
728
781
 
782
+ def _handle_list(password: str):
783
+ """子命令 list:列出所有账号(ID + issuer + account)
784
+
785
+ 零交互:密码通过参数传入,输出纯文本。
786
+ """
787
+ # 初始化数据目录(确保存在)
788
+ init_data_dir()
789
+
790
+ db_path = str(get_db_path())
791
+ key_path = str(get_key_path())
792
+
793
+ # 检查主密钥文件是否存在
794
+ if not os.path.exists(key_path):
795
+ print("错误: 首次使用请先运行 mini2fa 设置主密码", file=sys.stderr)
796
+ sys.exit(1)
797
+
798
+ crypto = CryptoManager(key_path)
799
+
800
+ # 验证密码(不污染会话)
801
+ if not crypto.initialize(password):
802
+ print("密码错误", file=sys.stderr)
803
+ sys.exit(1)
804
+
805
+ storage = StorageManager(db_path, crypto)
806
+ accounts = storage.get_all_accounts()
807
+
808
+ if not accounts:
809
+ print("暂无账号", file=sys.stderr)
810
+ sys.exit(0)
811
+
812
+ # 按分类分组,输出 ID + issuer + account
813
+ groups = {}
814
+ for acc in accounts:
815
+ groups.setdefault(acc.category, []).append(acc)
816
+
817
+ categories = sorted(groups.keys(), key=lambda c: (c != 'default', c))
818
+
819
+ for category in categories:
820
+ print(f"\n [{category}]")
821
+ for acc in groups[category]:
822
+ print(f" {acc.id:3d}. {acc.issuer} - {acc.account}")
823
+
824
+
825
+ def _handle_get(password: str, account_id: int):
826
+ """子命令 get:用 ID 获取验证码
827
+
828
+ 零交互:密码通过参数传入,输出简洁一行式。
829
+ """
830
+ # 初始化数据目录(确保存在)
831
+ init_data_dir()
832
+
833
+ db_path = str(get_db_path())
834
+ key_path = str(get_key_path())
835
+
836
+ # 检查主密钥文件是否存在
837
+ if not os.path.exists(key_path):
838
+ print("错误: 首次使用请先运行 mini2fa 设置主密码", file=sys.stderr)
839
+ sys.exit(1)
840
+
841
+ crypto = CryptoManager(key_path)
842
+
843
+ # 验证密码
844
+ if not crypto.initialize(password):
845
+ print("密码错误", file=sys.stderr)
846
+ sys.exit(1)
847
+
848
+ storage = StorageManager(db_path, crypto)
849
+ account = storage.get_account(account_id)
850
+
851
+ if not account:
852
+ print(f"账号不存在: {account_id}", file=sys.stderr)
853
+ sys.exit(1)
854
+
855
+ # 解密密钥并生成验证码
856
+ secret = storage.get_secret(account_id)
857
+ code = generate_totp(secret, account.algorithm, account.digits, account.period)
858
+ remaining = get_remaining_seconds(account.period)
859
+
860
+ # 简洁一行式:issuer - account: code (remaining)
861
+ print(f"{account.issuer} - {account.account}: {code} ({remaining}s)")
862
+
863
+
729
864
  def _main_inner():
730
865
  """程序主体(不含 TUI 生命周期)"""
731
866
  ui.clear() # 启动:清屏后显示 banner,保证顶部干净
@@ -768,6 +903,8 @@ def _main_inner():
768
903
  else:
769
904
  ui.print_line(f"\n✗ 主密码设置失败:已尝试 {MAX_PASSWORD_ATTEMPTS} 次。")
770
905
  ui.print_line(" 请重新运行 mini2fa 重新设置。")
906
+ import time
907
+ time.sleep(1.5)
771
908
  sys.exit(1)
772
909
 
773
910
  # 设置密保提示
@@ -777,6 +914,8 @@ def _main_inner():
777
914
  ui.print_line("✓ 主密码已设置成功!")
778
915
  else:
779
916
  ui.print_line("✗ 初始化失败!")
917
+ import time
918
+ time.sleep(1.5)
780
919
  sys.exit(1)
781
920
  else:
782
921
  # 获取密保提示
@@ -794,7 +933,9 @@ def _main_inner():
794
933
  if hint:
795
934
  ui.print_line(f"💡 密码提示:{hint}")
796
935
  else:
797
- ui.print_line("✗ 密码错误")
936
+ ui.print_line("✗ 密码错误次数过多,程序将退出")
937
+ import time
938
+ time.sleep(1.5)
798
939
  sys.exit(1)
799
940
 
800
941
  # 初始化存储管理器
@@ -846,6 +987,8 @@ def _run():
846
987
  except StorageCorruptedError as e:
847
988
  ui.print_line(f"\n✗ {e}")
848
989
  ui.print_line(" 请检查数据文件是否完整,或从备份恢复。")
990
+ import time
991
+ time.sleep(1.5)
849
992
  sys.exit(1)
850
993
 
851
994
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mini2fa
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: 安全的本地 TOTP 双因素认证管理工具
5
5
  License-Expression: GPL-3.0-or-later
6
6
  Project-URL: Homepage, https://github.com/Duroxi/Mini2FA
@@ -197,7 +197,8 @@ class TestChangePasswordCLI:
197
197
  # getpass 3次都是错误旧密码
198
198
  inputs = iter(['wrong1', 'wrong2', 'wrong3'])
199
199
  with patch('getpass.getpass', side_effect=lambda prompt: next(inputs)):
200
- handle_change_password(crypto)
200
+ with patch('builtins.input', return_value=''):
201
+ handle_change_password(crypto)
201
202
 
202
203
  from mini2fa.crypto import CryptoManager
203
204
  reload = CryptoManager(key)
@@ -318,7 +319,8 @@ class TestAddAccountFlow:
318
319
  storage.add_account('Google', 'u@gmail.com', 'JBSWY3DPEHPK3PXP')
319
320
  try:
320
321
  # 若流程错误进入填字段, next() 会 StopIteration 崩溃
321
- inputs = iter(['fake.png'])
322
+ # 需要两个 input: 图片路径 + 按 Enter 返回主菜单
323
+ inputs = iter(['fake.png', ''])
322
324
  with patch('mini2fa._cli.os.path.exists', return_value=True):
323
325
  with patch('mini2fa._cli.scan_qrcode', return_value=info):
324
326
  with patch('builtins.input', side_effect=lambda prompt: next(inputs)):
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes