Files
openrouter-watcher/src/openrouter_monitor/services/encryption.py
Luca Sacchi Ricciardi 2fdd9d16fd feat(security): T12 implement AES-256 encryption service
- Add EncryptionService with AES-256-GCM via cryptography.fernet
- Implement PBKDF2HMAC key derivation with SHA256 (100k iterations)
- Deterministic salt derived from master_key for consistency
- Methods: encrypt(), decrypt() with proper error handling
- 12 comprehensive tests with 100% coverage
- Handle InvalidToken, TypeError edge cases
2026-04-07 12:03:45 +02:00

99 lines
3.1 KiB
Python

"""Encryption service for sensitive data using AES-256-GCM.
This module provides encryption and decryption functionality using
cryptography.fernet which implements AES-256-GCM with PBKDF2HMAC
key derivation.
"""
import base64
import hashlib
from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
class EncryptionService:
"""Service for encrypting and decrypting sensitive data.
Uses AES-256-GCM via Fernet with PBKDF2HMAC key derivation.
The salt is derived deterministically from the master key to
ensure consistent encryption/decryption across sessions.
"""
def __init__(self, master_key: str):
"""Initialize encryption service with master key.
Args:
master_key: The master encryption key. Should be at least
32 characters for security.
"""
self._fernet = self._derive_key(master_key)
def _derive_key(self, master_key: str) -> Fernet:
"""Derive Fernet key from master key using PBKDF2HMAC.
The salt is derived deterministically from the master key itself
using SHA-256. This ensures:
1. Same master key always produces same encryption key
2. No need to store salt separately
3. Different master keys produce different salts
Args:
master_key: The master encryption key.
Returns:
Fernet instance initialized with derived key.
"""
# Derive salt deterministically from master_key
# This ensures same master_key always produces same key
salt = hashlib.sha256(master_key.encode()).digest()[:16]
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=100000,
)
key = base64.urlsafe_b64encode(kdf.derive(master_key.encode()))
return Fernet(key)
def encrypt(self, plaintext: str) -> str:
"""Encrypt plaintext string.
Args:
plaintext: The string to encrypt.
Returns:
Base64-encoded ciphertext.
Raises:
TypeError: If plaintext is not a string.
"""
if not isinstance(plaintext, str):
raise TypeError("plaintext must be a string")
# Fernet.encrypt returns bytes, decode to string
ciphertext_bytes = self._fernet.encrypt(plaintext.encode("utf-8"))
return ciphertext_bytes.decode("utf-8")
def decrypt(self, ciphertext: str) -> str:
"""Decrypt ciphertext string.
Args:
ciphertext: The base64-encoded ciphertext to decrypt.
Returns:
The decrypted plaintext string.
Raises:
InvalidToken: If ciphertext is invalid or corrupted.
TypeError: If ciphertext is not a string.
"""
if not isinstance(ciphertext, str):
raise TypeError("ciphertext must be a string")
# Fernet.decrypt expects bytes
plaintext_bytes = self._fernet.decrypt(ciphertext.encode("utf-8"))
return plaintext_bytes.decode("utf-8")