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

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