From edcf2787ee2338cdae16a5674dcd0bd3125c53e1 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 6 Aug 2025 09:58:44 -0400 Subject: [PATCH] Add Argon2 calibration and encryption fuzz tests --- docs/security.md | 30 ++++++++++++++++ src/seedpass/core/config_manager.py | 15 ++++++++ src/tests/test_encryption_fuzz.py | 37 ++++++++++++++++++++ src/utils/key_derivation.py | 53 +++++++++++++++++++++++++++++ 4 files changed, 135 insertions(+) create mode 100644 docs/security.md create mode 100644 src/tests/test_encryption_fuzz.py diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..e79b536 --- /dev/null +++ b/docs/security.md @@ -0,0 +1,30 @@ +# Security Testing and Calibration + +This project includes fuzz tests and a calibration routine to tune Argon2 parameters for your hardware. + +## Running Fuzz Tests + +The fuzz tests exercise encryption and decryption with random data using [Hypothesis](https://hypothesis.readthedocs.io/). +Activate the project's virtual environment and run: + +```bash +pytest src/tests/test_encryption_fuzz.py +``` + +Running the entire test suite will also execute these fuzz tests. + +## Calibrating Argon2 Time Cost + +Argon2 performance varies by device. To calibrate the `time_cost` parameter, run the helper function: + +```bash +python - <<'PY' +from seedpass.core.config_manager import ConfigManager +from utils.key_derivation import calibrate_argon2_time_cost + +# assuming ``cfg`` is a ConfigManager for your profile +calibrate_argon2_time_cost(cfg) +PY +``` + +The selected `time_cost` is stored in the profile's configuration and used for subsequent key derivations. diff --git a/src/seedpass/core/config_manager.py b/src/seedpass/core/config_manager.py index 0e45c7a..7f6f223 100644 --- a/src/seedpass/core/config_manager.py +++ b/src/seedpass/core/config_manager.py @@ -47,6 +47,7 @@ class ConfigManager: "inactivity_timeout": INACTIVITY_TIMEOUT, "kdf_iterations": 50_000, "kdf_mode": "pbkdf2", + "argon2_time_cost": 2, "additional_backup_path": "", "backup_interval": 0, "secret_mode_enabled": False, @@ -76,6 +77,7 @@ class ConfigManager: data.setdefault("inactivity_timeout", INACTIVITY_TIMEOUT) data.setdefault("kdf_iterations", 50_000) data.setdefault("kdf_mode", "pbkdf2") + data.setdefault("argon2_time_cost", 2) data.setdefault("additional_backup_path", "") data.setdefault("backup_interval", 0) data.setdefault("secret_mode_enabled", False) @@ -196,6 +198,19 @@ class ConfigManager: config = self.load_config(require_pin=False) return config.get("kdf_mode", "pbkdf2") + def set_argon2_time_cost(self, time_cost: int) -> None: + """Persist the Argon2 ``time_cost`` parameter.""" + if time_cost <= 0: + raise ValueError("time_cost must be positive") + config = self.load_config(require_pin=False) + config["argon2_time_cost"] = int(time_cost) + self.save_config(config) + + def get_argon2_time_cost(self) -> int: + """Retrieve the Argon2 ``time_cost`` setting.""" + config = self.load_config(require_pin=False) + return int(config.get("argon2_time_cost", 2)) + 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/tests/test_encryption_fuzz.py b/src/tests/test_encryption_fuzz.py new file mode 100644 index 0000000..0899a9d --- /dev/null +++ b/src/tests/test_encryption_fuzz.py @@ -0,0 +1,37 @@ +import pytest +from pathlib import Path +from cryptography.fernet import Fernet +from hypothesis import given, strategies as st, settings, HealthCheck + +from seedpass.core.encryption import EncryptionManager + + +@given(blob=st.binary()) +@settings( + deadline=None, + max_examples=25, + suppress_health_check=[HealthCheck.function_scoped_fixture], +) +def test_encrypt_decrypt_roundtrip(blob: bytes, tmp_path: Path) -> None: + """Ensure arbitrary data round-trips through EncryptionManager.""" + key = Fernet.generate_key() + mgr = EncryptionManager(key, tmp_path) + encrypted = mgr.encrypt_data(blob) + assert mgr.decrypt_data(encrypted) == blob + + +@given(blob=st.binary()) +@settings( + deadline=None, + max_examples=25, + suppress_health_check=[HealthCheck.function_scoped_fixture], +) +def test_corrupted_ciphertext_fails(blob: bytes, tmp_path: Path) -> None: + """Corrupted ciphertext should not decrypt successfully.""" + key = Fernet.generate_key() + mgr = EncryptionManager(key, tmp_path) + encrypted = bytearray(mgr.encrypt_data(blob)) + if encrypted: + encrypted[0] ^= 0xFF + with pytest.raises(Exception): + mgr.decrypt_data(bytes(encrypted)) diff --git a/src/utils/key_derivation.py b/src/utils/key_derivation.py index 4ccda54..c5d0322 100644 --- a/src/utils/key_derivation.py +++ b/src/utils/key_derivation.py @@ -21,6 +21,7 @@ import unicodedata import logging import traceback import hmac +import time from enum import Enum from typing import Optional, Union from bip_utils import Bip39SeedGenerator @@ -236,3 +237,55 @@ def derive_totp_secret(seed: str, index: int) -> str: except Exception as e: logger.error(f"Failed to derive TOTP secret: {e}", exc_info=True) raise + + +def calibrate_argon2_time_cost( + cfg_mgr: "ConfigManager", + *, + target_ms: float = 500.0, + max_time_cost: int = 6, +) -> int: + """Calibrate Argon2 ``time_cost`` based on device performance. + + Runs :func:`derive_key_from_password_argon2` with increasing ``time_cost`` + until the runtime meets or exceeds ``target_ms``. The chosen ``time_cost`` + is stored in ``cfg_mgr`` via ``set_argon2_time_cost`` and returned. + + Parameters + ---------- + cfg_mgr: + Instance of :class:`~seedpass.core.config_manager.ConfigManager` used to + persist the calibrated ``time_cost``. + target_ms: + Desired minimum execution time in milliseconds for one Argon2 hash. + max_time_cost: + Upper bound for ``time_cost`` to prevent excessively long calibration. + + Returns + ------- + int + Selected ``time_cost`` value. + """ + + password = "benchmark" + fingerprint = b"argon2-calibration" + time_cost = 1 + elapsed_ms = 0.0 + while time_cost <= max_time_cost: + start = time.perf_counter() + derive_key_from_password_argon2( + password, + fingerprint, + time_cost=time_cost, + memory_cost=8, + parallelism=1, + ) + elapsed_ms = (time.perf_counter() - start) * 1000 + if elapsed_ms >= target_ms: + break + time_cost += 1 + + cfg_mgr.set_argon2_time_cost(time_cost) + if cfg_mgr.load_config(require_pin=False).get("verbose_timing"): + logger.info("Calibrated Argon2 time_cost=%s (%.2f ms)", time_cost, elapsed_ms) + return time_cost