from __future__ import annotations import os from dataclasses import dataclass from cryptography.fernet import Fernet, InvalidToken from backend.bootstrap.settings import ConfigurationError, Settings @dataclass(frozen=True, slots=True) class SecretCipher: _fernet: Fernet @classmethod def from_key(cls, key: str) -> SecretCipher: try: return cls(Fernet(key.encode("ascii"))) except (ValueError, UnicodeEncodeError) as exc: raise ConfigurationError("APP_ENCRYPTION_KEY is not a valid Fernet key") from exc def encrypt(self, value: str) -> str: return self._fernet.encrypt(value.encode("utf-8")).decode("ascii") def decrypt(self, value: str) -> str: try: return self._fernet.decrypt(value.encode("ascii")).decode("utf-8") except (InvalidToken, UnicodeDecodeError, UnicodeEncodeError) as exc: raise ValueError("Encrypted value cannot be decrypted") from exc def load_or_create_cipher(settings: Settings) -> SecretCipher: if settings.encryption_key is not None: return SecretCipher.from_key(settings.encryption_key) if settings.environment == "production": raise ConfigurationError("APP_ENCRYPTION_KEY is required in production") key_file = settings.data_directory / ".encryption.key" key_file.parent.mkdir(parents=True, exist_ok=True) if key_file.exists(): return SecretCipher.from_key(key_file.read_text(encoding="ascii").strip()) key = Fernet.generate_key().decode("ascii") try: with key_file.open("x", encoding="ascii") as handle: handle.write(key) os.chmod(key_file, 0o600) except FileExistsError: key = key_file.read_text(encoding="ascii").strip() return SecretCipher.from_key(key)