Skip to content

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”

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.

  • 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' and use='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).

For the key model:

  1. 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 clean use separation in the catalog.
  2. Dual keys: Ed25519 (sig) + X25519 (enc), tied by a binding JWS (chosen). Two rows, two curves, explicit use. The signing key signs a binding that vouches for the encryption key.
  3. 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.

Derive X25519 from Ed25519Dual keys + binding (chosen)Single key, server-side enc
Curve correctnessreuses a sig key for ECDHeach curve used for its jobn/a (symmetric)
Key-use separationnone (one secret, two uses)explicit use='sig' / use='enc'none
End-to-endpossibleyesno - server decrypts
Standard JOSE interopunusualRFC 8037 / 7516 defaultn/a
Registration PoPunchanged gapclosed by binding JWSunchanged gap
A. Server-side at restB. Client JWE to agent (chosen)
Server can read secretsyesno
Operator / D1 dump exposurefull plaintextciphertext only
Crypto on the serverencrypt/decrypt + key custodynone - stores an opaque blob
Standard formatbespokeRFC 7516 JWE
Server can validate payloadyesno (opaque by design)

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 DID authentication / assertionMethod, the agent JWKS sig entries, and JWT verification. JWT verification is scoped to use='sig' (auth.ts) so a JWT can never be verified against an encryption key that shares the agent_key table.
  • Encryption key - X25519, use='enc', algorithm='X25519'. Backs the DID keyAgreement and the agent JWKS enc entries. 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 an exp. 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 stores bound_to_kid = the signing kid that vouched for it. On the generate_keypair=true path the server mints both keypairs and returns both private JWKs exactly once.
  • Vault - data is 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.

  • True end-to-end confidentiality for vault entries. A server compromise, a D1 export, or an operator browsing /_admin/vault/entries yields 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' vs use='enc', enforced in the verifier and by the agent_key_use_check constraint.
  • The registration proof-of-possession gap is closed on the client path.
  • Clients must do JOSE/JWE and manage two keys instead of one.
  • The server cannot validate that data is 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.ts still 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.
  • Migration 0014_agent_key_dual_use widens the algorithm CHECK to ('EdDSA','X25519'), adds use (default 'sig') and bound_to_kid, and backfills every existing row as use='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.
  1. 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).
  2. 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 on generate_keypair (packages/identity/src/service/register.ts).
  3. JWKS / DID services emit both kinds - sig in authentication / assertionMethod, enc in keyAgreement (service/jwks.ts, service/did.ts).
  4. JWT verification scopes its kid lookup to use='sig' (auth.ts).
  5. 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).
  6. 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).
  • 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/me rotate-key / self-revoke flow, including rotation of the enc key (and its bound_to_kid re-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.