Dual agent keys and end-to-end vault encryption
ADR-2026-0006: Dual agent keys and end-to-end vault encryption
Section titled “ADR-2026-0006: Dual agent keys and end-to-end vault encryption”Context and problem statement
Section titled “Context and problem statement”An agent has, until now, exactly one keypair: an Ed25519 signing key
(use='sig', EdDSA) that backs its DID, its JWKS entry, and the
self-signed JWTs it presents to the API
(packages/identity/src/auth.ts).
The vault stored each entry’s data as an “opaque” string, but nothing
encrypted it end-to-end: a data blob written by an agent was readable
by the server, by anyone with D1 access, and by an operator browsing the
admin surface. For a product whose entire premise is secrets for
agents, “the server can read every vault entry” is the wrong default.
Making the vault end-to-end encrypted means the client encrypts before it sends, and the server stores ciphertext it cannot open. That requires an asymmetric encryption key the client can encrypt to. Ed25519 cannot serve this purpose: it is a signature curve (EdDSA), not a key-agreement curve, and reusing one keypair for both signing and key agreement is a known cross-protocol hazard. The standard JOSE answer (RFC 8037) is a second key on the X25519 curve used for ECDH-ES key agreement inside an RFC 7516 JWE.
There is a second, independent problem the same change can close.
Registration accepted a client-supplied public_key_jwk with no proof
that the caller controls the matching private key. A caller could
register an agent bound to a public key they do not hold - key-squatting,
or binding a known third-party key to an identity under their tenant.
Adding a second key is the natural moment to require a proof-of-possession
that also covers the new key.
We need a recorded decision so the dual-key model, the binding proof, and the “server never decrypts” guarantee are not later “simplified” back into a single key or a server-side-encryption shortcut.
Decision drivers
Section titled “Decision drivers”- End-to-end confidentiality. The threat model includes the server itself, a D1 dump, and a curious operator. None may be able to read a vault secret. Only the holder of the agent’s encryption private key may.
- Curve fitness. Ed25519 is for signatures; X25519 is the CFRG
key-agreement curve. JOSE/JWE key agreement (ECDH-ES) over OKP keys uses
X25519 (RFC 8037). Deriving an X25519 key from the Ed25519 key via the
birational map is possible but couples two
uses to one secret and is widely discouraged. - Standard envelope. Reuse RFC 7516 JWE + RFC 8037 rather than a bespoke format, so any standard JOSE library can produce and consume vault payloads.
- Key-use separation. A key should do one job.
use='sig'anduse='enc'are distinct rows, distinct curves, distinct kids - and the JWT verifier must never accept an encryption key as a signing key. - Registration proof-of-possession. A registrant must prove control of the signing key, and vouch for the encryption key, at registration time.
- Discoverability. For an external party (a peer agent, a future
sharing flow) to encrypt to an agent, it must be able to fetch that
agent’s X25519 public key - so the key must publish through the agent’s
JWKS and DID (
keyAgreement).
Considered options
Section titled “Considered options”For the key model:
- Single Ed25519 key, derive X25519 on demand. One stored secret;
map it to X25519 for ECDH. Reuses one key across two
uses; subtle to get right; no cleanuseseparation in the catalog. - Dual keys: Ed25519 (
sig) + X25519 (enc), tied by a binding JWS (chosen). Two rows, two curves, explicituse. The signing key signs a binding that vouches for the encryption key. - Single key, no encryption - keep server-side at-rest encryption. The server holds a symmetric key (KMS-style) and encrypts entries. Simple, but the server can decrypt → not end-to-end.
For vault encryption:
A. Server-side encryption at rest. Server holds the data key. Operationally easy, but fails the threat model (server/operator/D1 can read). B. Client-side JWE encrypted to the agent’s X25519 key; server stores the JWE verbatim (chosen). The server never holds a data key and cannot decrypt.
Pros and cons - key model
Section titled “Pros and cons - key model”| Derive X25519 from Ed25519 | Dual keys + binding (chosen) | Single key, server-side enc | |
|---|---|---|---|
| Curve correctness | reuses a sig key for ECDH | each curve used for its job | n/a (symmetric) |
| Key-use separation | none (one secret, two uses) | explicit use='sig' / use='enc' | none |
| End-to-end | possible | yes | no - server decrypts |
| Standard JOSE interop | unusual | RFC 8037 / 7516 default | n/a |
| Registration PoP | unchanged gap | closed by binding JWS | unchanged gap |
Pros and cons - vault encryption
Section titled “Pros and cons - vault encryption”| A. Server-side at rest | B. Client JWE to agent (chosen) | |
|---|---|---|
| Server can read secrets | yes | no |
| Operator / D1 dump exposure | full plaintext | ciphertext only |
| Crypto on the server | encrypt/decrypt + key custody | none - stores an opaque blob |
| Standard format | bespoke | RFC 7516 JWE |
| Server can validate payload | yes | no (opaque by design) |
Decision
Section titled “Decision”We adopt option 2 + option B. Each agent holds two keys, and the vault stores client-produced JWE.
- Signing key - Ed25519,
use='sig',algorithm='EdDSA'. Backs the DIDauthentication/assertionMethod, the agent JWKSsigentries, and JWT verification. JWT verification is scoped touse='sig'(auth.ts) so a JWT can never be verified against an encryption key that shares theagent_keytable. - Encryption key - X25519,
use='enc',algorithm='X25519'. Backs the DIDkeyAgreementand the agent JWKSencentries. Clients encrypt vault payloads to this key. - Binding - on the client-supplied registration path the caller sends
public_key_jwk+encryption_key_jwk+ a compact key-binding JWS signed by the signing key over both public keys, the slug, and anexp. Verifying it proves possession of the signing private key and records the holder’s assertion that the encryption key is theirs. The enc key’s row storesbound_to_kid= the signing kid that vouched for it. On thegenerate_keypair=truepath the server mints both keypairs and returns both private JWKs exactly once. - Vault -
datais an RFC 7516 JWE produced client-side, encrypted to the owner’s X25519 key. The server stores it verbatim (≤ 65536 bytes), never inspects or decrypts it, and scopes every read/delete to the owning agent - returning 404 (not 403) to a non-owner so there is no existence oracle (packages/vault/src/service/vault.ts).
The encryption key is vouched for, not directly proven: the binding proves the signing key, and the signing key asserts the enc key. We accept this asymmetry because registering an enc key you do not hold only denies service to yourself - no other agent’s confidentiality depends on it.
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- True end-to-end confidentiality for vault entries. A server compromise,
a D1 export, or an operator browsing
/_admin/vault/entriesyields ciphertext only. - Standard envelope. Any RFC 7516 / RFC 8037 JOSE library can produce and read vault payloads; no bespoke crypto on either side.
- Clean key-use separation.
use='sig'vsuse='enc', enforced in the verifier and by theagent_key_use_checkconstraint. - The registration proof-of-possession gap is closed on the client path.
Negative / cost
Section titled “Negative / cost”- Clients must do JOSE/JWE and manage two keys instead of one.
- The server cannot validate that
datais well-formed JWE (it is opaque by design); a buggy client could store plaintext believing it encrypted. This is inherent to end-to-end and is a documented client responsibility. - The enc key has only a vouched PoP, not a direct X25519 challenge (accepted above).
- Discovery is not yet wired. The JWKS and DID services publish both
key kinds, but the mounted routes in
packages/identity/src/router/index.tsstill return stub empty sets. Until they are wired, an agent can encrypt to its own enc key (it holds it from registration), but a third party cannot fetch another agent’s enc key - so encrypt-to-another-agent is not yet usable. Tracked as follow-up.
Neutral
Section titled “Neutral”- Migration
0014_agent_key_dual_usewidens thealgorithmCHECK to('EdDSA','X25519'), addsuse(default'sig') andbound_to_kid, and backfills every existing row asuse='sig'. - The spec keeps a future, non-stored server-side decryption path in mind (a request-scoped delegation in which the live agent re-wraps the content key to a one-time server ephemeral). Nothing in this decision grants the server standing access.
Implementation
Section titled “Implementation”- Schema + migration: add
use/bound_to_kid, widen the algorithm check (packages/identity/src/db/schema.ts,packages/identity/migrations/0014_agent_key_dual_use.sql). - Registration: validate the X25519 JWK, verify the key-binding JWS, and
insert both key rows (sig active, enc active
bound_to_kid=sigKid); or mint both ongenerate_keypair(packages/identity/src/service/register.ts). - JWKS / DID services emit both kinds - sig in
authentication/assertionMethod, enc inkeyAgreement(service/jwks.ts,service/did.ts). - JWT verification scopes its
kidlookup touse='sig'(auth.ts). - Vault CRUD: owner-scoped list/get/create/delete with the 64 KiB cap and
404-no-oracle contract; admin list/delete behind the service key
(
packages/vault/src/service/vault.ts,router/index.ts,router/admin.ts). - Spec: dual-key registration models and the JWE-at-rest vault contract
(
packages/spec/identity/register.tsp,packages/spec/vault/operations.tsp,packages/spec/common/jose.tsp).
Compliance / follow-up
Section titled “Compliance / follow-up”- Wire the per-agent JWKS and DID routes to call the existing services so enc keys are discoverable; this unblocks encrypt-to-another-agent. Until then the routes return empty stubs - see ADR-2026-0003 for the per-agent JWKS resolution contract this plugs into.
- Implement the
/v1/agent/merotate-key / self-revoke flow, including rotation of the enc key (and itsbound_to_kidre-vouching). - If the threat model tightens, add a direct X25519 proof-of-possession challenge at registration rather than the vouched binding.
- Specify and build the request-scoped server-side decryption delegation if the “proxy” read path is ever required; it must remain request-scoped and never become standing access.
References
Section titled “References”- RFC 7516 - JSON Web Encryption (JWE)
- RFC 8037 - CFRG ECDH and signatures in JOSE (Ed25519, X25519)
- RFC 7748 - Elliptic Curves for Security (X25519)
- ADR-2026-0003 - per-agent JWKS, the discovery path enc keys publish through
packages/identity/src/service/register.ts- dual keygen + binding verificationpackages/vault/src/service/vault.ts- owner-scoped, opaque-blob vaultpackages/spec/vault/operations.tsp- JWE-at-rest contract