Harden security item 2: KDF policy, export safeguards, and tests

This commit is contained in:
thePR0M3TH3AN committed 2026-02-25 18:15:51 -05:00
1 parent c3eef44ab7
commit fcbe2b2943
13 files changed
+290 -126

No files matched your search

+5 -5
View File
@@ -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()
+1 -1
View File
@@ -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"
+5 -3
View File
@@ -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."""
+1 -9
View File
@@ -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:
+97 -13
View File
@@ -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
+2 -2
View File
@@ -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
+40
View File
@@ -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
+88
View File
@@ -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)
+1 -1
View File
@@ -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