Skip to content

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.