"""AES-GCM settings encryption and decryption. Per AGENTS.md ยง4 and DATA-MODEL.md settings table: - AES master key is SLAB_AES_KEY env var (32 bytes base64). - value_enc = nonce(12) || ciphertext. - Fresh nonce per encrypt. - Tampered ciphertext raises authentication failure. - No plaintext fallback. """ import base64 from cryptography.hazmat.primitives.ciphers.aead import AESGCM class AuthenticationError(Exception): """Raised when ciphertext integrity check fails (tamper or wrong key).""" pass def get_aes_key(key_input: str | bytes) -> bytes: """Decode the AES master key to 32 bytes. Accepts either a base64-encoded string or raw bytes. Args: key_input: Base64-encoded string or raw bytes (must be >= 32 bytes when decoded). Returns: 32 raw bytes suitable for AES-256-GCM. Raises: ValueError: if key is too short or not valid base64. """ if isinstance(key_input, bytes): key_bytes = key_input else: key_bytes = base64.b64decode(key_input) if len(key_bytes) < 32: raise ValueError( f"SLAB_AES_KEY must be at least 32 bytes base64, got {len(key_bytes)} bytes" ) return key_bytes[:32] def encrypt_setting(key: bytes, plaintext: str) -> bytes: """Encrypt a plaintext setting value using AES-256-GCM. Args: key: 32-byte AES key (decoded from SLAB_AES_KEY). plaintext: The secret value to encrypt (e.g. woo_app_password). Returns: Bytes of form nonce(12) || ciphertext. Each call generates a fresh 12-byte nonce. """ import os aesgcm = AESGCM(key) nonce = os.urandom(12) ct = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None) return nonce + ct def decrypt_setting(key: bytes, ciphertext: bytes) -> str: """Decrypt a setting ciphertext back to plaintext. Args: key: 32-byte AES key (decoded from SLAB_AES_KEY). ciphertext: Bytes of form nonce(12) || ciphertext from encrypt_setting. Returns: Decrypted plaintext string. Raises: AuthenticationError: if ciphertext is tampered, truncated, or decrypted with the wrong key. """ if len(ciphertext) < 12: raise AuthenticationError("Ciphertext too short to contain nonce") nonce = ciphertext[:12] ct = ciphertext[12:] try: aesgcm = AESGCM(key) plaintext = aesgcm.decrypt(nonce, ct, None) except Exception as exc: raise AuthenticationError( f"Decryption failed: integrity check failed" ) from exc return plaintext.decode("utf-8")