Files
webmail-client/backend/app/security/crypto.py
T

78 lines
2.7 KiB
Python
Raw Normal View History

2026-07-17 23:20:34 +02:00
"""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