Registry receipts (ACDP 0.2.0 / RFC-ACDP-0010)
A registry receipt is the registry's signed, non-repudiable attestation of
what it did at publish time: the identifiers it assigned (ctx_id,
lineage_id), the timestamp it stamped (created_at), the content hash it
verified, and — critically — the fingerprint of the producer key it actually
resolved for signature verification. That last binding is what makes
contexts verifiable years later, after the producer has rotated keys or let
its domain lapse.
This doc is the operator runbook. The receipt wire format, signing
construction, and consumer-side verification procedure are normative in
RFC-ACDP-0010; the verifying client lives in acdp-rs
(VerificationPolicy, ReceiptPolicy::Require).
Enabling receipts
Generate a 32-byte Ed25519 seed and configure it:
openssl rand -base64 32[receipt]
signing_key_seed_b64 = "<that value>" # or signing_key_path = "/etc/acdp/receipt-key.seed"
key_id_fragment = "receipt-key-1"Env-only deployments set ACDP_REGISTRY_RECEIPT__SIGNING_KEY_SEED_B64.
With a key configured the registry:
- advertises
acdp_version: "0.2.0"and theacdp-registry-receiptsprofile in/.well-known/acdp.json; - mints a receipt for every publish, atomically with the context row (one INSERT, one transaction — a context can never exist without its receipt, and a failed mint aborts the publish);
- returns the receipt as the top-level
registry_receiptmember of the publish response and ofGET /contexts/{ctx_id}(never ofGET /contexts/{ctx_id}/body); - serves its own DID document at
GET /.well-known/did.json(below).
Advertising the profile is a hard commitment — there is no
receipt_unavailable error and no degraded mode. That is also why
playground.enabled and [receipt] are mutually exclusive at startup: the
playground path never resolves the producer key, so any fingerprint it
attested would be false.
Serving /.well-known/did.json
A receipt's signature references did:web:<authority>#<fragment>, and
did:web:<authority> resolves to https://<authority>/.well-known/did.json.
The registry generates and serves that document itself from the [receipt]
section — no out-of-band hosting needed, but note:
- the registry must be reachable over HTTPS at the bare authority it
mints (
registry.authority); DID resolution is HTTPS-only; - if a CDN or proxy fronts the registry, make sure
/.well-known/did.jsonis routed through (it is cacheable,max-age=300).
The active signing key appears in both verificationMethod and
assertionMethod. Retired keys appear in verificationMethod only.
The key-retention rule (read before rotating)
RFC-ACDP-0010 §9 (MUST): every key that ever signed a receipt stays in
verificationMethod indefinitely. Rotation removes a key from
assertionMethod only. A verifier accepts a receipt key found in
verificationMethod even when absent from assertionMethod — that is what
keeps old receipts verifiable after rotation.
Removing a retired key from verificationMethod bricks every receipt that
key ever signed. The one sanctioned exception is confirmed key compromise,
where invalidation is the point; in that case re-mint affected receipts under
the successor key (they attest the original stored created_at, not re-mint
time).
Rotation procedure
-
Generate a new seed; pick a fresh fragment (
receipt-key-2). -
Move the old key's public half into
[[receipt.retired_keys]]:[receipt] signing_key_seed_b64 = "<new seed>" key_id_fragment = "receipt-key-2" [[receipt.retired_keys]] public_key_b64 = "<base64 of the OLD raw 32-byte public key>" key_id_fragment = "receipt-key-1" -
Restart.
did.jsonnow lists both keys inverificationMethodand onlyreceipt-key-2inassertionMethod. -
Never delete the
retired_keysentry (see above).
Backfill policy: none
Contexts published before receipts were enabled stay receipt-less, and their
registry_receipt member is simply absent. We deliberately do not
backfill: a receipt attests publish-time facts — above all the producer key
the registry actually resolved at that moment — and minting one later would
be a false attestation. (RFC-ACDP-0010 permits backfill that attests the
stored created_at; this implementation takes the conservative position.)
The GET /admin/lineages/{lineage_id}/audit report exposes
receiptless_contexts so you can see how much pre-receipts history a lineage
carries.
did:key producers
Enable with:
[auth]
did_methods = ["did:web", "did:key"]did:key publishes verify offline — the DID is the key, so there is no
DID-document fetch, no SSRF surface, and no assertionMethod check. When the
method is not advertised, a did:key publish is rejected with
key_resolution_failed (HTTP 400, permanent — fixture dk-003).
Receipts matter more for did:key producers: the DID has no document to
attest anything else, and a new key is a new identity (no rotation, so
lineage continuity ends with the key). The recommended pattern is two-tier:
organizations anchor on did:web, ephemeral/archival producers use
did:key.
Lineage anchoring and the audit endpoint
The publish path validates a v(N+1) against the immediate predecessor's persisted row (lineage anchoring, RFC-ACDP-0001 §5.6.2) instead of walking the chain back to v1 — a deep lineage with an unretrievable intermediate can no longer fail a publish. The full walk still exists, as an on-demand integrity check:
GET /admin/lineages/{lineage_id}/audit
Authorization: Bearer <auth.admin_tokens entry>It verifies version contiguity, supersedes links, the lineage_id
derivation from v1, producer continuity, and the single-tip invariant, and
reports receipt coverage. Run it periodically (or after restoring from
backup) — it detects storage corruption the anchored fast path would
silently inherit.
KMS / HSM
Key loading is isolated in acdp-registry-core::receipt (config →
acdp::types::receipt::ReceiptSigner). A KMS/HSM-backed deployment plugs in
at that seam; note that today's acdp ReceiptSigner holds raw key
material, so non-extractable keys additionally need an upstream acdp
signer abstraction — tracked as future work, the publish path will not
change.
Verifying end-to-end
From any machine that can reach the registry over TLS:
use acdp::client::registry::RegistryClient; // see docs.rs/acdp
use acdp::client::verified::{ReceiptPolicy, VerificationPolicy, VerifiedContext};
let policy = VerificationPolicy {
receipts: ReceiptPolicy::Require,
..VerificationPolicy::default()
};
// fetch + verify body signature, then receipt signature against the
// registry's /.well-known/did.json, plus all RFC-ACDP-0010 §8 cross-checks.A receipt failure does not invalidate the body — the producer signature
stands on its own; what is lost is the registry's binding of identifiers and
time. acdp reports the two verdicts separately.