Extend password policy and generator

This commit is contained in:
thePR0M3TH3AN
2025-07-30 18:44:41 -04:00
parent d4d475438f
commit dcb5c6e805
5 changed files with 130 additions and 24 deletions

View File

@@ -50,6 +50,9 @@ DEFAULT_PASSWORD_LENGTH = 16 # Default length for generated passwords
MIN_PASSWORD_LENGTH = 8 # Minimum allowed password length MIN_PASSWORD_LENGTH = 8 # Minimum allowed password length
MAX_PASSWORD_LENGTH = 128 # Maximum 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 # Timeout in seconds before the vault locks due to inactivity
INACTIVITY_TIMEOUT = 15 * 60 # 15 minutes INACTIVITY_TIMEOUT = 15 * 60 # 15 minutes

View File

@@ -51,12 +51,27 @@ logger = logging.getLogger(__name__)
@dataclass @dataclass
class PasswordPolicy: 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_uppercase: int = 2
min_lowercase: int = 2 min_lowercase: int = 2
min_digits: int = 2 min_digits: int = 2
min_special: 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: class PasswordGenerator:
@@ -175,9 +190,28 @@ class PasswordGenerator:
dk = self._derive_password_entropy(index=index) 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._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) password = self._shuffle_deterministically(password, dk)
# Ensure password length by extending if necessary # Ensure password length by extending if necessary
@@ -195,7 +229,9 @@ class PasswordGenerator:
# produced above when the requested length is shorter than the # produced above when the requested length is shorter than the
# initial entropy size. # initial entropy size.
password = password[:length] 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) password = self._shuffle_deterministically(password, dk)
logger.debug( logger.debug(
f"Final password (trimmed to {length} chars with complexity enforced): {password}" 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")) print(colored(f"Error: Failed to generate password: {e}", "red"))
raise 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, Ensures that the password contains at least two uppercase letters, two lowercase letters,
two digits, and two special characters, modifying it deterministically if necessary. two digits, and two special characters, modifying it deterministically if necessary.
@@ -226,7 +264,13 @@ class PasswordGenerator:
uppercase = string.ascii_uppercase uppercase = string.ascii_uppercase
lowercase = string.ascii_lowercase lowercase = string.ascii_lowercase
digits = string.digits 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) password_chars = list(password)
@@ -244,7 +288,7 @@ class PasswordGenerator:
min_upper = self.policy.min_uppercase min_upper = self.policy.min_uppercase
min_lower = self.policy.min_lowercase min_lower = self.policy.min_lowercase
min_digits = self.policy.min_digits 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 # Initialize derived key index
dk_index = 0 dk_index = 0
@@ -282,7 +326,7 @@ class PasswordGenerator:
password_chars[index] = char password_chars[index] = char
logger.debug(f"Added digit '{char}' at position {index}.") 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): for _ in range(min_special - current_special):
index = get_dk_value() % len(password_chars) index = get_dk_value() % len(password_chars)
char = special[get_dk_value() % len(special)] char = special[get_dk_value() % len(special)]
@@ -292,23 +336,29 @@ class PasswordGenerator:
) )
# Additional deterministic inclusion of symbols to increase score # Additional deterministic inclusion of symbols to increase score
symbol_target = 3 # Increase target number of symbols if special:
current_symbols = sum(1 for c in password_chars if c in special) symbol_target = 3 # Increase target number of symbols
additional_symbols_needed = max(symbol_target - current_symbols, 0) 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): for _ in range(additional_symbols_needed):
if dk_index >= dk_length: if dk_index >= dk_length:
break # Avoid exceeding the derived key length break # Avoid exceeding the derived key length
index = get_dk_value() % len(password_chars) index = get_dk_value() % len(password_chars)
char = special[get_dk_value() % len(special)] char = special[get_dk_value() % len(special)]
password_chars[index] = char password_chars[index] = char
logger.debug(f"Added additional symbol '{char}' at position {index}.") logger.debug(
f"Added additional symbol '{char}' at position {index}."
)
# Ensure balanced distribution by assigning different character types to specific segments # Ensure balanced distribution by assigning different character types to specific segments
# Example: Divide password into segments and assign different types # 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: 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_start = i * segment_length
segment_end = segment_start + segment_length segment_end = segment_start + segment_length
if segment_end > len(password_chars): if segment_end > len(password_chars):
@@ -330,7 +380,11 @@ class PasswordGenerator:
char = digits[get_dk_value() % len(digits)] char = digits[get_dk_value() % len(digits)]
password_chars[j] = char password_chars[j] = char
logger.debug(f"Assigned digit '{char}' to position {j}.") 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)] char = special[get_dk_value() % len(special)]
password_chars[j] = char password_chars[j] = char
logger.debug( logger.debug(

View File

@@ -39,7 +39,7 @@ def test_zero_policy_preserves_length():
pg = make_generator(policy) pg = make_generator(policy)
alphabet = string.ascii_lowercase alphabet = string.ascii_lowercase
dk = bytes(range(32)) dk = bytes(range(32))
result = pg._enforce_complexity("a" * 32, alphabet, dk) result = pg._enforce_complexity("a" * 32, alphabet, "", dk)
assert len(result) == 32 assert len(result) == 32
@@ -50,7 +50,7 @@ def test_custom_policy_applied():
pg = make_generator(policy) pg = make_generator(policy)
alphabet = string.ascii_letters + string.digits + string.punctuation alphabet = string.ascii_letters + string.digits + string.punctuation
dk = bytes(range(32)) 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) counts = count_types(result)
assert counts[0] >= 4 assert counts[0] >= 4
assert counts[1] >= 1 assert counts[1] >= 1

View File

@@ -41,7 +41,7 @@ def test_enforce_complexity_minimum_counts():
pg = make_generator() pg = make_generator()
alphabet = string.ascii_letters + string.digits + string.punctuation alphabet = string.ascii_letters + string.digits + string.punctuation
dk = bytes(range(32)) 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.isupper()) >= 2
assert sum(1 for c in result if c.islower()) >= 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.isdigit()) >= 2

View File

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