Add Argon2 calibration and encryption fuzz tests

This commit is contained in:
thePR0M3TH3AN
2025-08-06 09:58:44 -04:00
parent 072db52650
commit edcf2787ee
4 changed files with 135 additions and 0 deletions

30
docs/security.md Normal file
View File

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

View File

@@ -47,6 +47,7 @@ class ConfigManager:
"inactivity_timeout": INACTIVITY_TIMEOUT, "inactivity_timeout": INACTIVITY_TIMEOUT,
"kdf_iterations": 50_000, "kdf_iterations": 50_000,
"kdf_mode": "pbkdf2", "kdf_mode": "pbkdf2",
"argon2_time_cost": 2,
"additional_backup_path": "", "additional_backup_path": "",
"backup_interval": 0, "backup_interval": 0,
"secret_mode_enabled": False, "secret_mode_enabled": False,
@@ -76,6 +77,7 @@ class ConfigManager:
data.setdefault("inactivity_timeout", INACTIVITY_TIMEOUT) data.setdefault("inactivity_timeout", INACTIVITY_TIMEOUT)
data.setdefault("kdf_iterations", 50_000) data.setdefault("kdf_iterations", 50_000)
data.setdefault("kdf_mode", "pbkdf2") data.setdefault("kdf_mode", "pbkdf2")
data.setdefault("argon2_time_cost", 2)
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)
@@ -196,6 +198,19 @@ class ConfigManager:
config = self.load_config(require_pin=False) config = self.load_config(require_pin=False)
return config.get("kdf_mode", "pbkdf2") 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: 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)

View File

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

View File

@@ -21,6 +21,7 @@ import unicodedata
import logging import logging
import traceback import traceback
import hmac import hmac
import time
from enum import Enum from enum import Enum
from typing import Optional, Union from typing import Optional, Union
from bip_utils import Bip39SeedGenerator from bip_utils import Bip39SeedGenerator
@@ -236,3 +237,55 @@ def derive_totp_secret(seed: str, index: int) -> str:
except Exception as e: except Exception as e:
logger.error(f"Failed to derive TOTP secret: {e}", exc_info=True) logger.error(f"Failed to derive TOTP secret: {e}", exc_info=True)
raise 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