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
This commit is contained in:
98
src/openrouter_monitor/services/encryption.py
Normal file
98
src/openrouter_monitor/services/encryption.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user