Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 36 additions & 10 deletions packages/core/src/did/peer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}

Expand All @@ -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[];
}

/**
Expand All @@ -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<string, unknown> = {
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}`;
Expand Down
22 changes: 21 additions & 1 deletion packages/core/src/store/holder-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
92 changes: 92 additions & 0 deletions packages/core/tests/did.peer-services.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
Loading