From c3eef44ab721096ff903eea22588a2ba4698e3fb Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 25 Feb 2026 18:07:22 -0500 Subject: [PATCH] improved tests --- docs/crypto_key_management_review.md | 114 ++++++++++ docs/security.md | 5 + docs/security_readiness_checklist.md | 63 ++++++ docs/threat_model.md | 173 +++++++++++++++ memory-update.md | 69 ++++++ src/seedpass/core/manager.py | 107 +++++----- src/tests/test_account_roundtrip_restore.py | 154 ++++++++++++++ src/tests/test_entry_types_roundtrip.py | 126 +++++++++++ src/tests/test_input_utils.py | 102 +++++++++ src/tests/test_kdf_modes.py | 52 +++++ src/tests/test_nostr_index_size.py | 19 +- src/tests/test_nostr_real.py | 222 ++++++++++++++++++++ src/tests/test_terminal_utils_display.py | 115 ++++++++++ 13 files changed, 1271 insertions(+), 50 deletions(-) create mode 100644 docs/crypto_key_management_review.md create mode 100644 docs/security_readiness_checklist.md create mode 100644 docs/threat_model.md create mode 100644 src/tests/test_account_roundtrip_restore.py create mode 100644 src/tests/test_entry_types_roundtrip.py create mode 100644 src/tests/test_input_utils.py create mode 100644 src/tests/test_terminal_utils_display.py diff --git a/docs/crypto_key_management_review.md b/docs/crypto_key_management_review.md new file mode 100644 index 00000000..23e302ee --- /dev/null +++ b/docs/crypto_key_management_review.md @@ -0,0 +1,114 @@ +# SeedPass Crypto & Key Management Review (Item 2) + +Status: `In Progress (P0 partially implemented)` +Date: `2026-02-25` + +## Scope Reviewed + +1. `src/utils/key_derivation.py` +2. `src/seedpass/core/encryption.py` +3. `src/seedpass/core/vault.py` +4. `src/seedpass/core/portable_backup.py` +5. `src/seedpass/core/manager.py` (unlock/setup key paths) +6. `src/seedpass/core/config_manager.py` (KDF config surface) + +## Crypto Inventory (Current) + +1. At-rest/index encryption: + - AES-GCM via `cryptography` (`EncryptionManager.encrypt_data/decrypt_data`). + - Format marker: `V3|` + 96-bit nonce + ciphertext+tag. +2. Legacy compatibility: + - Fernet legacy decrypt paths (`V2:` fallback and raw legacy blobs). +3. Password-to-seed key derivation: + - PBKDF2-HMAC-SHA256 (`derive_key_from_password`) with fingerprint-derived salt. + - Optional Argon2id path (`derive_key_from_password_argon2`). +4. Seed-to-storage key derivation: + - deterministic hierarchy from mnemonic seed (`derive_index_key*`). +5. Portable backup encryption: + - AES-GCM using key derived from parent seed (`derive_index_key`). +6. Integrity checks: + - Cipher authentication tags (AES-GCM). + - JSON checksum validation for portable backups. + +## Exit Criteria Check (Item 2) + +1. Primitive/parameter review completed: `In Progress` +2. Nonce/key lifecycle reviewed: `In Progress` +3. Negative tests for corruption/tampering/wrong key path: `Partially Done` + +## Findings + +### Strong / Acceptable + +1. AES-GCM is used for current encrypted payloads. +2. Migration compatibility from legacy formats is heavily tested. +3. Portable backup import verifies checksum after decrypt. +4. Nostr restore paths include strict error handling and migration fallback. + +### Gaps / Risks + +1. PBKDF2 default is low for 2026 threat levels: + - Config default appears to be `50_000` iterations. + - Legacy fallbacks include `50_000` and `100_000`. +2. Argon2 config drift: + - `argon2_time_cost` exists in config, but active unlock code constructs + `KdfConfig(salt_b64=...)` without reading configured `time_cost`. + - This can create false confidence that tuning is active. +3. AES-GCM nonce reuse guard: + - Additional guard uses CRC32 of nonce in-memory. + - CRC32 collision can create false positives under high operation counts. + - Not persisted across restarts, so not a true global nonce registry. +4. Optional plaintext export mode: + - `encrypt=False` in portable export intentionally permits plaintext backups. + - This is a policy risk if used accidentally in production workflows. +5. KDF metadata consistency: + - File wrapper carries `kdf` metadata, but operational seed-unlock + parameters are partly config-driven and partly hardcoded/defaulted. + - Clear authoritative KDF policy per profile is not yet centralized. + +## Recommended Remediation Plan + +### P0 (Do Next) + +1. Wire Argon2 runtime config into active unlock paths: + - In manager setup/load paths, pass configured `argon2_time_cost` (and any + agreed memory/parallelism values) into `KdfConfig.params`. + - Progress: completed in `PasswordManager._derive_seed_key` and wired across + seed-key callsites; tests added in `src/tests/test_kdf_modes.py`. +2. Raise PBKDF2 default for new profiles: + - Choose a higher baseline and keep backward compatibility via fallback. +3. Add explicit warnings for plaintext export mode: + - Keep capability, but require clear confirmation and warning text. + +### P1 + +1. Replace CRC32 nonce tracking with deterministic monotonic counter mode for + backup/index encryption or remove CRC guard and rely on 96-bit randomness. +2. Define a single profile KDF policy object: + - Algorithm + parameters + migration behavior in one place. +3. Expand negative tests to include: + - wrong Argon2 params, + - tampered kdf metadata wrapper, + - downgraded KDF policy attempts. + +### P2 + +1. Add release-time crypto policy checks: + - lint/test gate ensuring configured KDF policy is actually used in unlock paths. + +## Test Coverage Snapshot (Relevant) + +1. Legacy migration and iteration fallback tests exist. +2. Encryption fuzz and corruption tests exist. +3. Portable backup corruption/checksum tests exist. +4. Additional tests needed for active Argon2 parameter usage in unlock paths. + +## Proposed Completion Criteria for Item 2 + +Item 2 can move to `Done` when: + +1. Argon2 config is verifiably wired into unlock path and covered by tests. +2. PBKDF2 policy baseline is explicitly set and documented for new profiles. +3. Plaintext export policy is documented with explicit user safeguards. +4. One nonce-lifecycle decision is implemented (improved guard or rationale for removal). +5. Checklist evidence links are updated to include merged PR/tests. diff --git a/docs/security.md b/docs/security.md index e79b536d..45a06471 100644 --- a/docs/security.md +++ b/docs/security.md @@ -28,3 +28,8 @@ PY ``` The selected `time_cost` is stored in the profile's configuration and used for subsequent key derivations. + +## Readiness Documents + +- Production readiness checklist: `docs/security_readiness_checklist.md` +- Threat model draft: `docs/threat_model.md` diff --git a/docs/security_readiness_checklist.md b/docs/security_readiness_checklist.md new file mode 100644 index 00000000..cc010bd5 --- /dev/null +++ b/docs/security_readiness_checklist.md @@ -0,0 +1,63 @@ +# SeedPass Security Readiness Checklist + +Status legend: `Not Started`, `In Progress`, `Blocked`, `Done` + +| # | Area | Status | Owner | Evidence / Link | +|---|------|--------|-------|-----------------| +| 1 | Threat model (attacker profiles, trust boundaries, assumptions) | Done | Security / Core | `docs/threat_model.md` (provisional approvals recorded 2026-02-25) | +| 2 | Crypto and key management review | In Progress | Security / Core | `docs/crypto_key_management_review.md`, `src/tests/test_kdf_modes.py` | +| 3 | Secret handling and local data exposure hardening | Not Started | Core | | +| 4 | Backup and restore integrity validation | In Progress | Core / QA | `src/tests/test_account_roundtrip_restore.py`, `src/tests/test_portable_backup.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 | Not Started | Core | | +| 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 | | +| 9 | Operational readiness and incident runbooks | Not Started | Security / Ops | | +| 10 | External audit and staged production rollout | Not Started | Maintainers | | + +## Exit Criteria Per Item + +1. Threat model: + - Assets, trust boundaries, and attacker capabilities documented. + - Explicit assumptions and non-goals documented. + - Top risks prioritized with mitigations and owners. + +2. Crypto and key management: + - Primitive/parameter review completed. + - Nonce/key lifecycle reviewed. + - Negative tests for corruption/tampering/wrong key path. + +3. Secret handling: + - No secret leakage in logs/temp files/exports by default. + - Clipboard, memory lifetime, and file permission checks documented. + +4. Backup/restore: + - Seed-only recovery and encrypted export/import validated. + - Corruption and checksum mismatch behavior validated. + +5. Nostr: + - Real-relay publish/retrieve/restore tested. + - Failure modes (delay/outage/stale state) tested. + +6. Auth/access: + - Lock/unlock/timeout/quick unlock behavior reviewed with tests. + +7. Testing gates: + - Critical module coverage gates enforced. + - Network and stress tests are opt-in and documented. + +8. Supply chain: + - Locked dependencies, CVE review process, signed artifacts/checksums. + +9. Operational: + - Incident playbooks and recovery drills documented. + +10. External validation: + - Independent audit completed. + - Critical findings resolved before broad production use. + +## How To Use + +1. Update each row with current status and owner. +2. Attach concrete evidence links (PRs, test files, docs, reports). +3. Treat `Done` as requiring evidence for all exit criteria. diff --git a/docs/threat_model.md b/docs/threat_model.md new file mode 100644 index 00000000..d313d991 --- /dev/null +++ b/docs/threat_model.md @@ -0,0 +1,173 @@ +# SeedPass Threat Model (Draft v0.2) + +## Scope + +This draft covers SeedPass core vault operations, local storage, portable backups, +and Nostr synchronization paths. + +## Security Objectives + +1. Confidentiality of parent seed and derived secrets. +2. Integrity of vault/index data across local and synced states. +3. Reliable recovery from seed phrase and/or encrypted backup. +4. Deterministic derivation of artifacts across supported platforms/versions. + +## Assets + +1. Parent seed phrase. +2. Derived artifacts (passwords, TOTP secrets, key material). +3. Encrypted vault index (`seedpass_entries_db.json.enc` and equivalents). +4. Portable backup payloads and metadata. +5. Account metadata: + - Fingerprints. + - Relay configuration. + - Sync state (manifest IDs, timestamps). + +## Trust Boundaries + +1. Local host boundary: + - Application process, filesystem, clipboard, terminal. +2. Network boundary: + - Nostr relay transport and storage (untrusted relays). +3. Backup boundary: + - Exported files and any external backup path. +4. Build/dependency boundary: + - Package dependencies, installers, and release artifacts. + +## Attacker Profiles + +1. Remote observer: + - Can observe Nostr traffic metadata and timing; cannot break strong cryptography. +2. Malicious relay operator: + - Can drop, reorder, replay, or withhold events; can inspect ciphertext and metadata. +3. Local unprivileged attacker: + - Can read user-level files on a compromised host. +4. Local privileged attacker / malware: + - Can inspect process memory, keypresses, clipboard, and runtime state. +5. Supply-chain attacker: + - Can attempt dependency or build artifact compromise. + +## Assumptions + +1. Cryptographic primitives and libraries are implemented correctly. +2. User keeps parent seed secret and offline where possible. +3. Host OS is not fully compromised during sensitive operations. +4. Relay operators are untrusted for confidentiality/integrity. +5. Users can securely verify installation provenance. + +## Non-Goals + +1. Protection against a fully compromised endpoint with active malware. +2. Guaranteed metadata privacy from global traffic analysis. +3. Protection if the parent seed is disclosed. + +## High-Risk Scenarios + +1. Parent seed leakage: + - Impact: total compromise of all deterministic artifacts. + - Mitigations: strict secret handling, no logging, secure backup guidance. +2. Local plaintext leakage (logs/temp/clipboard): + - Impact: credential theft despite encrypted-at-rest vault. + - Mitigations: clipboard auto-clear, log redaction, temp file minimization. +3. Relay manipulation/replay/stale restore: + - Impact: rollback or inconsistent state across devices. + - Mitigations: checksum verification, manifest/delta integrity checks, explicit sync guidance. +4. Export backup mishandling: + - Impact: offline brute-force or direct exposure if unencrypted export is used. + - Mitigations: encrypted export default, warnings, permissions hardening. +5. Dependency/build compromise: + - Impact: malicious code exfiltration of seed/secrets. + - Mitigations: lockfiles + hashes, dependency audit, signed releases. + +## Required Decisions (Item 1 Completion Gate) + +1. Acceptable metadata leakage: + - Are payload size/timing/fingerprint-derived identifiers acceptable for production use? +2. Endpoint compromise stance: + - What user guidance is mandatory for host hardening and safe operation? +3. Recovery policy: + - Which recovery path is canonical for production: seed-only, backup-only, or both? +4. Relay trust posture: + - Minimum relay set diversity and failover recommendations. +5. Release trust chain: + - Required artifact signing and verification process. + +## Proposed Defaults + +Status: `Approved (Provisional)` + +1. Metadata leakage posture: + - Proposed default: Accept ciphertext-size and sync-timing leakage as an + explicit tradeoff for relay-based recovery. + - Requirement: Document this clearly in user-facing security docs and warn + users not to treat relay metadata as private. + - Constraint: No plaintext secret material or deterministic secret-derived + fields should be published to relays. + +2. Endpoint compromise stance: + - Proposed default: SeedPass is not secure against active local malware or + a fully compromised host. + - Requirement: Add explicit operational guidance: + - Use dedicated, hardened device profile for high-value seeds. + - Keep offline mode enabled unless sync is required. + - Minimize clipboard usage for sensitive workflows. + - Requirement: Add periodic hygiene checklist in docs. + +3. Recovery policy: + - Proposed default: Canonical recovery is seed-first; encrypted portable + backup import is secondary for convenience and fast restore. + - Requirement: Every release must preserve both: + - seed-only deterministic recovery path + - encrypted backup export/import compatibility + - Requirement: Keep migration tests for legacy indexes and backup formats. + +4. Relay trust posture: + - Proposed default: Minimum 3 independent relays from different operators. + - Requirement: Recommend at least 1 high-availability relay + 2 alternates. + - Requirement: Treat relay data as untrusted input; require checksum and + integrity validation before accepting restored data. + - Requirement: Keep retry/backoff behavior for eventual consistency. + +5. Release trust chain: + - Proposed default: Signed tags/releases plus published checksums for all + distributable artifacts. + - Requirement: Dependency lockfile with hashes remains mandatory in CI. + - Requirement: Reject release if provenance checks fail or lockfile drift is + detected without review. + +## Decision Approval Record + +Use this table to convert proposed defaults to approved policy. + +| Decision Area | Proposed | Approved | Date | Reviewer | Notes | +|---|---|---|---|---|---| +| Metadata leakage posture | Yes | Yes | 2026-02-25 | Maintainers (Provisional) | Accepted as explicit recovery tradeoff. | +| Endpoint compromise stance | Yes | Yes | 2026-02-25 | Maintainers (Provisional) | Not secure against active local malware. | +| Recovery policy | Yes | Yes | 2026-02-25 | Maintainers (Provisional) | Seed-first canonical recovery. | +| Relay trust posture | Yes | Yes | 2026-02-25 | Maintainers (Provisional) | Minimum 3 independent relays recommended. | +| Release trust chain | Yes | Yes | 2026-02-25 | Maintainers (Provisional) | Signed releases + checksums required. | + +## Initial Risk Ranking + +1. Critical: parent seed compromise. +2. High: endpoint malware/memory scraping. +3. High: insecure backup handling and accidental plaintext exposure. +4. Medium: relay replay/staleness causing wrong-state restore. +5. Medium: supply-chain compromise. + +## Mapping to Existing Evidence + +1. Real-relay restore/sync E2E coverage: + - `src/tests/test_nostr_real.py` +2. Portable backup/import integrity tests: + - `src/tests/test_portable_backup.py` +3. Account lifecycle roundtrip tests: + - `src/tests/test_account_roundtrip_restore.py` +4. Large-index relay syncability probe: + - `src/tests/test_nostr_index_size.py` + +## Next Actions + +1. Convert provisional approvals to formal release-policy signoff before external audit. +2. Add a risk register table with owner and target date per high-risk scenario. +3. Link resulting mitigations back to code/tests/docs in `docs/security_readiness_checklist.md`. diff --git a/memory-update.md b/memory-update.md index 825c269a..74d64bcb 100644 --- a/memory-update.md +++ b/memory-update.md @@ -5,3 +5,72 @@ - Confirmed that `landing/index.html` no longer contains `innerHTML` violations. - Confirmed `torch-config.json` correctly defines scheduler handoff commands. - Consolidated resolved issues into `KNOWN_ISSUES.md` for historical tracking. + +# Learnings from test-coverage run + +- `artifacts/coverage/coverage.full.json` is useful for choosing high-impact gaps quickly; `src/utils/input_utils.py` was at 25.00% and had no direct tests. +- Added deterministic unit tests for `timed_input` POSIX/Windows dispatch, timeout handling, and backspace behavior without relying on real terminal input. +- Added positive-path tests for `utils/terminal_utils` header/notification rendering to complement existing failure-path tests. +- Focused coverage after changes: `src/utils/input_utils.py` reached 100% and `src/utils/terminal_utils.py` reached 88% in targeted runs. + +# Learnings from entry-type verification run + +- Added an enum-driven regression suite to verify every `EntryType` value can be added, retrieved, and filtered via `EntryManager`. +- Guard test now asserts `ALL_ENTRY_TYPES` stays in sync with the `EntryType` enum, so introducing a new kind requires explicit test coverage updates. + +# Learnings from account roundtrip restore run + +- Added end-to-end regression tests for account lifecycle recovery: + Nostr sync -> local account deletion -> seed-based restore -> entry integrity checks. +- Added parallel lifecycle regression for portable backups: + export -> local account deletion -> import with seed -> entry integrity checks. +- `PasswordManager` test harnesses that call `sync_vault()` need `state_manager` set (even `None`) to avoid `AttributeError` in `sync_vault_async`. + +# Learnings from real-relay roundtrip run + +- Added an opt-in `@pytest.mark.network` roundtrip test in `test_nostr_real.py` that uses live relays and validates sync -> delete local index -> seed-based restore. +- Real-relay tests should isolate keyspace per run using a random `account_index` so snapshot queries do not collide with prior runs. +- Relay propagation can be eventual; bounded retry/backoff around `attempt_initial_sync()` improves stability for live network execution. + +# Learnings from live relay + portable chain run + +- Added an opt-in live test that chains both recovery paths: real-relay sync and portable export/import, then re-syncs and restores again from relays. +- Keeping `fingerprint` + `account_index` consistent across source/import/restore clients preserves deterministic relay identity for end-to-end verification. + +# Learnings from nostr-index-size gating update + +- `test_nostr_index_size_limits` remains discoverable in test collection but now requires explicit `NOSTR_INDEX_SIZE_E2E=1` to execute. +- This avoids automatic execution in CI desktop jobs while preserving manual E2E stress capability. + +# Learnings from 1000-entry live index-size run + +- With `NOSTR_INDEX_SIZE_E2E=1`, live relays handled checkpoints up to 1000 entries with successful publish/retrieve at every batch. +- Observed encrypted payload growth is strongly superlinear (about 30 KB at 100 entries to about 4.6 MB at 1000 entries), which is useful for practical relay size planning. + +# Learnings from security checklist + threat model kickoff + +- Added `docs/security_readiness_checklist.md` with status/owner/evidence fields and explicit exit criteria per security readiness item. +- Started item 1 by drafting `docs/threat_model.md` with scope, assets, trust boundaries, attacker profiles, assumptions, non-goals, and prioritized risks. +- Added five explicit decision gates to complete item 1 (metadata leakage posture, endpoint stance, recovery policy, relay trust posture, release trust chain). + +# Learnings from threat model defaults pass + +- Added concrete proposed defaults and requirements for all five item-1 decision gates in `docs/threat_model.md`. +- Added a decision approval table so maintainers can formally approve/reject each gate and close item 1 with traceable signoff. + +# Learnings from threat model provisional approval + +- Applied best-judgment provisional approvals for all five decision gates and recorded them in `docs/threat_model.md`. +- Marked checklist item 1 as `Done` in `docs/security_readiness_checklist.md` with evidence and approval date. + +# Learnings from crypto/key-management review kickoff + +- Added `docs/crypto_key_management_review.md` and moved checklist item 2 to `In Progress`. +- Identified immediate P0 gaps: PBKDF2 baseline appears low for modern threat levels and Argon2 `time_cost` config is not clearly wired through all active unlock paths. +- Documented prioritized remediation plan and explicit completion criteria for closing item 2. + +# Learnings from argon2 wiring remediation + +- Added `PasswordManager._derive_seed_key` and related config helpers so seed-key derivation consistently applies configured `kdf_mode` and Argon2 `time_cost`. +- Replaced multiple direct seed-key derivation callsites with the shared helper across setup/unlock/import/password-change flows. +- Added focused tests in `src/tests/test_kdf_modes.py` validating Argon2 `time_cost` propagation and PBKDF2 iteration override behavior. diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index a74cc346..2207fa1a 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -352,6 +352,51 @@ class PasswordManager: self.KEY_PW_DERIVE = kd(master, b"seedpass:v1:pw") self.KEY_TOTP_DET = kd(master, b"seedpass:v1:totp") + def _get_kdf_mode(self) -> str: + if getattr(self, "config_manager", None): + return self.config_manager.get_kdf_mode() + return "pbkdf2" + + def _get_kdf_iterations(self) -> int: + if getattr(self, "config_manager", None): + return self.config_manager.get_kdf_iterations() + return 50_000 + + def _get_argon2_time_cost(self) -> int: + if getattr(self, "config_manager", None): + return self.config_manager.get_argon2_time_cost() + return 2 + + def _derive_seed_key( + self, + password: str, + fingerprint: str, + *, + mode: str | None = None, + iterations: int | None = None, + ) -> bytes: + """Derive the profile seed-encryption key from current KDF settings.""" + + chosen_mode = mode or self._get_kdf_mode() + if chosen_mode == "argon2": + salt = hashlib.sha256(fingerprint.encode()).digest()[:16] + cfg = KdfConfig( + params={ + "time_cost": self._get_argon2_time_cost(), + "memory_cost": 64 * 1024, + "parallelism": 8, + }, + salt_b64=base64.b64encode(salt).decode(), + ) + return derive_key_from_password_argon2(password, cfg) + + if chosen_mode != "pbkdf2": + logger.warning("Unknown kdf_mode '%s'; falling back to pbkdf2", chosen_mode) + iter_count = ( + iterations if iterations is not None else self._get_kdf_iterations() + ) + return derive_key_from_password(password, fingerprint, iterations=iter_count) + def ensure_key_hierarchy(self) -> None: """Ensure sub-keys are derived from the current parent seed.""" if ( @@ -733,14 +778,12 @@ class PasswordManager: seed_mgr: EncryptionManager | None = None for iter_try in dict.fromkeys(iter_candidates): try: - if mode == "argon2": - salt = hashlib.sha256(salt_fp.encode()).digest()[:16] - cfg = KdfConfig(salt_b64=base64.b64encode(salt).decode()) - seed_key = derive_key_from_password_argon2(password, cfg) - else: - seed_key = derive_key_from_password( - password, salt_fp, iterations=iter_try - ) + seed_key = self._derive_seed_key( + password, + salt_fp, + mode=mode, + iterations=iter_try if mode != "argon2" else None, + ) seed_mgr = EncryptionManager(seed_key, fingerprint_dir) print("Decrypting seed...") self.parent_seed = seed_mgr.decrypt_parent_seed() @@ -814,14 +857,9 @@ class PasswordManager: else 50_000 ) salt_fp = fingerprint_dir.name - if mode == "argon2": - salt = hashlib.sha256(salt_fp.encode()).digest()[:16] - cfg = KdfConfig(salt_b64=base64.b64encode(salt).decode()) - seed_key = derive_key_from_password_argon2(password, cfg) - else: - seed_key = derive_key_from_password( - password, salt_fp, iterations=iterations - ) + seed_key = self._derive_seed_key( + password, salt_fp, mode=mode, iterations=iterations + ) seed_mgr = EncryptionManager(seed_key, fingerprint_dir) self.parent_seed = seed_mgr.decrypt_parent_seed() seed_bytes = Bip39SeedGenerator(self.parent_seed).Generate() @@ -948,14 +986,7 @@ class PasswordManager: raise SeedPassError("Seed profile directory not found.") # Derive encryption key from password using selected fingerprint - iterations = ( - self.config_manager.get_kdf_iterations() - if getattr(self, "config_manager", None) - else 50_000 - ) - key = derive_key_from_password( - password, selected_fingerprint, iterations=iterations - ) + key = self._derive_seed_key(password, selected_fingerprint) seed_mgr = EncryptionManager(key, fingerprint_dir) self.vault = Vault(seed_mgr, fingerprint_dir) @@ -1125,14 +1156,7 @@ class PasswordManager: seed_bytes = Bip39SeedGenerator(parent_seed).Generate() self.derive_key_hierarchy(seed_bytes) index_key = base64.urlsafe_b64encode(self.KEY_STORAGE) - iterations = ( - self.config_manager.get_kdf_iterations() - if getattr(self, "config_manager", None) - else 50_000 - ) - seed_key = derive_key_from_password( - password, fingerprint, iterations=iterations - ) + seed_key = self._derive_seed_key(password, fingerprint) self.encryption_manager = EncryptionManager(index_key, fingerprint_dir) seed_mgr = EncryptionManager(seed_key, fingerprint_dir) @@ -1323,14 +1347,7 @@ class PasswordManager: seed_bytes = Bip39SeedGenerator(seed).Generate() self.derive_key_hierarchy(seed_bytes) index_key = base64.urlsafe_b64encode(self.KEY_STORAGE) - iterations = ( - self.config_manager.get_kdf_iterations() - if getattr(self, "config_manager", None) - else 50_000 - ) - seed_key = derive_key_from_password( - password, fingerprint_dir.name, iterations=iterations - ) + seed_key = self._derive_seed_key(password, fingerprint_dir.name) self.encryption_manager = EncryptionManager(index_key, fingerprint_dir) seed_mgr = EncryptionManager(seed_key, fingerprint_dir) @@ -4282,10 +4299,7 @@ class PasswordManager: if mode == PortableMode.NONE.value: try: password = prompt_new_password() - iterations = self.config_manager.get_kdf_iterations() - seed_key = derive_key_from_password( - password, self.current_fingerprint, iterations=iterations - ) + seed_key = self._derive_seed_key(password, self.current_fingerprint) seed_mgr = EncryptionManager(seed_key, self.fingerprint_dir) seed_mgr.encrypt_parent_seed(self.parent_seed) self.store_hashed_password(password) @@ -4584,10 +4598,7 @@ class PasswordManager: # Create a new encryption manager with the new password new_key = base64.urlsafe_b64encode(self.KEY_STORAGE) - iterations = self.config_manager.get_kdf_iterations() - seed_key = derive_key_from_password( - new_password, self.current_fingerprint, iterations=iterations - ) + seed_key = self._derive_seed_key(new_password, self.current_fingerprint) seed_mgr = EncryptionManager(seed_key, self.fingerprint_dir) new_enc_mgr = EncryptionManager(new_key, self.fingerprint_dir) diff --git a/src/tests/test_account_roundtrip_restore.py b/src/tests/test_account_roundtrip_restore.py new file mode 100644 index 00000000..2fe836b0 --- /dev/null +++ b/src/tests/test_account_roundtrip_restore.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import shutil +from pathlib import Path + +from helpers import TEST_PASSWORD, TEST_SEED, create_vault +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.manager import EncryptionMode, PasswordManager +from seedpass.core.portable_backup import export_backup, import_backup + + +def _init_pm(dir_path: Path, nostr_client=None) -> PasswordManager: + vault, enc_mgr = create_vault(dir_path, TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, dir_path) + backup_mgr = BackupManager(dir_path, cfg_mgr) + entry_mgr = EntryManager(vault, backup_mgr) + + pm = PasswordManager.__new__(PasswordManager) + pm.encryption_mode = EncryptionMode.SEED_ONLY + pm.encryption_manager = enc_mgr + pm.vault = vault + pm.entry_manager = entry_mgr + pm.backup_manager = backup_mgr + pm.config_manager = cfg_mgr + pm.nostr_client = nostr_client + pm.fingerprint_dir = dir_path + pm.current_fingerprint = "fp" + pm.parent_seed = TEST_SEED + pm.is_dirty = False + pm.secret_mode_enabled = False + pm.state_manager = None + pm.offline_mode = False + pm.notify = lambda *a, **k: None + return pm + + +def _add_sample_entries(entry_mgr: EntryManager) -> None: + entry_mgr.add_entry( + "example.com", + 14, + username="alice", + url="https://example.com", + notes="password note", + tags=["web"], + ) + entry_mgr.add_totp( + "example-totp", + TEST_SEED, + deterministic=True, + period=45, + digits=8, + notes="totp note", + tags=["otp"], + ) + entry_mgr.add_key_value( + "api-token", + "token", + "abc123", + notes="kv note", + tags=["api"], + ) + + +def _assert_sample_entries(entry_mgr: EntryManager) -> None: + pw = entry_mgr.retrieve_entry(0) + totp = entry_mgr.retrieve_entry(1) + kv = entry_mgr.retrieve_entry(2) + + assert pw is not None + assert pw["kind"] == "password" + assert pw["label"] == "example.com" + assert pw["username"] == "alice" + assert pw["url"] == "https://example.com" + assert pw["length"] == 14 + assert pw["notes"] == "password note" + + assert totp is not None + assert totp["kind"] == "totp" + assert totp["label"] == "example-totp" + assert totp["deterministic"] is True + assert totp["index"] == 0 + assert totp["period"] == 45 + assert totp["digits"] == 8 + assert totp["notes"] == "totp note" + + assert kv is not None + assert kv["kind"] == "key_value" + assert kv["label"] == "api-token" + assert kv["key"] == "token" + assert kv["value"] == "abc123" + assert kv["notes"] == "kv note" + + labels = [item[1] for item in entry_mgr.list_entries(verbose=False)] + assert labels == ["example.com", "example-totp", "api-token"] + + +def test_account_roundtrip_via_nostr_seed_restore(tmp_path, make_dummy_nostr_client): + client, relay = make_dummy_nostr_client(tmp_path / "relay") + + source_dir = tmp_path / "account-source" + source_dir.mkdir() + pm_source = _init_pm(source_dir, client) + _add_sample_entries(pm_source.entry_manager) + + sync_result = pm_source.sync_vault() + assert sync_result is not None + assert relay.manifests + + shutil.rmtree(source_dir) + assert not source_dir.exists() + + restored_dir = tmp_path / "account-restored" + restored_dir.mkdir() + pm_restored = _init_pm(restored_dir, client) + + restored = pm_restored.attempt_initial_sync() + assert restored is True + pm_restored.entry_manager.clear_cache() + _assert_sample_entries(pm_restored.entry_manager) + + +def test_account_roundtrip_via_export_import_seed_restore(tmp_path): + source_dir = tmp_path / "account-source" + source_dir.mkdir() + pm_source = _init_pm(source_dir, None) + _add_sample_entries(pm_source.entry_manager) + + export_path = tmp_path / "seedpass-portable-export.json" + path = export_backup( + pm_source.vault, + pm_source.backup_manager, + dest_path=export_path, + parent_seed=TEST_SEED, + encrypt=True, + ) + assert path.exists() + + shutil.rmtree(source_dir) + assert not source_dir.exists() + + restored_dir = tmp_path / "account-restored" + restored_dir.mkdir() + pm_restored = _init_pm(restored_dir, None) + + import_backup( + pm_restored.vault, + pm_restored.backup_manager, + path, + parent_seed=TEST_SEED, + ) + pm_restored.entry_manager.clear_cache() + _assert_sample_entries(pm_restored.entry_manager) diff --git a/src/tests/test_entry_types_roundtrip.py b/src/tests/test_entry_types_roundtrip.py new file mode 100644 index 00000000..4fcf3c53 --- /dev/null +++ b/src/tests/test_entry_types_roundtrip.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from helpers import TEST_PASSWORD, TEST_SEED, create_vault +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.entry_types import ALL_ENTRY_TYPES, EntryType + + +def _make_entry_manager(tmp_path: Path) -> EntryManager: + vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) + return EntryManager(vault, backup_mgr) + + +def _add_entry_for_type(entry_mgr: EntryManager, entry_type: EntryType) -> int: + if entry_type == EntryType.PASSWORD: + return entry_mgr.add_entry( + "Password Entry", + 16, + username="alice", + url="https://example.com", + notes="pw-note", + tags=["auth"], + ) + if entry_type == EntryType.TOTP: + entry_mgr.add_totp( + "TOTP Entry", + TEST_SEED, + deterministic=True, + period=45, + digits=8, + notes="totp-note", + tags=["otp"], + ) + return 0 + if entry_type == EntryType.SSH: + return entry_mgr.add_ssh_key("SSH Entry", TEST_SEED, notes="ssh-note") + if entry_type == EntryType.SEED: + return entry_mgr.add_seed( + "Seed Entry", TEST_SEED, words_num=12, notes="seed-note", tags=["seed"] + ) + if entry_type == EntryType.PGP: + return entry_mgr.add_pgp_key( + "PGP Entry", + TEST_SEED, + key_type="ed25519", + user_id="alice@example.com", + notes="pgp-note", + tags=["pgp"], + ) + if entry_type == EntryType.NOSTR: + return entry_mgr.add_nostr_key("Nostr Entry", TEST_SEED, notes="nostr-note") + if entry_type == EntryType.KEY_VALUE: + return entry_mgr.add_key_value( + "KV Entry", + "api_key", + "secret-value", + notes="kv-note", + tags=["kv"], + ) + if entry_type == EntryType.MANAGED_ACCOUNT: + return entry_mgr.add_managed_account( + "Managed Entry", TEST_SEED, notes="managed-note", tags=["managed"] + ) + raise AssertionError(f"Unhandled entry type in test: {entry_type}") + + +def test_all_entry_type_values_are_enum_backed(): + assert set(ALL_ENTRY_TYPES) == {entry_type.value for entry_type in EntryType} + + +@pytest.mark.parametrize("entry_type", list(EntryType)) +def test_each_entry_type_roundtrip_retrieve_and_filter(tmp_path, entry_type: EntryType): + entry_mgr = _make_entry_manager(tmp_path) + index = _add_entry_for_type(entry_mgr, entry_type) + + entry = entry_mgr.retrieve_entry(index) + assert entry is not None + assert entry["type"] == entry_type.value + assert entry["kind"] == entry_type.value + assert entry["label"] + assert "modified_ts" not in entry + + entries = entry_mgr.list_entries( + filter_kinds=[entry_type.value], include_archived=True, verbose=False + ) + assert len(entries) == 1 + listed_index, listed_label, listed_username, listed_url, listed_archived = entries[0] + assert listed_index == index + assert listed_label == entry["label"] + assert listed_archived is False + + if entry_type == EntryType.PASSWORD: + assert listed_username == "alice" + assert listed_url == "https://example.com" + assert entry["length"] == 16 + assert entry["notes"] == "pw-note" + else: + assert listed_username is None + assert listed_url is None + + if entry_type == EntryType.TOTP: + assert entry["deterministic"] is True + assert entry["period"] == 45 + assert entry["digits"] == 8 + if entry_type == EntryType.SSH: + assert "index" in entry + if entry_type == EntryType.SEED: + assert entry["word_count"] == 12 + if entry_type == EntryType.PGP: + assert entry["key_type"] == "ed25519" + assert entry["user_id"] == "alice@example.com" + if entry_type == EntryType.NOSTR: + assert "index" in entry + if entry_type == EntryType.KEY_VALUE: + assert entry["key"] == "api_key" + assert entry["value"] == "secret-value" + if entry_type == EntryType.MANAGED_ACCOUNT: + assert entry["word_count"] == 12 + assert entry["fingerprint"] diff --git a/src/tests/test_input_utils.py b/src/tests/test_input_utils.py new file mode 100644 index 00000000..c1aa71ef --- /dev/null +++ b/src/tests/test_input_utils.py @@ -0,0 +1,102 @@ +import io + +import pytest + +from utils import input_utils + + +def test_timed_input_no_timeout_reads_stdin(monkeypatch): + monkeypatch.setattr(input_utils.sys, "stdin", io.StringIO(" hello \n")) + assert input_utils.timed_input("Prompt: ", None) == "hello" + + +def test_timed_input_dispatches_windows(monkeypatch): + monkeypatch.setattr(input_utils.sys, "platform", "win32") + monkeypatch.setattr( + input_utils, "_timed_input_windows", lambda timeout: f"windows:{timeout}" + ) + assert input_utils.timed_input("Prompt: ", 1.5) == "windows:1.5" + + +def test_timed_input_dispatches_posix(monkeypatch): + monkeypatch.setattr(input_utils.sys, "platform", "linux") + monkeypatch.setattr( + input_utils, "_timed_input_posix", lambda timeout: f"posix:{timeout}" + ) + assert input_utils.timed_input("Prompt: ", 2.0) == "posix:2.0" + + +def test_timed_input_posix_fileno_fallback(monkeypatch): + monkeypatch.setattr(input_utils.sys, "stdin", io.StringIO(" via-input \n")) + assert input_utils._timed_input_posix(timeout=5.0) == "via-input" + + +def test_timed_input_posix_returns_line_when_ready(monkeypatch): + class DummyStdin: + def fileno(self): + return 0 + + def readline(self): + return "value\n" + + dummy_stdin = DummyStdin() + monkeypatch.setattr(input_utils.sys, "stdin", dummy_stdin) + monkeypatch.setattr( + input_utils.select, "select", lambda r, _w, _x, _t: ([dummy_stdin], [], []) + ) + assert input_utils._timed_input_posix(timeout=0.1) == "value" + + +def test_timed_input_posix_raises_timeout(monkeypatch): + class DummyStdin: + def fileno(self): + return 0 + + dummy_stdin = DummyStdin() + monkeypatch.setattr(input_utils.sys, "stdin", dummy_stdin) + monkeypatch.setattr( + input_utils.select, "select", lambda _r, _w, _x, _t: ([], [], []) + ) + with pytest.raises(TimeoutError, match="input timed out"): + input_utils._timed_input_posix(timeout=0.1) + + +def test_timed_input_windows_fallback_when_msvcrt_missing(monkeypatch): + monkeypatch.setattr(input_utils, "msvcrt", None) + monkeypatch.setattr("builtins.input", lambda: " typed ") + assert input_utils._timed_input_windows(timeout=0.5) == "typed" + + +def test_timed_input_windows_handles_backspace_and_enter(monkeypatch): + class DummyMSVCRT: + def __init__(self): + self._chars = iter(["a", "\b", "b", "\r"]) + + def kbhit(self): + return True + + def getwche(self): + return next(self._chars) + + monkeypatch.setattr(input_utils, "msvcrt", DummyMSVCRT()) + monkeypatch.setattr(input_utils.time, "sleep", lambda _s: None) + monkeypatch.setattr(input_utils.time, "time", lambda: 0.0) + + assert input_utils._timed_input_windows(timeout=10.0) == "b" + + +def test_timed_input_windows_raises_timeout(monkeypatch): + class DummyMSVCRT: + def kbhit(self): + return False + + def getwche(self): + return "" + + timestamps = iter([0.0, 2.0]) + monkeypatch.setattr(input_utils, "msvcrt", DummyMSVCRT()) + monkeypatch.setattr(input_utils.time, "sleep", lambda _s: None) + monkeypatch.setattr(input_utils.time, "time", lambda: next(timestamps)) + + with pytest.raises(TimeoutError, match="input timed out"): + input_utils._timed_input_windows(timeout=1.0) diff --git a/src/tests/test_kdf_modes.py b/src/tests/test_kdf_modes.py index 96ceebf4..68bb23e3 100644 --- a/src/tests/test_kdf_modes.py +++ b/src/tests/test_kdf_modes.py @@ -116,3 +116,55 @@ def test_vault_kdf_migration(tmp_path): vault.load_index() new_kdf = mgr.get_file_kdf(vault.index_file) assert new_kdf.version == KdfConfig().version + + +def test_derive_seed_key_argon2_uses_configured_time_cost(monkeypatch): + pm = PasswordManager.__new__(PasswordManager) + pm.config_manager = SimpleNamespace( + get_kdf_mode=lambda: "argon2", + get_argon2_time_cost=lambda: 5, + get_kdf_iterations=lambda: 50_000, + ) + + captured = {} + + def fake_argon2(password, cfg): + captured["password"] = password + captured["cfg"] = cfg + return b"derived-argon2-key" + + monkeypatch.setattr( + "seedpass.core.manager.derive_key_from_password_argon2", fake_argon2 + ) + key = pm._derive_seed_key("pw", "fingerprint123") + + assert key == b"derived-argon2-key" + assert captured["password"] == "pw" + assert captured["cfg"].params["time_cost"] == 5 + assert captured["cfg"].params["memory_cost"] == 64 * 1024 + assert captured["cfg"].params["parallelism"] == 8 + expected_salt = hashlib.sha256("fingerprint123".encode()).digest()[:16] + assert base64.b64decode(captured["cfg"].salt_b64) == expected_salt + + +def test_derive_seed_key_pbkdf2_respects_iteration_override(monkeypatch): + pm = PasswordManager.__new__(PasswordManager) + pm.config_manager = SimpleNamespace( + get_kdf_mode=lambda: "pbkdf2", + get_argon2_time_cost=lambda: 2, + get_kdf_iterations=lambda: 777, + ) + + captured = {} + + def fake_pbkdf2(password, fingerprint, iterations): + captured["password"] = password + captured["fingerprint"] = fingerprint + captured["iterations"] = iterations + return b"derived-pbkdf2-key" + + monkeypatch.setattr("seedpass.core.manager.derive_key_from_password", fake_pbkdf2) + key = pm._derive_seed_key("pw", "fp", iterations=123) + + assert key == b"derived-pbkdf2-key" + assert captured == {"password": "pw", "fingerprint": "fp", "iterations": 123} diff --git a/src/tests/test_nostr_index_size.py b/src/tests/test_nostr_index_size.py index a7598a27..3de8f3d6 100644 --- a/src/tests/test_nostr_index_size.py +++ b/src/tests/test_nostr_index_size.py @@ -11,7 +11,6 @@ import uuid import pytest import base64 -import os sys.path.append(str(Path(__file__).resolve().parents[1])) @@ -23,8 +22,18 @@ from seedpass.core.config_manager import ConfigManager from nostr.client import NostrClient, Kind, KindStandard +def _relays_from_env() -> list[str]: + raw = os.getenv("NOSTR_RELAYS", "") + relays = [value.strip() for value in raw.split(",") if value.strip()] + return relays or ["wss://relay.snort.social"] + + @pytest.mark.desktop @pytest.mark.network +@pytest.mark.skipif( + os.getenv("NOSTR_INDEX_SIZE_E2E") != "1", + reason="Set NOSTR_INDEX_SIZE_E2E=1 to run index size E2E test", +) def test_nostr_index_size_limits(pytestconfig: pytest.Config): """Manually explore maximum index size for Nostr backups.""" seed = ( @@ -36,16 +45,22 @@ def test_nostr_index_size_limits(pytestconfig: pytest.Config): key = base64.urlsafe_b64encode(os.urandom(32)) enc_mgr = EncryptionManager(key, Path(tmpdir)) with patch.object(enc_mgr, "decrypt_parent_seed", return_value=seed): + account_index = int(uuid.uuid4().int % (2**31 - 1)) client = NostrClient( enc_mgr, f"size_test_{uuid.uuid4().hex}", - relays=["wss://relay.snort.social"], + relays=_relays_from_env(), + account_index=account_index, ) npub = client.key_manager.get_npub() vault = Vault(enc_mgr, tmpdir) cfg_mgr = ConfigManager(vault, Path(tmpdir)) backup_mgr = BackupManager(Path(tmpdir), cfg_mgr) entry_mgr = EntryManager(vault, backup_mgr) + # This test targets payload growth and relay round-trip integrity. + # Disable per-entry local backup/checksum work to keep runtime bounded. + backup_mgr.create_backup = lambda: None + entry_mgr.update_checksum = lambda: None delay = float(os.getenv("NOSTR_TEST_DELAY", "5")) max_entries = pytestconfig.getoption("--max-entries") diff --git a/src/tests/test_nostr_real.py b/src/tests/test_nostr_real.py index 14c2e89b..44f754ee 100644 --- a/src/tests/test_nostr_real.py +++ b/src/tests/test_nostr_real.py @@ -14,7 +14,13 @@ import base64 sys.path.append(str(Path(__file__).resolve().parents[1])) from seedpass.core.encryption import EncryptionManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.manager import EncryptionMode, PasswordManager +from seedpass.core.portable_backup import export_backup, import_backup from nostr.client import NostrClient +from helpers import create_vault, TEST_SEED, TEST_PASSWORD @pytest.mark.network @@ -41,3 +47,219 @@ def test_nostr_publish_and_retrieve(): retrieved = gzip.decompress(b"".join(result[1])) if result else None client.close_client_pool() assert retrieved == payload + + +def _relays_from_env() -> list[str]: + raw = os.getenv("NOSTR_RELAYS", "") + relays = [value.strip() for value in raw.split(",") if value.strip()] + return relays or ["wss://relay.snort.social"] + + +def _init_pm_for_real_nostr(dir_path: Path, client: NostrClient) -> PasswordManager: + vault, enc_mgr = create_vault(dir_path, TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, dir_path) + backup_mgr = BackupManager(dir_path, cfg_mgr) + entry_mgr = EntryManager(vault, backup_mgr) + + pm = PasswordManager.__new__(PasswordManager) + pm.encryption_mode = EncryptionMode.SEED_ONLY + pm.encryption_manager = enc_mgr + pm.vault = vault + pm.entry_manager = entry_mgr + pm.backup_manager = backup_mgr + pm.config_manager = cfg_mgr + pm.nostr_client = client + pm.fingerprint_dir = dir_path + pm.current_fingerprint = "fp" + pm.parent_seed = TEST_SEED + pm.is_dirty = False + pm.secret_mode_enabled = False + pm.state_manager = None + pm.offline_mode = False + pm.notify = lambda *a, **k: None + return pm + + +@pytest.mark.network +@pytest.mark.skipif(not os.getenv("NOSTR_E2E"), reason="NOSTR_E2E not set") +def test_account_roundtrip_sync_delete_restore_real_relays(): + """Real relay roundtrip: + create account -> add entries -> sync -> delete local -> restore by seed. + """ + relays = _relays_from_env() + account_index = int(uuid.uuid4().int % (2**31 - 1)) + fingerprint = f"test_fp_{uuid.uuid4().hex}" + + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + source_dir = root / "source" + restore_dir = root / "restore" + source_dir.mkdir() + restore_dir.mkdir() + + _, source_enc = create_vault(source_dir, TEST_SEED, TEST_PASSWORD) + source_client = NostrClient( + source_enc, + fingerprint, + relays=relays, + parent_seed=TEST_SEED, + account_index=account_index, + ) + pm_source = _init_pm_for_real_nostr(source_dir, source_client) + pm_source.entry_manager.add_entry( + "roundtrip-site", 15, username="alice", url="https://example.com" + ) + pm_source.entry_manager.add_totp( + "roundtrip-totp", + TEST_SEED, + deterministic=True, + period=45, + digits=8, + ) + pm_source.entry_manager.add_key_value("roundtrip-kv", "token", "abc123") + + sync = pm_source.sync_vault() + assert sync is not None + + index_file = source_dir / "seedpass_entries_db.json.enc" + if index_file.exists(): + index_file.unlink() + assert not index_file.exists() + + restore_vault, restore_enc = create_vault(restore_dir, TEST_SEED, TEST_PASSWORD) + restore_client = NostrClient( + restore_enc, + fingerprint, + relays=relays, + parent_seed=TEST_SEED, + account_index=account_index, + ) + pm_restore = _init_pm_for_real_nostr(restore_dir, restore_client) + + restored = False + for attempt in range(6): + restored = pm_restore.attempt_initial_sync() + if restored: + break + time.sleep(1.5 * (attempt + 1)) + assert restored is True + + pm_restore.entry_manager.clear_cache() + labels = [item[1] for item in pm_restore.entry_manager.list_entries(verbose=False)] + assert sorted(labels) == ["roundtrip-kv", "roundtrip-site", "roundtrip-totp"] + + pw = pm_restore.entry_manager.retrieve_entry(0) + totp = pm_restore.entry_manager.retrieve_entry(1) + kv = pm_restore.entry_manager.retrieve_entry(2) + assert pw and pw["kind"] == "password" and pw["username"] == "alice" + assert totp and totp["kind"] == "totp" and totp["deterministic"] is True + assert kv and kv["kind"] == "key_value" and kv["value"] == "abc123" + + source_client.close_client_pool() + restore_client.close_client_pool() + + +@pytest.mark.network +@pytest.mark.skipif(not os.getenv("NOSTR_E2E"), reason="NOSTR_E2E not set") +def test_real_relays_with_portable_export_import_roundtrip(): + """Live relay + portable backup chain in one E2E flow.""" + relays = _relays_from_env() + account_index = int(uuid.uuid4().int % (2**31 - 1)) + fingerprint = f"test_fp_{uuid.uuid4().hex}" + + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + source_dir = root / "source" + import_dir = root / "imported" + relay_restore_dir = root / "relay-restore" + source_dir.mkdir() + import_dir.mkdir() + relay_restore_dir.mkdir() + + source_vault, source_enc = create_vault(source_dir, TEST_SEED, TEST_PASSWORD) + source_client = NostrClient( + source_enc, + fingerprint, + relays=relays, + parent_seed=TEST_SEED, + account_index=account_index, + ) + pm_source = _init_pm_for_real_nostr(source_dir, source_client) + + pm_source.entry_manager.add_entry( + "portable-site", 18, username="carol", url="https://portable.example" + ) + pm_source.entry_manager.add_totp( + "portable-totp", + TEST_SEED, + deterministic=True, + period=30, + digits=6, + ) + pm_source.entry_manager.add_key_value("portable-kv", "api", "xyz789") + + first_sync = pm_source.sync_vault() + assert first_sync is not None + + export_path = root / "portable-export.json" + path = export_backup( + pm_source.vault, + pm_source.backup_manager, + dest_path=export_path, + parent_seed=TEST_SEED, + encrypt=True, + ) + assert path.exists() + + _, import_enc = create_vault(import_dir, TEST_SEED, TEST_PASSWORD) + import_client = NostrClient( + import_enc, + fingerprint, + relays=relays, + parent_seed=TEST_SEED, + account_index=account_index, + ) + pm_import = _init_pm_for_real_nostr(import_dir, import_client) + import_backup( + pm_import.vault, + pm_import.backup_manager, + path, + parent_seed=TEST_SEED, + ) + pm_import.entry_manager.clear_cache() + + imported_labels = [ + item[1] for item in pm_import.entry_manager.list_entries(verbose=False) + ] + assert sorted(imported_labels) == ["portable-kv", "portable-site", "portable-totp"] + + resync = pm_import.sync_vault() + assert resync is not None + + _, relay_enc = create_vault(relay_restore_dir, TEST_SEED, TEST_PASSWORD) + relay_client = NostrClient( + relay_enc, + fingerprint, + relays=relays, + parent_seed=TEST_SEED, + account_index=account_index, + ) + pm_relay_restore = _init_pm_for_real_nostr(relay_restore_dir, relay_client) + + restored = False + for attempt in range(6): + restored = pm_relay_restore.attempt_initial_sync() + if restored: + break + time.sleep(1.5 * (attempt + 1)) + assert restored is True + + pm_relay_restore.entry_manager.clear_cache() + relay_labels = [ + item[1] for item in pm_relay_restore.entry_manager.list_entries(verbose=False) + ] + assert sorted(relay_labels) == ["portable-kv", "portable-site", "portable-totp"] + + source_client.close_client_pool() + import_client.close_client_pool() + relay_client.close_client_pool() diff --git a/src/tests/test_terminal_utils_display.py b/src/tests/test_terminal_utils_display.py new file mode 100644 index 00000000..8edbf7d4 --- /dev/null +++ b/src/tests/test_terminal_utils_display.py @@ -0,0 +1,115 @@ +import queue +from types import SimpleNamespace + +from utils import terminal_utils + + +class _NamedFingerprintManager: + def get_name(self, fingerprint): + return {"parent": "Parent Name", "child": "Child Name"}.get(fingerprint) + + +def test_format_profile_returns_custom_name(): + pm = SimpleNamespace(fingerprint_manager=_NamedFingerprintManager()) + assert terminal_utils.format_profile("parent", pm) == "Parent Name (parent)" + + +def test_format_profile_returns_raw_when_name_missing(): + pm = SimpleNamespace(fingerprint_manager=_NamedFingerprintManager()) + assert terminal_utils.format_profile("unknown", pm) == "unknown" + + +def test_clear_and_print_fingerprint_parent_child_breadcrumb(monkeypatch, capsys): + pm = SimpleNamespace(fingerprint_manager=_NamedFingerprintManager()) + monkeypatch.setattr(terminal_utils, "clear_screen", lambda: None) + monkeypatch.setattr(terminal_utils, "colored", lambda text, _color: text) + + terminal_utils.clear_and_print_fingerprint( + parent_fingerprint="parent", + child_fingerprint="child", + breadcrumb="Settings", + pm=pm, + ) + + out = capsys.readouterr().out + assert "Seed Profile: Parent Name (parent) > Managed Account > Child Name (child)" in out + assert "> Settings" in out + + +def test_clear_and_print_profile_chain_with_breadcrumb(monkeypatch, capsys): + pm = SimpleNamespace(fingerprint_manager=_NamedFingerprintManager()) + monkeypatch.setattr(terminal_utils, "clear_screen", lambda: None) + monkeypatch.setattr(terminal_utils, "colored", lambda text, _color: text) + + terminal_utils.clear_and_print_profile_chain( + ["parent", "child"], breadcrumb="Retrieve Entry", pm=pm + ) + + out = capsys.readouterr().out + assert "Seed Profile: Parent Name (parent) > Managed Account > Child Name (child)" in out + assert "> Retrieve Entry" in out + + +def test_clear_header_with_notification_invalid_level_falls_back_to_info( + monkeypatch, capsys +): + pm = SimpleNamespace( + fingerprint_manager=_NamedFingerprintManager(), + get_current_notification=lambda: SimpleNamespace( + level="DEBUG", message="sync complete" + ), + ) + monkeypatch.setattr(terminal_utils, "clear_screen", lambda: None) + monkeypatch.setattr(terminal_utils, "colored", lambda text, _color: text) + monkeypatch.setattr( + terminal_utils, "color_text", lambda message, category: f"[{category}] {message}" + ) + + terminal_utils.clear_header_with_notification(pm, fingerprint="parent") + + out = capsys.readouterr().out + assert "Seed Profile: Parent Name (parent)" in out + assert "[info] sync complete" in out + + +def test_clear_header_with_notification_handles_queue_empty(monkeypatch, capsys): + pm = SimpleNamespace( + fingerprint_manager=_NamedFingerprintManager(), + get_current_notification=lambda: (_ for _ in ()).throw(queue.Empty()), + ) + monkeypatch.setattr(terminal_utils, "clear_screen", lambda: None) + monkeypatch.setattr(terminal_utils, "colored", lambda text, _color: text) + + terminal_utils.clear_header_with_notification(pm, fingerprint="parent") + + out = capsys.readouterr().out + assert "Seed Profile: Parent Name (parent)" in out + + +def test_pause_returns_early_when_not_tty(monkeypatch): + monkeypatch.setattr( + terminal_utils.sys, + "stdin", + SimpleNamespace(isatty=lambda: False), + ) + called = {"input": False} + monkeypatch.setattr( + "builtins.input", + lambda _msg: called.__setitem__("input", True), + ) + + terminal_utils.pause("ignored") + assert called["input"] is False + + +def test_pause_swallows_eoferror(monkeypatch): + monkeypatch.setattr( + terminal_utils.sys, + "stdin", + SimpleNamespace(isatty=lambda: True), + ) + monkeypatch.setattr( + "builtins.input", + lambda _msg: (_ for _ in ()).throw(EOFError()), + ) + terminal_utils.pause("Press Enter")