Security model
OpaqueVault is built around two non-negotiable guarantees:
- The server cannot decrypt your secrets — ever, under any circumstances, including a subpoena
- AI models cannot see your secret values — not in responses, not in logs, not accidentally
Everything in the architecture follows from these two rules.
What the server knows
Section titled “What the server knows”The API (api.opaquevault.com) stores:
- Your email address
- Your Argon2id salt (not secret — used to derive your KEK client-side)
- Encrypted blobs:
ciphertext,ct_nonce,encrypted_dek,dek_nonce,encrypted_name,name_nonce,name_hmac - Secret names as ciphertext plus an HMAC lookup token — there is no plaintext name column; migration
000004dropped it - App and environment slugs in plaintext (these are routing metadata, not encrypted)
- HMAC-hashed secret IDs in the audit log
The server does not store:
- Your master password
- Your KEK (Key Encryption Key)
- Any DEK (Data Encryption Key) in plaintext
- Any secret value in plaintext
What the AI sees
Section titled “What the AI sees”Claude Code (or any MCP client) receives:
- Secret names via
vault_list_secrets - Command exit code and run metadata via
vault_run— since v0.16.0 stdout/stderr never ride the MCP response (they divert to the daemon terminal / agent log); the exit code remains a deliberate, low-bandwidth signal the model can act on - Session status via
vault_status
Claude never receives:
- Secret values
- DEKs
- Any intermediate decryption material
The design enforces this structurally — no MCP tool response shape can carry a value, and vault_run returns no subprocess output at all. Behind that structural cut sits the interceptor, a pattern-and-entropy backstop over the content that is returned. It is deliberately never given the secret values themselves, so it catches secret-shaped strings rather than guaranteeing value matches — the structural cut, not the interceptor, is the boundary.
Key derivation
Section titled “Key derivation”master password + argon2_salt ↓ Argon2id(time=1, mem=64MB, threads=4, keyLen=32)KEK (32 bytes)The Argon2id parameters are hardcoded client-side. The server does not negotiate them. A compromised server cannot weaken your KDF by serving different parameters.
The KEK is derived on your machine and never transmitted to the server. It exists in memory only inside the unlock window: the agent daemon then holds a scope-derived keyring, and the raw KEK is zeroized. By explicit consent (ov agent provision), an encrypted copy (kek.enc, wrapped twice — by an Argon2id-derived key and a machine-local wrap key) may exist on disk; it is removable at any time with ov agent provision --remove and never leaves your machine.
Secret encryption
Section titled “Secret encryption”Each secret has its own randomly generated DEK:
random(32 bytes) → DEKDEK encrypted with KEK → dek_encrypted (stored on server)secret value encrypted with DEK → ciphertext (stored on server)Both encryptions use AES-256-GCM with a random 12-byte nonce. Nonces are never reused — each encryption operation generates a fresh nonce via crypto/rand.
Transport security
Section titled “Transport security”All communication between ov mcp serve and api.opaquevault.com runs over TLS 1.3, negotiating the X25519MLKEM768 hybrid key exchange:
- X25519 — classical Diffie-Hellman key exchange
- ML-KEM-768 — NIST FIPS 203 post-quantum KEM (formerly Kyber)
TLS derives the handshake secret from the outputs of both key exchanges. If X25519 is broken by a classical attack, ML-KEM-768 still protects the session. If ML-KEM-768 is broken by a quantum attack, X25519 still protects it. This is what defeats “harvest now, decrypt later”.
Go’s standard library provides the X25519MLKEM768 implementation, and OpaqueVault’s client code actively enforces its use: it pins TLS 1.3 as the minimum version, pins the hybrid group in its curve preferences, and asserts after every handshake that the hybrid group was actually negotiated — failing closed for api.opaquevault.com. OpaqueVault ships no flag to disable it.
At rest, secrets are protected by the AES-256-GCM envelope described above, which is already considered quantum-resistant at a 256-bit key size. The post-quantum hybrid protects data in motion; it is not part of the at-rest key hierarchy.
Audit log
Section titled “Audit log”Every operation is logged:
2026-04-09 14:32:11 | read | secret:a3f9b2... | api_key:ci-runner | ip:1.2.3.42026-04-09 14:33:02 | write | secret:c7d1e8... | api_key:ci-runner | ip:1.2.3.4(Interceptor events are reported on the bridge’s stderr, not in the server-side audit log.)
Secret IDs in the audit log are HMAC-SHA256 hashed with a server-side key. The raw log is less sensitive than plaintext IDs, but the server can verify which secret was accessed by re-computing the HMAC.
What a compromised server reveals
Section titled “What a compromised server reveals”If api.opaquevault.com were fully compromised:
- Attacker gets: encrypted blobs, salts, encrypted secret names and their HMAC lookup tokens, app/environment slugs, audit entries — and, because the audit HMAC root key lives server-side, the ability to resolve those audit entries back to the secrets they reference
- Attacker cannot get: plaintext secret values (requires KEK, which never left the client)
- Attacker cannot get: your master password (never transmitted)
- Attacker cannot get: your KEK (derived locally, never stored or sent)
The only way to decrypt your secrets is to have your master password and run the same Argon2id derivation locally. This is by design.