Files
seedPass/src/seedpass/core/password_generation.py
2025-07-17 19:21:10 -04:00

491 lines
20 KiB
Python

# seedpass.core/password_generation.py
"""
Password Generation Module
This module provides the PasswordGenerator class responsible for deterministic password generation
based on a BIP-39 parent seed. It leverages BIP-85 for entropy derivation and ensures that
generated passwords meet complexity requirements.
Ensure that all dependencies are installed and properly configured in your environment.
Never ever ever use Random Salt. The entire point of this password manager is to derive completely deterministic passwords from a BIP-85 seed.
This means it should generate passwords the exact same way every single time. Salts would break this functionality and is not appropriate for this software's use case.
"""
import os
import logging
import hashlib
import string
import random
import traceback
import base64
from typing import Optional
from dataclasses import dataclass
from termcolor import colored
from pathlib import Path
import shutil
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ed25519
from cryptography.hazmat.backends import default_backend
from bip_utils import Bip39SeedGenerator
# Ensure the ``imghdr`` module is available for ``pgpy`` on Python 3.13+
try: # pragma: no cover - only executed on Python >= 3.13
import imghdr # type: ignore
except ModuleNotFoundError: # pragma: no cover - fallback for removed module
from utils import imghdr_stub as imghdr # type: ignore
import sys
sys.modules.setdefault("imghdr", imghdr)
from local_bip85.bip85 import BIP85
from constants import DEFAULT_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH, MAX_PASSWORD_LENGTH
from .encryption import EncryptionManager
# Instantiate the logger
logger = logging.getLogger(__name__)
@dataclass
class PasswordPolicy:
"""Minimum complexity requirements for generated passwords."""
min_uppercase: int = 2
min_lowercase: int = 2
min_digits: int = 2
min_special: int = 2
class PasswordGenerator:
"""
PasswordGenerator Class
Responsible for deterministic password generation based on a BIP-39 parent seed.
Utilizes BIP-85 for entropy derivation and ensures that generated passwords meet
complexity requirements.
"""
def __init__(
self,
encryption_manager: EncryptionManager,
parent_seed: str,
bip85: BIP85,
policy: PasswordPolicy | None = None,
):
"""
Initializes the PasswordGenerator with the encryption manager, parent seed, and BIP85 instance.
Parameters:
encryption_manager (EncryptionManager): The encryption manager instance.
parent_seed (str): The BIP-39 parent seed phrase.
bip85 (BIP85): The BIP85 instance for generating deterministic entropy.
"""
try:
self.encryption_manager = encryption_manager
self.parent_seed = parent_seed
self.bip85 = bip85
self.policy = policy or PasswordPolicy()
# Derive seed bytes from parent_seed using BIP39 (handled by EncryptionManager)
self.seed_bytes = self.encryption_manager.derive_seed_from_mnemonic(
self.parent_seed
)
logger.debug("PasswordGenerator initialized successfully.")
except Exception as e:
logger.error(f"Failed to initialize PasswordGenerator: {e}", exc_info=True)
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:
"""
Generates a deterministic password based on the parent seed, desired length, and index.
Steps:
1. Derive entropy using BIP-85.
2. Use HKDF-HMAC-SHA256 to derive a key from entropy.
3. Map the derived key to all allowed characters.
4. Ensure the password meets complexity requirements.
5. Shuffle the password deterministically based on the derived key.
6. Trim or extend the password to the desired length.
Parameters:
length (int): Desired length of the password.
index (int): Index for deriving child entropy.
Returns:
str: The generated password.
"""
try:
# Validate password length
if length < MIN_PASSWORD_LENGTH:
logger.error(
f"Password length must be at least {MIN_PASSWORD_LENGTH} characters."
)
raise ValueError(
f"Password length must be at least {MIN_PASSWORD_LENGTH} characters."
)
if length > MAX_PASSWORD_LENGTH:
logger.error(
f"Password length must not exceed {MAX_PASSWORD_LENGTH} characters."
)
raise ValueError(
f"Password length must not exceed {MAX_PASSWORD_LENGTH} characters."
)
dk = self._derive_password_entropy(index=index)
all_allowed = string.ascii_letters + string.digits + string.punctuation
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)
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 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]
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
except Exception as e:
logger.error(f"Error generating password: {e}", exc_info=True)
print(colored(f"Error: Failed to generate password: {e}", "red"))
raise
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.
Also balances the distribution of character types.
Parameters:
password (str): The initial password.
alphabet (str): Allowed characters in the password.
dk (bytes): Derived key used for deterministic modifications.
Returns:
str: Password that meets complexity requirements.
"""
try:
uppercase = string.ascii_uppercase
lowercase = string.ascii_lowercase
digits = string.digits
special = string.punctuation
password_chars = list(password)
# Current counts
current_upper = sum(1 for c in password_chars if c in uppercase)
current_lower = sum(1 for c in password_chars if c in lowercase)
current_digits = sum(1 for c in password_chars if c in digits)
current_special = sum(1 for c in password_chars if c in special)
logger.debug(
f"Current character counts - Upper: {current_upper}, Lower: {current_lower}, Digits: {current_digits}, Special: {current_special}"
)
# Set minimum counts from policy
min_upper = self.policy.min_uppercase
min_lower = self.policy.min_lowercase
min_digits = self.policy.min_digits
min_special = self.policy.min_special
# Initialize derived key index
dk_index = 0
dk_length = len(dk)
def get_dk_value() -> int:
nonlocal dk_index
value = dk[dk_index % dk_length]
dk_index += 1
return value
# Replace characters to meet minimum counts
if current_upper < min_upper:
for _ in range(min_upper - current_upper):
index = get_dk_value() % len(password_chars)
char = uppercase[get_dk_value() % len(uppercase)]
password_chars[index] = char
logger.debug(
f"Added uppercase letter '{char}' at position {index}."
)
if current_lower < min_lower:
for _ in range(min_lower - current_lower):
index = get_dk_value() % len(password_chars)
char = lowercase[get_dk_value() % len(lowercase)]
password_chars[index] = char
logger.debug(
f"Added lowercase letter '{char}' at position {index}."
)
if current_digits < min_digits:
for _ in range(min_digits - current_digits):
index = get_dk_value() % len(password_chars)
char = digits[get_dk_value() % len(digits)]
password_chars[index] = char
logger.debug(f"Added digit '{char}' at position {index}.")
if 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)]
password_chars[index] = char
logger.debug(
f"Added special character '{char}' at position {index}."
)
# 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)
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
if segment_length > 0:
for i, char_type in enumerate([uppercase, lowercase, digits, special]):
segment_start = i * segment_length
segment_end = segment_start + segment_length
if segment_end > len(password_chars):
segment_end = len(password_chars)
for j in range(segment_start, segment_end):
if i == 0 and password_chars[j] not in uppercase:
char = uppercase[get_dk_value() % len(uppercase)]
password_chars[j] = char
logger.debug(
f"Assigned uppercase letter '{char}' to position {j}."
)
elif i == 1 and password_chars[j] not in lowercase:
char = lowercase[get_dk_value() % len(lowercase)]
password_chars[j] = char
logger.debug(
f"Assigned lowercase letter '{char}' to position {j}."
)
elif i == 2 and password_chars[j] not in digits:
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:
char = special[get_dk_value() % len(special)]
password_chars[j] = char
logger.debug(
f"Assigned special character '{char}' to position {j}."
)
# Shuffle again to distribute the characters more evenly
shuffle_seed = (
int.from_bytes(dk, "big") + dk_index
) # Modify seed to vary shuffle
rng = random.Random(shuffle_seed)
rng.shuffle(password_chars)
logger.debug(f"Shuffled password characters for balanced distribution.")
# Final counts after modifications
final_upper = sum(1 for c in password_chars if c in uppercase)
final_lower = sum(1 for c in password_chars if c in lowercase)
final_digits = sum(1 for c in password_chars if c in digits)
final_special = sum(1 for c in password_chars if c in special)
logger.debug(
f"Final character counts - Upper: {final_upper}, Lower: {final_lower}, Digits: {final_digits}, Special: {final_special}"
)
return "".join(password_chars)
except Exception as e:
logger.error(f"Error ensuring password complexity: {e}", exc_info=True)
print(colored(f"Error: Failed to ensure password complexity: {e}", "red"))
raise
def derive_ssh_key(bip85: BIP85, idx: int) -> bytes:
"""Derive 32 bytes of entropy suitable for an SSH key."""
return bip85.derive_entropy(index=idx, bytes_len=32, app_no=32)
def derive_ssh_key_pair(parent_seed: str, index: int) -> tuple[str, str]:
"""Derive an Ed25519 SSH key pair from the seed phrase and index."""
seed_bytes = Bip39SeedGenerator(parent_seed).Generate()
bip85 = BIP85(seed_bytes)
entropy = derive_ssh_key(bip85, index)
private_key = ed25519.Ed25519PrivateKey.from_private_bytes(entropy)
priv_pem = private_key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
).decode()
public_key = private_key.public_key()
pub_pem = public_key.public_bytes(
serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo,
).decode()
return priv_pem, pub_pem
def derive_seed_phrase(bip85: BIP85, idx: int, words: int = 24) -> str:
"""Derive a new BIP39 seed phrase using BIP85."""
return bip85.derive_mnemonic(index=idx, words_num=words)
def derive_pgp_key(
bip85: BIP85, idx: int, key_type: str = "ed25519", user_id: str = ""
) -> tuple[str, str]:
"""Derive a deterministic PGP private key and return it with its fingerprint."""
from pgpy import PGPKey, PGPUID
from pgpy.packet.packets import PrivKeyV4
from pgpy.packet.fields import (
EdDSAPriv,
RSAPriv,
ECPoint,
ECPointFormat,
EllipticCurveOID,
MPI,
)
from pgpy.constants import (
PubKeyAlgorithm,
KeyFlags,
HashAlgorithm,
SymmetricKeyAlgorithm,
CompressionAlgorithm,
)
from Crypto.PublicKey import RSA
from Crypto.Util.number import inverse
from cryptography.hazmat.primitives.asymmetric import ed25519
from cryptography.hazmat.primitives import serialization
import hashlib
import datetime
entropy = bip85.derive_entropy(index=idx, bytes_len=32, app_no=32)
created = datetime.datetime(2000, 1, 1, tzinfo=datetime.timezone.utc)
if key_type.lower() == "rsa":
class DRNG:
def __init__(self, seed: bytes) -> None:
self.seed = seed
def __call__(self, n: int) -> bytes: # pragma: no cover - deterministic
out = b""
while len(out) < n:
self.seed = hashlib.sha256(self.seed).digest()
out += self.seed
return out[:n]
rsa_key = RSA.generate(2048, randfunc=DRNG(entropy))
keymat = RSAPriv()
keymat.n = MPI(rsa_key.n)
keymat.e = MPI(rsa_key.e)
keymat.d = MPI(rsa_key.d)
keymat.p = MPI(rsa_key.p)
keymat.q = MPI(rsa_key.q)
keymat.u = MPI(inverse(keymat.p, keymat.q))
keymat._compute_chksum()
pkt = PrivKeyV4()
pkt.pkalg = PubKeyAlgorithm.RSAEncryptOrSign
pkt.keymaterial = keymat
else:
priv = ed25519.Ed25519PrivateKey.from_private_bytes(entropy)
public = priv.public_key().public_bytes(
serialization.Encoding.Raw, serialization.PublicFormat.Raw
)
keymat = EdDSAPriv()
keymat.oid = EllipticCurveOID.Ed25519
keymat.s = MPI(int.from_bytes(entropy, "big"))
keymat.p = ECPoint.from_values(
keymat.oid.key_size, ECPointFormat.Native, public
)
keymat._compute_chksum()
pkt = PrivKeyV4()
pkt.pkalg = PubKeyAlgorithm.EdDSA
pkt.keymaterial = keymat
pkt.created = created
pkt.update_hlen()
key = PGPKey()
key._key = pkt
uid = PGPUID.new(user_id)
key.add_uid(
uid,
usage=[
KeyFlags.Sign,
KeyFlags.EncryptCommunications,
KeyFlags.EncryptStorage,
],
hashes=[HashAlgorithm.SHA256],
ciphers=[SymmetricKeyAlgorithm.AES256],
compression=[CompressionAlgorithm.ZLIB],
created=created,
)
return str(key), key.fingerprint