A decrypt service for end-to-end encrypted agent messaging: it holds the private key that decrypts
your messages and decrypts them for your agent. This is the code that runs the hosted default at
decrypt.aauth.dev (the default read service for secret.agent.coop),
and it is what you deploy to run your own. Nothing in the code names a messaging service: resource
is a parameter whose default is the DEFAULT_RESOURCE var in wrangler.jsonc.
Status: rewritten for plan D27 (2026-09-17). readMessage, getKey, rotateKey, getKeys;
multi-tenant with keys in D1 wrapped under a KEK secret; a message is one compact JWE; interop
vectors from jose and jwcrypto. getMessage and decryptEnvelope are gone. Single-tenant mode
(keys in secrets, no D1) is a later stage.
- A Cloudflare Worker, an AAuth resource with
access_mode: person-token, and an AAuth agent toward the messaging service (/.well-known/aauth-agent.json,aauth:read@<host>). In secret.agent.coop's terms it is a read service:listMessagesthere names a person's asread_message_with. Identity is the(iss, sub)pair from the person token, directed to this service. It never sees an email address; events carry a hash of the identity. - Operations (
/openapi.json):POST /read(readMessage){id, resource?}: person token forresourceover a call chain,downloadMessage {id}there withAccept: application/json, decrypt the JWE with the private key for itskid, return{id, from, from_name, to, created_at, resource, kid, text, warnings}.GET /key(getKey): current public key, created if none. The messaging service calls it for the person atcreateAccountandsetServices, finding it by operationId inopenapi.json.POST /key(rotateKey){resource?}: mint a new key; older keys stay, so messages already stored still read. When an agent calls, the new key goes to the messaging service'ssetPublicKeyover the chain, and is removed again if that fails. When the messaging service calls (itsrotatePublicKey; the person token'sagent_idis on the host ofresource) there is no call back. That is the no-loop rule.GET /keys(getKeys): all keys.
- Keys: P-256, used as JWE
ECDH-ESwithA256GCM. One format, a compact JWE: spec/container.md. Decryption is Web Crypto directly (src/jwe.ts), no library, checked against the vectors inspec/vectors/. - Private keys at rest are AES-256-GCM wrapped under the
KEKsecret with(iss, sub, kid)as AAD. - Text up to 64 KB; the JWE up to 96 KB at the messaging service.
openapi.jsonand/.well-known/aauth-resource.jsonare served withCache-Control: public, max-age=300and anETag.
Setup is done for you: secret.agent.coop createAccount fetches your key from here over an AAuth
call chain (no consent card for this service at that point). To read a message: listMessages at
secret.agent.coop for the id, then
invoke {resource: "decrypt.aauth.dev", op_id: "readMessage", body: {id: "msg_…"}}. Put the fields
directly in body. The first read shows this service's consent card, since it sees the plaintext.
The full flow is in the
secret-agent-coop skill.
npm install
npx wrangler d1 create decrypt-aauth-dev # put the id in wrangler.jsonc
npx wrangler d1 migrations apply DB --remote
npm run generate-kek | npx wrangler secret put KEK
npm run generate-key | npx wrangler secret put SIGNING_KEY
npm run generate-key | npx wrangler secret put AGENT_KEY
npx wrangler deploy # set your own route / custom domain in wrangler.jsonc
Set ORIGIN and DEFAULT_RESOURCE in wrangler.jsonc. Then tell your messaging service to use it:
at secret.agent.coop, setServices {read_service: "https://<your host>"}. The person approves that
one call; secret checks your host's metadata and operations, fetches the key with getKey over a
call chain, and from then on listMessages names your host as read_message_with.
npm test # vitest, Workers pool, D1 in Miniflare, fake Person Server in test/fake-ps
npm run typecheck
npm run vectors # regenerate spec/vectors/jose.json
python3 scripts/vectors_jwcrypto.py # needs `pip install jwcrypto`
MIT