The three roles
The issuer and consumers are usually derived actions built from shared templates, with the issuer’s CID stamped into every consumer’s manifest so the trust link is part of the consumer’s own identity.
Step 1: Canonical documents
Signatures cover bytes, so everyone must serialize the same object to the same bytes. Use a restricted canonical JSON:- Object keys sorted by ASCII code point.
- Numbers must be safe integers. No floats, no
-0. - No
undefined, no prototype tricks, no unknown fields (validate with a strict schema before hashing). - Strings are exact Unicode text with no normalization.
domain string (for example "my-app/policy/v1") and a v version field. Domain separation means a signature over a policy can never be replayed as a signature over a receipt or a request, even if the two happen to hash the same fields.
Step 2: The issuer signs a receipt, not the document
The receipt is a small payload that binds the document’s hash to a scope and a timestamp. Signing a fixed-shape payload rather than arbitrary documents keeps the issuer’s signing surface tiny and easy to audit.{ document, receipt }. The document stays plaintext JSON your app can query and index; the receipt travels with it.
The example uses
signMessage (EIP-191) because ethers is a runtime global and any EVM tool can verify it. The Keychain instead signs sha256(canonical(payload)) directly with secp256k1 via @noble/curves, which yields a compact 64-byte signature verifiable in Rust with no Ethereum dependency. Either works; pick one and pin it in the domain string.Step 3: Consumers fetch and verify at runtime
A consumer action never trusts what it fetched until the receipt verifies under the issuer’s public key, which it looks up by the issuer’s CID. Because the issuer CID is stamped into the consumer’s manifest, a consumer can only ever be satisfied by receipts from the one issuer it was built to trust.- Every claim in the request is checked against the signed document. The requester says “policy hash X, envelope hash Y, my key Z, operation W”. The action confirms the signed policy has that hash, lists that key, permits that operation, and covers that envelope. Storage cannot substitute a different policy without the hash check failing.
- Fetch fails closed. Timeouts, redirects, oversized bodies, non-2xx statuses, and schema failures all deny. The Keychain caps registry responses at 256 KB. Remember an action gets fifty outbound requests and ten key operations per execution.
- The key is requested last and the expiry is re-checked immediately before use, because fetches take real time.
Step 4: Sign the request too
If the consumer is acting for an agent or user, the request itself should be a signed canonical document so the consumer can verify who is asking and that the ask is fresh and specific.request.agentPublicKey, and require a short window (the Keychain allows at most 120 seconds of lifetime and rejects requests whose issue time is more than 30 seconds in the future). The nonce and window bound replay; the hashes bind the request to one exact policy and one exact ciphertext.
Step 5: Sign and encrypt the response
If the action’s output is sensitive, or if it passes through a proxy you do not control (a relayer, your own API, an MCP server), protect it on the way out:- Encrypt to the requester. HPKE-seal the result to
request.responsePublicKey, with the request hash in the HPKEinfoso a sealed result cannot be replayed to a different request. - Sign the sealed result with the action key. Payload:
{ domain: "my-app/response/v1", requestHash: digest(request), sealed }. The client verifies against the consumer action’s public key (looked up by the CID it computed itself).
What storage can and cannot do
Be precise about the residual trust, because it shapes your design.
The rollback row is the one to design around. The Keychain accepts it explicitly, and bounds it with two rules:
- Finite windows on anything that grants access. Agent policies expire in 30 days by default and 90 at most. A rolled-back grant is therefore a bounded exposure, not a permanent one.
- Epoch and previous-hash chaining. Each new policy carries
epoch: n+1andpreviousHash: digest(previous). The API enforces the chain on write so concurrent editors get a clean conflict instead of clobbering each other. This is an honesty check on the operator’s own writes, not rollback protection: a dishonest operator can still serve epoch 3 after epoch 4 exists.
digest(latestPolicy) to a contract on Base and having the consumer read it over a hostname-pinned RPC is one call and turns “operator can roll back” into “operator can only stall”.
When to use this instead of on-chain state
Most policy and ciphertext data belongs off-chain and signed. Anchor a hash on-chain only for the records where a rollback would be catastrophic.
Checklist
- Canonical JSON with sorted keys and safe integers, shared by every party.
domainandvon every signed object. Different domains for receipts, requests, responses, and key bindings.- Receipts bind
objectHashplus a scope such asvaultId. - Consumers look up the issuer’s key by CID and never accept a key from the fetched payload.
- Every field the requester relies on is bound in the signed document and checked.
- Fetches use HTTPS, reject redirects, time out, cap size, and fail closed.
- Key material is requested after all independent checks pass and expiry is re-checked before use.
- Grants have finite expiry. Chains use epoch and previous hash. Anchor on-chain if rollback matters.
- Responses are encrypted to the requester and signed by the action when they cross an untrusted hop.
- One generic error for every denial.
See also
- Derived Actions — how the issuer CID gets stamped into each consumer so the trust link is part of the code.
- Action-Identity Signing — the primitive this builds on.
- Keychain security contract — the full statement of what the cryptography enforces and what the operator can still do.
actions/authority.tsandactions/secret-common.ts— production issuer and consumer actions.