Refactor manager to accept provided credentials

This commit is contained in:
thePR0M3TH3AN
2025-07-18 08:25:07 -04:00
parent ae26190928
commit d679d52b66
9 changed files with 88 additions and 50 deletions

View File

@@ -462,8 +462,9 @@ def vault_reveal_parent_seed(
) -> None: ) -> None:
"""Display the parent seed and optionally write an encrypted backup file.""" """Display the parent seed and optionally write an encrypted backup file."""
vault_service, _profile, _sync = _get_services(ctx) vault_service, _profile, _sync = _get_services(ctx)
password = typer.prompt("Master password", hide_input=True)
vault_service.backup_parent_seed( vault_service.backup_parent_seed(
BackupParentSeedRequest(path=Path(file) if file else None) BackupParentSeedRequest(path=Path(file) if file else None, password=password)
) )
@@ -630,7 +631,10 @@ def fingerprint_remove(ctx: typer.Context, fingerprint: str) -> None:
def fingerprint_switch(ctx: typer.Context, fingerprint: str) -> None: def fingerprint_switch(ctx: typer.Context, fingerprint: str) -> None:
"""Switch to another seed profile.""" """Switch to another seed profile."""
_vault, profile_service, _sync = _get_services(ctx) _vault, profile_service, _sync = _get_services(ctx)
profile_service.switch_profile(ProfileSwitchRequest(fingerprint=fingerprint)) password = typer.prompt("Master password", hide_input=True)
profile_service.switch_profile(
ProfileSwitchRequest(fingerprint=fingerprint, password=password)
)
@util_app.command("generate-password") @util_app.command("generate-password")

View File

@@ -57,12 +57,14 @@ class BackupParentSeedRequest(BaseModel):
"""Optional path to write the encrypted seed backup.""" """Optional path to write the encrypted seed backup."""
path: Optional[Path] = None path: Optional[Path] = None
password: Optional[str] = None
class ProfileSwitchRequest(BaseModel): class ProfileSwitchRequest(BaseModel):
"""Select a different seed profile.""" """Select a different seed profile."""
fingerprint: str fingerprint: str
password: Optional[str] = None
class ProfileRemoveRequest(BaseModel): class ProfileRemoveRequest(BaseModel):
@@ -123,7 +125,9 @@ class VaultService:
"""Backup and reveal the parent seed.""" """Backup and reveal the parent seed."""
with self._lock: with self._lock:
self._manager.handle_backup_reveal_parent_seed(req.path) self._manager.handle_backup_reveal_parent_seed(
req.path, password=req.password
)
def stats(self) -> Dict: def stats(self) -> Dict:
"""Return statistics about the current profile.""" """Return statistics about the current profile."""
@@ -164,7 +168,7 @@ class ProfileService:
"""Switch to ``req.fingerprint``.""" """Switch to ``req.fingerprint``."""
with self._lock: with self._lock:
self._manager.select_fingerprint(req.fingerprint) self._manager.select_fingerprint(req.fingerprint, password=req.password)
class SyncService: class SyncService:

View File

@@ -546,7 +546,7 @@ class PasswordManager:
print(colored(f"Error: Failed to load parent seed: {e}", "red")) print(colored(f"Error: Failed to load parent seed: {e}", "red"))
sys.exit(1) sys.exit(1)
def handle_switch_fingerprint(self) -> bool: def handle_switch_fingerprint(self, *, password: Optional[str] = None) -> bool:
""" """
Handles switching to a different seed profile. Handles switching to a different seed profile.
@@ -587,9 +587,10 @@ class PasswordManager:
return False # Return False to indicate failure return False # Return False to indicate failure
# Prompt for master password for the selected seed profile # Prompt for master password for the selected seed profile
password = prompt_existing_password( if password is None:
"Enter the master password for the selected seed profile: " password = prompt_existing_password(
) "Enter the master password for the selected seed profile: "
)
# Set up the encryption manager with the new password and seed profile directory # Set up the encryption manager with the new password and seed profile directory
if not self.setup_encryption_manager( if not self.setup_encryption_manager(
@@ -676,14 +677,14 @@ class PasswordManager:
self.update_activity() self.update_activity()
self.start_background_sync() self.start_background_sync()
def handle_existing_seed(self) -> None: def handle_existing_seed(self, *, password: Optional[str] = None) -> None:
""" """
Handles the scenario where an existing parent seed file is found. Handles the scenario where an existing parent seed file is found.
Prompts the user for the master password to decrypt the seed. Prompts the user for the master password to decrypt the seed.
""" """
try: try:
# Prompt for password using masked input if password is None:
password = prompt_existing_password("Enter your login password: ") password = prompt_existing_password("Enter your login password: ")
# Derive encryption key from password # Derive encryption key from password
iterations = ( iterations = (
@@ -778,7 +779,11 @@ class PasswordManager:
sys.exit(1) sys.exit(1)
def setup_existing_seed( def setup_existing_seed(
self, method: Literal["paste", "words"] = "paste" self,
method: Literal["paste", "words"] = "paste",
*,
seed: Optional[str] = None,
password: Optional[str] = None,
) -> Optional[str]: ) -> Optional[str]:
"""Prompt for an existing BIP-85 seed and set it up. """Prompt for an existing BIP-85 seed and set it up.
@@ -794,7 +799,9 @@ class PasswordManager:
The fingerprint if setup is successful, ``None`` otherwise. The fingerprint if setup is successful, ``None`` otherwise.
""" """
try: try:
if method == "words": if seed is not None:
parent_seed = seed
elif method == "words":
parent_seed = prompt_seed_words() parent_seed = prompt_seed_words()
else: else:
parent_seed = masked_input("Enter your 12-word BIP-85 seed: ").strip() parent_seed = masked_input("Enter your 12-word BIP-85 seed: ").strip()
@@ -804,17 +811,21 @@ class PasswordManager:
print(colored("Error: Invalid BIP-85 seed phrase.", "red")) print(colored("Error: Invalid BIP-85 seed phrase.", "red"))
sys.exit(1) sys.exit(1)
return self._finalize_existing_seed(parent_seed) return self._finalize_existing_seed(parent_seed, password=password)
except KeyboardInterrupt: except KeyboardInterrupt:
logging.info("Operation cancelled by user.") logging.info("Operation cancelled by user.")
self.notify("Operation cancelled by user.", level="WARNING") self.notify("Operation cancelled by user.", level="WARNING")
sys.exit(0) sys.exit(0)
def setup_existing_seed_word_by_word(self) -> Optional[str]: def setup_existing_seed_word_by_word(
self, *, seed: Optional[str] = None, password: Optional[str] = None
) -> Optional[str]:
"""Prompt for an existing seed one word at a time and set it up.""" """Prompt for an existing seed one word at a time and set it up."""
return self.setup_existing_seed(method="words") return self.setup_existing_seed(method="words", seed=seed, password=password)
def _finalize_existing_seed(self, parent_seed: str) -> Optional[str]: def _finalize_existing_seed(
self, parent_seed: str, *, password: Optional[str] = None
) -> Optional[str]:
"""Common logic for initializing an existing seed.""" """Common logic for initializing an existing seed."""
if self.validate_bip85_seed(parent_seed): if self.validate_bip85_seed(parent_seed):
fingerprint = self.fingerprint_manager.add_fingerprint(parent_seed) fingerprint = self.fingerprint_manager.add_fingerprint(parent_seed)
@@ -842,7 +853,8 @@ class PasswordManager:
logging.info(f"Current seed profile set to {fingerprint}") logging.info(f"Current seed profile set to {fingerprint}")
try: try:
password = prompt_for_password() if password is None:
password = prompt_for_password()
index_key = derive_index_key(parent_seed) index_key = derive_index_key(parent_seed)
iterations = ( iterations = (
self.config_manager.get_kdf_iterations() self.config_manager.get_kdf_iterations()
@@ -976,7 +988,9 @@ class PasswordManager:
print(colored(f"Error: Failed to generate BIP-85 seed: {e}", "red")) print(colored(f"Error: Failed to generate BIP-85 seed: {e}", "red"))
sys.exit(1) sys.exit(1)
def save_and_encrypt_seed(self, seed: str, fingerprint_dir: Path) -> None: def save_and_encrypt_seed(
self, seed: str, fingerprint_dir: Path, *, password: Optional[str] = None
) -> None:
""" """
Saves and encrypts the parent seed. Saves and encrypts the parent seed.
@@ -988,8 +1002,8 @@ class PasswordManager:
# Set self.fingerprint_dir # Set self.fingerprint_dir
self.fingerprint_dir = fingerprint_dir self.fingerprint_dir = fingerprint_dir
# Prompt for password if password is None:
password = prompt_for_password() password = prompt_for_password()
index_key = derive_index_key(seed) index_key = derive_index_key(seed)
iterations = ( iterations = (
@@ -3732,7 +3746,9 @@ class PasswordManager:
print(colored(f"Error: Failed to export 2FA codes: {e}", "red")) print(colored(f"Error: Failed to export 2FA codes: {e}", "red"))
return None return None
def handle_backup_reveal_parent_seed(self, file: Path | None = None) -> None: def handle_backup_reveal_parent_seed(
self, file: Path | None = None, *, password: Optional[str] = None
) -> None:
"""Reveal the parent seed and optionally save an encrypted backup. """Reveal the parent seed and optionally save an encrypted backup.
Parameters Parameters
@@ -3762,9 +3778,10 @@ class PasswordManager:
) )
# Verify user's identity with secure password verification # Verify user's identity with secure password verification
password = prompt_existing_password( if password is None:
"Enter your master password to continue: " password = prompt_existing_password(
) "Enter your master password to continue: "
)
if not self.verify_password(password): if not self.verify_password(password):
print(colored("Incorrect password. Operation aborted.", "red")) print(colored("Incorrect password. Operation aborted.", "red"))
return return

View File

@@ -22,9 +22,6 @@ def test_switch_fingerprint_triggers_bg_sync(monkeypatch, tmp_path):
pm.config_manager = SimpleNamespace(get_quick_unlock=lambda: False) pm.config_manager = SimpleNamespace(get_quick_unlock=lambda: False)
monkeypatch.setattr("builtins.input", lambda *_a, **_k: "1") monkeypatch.setattr("builtins.input", lambda *_a, **_k: "1")
monkeypatch.setattr(
"seedpass.core.manager.prompt_existing_password", lambda *_a, **_k: "pw"
)
monkeypatch.setattr( monkeypatch.setattr(
PasswordManager, "setup_encryption_manager", lambda *a, **k: True PasswordManager, "setup_encryption_manager", lambda *a, **k: True
) )
@@ -39,7 +36,7 @@ def test_switch_fingerprint_triggers_bg_sync(monkeypatch, tmp_path):
monkeypatch.setattr(PasswordManager, "start_background_sync", fake_bg) monkeypatch.setattr(PasswordManager, "start_background_sync", fake_bg)
assert pm.handle_switch_fingerprint() assert pm.handle_switch_fingerprint(password="pw")
assert calls["count"] == 1 assert calls["count"] == 1

View File

@@ -43,7 +43,7 @@ class DummyPM:
self.change_password = lambda *a, **kw: None self.change_password = lambda *a, **kw: None
self.lock_vault = lambda: None self.lock_vault = lambda: None
self.get_profile_stats = lambda: {"n": 1} self.get_profile_stats = lambda: {"n": 1}
self.handle_backup_reveal_parent_seed = lambda path=None: None self.handle_backup_reveal_parent_seed = lambda path=None, **_: None
self.handle_verify_checksum = lambda: None self.handle_verify_checksum = lambda: None
self.handle_update_script_checksum = lambda: None self.handle_update_script_checksum = lambda: None
self.add_new_fingerprint = lambda: None self.add_new_fingerprint = lambda: None
@@ -76,7 +76,7 @@ class DummyPM:
) )
self.secret_mode_enabled = True self.secret_mode_enabled = True
self.clipboard_clear_delay = 30 self.clipboard_clear_delay = 30
self.select_fingerprint = lambda fp: None self.select_fingerprint = lambda fp, **_: None
def load_doc_commands() -> list[str]: def load_doc_commands() -> list[str]:

View File

@@ -36,7 +36,7 @@ def test_setup_existing_seed_words(monkeypatch):
monkeypatch.setattr(builtins, "input", lambda *_: "y") monkeypatch.setattr(builtins, "input", lambda *_: "y")
pm = PasswordManager.__new__(PasswordManager) pm = PasswordManager.__new__(PasswordManager)
monkeypatch.setattr(pm, "_finalize_existing_seed", lambda seed: seed) monkeypatch.setattr(pm, "_finalize_existing_seed", lambda seed, **_: seed)
result = pm.setup_existing_seed(method="words") result = pm.setup_existing_seed(method="words")
assert result == phrase assert result == phrase
@@ -60,8 +60,32 @@ def test_setup_existing_seed_paste(monkeypatch):
) )
pm = PasswordManager.__new__(PasswordManager) pm = PasswordManager.__new__(PasswordManager)
monkeypatch.setattr(pm, "_finalize_existing_seed", lambda seed: seed) monkeypatch.setattr(pm, "_finalize_existing_seed", lambda seed, **_: seed)
result = pm.setup_existing_seed(method="paste") result = pm.setup_existing_seed(method="paste")
assert result == phrase assert result == phrase
assert called["prompt"].startswith("Enter your 12-word BIP-85 seed") assert called["prompt"].startswith("Enter your 12-word BIP-85 seed")
def test_setup_existing_seed_with_args(monkeypatch):
m = Mnemonic("english")
phrase = m.generate(strength=128)
called = {}
monkeypatch.setattr(
"seedpass.core.manager.masked_input",
lambda *_: (_ for _ in ()).throw(RuntimeError("prompt")),
)
def finalize(seed, *, password=None):
called["seed"] = seed
called["pw"] = password
return "fp"
pm = PasswordManager.__new__(PasswordManager)
monkeypatch.setattr(pm, "_finalize_existing_seed", finalize)
result = pm.setup_existing_seed(method="paste", seed=phrase, password="pw")
assert result == "fp"
assert called["seed"] == phrase
assert called["pw"] == "pw"

View File

@@ -24,9 +24,6 @@ def _make_pm(tmp_path: Path) -> PasswordManager:
def test_handle_backup_reveal_parent_seed_confirm(monkeypatch, tmp_path, capsys): def test_handle_backup_reveal_parent_seed_confirm(monkeypatch, tmp_path, capsys):
pm = _make_pm(tmp_path) pm = _make_pm(tmp_path)
monkeypatch.setattr(
"seedpass.core.manager.prompt_existing_password", lambda *_: "pw"
)
confirms = iter([True, True]) confirms = iter([True, True])
monkeypatch.setattr( monkeypatch.setattr(
"seedpass.core.manager.confirm_action", lambda *_a, **_k: next(confirms) "seedpass.core.manager.confirm_action", lambda *_a, **_k: next(confirms)
@@ -39,7 +36,7 @@ def test_handle_backup_reveal_parent_seed_confirm(monkeypatch, tmp_path, capsys)
pm.encryption_manager = SimpleNamespace(encrypt_and_save_file=fake_save) pm.encryption_manager = SimpleNamespace(encrypt_and_save_file=fake_save)
monkeypatch.setattr(builtins, "input", lambda *_: "mybackup.enc") monkeypatch.setattr(builtins, "input", lambda *_: "mybackup.enc")
pm.handle_backup_reveal_parent_seed() pm.handle_backup_reveal_parent_seed(password="pw")
out = capsys.readouterr().out out = capsys.readouterr().out
assert "seed phrase" in out assert "seed phrase" in out
@@ -50,16 +47,13 @@ def test_handle_backup_reveal_parent_seed_confirm(monkeypatch, tmp_path, capsys)
def test_handle_backup_reveal_parent_seed_cancel(monkeypatch, tmp_path, capsys): def test_handle_backup_reveal_parent_seed_cancel(monkeypatch, tmp_path, capsys):
pm = _make_pm(tmp_path) pm = _make_pm(tmp_path)
monkeypatch.setattr(
"seedpass.core.manager.prompt_existing_password", lambda *_: "pw"
)
monkeypatch.setattr("seedpass.core.manager.confirm_action", lambda *_a, **_k: False) monkeypatch.setattr("seedpass.core.manager.confirm_action", lambda *_a, **_k: False)
saved = [] saved = []
pm.encryption_manager = SimpleNamespace( pm.encryption_manager = SimpleNamespace(
encrypt_and_save_file=lambda data, path: saved.append((data, path)) encrypt_and_save_file=lambda data, path: saved.append((data, path))
) )
pm.handle_backup_reveal_parent_seed() pm.handle_backup_reveal_parent_seed(password="pw")
out = capsys.readouterr().out out = capsys.readouterr().out
assert "seed phrase" not in out assert "seed phrase" not in out

View File

@@ -31,10 +31,6 @@ def test_add_and_switch_fingerprint(monkeypatch):
pm.current_fingerprint = None pm.current_fingerprint = None
monkeypatch.setattr("builtins.input", lambda *_args, **_kwargs: "1") monkeypatch.setattr("builtins.input", lambda *_args, **_kwargs: "1")
monkeypatch.setattr(
"seedpass.core.manager.prompt_existing_password",
lambda *_a, **_k: "pass",
)
monkeypatch.setattr( monkeypatch.setattr(
PasswordManager, PasswordManager,
"setup_encryption_manager", "setup_encryption_manager",
@@ -50,7 +46,7 @@ def test_add_and_switch_fingerprint(monkeypatch):
"seedpass.core.manager.NostrClient", lambda *a, **kw: object() "seedpass.core.manager.NostrClient", lambda *a, **kw: object()
) )
assert pm.handle_switch_fingerprint() assert pm.handle_switch_fingerprint(password="pass")
assert pm.current_fingerprint == fingerprint assert pm.current_fingerprint == fingerprint
assert fm.current_fingerprint == fingerprint assert fm.current_fingerprint == fingerprint
assert pm.fingerprint_dir == expected_dir assert pm.fingerprint_dir == expected_dir

View File

@@ -156,7 +156,7 @@ def test_vault_lock(monkeypatch):
def test_vault_reveal_parent_seed(monkeypatch, tmp_path): def test_vault_reveal_parent_seed(monkeypatch, tmp_path):
called = {} called = {}
def reveal(path=None): def reveal(path=None, **_):
called["path"] = path called["path"] = path
pm = SimpleNamespace( pm = SimpleNamespace(
@@ -165,7 +165,9 @@ def test_vault_reveal_parent_seed(monkeypatch, tmp_path):
monkeypatch.setattr(cli, "PasswordManager", lambda: pm) monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
out_path = tmp_path / "seed.enc" out_path = tmp_path / "seed.enc"
result = runner.invoke( result = runner.invoke(
app, ["vault", "reveal-parent-seed", "--file", str(out_path)] app,
["vault", "reveal-parent-seed", "--file", str(out_path)],
input="pw\n",
) )
assert result.exit_code == 0 assert result.exit_code == 0
assert called["path"] == out_path assert called["path"] == out_path
@@ -231,14 +233,14 @@ def test_fingerprint_remove(monkeypatch):
def test_fingerprint_switch(monkeypatch): def test_fingerprint_switch(monkeypatch):
called = {} called = {}
def switch(fp): def switch(fp, **_):
called["fp"] = fp called["fp"] = fp
pm = SimpleNamespace( pm = SimpleNamespace(
select_fingerprint=switch, fingerprint_manager=SimpleNamespace() select_fingerprint=switch, fingerprint_manager=SimpleNamespace()
) )
monkeypatch.setattr(cli, "PasswordManager", lambda: pm) monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["fingerprint", "switch", "def"]) result = runner.invoke(app, ["fingerprint", "switch", "def"], input="pw\n")
assert result.exit_code == 0 assert result.exit_code == 0
assert called.get("fp") == "def" assert called.get("fp") == "def"