---
title: "Credential vault"
description: "Envelope encryption for customer vendor credentials: the AES-256-GCM wire format, KEK providers, and why plaintext exists only inside the gateway process."
---
The vault holds customer vendor credentials under envelope encryption. Two
planes touch it and their roles are asymmetric: control seals and persists
ciphertext, and **the gateway is the only process that ever decrypts**
(ADR-0004, ADR-0016).
**Plaintext decrypt happens only inside the gateway process.** A plaintext
vendor secret exists between `Vault::unwrap` and the outbound socket, and
nowhere else. Control's process, task logs, and crash dumps never contain a
vendor token; neither does the web app, an agent, or a worker.
## What is stored
An `EncryptedCredential` is the only credential shape that reaches Postgres or a
snapshot (`apps/gateway/crates/obol-vault`, `obol-types::snapshot`):
| Field | Contents |
|---|---|
| `ciphertext_b64` | base64 (standard, padded) of `nonce(12) ‖ ciphertext ‖ tag(16)` |
| `dek_wrapped_b64` | base64 of the KEK-wrapped 32-byte DEK; inner format depends on the provider |
| `kek_provider` | `local`, `aws`, or `gcp` — must equal the gateway's configured provider |
| `kms_key_id` | Optional KMS key id override (AWS); `null` for `local` |
| `scheme` | `bearer`, `basic`, `header{name,prefix}`, or `query{param}` |
| `last4` | Last four characters of the plaintext — the only fragment ever logged |
The plaintext counterpart, `OutboundCredential`, deliberately implements neither
`Serialize` nor `JsonSchema`. That is enforced at compile time with a
`compile_fail` doctest, so it cannot reach a snapshot, a log line, or a response
body by accident.
## Wire format
AES-256-GCM with a fixed layout, shared byte-for-byte with control's Python side
so that either language can produce a credential the other reads:
- Nonce: 96 bits, freshly random per seal.
- Tag: 128 bits.
- AAD: empty.
- Plaintext: the UTF-8 bytes of the credential string.
- One fresh DEK per seal.
```python
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
dek = os.urandom(32)
nonce = os.urandom(12)
ciphertext_b64 = b64encode(nonce + AESGCM(dek).encrypt(nonce, secret.encode(), None))
```
A known-answer test pins the layout: key = 32 × `0x01`, nonce = 12 × `0x02`,
plaintext `"obol"` produces
`0202…02 68b4a625 739201b722fde75876f1b38a0103f51d`
(`obol-vault/src/aead_tests.rs::aead_wire_format_matches_python_reference`).
Inputs shorter than 28 bytes are rejected as `InvalidFormat` before the cipher
runs; any authentication failure is `DecryptionFailed`.
## KEK providers
The DEK is wrapped by a key-encryption key behind
`trait Kek { provider, unwrap_dek, wrap_dek }`, selected by
`OBOL_KEK_PROVIDER` through `kek_from_env`.
| Provider | Wrapped DEK | Status |
|---|---|---|
| `local` (`LocalAgeKek`, feature `kek-local`, default) | Binary (non-armored) age v1 file encrypted to an x25519 recipient; identity read from `OBOL_AGE_KEY_FILE` | Shipped |
| `aws` (`AwsKmsKek`, feature `kek-aws`) | KMS `Encrypt`/`Decrypt` `CiphertextBlob`; key id from the credential's `kms_key_id`, else the configured default | Shipped |
| `gcp` (`GcpKmsKek`) | — | Not implemented; construction always returns `KekUnavailable` |
Every misconfiguration fails closed: `local` without `OBOL_AGE_KEY_FILE`, `aws`
without `OBOL_KMS_KEY_ID` or without the feature compiled in, and `gcp` in all
cases return `VaultError::KekUnavailable`. A credential whose `kek_provider`
differs from the configured provider is refused with `WrongProvider` **before
any decrypt is attempted**.
{/* TODO: AWS KMS behaviour is verified by construction only — ADR-0016 records no live KMS test. Confirm before documenting operational guidance for the `aws` provider. */}
## Unwrap path
`Vault::unwrap` performs, in order:
1. Provider match, or `WrongProvider`.
2. Base64 decode of the wrapped DEK and the sealed credential; a sealed value
shorter than nonce plus tag is `Malformed`.
3. DEK resolution through the cache, else a KEK unwrap.
4. AES-256-GCM open.
5. UTF-8 decode; a failure zeroizes the bytes and returns `Malformed`.
6. A `last4` comparison against the stored `last4`. A mismatch logs one `warn!`
carrying the expected `last4` only, and refuses.
`Vault::unwrap_checked` additionally refuses a credential whose `expires_at` is
at or before `now`, checked before any KEK work. Call sites pass the
connection's `expires_at` (`obol-gateway/src/hooks.rs`,
`routes/v1.rs`, `verification.rs`).
## Caching
Unwrapped DEKs are cached in `moka`, keyed by `sha256(dek_wrapped)`, with
`DEK_CACHE_TTL = 5 minutes` and a 10,000-entry ceiling. The cache holds
`Arc>` and nothing else.
A plaintext credential is never cached. Two test hooks exist solely to prove it:
`cache_entry_lens` (every entry is exactly 32 bytes) and `cache_contains_bytes`,
used by `dek_cache_never_holds_plaintext_credential`.
The five-minute TTL is a deliberate trade: it bounds KEK revocation lag while
avoiding one KMS `Decrypt` per tool call.
## What never leaves the gateway process
- No plaintext credential is serialized, cached, or returned to control.
- Key material is held in `SecretBox`/`SecretString` and zeroized on drop; every
`Debug` implementation is redacted (`Aes256Gcm([REDACTED])`,
`LocalAgeKek([REDACTED])`, `OutboundCredential { secret: "[REDACTED]" }`).
- `VaultError` maps to a generic 5xx without echoing details.
- Logs carry the connector or connection id and `last4` only.
## Injection on the outbound hop
`OutboundCredential::apply` is the single place header and query mutation
happens (`obol-types/src/credential.rs`). It writes one of:
- `Authorization: Bearer `
- `Authorization: Basic `
- a named header, optionally with a prefix
- a query parameter
Header values are marked `sensitive` so the HTTP stack keeps them out of debug
output. Callers drop the plaintext immediately after.
## Sealing
Control never seals a credential itself. It calls the gateway's internal
`POST /internal/v1/credentials/seal` with a short-lived, single-use capability;
the gateway seals and returns ciphertext plus a `credential_revision`, and
control persists only that envelope
(`obol-gateway/src/routes/credentials.rs`, `apps/control/app/services/connections.py`).
Two custody rules are enforced at that boundary rather than in a runbook:
- Control refuses any sealable secret beginning with `sk_live_`
(`validate_sealable_secret`), and the gateway refuses a Stripe API-key seal
beginning with `sk_live_` again on its own side.
- A federated connection cannot accept a vendor secret at all: sealing is
rejected unless the auth profile is `federated_broker`.
That is invariant 8 in code — no pooled vendor accounts, no full `sk_live_`
storage; restricted keys, OAuth, or Connect only. See
[/security/oauth](/security/oauth) and [/concepts/invariants](/concepts/invariants).
## Enterprise self-host
For development and [Enterprise self-host](/get-started/self-hosting), `local`
is the default: an age x25519 identity file at `OBOL_AGE_KEY_FILE`. `packages/fixtures/keys/age-test.key` is a
committed test identity and must never guard real data. See
[/get-started/self-hosting](/get-started/self-hosting).