Encryption stack
Layer 1 — Key derivation
Section titled “Layer 1 — Key derivation”master_password + argon2_salt (16 bytes, per-user, stored on server) ↓Argon2id( time=1, memory=65536 KB (64 MB), threads=4, keyLen=32) ↓KEK — 32 bytesWhy Argon2id: Memory-hard. Resistant to GPU and ASIC brute-force attacks. Winner of the Password Hashing Competition. The id variant combines the side-channel resistance of Argon2i with the GPU resistance of Argon2d.
Why these parameters: Calibrated to ~500ms on a modern laptop — fast enough to not be annoying, slow enough to make brute-force prohibitively expensive. Parameters are hardcoded client-side and not negotiated with the server.
Layer 2 — DEK envelope
Section titled “Layer 2 — DEK envelope”Each secret has a unique, randomly generated Data Encryption Key (DEK):
crypto/rand → DEK (32 bytes)
AES-256-GCM( key=KEK, nonce=crypto/rand(12 bytes), plaintext=DEK) → dek_encrypted + dek_nonceThe encrypted DEK (dek_encrypted) and its nonce (dek_nonce) are stored on the server.
Layer 3 — Secret encryption
Section titled “Layer 3 — Secret encryption”AES-256-GCM( key=DEK, nonce=crypto/rand(12 bytes), plaintext=secret_value) → ciphertext + nonceThe ciphertext and nonce are stored on the server. The DEK is zeroed from memory immediately after this operation.
What the server stores (per secret)
Section titled “What the server stores (per secret)”{ "ciphertext_b64": "base64(AES-256-GCM(DEK, plaintext))", "nonce_b64": "base64(12-byte nonce for ciphertext)", "dek_encrypted_b64": "base64(AES-256-GCM(KEK, DEK))", "dek_nonce_b64": "base64(12-byte nonce for DEK encryption)"}The server has none of the keys required to decrypt any of these fields.
Transport — ML-KEM-768 + X25519 Hybrid
Section titled “Transport — ML-KEM-768 + X25519 Hybrid”All HTTPS traffic between ov mcp serve and api.opaquevault.com uses a post-quantum hybrid KEM:
client_x25519_private + server_x25519_public → shared_x25519client_mlkem_private + server_mlkem_public → shared_mlkem
shared_secret = KDF(shared_x25519 XOR shared_mlkem)Why hybrid: If X25519 is broken by a classical adversary, ML-KEM-768 still protects the session. If ML-KEM-768 is broken by a quantum adversary (harvest-now-decrypt-later), X25519 still protects the session. Both must be broken simultaneously to compromise transport security.
Why ML-KEM-768: NIST FIPS 203 standard (formerly Kyber-768). Recommended security level III — equivalent to AES-192. Implemented via cloudflare/circl in pure Go, no CGO.
No fallback. There is no X25519-only mode, no --no-pqc flag, no version negotiation that could downgrade to classical-only. PQC is on unconditionally from day one.
Nonce uniqueness
Section titled “Nonce uniqueness”Every encryption operation generates a fresh 12-byte nonce via crypto/rand.Read. Nonces are never derived, never sequential, never reused. This is enforced by tests — two encryptions of the same value must produce different ciphertexts.
Implementation
Section titled “Implementation”All cryptographic code lives in internal/crypto/:
| File | Contents |
|---|---|
argon2.go | Key derivation — DeriveKEK(password, salt) |
envelope.go | DEK generation, AES-256-GCM encrypt/decrypt |
pqc.go | ML-KEM-768 + X25519 hybrid KEM |
sanitize.go | Memory zeroing helpers for DEKs and KEKs |
Test coverage for internal/crypto/ is 100% — a hard requirement.