Add Argon2 key derivation option

This commit is contained in:
thePR0M3TH3AN
2025-07-13 12:24:10 -04:00
parent 37d4cc260d
commit f86067c1d8
9 changed files with 158 additions and 2 deletions

View File

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

View File

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

View File

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

View File

@@ -31,3 +31,4 @@ httpx>=0.28.1
requests>=2.32
python-multipart
orjson
argon2-cffi

View File

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

View File

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

View File

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

View File

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

View File

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