Skip to content

feat: DIDComm Messaging v2 (Phases 0–7): TS + Python parity, transport, protocols, auto-discovery - #633

Merged
macterra merged 29 commits into
mainfrom
docs/didcomm-design
Jun 24, 2026
Merged

feat: DIDComm Messaging v2 (Phases 0–7): TS + Python parity, transport, protocols, auto-discovery#633
macterra merged 29 commits into
mainfrom
docs/didcomm-design

Conversation

@macterra

@macterra macterra commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator

Implements DIDComm Messaging v2 for Archon end-to-end — design doc plus a working, interop-validated stack reachable from every client tier in both the TypeScript and Python ecosystems. Every envelope is round-tripped against the didcomm-node reference library, kept as a dev/test oracle only — not a runtime dependency (no didcomm-node in any shipped manifest); the runtime crypto is pure-JS (and pure-Python).

See docs/didcomm-design.md for the full design and docs/services/didcomm/README.md for the relay service spec.

What's implemented (Phases 0–7, all complete)

Phase What landed
0 Spike spike/didcomm-phase0 + interop oracle; validated decisions (build-not-buy, X25519)
1 Keys deterministic X25519 keyAgreement keys in did:cid docs (publishDidComm/unpublishDidComm)
2 Envelope crypto + API pure-JS anoncrypt / authcrypt / signed in cipher (ECDH-ES & ECDH-1PU + A256KW, A256CBC-HS512, XC20P, A256GCM, ES256K JWS); keymaster packDidComm/unpackDidComm
3a Cross-method resolution did:key (Ed25519/X25519) + universal-resolver fallback; multibase normalization — Archon did:ciddid:key both directions
3b Transport (mailbox) services/didcomm/server relay (signed-challenge fetch auth; in-memory/redis stores; Docker + opt-in didcomm compose profile, port 4236); keymaster sendDidComm/receiveDidComm; Drawbridge /didcomm reverse proxy for the public endpoint
3c Forward/routing wrapForward/parseForward; DID-doc routingKeys; sendDidComm mediator wrapping; mediateDidComm (Archon-as-mediator)
4 Application protocols message builders for Trust Ping, Basic Message, Discover Features, Out-of-Band (+ _oob URL encode/decode)
5 Credential exchange Issue-Credential 3.0 + Present-Proof 3.0 builders carrying an Archon VC/VP as a DIDComm attachment (bindCredential/addProof/verifyProof)
6 Routing/mediation Coordinate-Mediation 2.0 enrollment handshake (mediate-request/grant with routing_did, keylist-update); Forward landed in 3c
7 Parity & polish the full surface wired through three CLIs, the Python SDK, a full Python-library port, and MCP; endpoint auto-discovery (below)

Reachable from every tier

The DIDComm verbs (publish/unpublish/pack/unpack/send/receive/mediate) are exposed consistently across:

core Keymaster → REST API (+ Swagger) → KeymasterClient → CLI → MCP server, and independently in Python (SDK + standalone library).

  • CLIs (all three, in parity per AGENTS.md): packages/keymaster/src/cli.ts, scripts/archon-cli.js, and the Python keymaster CLI each gain publish-/unpublish-/pack-/unpack-/send-/receive-/mediate-didcomm.
  • MCP server: archon_{publish,unpublish,pack,unpack,send,receive,mediate}_didcomm (a parity test enforces one tool per CLI command).
  • Python SDK (python/keymaster_sdk): publish_didcommmediate_didcomm, mirroring KeymasterClient (REST).
  • Python keymaster library (python/keymaster): a full pure-Python port — envelope crypto (didcomm_crypto.py: X25519, ECDH-ES/1PU+A256KW with the tag-in-Concat-KDF, A256CBC-HS512/XC20P/A256GCM, ES256K JWS, did:key, Forward), protocol builders, and the Keymaster methods/CLI. Uses cryptography + coincurve; no new native deps — XChaCha20-Poly1305 and the Ed25519→X25519 did:key map are implemented inline (a moderate libsodium/PyNaCl advisory made the dependency not worth it). Validated byte-for-byte against the TS stack in both directions (committed JS-produced envelope vectors decrypt in Python; Python round-trips decrypt in didcomm-node).

Endpoint auto-discovery

publishDidComm with no explicit endpoint now auto-discovers it from the gateway (GET /api/v1/didcomm-endpoint), the way publishLightning learns its public host:

  1. ARCHON_DRAWBRIDGE_PUBLIC_HOST<host>/didcomm,
  2. else the Tor onion fronting Drawbridge → http://<onion>:<port>/didcomm (read from the shared hidden-service hostname),
  3. else key-agreement-only publish; an explicit endpoint always overrides.

Wires up the previously-unused ARCHON_DRAWBRIDGE_PUBLIC_HOST in Drawbridge and mounts the shared tor-hostname volume into it.

Key design decision

Build, not buy (decided after confirming in-browser self-custody is the primary use case): nothing in DIDComm requires WASM, so the envelope crypto is pure-JS in cipher — identical in node and the browser, no WASM bundler/CSP friction. Correctness is held by interop tests against didcomm-node; the Python port holds the same contract via committed cross-language vectors.

Tests

  • cipher / keymaster: didcomm unit tests (RFC 3394 KW vector, all enc round-trips, JWS, did:key W3C vector, Forward) + e2e for authcrypt/anoncrypt/signed, cross-method, mediator routing, and endpoint auto-discovery.
  • relay: mailbox/auth unit tests + opt-in live-redis + full two-identity-over-HTTP e2e (Alice→mediator→Bob, Coordinate-Mediation enrollment, forged-fetch rejection).
  • protocols: builder unit tests for Phases 4–6.
  • Python SDK: mocked contract parity (all 7 endpoints/bodies/returns) + a live authcrypt packunpack round-trip against the docker stack.
  • Python library: self round-trips + JS-produced envelope vectors decrypted in Python (cross-language interop, no Node at test time) + did:key W3C vector + builder checks.
  • MCP: CLI-parity + full tool suite.
  • CI green across the board: unit-test, both Python suites, browser/explorer/webapp builds, both Docker image builds, lockfile + dependency review.

Review feedback addressed

  • packDidComm builds the envelope with protocol-controlled id/typ/to forced after the caller's message and from set only for authcrypt (a caller can no longer override typ or smuggle a from into an anoncrypt envelope) — fixed in both languages.
  • unpublishDidComm deletes an empty verificationMethod array (symmetry with service).
  • Swagger for POST /didcomm/publish documents the routingKeys field.
  • (The "broken" compose healthcheck flagged in review was a false positive — the backslashes are valid YAML escaped line-breaks; the folded node -e script passes node --check.)

Test-infra fix included

Made the live-redis mailbox suite opt-in (ARCHON_REDIS_URL set) instead of opt-out — it was running by default against a non-existent redis in the unit-test CI, leaving ioredis reconnecting forever, a leaked handle that hung jest --runInBand (no --forceExit). Also wired the previously-unrun SDK contract-unit test into python-sdk-tests.yml.

Docs / config

  • Relay service spec: docs/services/didcomm/README.md (+ "Messaging" entry in the services index).
  • sample.env documents the relay env vars (ARCHON_DIDCOMM_*), the didcomm profile, and the ARCHON_DRAWBRIDGE_PUBLIC_HOST / onion auto-discovery behaviour.

Optional follow-ons (not blocking)

EdDSA signature verify (foreign Ed25519 signers), P-256 key agreement, a did:cid Universal Resolver driver so non-Archon agents can resolve did:cid, a mongo mailbox backend, and a standard credential attachment format for cross-agent VC interop (Archon VCs use EcdsaSecp256k1Signature2019).

Note: spike/ is a throwaway/reference tree intended to be removed before release.

🤖 Generated with Claude Code

macterra and others added 2 commits June 20, 2026 17:52
Research and a phased plan for adding standards-based DIDComm v2
messaging to Archon: the secp256k1-vs-key-agreement-curve constraint,
the existing reuse surface (JWE, sign/verify, service endpoints, Dmail),
build-vs-buy decision, and a Phase 0-7 implementation plan. Linked from
the docs index.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Self-contained, throwaway spike validating the DIDComm design doc's
build-vs-buy and curve decisions. Packs/unpacks DIDComm v2 envelopes
between two Archon-shaped DIDs via the didcomm-node library and the
DIDResolver/SecretsResolver adapter interfaces later phases will back
with the gatekeeper and wallet.

All three round-trips pass: anoncrypt (XC20P/ECDH-ES over X25519),
authcrypt (A256CBC-HS512/ECDH-1PU over X25519), and authcrypt+sign with
a secp256k1 key (ES256K) — confirming Archon's existing keys can sign
DIDComm as-is and only a new X25519 key-agreement key is required.

Not wired into the monorepo (root workspaces is packages/* only); runs
standalone via `npm ci && node spike.mjs` in spike/didcomm-phase0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@macterra
macterra force-pushed the docs/didcomm-design branch from a261a62 to cb75497 Compare June 21, 2026 01:07
@macterra macterra changed the title docs: DIDComm messaging support design doc DIDComm v2 support: design doc + Phase 0 spike Jun 21, 2026
macterra and others added 13 commits June 20, 2026 21:33
Adds the standards-curve key-agreement key that DIDComm v2 encryption
requires (secp256k1 cannot do key agreement; it keeps signing).

- cipher: generateX25519Jwk(seed) + OKP JWK types. Derives a deterministic
  X25519 keypair from 32 bytes of seed material (@noble/curves, already a
  dependency).
- keymaster: fetchDidCommKeyPair derives the key on a dedicated HD branch
  (m/44'/0'/{account}'/1/0) so it never collides with the signing key at
  change=0 and survives backup/recovery with no extra stored material.
  publishDidComm writes it into the DID document as a keyAgreement
  verification method (+ optional DIDCommMessaging service); unpublishDidComm
  removes it. Wired through KeymasterInterface, KeymasterClient, and the
  keymaster API (/didcomm/publish).
- gatekeeper: DidCidDocument gains keyAgreement[] and allows OKP publicKeyJwk;
  signature verification now asserts the signing key is secp256k1 (EC).

Tests: tests/keymaster/didcomm.test.ts (derivation determinism, distinct
from signing key, publish/unpublish, idempotent, DIDCommMessaging service)
and client coverage. Verified resolved DID docs carry a valid X25519
keyAgreement key. Phase 0 spike's lib round-trip already confirmed
didcomm-node accepts exactly this key/doc shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In-browser self-custody wallets are the primary use case, so pack/unpack must
run client-side with local keys. Nothing in DIDComm requires WASM — every
primitive is available pure-JS via @noble — so building the envelope crypto by
extending the cipher package (which already runs in browser + node with zero
WASM) beats shipping the SICPA WASM library to two browser bundlers (Vite +
Webpack), with async init, bundle weight, and an MV3 wasm-unsafe-eval CSP
allowance.

- Decision A: build, not buy. cipher owns envelope crypto (ECDH-1PU, A256KW,
  A256CBC-HS512 + JWM/JWS/JWE framing) on raw JWKs; keymaster orchestrates
  resolve+derive. didcomm-node retained only as a dev/test interop oracle.
- Architecture/diagram updated to the cipher<->keymaster split.
- Phase 2 re-planned: 2a (cipher crypto, interop-tested vs didcomm-node) +
  2b (keymaster packDidComm/unpackDidComm). Phases 0 and 1 marked done.
- Risk reframed from "WASM dependency" to "we own the envelope crypto"
  (mitigated by two-way interop tests). Spike README annotated with the
  revision.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements the DIDComm v2 encrypted (JWE) and signed (JWS) envelopes in pure
JS, so they run identically in node and the browser (no WASM). New module
packages/cipher/src/didcomm.ts (exported as @didcid/cipher/didcomm):

- anoncrypt: ECDH-ES + A256KW, XC20P
- authcrypt: ECDH-1PU + A256KW, A256CBC-HS512 — with the content-encryption
  tag mixed into the Concat-KDF as length-prefixed SuppPrivInfo (the 1PU+KW
  requirement; ECDH-ES omits it)
- A256KW (RFC 3394) on AES-ECB; A256CBC-HS512 (AES-CBC + HMAC-SHA-512)
- JWS sign/verify with ES256K (Archon's secp256k1 keys)
- message-level packDidCommMessage/unpackDidCommMessage (sign-then-encrypt)
  and getEnvelopeInfo for skid resolution
- header construction (alg/enc/apu/apv/skid/epk) matches didcomm-rust

concat-kdf gains an optional SuppPrivInfo arg (backward compatible — the ES
path passes nothing). Pure functions over raw JWKs; keymaster (Phase 2b) will
resolve DIDs to keys and orchestrate.

Validated:
- tests/cipher/didcomm.test.ts — 22 unit tests (RFC 3394 KW vector, all enc
  round-trips, header shapes, tamper rejection, JWS, sign-then-encrypt)
- spike/didcomm-phase0/interop.mjs — round-trips against didcomm-node BOTH
  directions for anoncrypt/authcrypt/sign-then-encrypt (6/6). didcomm-node is
  a dev/test oracle only, never a runtime dependency.
- full cipher build (esm+cjs) + 48/48 cipher tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wires the pure-JS cipher envelope crypto into keymaster so encrypted DIDComm
messages flow between real did:cid identities, with live gatekeeper resolution
and wallet-derived keys (retiring the spike's mock-doc caveat).

- packDidComm(message, to, { sign, anoncrypt, encryption, name }): resolves each
  recipient DID's X25519 keyAgreement key, sets from/to, derives the sender's
  X25519 key for authcrypt (default) and the secp256k1 key for an optional
  ES256K signature, then calls cipher.packDidCommMessage.
- unpackDidComm(packed, { name }): inspects the envelope (getEnvelopeInfo),
  checks the message is addressed to this identity, resolves the skid (authcrypt)
  and nested JWS signer DID, derives the recipient key, and decrypts/verifies.
- Verification-method lookup matches by DID-fragment so it handles both the
  relative (#key-1) and absolute (did#key-agreement-1) ids in did:cid docs.
- Wired through KeymasterInterface, KeymasterClient, and the keymaster API
  (/didcomm/pack, /didcomm/unpack). Because the crypto is pure-JS in cipher,
  this works unchanged in the browser-shared Keymaster core.

Tests: tests/keymaster/didcomm.test.ts gains 5 end-to-end cases between two
real identities (authcrypt, anoncrypt, sign-then-encrypt, plus the
not-published and not-a-recipient error paths) — 12/12; client nock coverage
added (313/313); keymaster builds and the service typechecks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phases 0-2 are done (spike, X25519 keyAgreement keys, pure-JS envelope crypto,
keymaster pack/unpack). Reframes Phase 3: because self-custody wallets hold
keys client-side, the inbound side can only store-and-forward encrypted
envelopes (a mailbox), not unpack on the agent's behalf — which converges with
the Phase 6 mediator. Records the open transport decisions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Enables DIDComm with non-Archon agents (other DID methods), not just did:cid.

cipher (@didcid/cipher/didcomm):
- didKeyToX25519: resolves did:key to its X25519 key-agreement key — z6LS
  (X25519 direct) and z6Mk (Ed25519, with the spec-correct Ed25519->X25519
  derivation, verified against the W3C did:key vector).
- normalizeX25519PublicKey: accepts publicKeyJwk (OKP) or publicKeyMultibase,
  so foreign DID docs (e.g. from a universal resolver) normalize to an X25519 JWK.
- x25519JwkToDidKey: encode an X25519 key as a did:key.

keymaster:
- resolveDidForDidComm: resolves did:key locally; routes everything else through
  the gatekeeper (which has a universal-resolver fallback for did:web etc.).
- pack/unpack use it for recipient and sender (skid) keys; resolveKeyAgreement
  now normalizes multibase key material.

Validated:
- cipher unit tests incl the W3C did:key known-answer vector (53/53 cipher).
- keymaster e2e: Archon did:cid <-> did:key both directions (14/14).
- spike interop: our pack to a did:key recipient is unpacked by the reference
  didcomm-node library.

Caveats (additive, later): foreign signing interop needs EdDSA verify (we verify
ES256K only); P-256 key agreement for ecosystems that use it; and a Universal
Resolver driver so others can resolve did:cid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Standard HTTP transport so self-custody recipients can receive DIDComm over a
published DIDCommMessaging endpoint (store-and-forward, since the relay can't
unpack on the key-holder's behalf).

New services/didcomm/server (mirrors the keymaster server):
- POST /api/v1/messages — store an encrypted envelope by recipient DID (parsed
  from the JWE recipient kids); inbound is open (envelopes are encrypted).
- GET /api/v1/challenge + POST /api/v1/messages/fetch — a recipient proves DID
  control with a single-use signed challenge (ES256K over the nonce, verified by
  resolving the DID and checking its key), then retrieves its queue.
- POST /api/v1/messages/remove — ack/remove fetched messages.
- Express-free core (store.ts MailboxStore + MemoryMailboxStore w/ TTL,
  mailbox.ts recipient-parse + challenge verify) for unit-testability; in-memory
  store now, swappable for redis/mongo behind MailboxStore.

keymaster:
- sendDidComm(message, to, options): pack (Phase 2/3a) -> resolve each
  recipient's DIDCommMessaging endpoint -> POST the envelope.
- receiveDidComm({ name, endpoint }): challenge -> sign -> fetch -> unpack -> ack.
- Wired through KeymasterInterface, KeymasterClient, and the keymaster API
  (/didcomm/send, /didcomm/receive). Uses global fetch (browser + node).

Tests: tests/didcomm/mailbox.test.ts (store/parse/auth, 8) and
tests/didcomm/e2e.test.ts (two identities exchange authcrypt + signed messages
through the live relay over HTTP, ack, forged-fetch rejected, 3); keymaster
client nock coverage (317). Service builds (tsc) with its own deps.

Remaining for transport: Dockerfile + docker-compose wiring, redis/mongo store,
and 3c Forward/routing for mediated recipients.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds docker/Dockerfile.didcomm (mirrors Dockerfile.keymaster-ts: build the
@didcid/* workspace packages, then the didcomm server) and an opt-in compose
fragment docker/compose/didcomm.yml (enable with COMPOSE_PROFILES=didcomm),
included from docker-compose.yml. The relay depends only on the gatekeeper
(in-memory store, no redis/mongo yet) and health-checks GET /health.

`docker compose config` validates the merged configuration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Makes the MailboxStore interface async (prerequisite for network-backed stores)
and adds a redis backend alongside the in-memory one, selected by
ARCHON_DIDCOMM_DB (memory|redis). Redis uses native key expiry (EX/PX) for
message and challenge TTLs; an inbox SET per recipient with lazy pruning of
expired ids; GETDEL for single-use challenges.

- store.ts: MailboxStore now returns Promises; MemoryMailboxStore updated;
  RedisMailboxStore (ioredis) added with create()/connect()/disconnect().
- didcomm-api.ts: route handlers await the store.
- index.ts/config.js: select store by ARCHON_DIDCOMM_DB; ARCHON_REDIS_URL.
- compose: ARCHON_DIDCOMM_DB + ARCHON_REDIS_URL env, depends_on redis.

Validated: 13/13 didcomm tests incl. the RedisMailboxStore against a live redis,
and the relay e2e still green after the async refactor. Service builds with
ioredis; `docker compose config` valid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements the routing/2.0 Forward protocol crypto in cipher so messages can be
delivered to recipients behind a mediator:
- wrapForward(inner, next, routingKey): builds a routing/2.0/forward JWM
  (body.next = recipient, attachments[0].data.json = inner envelope) and
  anoncrypts it to the mediator's key.
- parseForward(plaintext): extracts { next, forwardedMessage } from a decrypted
  Forward, for a mediator to relay.

Format matches didcomm-rust; interop-validated against didcomm-node both ways
(our wrapForward read by the lib; the lib's wrap_in_forward parsed by us with
the inner envelope still decrypting for the final recipient). Cipher unit tests
+ spike interop oracle updated (9 interop checks, all green; 29 cipher tests).

Remaining 3c integration (separate): DID-doc routingKeys (serviceEndpoint object
form + publishDidComm), sendDidComm wrapping when routingKeys are present, and an
Archon mediator that unpacks a Forward and relays to next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…diator

Completes Forward/routing end-to-end:
- DID-doc serviceEndpoint now allows the DIDComm object form
  { uri, accept, routingKeys }; publishDidComm(endpoint, name, routingKeys)
  advertises a mediator. (lightning-mediator narrows its string endpoint
  accordingly.)
- resolveDidCommEndpoint returns { uri, routingKeys }; sendDidComm wraps the
  packed message in a Forward to the recipient's mediator routing key when one
  is advertised, and delivers to the mediator endpoint.
- mediateDidComm: an Archon identity acting as a mediator fetches Forward
  messages from its mailbox, unpacks them, and relays the inner envelope to the
  final recipient (stored under `next`). Wired through interface/client/API
  (/didcomm/mediate).

Validated: an e2e where Alice -> mediator -> Bob is delivered via the Forward
protocol through the live relay (14 didcomm tests); keymaster + service build,
360 keymaster/cipher tests, 319 client tests, lightning mediator typechecks.

Phase 3 (cross-method resolution + transport + routing) is now complete.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Fix a port collision: the didcomm relay used 4228, which react-wallet already
  owns. Moved it to 4236 (config default + compose).
- Front the relay through Drawbridge (the public gateway, with Tor): add a
  generic proxyRequest, mount `/didcomm` -> config.didcommURL (ARCHON_DIDCOMM_URL,
  default http://didcomm:4236), and capture application/didcomm-encrypted+json as
  text so envelopes survive proxying. So the published DIDCommMessaging endpoint
  is `<drawbridge public host>/didcomm` (or .onion), not the relay exposed
  directly.

Drawbridge + didcomm services typecheck/build; `docker compose config` valid;
14 didcomm tests still green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@macterra macterra changed the title DIDComm v2 support: design doc + Phase 0 spike DIDComm v2 support (Phases 0–3): envelopes, cross-method, transport, routing Jun 22, 2026
@macterra macterra changed the title DIDComm v2 support (Phases 0–3): envelopes, cross-method, transport, routing feat: DIDComm v2 support (Phases 0–3): envelopes, cross-method, transport, routing Jun 22, 2026
macterra and others added 7 commits June 22, 2026 16:27
Adds packages/keymaster/src/didcomm-protocols.ts (re-exported from
@didcid/keymaster) with spec-correct builders for the core DIDComm v2
application protocols, which compose with sendDidComm/receiveDidComm:

- Trust Ping 2.0: trustPing / trustPingResponse (thid-correlated)
- Basic Message 2.0: basicMessage(content)
- Discover Features 2.0: discoverFeaturesQuery / discoverFeaturesDisclose
- Out-of-Band 2.0: outOfBandInvitation + encode/decodeOutOfBandInvitation
  (the _oob=base64url(json) URL form)

Type URIs and body shapes verified against the DIDComm messaging spec.

Tests: tests/keymaster/didcomm-protocols.test.ts (builder shapes vs spec) and a
relay e2e where Alice sends a Basic Message + a Trust Ping and Bob returns a
thid-correlated ping-response (5 e2e / 4 unit, all green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rewrote a chained ternary in POST /messages as an if/else block to satisfy the
indent rule (build-and-test lint step).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nt-Proof 3.0)

Adds Issue-Credential 3.0 and Present-Proof 3.0 message builders to
didcomm-protocols.ts, carrying an Archon verifiable credential/presentation as a
DIDComm attachment (data.json). They map onto Archon's existing VC machinery
(bindCredential/addProof to issue, verifyProof to check) and compose with
sendDidComm/receiveDidComm:

- issueCredentialMessage(vc), offerCredential, requestCredential
- requestPresentation, presentationMessage(vp), proposePresentation
- attachedJson(message) to extract the VC/VP from an attachment
- spec type URIs + attachment format identifiers

Tests: builder-shape units + an e2e where Alice issues a signed VC to Bob over
DIDComm (Bob verifies the issuer proof), then Carol requests a presentation, Bob
presents a VP wrapping the VC, and Carol verifies both holder and issuer
signatures.

Note: cross-agent VC interop is bounded by the credential format — Archon VCs use
EcdsaSecp256k1Signature2019; a standard attachment format for Aries/AnonCreds is a
follow-on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes Phase 6 (Forward messages already landed in 3c). Adds the
Coordinate-Mediation 2.0 enrollment handshake so the mediator relationship is
negotiated rather than hard-coded:

- didcomm-protocols.ts: builders for mediate-request / mediate-grant (routing_did)
  / mediate-deny / keylist-update (+response) / keylist-query / keylist, with the
  exact spec type URIs and bodies.
- keymaster: resolveRoutingKey accepts a routing key as a full kid OR a bare DID
  (the grant's routing_did form); sendDidComm uses it when wrapping Forwards.

Tests: builder-shape units + an e2e where Bob requests mediation, the mediator
grants its routing_did and acks a keylist-update, Bob re-publishes advertising it,
and Alice then routes to Bob through the mediator (17 protocol/e2e tests green;
lint + build clean).

Note: the mediator still relays any Forward it can unpack; gating on the
registered keylist is a follow-on refinement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The RedisMailboxStore integration tests were gated opt-OUT
(`ARCHON_SKIP_REDIS ? describe.skip : describe`), so they ran by default.
The unit-test CI job has no redis service and doesn't set ARCHON_SKIP_REDIS,
so the suite connected `new Redis('redis://localhost:6379')` to nothing;
ioredis then reconnected forever, leaking a timer/socket handle. Under
`jest --runInBand` with no `--forceExit`, that open handle kept the event
loop alive and jest never exited — the unit-test workflow hung indefinitely
(observed across the last few commits; runs sat until the runner timeout).

Flip to opt-IN: the live-redis suite runs only when ARCHON_REDIS_URL is set
(pointing at a reachable redis) and is skipped otherwise. Verified locally:
mailbox.test.ts now skips the redis block and jest exits 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wires the DIDComm v2 surface (REST routes already shipped in Phase 3a/3b)
through the two remaining client tiers, completing the build.

CLI (packages/keymaster/src/cli.ts): publish-didcomm / unpublish-didcomm /
pack-didcomm / unpack-didcomm / send-didcomm / receive-didcomm /
mediate-didcomm. pack/send read the plaintext message from a JSON file and a
comma-separated recipient list, with --sign / --anoncrypt / --encryption /
--name flags.

Python SDK (python/keymaster_sdk): publish_didcomm / unpublish_didcomm /
pack_didcomm / unpack_didcomm / send_didcomm / receive_didcomm /
mediate_didcomm, mirroring KeymasterClient (same endpoints/bodies), exported
from the package.

Tests: a mocked contract test asserts endpoint + body + return parity for all
seven SDK functions, and a live test does a real authcrypt pack->unpack
round-trip between two identities. The contract-unit file is now also run by
the python-sdk-tests workflow (it was previously not executed by any CI).

Docs: design-doc Phase 7 marked done with the CLI/SDK surface; status table
shows Phases 0-7 all complete.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The mcp-server CLI-parity test (tests/mcp-server/parity.test.ts) requires one
MCP tool per Keymaster CLI command. Phase 7's new didcomm CLI commands had no
MCP counterparts, so parity failed (7 unmapped commands). Add the matching
tools — archon_{publish,unpublish,pack,unpack,send,receive,mediate}_didcomm —
each delegating to the corresponding KeymasterClient method. publish/unpublish/
send/receive/mediate are marked mutates (DID-doc/mailbox/network side effects);
pack/unpack are read-only.

Parity + full mcp-server suite green (24 tests); root build + lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@macterra macterra changed the title feat: DIDComm v2 support (Phases 0–3): envelopes, cross-method, transport, routing feat: DIDComm Messaging v2 (Phases 0–7): envelopes, transport, protocols, CLI/SDK/MCP parity Jun 23, 2026
@macterra
macterra requested a review from Copilot June 23, 2026 00:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements end-to-end DIDComm Messaging v2 support across Archon’s crypto (cipher), orchestration (keymaster), transport (new relay service), and client surfaces (REST/Swagger, JS client, Python SDK, MCP tools), with extensive unit + e2e + interop validation.

Changes:

  • Add pure-JS DIDComm v2 envelope crypto (anoncrypt/authcrypt + optional ES256K JWS) plus did:key/X25519 normalization and Forward routing helpers.
  • Extend Keymaster with DIDComm keyAgreement publishing, pack/unpack, send/receive, and mediator relay; expose via REST + client SDKs + MCP tools.
  • Introduce a DIDComm mailbox relay service (memory/redis stores), Docker/Compose wiring, Drawbridge reverse proxy, and broad test coverage.

Reviewed changes

Copilot reviewed 48 out of 50 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
tests/keymaster/didcomm.test.ts Keymaster DIDComm key derivation/publish + pack/unpack e2e tests
tests/keymaster/didcomm-protocols.test.ts Unit tests for DIDComm protocol builders (ping/basic/oob/cred/mediation)
tests/keymaster/client.test.ts Adds KeymasterClient contract tests for DIDComm REST endpoints
tests/didcomm/mailbox.test.ts Unit + opt-in live-redis tests for mailbox store + auth helpers
tests/didcomm/e2e.test.ts Full HTTP relay e2e flows (send/receive/forward/mediation/forgery rejection)
tests/cipher/didcomm.test.ts Unit tests for DIDComm crypto primitives, did:key, Forward routing
spike/didcomm-phase0/spike.mjs Phase-0 reference spike using didcomm-node (dev/test oracle)
spike/didcomm-phase0/README.md Spike documentation + rationale/interop instructions
spike/didcomm-phase0/package.json Spike package definition (didcomm-node dependency)
spike/didcomm-phase0/package-lock.json Lockfile for spike package
spike/didcomm-phase0/interop.mjs Interop oracle (ours↔didcomm-node) regression runner
spike/didcomm-phase0/.gitignore Ignore node_modules for spike
services/mediators/lightning/src/lightning-mediator.ts Accept serviceEndpoint object form by normalizing to a URL string
services/keymaster/server/src/keymaster-api.ts REST + Swagger routes for DIDComm publish/pack/unpack/send/receive/mediate
services/drawbridge/server/src/drawbridge-api.ts Add generic proxy helper + public /didcomm reverse proxy + body parsing
services/drawbridge/server/src/config.ts Add didcommURL config knob
services/didcomm/server/tsconfig.json TS build config for the new relay service
services/didcomm/server/src/store.ts Mailbox store interface + memory and redis implementations
services/didcomm/server/src/mailbox.ts Core relay logic helpers (recipient extraction, challenge signature verify)
services/didcomm/server/src/index.ts Relay service entrypoint wiring Gatekeeper resolver + store + app
services/didcomm/server/src/didcomm-api.ts Relay HTTP API (store, challenge, fetch/remove)
services/didcomm/server/src/config.js Relay environment configuration
services/didcomm/server/package.json Relay package definition + deps
python/keymaster_sdk/tests/test_keymaster_sdk.py Live DIDComm pack→unpack round-trip test
python/keymaster_sdk/tests/test_keymaster_sdk_contract_unit.py Contract-unit parity tests for DIDComm SDK wrappers
python/keymaster_sdk/src/keymaster_sdk/keymaster_sdk.py Python SDK DIDComm wrapper functions
python/keymaster_sdk/src/keymaster_sdk/init.py Export DIDComm SDK functions
packages/mcp-server/src/tools.ts Add MCP tool definitions for DIDComm commands
packages/keymaster/src/types.ts Add DIDComm types + KeymasterInterface methods
packages/keymaster/src/keymaster.ts Implement DIDComm key derivation, publish, pack/unpack, send/receive, mediate
packages/keymaster/src/keymaster-client.ts Add DIDComm methods to KeymasterClient (REST calls)
packages/keymaster/src/index.ts Re-export DIDComm protocol builders
packages/keymaster/src/didcomm-protocols.ts Add DIDComm protocol message builder utilities
packages/keymaster/src/cli.ts Add DIDComm CLI commands
packages/gatekeeper/src/types.ts Extend DID document types for keyAgreement + DIDComm serviceEndpoint object form
packages/gatekeeper/src/gatekeeper.ts Ensure signature verification uses secp256k1 (kty check)
packages/cipher/src/types.ts Add OKP/X25519 JWK types + cipher API for X25519 keygen
packages/cipher/src/didcomm.ts Implement DIDComm v2 envelope crypto + Forward routing + did:key helpers
packages/cipher/src/concat-kdf.ts Add SuppPrivInfo support for ECDH-1PU KDF
packages/cipher/src/cipher-base.ts Implement deterministic X25519 JWK generation
packages/cipher/rollup.cjs.config.js Add didcomm entry to CJS bundling
packages/cipher/package.json Export ./didcomm entrypoint
docs/index.md Link to DIDComm design doc
docs/didcomm-design.md DIDComm design/decisions/phases documentation
docker/Dockerfile.didcomm Docker image build for relay service
docker/compose/drawbridge.yml Wire drawbridge env var to reach didcomm service
docker/compose/didcomm.yml Compose profile for relay service + healthcheck
docker-compose.yml Include didcomm compose fragment
.github/workflows/python-sdk-tests.yml Run both Python SDK test suites in CI
Files not reviewed (2)
  • services/didcomm/server/package-lock.json: Generated file
  • spike/didcomm-phase0/package-lock.json: Generated file

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/keymaster/src/keymaster.ts
Comment thread packages/keymaster/src/keymaster.ts
Comment thread services/keymaster/server/src/keymaster-api.ts
Comment thread docker/compose/didcomm.yml
Comment thread packages/keymaster/src/cli.ts
macterra and others added 7 commits June 22, 2026 22:38
Completes the deferred Python work: a full pure-Python port of the DIDComm v2
stack in the standalone python/keymaster library, interoperable byte-for-byte
with the TypeScript stack and the didcomm/didcomm-node reference library.

- didcomm_crypto.py: envelope crypto mirroring @didcid/cipher's didcomm.ts —
  X25519, ECDH-ES/1PU+A256KW (the content-encryption tag mixed into Concat-KDF
  as length-prefixed SuppPrivInfo), A256CBC-HS512 / XC20P / A256GCM, ES256K JWS,
  RFC 3394 key wrap, Forward (routing/2.0), and did:key resolution/normalization.
  Built on `cryptography` + `coincurve`, plus PyNaCl for XChaCha20-Poly1305 and
  the Ed25519->X25519 did:key conversion.
- didcomm_protocols.py: trust-ping / basic-message / discover-features /
  out-of-band / issue-credential 3.0 / present-proof 3.0 / coordinate-mediation
  2.0 message builders.
- core.py (Keymaster): fetch_didcomm_key_pair, publish_didcomm,
  unpublish_didcomm, pack_didcomm, unpack_didcomm, send_didcomm, receive_didcomm,
  mediate_didcomm + resolution/routing helpers, mirroring keymaster.ts.
- cli.py + scripts/archon-cli.js: the 7 DIDComm commands, bringing all three
  CLIs into parity with packages/keymaster/src/cli.ts (AGENTS.md CLI-parity rule).

Tests (tests/test_didcomm.py): Python self round-trips (anon/auth/gcm/signed/
forward), JS-produced envelope vectors decrypted/verified in Python (JS->PY
interop, no Node needed at test time), did:key round-trip + the Ed25519 W3C
vector, and protocol-builder shape checks. 16 new tests; full library suite
(160) and keymaster_service (21) green; pynacl added to deps; lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…5519 inline

The Dependency Review gate (fail-on-severity: moderate) flagged the new
pynacl@1.5.0 for GHSA-mrfv-m5wm-5w6w (libsodium incomplete input validation),
which has no patched PyNaCl release. Rather than weaken the security gate,
remove the dependency:

- XChaCha20-Poly1305 is now built from a small HChaCha20 subkey derivation plus
  `cryptography`'s IETF ChaCha20Poly1305 (the standard XChaCha construction).
- Ed25519->X25519 (for did:key z6Mk… key agreement) uses the standard Edwards
  y -> Montgomery u birational map.

Both are gated by existing test vectors: the committed JS-produced XC20P
envelope decrypts in Python (proving the HChaCha20 subkey matches
libsodium/@noble byte-for-byte), and the Ed25519 W3C did:key vector still
resolves. 16 didcomm tests + full library suite green; no pynacl in deps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolves the valid Copilot review comments on the DIDComm work:

- packDidComm (keymaster.ts + Python core.py): spread the caller's message
  first, then force the protocol-controlled headers (id/typ/to) and delete any
  caller-supplied `from`, setting `from` only for authcrypt. Previously a caller
  could override `typ` (producing a non-compliant envelope) or smuggle a `from`
  into an anoncrypt envelope, defeating sender anonymity.
- unpublishDidComm (keymaster.ts + Python core.py): delete `verificationMethod`
  when the filtered list is empty, mirroring the `service` handling (defensive
  symmetry; an identity always retains its signing key in practice).
- Swagger for POST /didcomm/publish: document the `routingKeys` field the
  handler already accepts, so the spec matches the implementation.

(The 4th review comment — a "broken" compose healthcheck — was a false positive:
the backslashes are intentional YAML escaped line-breaks; the folded `node -e`
script is valid JS, verified with `node --check`.)

JS DIDComm e2e + unit (21) and Python didcomm (16) green; keymaster build + lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The DIDComm relay + Drawbridge reverse-proxy work introduced env vars that were
never added to sample.env. Add them and list the `didcomm` compose profile:

- ARCHON_DIDCOMM_PORT (4236), ARCHON_DIDCOMM_HOST_BIND (127.0.0.1),
  ARCHON_DIDCOMM_DB (memory|redis), and commented ARCHON_DIDCOMM_UPLOAD_LIMIT /
  ARCHON_DIDCOMM_MESSAGE_TTL_MS / ARCHON_REDIS_URL.
- ARCHON_DIDCOMM_URL (Drawbridge -> relay proxy) documented in the Drawbridge block.
- Added `didcomm` to the available-profiles comment.

Also corrected the didcomm compose header comment (the redis backend exists; it
no longer says "no redis dependency yet").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document the new DIDComm relay (mailbox) service under docs/services/, in the
same language-agnostic spec style as the other services:

- docs/services/didcomm/README.md — responsibilities, HTTP API (/health,
  /messages, /challenge, /messages/fetch|remove), signed-challenge auth,
  mailbox routing by JWE recipient kids, the MailboxStore contract +
  memory/redis backends (incl. the redis key schema), lifecycle + env vars,
  deployment (didcomm profile, Drawbridge /didcomm proxy, Tor/NAT pull model),
  and conformance requirements.
- docs/services/README.md — new "Messaging" section linking the relay spec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
publish-didcomm previously required an explicit endpoint, unlike publish-lightning
which learns its public host automatically. Close that gap.

Drawbridge owns the public /didcomm proxy mount, so it's the right place to
advertise the endpoint:
- Wire up the (previously unused) ARCHON_DRAWBRIDGE_PUBLIC_HOST in Drawbridge
  config and add a public GET /api/v1/didcomm-endpoint returning
  `<publicHost>/didcomm` (or null), bypassing L402.
- DrawbridgeClient.getDidCommEndpoint() (JS) + GatekeeperClient.get_didcomm_endpoint()
  (Python) query it; added to DrawbridgeInterface.
- publishDidComm / publish_didcomm: when no endpoint is given, auto-discover via
  the gateway (mirrors publishLightning's publicHost fallback). A plain
  Gatekeeper exposes nothing, so it falls back to today's key-agreement-only
  publish; an explicit endpoint still overrides (standalone relay, other proxy,
  onion, etc.).
- CLI help (all three) + sample.env + the relay service spec updated.

Tests: JS keymaster test stubs a gateway endpoint and asserts publishDidComm()
writes the auto-discovered DIDCommMessaging service (15 didcomm tests green);
Python didcomm suite (16) green; root build + drawbridge typecheck + lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirror publishLightning fully: when ARCHON_DRAWBRIDGE_PUBLIC_HOST is unset,
GET /api/v1/didcomm-endpoint now falls back to the Tor onion fronting Drawbridge
— `http://<onion>:<drawbridgePort>/didcomm` — read from the shared hidden-service
hostname file (the same /data/tor/hostname the lightning-mediator uses). So a
Tor-only node gets an onion DIDComm endpoint automatically, just like its
Lightning endpoint.

- config.ts: add torHostnameFile (default /data/tor/hostname).
- drawbridge-api.ts: resolveDidCommEndpoint() prefers the explicit public host,
  else reads the onion; cached on first success (null until the hostname file
  exists, so it retries).
- drawbridge.yml: mount the shared tor-hostname volume read-only at /data/tor.
- Docs/sample.env note the onion fallback.

Drawbridge typecheck + lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@macterra macterra changed the title feat: DIDComm Messaging v2 (Phases 0–7): envelopes, transport, protocols, CLI/SDK/MCP parity feat: DIDComm Messaging v2 (Phases 0–7): TS + Python parity, transport, protocols, auto-discovery Jun 23, 2026
@macterra
macterra merged commit f53feef into main Jun 24, 2026
28 checks passed
@macterra
macterra deleted the docs/didcomm-design branch June 24, 2026 02:22
macterra added a commit that referenced this pull request Aug 22, 2026
…too (#921)

The Python service registered none of the DIDComm routes -- `grep -c
didcomm app.py` was 0 -- so all eleven SDK didcomm methods fell through
to the catch-all 404 while working fine against the JS service. The
`keymaster` library has implemented the operations since #633; only this
routing layer was missing, and nothing checked.

Closes #920.

Two neighbours were missing for the same reason, so this closes them at
the same time: GET /capabilities and POST|DELETE /addresses/publish. That
brings the two route surfaces to exact parity in both directions, which
means the new guard needs no allowlist to rot.

The guard compares the JS routers against app.py -- paths, methods, and
public-vs-protected, read from the mount order around createRequireAdminKey
rather than a hardcoded list. It resolves each createXRouter to its file by
looking for the export, not by mangling the name into a filename: the first
draft guessed keymaster-did-comm-router.ts, silently dropped the router it
was written to check, and passed. Mutation-tested by renaming a route.

That guard also found a live bug: POST /polls/ballot/send was declared
after POST /polls/{poll}/send, and FastAPI matches in declaration order, so
a ballot send arrived as send_poll(poll="ballot"). Verified against
unmodified main by resolving the route through the real app. Both it and
the new /addresses/publish now sit ahead of the templates that would
swallow them, with a test for that ordering class.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants