"""AES-256-GCM encryption for cloud provider credentials stored in DB.""" import base64 import binascii import json import logging import os from cryptography.exceptions import InvalidTag from cryptography.hazmat.primitives.ciphers.aead import AESGCM from app.core.config import settings logger = logging.getLogger(__name__) class CredentialDecryptionError(RuntimeError): """Raised when a stored credential blob cannot be decrypted. Carries an actionable message so admins know which integration to repair instead of seeing an opaque base64/InvalidTag traceback. """ def _get_key() -> bytes: raw = settings.CREDENTIAL_ENCRYPTION_KEY if not raw: raise RuntimeError("CREDENTIAL_ENCRYPTION_KEY is not set") padded = raw + "=" * (4 - len(raw) % 4) try: key = base64.urlsafe_b64decode(padded) except (ValueError, TypeError) as exc: # Pre-launch hardening 2026-05-16 (SS3 P2): surface a clear # "your KEK isn't valid base64" instead of letting it ripple # into an InvalidKey at first encrypt. raise RuntimeError( "CREDENTIAL_ENCRYPTION_KEY is not valid url-safe base64", ) from exc if len(key) not in (16, 24, 32): # AES-GCM requires 128 / 192 / 256-bit keys. A misconfigured # 17-byte env value would otherwise fail later with # ``InvalidKey`` deep inside ``AESGCM(key)``. Loud upfront # check makes the operator error obvious at boot / first call. raise RuntimeError( f"CREDENTIAL_ENCRYPTION_KEY decodes to {len(key)} bytes; " "AES-GCM requires 16, 24, or 32 bytes." ) return key def _aad_for(integration_id: str | None) -> bytes | None: """Compute the AES-GCM Additional Authenticated Data (AAD) tying a ciphertext to a specific integration row. Pre-launch hardening 2026-05-06: without row-bound AAD, an attacker with DB write (or a SQL-injection on a different domain — defense in depth matters) can SWAP the ``(secrets_encrypted, secrets_nonce)`` pair from a low-privilege Slack-test integration onto a high-privilege production AWS row, then call test-connection: the AWS provider class would happily run with whichever credentials the test row carries. Binding AAD to the row id makes the AEAD tag refuse to decrypt against any other row. Returns None when ``integration_id`` is None — used by legacy callers / migration shims that need to round-trip pre-AAD blobs until they're re-saved. """ if integration_id is None: return None return f"integration:{integration_id}".encode() def encrypt_fields( fields: dict, *, integration_id: str | None = None, ) -> tuple[str, str]: """Encrypt a credential dict. Returns (ciphertext_b64url, nonce_b64url). When ``integration_id`` is supplied (the new default for all production callers), the AEAD tag binds to the row's identity so a ciphertext-swap attack is detected on decrypt. """ key = _get_key() nonce = os.urandom(12) aesgcm = AESGCM(key) plaintext = json.dumps(fields).encode() aad = _aad_for(integration_id) ciphertext = aesgcm.encrypt(nonce, plaintext, aad) return ( base64.urlsafe_b64encode(ciphertext).decode(), base64.urlsafe_b64encode(nonce).decode(), ) def decrypt_fields( ciphertext_b64: str | None, nonce_b64: str | None, *, integration_id: str | None = None, ) -> dict: """Decrypt a credential dict. Raises CredentialDecryptionError with an actionable message instead of a low-level TypeError / InvalidTag when the stored blob is malformed. When ``integration_id`` is supplied, decrypt with the matching AAD first; legacy rows (encrypted before 2026-05-06 without AAD) fall back transparently. When the fallback succeeds, callers SHOULD re-encrypt the row on the next save so the legacy blob doesn't persist forever — this is a transitional path, not a permanent bypass. """ if not ciphertext_b64: raise CredentialDecryptionError( "credential ciphertext is missing — re-upload the integration credentials" ) if not nonce_b64: raise CredentialDecryptionError( "credential nonce is missing — re-upload the integration credentials" ) try: key = _get_key() except Exception as exc: raise CredentialDecryptionError( f"CREDENTIAL_ENCRYPTION_KEY misconfigured: {exc}" ) from exc try: ct = base64.urlsafe_b64decode(ciphertext_b64 + "=" * (4 - len(ciphertext_b64) % 4)) nonce = base64.urlsafe_b64decode(nonce_b64 + "=" * (4 - len(nonce_b64) % 4)) except (binascii.Error, ValueError) as exc: raise CredentialDecryptionError( f"credential blob is not valid base64: {exc}" ) from exc if len(nonce) != 12: raise CredentialDecryptionError( f"credential nonce has wrong length ({len(nonce)} bytes, expected 12) — " "the row is corrupted; re-upload the integration credentials" ) aesgcm = AESGCM(key) aad = _aad_for(integration_id) plaintext: bytes | None = None try: plaintext = aesgcm.decrypt(nonce, ct, aad) except InvalidTag: # Legacy fallback: rows encrypted before AAD was bound to the # integration_id. Try once with AAD=None. If that succeeds, the # row is still vulnerable to a ciphertext-swap attack against # any other legacy row. Log a WARNING so ops can monitor the # transition and trigger re-encryption (migration 125 ships # the one-shot rotate; this warning is the residual signal). if aad is not None: try: plaintext = aesgcm.decrypt(nonce, ct, None) except InvalidTag as exc: raise CredentialDecryptionError( "credential decryption failed (InvalidTag) — either " "the ciphertext was swapped from a different " "integration row (AAD mismatch), the encryption key " "was rotated without re-encryption, or the stored " "blob is corrupted." ) from exc logger.warning( "credential_crypto: legacy no-AAD ciphertext decrypted " "on integration_id=%s — this row is still vulnerable to " "cross-row ciphertext swap until re-saved or migration " "125 re-encrypts it. Save the integration via the admin " "UI to upgrade the AAD binding.", integration_id, ) else: raise CredentialDecryptionError( "credential decryption failed (InvalidTag) — likely " "CREDENTIAL_ENCRYPTION_KEY rotation without re-encryption, " "or the stored blob is corrupted" ) try: return json.loads(plaintext) except json.JSONDecodeError as exc: raise CredentialDecryptionError( f"decrypted credential is not valid JSON: {exc}" ) from exc