Skip to content

Encryption stack

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 bytes

Why 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: They target a derivation cost that is unnoticeable interactively but expensive to brute-force at scale. Actual wall-clock time is hardware-dependent and varies by an order of magnitude across machines, so benchmark it on your own hardware rather than relying on a quoted figure. Parameters are hardcoded client-side and not negotiated with the server — a server that could serve KDF parameters could serve weak ones.


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_nonce

The encrypted DEK (dek_encrypted) and its nonce (dek_nonce) are stored on the server.

The DEK wrap is bound to the identity of the secret it belongs to: the AES-GCM call passes additional authenticated data (AAD) of name_hmac || version. A server that swapped one secret’s wrapped DEK for another’s — or replayed an older version’s wrap in place of the current one — produces an authentication failure rather than a successful decryption.


AES-256-GCM(
key=DEK,
nonce=crypto/rand(12 bytes),
plaintext=secret_value
) → ciphertext + nonce

The ciphertext and nonce are stored on the server. The DEK is zeroed from memory immediately after this operation.


{
"ciphertext": "base64(AES-256-GCM(DEK, plaintext))",
"ct_nonce": "base64(12-byte nonce for ciphertext)",
"encrypted_dek": "base64(AES-256-GCM(KEK, DEK))",
"dek_nonce": "base64(12-byte nonce for DEK wrap)",
"encrypted_name": "base64(AES-256-GCM(NEK, name))",
"name_nonce": "base64(12-byte nonce for name encryption)",
"name_hmac": "base64(HMAC-SHA256 lookup token — the name never travels in plaintext)"
}

The server has none of the keys required to decrypt any of these fields.


Transport — TLS 1.3 with a post-quantum hybrid key exchange

Section titled “Transport — TLS 1.3 with a post-quantum hybrid key exchange”

All HTTPS traffic between ov mcp serve and api.opaquevault.com runs over TLS 1.3, and the handshake negotiates the X25519MLKEM768 hybrid group:

  • X25519 — classical elliptic-curve Diffie-Hellman (RFC 7748)
  • ML-KEM-768 — NIST FIPS 203 post-quantum KEM (formerly Kyber-768), security level III

TLS derives the handshake secret from the outputs of both key exchanges, so the session is compromised only if both primitives are broken. X25519 covers the present; ML-KEM-768 covers “harvest now, decrypt later”.

Where this comes from. Go’s standard library implements X25519MLKEM768 (Go 1.24+; this project builds on Go 1.25), and the API’s edge terminator supports it. No OpaqueVault-implemented KEM sits on the transport path — but OpaqueVault application code actively enforces the hybrid rather than merely inheriting the default: the client pins TLS 1.3 as the minimum version, pins the hybrid group in its curve preferences, and asserts after each handshake that X25519MLKEM768 was actually negotiated.

On downgrade. OpaqueVault ships no flag to turn the hybrid off, and there is no version-negotiation downgrade path: connections to api.opaquevault.com fail closed if the negotiated group is not the hybrid. (Self-hosted endpoints get a loud warning instead, or the same hard failure with OV_STRICT_PQ=1.)

Not an at-rest layer. The post-quantum hybrid protects data in motion only. Secrets at rest are protected by the AES-256-GCM envelope described above; DEK envelopes are not additionally wrapped with ML-KEM. AES-256 is already considered quantum-resistant at a 256-bit key size, so there is no at-rest PQC layer to add.


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.


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 X25519 + ML-KEM-768 hybrid KEM primitives — available for future quantum-safe key transport; not on the at-rest encryption path
sanitize.go Memory zeroing helpers for DEKs and KEKs

internal/crypto/ is held to a high coverage bar, enforced in CI rather than asserted here. We deliberately do not publish a percentage: a number in prose is wrong the moment a statement is added, which is how the previous absolute claim on this page came to be false. The threshold and the enumerated exemptions live with the code.