mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-27 07:52:03 +00:00
improved tests
This commit is contained in:
1 parent
f7f81a29d7
commit
c3eef44ab7
13 files changed
+1271
-50
No files matched your search
@@ -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.
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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="[email protected]",
|
||||
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"] == "[email protected]"
|
||||
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"]
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user