from __future__ import annotations import secrets from abc import ABC, abstractmethod from typing import Literal from cryptography.fernet import Fernet from fansifter_common.utils.functional import lazy_proxy from app.adapters import aws_kms from app.config import settings class Encrypter(ABC): @abstractmethod def encrypt(self, plaintext: str) -> str: """Encrypt plaintext and return a base64-encoded ciphertext string.""" @abstractmethod def decrypt(self, ciphertext: str) -> str: """Decrypt a base64-encoded ciphertext string and return plaintext.""" # --------------------------------------------------------------------------- # Fernet # --------------------------------------------------------------------------- _KEY_ID_LEN = 8 class FernetEncrypter(Encrypter): def __init__(self, keys: list[str]) -> None: self._fernets: dict[str, Fernet] = {} for k in keys: key_id, _, fernet_key = k.partition(":") self._fernets[key_id] = Fernet(fernet_key.encode()) self._active_key_id = keys[0].partition(":")[0] @property def active_key_id(self) -> str: return self._active_key_id def encrypt(self, plaintext: str) -> str: token = self._fernets[self._active_key_id].encrypt(plaintext.encode()).decode() return f"{self._active_key_id}:{token}" def decrypt(self, ciphertext: str) -> str: key_id, _, token = ciphertext.partition(":") return self._fernets[key_id].decrypt(token.encode()).decode() @staticmethod def generate_key() -> str: key_id = secrets.token_hex(_KEY_ID_LEN // 2) return f"{key_id}:{Fernet.generate_key().decode()}" # --------------------------------------------------------------------------- # KMS # --------------------------------------------------------------------------- class KMSEncrypter(Encrypter): def encrypt(self, plaintext: str) -> str: return aws_kms.encrypt(plaintext) def decrypt(self, ciphertext: str) -> str: return aws_kms.decrypt(ciphertext) # --------------------------------------------------------------------------- # Factory # --------------------------------------------------------------------------- EncrypterBackend = Literal["fernet", "kms"] def get_encrypter() -> Encrypter: if settings.encrypter_backend == "kms": return KMSEncrypter() return FernetEncrypter(keys=settings.fernet_keys) encrypter = lazy_proxy(get_encrypter) def encrypt(plaintext: str) -> str: return encrypter.encrypt(plaintext) def decrypt(ciphertext: str) -> str: return encrypter.decrypt(ciphertext)