From de9c23706be9143bd4f115aefa0aff207a55f745 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Wed, 21 Oct 2026 11:42:40 -0700 Subject: [PATCH 1/2] feat: did:scid contacts and DIDs, used natively under the bare DID Signed-off-by: Glenn Gore --- packages/core/CHANGELOG.md | 22 +++++ packages/core/src/did/derive-signing-key.ts | 7 +- packages/core/src/did/egress-guard.ts | 20 ++++ packages/core/src/did/methods.ts | 55 ++++++++--- packages/core/src/did/verify.ts | 33 ++++++- packages/core/src/links/trigger-link.ts | 82 ++++++++++++++-- packages/core/src/trust-tasks/verify.ts | 21 ++-- packages/core/src/vtc/oob-sign-in.ts | 6 +- packages/core/tests/did.contact-methods.mjs | 93 ++++++++++++++++-- packages/core/tests/did.scid.mjs | 96 +++++++++++++++++++ packages/core/tests/links.trigger-link.mjs | 5 +- packages/extension/src/bridge-protocol.ts | 2 +- packages/extension/src/confirm.tsx | 2 +- packages/extension/src/did-display.ts | 21 ++++ .../src/manager/mediator-lens-model.ts | 11 +++ packages/extension/src/mediator-fallback.ts | 17 +++- packages/extension/src/sign-in-flows.ts | 12 ++- packages/extension/tests/did-display.test.mts | 25 +++++ .../tests/mediator-fallback.test.mts | 6 ++ .../extension/tests/mediator-lens.test.mts | 1 + 20 files changed, 487 insertions(+), 50 deletions(-) create mode 100644 packages/core/tests/did.scid.mjs diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index d8c801cc..7f970e7c 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -10,6 +10,28 @@ For history before this file, see `git log` on `packages/core`. ### Added +- **did:scid contacts and DIDs, used natively.** A trigger link's `_from` may + be a `did:scid:vh:1:?src=[/]` DID URL: it is host-named + (route A, VTI-LNK-119), shown as its `src` host and path (VTI-LNK-050), + resolved through that DID URL with the `src` host guarded like a did:webvh + host (`assertResolvableScidHost`), and recorded and addressed as the bare + `did:scid:vh:1:` (`contactIdentity`, `didIdentity`). A bare did:scid + `_from` names no host and is `from-not-allowed`; another did:scid format is + `unsupported-vid`. `verifyDid` and `deriveSigningKeyId` take did:scid + (`DidMethod` gains `"scid"`, `didScidDomain`). Needs `@openvtc/vti-didcomm-js` + with did:scid support. +- `SCID_CONTACT_POLICY`, `SCID_CONTACT_METHOD`; a contact policy may say + `supports(did)` (false is `unsupported-vid`), and a host-named contact whose + `hostPath` names no host has no route. + +### Changed + +- `verifyTrustTaskProof` attributes a proof to its verification method's DID + without a did:scid `src` (`signer` is the identity) and matches the key and + its relationship by DID and fragment, ignoring that `src`. A community's + reply signed under `did:scid:…?src=…#key` is checked against the community's + identity the same way. + - **`@openvtc/pnm-core/rooms/files`** — room files, for a wallet's offscreen document (it names `rooms/keys/file-key`, so a console must not import it). The host's blob tasks (`rooms/blobs/*`, `rooms/info`, `rooms/records/{get,put}/0.2`, diff --git a/packages/core/src/did/derive-signing-key.ts b/packages/core/src/did/derive-signing-key.ts index 52fdde73..1850b68d 100644 --- a/packages/core/src/did/derive-signing-key.ts +++ b/packages/core/src/did/derive-signing-key.ts @@ -28,9 +28,9 @@ // path; this file is a parallel surface focused on extracting the // VM id rather than just validating resolution. -import { resolve as vtiResolve } from "@openvtc/vti-didcomm-js"; +import { resolve as vtiResolve, scidSrc } from "@openvtc/vti-didcomm-js"; -import { assertResolvableWebvhHost } from "./egress-guard.js"; +import { assertResolvableScidHost, assertResolvableWebvhHost } from "./egress-guard.js"; import { didMethod, type ResolveDidOptions } from "./verify.js"; export interface DeriveSigningKeyIdResult { @@ -69,11 +69,12 @@ export async function deriveSigningKeyId( if (method === "unknown") { return { did, candidates: [], error: "Unrecognised DID method" }; } - // did:peer, did:webvh: full resolution. did:peer is offline; webvh + // did:peer, did:webvh, did:scid: full resolution. did:peer is offline; webvh // hits the network and verifies the log — at a host the DID's author // chose, so the host is judged first. try { if (method === "webvh") assertResolvableWebvhHost(did); + if (method === "scid" && scidSrc(did)) assertResolvableScidHost(did); const resolve = opts.resolveDid ?? ((d: string) => vtiResolve(d, {})); const resolution = (await resolve(did)) as { didDocument?: { diff --git a/packages/core/src/did/egress-guard.ts b/packages/core/src/did/egress-guard.ts index d669fbf0..56faeee5 100644 --- a/packages/core/src/did/egress-guard.ts +++ b/packages/core/src/did/egress-guard.ts @@ -35,6 +35,8 @@ // `net-guard.js` in `@openvtc/vti-didcomm-js`, so this module can be replaced // by that one once it is released. +import { didScid } from "@openvtc/vti-didcomm-js"; + import { didWebvhDomain } from "./verify.js"; /** Stable `code` carried by every {@link BlockedEndpointError}. Match on this, @@ -296,3 +298,21 @@ function parseIpv6(input: string): number[] | null { if (fill < 1) return null; return [...head, ...new Array(fill).fill(0), ...rest, ...tail]; } + +/** + * {@link assertResolvableWebvhHost} for a `did:scid:vh:1` DID URL: its log is + * fetched from the host its `src` names, so that host is judged the same way. + * A did:scid with no usable `src` names nothing to fetch and is refused. + * + * @throws {BlockedEndpointError} + */ +export function assertResolvableScidHost(did: string): void { + const webvh = didScid.scidToWebvhDid(did); + if (!webvh) { + throw new BlockedEndpointError( + "the did:scid names no usable source host, so it was not contacted", + "invalid_url", + ); + } + assertResolvableWebvhHost(webvh); +} diff --git a/packages/core/src/did/methods.ts b/packages/core/src/did/methods.ts index c0381e88..25b01768 100644 --- a/packages/core/src/did/methods.ts +++ b/packages/core/src/did/methods.ts @@ -9,16 +9,22 @@ // it to the parser, to `allowsContact` and to resolution, so all three ask the // same table. // -// Only `did:webvh` and `did:web` are registered, both host-named (route A). -// A domain-linked method (`did:scid`, a ledger DID) is added by registering a -// policy with `route: "domain-linked"` and a resolver; the flow then confirms -// the portal's DIF DID Configuration after the person continues -// (`vtc/domain-linkage.ts`) without any change to it. +// `did:webvh`, `did:web` and `did:scid` (a `did:scid:vh:1` DID URL with +// `src`) are registered, all host-named (route A). A domain-linked method (a +// ledger DID) is added by registering a policy with `route: "domain-linked"` +// and a resolver; the flow then confirms the portal's DIF DID Configuration +// after the person continues (`vtc/domain-linkage.ts`) without any change to +// it. +// +// A did:scid contact is a DID URL — `did:scid:vh:1:?src=…` — but its +// identity, and its document's `id`, is the bare DID (VTI-KEY-160, 161): the +// document checks below compare against the identity, not the text. -import { resolve as vtiResolve } from "@openvtc/vti-didcomm-js"; +import { resolve as vtiResolve, stripScidSrc } from "@openvtc/vti-didcomm-js"; import { BUILTIN_CONTACT_POLICIES, + SCID_CONTACT_POLICY, WEBVH_CONTACT_POLICY, WEB_CONTACT_POLICY, meetsHostRules, @@ -27,7 +33,7 @@ import { type ContactMethodTable, type ContactRoute, } from "../links/trigger-link.js"; -import { assertResolvableWebvhHost } from "./egress-guard.js"; +import { assertResolvableScidHost, assertResolvableWebvhHost } from "./egress-guard.js"; import type { ResolveDidOptions } from "./verify.js"; /** A verified DID document, or why there is none. */ @@ -40,7 +46,8 @@ export interface ContactMethod extends ContactMethodPolicy { /** * Resolve `did` and verify its document (VTI-LNK-052). Called only for a * DID of this method, and — for a host-named method — only once the host it - * names meets the host rules. Must return a document whose `id` is `did`; + * names meets the host rules. Must return a document whose `id` is `did`'s + * identity (for a did:scid DID URL, the bare DID); * may throw, which counts as `did-document-unverified`. `opts.resolveDid`, * when given, is the resolver to use (tests inject one). */ @@ -67,7 +74,7 @@ export async function resolveThroughResolver(did: string, opts: ResolveDidOption }; if (res?.didResolutionMetadata?.error) return { ok: false, reason: "did-document-unverified" }; const doc = res?.didDocument; - if (!doc || doc.id !== did) return { ok: false, reason: "did-document-unverified" }; + if (!doc || doc.id !== didIdentity(did)) return { ok: false, reason: "did-document-unverified" }; return { ok: true, doc }; } @@ -81,6 +88,24 @@ export const WEBVH_CONTACT_METHOD: ContactMethod = { }, }; +/** `did:scid:vh:1` with `src`, route A. The `src` host is judged like a + * did:webvh host before anything is fetched; the resolver verifies the + * did:webvh log it names and returns the did:scid's own document. */ +export const SCID_CONTACT_METHOD: ContactMethod = { + ...SCID_CONTACT_POLICY, + resolve: async (did, opts) => { + assertResolvableScidHost(did); + return resolveThroughResolver(did, opts); + }, +}; + +/** The identity of a DID or DID URL: a did:scid without its `src` locator + * (VTI-KEY-160); any other DID as written. Compare identities with this, + * never the text a contact or proof arrived as. */ +export function didIdentity(did: string): string { + return stripScidSrc(did); +} + /** `did:web`, route A. */ export const WEB_CONTACT_METHOD: ContactMethod = { ...WEB_CONTACT_POLICY, @@ -125,7 +150,10 @@ export class ContactMethodRegistry implements ContactMethodTable { /** The route a contact `did` qualifies by, or `undefined`. */ routeOf(did: string): ContactRoute | undefined { const m = this.methodOf(did); - return m?.route !== undefined && m.listsServices && m.verified && m.resolve ? m.route : undefined; + if (m?.route === undefined || !m.listsServices || !m.verified || !m.resolve) return undefined; + // A host-named contact that names no host (a bare did:scid) has no route. + if (m.route === "host-named" && m.hostPath && m.hostPath(did) === undefined) return undefined; + return m.route; } /** The registered kinds, in registration order. */ @@ -134,13 +162,14 @@ export class ContactMethodRegistry implements ContactMethodTable { } } -/** A fresh registry holding the built-in methods: `did:webvh` and `did:web` - * (route A, with resolvers), and the policies of `did:key` and `did:peer`, +/** A fresh registry holding the built-in methods: `did:webvh`, `did:web` and + * `did:scid` (route A, with resolvers), and the policies of `did:key` and `did:peer`, * which no route qualifies. */ export function defaultContactMethods(): ContactMethodRegistry { const resolvers: ReadonlyMap = new Map([ [WEBVH_CONTACT_METHOD.kind, WEBVH_CONTACT_METHOD], [WEB_CONTACT_METHOD.kind, WEB_CONTACT_METHOD], + [SCID_CONTACT_METHOD.kind, SCID_CONTACT_METHOD], ]); return new ContactMethodRegistry(BUILTIN_CONTACT_POLICIES.map((p) => resolvers.get(p.kind) ?? p)); } @@ -177,7 +206,7 @@ export async function resolveContactDocument(did: string, opts: ResolveContactOp } try { const res = await method.resolve(did, opts.resolveDid ? { resolveDid: opts.resolveDid } : {}); - if (!res.ok || res.doc.id !== did) return unverified; + if (!res.ok || res.doc.id !== didIdentity(did)) return unverified; return { ok: true, doc: res.doc, route }; } catch { return unverified; diff --git a/packages/core/src/did/verify.ts b/packages/core/src/did/verify.ts index 416cc50b..a8323f78 100644 --- a/packages/core/src/did/verify.ts +++ b/packages/core/src/did/verify.ts @@ -13,12 +13,17 @@ // — a successful resolve is a real attestation, not just "fetch returned 200". // did:peer / did:key are self-certifying (the keys are encoded in the id), so // resolution is purely structural validation. +// +// did:scid:vh:1 is verified by the same did:webvh log, fetched from the host +// its `src` names (`did:scid:vh:1:?src=example.com/alice`). Its identity +// is the bare DID: the resolved document's `id` is `did:scid:vh:1:`, and +// a bare DID resolves only through a source the resolver already verified. -import { resolve as vtiResolve } from "@openvtc/vti-didcomm-js"; +import { resolve as vtiResolve, scidSrc } from "@openvtc/vti-didcomm-js"; -import { assertResolvableWebvhHost } from "./egress-guard.js"; +import { assertResolvableScidHost, assertResolvableWebvhHost } from "./egress-guard.js"; -export type DidMethod = "webvh" | "peer" | "key" | "unknown"; +export type DidMethod = "webvh" | "scid" | "peer" | "key" | "unknown"; export interface VerifyDidResult { /** Canonical DID under verification. */ @@ -31,7 +36,8 @@ export interface VerifyDidResult { resolved: boolean; /** For did:webvh — the host the DID identifies (extracted from the DID, * not the resolved document, so the operator sees what the wallet asked - * for). The DID is `did:webvh::[:...]`. */ + * for). The DID is `did:webvh::[:...]`. For a did:scid + * DID URL, the host its `src` names; a bare did:scid names none. */ domain?: string; /** Human-readable error if resolution failed. */ error?: string; @@ -57,6 +63,7 @@ export interface VerifyDidResult { */ export function didMethod(did: string): DidMethod { if (did.startsWith("did:webvh:")) return "webvh"; + if (did.startsWith("did:scid:")) return "scid"; if (did.startsWith("did:peer:")) return "peer"; if (did.startsWith("did:key:")) return "key"; return "unknown"; @@ -79,6 +86,19 @@ export function didWebvhDomain(did: string): string | undefined { return parts[3]; } +/** + * The host a `did:scid` DID URL's `src` names (`example.com` for + * `?src=https://example.com/alice/`), as written — a port stays on it, as it + * does on a did:webvh host. `undefined` for a bare did:scid or a non-scid DID. + */ +export function didScidDomain(did: string): string | undefined { + if (!did.startsWith("did:scid:")) return undefined; + const src = scidSrc(did); + if (!src) return undefined; + const host = src.replace(/^https?:\/\//, "").split("/")[0]; + return host || undefined; +} + /** Options shared by the functions here that resolve a DID. */ export interface ResolveDidOptions { /** Resolve a DID to its resolution result. Defaults to the @@ -102,7 +122,7 @@ export async function verifyDid( opts: ResolveDidOptions = {}, ): Promise { const method = didMethod(did); - const domain = method === "webvh" ? didWebvhDomain(did) : undefined; + const domain = method === "webvh" ? didWebvhDomain(did) : method === "scid" ? didScidDomain(did) : undefined; const base: VerifyDidResult = { did, method, @@ -114,6 +134,9 @@ export async function verifyDid( } try { if (method === "webvh") assertResolvableWebvhHost(did); + // A bare did:scid fetches from a source the resolver vetted when it + // learned it; a DID URL names its host here. + if (method === "scid" && scidSrc(did)) assertResolvableScidHost(did); const resolution = (await (opts.resolveDid ?? defaultResolveDid)(did)) as { didDocument?: { id?: string; alsoKnownAs?: unknown }; didResolutionMetadata?: { error?: string }; diff --git a/packages/core/src/links/trigger-link.ts b/packages/core/src/links/trigger-link.ts index 40b7746f..b591ea97 100644 --- a/packages/core/src/links/trigger-link.ts +++ b/packages/core/src/links/trigger-link.ts @@ -141,7 +141,10 @@ export const FLOW_REGISTRY_BASE = "https://link.trustoverip.org/vti/flow/"; // `SignInPortal`: // // - **host-named** (route A): the DID names the host that serves its -// document (`did:webvh`, `did:web`). Nothing more is checked. +// document (`did:webvh`, `did:web`, and a `did:scid:vh:1` DID URL whose +// `src` names it). Nothing more is checked. A contact of a host-named +// method that names no host — a bare `did:scid` — has no route and is +// `from-not-allowed`. // - **domain-linked** (route B): any other method. After the person // continues and before the claim, the reader confirms the portal origin is // linked to the DID by the DIF Well-Known DID Configuration @@ -173,8 +176,12 @@ export interface ContactMethodPolicy { verified: boolean; /** Method-specific syntax, beyond DID syntax. `false` is `bad-from`. */ wellFormed?: (did: string) => boolean; + /** Whether this reader can use this particular DID of the method (a + * `did:scid` format it resolves). `false` is `unsupported-vid`. */ + supports?: (did: string) => boolean; /** For a host-named method: the host serving the document, then the path, - * each percent-decoded once (VTI-LNK-050). */ + * each percent-decoded once (VTI-LNK-050). `undefined` for a contact that + * names no host, which no route can then qualify. */ hostPath?: (did: string) => string[] | undefined; } @@ -216,6 +223,54 @@ export const WEB_CONTACT_POLICY: ContactMethodPolicy = { hostPath: (did) => decodeSegments(did.split(":").slice(2)), }; +/** + * The `src` of a `did:scid` DID URL whose only query parameter is `src` + * (`did:scid:vh:1:?src=dids.example.org/farm-auth`), or `undefined`. + * Mirrors `scidSrc` in `@openvtc/vti-didcomm-js`, inlined because this module + * imports nothing. + */ +function scidSrcOf(did: string): string | undefined { + if (!did.startsWith("did:scid:")) return undefined; + const q = did.indexOf("?"); + if (q < 0) return undefined; + const query = did.slice(q + 1).split("#")[0]!; + if (!query.startsWith("src=")) return undefined; + const src = query.slice(4); + return src && !src.includes("&") ? src : undefined; +} + +/** + * The identity a contact names: for a `did:scid` DID URL, the bare + * `did:scid:vh:1:` without its `src` locator (VTI-KEY-160); anything + * else as written. The flow records and addresses the contact by this, and + * resolves it through {@link TriggerLinkContact.value}. + */ +export function contactIdentity(contact: TriggerLinkContact): string { + return scidSrcOf(contact.value) === undefined ? contact.value : contact.value.slice(0, contact.value.indexOf("?")); +} + +/** + * `did:scid:vh:1:?src=[/]`. Route A: the `src` names the host + * the did:webvh log is fetched from (VTI-LNK-119). A bare `did:scid`, or one + * with no `src`, names no host, so no route qualifies it: `from-not-allowed`. + * Only the `vh:1` format is supported; another is `unsupported-vid`. + */ +export const SCID_CONTACT_POLICY: ContactMethodPolicy = { + kind: "did:scid", + route: "host-named", + listsServices: true, + verified: true, + wellFormed: (did) => /^did:scid:[a-z0-9]+:[0-9]+:[A-Za-z0-9._-]+(?:\?src=[^?#&]+)?$/.test(did), + supports: (did) => did.startsWith("did:scid:vh:1:"), + hostPath: (did) => { + const src = scidSrcOf(did); + if (src === undefined) return undefined; + const stripped = src.replace(/^https?:\/\//, "").replace(/\/+$/, ""); + const segments = stripped.split("/"); + return segments.some((seg) => seg === "") ? undefined : decodeSegments(segments); + }, +}; + /** Self-certifying, and named after no host: no domain to show, and its * document lists no services. No route. */ export const KEY_CONTACT_POLICY: ContactMethodPolicy = { @@ -234,11 +289,12 @@ export const PEER_CONTACT_POLICY: ContactMethodPolicy = { }; /** The contact methods this reader classifies before any registry is - * consulted: route A for `did:webvh` and `did:web`, and the two methods it - * knows can never qualify. */ + * consulted: route A for `did:webvh`, `did:web` and `did:scid` (with `src`), + * and the two methods it knows can never qualify. */ export const BUILTIN_CONTACT_POLICIES: readonly ContactMethodPolicy[] = [ WEBVH_CONTACT_POLICY, WEB_CONTACT_POLICY, + SCID_CONTACT_POLICY, KEY_CONTACT_POLICY, PEER_CONTACT_POLICY, ]; @@ -283,7 +339,10 @@ export function contactRoute( methods: ContactMethodTable = DEFAULT_CONTACT_METHODS, ): ContactRoute | undefined { const p = methods.policyFor(contact.kind); - return p?.route !== undefined && p.listsServices && p.verified ? p.route : undefined; + if (p?.route === undefined || !p.listsServices || !p.verified) return undefined; + // A host-named contact that names no host (a bare did:scid) has no route. + if (p.route === "host-named" && p.hostPath && p.hostPath(contact.value) === undefined) return undefined; + return p.route; } /** @@ -591,6 +650,16 @@ function parseEpochSeconds(value: string): number | undefined { /** DID syntax (DID Core §3.1), DID only — no path, query or fragment. */ const DID_SYNTAX = /^did:[a-z0-9]+:(?:(?:[A-Za-z0-9._-]|%[0-9A-Fa-f]{2})*:)*(?:[A-Za-z0-9._-]|%[0-9A-Fa-f]{2})+$/; +/** A `did:scid` contact may be a DID URL whose only query is `src` + * (VTI-KEY-163): the DID before it must be a DID, and `src` a host and path. */ +const SCID_SRC_SYNTAX = /^[A-Za-z0-9._~:%/-]+$/; + +function isContactDidSyntax(value: string): boolean { + const src = scidSrcOf(value); + if (src === undefined) return DID_SYNTAX.test(value); + return DID_SYNTAX.test(value.slice(0, value.indexOf("?"))) && !value.includes("#") && SCID_SRC_SYNTAX.test(src); +} + /** * VTI-LNK-020 step 6, VTI-LNK-030 to 032. Empty is `bad-from`. A value * containing `/@` is an agent name, reserved and refused as `unsupported-vid` @@ -609,13 +678,14 @@ function classifyContact( if (value.length === 0) return "bad-from"; if (value.includes("/@")) return "unsupported-vid"; if (value.startsWith("did:")) { - if (!DID_SYNTAX.test(value)) return "bad-from"; + if (!isContactDidSyntax(value)) return "bad-from"; const kind: ContactKind = `did:${value.slice(4, value.indexOf(":", 4))}`; const policy = methods.policyFor(kind); // A method the reader has no policy for, or one with a route but no // resolver, is one it cannot use (VTI-LNK-031). if (!policy || (policy.route !== undefined && !policy.verified)) return "unsupported-vid"; if (policy.wellFormed && !policy.wellFormed(value)) return "bad-from"; + if (policy.supports && !policy.supports(value)) return "unsupported-vid"; return { kind, value }; } return "unsupported-vid"; diff --git a/packages/core/src/trust-tasks/verify.ts b/packages/core/src/trust-tasks/verify.ts index 70817f97..5f8c5234 100644 --- a/packages/core/src/trust-tasks/verify.ts +++ b/packages/core/src/trust-tasks/verify.ts @@ -11,7 +11,7 @@ // resolves to. import { ed25519 } from "@noble/curves/ed25519.js"; -import { didKey, multibase } from "@openvtc/vti-didcomm-js"; +import { didKey, multibase, sameReference, stripScidSrc } from "@openvtc/vti-didcomm-js"; import { resolveDidDocument } from "../didcomm/index.js"; import { base58btcDecode, jcsCanonicalize, sha256 } from "./canonical.js"; @@ -79,14 +79,18 @@ export async function verifyTrustTaskProof( return { verified: false, reason: "missing/invalid proofValue (expected multibase 'z')" }; } + // Resolved through the DID URL the proof names — for a did:scid, with the + // `src` a verifier that has not seen it needs — and attributed to its + // identity, the DID without that `src` (VTI-KEY-163). const controller = vm.slice(0, vm.indexOf("#")); + const signer = stripScidSrc(controller); let publicKey: Uint8Array; let didDocument: Record; try { ({ key: publicKey, doc: didDocument } = await resolveEd25519Key(vm, controller, opts.resolveDid)); } catch (e) { - return { verified: false, signer: controller, reason: `verificationMethod resolution failed: ${e instanceof Error ? e.message : String(e)}` }; + return { verified: false, signer, reason: `verificationMethod resolution failed: ${e instanceof Error ? e.message : String(e)}` }; } // The resolver finds a key under any relationship, so the relationship the // proof claims is checked here: the VTA, the VTC and the did-hosting RP check @@ -94,7 +98,7 @@ export async function verifyTrustTaskProof( if (opts.expectedProofPurpose && !listsMethodUnder(didDocument, vm, opts.expectedProofPurpose)) { return { verified: false, - signer: controller, + signer, reason: `${vm} is not listed under ${opts.expectedProofPurpose} in the DID document of ${controller}`, }; } @@ -114,13 +118,13 @@ export async function verifyTrustTaskProof( const sig = base58btcDecode(proof.proofValue.slice(1)); if (sig.length !== 64) { - return { verified: false, signer: controller, reason: `unexpected signature length: ${sig.length}` }; + return { verified: false, signer, reason: `unexpected signature length: ${sig.length}` }; } const verified = ed25519.verify(sig, toVerify, publicKey); return verified - ? { verified: true, signer: controller } - : { verified: false, signer: controller, reason: "signature verification failed" }; + ? { verified: true, signer } + : { verified: false, signer, reason: "signature verification failed" }; } /** @@ -135,7 +139,7 @@ export function listsMethodUnder( const hash = vm.indexOf("#"); const fragment = hash >= 0 ? vm.slice(hash) : undefined; const names = (id: unknown): boolean => - typeof id === "string" && (id === vm || (fragment !== undefined && id === fragment)); + typeof id === "string" && (sameReference(id, vm) || (fragment !== undefined && id === fragment)); const entries = doc[relationship]; if (!Array.isArray(entries)) return false; return entries.some((entry) => @@ -157,7 +161,8 @@ async function resolveEd25519Key( : await (resolveDid ?? resolveDidDocument)(controller); const methods = (doc.verificationMethod as { id: string; publicKeyMultibase?: string }[] | undefined) ?? []; - const method = methods.find((m) => m.id === vm) ?? methods.find((m) => m.id.endsWith(vm.slice(vm.indexOf("#")))); + const method = + methods.find((m) => sameReference(m.id, vm)) ?? methods.find((m) => m.id.endsWith(vm.slice(vm.indexOf("#")))); if (!method?.publicKeyMultibase) { throw new Error(`verificationMethod ${vm} not found or missing publicKeyMultibase`); } diff --git a/packages/core/src/vtc/oob-sign-in.ts b/packages/core/src/vtc/oob-sign-in.ts index 7af1f6b9..bbf04f72 100644 --- a/packages/core/src/vtc/oob-sign-in.ts +++ b/packages/core/src/vtc/oob-sign-in.ts @@ -20,6 +20,8 @@ // local copy of base design §10 plus contract C5, because the // `dtgwg-trust-tasks-tf` bindings for `auth/oob/*` are not published yet. +import { sameReference, stripScidSrc } from "@openvtc/vti-didcomm-js"; + import { resolveContactDocument, type ContactMethodRegistry } from "../did/methods.js"; import { generateSigningIdentity, type SigningIdentity } from "../siop/self-issued.js"; import { signTrustTask } from "../trust-tasks/sign.js"; @@ -625,7 +627,7 @@ export async function assertSignedByCommunity( const result = await verifyTrustTaskProof(reply as unknown as Record, { expectedProofPurpose: purpose, resolveDid: async (did) => { - if (did !== vtcDid) throw new Error(`reply signed by ${did}, not the community`); + if (stripScidSrc(did) !== vtcDid) throw new Error(`reply signed by ${did}, not the community`); return vtcDocument; }, }); @@ -638,7 +640,7 @@ export async function assertSignedByCommunity( const fragment = vm.slice(vm.indexOf("#")); const ok = listed.some((m) => { const id = typeof m === "string" ? m : (m as { id?: unknown })?.id; - return typeof id === "string" && (id === vm || id === fragment); + return typeof id === "string" && (sameReference(id, vm) || id === fragment); }); if (!ok) throw new OobReplyInvalidError(`${label}: the signing key is not one of the community's ${purpose} keys`); } diff --git a/packages/core/tests/did.contact-methods.mjs b/packages/core/tests/did.contact-methods.mjs index 8e71a68f..85a17e32 100644 --- a/packages/core/tests/did.contact-methods.mjs +++ b/packages/core/tests/did.contact-methods.mjs @@ -1,6 +1,7 @@ // The contact-method registry (VTI-LNK-102 / 112, dtgwg-vti-spec #58). // -// Pinned: only did:webvh and did:web are registered, both host-named; a +// Pinned: did:webvh, did:web and did:scid (with `src`) are registered, all +// host-named; a bare did:scid names no host and has no route; a // method with no registered resolver is unsupported-vid; did:key and did:peer // stay from-not-allowed; and a domain-linked method is added by registering it // and nothing else — the parser, the contact rule and resolution all follow. @@ -10,6 +11,8 @@ import assert from "node:assert/strict"; import { CONTACT_METHODS, ContactMethodRegistry, + WEBVH_CONTACT_METHOD, + didIdentity, defaultContactMethods, resolveContactDocument, contactKindOf, @@ -18,6 +21,7 @@ import { parseTriggerLink, contactRoute, contactDomainLabel, + contactIdentity, isHostNamedServiceContact, isServiceContact, SIGN_IN_FLOW, @@ -25,6 +29,7 @@ import { import { resolveCommunityDocument } from "../dist/vtc/index.js"; const WEBVH = "did:webvh:QmPEQVM1JPTyrvEgBcDXwjK4TeyLGSX1PxjgyeAisPviUx:members.example.org"; +const SCID = "did:scid:vh:1:QmXa7Rk2ZpLwT9vNc4HbYe1Jd8sMfU3qGo6PtVnEyKiBhW"; const ID = "Hk2pQ9xV4mT7rW1sZ8yN3A"; const EXP = 1791460920; const link = (from) => @@ -43,13 +48,15 @@ const testMethod = (resolved = []) => ({ }, }); -test("registry: only did:webvh and did:web have a route, both host-named", () => { - assert.deepEqual(CONTACT_METHODS.kinds(), ["did:webvh", "did:web", "did:key", "did:peer"]); +test("registry: did:webvh, did:web and did:scid have a route, all host-named", () => { + assert.deepEqual(CONTACT_METHODS.kinds(), ["did:webvh", "did:web", "did:scid", "did:key", "did:peer"]); + assert.equal(CONTACT_METHODS.routeOf(`${SCID}?src=members.example.org`), "host-named"); + assert.equal(CONTACT_METHODS.routeOf(SCID), undefined, "a bare did:scid names no host"); assert.equal(CONTACT_METHODS.routeOf(WEBVH), "host-named"); assert.equal(CONTACT_METHODS.routeOf("did:web:members.example.org"), "host-named"); assert.equal(CONTACT_METHODS.routeOf("did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"), undefined); assert.equal(CONTACT_METHODS.routeOf("did:peer:2.Ez6L"), undefined); - assert.equal(CONTACT_METHODS.routeOf("did:scid:abc"), undefined); + assert.equal(CONTACT_METHODS.routeOf("did:example:abc"), undefined); assert.equal(typeof CONTACT_METHODS.policyFor("did:webvh").resolve, "function"); assert.equal(typeof CONTACT_METHODS.policyFor("did:web").resolve, "function"); }); @@ -63,7 +70,7 @@ test("registry: did:webvh and did:web are accepted by the parser through the reg }); test("registry: an unregistered method is unsupported-vid (update)", () => { - for (const from of ["did:scid:abc", "did:webs:example.org", "did:ion:abc"]) { + for (const from of ["did:example:abc", "did:webs:example.org", "did:ion:abc"]) { const r = read(from, CONTACT_METHODS); assert.equal(r.ok, false); assert.equal(r.reason, "unsupported-vid", from); @@ -80,7 +87,7 @@ test("registry: did:key and did:peer stay from-not-allowed", () => { test("registry: resolution of an unregistered method is unsupported-vid, nothing resolved", async () => { let called = 0; - const r = await resolveContactDocument("did:scid:abc", { resolveDid: async () => (called++, {}) }); + const r = await resolveContactDocument("did:example:abc", { resolveDid: async () => (called++, {}) }); assert.deepEqual(r, { ok: false, reason: "unsupported-vid" }); const c = await resolveCommunityDocument("did:ion:abc", { resolveDid: async () => (called++, {}) }); assert.deepEqual(c, { ok: false, reason: "unsupported-vid" }); @@ -143,3 +150,77 @@ test("contactKindOf", () => { assert.equal(contactKindOf("urn:x"), undefined); assert.equal(contactKindOf("did::x"), undefined); }); + +// ── did:scid (VTI-KEY-160 to 163; the spec's 07a Examples rows) ────────────── + +const SCID_URL = `${SCID}?src=dids.example.org/farm-auth`; + +test("did:scid with src: accepted, route A, shown as its src host and path, recorded as the bare DID", () => { + const r = read(SCID_URL, CONTACT_METHODS); + assert.equal(r.ok, true, JSON.stringify(r)); + assert.equal(r.link.from.kind, "did:scid"); + assert.equal(r.link.from.value, SCID_URL); + assert.equal(contactRoute(r.link.from, CONTACT_METHODS), "host-named"); + assert.equal(SIGN_IN_FLOW.allowsContact(r.link.from, CONTACT_METHODS), true); + assert.equal(contactDomainLabel(r.link.from, CONTACT_METHODS), "dids.example.org/farm-auth"); + assert.equal(contactIdentity(r.link.from), SCID); + assert.equal(didIdentity(SCID_URL), SCID); +}); + +test("did:scid with src resolves to the bare DID's document", async () => { + const asked = []; + const ok = await resolveCommunityDocument(SCID_URL, { + resolveDid: async (did) => (asked.push(did), { didDocument: { id: SCID } }), + }); + assert.equal(ok.ok, true); + assert.equal(ok.route, "host-named"); + // Resolved through the DID URL, so a first-contact resolver has a source. + assert.deepEqual(asked, [SCID_URL]); +}); + +test("did:scid: a document whose id is not the bare DID is unverified", async () => { + const r = await resolveCommunityDocument(SCID_URL, { + resolveDid: async () => ({ didDocument: { id: SCID_URL } }), + }); + assert.deepEqual(r, { ok: false, reason: "did-document-unverified" }); +}); + +test("did:scid: a source serving a log older than one verified is did-document-unverified", async () => { + const r = await resolveCommunityDocument(SCID_URL, { + resolveDid: async () => { + throw new Error("did:scid resolve: the log at dids.example.org/farm-auth is at version 1-Qm…, behind 2-Qm…"); + }, + }); + assert.deepEqual(r, { ok: false, reason: "did-document-unverified" }); +}); + +test("did:scid with src=localhost%3A8000: refused at resolution by the host rules, nothing fetched", async () => { + const r = read(`${SCID}?src=localhost%3A8000`, CONTACT_METHODS); + assert.equal(r.ok, true, "the link parses; the host rules apply at resolution"); + const res = await resolveCommunityDocument(r.link.from.value, { + resolveDid: async () => assert.fail("must not resolve"), + }); + assert.deepEqual(res, { ok: false, reason: "did-document-unverified" }); +}); + +test("did:scid with no src: from-not-allowed (it names no host)", () => { + const r = read(SCID, CONTACT_METHODS); + assert.equal(r.ok, false); + assert.equal(r.reason, "from-not-allowed"); + assert.equal(r.outcome, "invalid"); +}); + +test("did:scid of another format, and a reader with no did:scid method: unsupported-vid", () => { + assert.equal(read("did:scid:ke:1:EKYLUMmNPZeEs77Zvclf0bSN5IN?src=example.org", CONTACT_METHODS).reason, "unsupported-vid"); + const noScid = new ContactMethodRegistry([WEBVH_CONTACT_METHOD]); + const r = read(`${SCID}?src=dids.example.org`, noScid); + assert.equal(r.reason, "unsupported-vid"); + assert.equal(r.outcome, "update"); +}); + +test("did:scid: a malformed src or a second query parameter is bad-from", () => { + // A raw `&` would end `_from` (VTI-LNK-020); `%26` decodes into the value. + for (const from of [`${SCID}?src=`, `${SCID}?src=dids.example.org%26x=1`, `${SCID}?versionId=1`]) { + assert.equal(read(from, CONTACT_METHODS).reason, "bad-from", from); + } +}); diff --git a/packages/core/tests/did.scid.mjs b/packages/core/tests/did.scid.mjs new file mode 100644 index 00000000..b00231ed --- /dev/null +++ b/packages/core/tests/did.scid.mjs @@ -0,0 +1,96 @@ +// did:scid in the wallet's own DID handling: method detection and the domain +// shown for it, the src host guard, and a Trust-Task proof by a did:scid key +// attributed to the identity — the bare DID — whatever `src` the proof's +// verification method carried (VTI-KEY-160, 163). + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { ed25519 } from "@noble/curves/ed25519.js"; + +import { + didMethod, + didScidDomain, + verifyDid, + assertResolvableScidHost, + isBlockedEndpointError, + deriveSigningKeyId, +} from "../dist/did/index.js"; +import { signTrustTask } from "../dist/trust-tasks/sign.js"; +import { verifyTrustTaskProof, listsMethodUnder } from "../dist/trust-tasks/verify.js"; +import { base58btcEncode } from "../dist/trust-tasks/canonical.js"; + +const SCID = "did:scid:vh:1:QmXa7Rk2ZpLwT9vNc4HbYe1Jd8sMfU3qGo6PtVnEyKiBhW"; +const URL_ = `${SCID}?src=dids.example.org/alice`; + +test("didMethod and didScidDomain", () => { + assert.equal(didMethod(URL_), "scid"); + assert.equal(didMethod(SCID), "scid"); + assert.equal(didScidDomain(URL_), "dids.example.org"); + assert.equal(didScidDomain(`${SCID}?src=https://dids.example.org/alice/`), "dids.example.org"); + assert.equal(didScidDomain(SCID), undefined); + assert.equal(didScidDomain("did:webvh:Qm:dids.example.org"), undefined); +}); + +test("the src host is guarded like a did:webvh host", () => { + assertResolvableScidHost(URL_); + for (const did of [`${SCID}?src=localhost:8000`, `${SCID}?src=127.0.0.1`, `${SCID}?src=intranet`, SCID]) { + assert.throws(() => assertResolvableScidHost(did), (e) => isBlockedEndpointError(e), did); + } +}); + +test("verifyDid: a did:scid DID URL resolves, with its src host as the domain", async () => { + const asked = []; + const r = await verifyDid(URL_, { + resolveDid: async (did) => (asked.push(did), { didDocument: { id: SCID, alsoKnownAs: ["did:webvh:Qm:dids.example.org:alice"] } }), + }); + assert.equal(r.method, "scid"); + assert.equal(r.domain, "dids.example.org"); + assert.equal(r.resolved, true); + assert.deepEqual(asked, [URL_]); +}); + +test("verifyDid: a private src host is refused before anything is resolved", async () => { + const r = await verifyDid(`${SCID}?src=localhost:8000`, { resolveDid: async () => assert.fail("must not resolve") }); + assert.equal(r.resolved, false); +}); + +test("deriveSigningKeyId: a did:scid's candidates are its document's bare key ids", async () => { + const r = await deriveSigningKeyId(URL_, { + resolveDid: async () => ({ didDocument: { id: SCID, authentication: [`${SCID}#key-1`] } }), + }); + assert.deepEqual(r.candidates, [`${SCID}#key-1`]); +}); + +function scidSigner() { + const privateKey = ed25519.utils.randomSecretKey(); + const publicKey = ed25519.getPublicKey(privateKey); + const multikey = `z${base58btcEncode(new Uint8Array([0xed, 0x01, ...publicKey]))}`; + const doc = { + id: SCID, + verificationMethod: [{ id: `${SCID}#key-1`, type: "Multikey", controller: SCID, publicKeyMultibase: multikey }], + authentication: [`${SCID}#key-1`], + }; + return { privateKey, publicKey, doc }; +} + +test("a proof whose verificationMethod carries src is attributed to the bare did:scid", async () => { + const { privateKey, publicKey, doc } = scidSigner(); + const envelope = { id: "t-1", type: "https://trusttasks.org/spec/example/0.1", issuer: SCID, recipient: "did:key:z6Mk", issuedAt: new Date().toISOString(), payload: {} }; + await signTrustTask({ + envelope, + signing: { did: SCID, kid: `${URL_}#key-1`, privateKey, publicKey }, + proofPurpose: "authentication", + }); + + const asked = []; + const r = await verifyTrustTaskProof(envelope, { + expectedProofPurpose: "authentication", + resolveDid: async (did) => (asked.push(did), doc), + }); + assert.equal(r.verified, true, r.reason); + assert.equal(r.signer, SCID); + // Resolved through the DID URL the proof named. + assert.deepEqual(asked, [URL_]); + assert.equal(listsMethodUnder(doc, `${URL_}#key-1`, "authentication"), true); + assert.equal(listsMethodUnder(doc, `${URL_}#key-2`, "authentication"), false); +}); diff --git a/packages/core/tests/links.trigger-link.mjs b/packages/core/tests/links.trigger-link.mjs index 999920ab..ca43b5a6 100644 --- a/packages/core/tests/links.trigger-link.mjs +++ b/packages/core/tests/links.trigger-link.mjs @@ -223,7 +223,10 @@ test("VTI-LNK-102: the contact rule is an allow-list by capability", () => { .filter(([, c]) => c.namesHost && c.listsServices && c.verified) .map(([kind]) => kind) .sort(); - assert.deepEqual(allowed, ["did:web", "did:webvh"]); + assert.deepEqual(allowed, ["did:scid", "did:web", "did:webvh"]); + // did:scid names its host in `src`; a bare one names none and has no route. + assert.equal(isHostNamedServiceContact({ kind: "did:scid", value: "did:scid:vh:1:Qm?src=example.org" }), true); + assert.equal(isHostNamedServiceContact({ kind: "did:scid", value: "did:scid:vh:1:Qm" }), false); assert.equal(isHostNamedServiceContact({ kind: "did:webvh", value: FROM }), true); assert.equal(isHostNamedServiceContact({ kind: "did:web", value: "did:web:example.org" }), true); assert.equal(isHostNamedServiceContact({ kind: "did:key", value: "did:key:z6Mk" }), false); diff --git a/packages/extension/src/bridge-protocol.ts b/packages/extension/src/bridge-protocol.ts index 25af23a2..d3206f8e 100644 --- a/packages/extension/src/bridge-protocol.ts +++ b/packages/extension/src/bridge-protocol.ts @@ -652,7 +652,7 @@ export interface RuntimeVerifyRpDidRequest { * depend on `@openvtc/pnm-core` types directly. */ export interface VerifyRpDidResult { did: string; - method: "webvh" | "peer" | "key" | "unknown"; + method: "webvh" | "scid" | "peer" | "key" | "unknown"; resolved: boolean; domain?: string; error?: string; diff --git a/packages/extension/src/confirm.tsx b/packages/extension/src/confirm.tsx index 5148b7d8..f9aad4c2 100644 --- a/packages/extension/src/confirm.tsx +++ b/packages/extension/src/confirm.tsx @@ -449,7 +449,7 @@ function VerificationDetails({ state }: { state: VerificationState }) { lines.push( Method: did:{result.method} - {result.method === "webvh" && " — log chain + proofs verified"} + {(result.method === "webvh" || result.method === "scid") && " — log chain + proofs verified"} {(result.method === "peer" || result.method === "key") && " — self-certifying identifier"} , ); diff --git a/packages/extension/src/did-display.ts b/packages/extension/src/did-display.ts index 7e3b7132..bf93bc89 100644 --- a/packages/extension/src/did-display.ts +++ b/packages/extension/src/did-display.ts @@ -38,12 +38,17 @@ export interface DidPart { * encoded because the host is compared by eye against a domain, which never * contains either. * + * `did:scid:vh:1:?src=[/…]` lifts the host its `src` names: + * that is where its log is fetched from, so it is what the person checks. A + * bare did:scid names no host and comes back as one part. + * * Anything else — `did:peer`, `did:key`, or a malformed string — comes back as * a single part rather than a guess. Unknown input renders plainly; it never * renders *wrongly*, which would mean emphasising a segment that is not the * host and inviting exactly the misread this exists to prevent. */ export function splitDid(did: string): DidPart[] { + if (did.startsWith("did:scid:")) return splitScid(did); if (!did.startsWith("did:webvh:")) { return did ? [{ text: did, role: "opaque" }] : []; } @@ -70,6 +75,22 @@ export function splitDid(did: string): DidPart[] { return parts; } +// `did:scid:vh:1:?src=[/]`. Only a `src` that is the sole +// query is lifted; anything else renders as one part. +function splitScid(did: string): DidPart[] { + const match = /^(did:scid:[a-z0-9]+:[0-9]+:)([^?#]+)(\?src=)([^/?#&]+)(\/[^?#&]*)?$/.exec(did); + if (!match) return [{ text: did, role: "opaque" }]; + const [, method, scid, query, host, path] = match; + const parts: DidPart[] = [ + { text: method!, role: "method" }, + { text: scid!, role: "opaque" }, + { text: query!, role: "method" }, + { text: host!, role: "host" }, + ]; + if (path) parts.push({ text: path, role: "path" }); + return parts; +} + /** The host a viewer should be checking, or undefined when there isn't one to * lift. Callers use this for the accessible label so a screen reader gets the * same emphasis the visual treatment provides. */ diff --git a/packages/extension/src/manager/mediator-lens-model.ts b/packages/extension/src/manager/mediator-lens-model.ts index 59c16367..271b58ea 100644 --- a/packages/extension/src/manager/mediator-lens-model.ts +++ b/packages/extension/src/manager/mediator-lens-model.ts @@ -155,6 +155,8 @@ export function verificationModeOf( * is shortened head-and-tail. */ export function didLabel(did: string): string { + const src = scidSrcLabel(did); + if (src) return src; const seg = did.split(":"); if (seg[0] === "did" && seg[1] === "webvh" && seg.length >= 4) { const host = safeDecode(seg[3]!); @@ -164,6 +166,15 @@ export function didLabel(did: string): string { return did.length > 28 ? `${did.slice(0, 16)}…${did.slice(-6)}` : did; } +// `host/path` from a did:scid DID URL's `src`, or undefined. Mirrors +// `scidSrcLabel` in `../mediator-fallback.ts`, inlined: this module takes no +// relative imports. +function scidSrcLabel(did: string): string | undefined { + const match = /^did:scid:[a-z0-9]+:[0-9]+:[^?#]+\?src=([^?#&]+)$/.exec(did); + if (!match) return undefined; + return safeDecode(match[1]!.replace(/^https?:\/\//, "").replace(/\/+$/, "")) || undefined; +} + function safeDecode(s: string): string { try { return decodeURIComponent(s); diff --git a/packages/extension/src/mediator-fallback.ts b/packages/extension/src/mediator-fallback.ts index eb655973..523abadd 100644 --- a/packages/extension/src/mediator-fallback.ts +++ b/packages/extension/src/mediator-fallback.ts @@ -99,8 +99,11 @@ export function fallbackNotification(agent: string): { title: string; message: s } /** A short name for an agent a person can recognise: `host/path` for a - * `did:webvh` (its SCID means nothing to anyone), the DID otherwise. */ + * `did:webvh` (its SCID means nothing to anyone) or a did:scid DID URL (the + * host and path its `src` names), the DID otherwise. */ export function agentLabel(did: string): string { + const src = scidSrcLabel(did); + if (src) return src; const parts = did.split(":"); if (parts[0] === "did" && parts[1] === "webvh" && parts.length >= 4) { return parts.slice(3).map(decodeURIComponent).join("/"); @@ -138,3 +141,15 @@ export function withExpiredFallbackDropped( for (const t of sittingOutTransports(health)) delete out[t]; return out; } + +/** `host/path` from a did:scid DID URL's `src`, or undefined. */ +export function scidSrcLabel(did: string): string | undefined { + const match = /^did:scid:[a-z0-9]+:[0-9]+:[^?#]+\?src=([^?#&]+)$/.exec(did); + if (!match) return undefined; + const src = match[1]!.replace(/^https?:\/\//, "").replace(/\/+$/, ""); + try { + return decodeURIComponent(src) || undefined; + } catch { + return src || undefined; + } +} diff --git a/packages/extension/src/sign-in-flows.ts b/packages/extension/src/sign-in-flows.ts index 79118165..432dc1e0 100644 --- a/packages/extension/src/sign-in-flows.ts +++ b/packages/extension/src/sign-in-flows.ts @@ -94,6 +94,8 @@ import { outcomeOf, triggerLinkMessage, contactDomainLabel, + contactIdentity, + didIdentity, bytesToBase64url, base64urlToBytes, type ParsedTriggerLink, @@ -596,7 +598,11 @@ export class SignInFlows { const link = parsed.link; if (!link.flow) return refused("untyped"); if (link.flow.spec.name !== "sign-in") return refused("unknown-flow"); - const vtcDid = link.from.value; + // The community's identity — for a did:scid contact, the bare DID; the + // link's DID URL, with its `src`, is only what it is resolved through + // (VTI-KEY-160, 163). Everything from here on is addressed to, recorded + // as and checked against the identity. + const vtcDid = contactIdentity(link.from); // C3 step 2 / VTI-LNK-101: one of this wallet's communities, or nothing is // sent to it. @@ -611,7 +617,7 @@ export class SignInFlows { (e) => e.secretKind === "didSelfIssued" && typeof e.principalDid === "string" && - e.targets.some((t) => t.kind === "did" && t.did === vtcDid), + e.targets.some((t) => t.kind === "did" && typeof t.did === "string" && didIdentity(t.did) === vtcDid), ) .map((e) => ({ entryId: e.id, did: e.principalDid!, label: e.label })) // Two entries for one DID sign in identically (the VTA signs as the DID, @@ -621,7 +627,7 @@ export class SignInFlows { // C3 step 3: resolve and verify, afresh — for a site the wallet does not // know too, so that what it is offered is the verified document's account // of the site and never the link's. - const resolved = await resolveCommunityDocument(vtcDid, { + const resolved = await resolveCommunityDocument(link.from.value, { ...(this.deps.resolveDid ? { resolveDid: this.deps.resolveDid } : {}), contactMethods: methods, }); diff --git a/packages/extension/tests/did-display.test.mts b/packages/extension/tests/did-display.test.mts index 8a5e12ef..11b1efe9 100644 --- a/packages/extension/tests/did-display.test.mts +++ b/packages/extension/tests/did-display.test.mts @@ -110,3 +110,28 @@ test("a trailing segment is a path, never promoted to a name", () => { assert.deepEqual(parts.map((p) => p.role), ["method", "opaque", "method", "host", "path"]); assert.equal(parts.at(-1)?.text, ":glenn-vta"); }); + +const SCID = "did:scid:vh:1:QmXa7Rk2ZpLwT9vNc4HbYe1Jd8sMfU3qGo6PtVnEyKiBhW"; + +test("a did:scid DID URL lifts the host its src names", () => { + const did = `${SCID}?src=dids.example.org/farm-auth`; + assert.deepEqual(splitDid(did), [ + { text: "did:scid:vh:1:", role: "method" }, + { text: "QmXa7Rk2ZpLwT9vNc4HbYe1Jd8sMfU3qGo6PtVnEyKiBhW", role: "opaque" }, + { text: "?src=", role: "method" }, + { text: "dids.example.org", role: "host" }, + { text: "/farm-auth", role: "path" }, + ]); + assert.equal(rejoin(did), did); + assert.equal(didHost(did), "dids.example.org"); + // Collapsing elides the SCID and keeps the host. + assert.ok(collapseDid(did).some((p) => p.role === "host" && p.text === "dids.example.org")); +}); + +test("a bare did:scid has no host and is left whole", () => { + assert.deepEqual(splitDid(SCID), [{ text: SCID, role: "opaque" }]); + assert.equal(didHost(SCID), undefined); + for (const did of [`${SCID}?src=a.example&x=1`, `${SCID}?versionId=1`]) { + assert.deepEqual(splitDid(did), [{ text: did, role: "opaque" }], did); + } +}); diff --git a/packages/extension/tests/mediator-fallback.test.mts b/packages/extension/tests/mediator-fallback.test.mts index 1ac77b0e..d75651cc 100644 --- a/packages/extension/tests/mediator-fallback.test.mts +++ b/packages/extension/tests/mediator-fallback.test.mts @@ -139,3 +139,9 @@ test("dropping an ended fallback leaves other down reasons alone", () => { const refused = { state: "down" as const, code: TRANSPORT_DIAGNOSIS.originNotAllowed, detail: "y" }; assert.deepEqual(withExpiredFallbackDropped({ TSP: refused }, false), { TSP: refused }); }); + +test("agentLabel: a did:scid reads as the host and path its src names", () => { + assert.equal(agentLabel("did:scid:vh:1:QmScid?src=dids.example.org/gt-vta"), "dids.example.org/gt-vta"); + assert.equal(agentLabel("did:scid:vh:1:QmScid?src=https://dids.example.org/gt-vta/"), "dids.example.org/gt-vta"); + assert.equal(agentLabel("did:scid:vh:1:QmScid"), "did:scid:vh:1:QmScid"); +}); diff --git a/packages/extension/tests/mediator-lens.test.mts b/packages/extension/tests/mediator-lens.test.mts index 9a092df9..17338d4d 100644 --- a/packages/extension/tests/mediator-lens.test.mts +++ b/packages/extension/tests/mediator-lens.test.mts @@ -262,6 +262,7 @@ test("a purge commits the plan the person was shown, not a fresh preview", () => test("a DID is named by host AND path, so a relay and an agent on one host read differently", () => { assert.equal(didLabel("did:webvh:QmA:webvh.storm.ws:mediator"), "webvh.storm.ws/mediator"); assert.equal(didLabel("did:webvh:QmB:webvh.storm.ws:agents:keyring"), "webvh.storm.ws/agents/keyring"); + assert.equal(didLabel("did:scid:vh:1:QmC?src=webvh.storm.ws/agents/keyring"), "webvh.storm.ws/agents/keyring"); assert.equal(didLabel("did:webvh:QmC:localhost%3A8080"), "localhost:8080"); assert.notEqual( didLabel("did:webvh:QmA:webvh.storm.ws:mediator"), From 348772d535fef6c03cf3f5bb56c837ab8879cdca Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Mon, 26 Oct 2026 10:10:00 +0800 Subject: [PATCH 2/2] chore(deps): @openvtc/vti-didcomm-js 0.13.0, for did:scid Signed-off-by: Glenn Gore --- package-lock.json | 8 ++++---- packages/core/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 00422e4b..6cc09192 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2307,9 +2307,9 @@ "license": "Apache-2.0" }, "node_modules/@openvtc/vti-didcomm-js": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@openvtc/vti-didcomm-js/-/vti-didcomm-js-0.12.0.tgz", - "integrity": "sha512-4/k/32YRhx7XSmwOgezP9RO0QZ0Qq/FPYnUWf6a8SdAlyRITRkhPXBg1LZG9gTelupEdQKBMixY+knSAd8NoBQ==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@openvtc/vti-didcomm-js/-/vti-didcomm-js-0.13.0.tgz", + "integrity": "sha512-0+stJw0kSPZvJYG1ojYBOMrvFnnHIMMOsUNYlTClk4L8aN7wS8VHpbLrqJMq8BcI5tpZQaEEdP4LmLO769D6EA==", "license": "Apache-2.0", "dependencies": { "@noble/curves": "^2.4.0", @@ -7980,7 +7980,7 @@ "@cfworker/json-schema": "^4.1.1", "@noble/curves": "^2.4.0", "@openvtc/trust-tasks": "^0.27.2", - "@openvtc/vti-didcomm-js": "^0.12.0", + "@openvtc/vti-didcomm-js": "^0.13.0", "@openvtc/vti-tsp-js": "^0.4.0", "@scure/base": "^2.2.0", "cbor-x": "^1.6.6" diff --git a/packages/core/package.json b/packages/core/package.json index 9b92c27b..f9cacea8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -143,7 +143,7 @@ "@cfworker/json-schema": "^4.1.1", "@noble/curves": "^2.4.0", "@openvtc/trust-tasks": "^0.27.2", - "@openvtc/vti-didcomm-js": "^0.12.0", + "@openvtc/vti-didcomm-js": "^0.13.0", "@openvtc/vti-tsp-js": "^0.4.0", "@scure/base": "^2.2.0", "cbor-x": "^1.6.6"