From 57fde0139fc6395bdcdf97a021c8fd17ded0271a Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 30 Jun 2025 14:11:16 -0400 Subject: [PATCH 1/3] Refactor password generation with helpers --- src/password_manager/password_generation.py | 83 +++++++++++---------- src/tests/test_password_helpers.py | 55 ++++++++++++++ 2 files changed, 98 insertions(+), 40 deletions(-) create mode 100644 src/tests/test_password_helpers.py diff --git a/src/password_manager/password_generation.py b/src/password_manager/password_generation.py index fa3049e..e55ec4d 100644 --- a/src/password_manager/password_generation.py +++ b/src/password_manager/password_generation.py @@ -73,6 +73,41 @@ class PasswordGenerator: print(colored(f"Error: Failed to initialize PasswordGenerator: {e}", "red")) raise + def _derive_password_entropy(self, index: int) -> bytes: + """Derive deterministic entropy for password generation.""" + entropy = self.bip85.derive_entropy(index=index, bytes_len=64, app_no=32) + logger.debug(f"Derived entropy: {entropy.hex()}") + + hkdf = HKDF( + algorithm=hashes.SHA256(), + length=32, + salt=None, + info=b"password-generation", + backend=default_backend(), + ) + hkdf_derived = hkdf.derive(entropy) + logger.debug(f"Derived key using HKDF: {hkdf_derived.hex()}") + + dk = hashlib.pbkdf2_hmac("sha256", entropy, b"", 100000) + logger.debug(f"Derived key using PBKDF2: {dk.hex()}") + return dk + + def _map_entropy_to_chars(self, dk: bytes, alphabet: str) -> str: + """Map derived bytes to characters from the provided alphabet.""" + password = "".join(alphabet[byte % len(alphabet)] for byte in dk) + logger.debug(f"Password after mapping to all allowed characters: {password}") + return password + + def _shuffle_deterministically(self, password: str, dk: bytes) -> str: + """Deterministically shuffle characters using derived bytes.""" + shuffle_seed = int.from_bytes(dk, "big") + rng = random.Random(shuffle_seed) + password_chars = list(password) + rng.shuffle(password_chars) + shuffled = "".join(password_chars) + logger.debug("Shuffled password deterministically.") + return shuffled + def generate_password( self, length: int = DEFAULT_PASSWORD_LENGTH, index: int = 0 ) -> str: @@ -111,52 +146,20 @@ class PasswordGenerator: f"Password length must not exceed {MAX_PASSWORD_LENGTH} characters." ) - # Derive entropy using BIP-85 - entropy = self.bip85.derive_entropy(index=index, bytes_len=64, app_no=32) - logger.debug(f"Derived entropy: {entropy.hex()}") + dk = self._derive_password_entropy(index=index) - # Use HKDF to derive key from entropy - hkdf = HKDF( - algorithm=hashes.SHA256(), - length=32, # 256 bits for AES-256 - salt=None, - info=b"password-generation", - backend=default_backend(), - ) - derived_key = hkdf.derive(entropy) - logger.debug(f"Derived key using HKDF: {derived_key.hex()}") - - # Use PBKDF2-HMAC-SHA256 to derive a key from entropy - dk = hashlib.pbkdf2_hmac("sha256", entropy, b"", 100000) - logger.debug(f"Derived key using PBKDF2: {dk.hex()}") - - # Map the derived key to all allowed characters all_allowed = string.ascii_letters + string.digits + string.punctuation - password = "".join(all_allowed[byte % len(all_allowed)] for byte in dk) - logger.debug( - f"Password after mapping to all allowed characters: {password}" - ) - - # Ensure the password meets complexity requirements - password = self.ensure_complexity(password, all_allowed, dk) - logger.debug(f"Password after ensuring complexity: {password}") - - # Shuffle characters deterministically based on dk - shuffle_seed = int.from_bytes(dk, "big") - rng = random.Random(shuffle_seed) - password_chars = list(password) - rng.shuffle(password_chars) - password = "".join(password_chars) - logger.debug("Shuffled password deterministically.") + password = self._map_entropy_to_chars(dk, all_allowed) + password = self._enforce_complexity(password, all_allowed, dk) + password = self._shuffle_deterministically(password, dk) # Ensure password length by extending if necessary if len(password) < length: while len(password) < length: dk = hashlib.pbkdf2_hmac("sha256", dk, b"", 1) - base64_extra = "".join( - all_allowed[byte % len(all_allowed)] for byte in dk - ) - password += "".join(base64_extra) + extra = self._map_entropy_to_chars(dk, all_allowed) + password += extra + password = self._shuffle_deterministically(password, dk) logger.debug(f"Extended password: {password}") # Trim the password to the desired length @@ -171,7 +174,7 @@ class PasswordGenerator: print(colored(f"Error: Failed to generate password: {e}", "red")) raise - def ensure_complexity(self, password: str, alphabet: str, dk: bytes) -> str: + def _enforce_complexity(self, password: str, alphabet: str, dk: bytes) -> str: """ Ensures that the password contains at least two uppercase letters, two lowercase letters, two digits, and two special characters, modifying it deterministically if necessary. diff --git a/src/tests/test_password_helpers.py b/src/tests/test_password_helpers.py new file mode 100644 index 0000000..9253130 --- /dev/null +++ b/src/tests/test_password_helpers.py @@ -0,0 +1,55 @@ +import string +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 + + +def test_derive_password_entropy_length(): + pg = make_generator() + dk = pg._derive_password_entropy(index=1) + assert isinstance(dk, bytes) + assert len(dk) == 32 + dk2 = pg._derive_password_entropy(index=2) + assert dk != dk2 + + +def test_map_entropy_to_chars_only_uses_alphabet(): + pg = make_generator() + alphabet = string.ascii_letters + string.digits + mapped = pg._map_entropy_to_chars(b"\x00\x01\x02", alphabet) + assert all(c in alphabet for c in mapped) + assert len(mapped) == 3 + + +def test_enforce_complexity_minimum_counts(): + pg = make_generator() + alphabet = string.ascii_letters + string.digits + string.punctuation + dk = bytes(range(32)) + result = pg._enforce_complexity("a" * 32, alphabet, dk) + assert sum(1 for c in result if c.isupper()) >= 2 + assert sum(1 for c in result if c.islower()) >= 2 + assert sum(1 for c in result if c.isdigit()) >= 2 + assert sum(1 for c in result if c in string.punctuation) >= 2 + + +def test_shuffle_deterministically_repeatable(): + pg = make_generator() + dk = bytes(range(32)) + pw1 = pg._shuffle_deterministically("abcdef", dk) + pw2 = pg._shuffle_deterministically("abcdef", dk) + assert pw1 == pw2 From 0a41bd84b937a289bdeaefef10ab921c0b5e2dd4 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 30 Jun 2025 14:30:56 -0400 Subject: [PATCH 2/3] Fix exclusive_lock indefinite wait --- src/utils/file_lock.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/utils/file_lock.py b/src/utils/file_lock.py index 4d674f2..5ffba52 100644 --- a/src/utils/file_lock.py +++ b/src/utils/file_lock.py @@ -19,7 +19,10 @@ def exclusive_lock( """ path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) - lock = portalocker.Lock(str(path), mode="a+b", timeout=timeout) + if timeout is None: + lock = portalocker.Lock(str(path), mode="a+b") + else: + lock = portalocker.Lock(str(path), mode="a+b", timeout=timeout) with lock as fh: yield fh From c05123c63801a77e2ee7bbba59077aacae025baa Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 30 Jun 2025 14:43:37 -0400 Subject: [PATCH 3/3] Fix shared_lock timeout handling --- src/utils/file_lock.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/utils/file_lock.py b/src/utils/file_lock.py index 5ffba52..8df6031 100644 --- a/src/utils/file_lock.py +++ b/src/utils/file_lock.py @@ -41,9 +41,19 @@ def shared_lock( path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) path.touch(exist_ok=True) - lock = portalocker.Lock( - str(path), mode="r+b", timeout=timeout, flags=portalocker.LockFlags.SHARED - ) + if timeout is None: + lock = portalocker.Lock( + str(path), + mode="r+b", + flags=portalocker.LockFlags.SHARED, + ) + else: + lock = portalocker.Lock( + str(path), + mode="r+b", + timeout=timeout, + flags=portalocker.LockFlags.SHARED, + ) with lock as fh: fh.seek(0) yield fh