Include fingerprint salt in password key derivation

This commit is contained in:
thePR0M3TH3AN
2025-08-03 09:37:59 -04:00
parent 2794b67d83
commit 5423c41b06
13 changed files with 102 additions and 44 deletions

View File

@@ -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"

View File

@@ -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)

View File

@@ -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)

View File

@@ -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})

View File

@@ -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)

View File

@@ -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)

View File

@@ -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(

View File

@@ -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

View File

@@ -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")

View File

@@ -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)

View File

@@ -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()

View File

@@ -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"

View File

@@ -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,