""" Cryptographic utilities for secrets management. Uses Argon2id for password-based key derivation and Fernet for encryption. """ import os import secrets as secrets_module from typing import Tuple from argon2 import PasswordHasher from argon2.low_level import hash_secret_raw, Type from cryptography.fernet import Fernet import base64 # Argon2id parameters (OWASP recommended for password-based KDF) # These provide strong defense against GPU/ASIC attacks ARGON2_TIME_COST = 3 # iterations ARGON2_MEMORY_COST = 65536 # 64 MB ARGON2_PARALLELISM = 4 # threads ARGON2_HASH_LENGTH = 32 # bytes (256 bits for Fernet key) ARGON2_SALT_LENGTH = 16 # bytes (128 bits) def generate_salt() -> bytes: """Generate a cryptographically secure random salt.""" return secrets_module.token_bytes(ARGON2_SALT_LENGTH) def derive_key_from_password(password: str, salt: bytes) -> bytes: """ Derive an encryption key from a password using Argon2id. Args: password: The master password salt: The salt (must be consistent for the same password to work) Returns: 32-byte key suitable for Fernet encryption """ password_bytes = password.encode('utf-8') # Use Argon2id (hybrid mode - best of Argon2i and Argon2d) raw_hash = hash_secret_raw( secret=password_bytes, salt=salt, time_cost=ARGON2_TIME_COST, memory_cost=ARGON2_MEMORY_COST, parallelism=ARGON2_PARALLELISM, hash_len=ARGON2_HASH_LENGTH, type=Type.ID # Argon2id ) return raw_hash def create_fernet(key: bytes) -> Fernet: """ Create a Fernet cipher instance from a raw key. Args: key: 32-byte raw key from Argon2id Returns: Fernet instance for encryption/decryption """ # Fernet requires a URL-safe base64-encoded 32-byte key fernet_key = base64.urlsafe_b64encode(key) return Fernet(fernet_key) def encrypt_data(data: bytes, key: bytes) -> bytes: """ Encrypt data using Fernet (AES-256-CBC). Args: data: Raw bytes to encrypt key: 32-byte encryption key Returns: Encrypted data (includes IV and auth tag) """ fernet = create_fernet(key) return fernet.encrypt(data) def decrypt_data(encrypted_data: bytes, key: bytes) -> bytes: """ Decrypt data using Fernet. Args: encrypted_data: Encrypted bytes from encrypt_data key: 32-byte encryption key (must match encryption key) Returns: Decrypted raw bytes Raises: cryptography.fernet.InvalidToken: If decryption fails (wrong key/corrupted data) """ fernet = create_fernet(key) return fernet.decrypt(encrypted_data) def create_verification_hash(password: str, salt: bytes) -> str: """ Create a verification hash to check if a password is correct. This is NOT for storing the password - it's for verifying the password unlocks the correct key without trying to decrypt the entire secrets file. Args: password: The master password salt: The salt used for key derivation Returns: Base64-encoded hash for verification """ # Derive key and hash it again for verification key = derive_key_from_password(password, salt) # Simple hash of the key for verification (not security critical since # the key itself is already derived from Argon2id) verification = base64.b64encode(key[:16]).decode('ascii') return verification def verify_password(password: str, salt: bytes, verification_hash: str) -> bool: """ Verify a password against a verification hash. Args: password: Password to verify salt: Salt used for key derivation verification_hash: Expected verification hash Returns: True if password is correct, False otherwise """ computed_hash = create_verification_hash(password, salt) # Constant-time comparison to prevent timing attacks return secrets_module.compare_digest(computed_hash, verification_hash)