mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-09 07:48:57 +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",
|
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))
|
||||||
|
@@ -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}
|
||||||
|
|
||||||
|
@@ -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))
|
||||||
|
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
@@ -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)
|
||||||
|
|
||||||
|
@@ -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
|
||||||
|
@@ -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(
|
||||||
{
|
{
|
||||||
|
@@ -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()
|
||||||
|
@@ -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."""
|
||||||
|
|
||||||
|
@@ -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(
|
||||||
|
@@ -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,
|
||||||
|
@@ -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",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
@@ -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
|
||||||
|
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
|
||||||
|
@@ -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)
|
||||||
|
Reference in New Issue
Block a user