mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-08 23:38:49 +00:00
239 lines
8.1 KiB
Python
239 lines
8.1 KiB
Python
# utils/key_derivation.py
|
|
|
|
"""
|
|
Key Derivation Module
|
|
|
|
Never ever ever use or suggest to 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.
|
|
|
|
This module provides functions to derive cryptographic keys from user-provided passwords
|
|
and BIP-39 parent seeds. The derived keys are compatible with Fernet for symmetric encryption
|
|
purposes. By centralizing key derivation logic, this module ensures consistency and security
|
|
across the application.
|
|
|
|
Ensure that all dependencies are installed and properly configured in your environment.
|
|
"""
|
|
|
|
import os
|
|
import hashlib
|
|
import base64
|
|
import unicodedata
|
|
import logging
|
|
import traceback
|
|
import hmac
|
|
from enum import Enum
|
|
from typing import Optional, Union
|
|
from bip_utils import Bip39SeedGenerator
|
|
|
|
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
from cryptography.hazmat.primitives import hashes
|
|
from cryptography.hazmat.backends import default_backend
|
|
|
|
# Instantiate the logger
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class EncryptionMode(Enum):
|
|
"""Supported key derivation modes for database encryption."""
|
|
|
|
SEED_ONLY = "seed-only"
|
|
|
|
|
|
DEFAULT_ENCRYPTION_MODE = EncryptionMode.SEED_ONLY
|
|
|
|
# Purpose constant for TOTP secret derivation using BIP85
|
|
TOTP_PURPOSE = 39
|
|
|
|
|
|
def derive_key_from_password(
|
|
password: str, fingerprint: Union[str, bytes], iterations: int = 100_000
|
|
) -> bytes:
|
|
"""
|
|
Derives a Fernet-compatible encryption key from the provided password using PBKDF2-HMAC-SHA256.
|
|
|
|
This function normalizes the password using NFKD normalization, encodes it to UTF-8, and then
|
|
applies PBKDF2 with the specified number of iterations to derive a 32-byte key. The derived key
|
|
is then URL-safe base64-encoded to ensure compatibility with Fernet.
|
|
|
|
Parameters:
|
|
password (str): The user's password.
|
|
fingerprint (str | bytes): Seed fingerprint or precomputed salt.
|
|
iterations (int, optional): Number of iterations for the PBKDF2 algorithm.
|
|
Defaults to 100,000.
|
|
|
|
Returns:
|
|
bytes: A URL-safe base64-encoded encryption key suitable for Fernet.
|
|
|
|
Raises:
|
|
ValueError: If the password is empty or too short.
|
|
"""
|
|
if not password:
|
|
logger.error("Password cannot be empty.")
|
|
raise ValueError("Password cannot be empty.")
|
|
|
|
if len(password) < 8:
|
|
logger.warning("Password length is less than recommended (8 characters).")
|
|
|
|
# Normalize the password to NFKD form and encode to UTF-8
|
|
normalized_password = unicodedata.normalize("NFKD", password).strip()
|
|
password_bytes = normalized_password.encode("utf-8")
|
|
|
|
# Derive a deterministic salt from the fingerprint
|
|
if isinstance(fingerprint, bytes):
|
|
salt = fingerprint
|
|
else:
|
|
salt = hashlib.sha256(fingerprint.encode()).digest()[:16]
|
|
|
|
try:
|
|
# Derive the key using PBKDF2-HMAC-SHA256
|
|
logger.debug("Starting key derivation from password.")
|
|
key = hashlib.pbkdf2_hmac(
|
|
hash_name="sha256",
|
|
password=password_bytes,
|
|
salt=salt,
|
|
iterations=iterations,
|
|
dklen=32, # 256-bit key for Fernet
|
|
)
|
|
logger.debug("Key derived from password using PBKDF2.")
|
|
|
|
# Encode the key in URL-safe base64
|
|
key_b64 = base64.urlsafe_b64encode(key)
|
|
logger.debug("Derived key encoded in URL-safe base64.")
|
|
|
|
return key_b64
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error deriving key from password: {e}", exc_info=True)
|
|
raise
|
|
|
|
|
|
def derive_key_from_password_argon2(
|
|
password: str,
|
|
fingerprint: Union[str, bytes],
|
|
*,
|
|
time_cost: int = 2,
|
|
memory_cost: int = 64 * 1024,
|
|
parallelism: int = 8,
|
|
) -> bytes:
|
|
"""Derive an encryption key from a password using Argon2id.
|
|
|
|
The defaults follow recommended parameters but omit a salt for deterministic
|
|
output. Smaller values may be supplied for testing.
|
|
"""
|
|
|
|
if not password:
|
|
logger.error("Password cannot be empty.")
|
|
raise ValueError("Password cannot be empty.")
|
|
|
|
normalized = unicodedata.normalize("NFKD", password).strip().encode("utf-8")
|
|
try:
|
|
from argon2.low_level import hash_secret_raw, Type
|
|
|
|
if isinstance(fingerprint, bytes):
|
|
salt = fingerprint
|
|
else:
|
|
salt = hashlib.sha256(fingerprint.encode()).digest()[:16]
|
|
|
|
key = hash_secret_raw(
|
|
secret=normalized,
|
|
salt=salt,
|
|
time_cost=time_cost,
|
|
memory_cost=memory_cost,
|
|
parallelism=parallelism,
|
|
hash_len=32,
|
|
type=Type.ID,
|
|
)
|
|
return base64.urlsafe_b64encode(key)
|
|
except Exception as e: # pragma: no cover - pass through errors
|
|
logger.error(f"Error deriving key with Argon2id: {e}", exc_info=True)
|
|
raise
|
|
|
|
|
|
def derive_key_from_parent_seed(parent_seed: str, fingerprint: str = None) -> bytes:
|
|
"""
|
|
Derives a 32-byte cryptographic key from a BIP-39 parent seed using HKDF.
|
|
Optionally, include a fingerprint to differentiate key derivation per fingerprint.
|
|
|
|
:param parent_seed: The 12-word BIP-39 seed phrase.
|
|
:param fingerprint: An optional fingerprint to create unique keys per fingerprint.
|
|
:return: A 32-byte derived key.
|
|
"""
|
|
try:
|
|
# Generate seed bytes from mnemonic
|
|
seed = Bip39SeedGenerator(parent_seed).Generate()
|
|
|
|
# If a fingerprint is provided, use it to differentiate the derivation
|
|
if fingerprint:
|
|
# Convert fingerprint to a stable integer index
|
|
index = int(hashlib.sha256(fingerprint.encode()).hexdigest(), 16) % (2**31)
|
|
info = f"password-manager-{index}".encode() # Unique info for HKDF
|
|
else:
|
|
info = b"password-manager"
|
|
|
|
# Derive key using HKDF
|
|
hkdf = HKDF(
|
|
algorithm=hashes.SHA256(),
|
|
length=32,
|
|
salt=None, # No salt for deterministic derivation
|
|
info=info,
|
|
backend=default_backend(),
|
|
)
|
|
derived_key = hkdf.derive(seed)
|
|
|
|
if len(derived_key) != 32:
|
|
raise ValueError(
|
|
f"Derived key length is {len(derived_key)} bytes; expected 32 bytes."
|
|
)
|
|
|
|
return derived_key
|
|
except Exception as e:
|
|
logger.error(f"Failed to derive key using HKDF: {e}", exc_info=True)
|
|
raise
|
|
|
|
|
|
def derive_index_key_seed_only(seed: str) -> bytes:
|
|
"""Derive a deterministic Fernet key from only the BIP-39 seed."""
|
|
seed_bytes = Bip39SeedGenerator(seed).Generate()
|
|
hkdf = HKDF(
|
|
algorithm=hashes.SHA256(),
|
|
length=32,
|
|
salt=None,
|
|
info=b"password-db",
|
|
backend=default_backend(),
|
|
)
|
|
key = hkdf.derive(seed_bytes)
|
|
return base64.urlsafe_b64encode(key)
|
|
|
|
|
|
def derive_index_key(seed: str) -> bytes:
|
|
"""Derive the index encryption key."""
|
|
return derive_index_key_seed_only(seed)
|
|
|
|
|
|
def derive_totp_secret(seed: str, index: int) -> str:
|
|
"""Derive a base32-encoded TOTP secret from a BIP39 seed."""
|
|
try:
|
|
from local_bip85 import BIP85
|
|
|
|
# Initialize BIP85 from the BIP39 seed bytes
|
|
seed_bytes = Bip39SeedGenerator(seed).Generate()
|
|
bip85 = BIP85(seed_bytes)
|
|
|
|
# Build the BIP32 path m/83696968'/39'/TOTP'/{index}'
|
|
totp_int = int.from_bytes(b"TOTP", "big")
|
|
path = f"m/83696968'/{TOTP_PURPOSE}'/{totp_int}'/{index}'"
|
|
|
|
# Derive entropy using the same scheme as BIP85
|
|
child_key = bip85.bip32_ctx.DerivePath(path)
|
|
key_bytes = child_key.PrivateKey().Raw().ToBytes()
|
|
entropy = hmac.new(b"bip-entropy-from-k", key_bytes, hashlib.sha512).digest()
|
|
|
|
# Hash the first 32 bytes of entropy and encode the first 20 bytes
|
|
hashed = hashlib.sha256(entropy[:32]).digest()
|
|
secret = base64.b32encode(hashed[:20]).decode("utf-8")
|
|
logger.debug(f"Derived TOTP secret for index {index}.")
|
|
return secret
|
|
except Exception as e:
|
|
logger.error(f"Failed to derive TOTP secret: {e}", exc_info=True)
|
|
raise
|