Token vault
Security: token vault
The token vault (Cbox\Id\TokenVault\) holds third-party credentials that grant
real downstream power, so its controls are load-bearing. This page states the
threat model, the sealing choice, and the honest limits.
What the vault protects, and how
| Control | Mechanism | Where |
|---|---|---|
| Sealed at rest | SecretBox (XChaCha20-Poly1305) ciphertext, AEAD-bound to the row id; never a plaintext or a hash |
DatabaseSecretVault, VaultSecret::secretContext() |
| Deny-by-default | ambient environment required; a lease needs a live (secret, client) grant |
DatabaseSecretVault::lease(), Models\VaultGrant |
| Uniform denial | unknown / no-grant / revoked / expired all raise the same LeaseDenied; reason audited only |
Exceptions\LeaseDenied |
| Revocable | revoked secret or grant, or expired secret, refuses on the next lease | DatabaseSecretVault |
| Accountable | store / rotate / revoke / grant / lease audited with actor + purpose, never the value | hash-chained audit trail |
| Environment scope | BelongsToEnvironment — cross-env access impossible |
Models\VaultSecret, Models\VaultGrant |
Why sealed, not hashed
A platform-issued token (a password-reset token, an OTP) is stored as a hash:
the platform only ever needs to verify a presented value, never reproduce it. A
vaulted downstream credential is the opposite — the vault must present the API
key to OpenAI or GitHub, so it must be recoverable. Hashing would make it
useless. So the value is sealed with the Crypto kernel's SecretBox: an
XChaCha20-Poly1305 AEAD envelope whose master key lives in config / a secret store,
not the database. A database dump therefore yields nothing without the master key,
and because the ciphertext is AEAD-bound (cbox-id:vault-secret:<id>) to its own
row, a dumped blob cannot be replayed against a different secret or column.
All primitives are vetted libsodium; nothing is hand-rolled.
No enumeration on the lease path
Management operations (rotate, revoke, grant) throw SecretNotFound for a
missing secret — they are called by the trusted operator managing the vault. The
lease() path is different: it may be driven on behalf of a partially-trusted
agent, so every failure mode — the secret does not exist, the client has no grant,
the secret or grant is revoked, the secret has expired — raises the same
LeaseDenied. The precise reason is written to the audit trail
(vault.lease.denied), never returned, so a caller cannot probe which secret ids
exist.
Honest limits
- Lease TTL is advisory. The vault returns the plaintext to an in-process caller
with a window (
SecretLease::expiresAt); it trusts the caller to stop using and drop the value by then. It is not a server-side, revocable token — revocation bounds future leases, not one in flight. Keep leases short; prefer downstream credentials that are themselves short-lived and narrowly scoped. - Post-lease exposure is out of scope. Once leased, the value lives in the agent's memory and travels to the provider; the vault cannot protect it there.
- Master-key rotation is the platform's, not the vault's. Since 1.22 every
sealed value names the master-key generation that sealed it, and
cbox-id:crypto:rewrapre-seals vault secrets with everything else (see master key management). Thekey_versioncolumn predates that and is superseded by the tag inside the ciphertext; it is still written as1and is not read. - This is a primitive, not a policy. Whether a given agent should hold a given credential, and for how long, is the host's decision; the vault enforces the mechanics — sealing, grants, uniform denial, audit.
Auditing
vault.secret.stored, vault.secret.rotated, vault.secret.revoked,
vault.grant.created, vault.grant.revoked, vault.secret.leased and
vault.lease.denied are recorded on the hash-chained trail — with the acting
client, the target secret and the stated purpose, and never the secret value.
See core-concepts/audit-streaming.md.