"""Credential vault — envelope encryption (SPEC §10.2). Mailbox secrets are encrypted with a fresh AES-256-GCM *data key*; the data key is itself wrapped with the master key from ``MASTER_KEY`` (32 bytes, base64). A key id is embedded to allow future master-key rotation. Wire format of ``secret_ct`` (all lengths fixed unless noted):: b"WMV1" # 4-byte magic + version key_id # 1 byte (master key id, 0 for now) wrap_nonce # 12 bytes (GCM nonce for the wrapped data key) wrapped_key # 32 + 16 bytes (data key ciphertext + tag) data_nonce # 12 bytes (GCM nonce for the payload) payload_ciphertext # variable (payload + 16-byte tag) The decryption path exists only in workers and ``accounts/test`` (SPEC §10.2); this module enforces nothing about callers but is imported only from there. """ from __future__ import annotations import base64 import os from cryptography.hazmat.primitives.ciphers.aead import AESGCM from app.config import get_settings _MAGIC = b"WMV1" _CURRENT_KEY_ID = 0 class VaultError(Exception): """Raised when a ciphertext is malformed or cannot be authenticated.""" def _master_key() -> bytes: raw = base64.b64decode(get_settings().master_key) if len(raw) != 32: raise VaultError("MASTER_KEY must decode to exactly 32 bytes") return raw def encrypt_secret(plaintext: bytes) -> bytes: """Envelope-encrypt ``plaintext`` and return the ``secret_ct`` blob.""" data_key = AESGCM.generate_key(256) wrap_nonce = os.urandom(12) wrapped = AESGCM(_master_key()).encrypt(wrap_nonce, data_key, _MAGIC) data_nonce = os.urandom(12) ciphertext = AESGCM(data_key).encrypt(data_nonce, plaintext, _MAGIC) return _MAGIC + bytes([_CURRENT_KEY_ID]) + wrap_nonce + wrapped + data_nonce + ciphertext def decrypt_secret(blob: bytes) -> bytes: """Authenticate and decrypt a ``secret_ct`` blob back to plaintext bytes.""" if len(blob) < 4 + 1 + 12 + 48 + 12 or blob[:4] != _MAGIC: raise VaultError("malformed secret ciphertext") pos = 4 key_id = blob[pos] pos += 1 if key_id != _CURRENT_KEY_ID: raise VaultError(f"unknown master key id {key_id}") wrap_nonce = blob[pos : pos + 12] pos += 12 wrapped = blob[pos : pos + 48] # 32-byte key + 16-byte GCM tag pos += 48 data_nonce = blob[pos : pos + 12] pos += 12 ciphertext = blob[pos:] try: data_key = AESGCM(_master_key()).decrypt(wrap_nonce, wrapped, _MAGIC) return AESGCM(data_key).decrypt(data_nonce, ciphertext, _MAGIC) except Exception as exc: # cryptography raises InvalidTag etc. raise VaultError("secret decryption failed") from exc