Docs
Security model
This page is the deep, technical version of Caddi’s crypto story: the primitives, the exact key material, and the threat model, with the honest limits included. For the shorter, prose overview aimed at a first-time reader, see the security page.
Primitives
Everything here is built on libsodium, via libsodium-wrappers. Two operations do all the work:
- Keypairs: X25519 (Curve25519 for key exchange), generated with
crypto_box_keypair. The same keypair shape is used for both vault keys and device keys. - Sealing: anonymous public-key encryption via
crypto_box_seal/crypto_box_seal_open(an X25519 sealed box: an ephemeral keypair is generated per message, combined with the recipient’s public key, and the ephemeral public key is prepended to the ciphertext, so only the holder of the recipient’s private key can open it). Ciphertext is wrapped in a versioned envelope,cv1.<base64>, so a future format change can be introduced without breaking old envelopes.
Fingerprints (both vault and device) are the first 10 bytes of a 16-byte BLAKE2b digest (crypto_generichash) of the public key, base32-encoded and grouped into four 4-character blocks. Nothing about a fingerprint requires the private key: it exists purely so a public key can be compared out of band.
The vault
Every account has one vault keypair. The public half is stored on Caddi’s servers in plaintext, so anything (the web app, the CLI, a future integration) can seal a secret into the vault with it. The private half never exists on the server. It’s wrapped separately for each device you’ve approved, using wrapVaultKey: a sealed box built with the exact same crypto_box_seal primitive above, addressed to that device’s public key. Unwrapping (unwrapVaultKey) is the inverse: it can only succeed on a device that holds the matching device private key. The wrapped copy sits in that device’s OS keychain, never in plaintext on disk.
Your API keys, OAuth tokens, and private skill blobs are stored on the server only as ciphertext, sealed to the vault’s public key with the same envelope format. Decrypting them happens exclusively on a device that holds the vault private key, in memory, at the moment something needs to use them.
Recovery codes
A recovery code is the vault private key itself, re-encoded to be human-writable. The encoding: the 32 raw key bytes, plus a 2-byte checksum (the first 2 bytes of a 16-byte BLAKE2b digest of the key), base32-encoded (55 characters, no padding), then grouped into 5-character blocks and prefixed with CADDI-. Decoding reverses this and verifies the checksum before accepting the code, so a mistyped or corrupted code fails fast with a clear error rather than silently producing a wrong key.
It’s printed exactly once, right after the vault is created (or after caddi vault rotate), and never again. Anyone who has it can read your entire vault, so it’s the one piece of key material in this whole system that’s deliberately shown to you in plaintext, on the assumption you’ll store it somewhere you control (a password manager, printed and locked away). Use it with caddi login --recovery to self-approve a new device when you’ve lost access to every previously-approved device.
Where secrets live per OS
Device private keys, the unwrapped vault private key, and the session token are all stored in the OS’s native secret store, never in a plaintext file, using whichever backend is available on the current platform:
| Platform | Backend |
|---|---|
| macOS | The security CLI (Keychain Access), one item per key under the caddi.<key> service name. |
| Linux | secret-tool (libsecret), if it’s on PATH. |
| Anywhere else | An encrypted file at $CADDI_HOME/secrets.enc, sealed to a machine-local keypair stored at $CADDI_HOME/.mk. This is a deliberate fallback, not the preferred path: the CLI warns on first use that it’s weaker than a real OS keychain (a machine-local key sitting next to its own ciphertext is only as strong as the filesystem permissions protecting both). |
Device approval and revocation
A device only gets vault access by being explicitly approved. When a device signs in for the first time, it’s registered as pending and has no wrapped vault key. It becomes active one of two ways:
- Approved from another active device:
caddi devices approve <id>fetches the pending device’s public key from the server, prints its fingerprint, and requires an explicit confirmation that the fingerprint matches what the pending device itself printed, before wrapping the vault private key to it. This matters because the device’s public key is read over the network from a server this approving device doesn’t fully trust with the vault key: if the server ever substituted its own key, the fingerprint mismatch is how that gets caught, not silently trusted. - Self-approved with a recovery code:
caddi login --recoverydecodes the pasted code into the vault private key, verifies the derived public key actually matches the account’s vault (refusing rather than wrapping to the wrong vault if it doesn’t), then wraps it to this device.
caddi devices revoke <id> deletes that device’s wrapped key copy from the server. Revocation is terminal: a revoked device can never be re-approved or re-linked, it has to re-enroll as a brand-new pending device. Revoking doesn’t retroactively invalidate anything the device already decrypted while it was active; see “honest limits” below.
What the server can and can’t see
| Can see | Can’t see |
|---|---|
| Ciphertext for every connection and private skill blob (sealed to the vault public key). | Plaintext API keys, OAuth tokens, or skill contents, ever. |
| Connection metadata: which services are connected, when, and their status. | The vault private key or any device private key. |
Audit event names: service, tool, a hashed project identifier, and outcome (ok, error, or denied). | Audit event argument values, ever, by design. |
Threat model, honestly
Who this defends against, and to what degree, without hedging:
- A database breach. Defended well: an attacker gets ciphertext and metadata, not plaintext secrets. Without a device keychain, the ciphertext doesn’t decrypt.
- A malicious or prompt-injected coding agent. Defended by scoping: the gateway hands out tools, not raw keys, and
get_secret(the one tool that does return a raw credential) is off by default, gated behind a per-projectcaddi.jsonflag and a separate humancaddi approve. But see the next two points: this defense has real edges. - A malicious skill or tool output. A skill you’ve added, or the output of any tool call an agent makes, is not sandboxed by Caddi. If a skill’s instructions or a tool’s returned data convinces an agent to misuse whatever tools it does have access to, that’s inside the agent’s own trust boundary, not something the gateway inspects or blocks. Scoping a project’s
allowlist narrowly limits the blast radius; it doesn’t eliminate it. - The no-allow-list default. A project with no
caddi.json, or one with noallowkey, exposes every tool for every connected provider to whatever agent runs there. This is a deliberate default (opt-in scoping, not opt-out), but it means the honest baseline for an unscoped project is wide open, and treating scoping as optional in a security-sensitive project is a real risk, not a hypothetical one. - A compromised device with an unlocked keychain. Not defended against, and we don’t claim otherwise. If an attacker has code execution on a device that currently holds the vault private key (or can read it out of an unlocked OS keychain), they can decrypt anything that device can decrypt, and any tool call an agent on that device makes is exactly as trustworthy as the device itself. Revoking the device after the fact stops future access; it can’t undo what was already decrypted while it was active.
caddi vault rotatere-seals every connection to a fresh key and is the right response once a device is suspected, but the honest caveat is that anything already exfiltrated from that device is already gone. - A compromised Caddi server. Could serve malicious JavaScript that captures a pasted token before it’s sealed in the browser, or hand the browser a substituted vault public key. Mitigated, not eliminated, by the fingerprint shown on every connect form and by
caddi status, and by the fact thatcaddi connect <service>from the CLI skips the browser entirely.
Next
- caddi.json: the allowlist and approval gate referenced above, in full.
- CLI reference: every command, including
devices,vault rotate, andapprove. - Security overview: the shorter, non-technical version of this page.