crypto¶
Runtime encryption primitives — AES-GCM envelopes and key wrapping.
Implements the client-owned key hierarchy used for session confidentiality:
DEK— random 256-bit data encryption key, generated client-side.KEK— key encryption key derived from the user's password via PBKDF2-HMAC-SHA256 (600,000 iterations). Derivation happens in the browser; the server only ever sees the DEK wrapped with the KEK.-
Envelope format stored in the database::
{"v": 1, "enc": "
"}
The server encrypts/decrypts with the DEK (supplied per request by the client via the X-DEK header) and never persists either key in a usable form. See add.md decision 14 for the full threat model.
This module intentionally has no I/O or FastAPI imports so it is trivially testable and reusable from the CLI.
CryptoError
¶
Bases: Exception
Raised when decryption, unwrapping, or envelope parsing fails.
decrypt(dek, envelope)
¶
Decrypt an envelope produced by :func:encrypt back to a string.
derive_kek(password, salt)
¶
Derive a 256-bit KEK from a password using PBKDF2-HMAC-SHA256.
Parameters must match the frontend WebCrypto derivation exactly (see frontend/src/lib/crypto.ts and the shared interop vectors).
encrypt(dek, plaintext, iv=None)
¶
Encrypt a plaintext string with the DEK and return an envelope.
The optional iv parameter is for deterministic test vectors
only — production callers must omit it so a fresh random nonce is
generated per message (GCM nonce reuse is catastrophic).
generate_dek()
¶
Generate a fresh random 256-bit data encryption key.
is_envelope(text)
¶
Return True if text looks like an encrypted envelope.
Used to distinguish legacy plaintext rows from encrypted ones. Deliberately cheap — full validation happens in decrypt().
is_valid_wrapped_dek(envelope)
¶
Return True if envelope wraps a well-formed 32-byte DEK.
Validates the envelope version and that the payload length is exactly nonce(12) + DEK(32) + tag(16). Used to reject malformed client uploads before they are persisted.
new_salt()
¶
Generate a fresh random 16-byte KDF salt.
normalize_salt(text)
¶
Decode and validate a base64url KDF salt; return None if invalid.
A valid salt is exactly 16 bytes. Salts are public — this only guards against malformed data, not secrecy.
unwrap_dek(kek, envelope)
¶
Recover a DEK from its KEK-wrapped envelope.
wrap_dek(kek, dek, iv=None)
¶
Encrypt a DEK with the KEK and return an envelope for storage.