From 88c1974ac6e712d875034a9aef76df84e01668b2 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 25 Feb 2026 19:18:54 -0500 Subject: [PATCH] Harden TUI recovery paths and add release integrity gates --- .github/workflows/python-ci.yml | 3 + .github/workflows/release-integrity.yml | 73 ++ .github/workflows/tests.yml | 2 + README.md | 2 + TODO.md | 9 + docs/README.md | 1 + docs/security_readiness_checklist.md | 2 +- docs/supply_chain_release_integrity.md | 70 ++ memory-update.md | 53 ++ scripts/release_integrity.py | 122 ++++ src/main.py | 628 +++++++++++------- src/seedpass/core/manager.py | 216 +++++- src/seedpass/release_integrity.py | 96 +++ src/tests/test_cli_export_import.py | 33 + src/tests/test_cli_invalid_input.py | 64 ++ .../test_deterministic_artifact_regression.py | 179 +++++ src/tests/test_profile_recovery_flow.py | 88 +++ src/tests/test_release_integrity_utils.py | 56 ++ src/tests/test_settings_menu.py | 93 +++ 19 files changed, 1524 insertions(+), 266 deletions(-) create mode 100644 .github/workflows/release-integrity.yml create mode 100644 docs/supply_chain_release_integrity.md create mode 100644 scripts/release_integrity.py create mode 100644 src/seedpass/release_integrity.py create mode 100644 src/tests/test_deterministic_artifact_regression.py create mode 100644 src/tests/test_profile_recovery_flow.py create mode 100644 src/tests/test_release_integrity_utils.py diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index ac312725..930f301c 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -94,6 +94,9 @@ jobs: - name: Enable Nostr network tests on main branch or nightly if: github.ref == 'refs/heads/main' || github.event_name == 'schedule' run: echo "NOSTR_E2E=1" >> $GITHUB_ENV + - name: Run deterministic regression tests + shell: bash + run: pytest -q src/tests/test_deterministic_artifact_regression.py - name: Run tests with coverage timeout-minutes: 16 shell: bash diff --git a/.github/workflows/release-integrity.yml b/.github/workflows/release-integrity.yml new file mode 100644 index 00000000..a5843460 --- /dev/null +++ b/.github/workflows/release-integrity.yml @@ -0,0 +1,73 @@ +name: Release Integrity + +on: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: write + id-token: write + +jobs: + verify-and-sign: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Validate lockfile and install dependencies + run: | + python -m pip install --upgrade pip + pip install pip-tools pip-audit build + python scripts/release_integrity.py check-lockfile --lockfile requirements.lock + pip-compile --generate-hashes --output-file=requirements.lock src/requirements.txt + git diff --exit-code requirements.lock + pip install --require-hashes -r requirements.lock + + - name: Run CVE audit + run: pip-audit -r requirements.lock --ignore-vuln GHSA-wj6h-64fc-37mp + + - name: Build release artifacts + run: python -m build + + - name: Generate and verify checksums + run: | + python scripts/release_integrity.py generate --artifact dist --base-dir . --output SHA256SUMS + python scripts/release_integrity.py verify --checksum-file SHA256SUMS --base-dir . + + - name: Install cosign + uses: sigstore/cosign-installer@v3 + + - name: Sign checksum manifest (keyless) + env: + COSIGN_EXPERIMENTAL: "1" + run: | + cosign sign-blob --yes \ + --output-signature SHA256SUMS.sig \ + --output-certificate SHA256SUMS.pem \ + SHA256SUMS + + - name: Upload release assets + if: github.event_name == 'release' + uses: softprops/action-gh-release@v2 + with: + files: | + dist/* + SHA256SUMS + SHA256SUMS.sig + SHA256SUMS.pem + + - name: Upload integrity artifacts (manual runs) + if: github.event_name != 'release' + uses: actions/upload-artifact@v4 + with: + name: release-integrity + path: | + dist/* + SHA256SUMS + SHA256SUMS.sig + SHA256SUMS.pem diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bab1548b..4b654bfb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -30,6 +30,8 @@ jobs: run: | poetry run pip-audit || echo "::warning::pip-audit found vulnerabilities" shell: bash + - name: Run deterministic regression tests + run: poetry run pytest -q src/tests/test_deterministic_artifact_regression.py - name: Run tests with coverage run: | poetry run coverage run -m pytest diff --git a/README.md b/README.md index ad2c6361..b1635b22 100644 --- a/README.md +++ b/README.md @@ -895,6 +895,8 @@ To review and merge these updates: A scheduled **Dependency Audit** workflow also runs [`pip-audit`](https://github.com/pypa/pip-audit) weekly to detect vulnerable packages. Address any reported issues promptly to keep dependencies secure. +For tagged releases, SeedPass also runs the `Release Integrity` workflow, which validates lockfile drift, runs `pip-audit`, builds distributables, generates `SHA256SUMS`, and signs the checksum manifest with keyless `cosign` before publishing release assets. + ## Contributing Contributions are welcome! If you have suggestions for improvements, bug fixes, or new features, please follow these steps: diff --git a/TODO.md b/TODO.md index fbba9358..d2c476e8 100644 --- a/TODO.md +++ b/TODO.md @@ -4,6 +4,11 @@ This file tracks the remaining work from the latest bug search and security eval ## Security +- [ ] Complete checklist item #8 (Supply chain and release integrity): + - run one tagged release through `.github/workflows/release-integrity.yml` and link evidence in `docs/security_readiness_checklist.md` + - resolve or formally time-box/own the `GHSA-wj6h-64fc-37mp` exception + - enforce release/tag protections requiring `Release Integrity` + dependency audit checks + - publish maintainer/consumer verification runbook (checksum + cosign verification with expected issuer/identity) - [ ] Upgrade vulnerable runtime dependencies in lockfiles and packaging inputs: - `aiohttp` -> `>=3.13.3` - `cryptography` -> `>=46.0.5` @@ -26,6 +31,10 @@ This file tracks the remaining work from the latest bug search and security eval - `src/seedpass/core/manager.py` - `src/seedpass/core/menu_handler.py` - [ ] Add telemetry-safe logs for swallowed error paths without exposing secrets. +- [ ] Harden startup/restore prompt flows against `EOFError` / cancellation loops: + - startup options (`Continue` / `Restore from backup`) + - restore-backup fingerprint/path prompts + - ensure graceful return-to-menu instead of process-level failure. ## Tests To Add diff --git a/docs/README.md b/docs/README.md index 5f24a9df..e5bf952e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,6 +9,7 @@ This directory contains SeedPass project documentation. - `crypto_key_management_review.md` - `secret_handling_local_exposure_review.md` - `auth_lock_unlock_access_review.md` +- `supply_chain_release_integrity.md` ## Architecture and Product Docs diff --git a/docs/security_readiness_checklist.md b/docs/security_readiness_checklist.md index aead8c9e..b226169c 100644 --- a/docs/security_readiness_checklist.md +++ b/docs/security_readiness_checklist.md @@ -11,7 +11,7 @@ Status legend: `Not Started`, `In Progress`, `Blocked`, `Done` | 5 | Nostr sync security and resilience validation | In Progress | Core / QA | `src/tests/test_nostr_real.py`, `src/tests/test_nostr_index_size.py` | | 6 | Auth, lock/unlock, and access-control hardening | Done | Core | `docs/auth_lock_unlock_access_review.md`, `src/tests/test_api_new_endpoints.py`, `src/tests/test_vault_lock_flag.py`, `src/tests/test_inactivity_lock.py`, `src/tests/test_unlock_sync.py` | | 7 | Testing gates and quality thresholds | In Progress | QA | `scripts/check_critical_coverage.py`, CI workflows | -| 8 | Supply chain and release integrity | Not Started | DevOps | | +| 8 | Supply chain and release integrity | In Progress | DevOps | `docs/supply_chain_release_integrity.md`, `.github/workflows/release-integrity.yml`, `scripts/release_integrity.py`, `src/tests/test_release_integrity_utils.py` | | 9 | Operational readiness and incident runbooks | Not Started | Security / Ops | | | 10 | External audit and staged production rollout | Not Started | Maintainers | | diff --git a/docs/supply_chain_release_integrity.md b/docs/supply_chain_release_integrity.md new file mode 100644 index 00000000..b43b4029 --- /dev/null +++ b/docs/supply_chain_release_integrity.md @@ -0,0 +1,70 @@ +# Supply Chain and Release Integrity + +This document defines the release trust chain for SeedPass and provides the evidence expected by checklist item #8 in `docs/security_readiness_checklist.md`. + +## Scope + +- Dependency lock and hash integrity (`requirements.lock`). +- Vulnerability review process for runtime dependencies. +- Release artifact checksums and signing. + +## Controls + +1. Locked dependencies: + - Dependencies are pinned and hash-locked in `requirements.lock`. + - CI and release automation regenerate the lock file from `src/requirements.txt` and fail on drift. + - `scripts/release_integrity.py check-lockfile` enforces that pinned entries and SHA-256 hashes are present. + +2. CVE review: + - `pip-audit` runs in CI (`.github/workflows/dependency-audit.yml`) and at release time (`.github/workflows/release-integrity.yml`). + - Vulnerabilities must be remediated before release unless a temporary exception is documented with a tracked advisory ID and owner. + - Current temporary exception: `GHSA-wj6h-64fc-37mp`. + +3. Artifact integrity: + - Release artifacts are built with `python -m build`. + - `scripts/release_integrity.py generate` creates a deterministic `SHA256SUMS` manifest. + - `scripts/release_integrity.py verify` re-checks artifacts against `SHA256SUMS`. + - `SHA256SUMS` is signed in GitHub Actions using keyless `cosign`, producing: + - `SHA256SUMS.sig` + - `SHA256SUMS.pem` + +## Release Workflow + +Workflow: `.github/workflows/release-integrity.yml` + +On every published GitHub release, the workflow: + +1. Verifies lockfile integrity and lock drift. +2. Runs CVE audit on pinned dependencies. +3. Builds source/wheel artifacts. +4. Generates and verifies `SHA256SUMS`. +5. Signs `SHA256SUMS` with keyless `cosign`. +6. Uploads artifacts and integrity files to the GitHub release. + +## Manual Verification + +From a checkout that includes release assets: + +```bash +python scripts/release_integrity.py verify --checksum-file SHA256SUMS --base-dir . +cosign verify-blob \ + --certificate SHA256SUMS.pem \ + --signature SHA256SUMS.sig \ + SHA256SUMS +``` + +## Evidence + +- Workflow: `.github/workflows/release-integrity.yml` +- Tooling: `scripts/release_integrity.py`, `src/seedpass/release_integrity.py` +- Tests: `src/tests/test_release_integrity_utils.py` + +## Remaining Work To Mark Checklist Item #8 Done + +1. Execute at least one tagged production release through `.github/workflows/release-integrity.yml` and record the run URL/artifact links in `docs/security_readiness_checklist.md`. +2. Eliminate the temporary audit exception (`GHSA-wj6h-64fc-37mp`) or document formal risk acceptance with expiration date and owner. +3. Add branch/release protection so publishable tags require successful `Release Integrity` and `Dependency Audit` checks before release publication. +4. Add maintainer verification runbook for release consumers that includes: + - `python scripts/release_integrity.py verify --checksum-file SHA256SUMS --base-dir .` + - `cosign verify-blob --certificate SHA256SUMS.pem --signature SHA256SUMS.sig SHA256SUMS` + - expected identity/issuer constraints for keyless signing. diff --git a/memory-update.md b/memory-update.md index d370b96b..6a0e692b 100644 --- a/memory-update.md +++ b/memory-update.md @@ -6,3 +6,56 @@ ## Outcome - Removed stale Archivox branding from the docs README and default docs title so the correct SeedPass README/content is displayed. + +## Supply Chain and Release Integrity (Checklist #8) +- Added a dedicated release integrity workflow at `.github/workflows/release-integrity.yml`. +- Workflow now enforces lockfile drift checks, runs `pip-audit`, builds release artifacts, generates `SHA256SUMS`, signs checksum manifest with keyless `cosign`, and publishes signatures/certs with release assets. +- Added `scripts/release_integrity.py` CLI with `check-lockfile`, `generate`, and `verify` subcommands. +- Added reusable checksum logic in `src/seedpass/release_integrity.py` and regression tests in `src/tests/test_release_integrity_utils.py`. +- Updated `docs/security_readiness_checklist.md` item #8 from `Not Started` to `In Progress` with evidence links. +- Added process documentation in `docs/supply_chain_release_integrity.md` and linked it from `docs/README.md` and `README.md`. + +## Determinism Regression Guardrail +- Added `src/tests/test_deterministic_artifact_regression.py` to lock deterministic vectors at index `352` for password, SSH, seed phrase, TOTP, PGP (fingerprint + armored hash), and Nostr keys. +- Added repeatability assertions for same-seed/same-index derivation in the same run. +- Logged explicit remaining tasks to finish checklist item #8 in both `docs/supply_chain_release_integrity.md` and `TODO.md`. + +## CI Determinism Enforcement +- Expanded deterministic regression vectors to cover indices `0`, `1`, `352`, and `1024` across password, SSH, seed phrase, TOTP, and Nostr outputs. +- Added pinned PGP vectors for both `ed25519` and `rsa` at index `352` (fingerprints + armored key hashes). +- Added explicit deterministic regression steps to push CI workflows: + - `.github/workflows/python-ci.yml` + - `.github/workflows/tests.yml` + +## Profile Unlock Failure Recovery +- Hardened startup profile selection to avoid fatal crash loops when unlock/decryption fails or is cancelled. +- `select_fingerprint(...)` now returns `False` on unlock/setup failure instead of always raising; selection menu now keeps the user in-flow. +- Added profile recovery flow `recover_profile_with_blank_index()` reachable from seed-profile selection menu. +- Recovery flow verifies that user-provided seed matches selected fingerprint, then re-encrypts seed and resets local entry index/checksum files to a blank state. +- Fixed "switch to existing profile" path during add-new-seed so it does not call `initialize_managers()` without an initialized `EncryptionManager`. +- Added regression tests in `src/tests/test_profile_recovery_flow.py` for these paths. + +## TUI Crash Hardening +- Hardened `display_menu()` to catch per-action `PasswordPromptError`, `SeedPassError`, and generic exceptions so failures no longer terminate the whole TUI session. +- Added graceful timeout-lock recovery messaging when unlock is cancelled/failed after inactivity timeout. +- Fixed incorrect call signature in Add Entry submenu header rendering (`clear_header_with_notification(...)` now receives `password_manager`). +- Added crash-regression coverage in `src/tests/test_cli_invalid_input.py` for action failures and timeout-unlock cancellation handling. + +## Settings/Relay Submenu Hardening +- Added local exception guards in `handle_settings`, `handle_profiles_menu`, and `handle_nostr_menu` so submenu actions report failures and return control instead of crashing the TUI. +- Added explicit graceful handling for Settings -> Lock Vault unlock cancellations/failures. +- Corrected `clear_header_with_notification(...)` call signatures in settings-related submenus to pass `password_manager` as first argument. +- Added regression tests in `src/tests/test_settings_menu.py` for: + - missing settings handler method + - lock/unlock cancellation + - profiles submenu action failure + +## Import/Export + Nostr Workflow Guardrails +- Hardened settings menu options for export/import/TOTP export with explicit error messaging (`Export failed`, `Import failed`, `2FA export failed`) instead of generic crashes. +- Added graceful no-path handling for Settings -> Import (`No path entered.`). +- Hardened CLI subcommand entrypoints in `main.py`: + - require `--file` for `export`/`import` + - catch and report failures for `export`, `import`, `search`, `get`, and `totp` commands. +- Added regression tests for these cases in: + - `src/tests/test_settings_menu.py` + - `src/tests/test_cli_export_import.py` diff --git a/scripts/release_integrity.py b/scripts/release_integrity.py new file mode 100644 index 00000000..f302dfd7 --- /dev/null +++ b/scripts/release_integrity.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Release integrity checks and checksum manifest tooling.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SRC_DIR = PROJECT_ROOT / "src" +if str(SRC_DIR) not in sys.path: + sys.path.insert(0, str(SRC_DIR)) + +from seedpass.release_integrity import generate_checksums, verify_checksums + + +def _check_lockfile(lockfile: Path) -> tuple[bool, list[str]]: + issues: list[str] = [] + if not lockfile.exists(): + return False, [f"Missing lockfile: {lockfile}"] + + package_line_count = 0 + hash_count = 0 + for raw_line in lockfile.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("--hash=sha256:"): + hash_count += 1 + continue + if "==" in line and not line.startswith(("-", "\\")): + package_line_count += 1 + + if package_line_count == 0: + issues.append("No pinned package entries found in lockfile.") + if hash_count == 0: + issues.append("No sha256 hashes found in lockfile.") + return len(issues) == 0, issues + + +def cmd_generate(args: argparse.Namespace) -> int: + artifacts = [Path(path) for path in args.artifact] + lines = generate_checksums(artifacts, base_dir=Path(args.base_dir)) + output_file = Path(args.output) + output_file.write_text("\n".join(lines) + "\n", encoding="utf-8") + print(f"Wrote {len(lines)} checksums to {output_file}") + return 0 + + +def cmd_verify(args: argparse.Namespace) -> int: + checksum_file = Path(args.checksum_file) + ok, failures = verify_checksums(checksum_file, base_dir=Path(args.base_dir)) + if ok: + print(f"Checksum verification passed: {checksum_file}") + return 0 + print(f"Checksum verification failed for {checksum_file}:") + for failure in failures: + print(f" - {failure}") + return 1 + + +def cmd_check_lockfile(args: argparse.Namespace) -> int: + lockfile = Path(args.lockfile) + ok, issues = _check_lockfile(lockfile) + if ok: + print(f"Lockfile integrity checks passed: {lockfile}") + return 0 + print(f"Lockfile integrity checks failed for {lockfile}:") + for issue in issues: + print(f" - {issue}") + return 1 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + generate_parser = subparsers.add_parser( + "generate", help="Generate SHA256SUMS manifest" + ) + generate_parser.add_argument( + "--artifact", + action="append", + required=True, + help="Artifact file or directory. Pass multiple times for multiple paths.", + ) + generate_parser.add_argument( + "--output", default="SHA256SUMS", help="Checksum output file" + ) + generate_parser.add_argument( + "--base-dir", + default=".", + help="Base directory used to render relative paths in SHA256SUMS.", + ) + generate_parser.set_defaults(func=cmd_generate) + + verify_parser = subparsers.add_parser("verify", help="Verify SHA256SUMS manifest") + verify_parser.add_argument("--checksum-file", default="SHA256SUMS") + verify_parser.add_argument( + "--base-dir", + default=".", + help="Base directory used to resolve file paths from SHA256SUMS.", + ) + verify_parser.set_defaults(func=cmd_verify) + + lockfile_parser = subparsers.add_parser( + "check-lockfile", help="Validate lockfile has pinned packages and hashes" + ) + lockfile_parser.add_argument("--lockfile", default="requirements.lock") + lockfile_parser.set_defaults(func=cmd_check_lockfile) + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/main.py b/src/main.py index 5c0a9db6..11fcb561 100644 --- a/src/main.py +++ b/src/main.py @@ -21,6 +21,7 @@ from utils.color_scheme import color_text import importlib from seedpass.core.manager import PasswordManager, restore_backup_index +from seedpass.core.errors import SeedPassError from nostr.client import NostrClient from seedpass.core.entry_types import EntryType from seedpass.core.config_manager import ConfigManager @@ -884,6 +885,7 @@ def handle_profiles_menu(password_manager: PasswordManager) -> None: (getattr(password_manager, "current_fingerprint", None), None, None), ) clear_header_with_notification( + password_manager, fp, "Main Menu > Settings > Profiles", parent_fingerprint=parent_fp, @@ -897,21 +899,34 @@ def handle_profiles_menu(password_manager: PasswordManager) -> None: print(color_text("5. Set Seed Profile Name", "menu")) choice = input("Select an option or press Enter to go back: ").strip() password_manager.update_activity() - if choice == "1": - if not password_manager.handle_switch_fingerprint(): - print(colored("Failed to switch seed profile.", "red")) - elif choice == "2": - handle_add_new_fingerprint(password_manager) - elif choice == "3": - handle_remove_fingerprint(password_manager) - elif choice == "4": - handle_list_fingerprints(password_manager) - elif choice == "5": - handle_set_profile_name(password_manager) - elif not choice: - break - else: - print(colored("Invalid choice.", "red")) + try: + if choice == "1": + if not password_manager.handle_switch_fingerprint(): + print(colored("Failed to switch seed profile.", "red")) + elif choice == "2": + handle_add_new_fingerprint(password_manager) + elif choice == "3": + handle_remove_fingerprint(password_manager) + elif choice == "4": + handle_list_fingerprints(password_manager) + elif choice == "5": + handle_set_profile_name(password_manager) + elif not choice: + break + else: + print(colored("Invalid choice.", "red")) + except PasswordPromptError as exc: + logging.warning("Profiles submenu action cancelled: %s", exc) + print(colored(f"Action cancelled: {exc}", "yellow")) + pause() + except SeedPassError as exc: + logging.error("Profiles submenu action failed: %s", exc, exc_info=True) + print(colored(f"Action failed: {exc}", "red")) + pause() + except Exception as exc: + logging.error("Unexpected profiles submenu error: %s", exc, exc_info=True) + print(colored(f"Unexpected profiles submenu error: {exc}", "red")) + pause() def handle_nostr_menu(password_manager: PasswordManager) -> None: @@ -933,6 +948,7 @@ def handle_nostr_menu(password_manager: PasswordManager) -> None: (getattr(password_manager, "current_fingerprint", None), None, None), ) clear_header_with_notification( + password_manager, fp, "Main Menu > Settings > Nostr", parent_fingerprint=parent_fp, @@ -948,24 +964,37 @@ def handle_nostr_menu(password_manager: PasswordManager) -> None: print(color_text("7. Display Nostr Public Key", "menu")) choice = input("Select an option or press Enter to go back: ").strip() password_manager.update_activity() - if choice == "1": - handle_post_to_nostr(password_manager) - elif choice == "2": - handle_retrieve_from_nostr(password_manager) - elif choice == "3": - handle_view_relays(cfg_mgr) - elif choice == "4": - handle_add_relay(password_manager) - elif choice == "5": - handle_remove_relay(password_manager) - elif choice == "6": - handle_reset_relays(password_manager) - elif choice == "7": - handle_display_npub(password_manager) - elif not choice: - break - else: - print(colored("Invalid choice.", "red")) + try: + if choice == "1": + handle_post_to_nostr(password_manager) + elif choice == "2": + handle_retrieve_from_nostr(password_manager) + elif choice == "3": + handle_view_relays(cfg_mgr) + elif choice == "4": + handle_add_relay(password_manager) + elif choice == "5": + handle_remove_relay(password_manager) + elif choice == "6": + handle_reset_relays(password_manager) + elif choice == "7": + handle_display_npub(password_manager) + elif not choice: + break + else: + print(colored("Invalid choice.", "red")) + except PasswordPromptError as exc: + logging.warning("Nostr submenu action cancelled: %s", exc) + print(colored(f"Action cancelled: {exc}", "yellow")) + pause() + except SeedPassError as exc: + logging.error("Nostr submenu action failed: %s", exc, exc_info=True) + print(colored(f"Action failed: {exc}", "red")) + pause() + except Exception as exc: + logging.error("Unexpected Nostr submenu error: %s", exc, exc_info=True) + print(colored(f"Unexpected Nostr submenu error: {exc}", "red")) + pause() def handle_settings(password_manager: PasswordManager) -> None: @@ -977,6 +1006,7 @@ def handle_settings(password_manager: PasswordManager) -> None: (getattr(password_manager, "current_fingerprint", None), None, None), ) clear_header_with_notification( + password_manager, fp, "Main Menu > Settings", parent_fingerprint=parent_fp, @@ -1001,73 +1031,116 @@ def handle_settings(password_manager: PasswordManager) -> None: print(color_text("16. Toggle Offline Mode (default ON)", "menu")) print(color_text("17. Toggle Quick Unlock", "menu")) choice = input("Select an option or press Enter to go back: ").strip() - if choice == "1": - handle_profiles_menu(password_manager) - elif choice == "2": - handle_nostr_menu(password_manager) - elif choice == "3": - try: - old_pw = prompt_existing_password("Enter your current password: ") - new_pw = prompt_new_password() - password_manager.change_password(old_pw, new_pw) - except ValueError: - print(colored("Incorrect password.", "red")) - except PasswordPromptError: - pass - except Exception as e: - print(colored(f"Error: {e}", "red")) + try: + if choice == "1": + handle_profiles_menu(password_manager) + elif choice == "2": + handle_nostr_menu(password_manager) + elif choice == "3": + try: + old_pw = prompt_existing_password("Enter your current password: ") + new_pw = prompt_new_password() + password_manager.change_password(old_pw, new_pw) + except ValueError: + print(colored("Incorrect password.", "red")) + except PasswordPromptError: + pass + except Exception as e: + print(colored(f"Error: {e}", "red")) + pause() + elif choice == "4": + password_manager.handle_verify_checksum() + pause() + elif choice == "5": + password_manager.handle_update_script_checksum() + pause() + elif choice == "6": + password_manager.handle_backup_reveal_parent_seed() + pause() + elif choice == "7": + try: + password_manager.handle_export_database() + except Exception as exc: + logging.error("Export database failed: %s", exc, exc_info=True) + print(colored(f"Export failed: {exc}", "red")) + pause() + elif choice == "8": + path = input("Enter path to backup file: ").strip() + if not path: + print(colored("No path entered.", "yellow")) + pause() + continue + try: + password_manager.handle_import_database(Path(path)) + except FileNotFoundError: + print(colored(f"Import failed: file '{path}' not found.", "red")) + except Exception as exc: + logging.error("Import database failed: %s", exc, exc_info=True) + print(colored(f"Import failed: {exc}", "red")) + pause() + elif choice == "9": + try: + password_manager.handle_export_totp_codes() + except Exception as exc: + logging.error("Export 2FA codes failed: %s", exc, exc_info=True) + print(colored(f"2FA export failed: {exc}", "red")) + pause() + elif choice == "10": + handle_set_additional_backup_location(password_manager) + pause() + elif choice == "11": + handle_set_kdf_iterations(password_manager) + pause() + elif choice == "12": + handle_set_inactivity_timeout(password_manager) + pause() + elif choice == "13": + password_manager.lock_vault() + print(colored("Vault locked. Please re-enter your password.", "yellow")) + try: + password_manager.unlock_vault() + except PasswordPromptError as exc: + print(colored(f"Unlock cancelled: {exc}", "yellow")) + except Exception as exc: + logging.error( + "Failed to unlock vault from settings: %s", + exc, + exc_info=True, + ) + print(colored(f"Failed to unlock vault: {exc}", "red")) + else: + password_manager.start_background_sync() + getattr( + password_manager, "start_background_relay_check", lambda: None + )() + pause() + elif choice == "14": + handle_display_stats(password_manager) + elif choice == "15": + handle_toggle_secret_mode(password_manager) + pause() + elif choice == "16": + handle_toggle_offline_mode(password_manager) + pause() + elif choice == "17": + handle_toggle_quick_unlock(password_manager) + pause() + elif not choice: + break + else: + print(colored("Invalid choice.", "red")) + except PasswordPromptError as exc: + logging.warning("Settings action cancelled: %s", exc) + print(colored(f"Action cancelled: {exc}", "yellow")) pause() - elif choice == "4": - password_manager.handle_verify_checksum() + except SeedPassError as exc: + logging.error("Settings action failed: %s", exc, exc_info=True) + print(colored(f"Action failed: {exc}", "red")) pause() - elif choice == "5": - password_manager.handle_update_script_checksum() + except Exception as exc: + logging.error("Unexpected settings error: %s", exc, exc_info=True) + print(colored(f"Unexpected settings error: {exc}", "red")) pause() - elif choice == "6": - password_manager.handle_backup_reveal_parent_seed() - pause() - elif choice == "7": - password_manager.handle_export_database() - pause() - elif choice == "8": - path = input("Enter path to backup file: ").strip() - if path: - password_manager.handle_import_database(Path(path)) - pause() - elif choice == "9": - password_manager.handle_export_totp_codes() - pause() - elif choice == "10": - handle_set_additional_backup_location(password_manager) - pause() - elif choice == "11": - handle_set_kdf_iterations(password_manager) - pause() - elif choice == "12": - handle_set_inactivity_timeout(password_manager) - pause() - elif choice == "13": - password_manager.lock_vault() - print(colored("Vault locked. Please re-enter your password.", "yellow")) - password_manager.unlock_vault() - password_manager.start_background_sync() - getattr(password_manager, "start_background_relay_check", lambda: None)() - pause() - elif choice == "14": - handle_display_stats(password_manager) - elif choice == "15": - handle_toggle_secret_mode(password_manager) - pause() - elif choice == "16": - handle_toggle_offline_mode(password_manager) - pause() - elif choice == "17": - handle_toggle_quick_unlock(password_manager) - pause() - elif not choice: - break - else: - print(colored("Invalid choice.", "red")) def display_menu( @@ -1109,7 +1182,25 @@ def display_menu( if time.time() - password_manager.last_activity > inactivity_timeout: print(colored("Session timed out. Vault locked.", "yellow")) password_manager.lock_vault() - password_manager.unlock_vault() + try: + password_manager.unlock_vault() + except PasswordPromptError as exc: + print( + colored( + f"Vault remains locked: {exc}. Return to menu to retry unlock.", + "yellow", + ) + ) + continue + except Exception as exc: + logging.error("Failed to unlock after timeout: %s", exc, exc_info=True) + print( + colored( + f"Unable to unlock vault after timeout: {exc}", + "red", + ) + ) + continue password_manager.start_background_sync() getattr(password_manager, "start_background_relay_check", lambda: None)() continue @@ -1137,7 +1228,25 @@ def display_menu( except TimeoutError: print(colored("Session timed out. Vault locked.", "yellow")) password_manager.lock_vault() - password_manager.unlock_vault() + try: + password_manager.unlock_vault() + except PasswordPromptError as exc: + print( + colored( + f"Vault remains locked: {exc}. Return to menu to retry unlock.", + "yellow", + ) + ) + continue + except Exception as exc: + logging.error("Failed to unlock after timeout: %s", exc, exc_info=True) + print( + colored( + f"Unable to unlock vault after timeout: {exc}", + "red", + ) + ) + continue password_manager.start_background_sync() getattr(password_manager, "start_background_relay_check", lambda: None)() continue @@ -1151,87 +1260,101 @@ def display_menu( getattr(password_manager, "cleanup", lambda: None)() _safe_close_client_pool(password_manager) sys.exit(0) - if choice == "1": - while True: - fp, parent_fp, child_fp = getattr( - password_manager, - "header_fingerprint_args", - ( - getattr(password_manager, "current_fingerprint", None), - None, - None, - ), - ) - clear_header_with_notification( - fp, - "Main Menu > Add Entry", - parent_fingerprint=parent_fp, - child_fingerprint=child_fp, - ) - print(color_text("\nAdd Entry:", "menu")) - print(color_text("1. Password", "menu")) - print(color_text("2. 2FA (TOTP)", "menu")) - print(color_text("3. SSH Key", "menu")) - print(color_text("4. Seed Phrase", "menu")) - print(color_text("5. Nostr Key Pair", "menu")) - print(color_text("6. PGP Key", "menu")) - print(color_text("7. Key/Value", "menu")) - print(color_text("8. Managed Account", "menu")) - sub_choice = input( - "Select entry type or press Enter to go back: " - ).strip() + try: + if choice == "1": + while True: + fp, parent_fp, child_fp = getattr( + password_manager, + "header_fingerprint_args", + ( + getattr(password_manager, "current_fingerprint", None), + None, + None, + ), + ) + clear_header_with_notification( + password_manager, + fp, + "Main Menu > Add Entry", + parent_fingerprint=parent_fp, + child_fingerprint=child_fp, + ) + print(color_text("\nAdd Entry:", "menu")) + print(color_text("1. Password", "menu")) + print(color_text("2. 2FA (TOTP)", "menu")) + print(color_text("3. SSH Key", "menu")) + print(color_text("4. Seed Phrase", "menu")) + print(color_text("5. Nostr Key Pair", "menu")) + print(color_text("6. PGP Key", "menu")) + print(color_text("7. Key/Value", "menu")) + print(color_text("8. Managed Account", "menu")) + sub_choice = input( + "Select entry type or press Enter to go back: " + ).strip() + password_manager.update_activity() + if sub_choice == "1": + password_manager.handle_add_password() + break + elif sub_choice == "2": + password_manager.handle_add_totp() + break + elif sub_choice == "3": + password_manager.handle_add_ssh_key() + break + elif sub_choice == "4": + password_manager.handle_add_seed() + break + elif sub_choice == "5": + password_manager.handle_add_nostr_key() + break + elif sub_choice == "6": + password_manager.handle_add_pgp() + break + elif sub_choice == "7": + password_manager.handle_add_key_value() + break + elif sub_choice == "8": + password_manager.handle_add_managed_account() + break + elif not sub_choice: + break + else: + print(colored("Invalid choice.", "red")) + elif choice == "2": password_manager.update_activity() - if sub_choice == "1": - password_manager.handle_add_password() - break - elif sub_choice == "2": - password_manager.handle_add_totp() - break - elif sub_choice == "3": - password_manager.handle_add_ssh_key() - break - elif sub_choice == "4": - password_manager.handle_add_seed() - break - elif sub_choice == "5": - password_manager.handle_add_nostr_key() - break - elif sub_choice == "6": - password_manager.handle_add_pgp() - break - elif sub_choice == "7": - password_manager.handle_add_key_value() - break - elif sub_choice == "8": - password_manager.handle_add_managed_account() - break - elif not sub_choice: - break - else: - print(colored("Invalid choice.", "red")) - elif choice == "2": - password_manager.update_activity() - password_manager.handle_retrieve_entry() - elif choice == "3": - password_manager.update_activity() - password_manager.handle_search_entries() - elif choice == "4": - password_manager.update_activity() - password_manager.handle_list_entries() - elif choice == "5": - password_manager.update_activity() - password_manager.handle_modify_entry() - elif choice == "6": - password_manager.update_activity() - password_manager.handle_display_totp_codes() - elif choice == "7": - password_manager.update_activity() - handle_settings(password_manager) - elif choice == "8": - password_manager.update_activity() - password_manager.handle_view_archived_entries() - else: - print(colored("Invalid choice. Please select a valid option.", "red")) + password_manager.handle_retrieve_entry() + elif choice == "3": + password_manager.update_activity() + password_manager.handle_search_entries() + elif choice == "4": + password_manager.update_activity() + password_manager.handle_list_entries() + elif choice == "5": + password_manager.update_activity() + password_manager.handle_modify_entry() + elif choice == "6": + password_manager.update_activity() + password_manager.handle_display_totp_codes() + elif choice == "7": + password_manager.update_activity() + handle_settings(password_manager) + elif choice == "8": + password_manager.update_activity() + password_manager.handle_view_archived_entries() + else: + print(colored("Invalid choice. Please select a valid option.", "red")) + except PasswordPromptError as exc: + logging.warning("Action cancelled by user: %s", exc) + print(colored(f"Action cancelled: {exc}", "yellow")) + pause() + except SeedPassError as exc: + logging.error("Menu action failed: %s", exc, exc_info=True) + print(colored(f"Action failed: {exc}", "red")) + pause() + except Exception as exc: + logging.error("Unhandled menu action error: %s", exc, exc_info=True) + print(colored(f"Unexpected menu error: {exc}", "red")) + pause() def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> int: @@ -1353,67 +1476,98 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in password_manager.deterministic_totp = True if args.command == "export": - password_manager.handle_export_database( - Path(args.file), encrypt=not args.unencrypted - ) - return 0 - if args.command == "import": - password_manager.handle_import_database(Path(args.file)) - return 0 - if args.command == "search": - matches = password_manager.entry_manager.search_entries(args.query) - if matches: - print_matches(password_manager, matches) - else: - print(colored("No matching entries found.", "yellow")) - return 0 - if args.command == "get": - matches = password_manager.entry_manager.search_entries(args.query) - if len(matches) != 1: - if not matches: - print(colored("No matching entries found.", "yellow")) - else: - print_matches(password_manager, matches) + if not args.file: + print(colored("Error: --file is required for export.", "red")) return 1 - idx = matches[0][0] - entry = password_manager.entry_manager.retrieve_entry(idx) - if entry.get("type", EntryType.PASSWORD.value) != EntryType.PASSWORD.value: - print(colored("Entry is not a password entry.", "red")) - return 1 - length = int(entry.get("length", 0)) - pw = password_manager.password_generator.generate_password(length, idx) - print(pw) - return 0 - if args.command == "totp": - matches = password_manager.entry_manager.search_entries(args.query) - if len(matches) != 1: - if not matches: - print(colored("No matching entries found.", "yellow")) - else: - print_matches(password_manager, matches) - return 1 - idx = matches[0][0] - entry = password_manager.entry_manager.retrieve_entry(idx) - if entry.get("type") != EntryType.TOTP.value: - print(colored("Entry is not a TOTP entry.", "red")) - return 1 - key = getattr(password_manager, "KEY_TOTP_DET", None) or getattr( - password_manager, "parent_seed", None - ) - code = password_manager.entry_manager.get_totp_code(idx, key) - print(code) try: - if copy_to_clipboard(code, password_manager.clipboard_clear_delay): - print(colored("Code copied to clipboard", "green")) - except ClipboardUnavailableError as exc: - print( - colored( - f"Clipboard unavailable: {exc}\n" - "Re-run with '--no-clipboard' to print codes instead.", - "yellow", - ) + password_manager.handle_export_database( + Path(args.file), encrypt=not args.unencrypted ) - return 0 + return 0 + except Exception as exc: + logger.error("Export command failed: %s", exc, exc_info=True) + print(colored(f"Error: Export failed: {exc}", "red")) + return 1 + if args.command == "import": + if not args.file: + print(colored("Error: --file is required for import.", "red")) + return 1 + try: + password_manager.handle_import_database(Path(args.file)) + return 0 + except Exception as exc: + logger.error("Import command failed: %s", exc, exc_info=True) + print(colored(f"Error: Import failed: {exc}", "red")) + return 1 + if args.command == "search": + try: + matches = password_manager.entry_manager.search_entries(args.query) + if matches: + print_matches(password_manager, matches) + else: + print(colored("No matching entries found.", "yellow")) + return 0 + except Exception as exc: + logger.error("Search command failed: %s", exc, exc_info=True) + print(colored(f"Error: Search failed: {exc}", "red")) + return 1 + if args.command == "get": + try: + matches = password_manager.entry_manager.search_entries(args.query) + if len(matches) != 1: + if not matches: + print(colored("No matching entries found.", "yellow")) + else: + print_matches(password_manager, matches) + return 1 + idx = matches[0][0] + entry = password_manager.entry_manager.retrieve_entry(idx) + if entry.get("type", EntryType.PASSWORD.value) != EntryType.PASSWORD.value: + print(colored("Entry is not a password entry.", "red")) + return 1 + length = int(entry.get("length", 0)) + pw = password_manager.password_generator.generate_password(length, idx) + print(pw) + return 0 + except Exception as exc: + logger.error("Get command failed: %s", exc, exc_info=True) + print(colored(f"Error: Get failed: {exc}", "red")) + return 1 + if args.command == "totp": + try: + matches = password_manager.entry_manager.search_entries(args.query) + if len(matches) != 1: + if not matches: + print(colored("No matching entries found.", "yellow")) + else: + print_matches(password_manager, matches) + return 1 + idx = matches[0][0] + entry = password_manager.entry_manager.retrieve_entry(idx) + if entry.get("type") != EntryType.TOTP.value: + print(colored("Entry is not a TOTP entry.", "red")) + return 1 + key = getattr(password_manager, "KEY_TOTP_DET", None) or getattr( + password_manager, "parent_seed", None + ) + code = password_manager.entry_manager.get_totp_code(idx, key) + print(code) + try: + if copy_to_clipboard(code, password_manager.clipboard_clear_delay): + print(colored("Code copied to clipboard", "green")) + except ClipboardUnavailableError as exc: + print( + colored( + f"Clipboard unavailable: {exc}\n" + "Re-run with '--no-clipboard' to print codes instead.", + "yellow", + ) + ) + return 0 + except Exception as exc: + logger.error("TOTP command failed: %s", exc, exc_info=True) + print(colored(f"Error: TOTP retrieval failed: {exc}", "red")) + return 1 def signal_handler(sig, _frame): print(colored("\nReceived shutdown signal. Exiting gracefully...", "yellow")) diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index 499a3666..dd02f64b 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -665,37 +665,65 @@ class PasswordManager: try: fingerprints = self.fingerprint_manager.list_fingerprints() current = self.fingerprint_manager.current_fingerprint + while True: + # Auto-select when only one fingerprint exists + if len(fingerprints) == 1 and self.select_fingerprint(fingerprints[0]): + return - # Auto-select when only one fingerprint exists - if len(fingerprints) == 1: - self.select_fingerprint(fingerprints[0]) - return + print(colored("\nAvailable Seed Profiles:", "cyan")) + for idx, fp in enumerate(fingerprints, start=1): + label = ( + self.fingerprint_manager.display_name(fp) + if hasattr(self.fingerprint_manager, "display_name") + else fp + ) + marker = " *" if fp == current else "" + print(colored(f"{idx}. {label}{marker}", "cyan")) - print(colored("\nAvailable Seed Profiles:", "cyan")) - for idx, fp in enumerate(fingerprints, start=1): - label = ( - self.fingerprint_manager.display_name(fp) - if hasattr(self.fingerprint_manager, "display_name") - else fp + add_idx = len(fingerprints) + 1 + recover_idx = len(fingerprints) + 2 + print(colored(f"{add_idx}. Add a new seed profile", "cyan")) + print( + colored( + f"{recover_idx}. Recover existing profile with blank local index", + "cyan", + ) ) - marker = " *" if fp == current else "" - print(colored(f"{idx}. {label}{marker}", "cyan")) + print(colored("Q. Exit", "cyan")) - print(colored(f"{len(fingerprints)+1}. Add a new seed profile", "cyan")) + choice = input("Select a seed profile by number: ").strip() + if choice.lower() in {"q", "quit", "exit"}: + raise SeedPassError("Operation cancelled by user") + if not choice.isdigit() or not ( + 1 <= int(choice) <= len(fingerprints) + 2 + ): + print(colored("Invalid selection. Please try again.", "red")) + continue - choice = input("Select a seed profile by number: ").strip() - if not choice.isdigit() or not (1 <= int(choice) <= len(fingerprints) + 1): - print(colored("Invalid selection. Exiting.", "red")) - raise SeedPassError("Invalid selection.") + choice_num = int(choice) + if choice_num == add_idx: + # Add a new seed profile + self.add_new_fingerprint() + if getattr(self, "encryption_manager", None) is not None: + return + elif choice_num == recover_idx: + if self.recover_profile_with_blank_index(): + return + else: + # Select existing seed profile + selected_fingerprint = fingerprints[choice_num - 1] + if self.select_fingerprint(selected_fingerprint): + return + print( + colored( + "Profile unlock failed or cancelled. You can retry or use recovery.", + "yellow", + ) + ) - choice = int(choice) - if choice == len(fingerprints) + 1: - # Add a new seed profile - self.add_new_fingerprint() - else: - # Select existing seed profile - selected_fingerprint = fingerprints[choice - 1] - self.select_fingerprint(selected_fingerprint) + # refresh profile list if new profiles were added/removed + fingerprints = self.fingerprint_manager.list_fingerprints() + current = self.fingerprint_manager.current_fingerprint except Exception as e: logger.error(f"Error during seed profile selection: {e}", exc_info=True) @@ -739,7 +767,13 @@ class PasswordManager: # Ensure managers are initialized for the newly created profile if getattr(self, "config_manager", None) is None: - self.initialize_managers() + if getattr(self, "encryption_manager", None) is None: + if hasattr(self.fingerprint_manager, "select_fingerprint"): + self.select_fingerprint(fingerprint) + else: + self.initialize_managers() + else: + self.initialize_managers() return fingerprint @@ -750,7 +784,7 @@ class PasswordManager: def select_fingerprint( self, fingerprint: str, *, password: Optional[str] = None - ) -> None: + ) -> bool: if self.fingerprint_manager.select_fingerprint(fingerprint): self.current_fingerprint = fingerprint # Add this line self.fingerprint_dir = ( @@ -767,7 +801,12 @@ class PasswordManager: f"Seed profile directory for {fingerprint} not found." ) # Setup the encryption manager and load parent seed - self.setup_encryption_manager(self.fingerprint_dir, password) + if not self.setup_encryption_manager( + self.fingerprint_dir, + password, + exit_on_fail=False, + ): + return False # Initialize BIP85 and other managers self.initialize_bip85() self.initialize_managers() @@ -777,10 +816,131 @@ class PasswordManager: "green", ) ) + return True else: print(colored(f"Error: Seed profile {fingerprint} not found.", "red")) raise SeedPassError(f"Seed profile {fingerprint} not found.") + def recover_profile_with_blank_index(self) -> bool: + """Reinitialize an existing profile with the same seed and blank local index.""" + fingerprints = self.fingerprint_manager.list_fingerprints() + if not fingerprints: + print(colored("No existing profiles available for recovery.", "red")) + return False + + print(colored("\nRecover Existing Profile", "cyan")) + for idx, fp in enumerate(fingerprints, start=1): + label = ( + self.fingerprint_manager.display_name(fp) + if hasattr(self.fingerprint_manager, "display_name") + else fp + ) + print(colored(f"{idx}. {label}", "cyan")) + + choice = input("Select profile to recover by number: ").strip() + if not choice.isdigit() or not (1 <= int(choice) <= len(fingerprints)): + print(colored("Invalid selection.", "red")) + return False + fingerprint = fingerprints[int(choice) - 1] + fingerprint_dir = self.fingerprint_manager.get_fingerprint_directory( + fingerprint + ) + if not fingerprint_dir: + print(colored("Profile directory not found.", "red")) + return False + + print( + colored( + "This will overwrite the encrypted seed file and reset local entries to a blank index.", + "yellow", + ) + ) + if not confirm_action("Continue recovery for this profile? (Y/N): "): + return False + + method = input( + "Seed entry method:\n" + "1. Paste in an existing seed in full\n" + "2. Enter existing seed one word at a time\n" + "Enter choice (1/2): " + ).strip() + if method == "1": + parent_seed = masked_input("Enter your 12-word BIP-85 seed: ").strip() + elif method == "2": + parent_seed = prompt_seed_words() + else: + print(colored("Invalid choice.", "red")) + return False + + if not self.validate_bip85_seed(parent_seed): + print(colored("Error: Invalid BIP-85 seed phrase.", "red")) + return False + + computed_fp = generate_fingerprint(parent_seed) + if computed_fp != fingerprint: + print( + colored( + "Error: Seed does not match selected profile fingerprint.", + "red", + ) + ) + return False + + try: + password = prompt_for_password() + self.current_fingerprint = fingerprint + self.fingerprint_manager.current_fingerprint = fingerprint + self.fingerprint_dir = fingerprint_dir + self.parent_seed = parent_seed + + seed_bytes = Bip39SeedGenerator(parent_seed).Generate() + self.derive_key_hierarchy(seed_bytes) + index_key = base64.urlsafe_b64encode(self.KEY_STORAGE) + seed_key = self._derive_seed_key(password, fingerprint) + + self.encryption_manager = EncryptionManager(index_key, fingerprint_dir) + seed_mgr = EncryptionManager(seed_key, fingerprint_dir) + self.vault = Vault(self.encryption_manager, fingerprint_dir) + self.config_manager = ConfigManager( + vault=self.vault, + fingerprint_dir=fingerprint_dir, + ) + + seed_mgr.encrypt_parent_seed( + parent_seed, + kdf=self._build_seed_kdf_config( + fingerprint, + mode=self._get_kdf_mode(), + ), + ) + self.store_hashed_password(password) + + for stale_file in ( + fingerprint_dir / "seedpass_entries_db.json.enc", + fingerprint_dir / "seedpass_passwords_db.json.enc", + fingerprint_dir / "seedpass_entries_db_checksum.txt", + fingerprint_dir / "seedpass_passwords_db_checksum.txt", + ): + try: + stale_file.unlink() + except FileNotFoundError: + pass + + self.initialize_bip85() + self.initialize_managers() + self.start_background_sync() + print( + colored( + "Profile recovered successfully with a blank local index.", + "green", + ) + ) + return True + except Exception as exc: + logger.error("Profile recovery failed: %s", exc, exc_info=True) + print(colored(f"Error: Profile recovery failed: {exc}", "red")) + return False + def setup_encryption_manager( self, fingerprint_dir: Path, diff --git a/src/seedpass/release_integrity.py b/src/seedpass/release_integrity.py new file mode 100644 index 00000000..e452b283 --- /dev/null +++ b/src/seedpass/release_integrity.py @@ -0,0 +1,96 @@ +"""Utilities for release checksum generation and verification.""" + +from __future__ import annotations + +import hashlib +from pathlib import Path +from typing import Iterable + + +def _file_sha256(file_path: Path) -> str: + hasher = hashlib.sha256() + with file_path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + hasher.update(chunk) + return hasher.hexdigest() + + +def collect_artifacts(paths: Iterable[Path]) -> list[Path]: + """Return deterministic, deduplicated file list for checksum operations.""" + collected: list[Path] = [] + for path in paths: + candidate = path.expanduser().resolve() + if not candidate.exists(): + raise FileNotFoundError(f"Artifact path does not exist: {path}") + if candidate.is_file(): + collected.append(candidate) + continue + if candidate.is_dir(): + for artifact in sorted(p for p in candidate.rglob("*") if p.is_file()): + collected.append(artifact.resolve()) + + unique: dict[str, Path] = {} + for artifact in sorted(collected, key=lambda p: str(p)): + unique[str(artifact)] = artifact + return list(unique.values()) + + +def generate_checksums( + artifacts: Iterable[Path], base_dir: Path | None = None +) -> list[str]: + """Generate SHA256SUMS-compatible lines for provided artifacts.""" + resolved_artifacts = collect_artifacts(artifacts) + root = base_dir.resolve() if base_dir else None + lines: list[str] = [] + for artifact in resolved_artifacts: + digest = _file_sha256(artifact) + display_path = ( + artifact.relative_to(root).as_posix() + if root and artifact.is_relative_to(root) + else artifact.name + ) + lines.append(f"{digest} {display_path}") + return lines + + +def parse_checksum_line(line: str) -> tuple[str, str]: + """Parse a SHA256SUMS line into digest and path.""" + cleaned = line.strip() + if not cleaned: + raise ValueError("Checksum line cannot be empty") + if " " not in cleaned: + raise ValueError(f"Checksum line is malformed: {line!r}") + digest, path = cleaned.split(" ", 1) + digest = digest.strip() + path = path.strip() + if len(digest) != 64 or any(ch not in "0123456789abcdef" for ch in digest): + raise ValueError(f"Invalid SHA-256 digest: {digest!r}") + if not path: + raise ValueError("Checksum path cannot be empty") + return digest, path + + +def verify_checksums( + checksum_file: Path, base_dir: Path | None = None +) -> tuple[bool, list[str]]: + """Verify checksums listed in checksum_file against files in base_dir/current dir.""" + root = base_dir.resolve() if base_dir else Path.cwd().resolve() + failures: list[str] = [] + for idx, line in enumerate( + checksum_file.read_text(encoding="utf-8").splitlines(), start=1 + ): + if not line.strip(): + continue + try: + expected, rel_path = parse_checksum_line(line) + except ValueError as exc: + failures.append(f"Line {idx}: {exc}") + continue + target = (root / rel_path).resolve() + if not target.exists(): + failures.append(f"Line {idx}: missing file {rel_path}") + continue + actual = _file_sha256(target) + if actual != expected: + failures.append(f"Line {idx}: checksum mismatch for {rel_path}") + return (len(failures) == 0, failures) diff --git a/src/tests/test_cli_export_import.py b/src/tests/test_cli_export_import.py index 7b1a53ee..b4008cef 100644 --- a/src/tests/test_cli_export_import.py +++ b/src/tests/test_cli_export_import.py @@ -124,3 +124,36 @@ def test_cli_export_import_unencrypted(monkeypatch, tmp_path): rc = main.main(["import", "--file", str(export_path)]) assert rc == 0 assert vault.load_index() == data + + +def test_cli_export_requires_file(monkeypatch, tmp_path, capsys): + pm, _vault = _setup_pm(tmp_path) + monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm) + monkeypatch.setattr(main, "configure_logging", lambda: None) + monkeypatch.setattr(main, "initialize_app", lambda: None) + monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None) + + rc = main.main(["export"]) + out = capsys.readouterr().out + assert rc == 1 + assert "--file is required for export" in out + + +def test_cli_import_handler_exception_returns_error(monkeypatch, tmp_path, capsys): + pm, _vault = _setup_pm(tmp_path) + pm = SimpleNamespace( + handle_export_database=pm.handle_export_database, + handle_import_database=lambda _p: (_ for _ in ()).throw( + RuntimeError("bad import") + ), + nostr_client=SimpleNamespace(close_client_pool=lambda: None), + ) + monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm) + monkeypatch.setattr(main, "configure_logging", lambda: None) + monkeypatch.setattr(main, "initialize_app", lambda: None) + monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None) + + rc = main.main(["import", "--file", str(tmp_path / "in.json")]) + out = capsys.readouterr().out + assert rc == 1 + assert "Error: Import failed: bad import" in out diff --git a/src/tests/test_cli_invalid_input.py b/src/tests/test_cli_invalid_input.py index 0882df4d..30c72732 100644 --- a/src/tests/test_cli_invalid_input.py +++ b/src/tests/test_cli_invalid_input.py @@ -8,6 +8,8 @@ import pytest sys.path.append(str(Path(__file__).resolve().parents[1])) import main +from seedpass.core.errors import SeedPassError +from utils.password_prompt import PasswordPromptError def _make_pm(called, locked=None): @@ -102,3 +104,65 @@ def test_inactivity_timeout_loop(monkeypatch, capsys): assert locked["lock"] == 1 assert locked["unlock"] == 1 assert not any(called.values()) + + +def test_inactivity_timeout_unlock_cancelled_stays_in_menu(monkeypatch, capsys): + called = {"add": False, "retrieve": False, "modify": False} + pm, locked = _make_pm(called) + pm.last_activity = 0 + + def unlock_fail(): + locked["unlock"] += 1 + pm.last_activity = 100.0 + raise PasswordPromptError("Operation cancelled by user") + + pm.unlock_vault = unlock_fail + + # First iteration: timeout + cancelled unlock. Second: exit cleanly. + now = iter([100.0, 100.0, 100.0]) + monkeypatch.setattr(time, "time", lambda: next(now)) + monkeypatch.setattr(main, "timed_input", lambda *_: "") + + with pytest.raises(SystemExit): + main.display_menu(pm, sync_interval=1000, inactivity_timeout=0.1) + + out = capsys.readouterr().out + assert "Vault remains locked: Operation cancelled by user" in out + assert locked["lock"] == 1 + assert locked["unlock"] == 1 + + +def test_menu_action_seedpass_error_does_not_crash(monkeypatch, capsys): + called = {"add": False, "retrieve": False, "modify": False} + pm, _ = _make_pm(called) + + def fail_retrieve(): + raise SeedPassError("simulated retrieve failure") + + pm.handle_retrieve_entry = fail_retrieve + inputs = iter(["2", ""]) + monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) + + with pytest.raises(SystemExit): + main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000) + + out = capsys.readouterr().out + assert "Action failed: simulated retrieve failure" in out + + +def test_menu_action_password_prompt_error_does_not_crash(monkeypatch, capsys): + called = {"add": False, "retrieve": False, "modify": False} + pm, _ = _make_pm(called) + + def fail_retrieve(): + raise PasswordPromptError("Operation cancelled by user") + + pm.handle_retrieve_entry = fail_retrieve + inputs = iter(["2", ""]) + monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) + + with pytest.raises(SystemExit): + main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000) + + out = capsys.readouterr().out + assert "Action cancelled: Operation cancelled by user" in out diff --git a/src/tests/test_deterministic_artifact_regression.py b/src/tests/test_deterministic_artifact_regression.py new file mode 100644 index 00000000..2615ac9d --- /dev/null +++ b/src/tests/test_deterministic_artifact_regression.py @@ -0,0 +1,179 @@ +import hashlib +from dataclasses import dataclass + +from bip_utils import Bip39SeedGenerator +from local_bip85.bip85 import BIP85 +from nostr.coincurve_keys import Keys + +from seedpass.core.password_generation import ( + PasswordGenerator, + derive_pgp_key, + derive_seed_phrase, + derive_ssh_key_pair, +) +from seedpass.core.totp import TotpManager + +TEST_SEED = ( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon " + "abandon abandon about" +) + + +@dataclass(frozen=True) +class DeterministicVector: + index: int + password: str + ssh_priv_sha256: str + ssh_pub_sha256: str + seed24: str + totp_secret: str + totp_code_at_1700000000: str + npub: str + nsec: str + + +VECTORS = ( + DeterministicVector( + index=0, + password="Ti+q<_O4dcO07h1r#Q(C]R10", + ssh_priv_sha256="f30df9cf394783bd2c8f33cacf147344032d04ab60184abc1b09286b845461c0", + ssh_pub_sha256="8e8e0b8d2662c6bf47e2a03fc0d6c191744aa343df14b7d506c566d14347547c", + seed24=( + "stick exact spice sock filter ginger museum horse kit multiply " + "manual wear grief demand derive alert quiz fault december lava " + "picture immune decade jaguar" + ), + totp_secret="DRUATVCMCPFH4EEQXM5FEJZMU3BUTQ47", + totp_code_at_1700000000="919492", + npub="npub1xw0hg33pyty45095rg27pqhde0pl6guu2rremsszedtm254hrqlqnh0zc3", + nsec="nsec18ggmuuvurkq6p2mrp25mhlzzt3trenmw8tpnth3fmt69mrq06grs3duayu", + ), + DeterministicVector( + index=1, + password='B1O"C2D813Gm:]b"gm7>q/xX', + ssh_priv_sha256="0410faeb273218505f4c9cfd23acf59a54d03fade4162bf8db01425c71d2a620", + ssh_pub_sha256="f4db960980a446333b6af8fe25a196e4c5e808d5c163484e67fb55a7d6665a07", + seed24=( + "trade clock mom turtle clutch love surge truth bus reward hover " + "truck palm paddle fossil near group cactus alley gas borrow " + "amateur learn leisure" + ), + totp_secret="CLQCBCRIBIBSJUBKQVSLUUQIFZBNDHOG", + totp_code_at_1700000000="125654", + npub="npub176503mwgzhknlyd02kyz2qxc2umpzj9em0pceqvmpk658vdxgjfsf8zh8y", + nsec="nsec1gg9jjq7q64uwzz9g3dl6pn0f7n9csrzrk6hthamzchat5ahs4u2qhhtper", + ), + DeterministicVector( + index=352, + password="$oEEz0$606DE2g9o(%$wH;Uu", + ssh_priv_sha256="076b0eece3b5cb0ff3dec8c654fd5e47a53493597403a2211aa3b077b2c097aa", + ssh_pub_sha256="271a2934cf3739356fb86ce916f1b0463dff917fb97d2fadc8ef712be24db0ac", + seed24=( + "scrub please antenna tower shoulder oven public cinnamon catch " + "wrong excuse afford infant open fetch diet badge detail private " + "produce nuclear issue bottom pyramid" + ), + totp_secret="W7JJRQUQ7XSAJ4GCRKABXMN5GNWIF4S5", + totp_code_at_1700000000="716409", + npub="npub1hyz0p8386nq9u57rxwtxpqnc4p3ma32lqt8ecmws8q8yfjndkszspgeusn", + nsec="nsec12stv7u29f26ltzety2czeg38fgydsyc23hcfakcawgd8wccrs7zqat7a9v", + ), + DeterministicVector( + index=1024, + password="hO59JB_em--Do1)H1y90$n[O", + ssh_priv_sha256="553ff8f9849e7601b01b1f60f20edb9a6f196be46ef12dc55211ea104464d142", + ssh_pub_sha256="0262cb6a35b98a55976b31e98d4e6e2f1b75558d488f00f24f2c86019fe133f2", + seed24=( + "dove empty shadow speak outside cram account venture please aware " + "short slim neglect endless say cluster garbage lemon betray " + "arrive rookie display purpose soda" + ), + totp_secret="J67YM5ZPJV464YI7NQEAEJYVOP65DSRO", + totp_code_at_1700000000="642229", + npub="npub1l9hfe5f5akx8fa7tzyqa0a6z3fgven558cd8382nep346hmqnwysalt4qx", + nsec="nsec1pja266pu7umwtg9lzuz02etxjzgx7v8nf3r2247el6g0rv75pyrqjznlqu", + ), +) + + +class _MnemonicSeedEncoder: + """Minimal shim to satisfy PasswordGenerator dependency contract.""" + + @staticmethod + def derive_seed_from_mnemonic(mnemonic: str) -> bytes: + return Bip39SeedGenerator(mnemonic).Generate() + + +def _build_password_generator(seed_phrase: str) -> PasswordGenerator: + seed_bytes = Bip39SeedGenerator(seed_phrase).Generate() + bip85 = BIP85(seed_bytes) + return PasswordGenerator(_MnemonicSeedEncoder(), seed_phrase, bip85) + + +def test_deterministic_vectors_index_352_regression(): + """Lock deterministic outputs so derivation drift fails CI immediately.""" + seed_bytes = Bip39SeedGenerator(TEST_SEED).Generate() + bip85 = BIP85(seed_bytes) + generator = _build_password_generator(TEST_SEED) + + for vector in VECTORS: + password = generator.generate_password(length=24, index=vector.index) + ssh_priv, ssh_pub = derive_ssh_key_pair(TEST_SEED, vector.index) + seed24 = derive_seed_phrase(bip85, vector.index, 24) + totp_secret = TotpManager.derive_secret(TEST_SEED, vector.index) + totp_code = TotpManager.current_code( + TEST_SEED, vector.index, timestamp=1700000000 + ) + + entropy = bip85.derive_entropy(index=vector.index, entropy_bytes=32) + nostr_keys = Keys(priv_k=entropy.hex()) + npub = Keys.hex_to_bech32(nostr_keys.public_key_hex(), "npub") + nsec = Keys.hex_to_bech32(nostr_keys.private_key_hex(), "nsec") + + assert password == vector.password + assert seed24 == vector.seed24 + assert totp_secret == vector.totp_secret + assert totp_code == vector.totp_code_at_1700000000 + assert npub == vector.npub + assert nsec == vector.nsec + assert ( + hashlib.sha256(ssh_priv.encode("utf-8")).hexdigest() + == vector.ssh_priv_sha256 + ) + assert ( + hashlib.sha256(ssh_pub.encode("utf-8")).hexdigest() == vector.ssh_pub_sha256 + ) + + pgp_key, pgp_fp = derive_pgp_key(bip85, 352, "ed25519", "SeedPass Regression") + assert pgp_fp == "46A5AEB6797495ABCDC91E5687DC058A9B6AF8FA" + assert ( + hashlib.sha256(pgp_key.encode("utf-8")).hexdigest() + == "8e5e8128422901a7c3b1655f416ede4c1570470bef7e0f90c33adf743b7191ae" + ) + + # Pin deterministic RSA behavior at one known vector too. + rsa_key, rsa_fp = derive_pgp_key(bip85, 352, "rsa", "SeedPass Regression") + assert rsa_fp == "A04FE91ED24354AC13E25C43677FE463B8C74495" + assert ( + hashlib.sha256(rsa_key.encode("utf-8")).hexdigest() + == "2953b095a9214f29cde0846d5828ff6ea1fa3f431f3116a29bc94b812f1d2d05" + ) + + +def test_repeatability_for_same_seed_and_index(): + """Same seed/index must always produce identical artifacts within a run.""" + generator = _build_password_generator(TEST_SEED) + + for vector in VECTORS: + pw_1 = generator.generate_password(length=24, index=vector.index) + pw_2 = generator.generate_password(length=24, index=vector.index) + assert pw_1 == pw_2 + + ssh_priv_1, ssh_pub_1 = derive_ssh_key_pair(TEST_SEED, vector.index) + ssh_priv_2, ssh_pub_2 = derive_ssh_key_pair(TEST_SEED, vector.index) + assert ssh_priv_1 == ssh_priv_2 + assert ssh_pub_1 == ssh_pub_2 + + totp_secret_1 = TotpManager.derive_secret(TEST_SEED, vector.index) + totp_secret_2 = TotpManager.derive_secret(TEST_SEED, vector.index) + assert totp_secret_1 == totp_secret_2 diff --git a/src/tests/test_profile_recovery_flow.py b/src/tests/test_profile_recovery_flow.py new file mode 100644 index 00000000..11f49f70 --- /dev/null +++ b/src/tests/test_profile_recovery_flow.py @@ -0,0 +1,88 @@ +from pathlib import Path +from types import SimpleNamespace + +import seedpass.core.manager as manager_module +from helpers import TEST_SEED +from utils.fingerprint import generate_fingerprint + + +def test_add_new_fingerprint_switch_existing_selects_profile(monkeypatch, tmp_path): + pm = manager_module.PasswordManager.__new__(manager_module.PasswordManager) + fingerprint = generate_fingerprint(TEST_SEED) + pm.fingerprint_manager = SimpleNamespace( + current_fingerprint=None, + select_fingerprint=lambda fp: True, + ) + pm.config_manager = None + pm.encryption_manager = None + + monkeypatch.setattr( + manager_module.PasswordManager, + "setup_existing_seed", + lambda self, *a, **k: fingerprint, + ) + monkeypatch.setattr("builtins.input", lambda *_a, **_k: "1") + + called = {} + + def fake_select(self, fp, password=None): + called["fp"] = fp + self.encryption_manager = object() + return True + + monkeypatch.setattr( + manager_module.PasswordManager, "select_fingerprint", fake_select + ) + + result = pm.add_new_fingerprint() + + assert result == fingerprint + assert called["fp"] == fingerprint + + +def test_select_fingerprint_returns_false_on_unlock_failure(monkeypatch, tmp_path): + pm = manager_module.PasswordManager.__new__(manager_module.PasswordManager) + fingerprint = "ABCDEF1234567890" + fingerprint_dir = Path(tmp_path) / fingerprint + fingerprint_dir.mkdir(parents=True, exist_ok=True) + + pm.fingerprint_manager = SimpleNamespace( + select_fingerprint=lambda fp: fp == fingerprint, + get_current_fingerprint_dir=lambda: fingerprint_dir, + ) + + monkeypatch.setattr( + manager_module.PasswordManager, + "setup_encryption_manager", + lambda self, *a, **k: False, + ) + + assert pm.select_fingerprint(fingerprint) is False + + +def test_recover_profile_with_blank_index_rejects_mismatched_seed( + monkeypatch, tmp_path +): + pm = manager_module.PasswordManager.__new__(manager_module.PasswordManager) + existing_seed = TEST_SEED + existing_fp = generate_fingerprint(existing_seed) + other_seed = ( + "legal winner thank year wave sausage worth useful legal winner thank yellow" + ) + profile_dir = Path(tmp_path) / existing_fp + profile_dir.mkdir(parents=True, exist_ok=True) + + pm.fingerprint_manager = SimpleNamespace( + list_fingerprints=lambda: [existing_fp], + display_name=lambda fp: fp, + get_fingerprint_directory=lambda fp: profile_dir if fp == existing_fp else None, + current_fingerprint=None, + ) + pm.validate_bip85_seed = lambda seed: True + + inputs = iter(["1", "1"]) + monkeypatch.setattr("builtins.input", lambda *_a, **_k: next(inputs)) + monkeypatch.setattr(manager_module, "masked_input", lambda *_a, **_k: other_seed) + monkeypatch.setattr(manager_module, "confirm_action", lambda *_a, **_k: True) + + assert pm.recover_profile_with_blank_index() is False diff --git a/src/tests/test_release_integrity_utils.py b/src/tests/test_release_integrity_utils.py new file mode 100644 index 00000000..94c6bd28 --- /dev/null +++ b/src/tests/test_release_integrity_utils.py @@ -0,0 +1,56 @@ +from pathlib import Path + +from seedpass.release_integrity import ( + generate_checksums, + parse_checksum_line, + verify_checksums, +) + + +def test_generate_checksums_sorts_entries(tmp_path: Path): + (tmp_path / "b.txt").write_text("b", encoding="utf-8") + (tmp_path / "a.txt").write_text("a", encoding="utf-8") + + lines = generate_checksums([tmp_path], base_dir=tmp_path) + + assert len(lines) == 2 + assert lines[0].endswith(" a.txt") + assert lines[1].endswith(" b.txt") + + +def test_parse_checksum_line_rejects_invalid_digest(): + bad_line = "not-a-sha dist/file.whl" + try: + parse_checksum_line(bad_line) + except ValueError as exc: + assert "Invalid SHA-256 digest" in str(exc) + else: + raise AssertionError("Expected parse_checksum_line to fail") + + +def test_verify_checksums_success(tmp_path: Path): + artifact = tmp_path / "file.bin" + artifact.write_bytes(b"seedpass") + checksum_lines = generate_checksums([artifact], base_dir=tmp_path) + checksum_file = tmp_path / "SHA256SUMS" + checksum_file.write_text("\n".join(checksum_lines) + "\n", encoding="utf-8") + + ok, failures = verify_checksums(checksum_file, base_dir=tmp_path) + + assert ok is True + assert failures == [] + + +def test_verify_checksums_detects_mismatch(tmp_path: Path): + artifact = tmp_path / "file.bin" + artifact.write_bytes(b"seedpass") + checksum_lines = generate_checksums([artifact], base_dir=tmp_path) + checksum_file = tmp_path / "SHA256SUMS" + checksum_file.write_text("\n".join(checksum_lines) + "\n", encoding="utf-8") + artifact.write_bytes(b"tampered") + + ok, failures = verify_checksums(checksum_file, base_dir=tmp_path) + + assert ok is False + assert failures + assert "checksum mismatch" in failures[0] diff --git a/src/tests/test_settings_menu.py b/src/tests/test_settings_menu.py index 734a338c..da250e48 100644 --- a/src/tests/test_settings_menu.py +++ b/src/tests/test_settings_menu.py @@ -18,7 +18,9 @@ from nostr.client import DEFAULT_RELAYS from seedpass.core.config_manager import ConfigManager from seedpass.core.manager import Notification, PasswordManager from seedpass.core.vault import Vault +from seedpass.core.errors import SeedPassError from utils.fingerprint_manager import FingerprintManager +from utils.password_prompt import PasswordPromptError def setup_pm(tmp_path, monkeypatch): @@ -170,3 +172,94 @@ def test_settings_menu_without_nostr_client(monkeypatch): assert pm.error_queue.empty() assert pm.notifications.empty() + + +def test_settings_menu_missing_handler_is_graceful(monkeypatch, capsys): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + pm, _, _ = setup_pm(tmp_path, monkeypatch) + # Option 4 calls this method directly in settings. + if hasattr(pm, "handle_verify_checksum"): + delattr(pm, "handle_verify_checksum") + + inputs = iter(["4", ""]) + monkeypatch.setattr(main, "pause", lambda: None) + with patch("builtins.input", side_effect=lambda *_: next(inputs)): + main.handle_settings(pm) + + out = capsys.readouterr().out + assert "Unexpected settings error:" in out + + +def test_settings_lock_unlock_cancelled_is_graceful(monkeypatch, capsys): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + pm, _, _ = setup_pm(tmp_path, monkeypatch) + pm.lock_vault = lambda: None + + def unlock_fail(): + raise PasswordPromptError("Operation cancelled by user") + + pm.unlock_vault = unlock_fail + pm.start_background_sync = lambda: None + pm.start_background_relay_check = lambda: None + + inputs = iter(["13", ""]) + monkeypatch.setattr(main, "pause", lambda: None) + with patch("builtins.input", side_effect=lambda *_: next(inputs)): + main.handle_settings(pm) + + out = capsys.readouterr().out + assert "Unlock cancelled: Operation cancelled by user" in out + + +def test_profiles_submenu_action_error_is_graceful(monkeypatch, capsys): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + pm, _, fp_mgr = setup_pm(tmp_path, monkeypatch) + pm.update_activity = lambda: None + pm.handle_switch_fingerprint = lambda: (_ for _ in ()).throw( + SeedPassError("switch failure") + ) + + inputs = iter(["1", ""]) + monkeypatch.setattr(main, "pause", lambda: None) + with patch("builtins.input", side_effect=lambda *_: next(inputs)): + main.handle_profiles_menu(pm) + + out = capsys.readouterr().out + assert "Action failed: switch failure" in out + + +def test_settings_export_failure_message(monkeypatch, capsys): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + pm, _, _ = setup_pm(tmp_path, monkeypatch) + pm.handle_export_database = lambda: (_ for _ in ()).throw( + RuntimeError("export boom") + ) + + inputs = iter(["7", ""]) + monkeypatch.setattr(main, "pause", lambda: None) + with patch("builtins.input", side_effect=lambda *_: next(inputs)): + main.handle_settings(pm) + + out = capsys.readouterr().out + assert "Export failed: export boom" in out + + +def test_settings_import_missing_path_message(monkeypatch, capsys): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + pm, _, _ = setup_pm(tmp_path, monkeypatch) + pm.handle_import_database = lambda _path: (_ for _ in ()).throw( + FileNotFoundError + ) + + inputs = iter(["8", "missing.json.enc", ""]) + monkeypatch.setattr(main, "pause", lambda: None) + with patch("builtins.input", side_effect=lambda *_: next(inputs)): + main.handle_settings(pm) + + out = capsys.readouterr().out + assert "Import failed: file 'missing.json.enc' not found." in out