An ACA-Py plugin implementing an AnonCreds method backed by Archon, so an existing Aries/AnonCreds deployment can use an Archon node as its Verifiable Data Registry.
Status: working prototype. Exercised end to end against a live Archon node and two ACA-Py agents; not yet submitted to the AnonCreds Methods Registry. Requires no changes to Archon.
It implements both halves of ACA-Py's pluggable AnonCreds interface in one class:
- a resolver, which needs nothing but unauthenticated HTTP — no wallet, no account, no payment;
- a registrar, which signs Archon DID operations with the issuer's wallet.
Archon is a content-addressed DID platform: a DID is the content identifier of its own genesis operation, and a DID document is reconstructed by replaying that DID's append-only operation log. Two properties carry the method.
Content addressing. An AnonCreds object can be named by the hash of its own bytes, so integrity verification is trust-free: fetch the object from any node, re-hash it, compare against the identifier you were given. No trust in the server that answered.
Replayable history. A DID is an ordered log of individually addressable operations — no document is ever stored — so mutable state like a revocation status list can be reconstructed as of any past moment, without snapshots or a separate index.
Object identifiers are issuer-rooted DID URLs: the issuer's DID is the identifier's own root, so an object's association with its issuer is structural rather than a lookup.
did:cid:<issuer>?resource=<resource>&resourceType=<type>
| Object | resourceType |
resource |
|---|---|---|
| Schema | anonCredsSchema |
CID of the schema |
| Credential definition | anonCredsCredDef |
CID of the credential definition |
| Revocation registry definition | anonCredsRevocRegDef |
CID of the definition |
| Revocation status list | anonCredsStatusList |
the revocation registry's DID |
Two checks run on every resolve, and a failure of either is fatal:
- Content address — the retrieved object must hash to the CID the identifier names.
- Issuer binding — the object's
issuerIdmust equal the identifier's issuer root. Without this an issuer could publish an identifier rooted at their own DID pointing at someone else's object.
The three immutable object types are stored as the verbatim serialized AnonCreds object in RFC 8785 (JCS) canonical form — no method-specific envelope, so the bytes a CID commits to are exactly the bytes an AnonCreds implementation consumes.
Archon has no accumulator or bitstring status-list primitive, so the revocation registry is its own DID, and each status-list update appends one operation to its log. The DID carries a pointer, not the list:
The list lives in IPFS because Archon bounds a single DID operation at 64 KB
while a revocation list grows with maxCredNum. The pointer form is 1,222
bytes regardless of registry size. The accumulator is duplicated inline because
it is small and is what a non-revocation proof actually consumes, so a verifier
needing only the accumulator makes one fetch instead of two.
get_revocation_list(..., timestamp_to=T) returns the entry with the greatest
AnonCreds timestamp at or before T, found by replaying the registry's
operation log to successive depths with Archon's versionSequence selector.
It deliberately does not bound the replay by T as wall-clock time. An
AnonCreds timestamp is chosen by the issuer and signed into the status list,
whereas a VDR's own event time is set when the operation is written — after the
list has been serialized and published. The two differ by however long
publishing took, so bounding on wall-clock time selects the wrong entry whenever
that difference crosses an entry boundary, and does so silently. A wrong answer
about whether a credential is revoked is the worst failure this plugin could
have.
update_revocation_list refuses to publish an entry timestamped earlier than
its predecessor, which keeps entry timestamps non-decreasing and makes the
search binary.
poetry install --extras aca-py # add --extras wallet for the in-process walletLoad it like any ACA-Py plugin:
plugin:
- archon_anoncreds
plugin_config:
archon-anoncreds:
node_url: http://your-archon-node:4222
registry: hyperswarm
tails_dir: /var/lib/acapy/archon-tails
# A wallet, by any of three routes. Omit all and the agent is resolve-only:
# every registration fails with a clear error rather than degrading.
wallet_path: /var/lib/acapy/archon-wallet.json
wallet_passphrase: ...| Setting | Default | Meaning |
|---|---|---|
node_url |
http://localhost:4222 |
Drawbridge. All reads and IPFS uploads. Needs no credentials. |
wallet_path / wallet_passphrase |
unset | A file wallet. On its own this selects the in-process transport. |
keymaster_cli |
unset | Reach the wallet via the @didcid/keymaster CLI instead. |
keymaster_url |
unset | Reach the wallet via a Keymaster HTTP service instead. |
gatekeeper_url |
node_url |
Where signed operations are submitted. |
admin_api_key |
unset | Sent as X-Archon-Admin-Key when Keymaster is protected. |
registry |
hyperswarm |
Archon registry new revocation-registry DIDs are anchored to. |
tails_dir |
/tmp/archon-anoncreds-tails |
Where resolved tails files are cached. |
allow_unconfirmed_reads |
false |
Read through the legacy /api/v1/did/:did surface. See Assurance. |
request_timeout |
30 |
Seconds. |
Registration signs Archon DID operations, so it needs a wallet; resolution never does. Nothing downstream of the registrar knows which transport is in use:
| in-process | keymaster_cli |
keymaster_url |
|
|---|---|---|---|
| Selected by | wallet_path alone |
keymaster_cli |
keymaster_url |
| Mechanism | archon-keymaster in the agent's event loop |
subprocess to the Node CLI | HTTP to a Keymaster service |
| Listening port | no | no | yes |
| Extra runtime | none | Node | a running service |
| Errors surface as | exceptions | stderr, exit code 0 | HTTP status |
Prefer the in-process transport. It is native Python, runs in ACA-Py's own event loop, and reports failures as exceptions. The CLI is a fine fallback but exits 0 on error, so its wrapper validates output rather than return codes.
Note that archon-keymaster pins bolt11==2.2.0, which caps at Python <3.13
while acapy-agent requires >=3.13. pip cannot resolve both; uv can. The
dependency is therefore optional, with the CLI as fallback.
All three keep the private key in the wallet — the plugin never signs.
ACA-Py refuses to create a revocable credential definition unless
tails_server_base_url is set, and uploads the tails file there before this
plugin's registrar runs. The plugin stores the tails file in Archon regardless
and rewrites tailsLocation to its CID, so verifiers never contact the tails
server — it is an issuer-side requirement of ACA-Py only.
On a non-anchored registry (local, hyperswarm), entry timestamps are
issuer-claimed. Nothing external attests that a revocation was published when
the issuer says it was.
| Registry | Timestamp assurance | Publish latency |
|---|---|---|
local |
Issuer-claimed. Single node, no gossip. | Immediate |
hyperswarm |
Issuer-claimed, replicated across peers. | ~1 second |
BTC:signet, BTC:mainnet, … |
Anchored — ordering and an upper time bound attested by the chain. | As long as anchoring takes |
Independently, the resolution surface returns confirmed state, so a
just-published update is briefly invisible to verifiers. On local and
hyperswarm that window is about a second; on an anchored registry it is as long
as anchoring takes. Issuers needing read-your-writes can set
allow_unconfirmed_reads: true, trading the forced verification and correct 404s
for immediacy.
If you need revocation timestamps a relying party can trust without trusting the issuer, use an anchored registry.
poetry run pytest archon_anoncreds -q # unit, no network
poetry run ruff check archon_anoncreds
# integration: needs an Archon node, but no Docker — the issuer's wallet is a
# throwaway file wallet created per session and driven through the Keymaster CLI
ARCHON_NODE_URL=http://your-node:4222 \
ARCHON_GATEKEEPER_URL=http://your-node:4224 \
poetry run pytest integration/tests -q
python demo/acapy_flow.py # full ACA-Py credential lifecycleNo node to point at? integration/docker-compose.yml brings up a self-contained
one — a convenience, not a dependency. See
integration/README.md and
demo/README.md.
Against a live Archon node:
- round-trip of all four object types through registrar and resolver
- tails-file upload, fetch, CID and
tailsHashverification - revocation publish, update, and time-pinned reads across entry boundaries
- rotation safety — after an issuer rotates keys, previously published objects still resolve and pre-rotation revocation state resolves correctly
- rejection of tampered content, foreign issuer roots, wrong resource types, backwards timestamps, and registration for an issuer the wallet does not hold
With two real ACA-Py agents (demo/):
- issuer publishes schema, credential definition and both revocation registries
- credential issued over DIDComm; the holder resolves every object from Archon with no wallet configured
- proof with a non-revocation interval verifies
true - after revocation, the same credential verifies
false
Behaviour against a Drawbridge with L402 enabled, against an anchored registry
such as BTC:signet, and at large maxCredNum. See
FINDINGS.md §D.
SPEC-DRAFT.md |
Draft AnonCreds method specification — identifier syntax, storage, resolution rules, security considerations |
FINDINGS.md |
Every assumption verified or violated, deviations from the original design, and proposals for upstream Archon and ACA-Py |
WORKPLAN.md |
Interface-to-Archon-call mapping and the phase plan |
demo/README.md |
Running the full ACA-Py lifecycle, and the two ACA-Py quirks you will hit |
integration/README.md |
Integration test setup |
MIT — see LICENSE.
{ "anonCredsObjectType": "anonCredsRevocationRegistry", "issuerId": "did:cid:<issuer>", "revRegDefId": "did:cid:<issuer>?resource=<cid>&resourceType=anonCredsRevocRegDef", "entry": { "revocationListCid": "<CID of the RevList>", "currentAccumulator": "<accumulator>", "timestamp": 1755500600 } }