From dcb5c6e805800fd615ff758465050fcd34e6042e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 30 Jul 2025 18:44:41 -0400 Subject: [PATCH] Extend password policy and generator --- src/constants.py | 3 + src/seedpass/core/password_generation.py | 96 +++++++++++++++----- src/tests/test_password_generation_policy.py | 4 +- src/tests/test_password_helpers.py | 2 +- src/tests/test_password_special_chars.py | 49 ++++++++++ 5 files changed, 130 insertions(+), 24 deletions(-) create mode 100644 src/tests/test_password_special_chars.py diff --git a/src/constants.py b/src/constants.py index e221288..4843ecd 100644 --- a/src/constants.py +++ b/src/constants.py @@ -50,6 +50,9 @@ DEFAULT_PASSWORD_LENGTH = 16 # Default length for generated passwords MIN_PASSWORD_LENGTH = 8 # Minimum allowed password length MAX_PASSWORD_LENGTH = 128 # Maximum allowed password length +# Characters considered safe for passwords when limiting punctuation +SAFE_SPECIAL_CHARS = "!@#$%^*-_+=?" + # Timeout in seconds before the vault locks due to inactivity INACTIVITY_TIMEOUT = 15 * 60 # 15 minutes diff --git a/src/seedpass/core/password_generation.py b/src/seedpass/core/password_generation.py index a70ab0b..61617b0 100644 --- a/src/seedpass/core/password_generation.py +++ b/src/seedpass/core/password_generation.py @@ -51,12 +51,27 @@ logger = logging.getLogger(__name__) @dataclass class PasswordPolicy: - """Minimum complexity requirements for generated passwords.""" + """Minimum complexity requirements for generated passwords. + + Attributes: + min_uppercase: Minimum required uppercase letters. + min_lowercase: Minimum required lowercase letters. + min_digits: Minimum required digits. + min_special: Minimum required special characters. + include_special_chars: Whether to include any special characters. + allowed_special_chars: Explicit set of allowed special characters. + special_mode: Preset mode for special characters (e.g. "safe"). + exclude_ambiguous: Exclude easily confused characters like ``O`` and ``0``. + """ min_uppercase: int = 2 min_lowercase: int = 2 min_digits: int = 2 min_special: int = 2 + include_special_chars: bool = True + allowed_special_chars: str | None = None + special_mode: str | None = None + exclude_ambiguous: bool = False class PasswordGenerator: @@ -175,9 +190,28 @@ class PasswordGenerator: dk = self._derive_password_entropy(index=index) - all_allowed = string.ascii_letters + string.digits + string.punctuation + letters = string.ascii_letters + digits = string.digits + + if self.policy.exclude_ambiguous: + ambiguous = "O0Il1" + letters = "".join(c for c in letters if c not in ambiguous) + digits = "".join(c for c in digits if c not in ambiguous) + + if not self.policy.include_special_chars: + allowed_special = "" + elif self.policy.allowed_special_chars is not None: + allowed_special = self.policy.allowed_special_chars + elif self.policy.special_mode == "safe": + allowed_special = SAFE_SPECIAL_CHARS + else: + allowed_special = string.punctuation + + all_allowed = letters + digits + allowed_special password = self._map_entropy_to_chars(dk, all_allowed) - password = self._enforce_complexity(password, all_allowed, dk) + password = self._enforce_complexity( + password, all_allowed, allowed_special, dk + ) password = self._shuffle_deterministically(password, dk) # Ensure password length by extending if necessary @@ -195,7 +229,9 @@ class PasswordGenerator: # produced above when the requested length is shorter than the # initial entropy size. password = password[:length] - password = self._enforce_complexity(password, all_allowed, dk) + password = self._enforce_complexity( + password, all_allowed, allowed_special, dk + ) password = self._shuffle_deterministically(password, dk) logger.debug( f"Final password (trimmed to {length} chars with complexity enforced): {password}" @@ -208,7 +244,9 @@ class PasswordGenerator: print(colored(f"Error: Failed to generate password: {e}", "red")) raise - def _enforce_complexity(self, password: str, alphabet: str, dk: bytes) -> str: + def _enforce_complexity( + self, password: str, alphabet: str, allowed_special: 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. @@ -226,7 +264,13 @@ class PasswordGenerator: uppercase = string.ascii_uppercase lowercase = string.ascii_lowercase digits = string.digits - special = string.punctuation + special = allowed_special + + if self.policy.exclude_ambiguous: + ambiguous = "O0Il1" + uppercase = "".join(c for c in uppercase if c not in ambiguous) + lowercase = "".join(c for c in lowercase if c not in ambiguous) + digits = "".join(c for c in digits if c not in ambiguous) password_chars = list(password) @@ -244,7 +288,7 @@ class PasswordGenerator: min_upper = self.policy.min_uppercase min_lower = self.policy.min_lowercase min_digits = self.policy.min_digits - min_special = self.policy.min_special + min_special = self.policy.min_special if special else 0 # Initialize derived key index dk_index = 0 @@ -282,7 +326,7 @@ class PasswordGenerator: password_chars[index] = char logger.debug(f"Added digit '{char}' at position {index}.") - if current_special < min_special: + if special and current_special < min_special: for _ in range(min_special - current_special): index = get_dk_value() % len(password_chars) char = special[get_dk_value() % len(special)] @@ -292,23 +336,29 @@ class PasswordGenerator: ) # Additional deterministic inclusion of symbols to increase score - symbol_target = 3 # Increase target number of symbols - current_symbols = sum(1 for c in password_chars if c in special) - additional_symbols_needed = max(symbol_target - current_symbols, 0) + if special: + symbol_target = 3 # Increase target number of symbols + current_symbols = sum(1 for c in password_chars if c in special) + additional_symbols_needed = max(symbol_target - current_symbols, 0) - for _ in range(additional_symbols_needed): - if dk_index >= dk_length: - break # Avoid exceeding the derived key length - index = get_dk_value() % len(password_chars) - char = special[get_dk_value() % len(special)] - password_chars[index] = char - logger.debug(f"Added additional symbol '{char}' at position {index}.") + for _ in range(additional_symbols_needed): + if dk_index >= dk_length: + break # Avoid exceeding the derived key length + index = get_dk_value() % len(password_chars) + char = special[get_dk_value() % len(special)] + password_chars[index] = char + logger.debug( + f"Added additional symbol '{char}' at position {index}." + ) # Ensure balanced distribution by assigning different character types to specific segments # Example: Divide password into segments and assign different types - segment_length = len(password_chars) // 4 + char_types = [uppercase, lowercase, digits] + if special: + char_types.append(special) + segment_length = len(password_chars) // len(char_types) if segment_length > 0: - for i, char_type in enumerate([uppercase, lowercase, digits, special]): + for i, char_type in enumerate(char_types): segment_start = i * segment_length segment_end = segment_start + segment_length if segment_end > len(password_chars): @@ -330,7 +380,11 @@ class PasswordGenerator: char = digits[get_dk_value() % len(digits)] password_chars[j] = char logger.debug(f"Assigned digit '{char}' to position {j}.") - elif i == 3 and password_chars[j] not in special: + elif ( + special + and i == len(char_types) - 1 + and password_chars[j] not in special + ): char = special[get_dk_value() % len(special)] password_chars[j] = char logger.debug( diff --git a/src/tests/test_password_generation_policy.py b/src/tests/test_password_generation_policy.py index a4df419..0a90101 100644 --- a/src/tests/test_password_generation_policy.py +++ b/src/tests/test_password_generation_policy.py @@ -39,7 +39,7 @@ def test_zero_policy_preserves_length(): pg = make_generator(policy) alphabet = string.ascii_lowercase dk = bytes(range(32)) - result = pg._enforce_complexity("a" * 32, alphabet, dk) + result = pg._enforce_complexity("a" * 32, alphabet, "", dk) assert len(result) == 32 @@ -50,7 +50,7 @@ def test_custom_policy_applied(): pg = make_generator(policy) alphabet = string.ascii_letters + string.digits + string.punctuation dk = bytes(range(32)) - result = pg._enforce_complexity("a" * 32, alphabet, dk) + result = pg._enforce_complexity("a" * 32, alphabet, string.punctuation, dk) counts = count_types(result) assert counts[0] >= 4 assert counts[1] >= 1 diff --git a/src/tests/test_password_helpers.py b/src/tests/test_password_helpers.py index 080e363..b41f186 100644 --- a/src/tests/test_password_helpers.py +++ b/src/tests/test_password_helpers.py @@ -41,7 +41,7 @@ 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) + result = pg._enforce_complexity("a" * 32, alphabet, string.punctuation, 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 diff --git a/src/tests/test_password_special_chars.py b/src/tests/test_password_special_chars.py new file mode 100644 index 0000000..707e156 --- /dev/null +++ b/src/tests/test_password_special_chars.py @@ -0,0 +1,49 @@ +import string +from pathlib import Path +import sys + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from seedpass.core.password_generation import PasswordGenerator, PasswordPolicy + + +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(policy=None): + pg = PasswordGenerator.__new__(PasswordGenerator) + pg.encryption_manager = DummyEnc() + pg.bip85 = DummyBIP85() + pg.policy = policy or PasswordPolicy() + return pg + + +def test_no_special_chars(): + policy = PasswordPolicy(include_special_chars=False) + pg = make_generator(policy) + pw = pg.generate_password(length=16, index=0) + assert not any(c in string.punctuation for c in pw) + + +def test_allowed_special_chars_only(): + allowed = "@$" + policy = PasswordPolicy(allowed_special_chars=allowed) + pg = make_generator(policy) + pw = pg.generate_password(length=32, index=1) + specials = [c for c in pw if c in string.punctuation] + assert specials and all(c in allowed for c in specials) + + +def test_exclude_ambiguous_chars(): + policy = PasswordPolicy(exclude_ambiguous=True) + pg = make_generator(policy) + pw = pg.generate_password(length=32, index=2) + for ch in "O0Il1": + assert ch not in pw