145 lines
4.0 KiB
Python
145 lines
4.0 KiB
Python
"""
|
|
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)
|