mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-08 07:18:47 +00:00
feat: support random and deterministic TOTP secrets
This commit is contained in:
@@ -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))
|
||||
|
@@ -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}
|
||||
|
||||
|
@@ -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))
|
||||
|
@@ -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
|
||||
|
||||
|
||||
|
@@ -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)
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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(
|
||||
{
|
||||
|
@@ -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()
|
||||
|
@@ -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."""
|
||||
|
||||
|
@@ -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(
|
||||
|
@@ -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,
|
||||
|
@@ -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",
|
||||
),
|
||||
(
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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)
|
||||
|
Reference in New Issue
Block a user