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
+1524
-266
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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: |
|
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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 | |
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
## 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`
|
||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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)])
|
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
|
||||||
@@ -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
|
||||||
@@ -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.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
|
||||||
Reference in new issue
Block a user