Merge pull request #832 from PR0M3TH3AN/codex/implement-totp-secret-generation-feature

feat: support random and deterministic TOTP secrets
This commit is contained in:
thePR0M3TH3AN
2025-08-20 18:57:00 -04:00
committed by GitHub
16 changed files with 132 additions and 63 deletions

View File

@@ -1294,6 +1294,11 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
action="store_true", action="store_true",
help="Disable clipboard support and print secrets", help="Disable clipboard support and print secrets",
) )
parser.add_argument(
"--deterministic-totp",
action="store_true",
help="Derive TOTP secrets deterministically",
)
parser.add_argument( parser.add_argument(
"--max-prompt-attempts", "--max-prompt-attempts",
type=int, type=int,
@@ -1371,6 +1376,8 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
if args.no_clipboard: if args.no_clipboard:
password_manager.secret_mode_enabled = False password_manager.secret_mode_enabled = False
if args.deterministic_totp:
password_manager.deterministic_totp = True
if args.command == "export": if args.command == "export":
password_manager.handle_export_database(Path(args.file)) password_manager.handle_export_database(Path(args.file))

View File

@@ -214,13 +214,14 @@ async def create_entry(
uri = await run_in_threadpool( uri = await run_in_threadpool(
pm.entry_manager.add_totp, pm.entry_manager.add_totp,
entry.get("label"), entry.get("label"),
pm.parent_seed, pm.KEY_TOTP_DET if entry.get("deterministic", False) else None,
secret=entry.get("secret"), secret=entry.get("secret"),
index=entry.get("index"), index=entry.get("index"),
period=int(entry.get("period", 30)), period=int(entry.get("period", 30)),
digits=int(entry.get("digits", 6)), digits=int(entry.get("digits", 6)),
notes=entry.get("notes", ""), notes=entry.get("notes", ""),
archived=entry.get("archived", False), archived=entry.get("archived", False),
deterministic=entry.get("deterministic", False),
) )
return {"id": index, "uri": uri} return {"id": index, "uri": uri}

View File

@@ -30,6 +30,13 @@ no_clipboard_option = typer.Option(
is_flag=True, is_flag=True,
) )
deterministic_totp_option = typer.Option(
False,
"--deterministic-totp",
help="Derive TOTP secrets deterministically",
is_flag=True,
)
# Sub command groups # Sub command groups
from . import entry, vault, nostr, config, fingerprint, util, api from . import entry, vault, nostr, config, fingerprint, util, api
@@ -55,12 +62,17 @@ def main(
ctx: typer.Context, ctx: typer.Context,
fingerprint: Optional[str] = fingerprint_option, fingerprint: Optional[str] = fingerprint_option,
no_clipboard: bool = no_clipboard_option, no_clipboard: bool = no_clipboard_option,
deterministic_totp: bool = deterministic_totp_option,
) -> None: ) -> None:
"""SeedPass CLI entry point. """SeedPass CLI entry point.
When called without a subcommand this launches the interactive TUI. 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: if ctx.invoked_subcommand is None:
tui = importlib.import_module("main") tui = importlib.import_module("main")
raise typer.Exit(tui.main(fingerprint=fingerprint)) raise typer.Exit(tui.main(fingerprint=fingerprint))

View File

@@ -29,6 +29,8 @@ def _get_pm(ctx: typer.Context) -> PasswordManager:
pm = PasswordManager(fingerprint=fp) pm = PasswordManager(fingerprint=fp)
if ctx.obj.get("no_clipboard"): if ctx.obj.get("no_clipboard"):
pm.secret_mode_enabled = False pm.secret_mode_enabled = False
if ctx.obj.get("deterministic_totp"):
pm.deterministic_totp = True
return pm return pm

View File

@@ -177,6 +177,9 @@ def entry_add_totp(
secret: Optional[str] = typer.Option(None, "--secret", help="Import secret"), secret: Optional[str] = typer.Option(None, "--secret", help="Import secret"),
period: int = typer.Option(30, "--period", help="TOTP period in seconds"), period: int = typer.Option(30, "--period", help="TOTP period in seconds"),
digits: int = typer.Option(6, "--digits", help="Number of TOTP digits"), digits: int = typer.Option(6, "--digits", help="Number of TOTP digits"),
deterministic_totp: bool = typer.Option(
False, "--deterministic-totp", help="Derive secret deterministically"
),
) -> None: ) -> None:
"""Add a TOTP entry and output the otpauth URI.""" """Add a TOTP entry and output the otpauth URI."""
service = _get_entry_service(ctx) service = _get_entry_service(ctx)
@@ -186,6 +189,7 @@ def entry_add_totp(
secret=secret, secret=secret,
period=period, period=period,
digits=digits, digits=digits,
deterministic=deterministic_totp,
) )
typer.echo(uri) typer.echo(uri)

View File

@@ -363,15 +363,18 @@ class EntryService:
secret: str | None = None, secret: str | None = None,
period: int = 30, period: int = 30,
digits: int = 6, digits: int = 6,
deterministic: bool = False,
) -> str: ) -> str:
with self._lock: with self._lock:
key = self._manager.KEY_TOTP_DET if deterministic else None
uri = self._manager.entry_manager.add_totp( uri = self._manager.entry_manager.add_totp(
label, label,
self._manager.parent_seed, key,
index=index, index=index,
secret=secret, secret=secret,
period=period, period=period,
digits=digits, digits=digits,
deterministic=deterministic,
) )
self._manager.start_background_vault_sync() self._manager.start_background_vault_sync()
return uri return uri

View File

@@ -34,7 +34,7 @@ from pathlib import Path
from termcolor import colored from termcolor import colored
from .migrations import LATEST_VERSION from .migrations import LATEST_VERSION
from .entry_types import EntryType, ALL_ENTRY_TYPES 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.fingerprint import generate_fingerprint
from utils.checksum import canonical_json_dumps from utils.checksum import canonical_json_dumps
from utils.atomic_write import atomic_write from utils.atomic_write import atomic_write
@@ -257,7 +257,7 @@ class EntryManager:
def add_totp( def add_totp(
self, self,
label: str, label: str,
parent_seed: str | bytes, parent_seed: str | bytes | None = None,
*, *,
archived: bool = False, archived: bool = False,
secret: str | None = None, secret: str | None = None,
@@ -266,13 +266,16 @@ class EntryManager:
digits: int = 6, digits: int = 6,
notes: str = "", notes: str = "",
tags: list[str] | None = None, tags: list[str] | None = None,
deterministic: bool = False,
) -> str: ) -> str:
"""Add a new TOTP entry and return the provisioning URI.""" """Add a new TOTP entry and return the provisioning URI."""
entry_id = self.get_next_index() entry_id = self.get_next_index()
data = self._load_index() data = self._load_index()
data.setdefault("entries", {}) 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: if index is None:
index = self.get_next_totp_index() index = self.get_next_totp_index()
secret = TotpManager.derive_secret(parent_seed, index) secret = TotpManager.derive_secret(parent_seed, index)
@@ -289,8 +292,11 @@ class EntryManager:
"archived": archived, "archived": archived,
"notes": notes, "notes": notes,
"tags": tags or [], "tags": tags or [],
"deterministic": True,
} }
else: else:
if secret is None:
secret = random_totp_secret()
if not validate_totp_secret(secret): if not validate_totp_secret(secret):
raise ValueError("Invalid TOTP secret") raise ValueError("Invalid TOTP secret")
entry = { entry = {
@@ -304,6 +310,7 @@ class EntryManager:
"archived": archived, "archived": archived,
"notes": notes, "notes": notes,
"tags": tags or [], "tags": tags or [],
"deterministic": False,
} }
data["entries"][str(entry_id)] = entry data["entries"][str(entry_id)] = entry
@@ -702,12 +709,12 @@ class EntryManager:
etype != EntryType.TOTP.value and kind != EntryType.TOTP.value etype != EntryType.TOTP.value and kind != EntryType.TOTP.value
): ):
raise ValueError("Entry is not a TOTP entry") raise ValueError("Entry is not a TOTP entry")
if "secret" in entry: if entry.get("deterministic", False) or "secret" not in entry:
return TotpManager.current_code_from_secret(entry["secret"], timestamp) if parent_seed is None:
if parent_seed is None: raise ValueError("Seed required for derived TOTP")
raise ValueError("Seed required for derived TOTP") totp_index = int(entry.get("index", 0))
totp_index = int(entry.get("index", 0)) return TotpManager.current_code(parent_seed, totp_index, timestamp)
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: def get_totp_time_remaining(self, index: int) -> int:
"""Return seconds remaining in the TOTP period for the given entry.""" """Return seconds remaining in the TOTP period for the given entry."""
@@ -723,7 +730,7 @@ class EntryManager:
return TotpManager.time_remaining(period) return TotpManager.time_remaining(period)
def export_totp_entries( def export_totp_entries(
self, parent_seed: str | bytes self, parent_seed: str | bytes | None
) -> dict[str, list[dict[str, Any]]]: ) -> dict[str, list[dict[str, Any]]]:
"""Return all TOTP secrets and metadata for external use.""" """Return all TOTP secrets and metadata for external use."""
data = self._load_index() data = self._load_index()
@@ -736,11 +743,13 @@ class EntryManager:
label = entry.get("label", "") label = entry.get("label", "")
period = int(entry.get("period", 30)) period = int(entry.get("period", 30))
digits = int(entry.get("digits", 6)) digits = int(entry.get("digits", 6))
if "secret" in entry: if entry.get("deterministic", False) or "secret" not in entry:
secret = entry["secret"] if parent_seed is None:
else: raise ValueError("Seed required for deterministic TOTP export")
idx = int(entry.get("index", 0)) idx = int(entry.get("index", 0))
secret = TotpManager.derive_secret(parent_seed, idx) secret = TotpManager.derive_secret(parent_seed, idx)
else:
secret = entry["secret"]
uri = TotpManager.make_otpauth_uri(label, secret, period, digits) uri = TotpManager.make_otpauth_uri(label, secret, period, digits)
exported.append( exported.append(
{ {

View File

@@ -239,6 +239,7 @@ class PasswordManager:
KEY_INDEX: bytes | None = None KEY_INDEX: bytes | None = None
KEY_PW_DERIVE: bytes | None = None KEY_PW_DERIVE: bytes | None = None
KEY_TOTP_DET: bytes | None = None KEY_TOTP_DET: bytes | None = None
deterministic_totp: bool = False
def __init__( def __init__(
self, fingerprint: Optional[str] = None, *, password: Optional[str] = None self, fingerprint: Optional[str] = None, *, password: Optional[str] = None
@@ -287,6 +288,7 @@ class PasswordManager:
self.is_locked: bool = False self.is_locked: bool = False
self.inactivity_timeout: float = INACTIVITY_TIMEOUT self.inactivity_timeout: float = INACTIVITY_TIMEOUT
self.secret_mode_enabled: bool = False self.secret_mode_enabled: bool = False
self.deterministic_totp: bool = False
self.clipboard_clear_delay: int = 45 self.clipboard_clear_delay: int = 45
self.offline_mode: bool = False self.offline_mode: bool = False
self.profile_stack: list[tuple[str, Path, str]] = [] self.profile_stack: list[tuple[str, Path, str]] = []
@@ -1852,7 +1854,7 @@ class PasswordManager:
child_fingerprint=child_fp, child_fingerprint=child_fp,
) )
print("\nAdd TOTP:") print("\nAdd TOTP:")
print("1. Make 2FA (derive from seed)") print("1. Make 2FA")
print("2. Import 2FA (paste otpauth URI or secret)") print("2. Import 2FA (paste otpauth URI or secret)")
choice = input("Select option or press Enter to go back: ").strip() choice = input("Select option or press Enter to go back: ").strip()
if choice == "1": if choice == "1":
@@ -1876,9 +1878,13 @@ class PasswordManager:
if tags_input if tags_input
else [] else []
) )
totp_index = self.entry_manager.get_next_totp_index()
entry_id = self.entry_manager.get_next_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( uri = self.entry_manager.add_totp(
label, label,
key, key,
@@ -1887,8 +1893,14 @@ class PasswordManager:
digits=int(digits), digits=int(digits),
notes=notes, notes=notes,
tags=tags, 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.is_dirty = True
self.last_update = time.time() self.last_update = time.time()
print( print(
@@ -1899,7 +1911,7 @@ class PasswordManager:
print(colored("Add this URI to your authenticator app:", "cyan")) print(colored("Add this URI to your authenticator app:", "cyan"))
print(colored(uri, "yellow")) print(colored(uri, "yellow"))
TotpManager.print_qr_code(uri) TotpManager.print_qr_code(uri)
print(color_text(f"Secret: {secret}\n", "deterministic")) print(color_text(f"Secret: {secret}\n", color_cat))
try: try:
self.start_background_vault_sync() self.start_background_vault_sync()
except Exception as nostr_error: except Exception as nostr_error:
@@ -1931,15 +1943,15 @@ class PasswordManager:
else [] else []
) )
entry_id = self.entry_manager.get_next_index() 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( uri = self.entry_manager.add_totp(
label, label,
key, None,
secret=secret, secret=secret,
period=period, period=period,
digits=digits, digits=digits,
notes=notes, notes=notes,
tags=tags, tags=tags,
deterministic=False,
) )
self.is_dirty = True self.is_dirty = True
self.last_update = time.time() self.last_update = time.time()

View File

@@ -2,8 +2,10 @@
from __future__ import annotations from __future__ import annotations
import os
import sys import sys
import time import time
import base64
from typing import Union from typing import Union
from urllib.parse import quote from urllib.parse import quote
from urllib.parse import urlparse, parse_qs, unquote from urllib.parse import urlparse, parse_qs, unquote
@@ -15,6 +17,11 @@ import pyotp
from utils import key_derivation 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: class TotpManager:
"""Helper methods for TOTP secrets and codes.""" """Helper methods for TOTP secrets and codes."""

View File

@@ -53,6 +53,7 @@ async def test_create_and_modify_totp_entry(client):
"digits": 8, "digits": 8,
"notes": "n", "notes": "n",
"archived": False, "archived": False,
"deterministic": False,
} }
res = await cl.put( res = await cl.put(

View File

@@ -25,7 +25,7 @@ class DummyPM:
retrieve_entry=lambda idx: {"type": EntryType.PASSWORD.value, "length": 8}, retrieve_entry=lambda idx: {"type": EntryType.PASSWORD.value, "length": 8},
get_totp_code=lambda idx, seed: "123456", get_totp_code=lambda idx, seed: "123456",
add_entry=lambda label, length, username, url, **kwargs: 1, 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_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_pgp_key=lambda label, seed, index=None, key_type="ed25519", user_id="", notes="": 3,
add_nostr_key=lambda label, seed, index=None, notes="": 4, add_nostr_key=lambda label, seed, index=None, notes="": 4,

View File

@@ -65,8 +65,14 @@ runner = CliRunner()
"--digits", "--digits",
"7", "7",
], ],
("Label", "seed"), ("Label", None),
{"index": 1, "secret": "abc", "period": 45, "digits": 7}, {
"index": 1,
"secret": "abc",
"period": 45,
"digits": 7,
"deterministic": False,
},
"otpauth://uri", "otpauth://uri",
), ),
( (

View File

@@ -33,7 +33,9 @@ class FakeEntries:
self.added.append(("password", label, length, username, url)) self.added.append(("password", label, length, username, url))
return 1 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)) self.added.append(("totp", label))
return 1 return 1

View File

@@ -60,15 +60,11 @@ def test_handle_add_totp(monkeypatch, capsys):
out = capsys.readouterr().out out = capsys.readouterr().out
entry = entry_mgr.retrieve_entry(0) entry = entry_mgr.retrieve_entry(0)
assert entry == { assert entry["type"] == "totp"
"type": "totp", assert entry["kind"] == "totp"
"kind": "totp", assert entry["label"] == "Example"
"label": "Example", assert entry["deterministic"] is False
"index": 0, assert "index" not in entry
"period": 30, assert "secret" in entry
"digits": 6, assert len(entry["secret"]) >= 16
"archived": False,
"notes": "",
"tags": [],
}
assert "ID 0" in out assert "ID 0" in out

View File

@@ -32,7 +32,7 @@ def test_handle_display_totp_codes(monkeypatch, capsys, password_manager):
pm.handle_display_totp_codes() pm.handle_display_totp_codes()
out = capsys.readouterr().out out = capsys.readouterr().out
assert "Generated 2FA Codes" in out assert "Imported 2FA Codes" in out
assert "[0] Example" in out assert "[0] Example" in out
assert "123456" in out assert "123456" in out

View File

@@ -28,23 +28,19 @@ def test_add_totp_and_get_code():
assert uri.startswith("otpauth://totp/") assert uri.startswith("otpauth://totp/")
entry = entry_mgr.retrieve_entry(0) entry = entry_mgr.retrieve_entry(0)
assert entry == { assert entry["deterministic"] is False
"type": "totp", assert "secret" in entry
"kind": "totp",
"label": "Example",
"index": 0,
"period": 30,
"digits": 6,
"archived": False,
"notes": "",
"tags": [],
}
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 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): def test_totp_time_remaining(monkeypatch):
with TemporaryDirectory() as tmpdir: with TemporaryDirectory() as tmpdir:
@@ -68,17 +64,8 @@ def test_add_totp_imported(tmp_path):
secret = "JBSWY3DPEHPK3PXP" secret = "JBSWY3DPEHPK3PXP"
em.add_totp("Imported", TEST_SEED, secret=secret) em.add_totp("Imported", TEST_SEED, secret=secret)
entry = em.retrieve_entry(0) entry = em.retrieve_entry(0)
assert entry == { assert entry["secret"] == secret
"type": "totp", assert entry["deterministic"] is False
"kind": "totp",
"label": "Imported",
"secret": secret,
"period": 30,
"digits": 6,
"archived": False,
"notes": "",
"tags": [],
}
code = em.get_totp_code(0, timestamp=0) code = em.get_totp_code(0, timestamp=0)
assert code == pyotp.TOTP(secret).at(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") em.add_totp("NoteLabel", TEST_SEED, notes="some note")
entry = em.retrieve_entry(0) entry = em.retrieve_entry(0)
assert entry["notes"] == "some note" 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)