From 83bdc64d6711c9aa9eb41e52c9895a9f3401ef65 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sat, 29 Aug 2026 20:35:36 +0800 Subject: [PATCH] feat(did): advertise both receive transports on the holder DID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transport negotiation runs in one direction only. The wallet reads a VTA's published services and picks TSP > DIDComm > REST, degrading on an explicit `unsupported`. An executor pushing *to* the wallet has nothing to read: the holder's `did:peer:2` carried exactly one service, DIDComm-typed by convention, so there is no signal that this wallet accepts TSP inbound and no basis on which to prefer it. That gap is what makes a TSP push unsafe rather than merely unimplemented. Hop acceptance is not delivery: a TSP frame pushed to a wallet that cannot route it is accepted and stored by the mediator and then never handled — for a `task-consent` request, a gated action that never got its human check (R7.2). A deployment flag cannot close that, because the thing it would be asserting is a per-peer property. So the holder now publishes what it can receive: a `DIDCommMessaging` service and a `TSPTransport` service, both pointing at its mediator — one deployment, demultiplexed on the TSP magic byte, advertised under two types because they are two things a sender must choose between. `createDidPeer2` takes `services` rather than `service`, emitting one `.S` element each. Both forms (multiple elements, or one element carrying an array) are spec-legal; the multiple-element form is what every resolver in this ecosystem indexes and numbers, and there is nothing to gain from the other. **The TSP type is spelled out, and that is not cosmetic.** The abbreviation table is not shared: `affinidi-did-common`'s peer resolver expands `"tsp"` to `TSPTransport`, while `vti-didcomm-js`'s expands only `"dm"` and passes everything else through verbatim. Publishing `"tsp"` would therefore resolve to two different service types depending on which side read the DID — the VTA seeing a TSP service where the wallet saw none. `"TSPTransport"` is preserved verbatim by both. A test pins the divergence so that if the JS resolver ever learns the abbreviation, it fails and says so rather than leaving a stale comment behind. `accept` is emitted only for a DIDComm service. `didcomm/v2` is a DIDComm media type, and asserting it on a TSP endpoint would advertise something untrue about what that endpoint speaks. **This changes the DID of a freshly-minted holder**, because did:peer:2 encodes services into the identifier. It does not migrate anything: `ensureHolder` returns a persisted holder unchanged, so an existing wallet keeps its DID, its ACL row, and its DIDComm inbox, and simply does not advertise TSP until it is re-onboarded. The capability arrives with new holders and by choice for old ones, rather than as a forced re-enrolment. Nothing reads the new service yet. The VTA-side negotiated push is the next change, and this is the half that has to land first — a peer cannot negotiate against a capability that has not been published. Signed-off-by: Glenn Gore --- packages/core/src/did/peer.ts | 46 ++++++++--- packages/core/src/store/holder-identity.ts | 22 +++++- packages/core/tests/did.peer-services.mjs | 92 ++++++++++++++++++++++ 3 files changed, 149 insertions(+), 11 deletions(-) create mode 100644 packages/core/tests/did.peer-services.mjs diff --git a/packages/core/src/did/peer.ts b/packages/core/src/did/peer.ts index 164a1d1..6a2ffe6 100644 --- a/packages/core/src/did/peer.ts +++ b/packages/core/src/did/peer.ts @@ -23,14 +23,24 @@ const ED25519_PUB = multibase.MULTICODEC.ED25519_PUB; * abbreviation (`t`/`s`/`r`/`a`) is the did:peer:2 convention the resolver * decodes back to a `DIDCommMessaging` service. */ export interface DidPeerService { - /** Service type. `"dm"` abbreviates `DIDCommMessaging` (the default). */ + /** Service type. `"dm"` abbreviates `DIDCommMessaging` (the default). + * + * **Spell a non-DIDComm type out in full.** The abbreviation table is not + * shared: `affinidi-did-common`'s peer resolver expands `"tsp"` to + * `TSPTransport`, `vti-didcomm-js`'s expands only `"dm"` and passes + * everything else through verbatim. So `"tsp"` resolves to two different + * service types depending on which side reads the DID — while + * `"TSPTransport"` is preserved verbatim by both and means the same thing + * everywhere. */ type?: string; /** serviceEndpoint URI — for mediator-routed delivery this is the * mediator's DID. */ serviceEndpoint: string; /** Optional routing keys. */ routingKeys?: string[]; - /** Accepted profiles (default `["didcomm/v2"]`). */ + /** Accepted profiles. Defaults to `["didcomm/v2"]` for a DIDComm service and + * is omitted otherwise — the media types are DIDComm's, and asserting them + * on a TSP endpoint would advertise something untrue. */ accept?: string[]; } @@ -48,8 +58,16 @@ export interface CreateDidPeer2Args { ed25519PublicKey: Uint8Array; /** X25519 public key (keyAgreement / authcrypt). */ x25519PublicKey: Uint8Array; - /** Optional DIDComm service to advertise (e.g. the wallet's mediator). */ - service?: DidPeerService; + /** Services to advertise, in order. Each becomes one `.S` element, and the + * resolved ids follow the did:peer:2 numbering (`#service`, `#service-1`, + * …) — which both this ecosystem's resolvers agree on. + * + * More than one is how a peer says what it can *receive*. A wallet that + * publishes only a DIDComm service is one an executor has no way to know + * accepts TSP, so it will never be sent any: the negotiation the wallet + * performs against a VTA's published services has no counterpart in the + * other direction unless the wallet publishes too. */ + services?: DidPeerService[]; } /** @@ -65,15 +83,23 @@ export function createDidPeer2(args: CreateDidPeer2Args): DidPeer2 { let did = `did:peer:2.E${kaMultibase}.V${authMultibase}`; - if (args.service) { - const s = args.service; - // Abbreviated DIDComm service; key insertion order t,s,r,a matches the - // did:peer:2 convention. `r` omitted when there are no routing keys. + for (const s of args.services ?? []) { + const type = s.type ?? "dm"; + // One `.S` element per service rather than a single element carrying an + // array. Both are spec-legal, but the multiple-element form is what every + // resolver in this ecosystem indexes and numbers; the array form is the + // less-travelled path and there is nothing to gain by taking it. + // + // Key insertion order t,s,r,a matches the did:peer:2 convention. `r` is + // omitted when there are no routing keys, and `a` when the service is not + // DIDComm — see `accept` above. + const isDidcomm = type === "dm" || type === "DIDCommMessaging"; + const accept = s.accept ?? (isDidcomm ? ["didcomm/v2"] : undefined); const abbreviated: Record = { - t: s.type ?? "dm", + t: type, s: s.serviceEndpoint, ...(s.routingKeys && s.routingKeys.length > 0 ? { r: s.routingKeys } : {}), - a: s.accept ?? ["didcomm/v2"], + ...(accept ? { a: accept } : {}), }; const encoded = base64url.encode(new TextEncoder().encode(JSON.stringify(abbreviated))); did += `.S${encoded}`; diff --git a/packages/core/src/store/holder-identity.ts b/packages/core/src/store/holder-identity.ts index 63aed36..4c8aa8a 100644 --- a/packages/core/src/store/holder-identity.ts +++ b/packages/core/src/store/holder-identity.ts @@ -179,7 +179,27 @@ export async function generateOrLoadHolderIdentity( const peer = createDidPeer2({ ed25519PublicKey: edPublic, x25519PublicKey: x25519Public, - ...(opts?.mediatorDid ? { service: { serviceEndpoint: opts.mediatorDid } } : {}), + // Both transports the wallet can *receive* on, advertised so an executor + // can negotiate against them the same way the wallet negotiates against a + // VTA's published services. One mediator carries both: it demultiplexes on + // the TSP magic byte, so the endpoint is the same DID twice under two + // types, not two deployments. + // + // Publishing this is what makes a push transport-agnostic. Without it an + // executor has no signal that this wallet handles TSP inbound, and hop + // acceptance is not delivery — a TSP push to a wallet that cannot route it + // is stored by the mediator and silently never handled, which for a + // consent request is a gated action that never got its human check (R7.2). + ...(opts?.mediatorDid + ? { + services: [ + { serviceEndpoint: opts.mediatorDid }, + // Spelled out, not `"tsp"`: the two resolvers in this ecosystem do + // not share an abbreviation table. See `DidPeerService.type`. + { type: "TSPTransport", serviceEndpoint: opts.mediatorDid }, + ], + } + : {}), }); const wrapped = await wrapSecret(edSecret, opts?.secretWrap); diff --git a/packages/core/tests/did.peer-services.mjs b/packages/core/tests/did.peer-services.mjs new file mode 100644 index 0000000..6806252 --- /dev/null +++ b/packages/core/tests/did.peer-services.mjs @@ -0,0 +1,92 @@ +// What the wallet publishes about itself, and why more than one entry. +// +// A wallet negotiates against a VTA's published services (TSP > DIDComm > +// REST). Nothing negotiates in the other direction unless the wallet publishes +// too — an executor has no signal that this holder accepts TSP inbound, so it +// will never send any. These tests pin the encoding, and pin it against the +// resolver that actually reads it rather than against my own decoder. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { createDidPeer2 } from "../dist/did/index.js"; +import { resolve as resolveDidPeer } from "@openvtc/vti-didcomm-js/did-peer"; +import { ed25519, x25519 } from "@noble/curves/ed25519.js"; + +const MEDIATOR = "did:webvh:QmMediator:example.test:mediator"; + +function keys() { + const ed = ed25519.utils.randomSecretKey(); + return { + ed25519PublicKey: ed25519.getPublicKey(ed), + x25519PublicKey: x25519.getPublicKey(ed25519.utils.toMontgomerySecret(ed)), + }; +} + +test("a holder advertising both transports resolves to two typed services", () => { + const { did } = createDidPeer2({ + ...keys(), + services: [ + { serviceEndpoint: MEDIATOR }, + { type: "TSPTransport", serviceEndpoint: MEDIATOR }, + ], + }); + + // One `.S` element per service — the form every resolver here indexes. + assert.equal(did.match(/\.S/g)?.length, 2); + + const doc = resolveDidPeer(did); + const services = doc.didDocument.service; + assert.equal(services.length, 2); + + // `dm` expands; the ids follow the did:peer:2 numbering both resolvers use. + assert.equal(services[0].type, "DIDCommMessaging"); + assert.equal(services[0].id, `${did}#service`); + assert.equal(services[0].serviceEndpoint, MEDIATOR); + + assert.equal(services[1].type, "TSPTransport"); + assert.equal(services[1].id, `${did}#service-1`); + assert.equal(services[1].serviceEndpoint, MEDIATOR); +}); + +test("the TSP entry is spelled out, because the abbreviation is not portable", () => { + // `affinidi-did-common` expands `"tsp"` to `TSPTransport`; `vti-didcomm-js` + // expands only `"dm"` and passes the rest through. So `"tsp"` resolves to two + // different service types depending on which side reads the DID — a drift + // that would show up as "the VTA sees a TSP service and the wallet does not". + // + // This asserts the trap is real, so that if the JS side ever learns the + // abbreviation this test fails and the comment above stops being true. + const { did } = createDidPeer2({ + ...keys(), + services: [{ type: "tsp", serviceEndpoint: MEDIATOR }], + }); + const svc = resolveDidPeer(did).didDocument.service[0]; + assert.equal( + svc.type, + "tsp", + "if this now reads TSPTransport, the JS resolver learned the abbreviation " + + "and `tsp` became safe to publish", + ); +}); + +test("no services means no `.S` element at all", () => { + const { did } = createDidPeer2(keys()); + assert.ok(!did.includes(".S")); + assert.equal(resolveDidPeer(did).didDocument.service, undefined); +}); + +test("a DIDComm service carries `accept`; a TSP one does not", () => { + const { did } = createDidPeer2({ + ...keys(), + services: [ + { serviceEndpoint: MEDIATOR }, + { type: "TSPTransport", serviceEndpoint: MEDIATOR }, + ], + }); + const [didcomm, tsp] = resolveDidPeer(did).didDocument.service; + // `didcomm/v2` is a DIDComm media type. Asserting it on a TSP endpoint would + // advertise something untrue about what that endpoint speaks. + assert.deepEqual(didcomm.accept, ["didcomm/v2"]); + assert.equal(tsp.accept, undefined); +});