mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-27 07:52:03 +00:00
Harden TUI recovery paths and add release integrity gates
This commit is contained in:
1 parent
1b0d11146e
commit
88c1974ac6
19 files changed
+1274
-16
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 | |
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
@@ -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())
|
||||
+156
-2
@@ -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,6 +899,7 @@ 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()
|
||||
try:
|
||||
if choice == "1":
|
||||
if not password_manager.handle_switch_fingerprint():
|
||||
print(colored("Failed to switch seed profile.", "red"))
|
||||
@@ -912,6 +915,18 @@ def handle_profiles_menu(password_manager: PasswordManager) -> None:
|
||||
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,6 +964,7 @@ 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()
|
||||
try:
|
||||
if choice == "1":
|
||||
handle_post_to_nostr(password_manager)
|
||||
elif choice == "2":
|
||||
@@ -966,6 +983,18 @@ def handle_nostr_menu(password_manager: PasswordManager) -> None:
|
||||
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,6 +1031,7 @@ 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()
|
||||
try:
|
||||
if choice == "1":
|
||||
handle_profiles_menu(password_manager)
|
||||
elif choice == "2":
|
||||
@@ -1027,15 +1058,32 @@ def handle_settings(password_manager: PasswordManager) -> None:
|
||||
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 path:
|
||||
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)
|
||||
@@ -1049,9 +1097,22 @@ def handle_settings(password_manager: PasswordManager) -> None:
|
||||
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)()
|
||||
getattr(
|
||||
password_manager, "start_background_relay_check", lambda: None
|
||||
)()
|
||||
pause()
|
||||
elif choice == "14":
|
||||
handle_display_stats(password_manager)
|
||||
@@ -1068,6 +1129,18 @@ def handle_settings(password_manager: PasswordManager) -> None:
|
||||
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()
|
||||
except SeedPassError as exc:
|
||||
logging.error("Settings action failed: %s", exc, exc_info=True)
|
||||
print(colored(f"Action failed: {exc}", "red"))
|
||||
pause()
|
||||
except Exception as exc:
|
||||
logging.error("Unexpected settings error: %s", exc, exc_info=True)
|
||||
print(colored(f"Unexpected settings error: {exc}", "red"))
|
||||
pause()
|
||||
|
||||
|
||||
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()
|
||||
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()
|
||||
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,6 +1260,7 @@ def display_menu(
|
||||
getattr(password_manager, "cleanup", lambda: None)()
|
||||
_safe_close_client_pool(password_manager)
|
||||
sys.exit(0)
|
||||
try:
|
||||
if choice == "1":
|
||||
while True:
|
||||
fp, parent_fp, child_fp = getattr(
|
||||
@@ -1163,6 +1273,7 @@ def display_menu(
|
||||
),
|
||||
)
|
||||
clear_header_with_notification(
|
||||
password_manager,
|
||||
fp,
|
||||
"Main Menu > Add Entry",
|
||||
parent_fingerprint=parent_fp,
|
||||
@@ -1232,6 +1343,18 @@ def display_menu(
|
||||
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,21 +1476,43 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
|
||||
password_manager.deterministic_totp = True
|
||||
|
||||
if args.command == "export":
|
||||
if not args.file:
|
||||
print(colored("Error: --file is required for export.", "red"))
|
||||
return 1
|
||||
try:
|
||||
password_manager.handle_export_database(
|
||||
Path(args.file), encrypt=not args.unencrypted
|
||||
)
|
||||
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:
|
||||
@@ -1384,7 +1529,12 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
|
||||
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:
|
||||
@@ -1414,6 +1564,10 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
|
||||
)
|
||||
)
|
||||
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"))
|
||||
|
||||
+173
-13
@@ -665,10 +665,9 @@ 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:
|
||||
self.select_fingerprint(fingerprints[0])
|
||||
if len(fingerprints) == 1 and self.select_fingerprint(fingerprints[0]):
|
||||
return
|
||||
|
||||
print(colored("\nAvailable Seed Profiles:", "cyan"))
|
||||
@@ -681,21 +680,50 @@ class PasswordManager:
|
||||
marker = " *" if fp == current else ""
|
||||
print(colored(f"{idx}. {label}{marker}", "cyan"))
|
||||
|
||||
print(colored(f"{len(fingerprints)+1}. Add a new seed profile", "cyan"))
|
||||
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",
|
||||
)
|
||||
)
|
||||
print(colored("Q. Exit", "cyan"))
|
||||
|
||||
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.")
|
||||
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 = int(choice)
|
||||
if choice == len(fingerprints) + 1:
|
||||
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 - 1]
|
||||
self.select_fingerprint(selected_fingerprint)
|
||||
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",
|
||||
)
|
||||
)
|
||||
|
||||
# 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,6 +767,12 @@ class PasswordManager:
|
||||
|
||||
# Ensure managers are initialized for the newly created profile
|
||||
if getattr(self, "config_manager", None) is None:
|
||||
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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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
|
||||
Reference in new issue
Block a user