From 619226d3367bcf685ca1e86b27b349dde13f6f30 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 20 Aug 2025 18:36:19 -0400 Subject: [PATCH] feat: support random and deterministic TOTP secrets --- src/main.py | 7 +++ src/seedpass/api.py | 3 +- src/seedpass/cli/__init__.py | 14 ++++- src/seedpass/cli/common.py | 2 + src/seedpass/cli/entry.py | 4 ++ src/seedpass/core/api.py | 5 +- src/seedpass/core/entry_management.py | 35 ++++++++----- src/seedpass/core/manager.py | 26 ++++++--- src/seedpass/core/totp.py | 7 +++ src/tests/test_api_new_endpoints.py | 1 + src/tests/test_cli_doc_examples.py | 2 +- src/tests/test_cli_entry_add_commands.py | 10 +++- src/tests/test_gui_headless.py | 4 +- src/tests/test_manager_add_totp.py | 18 +++---- src/tests/test_manager_display_totp_codes.py | 2 +- src/tests/test_totp_entry.py | 55 +++++++++++--------- 16 files changed, 132 insertions(+), 63 deletions(-) diff --git a/src/main.py b/src/main.py index ecb26f5..9717f82 100644 --- a/src/main.py +++ b/src/main.py @@ -1294,6 +1294,11 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in action="store_true", help="Disable clipboard support and print secrets", ) + parser.add_argument( + "--deterministic-totp", + action="store_true", + help="Derive TOTP secrets deterministically", + ) parser.add_argument( "--max-prompt-attempts", type=int, @@ -1371,6 +1376,8 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in if args.no_clipboard: password_manager.secret_mode_enabled = False + if args.deterministic_totp: + password_manager.deterministic_totp = True if args.command == "export": password_manager.handle_export_database(Path(args.file)) diff --git a/src/seedpass/api.py b/src/seedpass/api.py index 0d73ab8..384f002 100644 --- a/src/seedpass/api.py +++ b/src/seedpass/api.py @@ -214,13 +214,14 @@ async def create_entry( uri = await run_in_threadpool( pm.entry_manager.add_totp, entry.get("label"), - pm.parent_seed, + pm.KEY_TOTP_DET if entry.get("deterministic", False) else None, secret=entry.get("secret"), index=entry.get("index"), period=int(entry.get("period", 30)), digits=int(entry.get("digits", 6)), notes=entry.get("notes", ""), archived=entry.get("archived", False), + deterministic=entry.get("deterministic", False), ) return {"id": index, "uri": uri} diff --git a/src/seedpass/cli/__init__.py b/src/seedpass/cli/__init__.py index 771d623..2f5fe11 100644 --- a/src/seedpass/cli/__init__.py +++ b/src/seedpass/cli/__init__.py @@ -30,6 +30,13 @@ no_clipboard_option = typer.Option( is_flag=True, ) +deterministic_totp_option = typer.Option( + False, + "--deterministic-totp", + help="Derive TOTP secrets deterministically", + is_flag=True, +) + # Sub command groups from . import entry, vault, nostr, config, fingerprint, util, api @@ -55,12 +62,17 @@ def main( ctx: typer.Context, fingerprint: Optional[str] = fingerprint_option, no_clipboard: bool = no_clipboard_option, + deterministic_totp: bool = deterministic_totp_option, ) -> None: """SeedPass CLI entry point. When called without a subcommand this launches the interactive TUI. """ - ctx.obj = {"fingerprint": fingerprint, "no_clipboard": no_clipboard} + ctx.obj = { + "fingerprint": fingerprint, + "no_clipboard": no_clipboard, + "deterministic_totp": deterministic_totp, + } if ctx.invoked_subcommand is None: tui = importlib.import_module("main") raise typer.Exit(tui.main(fingerprint=fingerprint)) diff --git a/src/seedpass/cli/common.py b/src/seedpass/cli/common.py index c2ff6f0..114d596 100644 --- a/src/seedpass/cli/common.py +++ b/src/seedpass/cli/common.py @@ -29,6 +29,8 @@ def _get_pm(ctx: typer.Context) -> PasswordManager: pm = PasswordManager(fingerprint=fp) if ctx.obj.get("no_clipboard"): pm.secret_mode_enabled = False + if ctx.obj.get("deterministic_totp"): + pm.deterministic_totp = True return pm diff --git a/src/seedpass/cli/entry.py b/src/seedpass/cli/entry.py index 22f4e11..a90201d 100644 --- a/src/seedpass/cli/entry.py +++ b/src/seedpass/cli/entry.py @@ -177,6 +177,9 @@ def entry_add_totp( secret: Optional[str] = typer.Option(None, "--secret", help="Import secret"), period: int = typer.Option(30, "--period", help="TOTP period in seconds"), digits: int = typer.Option(6, "--digits", help="Number of TOTP digits"), + deterministic_totp: bool = typer.Option( + False, "--deterministic-totp", help="Derive secret deterministically" + ), ) -> None: """Add a TOTP entry and output the otpauth URI.""" service = _get_entry_service(ctx) @@ -186,6 +189,7 @@ def entry_add_totp( secret=secret, period=period, digits=digits, + deterministic=deterministic_totp, ) typer.echo(uri) diff --git a/src/seedpass/core/api.py b/src/seedpass/core/api.py index f80c003..1ecd03e 100644 --- a/src/seedpass/core/api.py +++ b/src/seedpass/core/api.py @@ -363,15 +363,18 @@ class EntryService: secret: str | None = None, period: int = 30, digits: int = 6, + deterministic: bool = False, ) -> str: with self._lock: + key = self._manager.KEY_TOTP_DET if deterministic else None uri = self._manager.entry_manager.add_totp( label, - self._manager.parent_seed, + key, index=index, secret=secret, period=period, digits=digits, + deterministic=deterministic, ) self._manager.start_background_vault_sync() return uri diff --git a/src/seedpass/core/entry_management.py b/src/seedpass/core/entry_management.py index 44292ce..69aedf8 100644 --- a/src/seedpass/core/entry_management.py +++ b/src/seedpass/core/entry_management.py @@ -34,7 +34,7 @@ from pathlib import Path from termcolor import colored from .migrations import LATEST_VERSION from .entry_types import EntryType, ALL_ENTRY_TYPES -from .totp import TotpManager +from .totp import TotpManager, random_totp_secret from utils.fingerprint import generate_fingerprint from utils.checksum import canonical_json_dumps from utils.atomic_write import atomic_write @@ -257,7 +257,7 @@ class EntryManager: def add_totp( self, label: str, - parent_seed: str | bytes, + parent_seed: str | bytes | None = None, *, archived: bool = False, secret: str | None = None, @@ -266,13 +266,16 @@ class EntryManager: digits: int = 6, notes: str = "", tags: list[str] | None = None, + deterministic: bool = False, ) -> str: """Add a new TOTP entry and return the provisioning URI.""" entry_id = self.get_next_index() data = self._load_index() data.setdefault("entries", {}) - if secret is None: + if deterministic: + if parent_seed is None: + raise ValueError("Seed required for deterministic TOTP") if index is None: index = self.get_next_totp_index() secret = TotpManager.derive_secret(parent_seed, index) @@ -289,8 +292,11 @@ class EntryManager: "archived": archived, "notes": notes, "tags": tags or [], + "deterministic": True, } else: + if secret is None: + secret = random_totp_secret() if not validate_totp_secret(secret): raise ValueError("Invalid TOTP secret") entry = { @@ -304,6 +310,7 @@ class EntryManager: "archived": archived, "notes": notes, "tags": tags or [], + "deterministic": False, } data["entries"][str(entry_id)] = entry @@ -702,12 +709,12 @@ class EntryManager: etype != EntryType.TOTP.value and kind != EntryType.TOTP.value ): raise ValueError("Entry is not a TOTP entry") - if "secret" in entry: - return TotpManager.current_code_from_secret(entry["secret"], timestamp) - if parent_seed is None: - raise ValueError("Seed required for derived TOTP") - totp_index = int(entry.get("index", 0)) - return TotpManager.current_code(parent_seed, totp_index, timestamp) + if entry.get("deterministic", False) or "secret" not in entry: + if parent_seed is None: + raise ValueError("Seed required for derived TOTP") + totp_index = int(entry.get("index", 0)) + return TotpManager.current_code(parent_seed, totp_index, timestamp) + return TotpManager.current_code_from_secret(entry["secret"], timestamp) def get_totp_time_remaining(self, index: int) -> int: """Return seconds remaining in the TOTP period for the given entry.""" @@ -723,7 +730,7 @@ class EntryManager: return TotpManager.time_remaining(period) def export_totp_entries( - self, parent_seed: str | bytes + self, parent_seed: str | bytes | None ) -> dict[str, list[dict[str, Any]]]: """Return all TOTP secrets and metadata for external use.""" data = self._load_index() @@ -736,11 +743,13 @@ class EntryManager: label = entry.get("label", "") period = int(entry.get("period", 30)) digits = int(entry.get("digits", 6)) - if "secret" in entry: - secret = entry["secret"] - else: + if entry.get("deterministic", False) or "secret" not in entry: + if parent_seed is None: + raise ValueError("Seed required for deterministic TOTP export") idx = int(entry.get("index", 0)) secret = TotpManager.derive_secret(parent_seed, idx) + else: + secret = entry["secret"] uri = TotpManager.make_otpauth_uri(label, secret, period, digits) exported.append( { diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index 4c8368f..f5f8e26 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -239,6 +239,7 @@ class PasswordManager: KEY_INDEX: bytes | None = None KEY_PW_DERIVE: bytes | None = None KEY_TOTP_DET: bytes | None = None + deterministic_totp: bool = False def __init__( self, fingerprint: Optional[str] = None, *, password: Optional[str] = None @@ -287,6 +288,7 @@ class PasswordManager: self.is_locked: bool = False self.inactivity_timeout: float = INACTIVITY_TIMEOUT self.secret_mode_enabled: bool = False + self.deterministic_totp: bool = False self.clipboard_clear_delay: int = 45 self.offline_mode: bool = False self.profile_stack: list[tuple[str, Path, str]] = [] @@ -1852,7 +1854,7 @@ class PasswordManager: child_fingerprint=child_fp, ) print("\nAdd TOTP:") - print("1. Make 2FA (derive from seed)") + print("1. Make 2FA") print("2. Import 2FA (paste otpauth URI or secret)") choice = input("Select option or press Enter to go back: ").strip() if choice == "1": @@ -1876,9 +1878,13 @@ class PasswordManager: if tags_input else [] ) - totp_index = self.entry_manager.get_next_totp_index() entry_id = self.entry_manager.get_next_index() - key = self.KEY_TOTP_DET or getattr(self, "parent_seed", None) + key = self.KEY_TOTP_DET if self.deterministic_totp else None + totp_index = ( + self.entry_manager.get_next_totp_index() + if self.deterministic_totp + else None + ) uri = self.entry_manager.add_totp( label, key, @@ -1887,8 +1893,14 @@ class PasswordManager: digits=int(digits), notes=notes, tags=tags, + deterministic=self.deterministic_totp, ) - secret = TotpManager.derive_secret(key, totp_index) + if self.deterministic_totp: + secret = TotpManager.derive_secret(key, totp_index or 0) + color_cat = "deterministic" + else: + _lbl, secret, _, _ = TotpManager.parse_otpauth(uri) + color_cat = "default" self.is_dirty = True self.last_update = time.time() print( @@ -1899,7 +1911,7 @@ class PasswordManager: print(colored("Add this URI to your authenticator app:", "cyan")) print(colored(uri, "yellow")) TotpManager.print_qr_code(uri) - print(color_text(f"Secret: {secret}\n", "deterministic")) + print(color_text(f"Secret: {secret}\n", color_cat)) try: self.start_background_vault_sync() except Exception as nostr_error: @@ -1931,15 +1943,15 @@ class PasswordManager: else [] ) entry_id = self.entry_manager.get_next_index() - key = self.KEY_TOTP_DET or getattr(self, "parent_seed", None) uri = self.entry_manager.add_totp( label, - key, + None, secret=secret, period=period, digits=digits, notes=notes, tags=tags, + deterministic=False, ) self.is_dirty = True self.last_update = time.time() diff --git a/src/seedpass/core/totp.py b/src/seedpass/core/totp.py index 4f130b0..7164d65 100644 --- a/src/seedpass/core/totp.py +++ b/src/seedpass/core/totp.py @@ -2,8 +2,10 @@ from __future__ import annotations +import os import sys import time +import base64 from typing import Union from urllib.parse import quote from urllib.parse import urlparse, parse_qs, unquote @@ -15,6 +17,11 @@ import pyotp from utils import key_derivation +def random_totp_secret(length: int = 20) -> str: + """Return a random Base32 encoded TOTP secret.""" + return base64.b32encode(os.urandom(length)).decode("ascii").rstrip("=") + + class TotpManager: """Helper methods for TOTP secrets and codes.""" diff --git a/src/tests/test_api_new_endpoints.py b/src/tests/test_api_new_endpoints.py index 22eec24..0dda092 100644 --- a/src/tests/test_api_new_endpoints.py +++ b/src/tests/test_api_new_endpoints.py @@ -53,6 +53,7 @@ async def test_create_and_modify_totp_entry(client): "digits": 8, "notes": "n", "archived": False, + "deterministic": False, } res = await cl.put( diff --git a/src/tests/test_cli_doc_examples.py b/src/tests/test_cli_doc_examples.py index 2d0b1c7..f4bbd61 100644 --- a/src/tests/test_cli_doc_examples.py +++ b/src/tests/test_cli_doc_examples.py @@ -25,7 +25,7 @@ class DummyPM: retrieve_entry=lambda idx: {"type": EntryType.PASSWORD.value, "length": 8}, get_totp_code=lambda idx, seed: "123456", add_entry=lambda label, length, username, url, **kwargs: 1, - add_totp=lambda label, seed, index=None, secret=None, period=30, digits=6: "totp://", + add_totp=lambda label, seed, index=None, secret=None, period=30, digits=6, deterministic=False: "totp://", add_ssh_key=lambda label, seed, index=None, notes="": 2, add_pgp_key=lambda label, seed, index=None, key_type="ed25519", user_id="", notes="": 3, add_nostr_key=lambda label, seed, index=None, notes="": 4, diff --git a/src/tests/test_cli_entry_add_commands.py b/src/tests/test_cli_entry_add_commands.py index da7c680..7159287 100644 --- a/src/tests/test_cli_entry_add_commands.py +++ b/src/tests/test_cli_entry_add_commands.py @@ -65,8 +65,14 @@ runner = CliRunner() "--digits", "7", ], - ("Label", "seed"), - {"index": 1, "secret": "abc", "period": 45, "digits": 7}, + ("Label", None), + { + "index": 1, + "secret": "abc", + "period": 45, + "digits": 7, + "deterministic": False, + }, "otpauth://uri", ), ( diff --git a/src/tests/test_gui_headless.py b/src/tests/test_gui_headless.py index d7b0129..3e8d56e 100644 --- a/src/tests/test_gui_headless.py +++ b/src/tests/test_gui_headless.py @@ -33,7 +33,9 @@ class FakeEntries: self.added.append(("password", label, length, username, url)) return 1 - def add_totp(self, label): + def add_totp( + self, label, deterministic=False, index=None, secret=None, period=30, digits=6 + ): self.added.append(("totp", label)) return 1 diff --git a/src/tests/test_manager_add_totp.py b/src/tests/test_manager_add_totp.py index 27ed550..5fe2cce 100644 --- a/src/tests/test_manager_add_totp.py +++ b/src/tests/test_manager_add_totp.py @@ -60,15 +60,11 @@ def test_handle_add_totp(monkeypatch, capsys): out = capsys.readouterr().out entry = entry_mgr.retrieve_entry(0) - assert entry == { - "type": "totp", - "kind": "totp", - "label": "Example", - "index": 0, - "period": 30, - "digits": 6, - "archived": False, - "notes": "", - "tags": [], - } + assert entry["type"] == "totp" + assert entry["kind"] == "totp" + assert entry["label"] == "Example" + assert entry["deterministic"] is False + assert "index" not in entry + assert "secret" in entry + assert len(entry["secret"]) >= 16 assert "ID 0" in out diff --git a/src/tests/test_manager_display_totp_codes.py b/src/tests/test_manager_display_totp_codes.py index 5887522..9c56704 100644 --- a/src/tests/test_manager_display_totp_codes.py +++ b/src/tests/test_manager_display_totp_codes.py @@ -32,7 +32,7 @@ def test_handle_display_totp_codes(monkeypatch, capsys, password_manager): pm.handle_display_totp_codes() out = capsys.readouterr().out - assert "Generated 2FA Codes" in out + assert "Imported 2FA Codes" in out assert "[0] Example" in out assert "123456" in out diff --git a/src/tests/test_totp_entry.py b/src/tests/test_totp_entry.py index eff4988..92e9bf7 100644 --- a/src/tests/test_totp_entry.py +++ b/src/tests/test_totp_entry.py @@ -28,23 +28,19 @@ def test_add_totp_and_get_code(): assert uri.startswith("otpauth://totp/") entry = entry_mgr.retrieve_entry(0) - assert entry == { - "type": "totp", - "kind": "totp", - "label": "Example", - "index": 0, - "period": 30, - "digits": 6, - "archived": False, - "notes": "", - "tags": [], - } + assert entry["deterministic"] is False + assert "secret" in entry - code = entry_mgr.get_totp_code(0, TEST_SEED, timestamp=0) + code = entry_mgr.get_totp_code(0, timestamp=0) - expected = TotpManager.current_code(TEST_SEED, 0, timestamp=0) + expected = pyotp.TOTP(entry["secret"]).at(0) assert code == expected + # second entry should have different secret + entry_mgr.add_totp("Other", TEST_SEED) + entry2 = entry_mgr.retrieve_entry(1) + assert entry["secret"] != entry2["secret"] + def test_totp_time_remaining(monkeypatch): with TemporaryDirectory() as tmpdir: @@ -68,17 +64,8 @@ def test_add_totp_imported(tmp_path): secret = "JBSWY3DPEHPK3PXP" em.add_totp("Imported", TEST_SEED, secret=secret) entry = em.retrieve_entry(0) - assert entry == { - "type": "totp", - "kind": "totp", - "label": "Imported", - "secret": secret, - "period": 30, - "digits": 6, - "archived": False, - "notes": "", - "tags": [], - } + assert entry["secret"] == secret + assert entry["deterministic"] is False code = em.get_totp_code(0, timestamp=0) assert code == pyotp.TOTP(secret).at(0) @@ -92,3 +79,23 @@ def test_add_totp_with_notes(tmp_path): em.add_totp("NoteLabel", TEST_SEED, notes="some note") entry = em.retrieve_entry(0) assert entry["notes"] == "some note" + + +def test_legacy_deterministic_entry(tmp_path): + vault, enc = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) + em = EntryManager(vault, backup_mgr) + + em.add_totp("Legacy", TEST_SEED, deterministic=True) + data = em._load_index() + entry = data["entries"]["0"] + entry.pop("deterministic", None) + em._save_index(data) + + code = em.get_totp_code(0, TEST_SEED, timestamp=0) + expected = TotpManager.current_code(TEST_SEED, 0, timestamp=0) + assert code == expected + + exported = em.export_totp_entries(TEST_SEED) + assert exported["entries"][0]["secret"] == TotpManager.derive_secret(TEST_SEED, 0)