diff --git a/requirements.lock b/requirements.lock index f762a5c..f3e21b4 100644 --- a/requirements.lock +++ b/requirements.lock @@ -2,6 +2,7 @@ aiohappyeyeballs==2.6.1 aiohttp==3.12.13 aiosignal==1.3.2 attrs==25.3.0 +argon2-cffi==23.1.0 base58==2.1.1 bcrypt==4.3.0 bech32==1.2.0 diff --git a/src/password_manager/config_manager.py b/src/password_manager/config_manager.py index c2c23a1..18a46ab 100644 --- a/src/password_manager/config_manager.py +++ b/src/password_manager/config_manager.py @@ -45,6 +45,7 @@ class ConfigManager: "password_hash": "", "inactivity_timeout": INACTIVITY_TIMEOUT, "kdf_iterations": 100_000, + "kdf_mode": "pbkdf2", "additional_backup_path": "", "backup_interval": 0, "secret_mode_enabled": False, @@ -60,6 +61,7 @@ class ConfigManager: data.setdefault("password_hash", "") data.setdefault("inactivity_timeout", INACTIVITY_TIMEOUT) data.setdefault("kdf_iterations", 100_000) + data.setdefault("kdf_mode", "pbkdf2") data.setdefault("additional_backup_path", "") data.setdefault("backup_interval", 0) data.setdefault("secret_mode_enabled", False) @@ -155,6 +157,19 @@ class ConfigManager: config = self.load_config(require_pin=False) return int(config.get("kdf_iterations", 100_000)) + def set_kdf_mode(self, mode: str) -> None: + """Persist the key derivation function mode.""" + if mode not in ("pbkdf2", "argon2"): + raise ValueError("kdf_mode must be 'pbkdf2' or 'argon2'") + config = self.load_config(require_pin=False) + config["kdf_mode"] = mode + self.save_config(config) + + def get_kdf_mode(self) -> str: + """Retrieve the configured key derivation function.""" + config = self.load_config(require_pin=False) + return config.get("kdf_mode", "pbkdf2") + def set_additional_backup_path(self, path: Optional[str]) -> None: """Persist an optional additional backup path in the config.""" config = self.load_config(require_pin=False) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index e631bb4..65a044f 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -35,6 +35,7 @@ from password_manager.entry_types import EntryType from utils.key_derivation import ( derive_key_from_parent_seed, derive_key_from_password, + derive_key_from_password_argon2, derive_index_key, EncryptionMode, ) @@ -387,13 +388,21 @@ class PasswordManager: if password is None: password = prompt_existing_password("Enter your master password: ") + mode = ( + self.config_manager.get_kdf_mode() + if getattr(self, "config_manager", None) + else "pbkdf2" + ) iterations = ( self.config_manager.get_kdf_iterations() if getattr(self, "config_manager", None) else 100_000 ) print("Deriving key...") - seed_key = derive_key_from_password(password, iterations=iterations) + if mode == "argon2": + seed_key = derive_key_from_password_argon2(password) + else: + seed_key = derive_key_from_password(password, iterations=iterations) seed_mgr = EncryptionManager(seed_key, fingerprint_dir) print("Decrypting seed...") try: @@ -448,12 +457,20 @@ class PasswordManager: password = prompt_existing_password("Enter your master password: ") try: + mode = ( + self.config_manager.get_kdf_mode() + if getattr(self, "config_manager", None) + else "pbkdf2" + ) iterations = ( self.config_manager.get_kdf_iterations() if getattr(self, "config_manager", None) else 100_000 ) - seed_key = derive_key_from_password(password, iterations=iterations) + if mode == "argon2": + seed_key = derive_key_from_password_argon2(password) + else: + seed_key = derive_key_from_password(password, iterations=iterations) seed_mgr = EncryptionManager(seed_key, fingerprint_dir) self.parent_seed = seed_mgr.decrypt_parent_seed() seed_bytes = Bip39SeedGenerator(self.parent_seed).Generate() diff --git a/src/requirements.txt b/src/requirements.txt index 64539a2..396ec2c 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -31,3 +31,4 @@ httpx>=0.28.1 requests>=2.32 python-multipart orjson +argon2-cffi diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index 66c1624..27776e1 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -462,6 +462,7 @@ def config_set(ctx: typer.Context, key: str, value: str) -> None: [r.strip() for r in v.split(",") if r.strip()], require_pin=False ), "kdf_iterations": lambda v: cfg.set_kdf_iterations(int(v)), + "kdf_mode": lambda v: cfg.set_kdf_mode(v), "backup_interval": lambda v: cfg.set_backup_interval(float(v)), } diff --git a/src/tests/test_cli_config_set_extra.py b/src/tests/test_cli_config_set_extra.py index 97af474..95abdbd 100644 --- a/src/tests/test_cli_config_set_extra.py +++ b/src/tests/test_cli_config_set_extra.py @@ -16,6 +16,7 @@ runner = CliRunner() ("additional_backup_path", "", "set_additional_backup_path", None), ("backup_interval", "5", "set_backup_interval", 5.0), ("kdf_iterations", "123", "set_kdf_iterations", 123), + ("kdf_mode", "argon2", "set_kdf_mode", "argon2"), ( "relays", "wss://a.com, wss://b.com", diff --git a/src/tests/test_kdf_modes.py b/src/tests/test_kdf_modes.py new file mode 100644 index 0000000..ab453de --- /dev/null +++ b/src/tests/test_kdf_modes.py @@ -0,0 +1,75 @@ +import bcrypt +from pathlib import Path +from tempfile import TemporaryDirectory +from types import SimpleNamespace + +from utils.key_derivation import ( + derive_key_from_password, + derive_key_from_password_argon2, + derive_index_key, +) +from password_manager.encryption import EncryptionManager +from password_manager.vault import Vault +from password_manager.config_manager import ConfigManager +from password_manager.manager import PasswordManager, EncryptionMode + +TEST_SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" +TEST_PASSWORD = "pw" + + +def _setup_profile(tmp: Path, mode: str): + argon_kwargs = dict(time_cost=1, memory_cost=8, parallelism=1) + if mode == "argon2": + seed_key = derive_key_from_password_argon2(TEST_PASSWORD, **argon_kwargs) + else: + seed_key = derive_key_from_password(TEST_PASSWORD, iterations=1) + EncryptionManager(seed_key, tmp).encrypt_parent_seed(TEST_SEED) + + index_key = derive_index_key(TEST_SEED) + enc_mgr = EncryptionManager(index_key, tmp) + vault = Vault(enc_mgr, tmp) + cfg_mgr = ConfigManager(vault, tmp) + cfg = cfg_mgr.load_config(require_pin=False) + cfg["password_hash"] = bcrypt.hashpw( + TEST_PASSWORD.encode(), bcrypt.gensalt() + ).decode() + cfg["kdf_mode"] = mode + cfg["kdf_iterations"] = 1 + cfg_mgr.save_config(cfg) + return cfg_mgr + + +def _make_pm(tmp: Path, cfg: ConfigManager): + pm = PasswordManager.__new__(PasswordManager) + pm.encryption_mode = EncryptionMode.SEED_ONLY + pm.config_manager = cfg + pm.fingerprint_dir = tmp + pm.current_fingerprint = "fp" + pm.verify_password = lambda pw: True + return pm + + +def test_setup_encryption_manager_kdf_modes(monkeypatch): + with TemporaryDirectory() as td: + tmp = Path(td) + argon_kwargs = dict(time_cost=1, memory_cost=8, parallelism=1) + for mode in ("pbkdf2", "argon2"): + path = tmp / mode + path.mkdir() + cfg = _setup_profile(path, mode) + pm = _make_pm(path, cfg) + monkeypatch.setattr( + "password_manager.manager.prompt_existing_password", + lambda *_: TEST_PASSWORD, + ) + if mode == "argon2": + monkeypatch.setattr( + "password_manager.manager.derive_key_from_password_argon2", + lambda pw: derive_key_from_password_argon2(pw, **argon_kwargs), + ) + monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda self: None) + monkeypatch.setattr( + PasswordManager, "initialize_managers", lambda self: None + ) + assert pm.setup_encryption_manager(path, exit_on_fail=False) + assert pm.parent_seed == TEST_SEED diff --git a/src/tests/test_key_derivation.py b/src/tests/test_key_derivation.py index a1ea90f..bfa0c65 100644 --- a/src/tests/test_key_derivation.py +++ b/src/tests/test_key_derivation.py @@ -2,6 +2,7 @@ import logging import pytest from utils.key_derivation import ( derive_key_from_password, + derive_key_from_password_argon2, derive_index_key_seed_only, derive_index_key, ) @@ -33,3 +34,11 @@ def test_seed_only_key_deterministic(): def test_derive_index_key_seed_only(): seed = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" assert derive_index_key(seed) == derive_index_key_seed_only(seed) + + +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) + assert k1 == k2 + assert len(k1) == 44 diff --git a/src/utils/key_derivation.py b/src/utils/key_derivation.py index d71b26c..091ef46 100644 --- a/src/utils/key_derivation.py +++ b/src/utils/key_derivation.py @@ -97,6 +97,42 @@ def derive_key_from_password(password: str, iterations: int = 100_000) -> bytes: raise +def derive_key_from_password_argon2( + password: str, + *, + time_cost: int = 2, + memory_cost: int = 64 * 1024, + parallelism: int = 8, +) -> bytes: + """Derive an encryption key from a password using Argon2id. + + The defaults follow recommended parameters but omit a salt for deterministic + output. Smaller values may be supplied for testing. + """ + + if not password: + logger.error("Password cannot be empty.") + raise ValueError("Password cannot be empty.") + + normalized = unicodedata.normalize("NFKD", password).strip().encode("utf-8") + try: + from argon2.low_level import hash_secret_raw, Type + + key = hash_secret_raw( + secret=normalized, + salt=b"\x00" * 16, + time_cost=time_cost, + memory_cost=memory_cost, + parallelism=parallelism, + hash_len=32, + type=Type.ID, + ) + return base64.urlsafe_b64encode(key) + except Exception as e: # pragma: no cover - pass through errors + logger.error(f"Error deriving key with Argon2id: {e}", exc_info=True) + raise + + def derive_key_from_parent_seed(parent_seed: str, fingerprint: str = None) -> bytes: """ Derives a 32-byte cryptographic key from a BIP-39 parent seed using HKDF.