Harden TUI recovery paths and add release integrity gates

This commit is contained in:
thePR0M3TH3AN committed 2026-02-25 19:18:54 -05:00
1 parent 1b0d11146e
commit 88c1974ac6
19 files changed
+1524 -266

No files matched your search

+3
View File
@@ -94,6 +94,9 @@ jobs:
- name: Enable Nostr network tests on main branch or nightly - name: Enable Nostr network tests on main branch or nightly
if: github.ref == 'refs/heads/main' || github.event_name == 'schedule' if: github.ref == 'refs/heads/main' || github.event_name == 'schedule'
run: echo "NOSTR_E2E=1" >> $GITHUB_ENV 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 - name: Run tests with coverage
timeout-minutes: 16 timeout-minutes: 16
shell: bash shell: bash
+73
View File
@@ -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
+2
View File
@@ -30,6 +30,8 @@ jobs:
run: | run: |
poetry run pip-audit || echo "::warning::pip-audit found vulnerabilities" poetry run pip-audit || echo "::warning::pip-audit found vulnerabilities"
shell: bash shell: bash
- name: Run deterministic regression tests
run: poetry run pytest -q src/tests/test_deterministic_artifact_regression.py
- name: Run tests with coverage - name: Run tests with coverage
run: | run: |
poetry run coverage run -m pytest poetry run coverage run -m pytest
+2
View File
@@ -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. 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 ## Contributing
Contributions are welcome! If you have suggestions for improvements, bug fixes, or new features, please follow these steps: Contributions are welcome! If you have suggestions for improvements, bug fixes, or new features, please follow these steps:
+9
View File
@@ -4,6 +4,11 @@ This file tracks the remaining work from the latest bug search and security eval
## Security ## 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: - [ ] Upgrade vulnerable runtime dependencies in lockfiles and packaging inputs:
- `aiohttp` -> `>=3.13.3` - `aiohttp` -> `>=3.13.3`
- `cryptography` -> `>=46.0.5` - `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/manager.py`
- `src/seedpass/core/menu_handler.py` - `src/seedpass/core/menu_handler.py`
- [ ] Add telemetry-safe logs for swallowed error paths without exposing secrets. - [ ] 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 ## Tests To Add
+1
View File
@@ -9,6 +9,7 @@ This directory contains SeedPass project documentation.
- `crypto_key_management_review.md` - `crypto_key_management_review.md`
- `secret_handling_local_exposure_review.md` - `secret_handling_local_exposure_review.md`
- `auth_lock_unlock_access_review.md` - `auth_lock_unlock_access_review.md`
- `supply_chain_release_integrity.md`
## Architecture and Product Docs ## Architecture and Product Docs
+1 -1
View File
@@ -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` | | 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` | | 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 | | 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 | | | 9 | Operational readiness and incident runbooks | Not Started | Security / Ops | |
| 10 | External audit and staged production rollout | Not Started | Maintainers | | | 10 | External audit and staged production rollout | Not Started | Maintainers | |
+70
View File
@@ -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.
+53
View File
@@ -6,3 +6,56 @@
## Outcome ## Outcome
- Removed stale Archivox branding from the docs README and default docs title so the correct SeedPass README/content is displayed. - 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`
+122
View File
@@ -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())
+391 -237
View File
@@ -21,6 +21,7 @@ from utils.color_scheme import color_text
import importlib import importlib
from seedpass.core.manager import PasswordManager, restore_backup_index from seedpass.core.manager import PasswordManager, restore_backup_index
from seedpass.core.errors import SeedPassError
from nostr.client import NostrClient from nostr.client import NostrClient
from seedpass.core.entry_types import EntryType from seedpass.core.entry_types import EntryType
from seedpass.core.config_manager import ConfigManager 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), (getattr(password_manager, "current_fingerprint", None), None, None),
) )
clear_header_with_notification( clear_header_with_notification(
password_manager,
fp, fp,
"Main Menu > Settings > Profiles", "Main Menu > Settings > Profiles",
parent_fingerprint=parent_fp, 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")) print(color_text("5. Set Seed Profile Name", "menu"))
choice = input("Select an option or press Enter to go back: ").strip() choice = input("Select an option or press Enter to go back: ").strip()
password_manager.update_activity() password_manager.update_activity()
if choice == "1": try:
if not password_manager.handle_switch_fingerprint(): if choice == "1":
print(colored("Failed to switch seed profile.", "red")) if not password_manager.handle_switch_fingerprint():
elif choice == "2": print(colored("Failed to switch seed profile.", "red"))
handle_add_new_fingerprint(password_manager) elif choice == "2":
elif choice == "3": handle_add_new_fingerprint(password_manager)
handle_remove_fingerprint(password_manager) elif choice == "3":
elif choice == "4": handle_remove_fingerprint(password_manager)
handle_list_fingerprints(password_manager) elif choice == "4":
elif choice == "5": handle_list_fingerprints(password_manager)
handle_set_profile_name(password_manager) elif choice == "5":
elif not choice: handle_set_profile_name(password_manager)
break elif not choice:
else: break
print(colored("Invalid choice.", "red")) 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: 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), (getattr(password_manager, "current_fingerprint", None), None, None),
) )
clear_header_with_notification( clear_header_with_notification(
password_manager,
fp, fp,
"Main Menu > Settings > Nostr", "Main Menu > Settings > Nostr",
parent_fingerprint=parent_fp, 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")) print(color_text("7. Display Nostr Public Key", "menu"))
choice = input("Select an option or press Enter to go back: ").strip() choice = input("Select an option or press Enter to go back: ").strip()
password_manager.update_activity() password_manager.update_activity()
if choice == "1": try:
handle_post_to_nostr(password_manager) if choice == "1":
elif choice == "2": handle_post_to_nostr(password_manager)
handle_retrieve_from_nostr(password_manager) elif choice == "2":
elif choice == "3": handle_retrieve_from_nostr(password_manager)
handle_view_relays(cfg_mgr) elif choice == "3":
elif choice == "4": handle_view_relays(cfg_mgr)
handle_add_relay(password_manager) elif choice == "4":
elif choice == "5": handle_add_relay(password_manager)
handle_remove_relay(password_manager) elif choice == "5":
elif choice == "6": handle_remove_relay(password_manager)
handle_reset_relays(password_manager) elif choice == "6":
elif choice == "7": handle_reset_relays(password_manager)
handle_display_npub(password_manager) elif choice == "7":
elif not choice: handle_display_npub(password_manager)
break elif not choice:
else: break
print(colored("Invalid choice.", "red")) 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: 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), (getattr(password_manager, "current_fingerprint", None), None, None),
) )
clear_header_with_notification( clear_header_with_notification(
password_manager,
fp, fp,
"Main Menu > Settings", "Main Menu > Settings",
parent_fingerprint=parent_fp, 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("16. Toggle Offline Mode (default ON)", "menu"))
print(color_text("17. Toggle Quick Unlock", "menu")) print(color_text("17. Toggle Quick Unlock", "menu"))
choice = input("Select an option or press Enter to go back: ").strip() choice = input("Select an option or press Enter to go back: ").strip()
if choice == "1": try:
handle_profiles_menu(password_manager) if choice == "1":
elif choice == "2": handle_profiles_menu(password_manager)
handle_nostr_menu(password_manager) elif choice == "2":
elif choice == "3": handle_nostr_menu(password_manager)
try: elif choice == "3":
old_pw = prompt_existing_password("Enter your current password: ") try:
new_pw = prompt_new_password() old_pw = prompt_existing_password("Enter your current password: ")
password_manager.change_password(old_pw, new_pw) new_pw = prompt_new_password()
except ValueError: password_manager.change_password(old_pw, new_pw)
print(colored("Incorrect password.", "red")) except ValueError:
except PasswordPromptError: print(colored("Incorrect password.", "red"))
pass except PasswordPromptError:
except Exception as e: pass
print(colored(f"Error: {e}", "red")) 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() pause()
elif choice == "4": except SeedPassError as exc:
password_manager.handle_verify_checksum() logging.error("Settings action failed: %s", exc, exc_info=True)
print(colored(f"Action failed: {exc}", "red"))
pause() pause()
elif choice == "5": except Exception as exc:
password_manager.handle_update_script_checksum() logging.error("Unexpected settings error: %s", exc, exc_info=True)
print(colored(f"Unexpected settings error: {exc}", "red"))
pause() 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( def display_menu(
@@ -1109,7 +1182,25 @@ def display_menu(
if time.time() - password_manager.last_activity > inactivity_timeout: if time.time() - password_manager.last_activity > inactivity_timeout:
print(colored("Session timed out. Vault locked.", "yellow")) print(colored("Session timed out. Vault locked.", "yellow"))
password_manager.lock_vault() 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() password_manager.start_background_sync()
getattr(password_manager, "start_background_relay_check", lambda: None)() getattr(password_manager, "start_background_relay_check", lambda: None)()
continue continue
@@ -1137,7 +1228,25 @@ def display_menu(
except TimeoutError: except TimeoutError:
print(colored("Session timed out. Vault locked.", "yellow")) print(colored("Session timed out. Vault locked.", "yellow"))
password_manager.lock_vault() 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() password_manager.start_background_sync()
getattr(password_manager, "start_background_relay_check", lambda: None)() getattr(password_manager, "start_background_relay_check", lambda: None)()
continue continue
@@ -1151,87 +1260,101 @@ def display_menu(
getattr(password_manager, "cleanup", lambda: None)() getattr(password_manager, "cleanup", lambda: None)()
_safe_close_client_pool(password_manager) _safe_close_client_pool(password_manager)
sys.exit(0) sys.exit(0)
if choice == "1": try:
while True: if choice == "1":
fp, parent_fp, child_fp = getattr( while True:
password_manager, fp, parent_fp, child_fp = getattr(
"header_fingerprint_args", password_manager,
( "header_fingerprint_args",
getattr(password_manager, "current_fingerprint", None), (
None, getattr(password_manager, "current_fingerprint", None),
None, None,
), None,
) ),
clear_header_with_notification( )
fp, clear_header_with_notification(
"Main Menu > Add Entry", password_manager,
parent_fingerprint=parent_fp, fp,
child_fingerprint=child_fp, "Main Menu > Add Entry",
) parent_fingerprint=parent_fp,
print(color_text("\nAdd Entry:", "menu")) child_fingerprint=child_fp,
print(color_text("1. Password", "menu")) )
print(color_text("2. 2FA (TOTP)", "menu")) print(color_text("\nAdd Entry:", "menu"))
print(color_text("3. SSH Key", "menu")) print(color_text("1. Password", "menu"))
print(color_text("4. Seed Phrase", "menu")) print(color_text("2. 2FA (TOTP)", "menu"))
print(color_text("5. Nostr Key Pair", "menu")) print(color_text("3. SSH Key", "menu"))
print(color_text("6. PGP Key", "menu")) print(color_text("4. Seed Phrase", "menu"))
print(color_text("7. Key/Value", "menu")) print(color_text("5. Nostr Key Pair", "menu"))
print(color_text("8. Managed Account", "menu")) print(color_text("6. PGP Key", "menu"))
sub_choice = input( print(color_text("7. Key/Value", "menu"))
"Select entry type or press Enter to go back: " print(color_text("8. Managed Account", "menu"))
).strip() 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() password_manager.update_activity()
if sub_choice == "1": password_manager.handle_retrieve_entry()
password_manager.handle_add_password() elif choice == "3":
break password_manager.update_activity()
elif sub_choice == "2": password_manager.handle_search_entries()
password_manager.handle_add_totp() elif choice == "4":
break password_manager.update_activity()
elif sub_choice == "3": password_manager.handle_list_entries()
password_manager.handle_add_ssh_key() elif choice == "5":
break password_manager.update_activity()
elif sub_choice == "4": password_manager.handle_modify_entry()
password_manager.handle_add_seed() elif choice == "6":
break password_manager.update_activity()
elif sub_choice == "5": password_manager.handle_display_totp_codes()
password_manager.handle_add_nostr_key() elif choice == "7":
break password_manager.update_activity()
elif sub_choice == "6": handle_settings(password_manager)
password_manager.handle_add_pgp() elif choice == "8":
break password_manager.update_activity()
elif sub_choice == "7": password_manager.handle_view_archived_entries()
password_manager.handle_add_key_value() else:
break print(colored("Invalid choice. Please select a valid option.", "red"))
elif sub_choice == "8": except PasswordPromptError as exc:
password_manager.handle_add_managed_account() logging.warning("Action cancelled by user: %s", exc)
break print(colored(f"Action cancelled: {exc}", "yellow"))
elif not sub_choice: pause()
break except SeedPassError as exc:
else: logging.error("Menu action failed: %s", exc, exc_info=True)
print(colored("Invalid choice.", "red")) print(colored(f"Action failed: {exc}", "red"))
elif choice == "2": pause()
password_manager.update_activity() except Exception as exc:
password_manager.handle_retrieve_entry() logging.error("Unhandled menu action error: %s", exc, exc_info=True)
elif choice == "3": print(colored(f"Unexpected menu error: {exc}", "red"))
password_manager.update_activity() pause()
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"))
def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> int: 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 password_manager.deterministic_totp = True
if args.command == "export": if args.command == "export":
password_manager.handle_export_database( if not args.file:
Path(args.file), encrypt=not args.unencrypted print(colored("Error: --file is required for export.", "red"))
)
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)
return 1 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: try:
if copy_to_clipboard(code, password_manager.clipboard_clear_delay): password_manager.handle_export_database(
print(colored("Code copied to clipboard", "green")) Path(args.file), encrypt=not args.unencrypted
except ClipboardUnavailableError as exc:
print(
colored(
f"Clipboard unavailable: {exc}\n"
"Re-run with '--no-clipboard' to print codes instead.",
"yellow",
)
) )
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): def signal_handler(sig, _frame):
print(colored("\nReceived shutdown signal. Exiting gracefully...", "yellow")) print(colored("\nReceived shutdown signal. Exiting gracefully...", "yellow"))
+188 -28
View File
@@ -665,37 +665,65 @@ class PasswordManager:
try: try:
fingerprints = self.fingerprint_manager.list_fingerprints() fingerprints = self.fingerprint_manager.list_fingerprints()
current = self.fingerprint_manager.current_fingerprint 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 print(colored("\nAvailable Seed Profiles:", "cyan"))
if len(fingerprints) == 1: for idx, fp in enumerate(fingerprints, start=1):
self.select_fingerprint(fingerprints[0]) label = (
return 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")) add_idx = len(fingerprints) + 1
for idx, fp in enumerate(fingerprints, start=1): recover_idx = len(fingerprints) + 2
label = ( print(colored(f"{add_idx}. Add a new seed profile", "cyan"))
self.fingerprint_manager.display_name(fp) print(
if hasattr(self.fingerprint_manager, "display_name") colored(
else fp f"{recover_idx}. Recover existing profile with blank local index",
"cyan",
)
) )
marker = " *" if fp == current else "" print(colored("Q. Exit", "cyan"))
print(colored(f"{idx}. {label}{marker}", "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() choice_num = int(choice)
if not choice.isdigit() or not (1 <= int(choice) <= len(fingerprints) + 1): if choice_num == add_idx:
print(colored("Invalid selection. Exiting.", "red")) # Add a new seed profile
raise SeedPassError("Invalid selection.") 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) # refresh profile list if new profiles were added/removed
if choice == len(fingerprints) + 1: fingerprints = self.fingerprint_manager.list_fingerprints()
# Add a new seed profile current = self.fingerprint_manager.current_fingerprint
self.add_new_fingerprint()
else:
# Select existing seed profile
selected_fingerprint = fingerprints[choice - 1]
self.select_fingerprint(selected_fingerprint)
except Exception as e: except Exception as e:
logger.error(f"Error during seed profile selection: {e}", exc_info=True) 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 # Ensure managers are initialized for the newly created profile
if getattr(self, "config_manager", None) is None: 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 return fingerprint
@@ -750,7 +784,7 @@ class PasswordManager:
def select_fingerprint( def select_fingerprint(
self, fingerprint: str, *, password: Optional[str] = None self, fingerprint: str, *, password: Optional[str] = None
) -> None: ) -> bool:
if self.fingerprint_manager.select_fingerprint(fingerprint): if self.fingerprint_manager.select_fingerprint(fingerprint):
self.current_fingerprint = fingerprint # Add this line self.current_fingerprint = fingerprint # Add this line
self.fingerprint_dir = ( self.fingerprint_dir = (
@@ -767,7 +801,12 @@ class PasswordManager:
f"Seed profile directory for {fingerprint} not found." f"Seed profile directory for {fingerprint} not found."
) )
# Setup the encryption manager and load parent seed # 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 # Initialize BIP85 and other managers
self.initialize_bip85() self.initialize_bip85()
self.initialize_managers() self.initialize_managers()
@@ -777,10 +816,131 @@ class PasswordManager:
"green", "green",
) )
) )
return True
else: else:
print(colored(f"Error: Seed profile {fingerprint} not found.", "red")) print(colored(f"Error: Seed profile {fingerprint} not found.", "red"))
raise SeedPassError(f"Seed profile {fingerprint} not found.") 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( def setup_encryption_manager(
self, self,
fingerprint_dir: Path, fingerprint_dir: Path,
+96
View File
@@ -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)
+33
View File
@@ -124,3 +124,36 @@ def test_cli_export_import_unencrypted(monkeypatch, tmp_path):
rc = main.main(["import", "--file", str(export_path)]) rc = main.main(["import", "--file", str(export_path)])
assert rc == 0 assert rc == 0
assert vault.load_index() == data 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
+64
View File
@@ -8,6 +8,8 @@ import pytest
sys.path.append(str(Path(__file__).resolve().parents[1])) sys.path.append(str(Path(__file__).resolve().parents[1]))
import main import main
from seedpass.core.errors import SeedPassError
from utils.password_prompt import PasswordPromptError
def _make_pm(called, locked=None): def _make_pm(called, locked=None):
@@ -102,3 +104,65 @@ def test_inactivity_timeout_loop(monkeypatch, capsys):
assert locked["lock"] == 1 assert locked["lock"] == 1
assert locked["unlock"] == 1 assert locked["unlock"] == 1
assert not any(called.values()) 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
@@ -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
+88
View File
@@ -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
+56
View File
@@ -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]
+93
View File
@@ -18,7 +18,9 @@ from nostr.client import DEFAULT_RELAYS
from seedpass.core.config_manager import ConfigManager from seedpass.core.config_manager import ConfigManager
from seedpass.core.manager import Notification, PasswordManager from seedpass.core.manager import Notification, PasswordManager
from seedpass.core.vault import Vault from seedpass.core.vault import Vault
from seedpass.core.errors import SeedPassError
from utils.fingerprint_manager import FingerprintManager from utils.fingerprint_manager import FingerprintManager
from utils.password_prompt import PasswordPromptError
def setup_pm(tmp_path, monkeypatch): 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.error_queue.empty()
assert pm.notifications.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