mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-27 07:52:03 +00:00
Harden security item 2: KDF policy, export safeguards, and tests
This commit is contained in:
1 parent
c3eef44ab7
commit
fcbe2b2943
13 files changed
+290
-126
No files matched your search
+1
-1
@@ -15,7 +15,7 @@ Each context string keeps derived keys domain separated.
|
||||
## KDF Parameters
|
||||
|
||||
Passwords are protected with **PBKDF2-HMAC-SHA256**. The default work factor is
|
||||
**50,000 iterations** but may be adjusted via the settings slider. The config
|
||||
**200,000 iterations** but may be adjusted via the settings slider. The config
|
||||
stores a `KdfConfig` structure with the chosen iteration count, algorithm name,
|
||||
and the current spec version (`CURRENT_KDF_VERSION = 1`). Argon2 is available
|
||||
with a default `time_cost` of 2 when selected.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# SeedPass Crypto & Key Management Review (Item 2)
|
||||
|
||||
Status: `In Progress (P0 partially implemented)`
|
||||
Status: `In Progress (P0 implemented, P1 remaining)`
|
||||
Date: `2026-02-25`
|
||||
|
||||
## Scope Reviewed
|
||||
@@ -33,8 +33,8 @@ Date: `2026-02-25`
|
||||
## 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`
|
||||
2. Nonce/key lifecycle reviewed: `Done`
|
||||
3. Negative tests for corruption/tampering/wrong key path: `Done`
|
||||
|
||||
## Findings
|
||||
|
||||
@@ -47,20 +47,19 @@ Date: `2026-02-25`
|
||||
|
||||
### 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`.
|
||||
1. PBKDF2 default was low for 2026 threat levels:
|
||||
- Remediated: new-profile default raised to `200_000` iterations.
|
||||
- Legacy fallback compatibility remains via `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.
|
||||
3. AES-GCM nonce lifecycle policy:
|
||||
- CRC32 in-memory nonce tracking was removed.
|
||||
- Policy now relies on fresh 96-bit random nonces per encryption call.
|
||||
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.
|
||||
- `encrypt=False` capability remains intentionally available.
|
||||
- Interactive export now requires explicit warning + second confirmation.
|
||||
5. KDF metadata consistency:
|
||||
- File wrapper carries `kdf` metadata, but operational seed-unlock
|
||||
parameters are partly config-driven and partly hardcoded/defaulted.
|
||||
@@ -76,20 +75,20 @@ Date: `2026-02-25`
|
||||
- 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.
|
||||
- Progress: completed with default baseline `200_000`.
|
||||
3. Add explicit warnings for plaintext export mode:
|
||||
- Keep capability, but require clear confirmation and warning text.
|
||||
- Progress: completed in `PasswordManager.handle_export_database`.
|
||||
|
||||
### 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.
|
||||
1. Decide nonce lifecycle strategy:
|
||||
- Completed by removing CRC guard and relying 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.
|
||||
- Completed in `src/tests/test_kdf_modes.py`.
|
||||
- Coverage includes wrong Argon2 params, tampered KDF wrapper payload, and
|
||||
downgraded KDF policy floor enforcement.
|
||||
|
||||
### P2
|
||||
|
||||
@@ -101,7 +100,7 @@ Date: `2026-02-25`
|
||||
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.
|
||||
4. Active Argon2 parameter and KDF downgrade/tamper negatives are now covered.
|
||||
|
||||
## Proposed Completion Criteria for Item 2
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ 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` |
|
||||
| 2 | Crypto and key management review | In Progress | Security / Core | `docs/crypto_key_management_review.md`, `src/tests/test_kdf_modes.py`, `src/tests/test_export_plaintext_policy.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` |
|
||||
|
||||
+29
-70
@@ -1,76 +1,35 @@
|
||||
# Learnings from known-issues-agent run
|
||||
# Memory Update (2026-02-25)
|
||||
|
||||
- Initialized `KNOWN_ISSUES.md` as it was missing.
|
||||
- Verified that `npm run --prefix torch lint` requires `npm install --prefix torch` to be run first.
|
||||
- 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.
|
||||
## Security Item 2 Progress
|
||||
- Raised PBKDF2 default policy for new profiles from `50_000` to `200_000` via `ConfigManager.DEFAULT_PBKDF2_ITERATIONS`.
|
||||
- Added policy-floor enforcement in `PasswordManager._get_kdf_iterations()` so downgraded configured values are ignored during normal seed-key derivation.
|
||||
- Kept unlock backward compatibility for legacy profiles by preserving fallback attempts for `50_000` and `100_000` iterations.
|
||||
|
||||
# Learnings from test-coverage run
|
||||
## KDF Metadata Consistency
|
||||
- Added `PasswordManager._build_seed_kdf_config(...)` and wired all seed re-encryption callsites to persist explicit KDF metadata (`pbkdf2` or `argon2id`) matching active derivation policy.
|
||||
|
||||
- `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.
|
||||
## Plaintext Export Safeguard
|
||||
- Hardened `handle_export_database` interactive flow: plaintext export now requires explicit warning + second confirmation.
|
||||
- Added audit metadata for backup exports (`encryption_mode` field).
|
||||
|
||||
# Learnings from entry-type verification run
|
||||
## Nonce Lifecycle Decision
|
||||
- Removed CRC32 nonce tracking from `EncryptionManager.encrypt_data`.
|
||||
- Policy now relies on fresh random 96-bit AES-GCM nonces per encryption call to avoid CRC false positives and in-memory collision bookkeeping.
|
||||
|
||||
- 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.
|
||||
## Added/Updated Tests
|
||||
- Expanded `src/tests/test_kdf_modes.py` with negatives for:
|
||||
- wrong Argon2 params on unlock,
|
||||
- tampered KDF wrapper payload,
|
||||
- downgraded KDF policy attempt (floor enforcement).
|
||||
- Added `src/tests/test_export_plaintext_policy.py` for double-confirm plaintext export guard.
|
||||
- Updated expectations in `test_config_manager.py` and `test_kdf_strength_slider.py` for new PBKDF2 baseline.
|
||||
|
||||
# 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.
|
||||
## Validation
|
||||
- Ran targeted suite:
|
||||
- `src/tests/test_kdf_modes.py`
|
||||
- `src/tests/test_export_plaintext_policy.py`
|
||||
- `src/tests/test_config_manager.py`
|
||||
- `src/tests/test_kdf_strength_slider.py`
|
||||
- `src/tests/test_noninteractive_init_unlock.py`
|
||||
- `src/tests/test_password_unlock_after_change.py`
|
||||
- Result: `28 passed`.
|
||||
+5
-5
@@ -642,11 +642,11 @@ def handle_set_kdf_iterations(password_manager: PasswordManager) -> None:
|
||||
print(colored("Configuration manager unavailable.", "red"))
|
||||
return
|
||||
levels = [
|
||||
("1", "Very Fast", 10_000),
|
||||
("2", "Fast", 50_000),
|
||||
("3", "Balanced", 100_000),
|
||||
("4", "Slow", 200_000),
|
||||
("5", "Paranoid", 500_000),
|
||||
("1", "Baseline", 200_000),
|
||||
("2", "Balanced", 300_000),
|
||||
("3", "Strong", 500_000),
|
||||
("4", "Very Strong", 800_000),
|
||||
("5", "Hardened", 1_000_000),
|
||||
]
|
||||
try:
|
||||
current = cfg_mgr.get_kdf_iterations()
|
||||
|
||||
@@ -153,7 +153,7 @@ def agent_init(
|
||||
help="Switch to existing profile if the seed fingerprint already exists",
|
||||
),
|
||||
kdf_iterations: int = typer.Option(
|
||||
100_000, "--kdf-iterations", help="PBKDF2 iterations for profile setup"
|
||||
200_000, "--kdf-iterations", help="PBKDF2 iterations for profile setup"
|
||||
),
|
||||
print_seed: bool = typer.Option(
|
||||
False, "--print-seed", help="Include generated seed in JSON output"
|
||||
|
||||
@@ -25,6 +25,8 @@ class ConfigManager:
|
||||
"""Manage per-profile configuration encrypted on disk."""
|
||||
|
||||
CONFIG_FILENAME = "seedpass_config.json.enc"
|
||||
DEFAULT_PBKDF2_ITERATIONS = 200_000
|
||||
LEGACY_PBKDF2_ITERATION_FALLBACKS = (50_000, 100_000)
|
||||
|
||||
def __init__(self, vault: Vault, fingerprint_dir: Path):
|
||||
self.vault = vault
|
||||
@@ -48,7 +50,7 @@ class ConfigManager:
|
||||
"pin_hash": "",
|
||||
"password_hash": "",
|
||||
"inactivity_timeout": INACTIVITY_TIMEOUT,
|
||||
"kdf_iterations": 50_000,
|
||||
"kdf_iterations": self.DEFAULT_PBKDF2_ITERATIONS,
|
||||
"kdf_mode": "pbkdf2",
|
||||
"argon2_time_cost": 2,
|
||||
"additional_backup_path": "",
|
||||
@@ -78,7 +80,7 @@ class ConfigManager:
|
||||
data.setdefault("pin_hash", "")
|
||||
data.setdefault("password_hash", "")
|
||||
data.setdefault("inactivity_timeout", INACTIVITY_TIMEOUT)
|
||||
data.setdefault("kdf_iterations", 50_000)
|
||||
data.setdefault("kdf_iterations", self.DEFAULT_PBKDF2_ITERATIONS)
|
||||
data.setdefault("kdf_mode", "pbkdf2")
|
||||
data.setdefault("argon2_time_cost", 2)
|
||||
data.setdefault("additional_backup_path", "")
|
||||
@@ -186,7 +188,7 @@ class ConfigManager:
|
||||
def get_kdf_iterations(self) -> int:
|
||||
"""Retrieve the PBKDF2 iteration count."""
|
||||
config = self.load_config(require_pin=False)
|
||||
return int(config.get("kdf_iterations", 50_000))
|
||||
return int(config.get("kdf_iterations", self.DEFAULT_PBKDF2_ITERATIONS))
|
||||
|
||||
def set_kdf_mode(self, mode: str) -> None:
|
||||
"""Persist the key derivation function mode."""
|
||||
|
||||
@@ -16,7 +16,6 @@ except Exception: # pragma: no cover - fallback for environments without orjson
|
||||
import hashlib
|
||||
import os
|
||||
import base64
|
||||
import zlib
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
@@ -93,21 +92,14 @@ class EncryptionManager:
|
||||
# Track user preference for handling legacy indexes
|
||||
self._legacy_migrate_flag = True
|
||||
self.last_migration_performed = False
|
||||
# Track nonces to detect accidental reuse
|
||||
self.nonce_crc_table: set[int] = set()
|
||||
|
||||
def encrypt_data(self, data: bytes) -> bytes:
|
||||
"""
|
||||
Encrypt data using AES-GCM, emitting ``b"V3|" + nonce + ciphertext + tag``.
|
||||
A fresh 96-bit nonce is generated for each call and tracked via a CRC
|
||||
table to detect accidental reuse during batch operations.
|
||||
A fresh 96-bit nonce is generated for each call.
|
||||
"""
|
||||
try:
|
||||
nonce = os.urandom(12) # 96-bit nonce is recommended for AES-GCM
|
||||
crc = zlib.crc32(nonce)
|
||||
if crc in self.nonce_crc_table:
|
||||
raise ValueError("Nonce reuse detected")
|
||||
self.nonce_crc_table.add(crc)
|
||||
ciphertext = self.cipher.encrypt(nonce, data, None)
|
||||
return b"V3|" + nonce + ciphertext
|
||||
except Exception as e:
|
||||
|
||||
@@ -358,9 +358,17 @@ class PasswordManager:
|
||||
return "pbkdf2"
|
||||
|
||||
def _get_kdf_iterations(self) -> int:
|
||||
floor = getattr(ConfigManager, "DEFAULT_PBKDF2_ITERATIONS", 200_000)
|
||||
if getattr(self, "config_manager", None):
|
||||
return self.config_manager.get_kdf_iterations()
|
||||
return 50_000
|
||||
configured = int(self.config_manager.get_kdf_iterations())
|
||||
if configured < floor:
|
||||
logger.warning(
|
||||
"Configured PBKDF2 iterations (%s) is below policy floor (%s); using floor.",
|
||||
configured,
|
||||
floor,
|
||||
)
|
||||
return max(configured, floor)
|
||||
return floor
|
||||
|
||||
def _get_argon2_time_cost(self) -> int:
|
||||
if getattr(self, "config_manager", None):
|
||||
@@ -397,6 +405,37 @@ class PasswordManager:
|
||||
)
|
||||
return derive_key_from_password(password, fingerprint, iterations=iter_count)
|
||||
|
||||
def _build_seed_kdf_config(
|
||||
self,
|
||||
fingerprint: str,
|
||||
*,
|
||||
mode: str | None = None,
|
||||
iterations: int | None = None,
|
||||
) -> KdfConfig:
|
||||
"""Return the metadata block describing seed-key derivation policy."""
|
||||
|
||||
chosen_mode = mode or self._get_kdf_mode()
|
||||
salt = hashlib.sha256(fingerprint.encode()).digest()[:16]
|
||||
salt_b64 = base64.b64encode(salt).decode()
|
||||
if chosen_mode == "argon2":
|
||||
return KdfConfig(
|
||||
name="argon2id",
|
||||
params={
|
||||
"time_cost": self._get_argon2_time_cost(),
|
||||
"memory_cost": 64 * 1024,
|
||||
"parallelism": 8,
|
||||
},
|
||||
salt_b64=salt_b64,
|
||||
)
|
||||
iter_count = (
|
||||
int(iterations) if iterations is not None else self._get_kdf_iterations()
|
||||
)
|
||||
return KdfConfig(
|
||||
name="pbkdf2",
|
||||
params={"iterations": iter_count},
|
||||
salt_b64=salt_b64,
|
||||
)
|
||||
|
||||
def ensure_key_hierarchy(self) -> None:
|
||||
"""Ensure sub-keys are derived from the current parent seed."""
|
||||
if (
|
||||
@@ -766,14 +805,20 @@ class PasswordManager:
|
||||
iterations = (
|
||||
self.config_manager.get_kdf_iterations()
|
||||
if getattr(self, "config_manager", None)
|
||||
else 50_000
|
||||
else getattr(ConfigManager, "DEFAULT_PBKDF2_ITERATIONS", 200_000)
|
||||
)
|
||||
print("Deriving key...")
|
||||
salt_fp = fingerprint_dir.name
|
||||
|
||||
iter_candidates: list[int] = [iterations]
|
||||
if mode != "argon2":
|
||||
iter_candidates.extend([50_000, 100_000])
|
||||
iter_candidates.extend(
|
||||
getattr(
|
||||
ConfigManager,
|
||||
"LEGACY_PBKDF2_ITERATION_FALLBACKS",
|
||||
(50_000, 100_000),
|
||||
)
|
||||
)
|
||||
|
||||
seed_mgr: EncryptionManager | None = None
|
||||
for iter_try in dict.fromkeys(iter_candidates):
|
||||
@@ -854,7 +899,7 @@ class PasswordManager:
|
||||
iterations = (
|
||||
self.config_manager.get_kdf_iterations()
|
||||
if getattr(self, "config_manager", None)
|
||||
else 50_000
|
||||
else getattr(ConfigManager, "DEFAULT_PBKDF2_ITERATIONS", 200_000)
|
||||
)
|
||||
salt_fp = fingerprint_dir.name
|
||||
seed_key = self._derive_seed_key(
|
||||
@@ -1167,7 +1212,10 @@ class PasswordManager:
|
||||
fingerprint_dir=fingerprint_dir,
|
||||
)
|
||||
|
||||
seed_mgr.encrypt_parent_seed(parent_seed)
|
||||
seed_mgr.encrypt_parent_seed(
|
||||
parent_seed,
|
||||
kdf=self._build_seed_kdf_config(fingerprint),
|
||||
)
|
||||
logging.info("Parent seed encrypted and saved successfully.")
|
||||
|
||||
self.store_hashed_password(password)
|
||||
@@ -1364,7 +1412,13 @@ class PasswordManager:
|
||||
self.store_hashed_password(password)
|
||||
logging.info("User password hashed and stored successfully.")
|
||||
|
||||
seed_mgr.encrypt_parent_seed(seed)
|
||||
seed_mgr.encrypt_parent_seed(
|
||||
seed,
|
||||
kdf=self._build_seed_kdf_config(
|
||||
fingerprint_dir.name,
|
||||
mode=self._get_kdf_mode(),
|
||||
),
|
||||
)
|
||||
logging.info("Parent seed encrypted and saved successfully.")
|
||||
|
||||
self.parent_seed = seed # Ensure this is a string
|
||||
@@ -4212,10 +4266,28 @@ class PasswordManager:
|
||||
parent_fingerprint=parent_fp,
|
||||
child_fingerprint=child_fp,
|
||||
)
|
||||
if encrypt is None:
|
||||
encrypt = not confirm_action(
|
||||
"Export database without encryption? (Y/N): "
|
||||
if encrypt is False:
|
||||
logger.warning("Exporting database in plaintext mode")
|
||||
self.notify(
|
||||
"Warning: plaintext export selected; protect and delete the file quickly.",
|
||||
level="WARNING",
|
||||
)
|
||||
if encrypt is None:
|
||||
if confirm_action("Export database without encryption? (Y/N): "):
|
||||
print(
|
||||
colored(
|
||||
"Warning: plaintext export contains all secrets in clear text.",
|
||||
"red",
|
||||
)
|
||||
)
|
||||
if not confirm_action(
|
||||
"Confirm plaintext export (high risk). Continue? (Y/N): "
|
||||
):
|
||||
print(colored("Export cancelled.", "yellow"))
|
||||
return None
|
||||
encrypt = False
|
||||
else:
|
||||
encrypt = True
|
||||
path = export_backup(
|
||||
self.vault,
|
||||
self.backup_manager,
|
||||
@@ -4226,7 +4298,13 @@ class PasswordManager:
|
||||
print(colored(f"Database exported to '{path}'.", "green"))
|
||||
audit_logger = getattr(self, "audit_logger", None)
|
||||
if audit_logger and path is not None:
|
||||
audit_logger.log("backup_export", {"path": str(path)})
|
||||
audit_logger.log(
|
||||
"backup_export",
|
||||
{
|
||||
"path": str(path),
|
||||
"encryption_mode": "encrypted" if encrypt else "none",
|
||||
},
|
||||
)
|
||||
return path
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to export database: {e}", exc_info=True)
|
||||
@@ -4301,7 +4379,10 @@ class PasswordManager:
|
||||
password = prompt_new_password()
|
||||
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)
|
||||
seed_mgr.encrypt_parent_seed(
|
||||
self.parent_seed,
|
||||
kdf=self._build_seed_kdf_config(self.current_fingerprint),
|
||||
)
|
||||
self.store_hashed_password(password)
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
@@ -4603,7 +4684,10 @@ class PasswordManager:
|
||||
|
||||
new_enc_mgr = EncryptionManager(new_key, self.fingerprint_dir)
|
||||
|
||||
seed_mgr.encrypt_parent_seed(self.parent_seed)
|
||||
seed_mgr.encrypt_parent_seed(
|
||||
self.parent_seed,
|
||||
kdf=self._build_seed_kdf_config(self.current_fingerprint),
|
||||
)
|
||||
self.vault.set_encryption_manager(new_enc_mgr)
|
||||
self.vault.save_index(index_data)
|
||||
self.config_manager.vault = self.vault
|
||||
|
||||
@@ -24,7 +24,7 @@ def test_config_defaults_and_round_trip():
|
||||
assert cfg["password_hash"] == ""
|
||||
assert cfg["additional_backup_path"] == ""
|
||||
assert cfg["quick_unlock_enabled"] is False
|
||||
assert cfg["kdf_iterations"] == 50_000
|
||||
assert cfg["kdf_iterations"] == 200_000
|
||||
|
||||
cfg_mgr.set_pin("1234")
|
||||
cfg_mgr.set_relays(["wss://example.com"], require_pin=False)
|
||||
@@ -155,7 +155,7 @@ def test_kdf_iterations_round_trip():
|
||||
vault, _ = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD)
|
||||
cfg_mgr = ConfigManager(vault, Path(tmpdir))
|
||||
|
||||
assert cfg_mgr.get_kdf_iterations() == 50_000
|
||||
assert cfg_mgr.get_kdf_iterations() == 200_000
|
||||
|
||||
cfg_mgr.set_kdf_iterations(200_000)
|
||||
assert cfg_mgr.get_kdf_iterations() == 200_000
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import queue
|
||||
from pathlib import Path
|
||||
|
||||
from seedpass.core.manager import PasswordManager
|
||||
import seedpass.core.manager as manager_module
|
||||
|
||||
|
||||
def _make_pm(tmp_path: Path) -> PasswordManager:
|
||||
pm = PasswordManager.__new__(PasswordManager)
|
||||
pm.fingerprint_dir = tmp_path
|
||||
pm.current_fingerprint = "user123"
|
||||
pm.profile_stack = []
|
||||
pm.notifications = queue.Queue()
|
||||
pm.vault = object()
|
||||
pm.backup_manager = object()
|
||||
pm.notify = lambda *_a, **_k: None
|
||||
return pm
|
||||
|
||||
|
||||
def test_plaintext_export_requires_second_confirmation(monkeypatch, tmp_path):
|
||||
pm = _make_pm(tmp_path)
|
||||
monkeypatch.setattr(
|
||||
manager_module, "clear_header_with_notification", lambda *a, **k: None
|
||||
)
|
||||
|
||||
confirms = iter([True, False])
|
||||
monkeypatch.setattr(
|
||||
"seedpass.core.manager.confirm_action",
|
||||
lambda *_a, **_k: next(confirms),
|
||||
)
|
||||
called = {"count": 0}
|
||||
monkeypatch.setattr(
|
||||
manager_module,
|
||||
"export_backup",
|
||||
lambda *_a, **_k: called.__setitem__("count", called["count"] + 1),
|
||||
)
|
||||
|
||||
result = pm.handle_export_database()
|
||||
assert result is None
|
||||
assert called["count"] == 0
|
||||
@@ -1,9 +1,11 @@
|
||||
import bcrypt
|
||||
import hashlib
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from types import SimpleNamespace
|
||||
import pytest
|
||||
|
||||
from utils.key_derivation import (
|
||||
derive_key_from_password,
|
||||
@@ -15,6 +17,8 @@ from seedpass.core.encryption import EncryptionManager
|
||||
from seedpass.core.vault import Vault
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
from seedpass.core.manager import PasswordManager, EncryptionMode
|
||||
from seedpass.core.errors import DecryptionError
|
||||
from seedpass.core.encryption import LegacyFormatRequiresMigrationError
|
||||
|
||||
TEST_SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
TEST_PASSWORD = "pw"
|
||||
@@ -168,3 +172,87 @@ def test_derive_seed_key_pbkdf2_respects_iteration_override(monkeypatch):
|
||||
|
||||
assert key == b"derived-pbkdf2-key"
|
||||
assert captured == {"password": "pw", "fingerprint": "fp", "iterations": 123}
|
||||
|
||||
|
||||
def test_derive_seed_key_pbkdf2_enforces_policy_floor(monkeypatch):
|
||||
pm = PasswordManager.__new__(PasswordManager)
|
||||
pm.config_manager = SimpleNamespace(
|
||||
get_kdf_mode=lambda: "pbkdf2",
|
||||
get_argon2_time_cost=lambda: 2,
|
||||
get_kdf_iterations=lambda: 1,
|
||||
)
|
||||
|
||||
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")
|
||||
|
||||
assert key == b"derived-pbkdf2-key"
|
||||
assert captured["iterations"] == ConfigManager.DEFAULT_PBKDF2_ITERATIONS
|
||||
|
||||
|
||||
def test_tampered_kdf_wrapper_payload_is_rejected(tmp_path):
|
||||
fp = "profile123"
|
||||
cfg = KdfConfig(
|
||||
name="pbkdf2",
|
||||
params={"iterations": 123_456},
|
||||
salt_b64=base64.b64encode(hashlib.sha256(fp.encode()).digest()[:16]).decode(),
|
||||
)
|
||||
key = derive_key_from_password(TEST_PASSWORD, fp, iterations=123_456)
|
||||
mgr = EncryptionManager(key, tmp_path)
|
||||
mgr.encrypt_parent_seed(TEST_SEED, kdf=cfg)
|
||||
|
||||
seed_file = tmp_path / "parent_seed.enc"
|
||||
wrapper = json.loads(seed_file.read_text())
|
||||
wrapper["ct"] = "!!!not-valid-base64!!!"
|
||||
seed_file.write_text(json.dumps(wrapper))
|
||||
|
||||
wrong_mgr = EncryptionManager(key, tmp_path)
|
||||
with pytest.raises((DecryptionError, LegacyFormatRequiresMigrationError)):
|
||||
wrong_mgr.decrypt_parent_seed()
|
||||
|
||||
|
||||
def test_setup_encryption_manager_rejects_wrong_argon2_params(monkeypatch):
|
||||
with TemporaryDirectory() as td:
|
||||
tmp = Path(td)
|
||||
fp = tmp.name
|
||||
cfg = KdfConfig(
|
||||
params={
|
||||
"time_cost": 1,
|
||||
"memory_cost": 64 * 1024,
|
||||
"parallelism": 8,
|
||||
},
|
||||
salt_b64=base64.b64encode(
|
||||
hashlib.sha256(fp.encode()).digest()[:16]
|
||||
).decode(),
|
||||
)
|
||||
seed_key = derive_key_from_password_argon2(TEST_PASSWORD, cfg)
|
||||
EncryptionManager(seed_key, tmp).encrypt_parent_seed(TEST_SEED, kdf=cfg)
|
||||
|
||||
index_key = derive_index_key(TEST_SEED)
|
||||
enc_mgr = EncryptionManager(index_key, tmp)
|
||||
vault = Vault(enc_mgr, tmp)
|
||||
cfg_mgr = ConfigManager(vault, tmp)
|
||||
profile_cfg = cfg_mgr.load_config(require_pin=False)
|
||||
profile_cfg["password_hash"] = bcrypt.hashpw(
|
||||
TEST_PASSWORD.encode(), bcrypt.gensalt()
|
||||
).decode()
|
||||
profile_cfg["kdf_mode"] = "argon2"
|
||||
profile_cfg["argon2_time_cost"] = 2
|
||||
cfg_mgr.save_config(profile_cfg)
|
||||
|
||||
pm = _make_pm(tmp, cfg_mgr)
|
||||
monkeypatch.setattr(
|
||||
"seedpass.core.manager.prompt_existing_password",
|
||||
lambda *_: TEST_PASSWORD,
|
||||
)
|
||||
monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda self: None)
|
||||
monkeypatch.setattr(PasswordManager, "initialize_managers", lambda self: None)
|
||||
|
||||
assert not pm.setup_encryption_manager(tmp, exit_on_fail=False)
|
||||
@@ -16,4 +16,4 @@ def test_kdf_strength_slider_persists(monkeypatch):
|
||||
inputs = iter(["3"])
|
||||
monkeypatch.setattr("builtins.input", lambda *_: next(inputs))
|
||||
handle_set_kdf_iterations(pm)
|
||||
assert cfg_mgr.get_kdf_iterations() == 100_000
|
||||
assert cfg_mgr.get_kdf_iterations() == 500_000
|
||||
Reference in new issue
Block a user