"""AES-GCM secret encryption for Woo app password + inference API key (SETTINGS-20/25, QUALITY-125). Master key = SLAB_AES_KEY env (32-byte base64). Stored form value_enc = base64(nonce(12) || ciphertext+GCM tag). Decrypt failures raise DecryptError — detectable, no crash, no secret leak (SETTINGS-75 masking degrades to configured:false on a bad blob). """ from __future__ import annotations import base64 import os from cryptography.exceptions import InvalidTag from cryptography.hazmat.primitives.ciphers.aead import AESGCM class DecryptError(Exception): """Tampered, corrupt, or wrong-key ciphertext. Message never carries the blob.""" def _get_key() -> bytes: key_b64 = os.environ.get("SLAB_AES_KEY") if not key_b64: raise RuntimeError("SETTINGS-25: SLAB_AES_KEY is required (32-byte base64)") try: key = base64.b64decode(key_b64, validate=True) except Exception: raise RuntimeError("SETTINGS-25: SLAB_AES_KEY must be valid base64") from None if len(key) != 32: raise RuntimeError("SETTINGS-25: SLAB_AES_KEY must decode to exactly 32 bytes") return key def encrypt(plaintext: str) -> str: """Encrypt a plaintext secret -> base64(nonce || ciphertext) for value_enc.""" if not isinstance(plaintext, str): raise TypeError("plaintext must be str") nonce = os.urandom(12) ct = AESGCM(_get_key()).encrypt(nonce, plaintext.encode("utf-8"), None) return base64.b64encode(nonce + ct).decode("ascii") def decrypt(ciphertext_b64: str) -> str: """Decrypt a value_enc blob; verifies the GCM tag. Raises DecryptError (no leak).""" if not isinstance(ciphertext_b64, str): raise TypeError("ciphertext_b64 must be str") try: blob = base64.b64decode(ciphertext_b64, validate=True) except Exception: raise DecryptError("corrupt base64") from None if len(blob) < 12: raise DecryptError("corrupt: too short for nonce") try: pt = AESGCM(_get_key()).decrypt(blob[:12], blob[12:], None) except InvalidTag: raise DecryptError("decrypt failed: invalid tag (tampered or wrong key)") from None except Exception: raise DecryptError("decrypt failed") from None return pt.decode("utf-8")