From 5423c41b06b79dd4ce5f0ded3ba0f53bbd6588b7 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 3 Aug 2025 09:37:59 -0400 Subject: [PATCH] Include fingerprint salt in password key derivation --- scripts/generate_test_profile.py | 2 +- src/seedpass/core/manager.py | 48 ++++++++++++------- src/tests/helpers.py | 4 +- src/tests/test_concurrency_stress.py | 4 +- src/tests/test_fuzz_key_derivation.py | 6 ++- src/tests/test_index_import_export.py | 4 +- src/tests/test_kdf_modes.py | 11 +++-- src/tests/test_key_derivation.py | 16 +++++-- .../test_password_unlock_after_change.py | 9 ++-- src/tests/test_portable_backup.py | 7 ++- src/tests/test_seed_import.py | 7 +-- src/tests/test_seed_migration.py | 4 +- src/utils/key_derivation.py | 24 ++++++++-- 13 files changed, 102 insertions(+), 44 deletions(-) diff --git a/scripts/generate_test_profile.py b/scripts/generate_test_profile.py index 00e7aa0..90c3301 100644 --- a/scripts/generate_test_profile.py +++ b/scripts/generate_test_profile.py @@ -79,7 +79,7 @@ def initialize_profile( profile_dir = APP_DIR / fingerprint profile_dir.mkdir(parents=True, exist_ok=True) - seed_key = derive_key_from_password(DEFAULT_PASSWORD) + seed_key = derive_key_from_password(DEFAULT_PASSWORD, fingerprint) seed_mgr = EncryptionManager(seed_key, profile_dir) seed_file = profile_dir / "parent_seed.enc" clear_path = profile_dir / "seed_phrase.txt" diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index 12b109a..00b19ad 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -510,10 +510,13 @@ class PasswordManager: else 50_000 ) print("Deriving key...") + salt_fp = fingerprint_dir.name if mode == "argon2": - seed_key = derive_key_from_password_argon2(password) + seed_key = derive_key_from_password_argon2(password, salt_fp) else: - seed_key = derive_key_from_password(password, iterations=iterations) + seed_key = derive_key_from_password( + password, salt_fp, iterations=iterations + ) seed_mgr = EncryptionManager(seed_key, fingerprint_dir) print("Decrypting seed...") try: @@ -578,10 +581,13 @@ class PasswordManager: if getattr(self, "config_manager", None) else 50_000 ) + salt_fp = fingerprint_dir.name if mode == "argon2": - seed_key = derive_key_from_password_argon2(password) + seed_key = derive_key_from_password_argon2(password, salt_fp) else: - seed_key = derive_key_from_password(password, iterations=iterations) + seed_key = derive_key_from_password( + password, salt_fp, iterations=iterations + ) seed_mgr = EncryptionManager(seed_key, fingerprint_dir) self.parent_seed = seed_mgr.decrypt_parent_seed() seed_bytes = Bip39SeedGenerator(self.parent_seed).Generate() @@ -746,14 +752,6 @@ class PasswordManager: if password is None: password = prompt_existing_password("Enter your login password: ") - # Derive encryption key from password - iterations = ( - self.config_manager.get_kdf_iterations() - if getattr(self, "config_manager", None) - else 50_000 - ) - key = derive_key_from_password(password, iterations=iterations) - # Initialize FingerprintManager if not already initialized if not self.fingerprint_manager: self.initialize_fingerprint_manager() @@ -792,6 +790,16 @@ class PasswordManager: print(colored("Error: Seed profile directory not found.", "red")) sys.exit(1) + # 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 + ) + # Initialize EncryptionManager with key and fingerprint_dir self.encryption_manager = EncryptionManager(key, fingerprint_dir) self.vault = Vault(self.encryption_manager, fingerprint_dir) @@ -927,7 +935,9 @@ class PasswordManager: if getattr(self, "config_manager", None) else 50_000 ) - seed_key = derive_key_from_password(password, iterations=iterations) + seed_key = derive_key_from_password( + password, fingerprint, iterations=iterations + ) self.encryption_manager = EncryptionManager(index_key, fingerprint_dir) seed_mgr = EncryptionManager(seed_key, fingerprint_dir) @@ -1077,7 +1087,9 @@ class PasswordManager: if getattr(self, "config_manager", None) else 50_000 ) - seed_key = derive_key_from_password(password, iterations=iterations) + seed_key = derive_key_from_password( + password, fingerprint_dir.name, iterations=iterations + ) self.encryption_manager = EncryptionManager(index_key, fingerprint_dir) seed_mgr = EncryptionManager(seed_key, fingerprint_dir) @@ -4206,7 +4218,9 @@ class PasswordManager: if confirm_action("Encrypt export with a password? (Y/N): "): password = prompt_new_password() iterations = self.config_manager.get_kdf_iterations() - key = derive_key_from_password(password, iterations=iterations) + key = derive_key_from_password( + password, self.current_fingerprint, iterations=iterations + ) enc_mgr = EncryptionManager(key, dest.parent) data_bytes = enc_mgr.encrypt_data(json_data.encode("utf-8")) dest = dest.with_suffix(dest.suffix + ".enc") @@ -4421,7 +4435,9 @@ class PasswordManager: new_key = derive_index_key(self.parent_seed) iterations = self.config_manager.get_kdf_iterations() - seed_key = derive_key_from_password(new_password, iterations=iterations) + seed_key = derive_key_from_password( + new_password, self.current_fingerprint, iterations=iterations + ) seed_mgr = EncryptionManager(seed_key, self.fingerprint_dir) new_enc_mgr = EncryptionManager(new_key, self.fingerprint_dir) diff --git a/src/tests/helpers.py b/src/tests/helpers.py index c36fa65..8373cd3 100644 --- a/src/tests/helpers.py +++ b/src/tests/helpers.py @@ -11,6 +11,7 @@ from utils.key_derivation import ( derive_index_key, derive_key_from_password, ) +from utils.fingerprint import generate_fingerprint TEST_SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" TEST_PASSWORD = "pw" @@ -22,7 +23,8 @@ def create_vault( password: str = TEST_PASSWORD, ) -> tuple[Vault, EncryptionManager]: """Create a Vault initialized for tests.""" - seed_key = derive_key_from_password(password) + fp = generate_fingerprint(seed) + seed_key = derive_key_from_password(password, fp) seed_mgr = EncryptionManager(seed_key, dir_path) seed_mgr.encrypt_parent_seed(seed) diff --git a/src/tests/test_concurrency_stress.py b/src/tests/test_concurrency_stress.py index 2c145c4..1f3ce41 100644 --- a/src/tests/test_concurrency_stress.py +++ b/src/tests/test_concurrency_stress.py @@ -3,6 +3,7 @@ from pathlib import Path from multiprocessing import Process, Queue import pytest from helpers import TEST_SEED, TEST_PASSWORD +from utils.fingerprint import generate_fingerprint sys.path.append(str(Path(__file__).resolve().parents[1])) @@ -51,7 +52,8 @@ def _backup(index_key: bytes, dir_path: Path, loops: int, out: Queue) -> None: @pytest.mark.parametrize("_", range(3)) def test_concurrency_stress(tmp_path: Path, loops: int, _): index_key = derive_index_key(TEST_SEED) - seed_key = derive_key_from_password(TEST_PASSWORD) + fp = generate_fingerprint(TEST_SEED) + seed_key = derive_key_from_password(TEST_PASSWORD, fp) EncryptionManager(seed_key, tmp_path).encrypt_parent_seed(TEST_SEED) enc = EncryptionManager(index_key, tmp_path) Vault(enc, tmp_path).save_index({"counter": 0}) diff --git a/src/tests/test_fuzz_key_derivation.py b/src/tests/test_fuzz_key_derivation.py index 89e26c8..a15b760 100644 --- a/src/tests/test_fuzz_key_derivation.py +++ b/src/tests/test_fuzz_key_derivation.py @@ -9,6 +9,7 @@ from utils.key_derivation import ( derive_key_from_password_argon2, derive_index_key, ) +from utils.fingerprint import generate_fingerprint from seedpass.core.encryption import EncryptionManager @@ -33,12 +34,13 @@ cfg_values = st.one_of( def test_fuzz_key_round_trip(password, seed_bytes, config, mode, tmp_path: Path): """Ensure EncryptionManager round-trips arbitrary data.""" seed_phrase = Mnemonic("english").to_mnemonic(seed_bytes) + fp = generate_fingerprint(seed_phrase) if mode == "argon2": key = derive_key_from_password_argon2( - password, time_cost=1, memory_cost=8, parallelism=1 + password, fp, time_cost=1, memory_cost=8, parallelism=1 ) else: - key = derive_key_from_password(password, iterations=1) + key = derive_key_from_password(password, fp, iterations=1) enc_mgr = EncryptionManager(key, tmp_path) diff --git a/src/tests/test_index_import_export.py b/src/tests/test_index_import_export.py index a9ee75a..f549278 100644 --- a/src/tests/test_index_import_export.py +++ b/src/tests/test_index_import_export.py @@ -4,6 +4,7 @@ from tempfile import TemporaryDirectory import pytest import sys from helpers import create_vault, TEST_SEED, TEST_PASSWORD +from utils.fingerprint import generate_fingerprint sys.path.append(str(Path(__file__).resolve().parents[1])) @@ -16,7 +17,8 @@ PASSWORD = "passw0rd" def setup_vault(tmp: Path) -> Vault: - seed_key = derive_key_from_password(PASSWORD) + fp = generate_fingerprint(SEED) + seed_key = derive_key_from_password(PASSWORD, fp) seed_mgr = EncryptionManager(seed_key, tmp) seed_mgr.encrypt_parent_seed(SEED) diff --git a/src/tests/test_kdf_modes.py b/src/tests/test_kdf_modes.py index 177d050..2cb0212 100644 --- a/src/tests/test_kdf_modes.py +++ b/src/tests/test_kdf_modes.py @@ -19,10 +19,11 @@ TEST_PASSWORD = "pw" def _setup_profile(tmp: Path, mode: str): argon_kwargs = dict(time_cost=1, memory_cost=8, parallelism=1) + fp = tmp.name if mode == "argon2": - seed_key = derive_key_from_password_argon2(TEST_PASSWORD, **argon_kwargs) + seed_key = derive_key_from_password_argon2(TEST_PASSWORD, fp, **argon_kwargs) else: - seed_key = derive_key_from_password(TEST_PASSWORD, iterations=1) + seed_key = derive_key_from_password(TEST_PASSWORD, fp, iterations=1) EncryptionManager(seed_key, tmp).encrypt_parent_seed(TEST_SEED) index_key = derive_index_key(TEST_SEED) @@ -44,7 +45,7 @@ def _make_pm(tmp: Path, cfg: ConfigManager): pm.encryption_mode = EncryptionMode.SEED_ONLY pm.config_manager = cfg pm.fingerprint_dir = tmp - pm.current_fingerprint = "fp" + pm.current_fingerprint = tmp.name pm.verify_password = lambda pw: True return pm @@ -65,7 +66,9 @@ def test_setup_encryption_manager_kdf_modes(monkeypatch): if mode == "argon2": monkeypatch.setattr( "seedpass.core.manager.derive_key_from_password_argon2", - lambda pw: derive_key_from_password_argon2(pw, **argon_kwargs), + lambda pw, fp: derive_key_from_password_argon2( + pw, fp, **argon_kwargs + ), ) monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda self: None) monkeypatch.setattr( diff --git a/src/tests/test_key_derivation.py b/src/tests/test_key_derivation.py index bfa0c65..e1806f3 100644 --- a/src/tests/test_key_derivation.py +++ b/src/tests/test_key_derivation.py @@ -10,8 +10,9 @@ from utils.key_derivation import ( def test_derive_key_deterministic(): password = "correct horse battery staple" - key1 = derive_key_from_password(password, iterations=1) - key2 = derive_key_from_password(password, iterations=1) + fp = "fp" + key1 = derive_key_from_password(password, fp, iterations=1) + key2 = derive_key_from_password(password, fp, iterations=1) assert key1 == key2 assert len(key1) == 44 logging.info("Deterministic key derivation succeeded") @@ -19,7 +20,7 @@ def test_derive_key_deterministic(): def test_derive_key_empty_password_error(): with pytest.raises(ValueError): - derive_key_from_password("") + derive_key_from_password("", "fp") logging.info("Empty password correctly raised ValueError") @@ -38,7 +39,12 @@ def test_derive_index_key_seed_only(): def test_argon2_key_deterministic(): pw = "correct horse battery staple" - k1 = derive_key_from_password_argon2(pw, time_cost=1, memory_cost=8, parallelism=1) - k2 = derive_key_from_password_argon2(pw, time_cost=1, memory_cost=8, parallelism=1) + fp = "fp" + k1 = derive_key_from_password_argon2( + pw, fp, time_cost=1, memory_cost=8, parallelism=1 + ) + k2 = derive_key_from_password_argon2( + pw, fp, time_cost=1, memory_cost=8, parallelism=1 + ) assert k1 == k2 assert len(k1) == 44 diff --git a/src/tests/test_password_unlock_after_change.py b/src/tests/test_password_unlock_after_change.py index a0bda0a..f8a5916 100644 --- a/src/tests/test_password_unlock_after_change.py +++ b/src/tests/test_password_unlock_after_change.py @@ -14,19 +14,22 @@ from seedpass.core.backup import BackupManager from seedpass.core.config_manager import ConfigManager from seedpass.core.manager import PasswordManager, EncryptionMode from utils.key_derivation import derive_index_key, derive_key_from_password +from utils.fingerprint import generate_fingerprint SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" def test_password_change_and_unlock(monkeypatch): with TemporaryDirectory() as tmpdir: - fp = Path(tmpdir) + fp_name = generate_fingerprint(SEED) + fp = Path(tmpdir) / fp_name + fp.mkdir() old_pw = "oldpw" new_pw = "newpw" # initial encryption setup index_key = derive_index_key(SEED) - seed_key = derive_key_from_password(old_pw) + seed_key = derive_key_from_password(old_pw, fp_name) enc_mgr = EncryptionManager(index_key, fp) seed_mgr = EncryptionManager(seed_key, fp) vault = Vault(enc_mgr, fp) @@ -54,7 +57,7 @@ def test_password_change_and_unlock(monkeypatch): pm.vault = vault pm.password_generator = SimpleNamespace(encryption_manager=enc_mgr) pm.fingerprint_dir = fp - pm.current_fingerprint = "fp" + pm.current_fingerprint = fp_name pm.parent_seed = SEED pm.nostr_client = SimpleNamespace( publish_snapshot=lambda *a, **k: (None, "abcd") diff --git a/src/tests/test_portable_backup.py b/src/tests/test_portable_backup.py index 7e1b0ff..30bbbbf 100644 --- a/src/tests/test_portable_backup.py +++ b/src/tests/test_portable_backup.py @@ -15,6 +15,7 @@ from seedpass.core.backup import BackupManager from seedpass.core.config_manager import ConfigManager from seedpass.core.portable_backup import export_backup, import_backup from utils.key_derivation import derive_index_key, derive_key_from_password +from utils.fingerprint import generate_fingerprint SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" @@ -22,7 +23,8 @@ PASSWORD = "passw0rd" def setup_vault(tmp: Path): - seed_key = derive_key_from_password(PASSWORD) + fp = generate_fingerprint(SEED) + seed_key = derive_key_from_password(PASSWORD, fp) seed_mgr = EncryptionManager(seed_key, tmp) seed_mgr.encrypt_parent_seed(SEED) @@ -126,7 +128,8 @@ def test_export_creates_additional_backup_and_import(monkeypatch): with TemporaryDirectory() as td, TemporaryDirectory() as extra: tmp = Path(td) - seed_key = derive_key_from_password(PASSWORD) + fp = generate_fingerprint(SEED) + seed_key = derive_key_from_password(PASSWORD, fp) seed_mgr = EncryptionManager(seed_key, tmp) seed_mgr.encrypt_parent_seed(SEED) diff --git a/src/tests/test_seed_import.py b/src/tests/test_seed_import.py index 7cbbe6d..6d3f860 100644 --- a/src/tests/test_seed_import.py +++ b/src/tests/test_seed_import.py @@ -3,6 +3,7 @@ from pathlib import Path from tempfile import TemporaryDirectory from helpers import TEST_PASSWORD from utils.key_derivation import derive_key_from_password +from utils.fingerprint import generate_fingerprint from mnemonic import Mnemonic sys.path.append(str(Path(__file__).resolve().parents[1])) @@ -13,10 +14,10 @@ from seedpass.core.manager import PasswordManager, EncryptionMode def test_seed_encryption_round_trip(): with TemporaryDirectory() as tmpdir: - key = derive_key_from_password(TEST_PASSWORD) - enc_mgr = EncryptionManager(key, Path(tmpdir)) - seed = Mnemonic("english").generate(strength=128) + fp = generate_fingerprint(seed) + key = derive_key_from_password(TEST_PASSWORD, fp) + enc_mgr = EncryptionManager(key, Path(tmpdir)) enc_mgr.encrypt_parent_seed(seed) decrypted = enc_mgr.decrypt_parent_seed() diff --git a/src/tests/test_seed_migration.py b/src/tests/test_seed_migration.py index 845dfaa..a552187 100644 --- a/src/tests/test_seed_migration.py +++ b/src/tests/test_seed_migration.py @@ -4,6 +4,7 @@ from cryptography.fernet import Fernet from helpers import TEST_PASSWORD, TEST_SEED from utils.key_derivation import derive_key_from_password +from utils.fingerprint import generate_fingerprint sys.path.append(str(Path(__file__).resolve().parents[1])) @@ -11,7 +12,8 @@ from seedpass.core.encryption import EncryptionManager def test_parent_seed_migrates_from_fernet(tmp_path: Path) -> None: - key = derive_key_from_password(TEST_PASSWORD) + fp = generate_fingerprint(TEST_SEED) + key = derive_key_from_password(TEST_PASSWORD, fp) fernet = Fernet(key) encrypted = fernet.encrypt(TEST_SEED.encode()) legacy_file = tmp_path / "parent_seed.enc" diff --git a/src/utils/key_derivation.py b/src/utils/key_derivation.py index 091ef46..63cd077 100644 --- a/src/utils/key_derivation.py +++ b/src/utils/key_derivation.py @@ -45,7 +45,9 @@ DEFAULT_ENCRYPTION_MODE = EncryptionMode.SEED_ONLY TOTP_PURPOSE = 39 -def derive_key_from_password(password: str, iterations: int = 100_000) -> bytes: +def derive_key_from_password( + password: str, fingerprint: Union[str, bytes], iterations: int = 100_000 +) -> bytes: """ Derives a Fernet-compatible encryption key from the provided password using PBKDF2-HMAC-SHA256. @@ -55,7 +57,9 @@ def derive_key_from_password(password: str, iterations: int = 100_000) -> bytes: Parameters: password (str): The user's password. - iterations (int, optional): Number of iterations for the PBKDF2 algorithm. Defaults to 100,000. + fingerprint (str | bytes): Seed fingerprint or precomputed salt. + iterations (int, optional): Number of iterations for the PBKDF2 algorithm. + Defaults to 100,000. Returns: bytes: A URL-safe base64-encoded encryption key suitable for Fernet. @@ -74,13 +78,19 @@ def derive_key_from_password(password: str, iterations: int = 100_000) -> bytes: normalized_password = unicodedata.normalize("NFKD", password).strip() password_bytes = normalized_password.encode("utf-8") + # Derive a deterministic salt from the fingerprint + if isinstance(fingerprint, bytes): + salt = fingerprint + else: + salt = hashlib.sha256(fingerprint.encode()).digest()[:16] + try: # Derive the key using PBKDF2-HMAC-SHA256 logger.debug("Starting key derivation from password.") key = hashlib.pbkdf2_hmac( hash_name="sha256", password=password_bytes, - salt=b"", # No salt for deterministic key derivation + salt=salt, iterations=iterations, dklen=32, # 256-bit key for Fernet ) @@ -99,6 +109,7 @@ def derive_key_from_password(password: str, iterations: int = 100_000) -> bytes: def derive_key_from_password_argon2( password: str, + fingerprint: Union[str, bytes], *, time_cost: int = 2, memory_cost: int = 64 * 1024, @@ -118,9 +129,14 @@ def derive_key_from_password_argon2( try: from argon2.low_level import hash_secret_raw, Type + if isinstance(fingerprint, bytes): + salt = fingerprint + else: + salt = hashlib.sha256(fingerprint.encode()).digest()[:16] + key = hash_secret_raw( secret=normalized, - salt=b"\x00" * 16, + salt=salt, time_cost=time_cost, memory_cost=memory_cost, parallelism=parallelism,