mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-09 15:58:48 +00:00
Merge pull request #500 from PR0M3TH3AN/codex/add-argon2-support-to-password-manager
Add argon2 KDF option
This commit is contained in:
@@ -2,6 +2,7 @@ aiohappyeyeballs==2.6.1
|
|||||||
aiohttp==3.12.13
|
aiohttp==3.12.13
|
||||||
aiosignal==1.3.2
|
aiosignal==1.3.2
|
||||||
attrs==25.3.0
|
attrs==25.3.0
|
||||||
|
argon2-cffi==23.1.0
|
||||||
base58==2.1.1
|
base58==2.1.1
|
||||||
bcrypt==4.3.0
|
bcrypt==4.3.0
|
||||||
bech32==1.2.0
|
bech32==1.2.0
|
||||||
|
@@ -45,6 +45,7 @@ class ConfigManager:
|
|||||||
"password_hash": "",
|
"password_hash": "",
|
||||||
"inactivity_timeout": INACTIVITY_TIMEOUT,
|
"inactivity_timeout": INACTIVITY_TIMEOUT,
|
||||||
"kdf_iterations": 100_000,
|
"kdf_iterations": 100_000,
|
||||||
|
"kdf_mode": "pbkdf2",
|
||||||
"additional_backup_path": "",
|
"additional_backup_path": "",
|
||||||
"backup_interval": 0,
|
"backup_interval": 0,
|
||||||
"secret_mode_enabled": False,
|
"secret_mode_enabled": False,
|
||||||
@@ -60,6 +61,7 @@ class ConfigManager:
|
|||||||
data.setdefault("password_hash", "")
|
data.setdefault("password_hash", "")
|
||||||
data.setdefault("inactivity_timeout", INACTIVITY_TIMEOUT)
|
data.setdefault("inactivity_timeout", INACTIVITY_TIMEOUT)
|
||||||
data.setdefault("kdf_iterations", 100_000)
|
data.setdefault("kdf_iterations", 100_000)
|
||||||
|
data.setdefault("kdf_mode", "pbkdf2")
|
||||||
data.setdefault("additional_backup_path", "")
|
data.setdefault("additional_backup_path", "")
|
||||||
data.setdefault("backup_interval", 0)
|
data.setdefault("backup_interval", 0)
|
||||||
data.setdefault("secret_mode_enabled", False)
|
data.setdefault("secret_mode_enabled", False)
|
||||||
@@ -155,6 +157,19 @@ class ConfigManager:
|
|||||||
config = self.load_config(require_pin=False)
|
config = self.load_config(require_pin=False)
|
||||||
return int(config.get("kdf_iterations", 100_000))
|
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:
|
def set_additional_backup_path(self, path: Optional[str]) -> None:
|
||||||
"""Persist an optional additional backup path in the config."""
|
"""Persist an optional additional backup path in the config."""
|
||||||
config = self.load_config(require_pin=False)
|
config = self.load_config(require_pin=False)
|
||||||
|
@@ -35,6 +35,7 @@ from password_manager.entry_types import EntryType
|
|||||||
from utils.key_derivation import (
|
from utils.key_derivation import (
|
||||||
derive_key_from_parent_seed,
|
derive_key_from_parent_seed,
|
||||||
derive_key_from_password,
|
derive_key_from_password,
|
||||||
|
derive_key_from_password_argon2,
|
||||||
derive_index_key,
|
derive_index_key,
|
||||||
EncryptionMode,
|
EncryptionMode,
|
||||||
)
|
)
|
||||||
@@ -387,13 +388,21 @@ class PasswordManager:
|
|||||||
if password is None:
|
if password is None:
|
||||||
password = prompt_existing_password("Enter your master password: ")
|
password = prompt_existing_password("Enter your master password: ")
|
||||||
|
|
||||||
|
mode = (
|
||||||
|
self.config_manager.get_kdf_mode()
|
||||||
|
if getattr(self, "config_manager", None)
|
||||||
|
else "pbkdf2"
|
||||||
|
)
|
||||||
iterations = (
|
iterations = (
|
||||||
self.config_manager.get_kdf_iterations()
|
self.config_manager.get_kdf_iterations()
|
||||||
if getattr(self, "config_manager", None)
|
if getattr(self, "config_manager", None)
|
||||||
else 100_000
|
else 100_000
|
||||||
)
|
)
|
||||||
print("Deriving key...")
|
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)
|
seed_mgr = EncryptionManager(seed_key, fingerprint_dir)
|
||||||
print("Decrypting seed...")
|
print("Decrypting seed...")
|
||||||
try:
|
try:
|
||||||
@@ -448,12 +457,20 @@ class PasswordManager:
|
|||||||
password = prompt_existing_password("Enter your master password: ")
|
password = prompt_existing_password("Enter your master password: ")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
mode = (
|
||||||
|
self.config_manager.get_kdf_mode()
|
||||||
|
if getattr(self, "config_manager", None)
|
||||||
|
else "pbkdf2"
|
||||||
|
)
|
||||||
iterations = (
|
iterations = (
|
||||||
self.config_manager.get_kdf_iterations()
|
self.config_manager.get_kdf_iterations()
|
||||||
if getattr(self, "config_manager", None)
|
if getattr(self, "config_manager", None)
|
||||||
else 100_000
|
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)
|
seed_mgr = EncryptionManager(seed_key, fingerprint_dir)
|
||||||
self.parent_seed = seed_mgr.decrypt_parent_seed()
|
self.parent_seed = seed_mgr.decrypt_parent_seed()
|
||||||
seed_bytes = Bip39SeedGenerator(self.parent_seed).Generate()
|
seed_bytes = Bip39SeedGenerator(self.parent_seed).Generate()
|
||||||
|
@@ -31,3 +31,4 @@ httpx>=0.28.1
|
|||||||
requests>=2.32
|
requests>=2.32
|
||||||
python-multipart
|
python-multipart
|
||||||
orjson
|
orjson
|
||||||
|
argon2-cffi
|
||||||
|
@@ -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
|
[r.strip() for r in v.split(",") if r.strip()], require_pin=False
|
||||||
),
|
),
|
||||||
"kdf_iterations": lambda v: cfg.set_kdf_iterations(int(v)),
|
"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)),
|
"backup_interval": lambda v: cfg.set_backup_interval(float(v)),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -16,6 +16,7 @@ runner = CliRunner()
|
|||||||
("additional_backup_path", "", "set_additional_backup_path", None),
|
("additional_backup_path", "", "set_additional_backup_path", None),
|
||||||
("backup_interval", "5", "set_backup_interval", 5.0),
|
("backup_interval", "5", "set_backup_interval", 5.0),
|
||||||
("kdf_iterations", "123", "set_kdf_iterations", 123),
|
("kdf_iterations", "123", "set_kdf_iterations", 123),
|
||||||
|
("kdf_mode", "argon2", "set_kdf_mode", "argon2"),
|
||||||
(
|
(
|
||||||
"relays",
|
"relays",
|
||||||
"wss://a.com, wss://b.com",
|
"wss://a.com, wss://b.com",
|
||||||
|
75
src/tests/test_kdf_modes.py
Normal file
75
src/tests/test_kdf_modes.py
Normal 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
|
@@ -2,6 +2,7 @@ import logging
|
|||||||
import pytest
|
import pytest
|
||||||
from utils.key_derivation import (
|
from utils.key_derivation import (
|
||||||
derive_key_from_password,
|
derive_key_from_password,
|
||||||
|
derive_key_from_password_argon2,
|
||||||
derive_index_key_seed_only,
|
derive_index_key_seed_only,
|
||||||
derive_index_key,
|
derive_index_key,
|
||||||
)
|
)
|
||||||
@@ -33,3 +34,11 @@ def test_seed_only_key_deterministic():
|
|||||||
def test_derive_index_key_seed_only():
|
def test_derive_index_key_seed_only():
|
||||||
seed = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
seed = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||||
assert derive_index_key(seed) == derive_index_key_seed_only(seed)
|
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
|
||||||
|
@@ -97,6 +97,42 @@ def derive_key_from_password(password: str, iterations: int = 100_000) -> bytes:
|
|||||||
raise
|
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:
|
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.
|
Derives a 32-byte cryptographic key from a BIP-39 parent seed using HKDF.
|
||||||
|
Reference in New Issue
Block a user