diff --git a/docs/docs/content/index.md b/docs/docs/content/index.md index 1a748ff..66c3069 100644 --- a/docs/docs/content/index.md +++ b/docs/docs/content/index.md @@ -423,6 +423,11 @@ pip install -r src/requirements.txt pytest -vv ``` +`test_fuzz_key_derivation.py` uses Hypothesis to generate random passwords, +seeds and configuration data. It performs round-trip encryption tests with the +`EncryptionManager` to catch edge cases automatically. These fuzz tests run in +CI alongside the rest of the suite. + ### Exploring Nostr Index Size Limits `test_nostr_index_size.py` demonstrates how SeedPass rotates snapshots after too many delta events. diff --git a/src/tests/test_fuzz_key_derivation.py b/src/tests/test_fuzz_key_derivation.py new file mode 100644 index 0000000..45a35b6 --- /dev/null +++ b/src/tests/test_fuzz_key_derivation.py @@ -0,0 +1,63 @@ +import os +from pathlib import Path + +from hypothesis import given, strategies as st, settings, HealthCheck +from mnemonic import Mnemonic + +from utils.key_derivation import ( + derive_key_from_password, + derive_key_from_password_argon2, + derive_index_key, +) +from password_manager.encryption import EncryptionManager + + +cfg_values = st.one_of( + st.integers(min_value=0, max_value=100), + st.text(min_size=0, max_size=20), + st.booleans(), +) + + +@given( + password=st.text(min_size=8, max_size=32), + seed_bytes=st.binary(min_size=16, max_size=16), + config=st.dictionaries(st.text(min_size=1, max_size=10), cfg_values, max_size=5), + mode=st.sampled_from(["pbkdf2", "argon2"]), +) +@settings( + deadline=None, + max_examples=20, + suppress_health_check=[HealthCheck.function_scoped_fixture], +) +def test_fuzz_key_round_trip(password, seed_bytes, config, mode, tmp_path: Path): + """Ensure EncryptionManager round-trips arbitrary data.""" + seed_phrase = Mnemonic("english").to_mnemonic(seed_bytes) + if mode == "argon2": + key = derive_key_from_password_argon2( + password, time_cost=1, memory_cost=8, parallelism=1 + ) + else: + key = derive_key_from_password(password, iterations=1) + + enc_mgr = EncryptionManager(key, tmp_path) + + # Parent seed round trip + enc_mgr.encrypt_parent_seed(seed_phrase) + assert enc_mgr.decrypt_parent_seed() == seed_phrase + + # JSON data round trip + enc_mgr.save_json_data(config, Path("config.enc")) + loaded = enc_mgr.load_json_data(Path("config.enc")) + assert loaded == config + + # Binary data round trip + blob = os.urandom(32) + enc_mgr.encrypt_and_save_file(blob, Path("blob.enc")) + assert enc_mgr.decrypt_file(Path("blob.enc")) == blob + + # Index key derived from seed also decrypts + index_key = derive_index_key(seed_phrase) + idx_mgr = EncryptionManager(index_key, tmp_path) + idx_mgr.save_json_data(config) + assert idx_mgr.load_json_data() == config