From 489b3b516516ec4590e5e84a70b08efd11cbd163 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 1 Jul 2025 14:40:00 -0400 Subject: [PATCH] Add Hypothesis password property tests --- src/password_manager/password_generation.py | 12 +++++- src/requirements.txt | 1 + src/tests/test_password_properties.py | 44 +++++++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 src/tests/test_password_properties.py diff --git a/src/password_manager/password_generation.py b/src/password_manager/password_generation.py index e55ec4d..8a3407c 100644 --- a/src/password_manager/password_generation.py +++ b/src/password_manager/password_generation.py @@ -162,9 +162,17 @@ class PasswordGenerator: password = self._shuffle_deterministically(password, dk) logger.debug(f"Extended password: {password}") - # Trim the password to the desired length + # Trim the password to the desired length and enforce complexity on + # the final result. Complexity enforcement is repeated here because + # trimming may remove required character classes from the password + # produced above when the requested length is shorter than the + # initial entropy size. password = password[:length] - logger.debug(f"Final password (trimmed to {length} chars): {password}") + password = self._enforce_complexity(password, all_allowed, dk) + password = self._shuffle_deterministically(password, dk) + logger.debug( + f"Final password (trimmed to {length} chars with complexity enforced): {password}" + ) return password diff --git a/src/requirements.txt b/src/requirements.txt index 08b2732..9d407a2 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -16,3 +16,4 @@ websocket-client==1.7.0 websockets>=15.0.0 tomli +hypothesis diff --git a/src/tests/test_password_properties.py b/src/tests/test_password_properties.py new file mode 100644 index 0000000..3a226b6 --- /dev/null +++ b/src/tests/test_password_properties.py @@ -0,0 +1,44 @@ +import sys +import string +from pathlib import Path +from hypothesis import given, strategies as st + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from password_manager.password_generation import PasswordGenerator + + +class DummyEnc: + def derive_seed_from_mnemonic(self, mnemonic): + return b"\x00" * 32 + + +class DummyBIP85: + def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes: + return bytes((index + i) % 256 for i in range(bytes_len)) + + +def make_generator(): + pg = PasswordGenerator.__new__(PasswordGenerator) + pg.encryption_manager = DummyEnc() + pg.bip85 = DummyBIP85() + return pg + + +@given( + length=st.integers(min_value=8, max_value=64), + index=st.integers(min_value=0, max_value=1000), +) +def test_password_properties(length, index): + pg = make_generator() + pw1 = pg.generate_password(length=length, index=index) + pw2 = pg.generate_password(length=length, index=index) + + assert pw1 == pw2 + assert len(pw1) == length + + assert sum(c.isupper() for c in pw1) >= 2 + assert sum(c.islower() for c in pw1) >= 2 + assert sum(c.isdigit() for c in pw1) >= 2 + assert sum(c in string.punctuation for c in pw1) >= 2 + assert not any(c.isspace() for c in pw1)