From 6cbbfa749e57d989b803472ffdc529f405ba6f8d Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Thu, 12 Nov 2026 15:48:16 +1000 Subject: [PATCH] feat: read did:webs (JsonWebKey) verification methods by relationship (VTI-KEY-003) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every party must resolve did:webs (VTI-KEY-003). A did:webs document carries JsonWebKey methods (publicKeyJwk, ids #), and a VTA with key roles (VTI-KEY-173) lists a different key under each relationship: the attestation key under assertionMethod, the operational key under authentication, and X25519 key agreement after a P-256 one. The plugin's own DID-document key readers assumed publicKeyMultibase, fell back to "any Ed25519 key", or matched ids by fragment suffix — each of which picks the wrong key for that document. One reader now serves every site: core/src/did/vm-key.ts (publicKeyOf, selectKey, x25519KeyAgreement), a port of vti-didcomm-js #39's verification-method module, to be replaced by that export when the plugin moves to the release that ships it (R4.1). It fails closed on an unknown or missing type, an encoding the type does not carry, both encodings, an unsupported curve, a JWK carrying d, or a revoked method. - trust-tasks/verify.ts: the proof key is read from the relationship the proof is for (the required purpose, else the proof's own proofPurpose), in either encoding; no endsWith fallback. - vta/tsp-vid.ts: the VID signing key is the authentication key only (tsp-enablement D5); no "any Ed25519" fallback, exact dereference, type checks; rawFromOkpJwk refuses d. - vtc/directory-card.ts assertionKeys and vtc/domain-linkage.ts assertionKey read assertionMethod through the same reader. - did/verify.ts didMethod knows webs (didWebsDomain, host guarded before resolution); deriveSigningKeyId offers no bare verificationMethod ids for a did:webs. Tests use vti-didcomm-js's did:webs party documents (VTA with key roles, mediator), with a did:webvh regression and fail-closed cases at each site. Signed-off-by: Glenn Gore --- packages/core/CHANGELOG.md | 23 ++ packages/core/src/did/derive-signing-key.ts | 25 +- packages/core/src/did/egress-guard.ts | 20 +- packages/core/src/did/index.ts | 1 + packages/core/src/did/verify.ts | 41 ++- packages/core/src/did/vm-key.ts | 286 +++++++++++++++ packages/core/src/trust-tasks/verify.ts | 75 ++-- packages/core/src/vta/tsp-vid.ts | 118 +++---- packages/core/src/vtc/directory-card.ts | 44 ++- packages/core/src/vtc/domain-linkage.ts | 60 +--- packages/core/tests/did.webs-keys.mjs | 333 ++++++++++++++++++ .../core/tests/fixtures/did-webs/README.md | 22 ++ .../tests/fixtures/did-webs/mediator/did.txt | 1 + .../fixtures/did-webs/mediator/resolved.json | 65 ++++ .../core/tests/fixtures/did-webs/vta/did.txt | 1 + .../tests/fixtures/did-webs/vta/resolved.json | 77 ++++ packages/core/tests/tsp.vid.mjs | 87 ++++- packages/core/tests/vta.outbound-signing.mjs | 10 +- packages/extension/src/bridge-protocol.ts | 2 +- packages/extension/src/confirm.tsx | 1 + 20 files changed, 1078 insertions(+), 214 deletions(-) create mode 100644 packages/core/src/did/vm-key.ts create mode 100644 packages/core/tests/did.webs-keys.mjs create mode 100644 packages/core/tests/fixtures/did-webs/README.md create mode 100644 packages/core/tests/fixtures/did-webs/mediator/did.txt create mode 100644 packages/core/tests/fixtures/did-webs/mediator/resolved.json create mode 100644 packages/core/tests/fixtures/did-webs/vta/did.txt create mode 100644 packages/core/tests/fixtures/did-webs/vta/resolved.json diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 1d679aaa..09280d86 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -104,6 +104,29 @@ For history before this file, see `git log` on `packages/core`. ### Changed +- **DID-document keys are read by verification relationship, in either + encoding, so a did:webs party works (VTI-KEY-003).** A did:webs publishes + `JsonWebKey` methods (`publicKeyJwk`, ids `#`), and a VTA with + key roles (VTI-KEY-173) lists a different key under each relationship. One + reader, `did/vm-key.ts` (`publicKeyOf`, `selectKey`, `x25519KeyAgreement`, a + port of vti-didcomm-js #39's `verification-method` to be replaced by it on + the bump), now serves every site, and fails closed on an unknown or missing + `type`, an encoding the type does not carry, both encodings, an unsupported + curve, a JWK carrying `d`, or a revoked method. + - `verifyTrustTaskProof` reads the key from the relationship the proof is + for — the required purpose, or the proof's own `proofPurpose` when none is + required, so an unrequired-purpose proof by a key not listed there no + longer verifies — and never matches an id by fragment suffix. + - The TSP VID signing key (`tspEndpointFromResolved`) is the DID's + `authentication` key only (TSP enablement D5): no "any Ed25519 method" + fallback, ids dereferenced exactly. `ed25519FromVerificationMethod` + requires a `type`; `rawFromOkpJwk` refuses a JWK with `d`. + - `assertionKeys` (directory card) and Domain Linkage Credential checks read + `assertionMethod` the same way; an unreadable key is `an unusable key (…)`. + - `didMethod` knows `webs` (`didWebsDomain`, `assertResolvableWebsHost`: + the host is judged before anything is fetched), and `deriveSigningKeyId` + never offers a did:webs's bare `verificationMethod[]` ids. + - **Room record bodies are checked against the author's DID document as of `bodyStoredAt`.** The file engine, and the wallet's records pane and page bridge, pass `resolveDidAt` to `verifyRecordBody`, so for a `did:webvh` or diff --git a/packages/core/src/did/derive-signing-key.ts b/packages/core/src/did/derive-signing-key.ts index 1850b68d..5f00ffd1 100644 --- a/packages/core/src/did/derive-signing-key.ts +++ b/packages/core/src/did/derive-signing-key.ts @@ -18,6 +18,14 @@ // walks the webvh log + verifies every Data Integrity proof, // so a successful resolve is a real attestation. // +// - **did:webs**: resolved over the network from the AID's KERI event +// stream (VTI-KEY-003). Its verification methods are `JsonWebKey`s with +// ids `#`, and with key roles (VTI-KEY-173) its +// `verificationMethod[]` also holds key-agreement keys (P-256, X25519) +// that are no signing key at all — so a did:webs offers only what it +// lists under `authentication` / `assertionMethod`, never "any +// verification method". +// // Selection: when the resolved DID document has a single // `authentication` (or `assertionMethod`) entry, return its id and // `candidates.length === 1` so the popup can auto-fill the field. @@ -30,7 +38,7 @@ import { resolve as vtiResolve, scidSrc } from "@openvtc/vti-didcomm-js"; -import { assertResolvableScidHost, assertResolvableWebvhHost } from "./egress-guard.js"; +import { assertResolvableScidHost, assertResolvableWebsHost, assertResolvableWebvhHost } from "./egress-guard.js"; import { didMethod, type ResolveDidOptions } from "./verify.js"; export interface DeriveSigningKeyIdResult { @@ -69,11 +77,12 @@ export async function deriveSigningKeyId( if (method === "unknown") { return { did, candidates: [], error: "Unrecognised DID method" }; } - // 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. + // did:peer, did:webvh, did:webs, did:scid: full resolution. did:peer is + // offline; webvh and webs hit the network and verify the log — at a host + // the DID's author chose, so the host is judged first. try { if (method === "webvh") assertResolvableWebvhHost(did); + if (method === "webs") assertResolvableWebsHost(did); if (method === "scid" && scidSrc(did)) assertResolvableScidHost(did); const resolve = opts.resolveDid ?? ((d: string) => vtiResolve(d, {})); const resolution = (await resolve(did)) as { @@ -95,12 +104,14 @@ export async function deriveSigningKeyId( } // Prefer `authentication` (the signing-purpose-correct list for // SIOP-style id_tokens). Fall back to `assertionMethod` if absent. - // Final fallback: enumerate `verificationMethod` ids — better to - // give the operator something than nothing. + // Final fallback, except for a did:webs: enumerate + // `verificationMethod` ids — better to give the operator something + // than nothing. A did:webs lists its key-agreement keys there too, + // and offering one as a signing key is worse than offering nothing. const candidates = collectVmIds(doc.authentication) || collectVmIds(doc.assertionMethod) || - collectVmIds(doc.verificationMethod) || + (method === "webs" ? null : collectVmIds(doc.verificationMethod)) || []; if (candidates.length === 0) { return { did, candidates: [], error: "DID document has no usable verification methods" }; diff --git a/packages/core/src/did/egress-guard.ts b/packages/core/src/did/egress-guard.ts index 56faeee5..fc597f30 100644 --- a/packages/core/src/did/egress-guard.ts +++ b/packages/core/src/did/egress-guard.ts @@ -1,4 +1,4 @@ -// Host guard for did:webvh resolution. +// Host guard for did:webvh (and did:webs) resolution. // // A did:webvh names the host its log is fetched from, and the wallet resolves // DIDs it did not choose: the consent prompt resolves whatever `rpDid` a page @@ -37,7 +37,7 @@ import { didScid } from "@openvtc/vti-didcomm-js"; -import { didWebvhDomain } from "./verify.js"; +import { didWebsDomain, didWebvhDomain } from "./verify.js"; /** Stable `code` carried by every {@link BlockedEndpointError}. Match on this, * never on the message (R3.7). */ @@ -91,7 +91,21 @@ export function isBlockedEndpointError(err: unknown): err is Error & { * `"private_name"` for a local-only name. */ export function assertResolvableWebvhHost(did: string): void { - const raw = didWebvhDomain(did); + assertResolvableHost(didWebvhDomain(did)); +} + +/** + * {@link assertResolvableWebvhHost} for a `did:webs:[:]:`: + * its `keri.cesr` and `did.json` are fetched from the host it names, so that + * host is judged the same way (VTI-KEY-003). + * + * @throws {BlockedEndpointError} + */ +export function assertResolvableWebsHost(did: string): void { + assertResolvableHost(didWebsDomain(did)); +} + +function assertResolvableHost(raw: string | undefined): void { const invalid = () => new BlockedEndpointError( `the DID's host ${JSON.stringify(raw ?? "")} is not a valid host name, so it was not contacted`, diff --git a/packages/core/src/did/index.ts b/packages/core/src/did/index.ts index d2ba8e75..18dd0751 100644 --- a/packages/core/src/did/index.ts +++ b/packages/core/src/did/index.ts @@ -1,6 +1,7 @@ export * from "./derive-signing-key.js"; export * from "./egress-guard.js"; export * from "./verification-method.js"; +export * from "./vm-key.js"; export * from "./peer.js"; export * from "./verify.js"; export * from "./methods.js"; diff --git a/packages/core/src/did/verify.ts b/packages/core/src/did/verify.ts index a8323f78..37505ede 100644 --- a/packages/core/src/did/verify.ts +++ b/packages/core/src/did/verify.ts @@ -18,12 +18,18 @@ // 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. +// +// did:webs (`did:webs:[:]:`) is a method every party must +// resolve (VTI-KEY-003): its document is the one the AID's KERI event stream +// (`keri.cesr`, from the host the DID names) establishes, so a successful +// resolve is likewise cryptographic. Its host is judged before anything is +// fetched, as a did:webvh's is. import { resolve as vtiResolve, scidSrc } from "@openvtc/vti-didcomm-js"; -import { assertResolvableScidHost, assertResolvableWebvhHost } from "./egress-guard.js"; +import { assertResolvableScidHost, assertResolvableWebsHost, assertResolvableWebvhHost } from "./egress-guard.js"; -export type DidMethod = "webvh" | "scid" | "peer" | "key" | "unknown"; +export type DidMethod = "webvh" | "webs" | "scid" | "peer" | "key" | "unknown"; export interface VerifyDidResult { /** Canonical DID under verification. */ @@ -31,13 +37,15 @@ export interface VerifyDidResult { /** Parsed DID method. `unknown` if the input is not a recognisable DID. */ method: DidMethod; /** True if resolution succeeded. For did:webvh this implies the SCID + log - * hash chain + every Data Integrity proof verified. For did:peer/did:key + * hash chain + every Data Integrity proof verified; for did:webs, the AID's + * key event log. For did:peer/did:key * it implies the identifier was structurally valid. */ 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 a did:scid - * DID URL, the host its `src` names; a bare did:scid names none. */ + * for). The DID is `did:webvh::[:...]`. For a did:webs, + * likewise the host it names (`did:webs:[:...]:`). 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; @@ -63,6 +71,7 @@ export interface VerifyDidResult { */ export function didMethod(did: string): DidMethod { if (did.startsWith("did:webvh:")) return "webvh"; + if (did.startsWith("did:webs:")) return "webs"; if (did.startsWith("did:scid:")) return "scid"; if (did.startsWith("did:peer:")) return "peer"; if (did.startsWith("did:key:")) return "key"; @@ -86,6 +95,18 @@ export function didWebvhDomain(did: string): string | undefined { return parts[3]; } +/** + * The host portion of a `did:webs:[:...]:`, as written (a + * port stays percent-encoded on it, as on a did:webvh host). `undefined` for + * any other DID. + */ +export function didWebsDomain(did: string): string | undefined { + if (!did.startsWith("did:webs:")) return undefined; + // ["did", "webs", "", ...path, ""] + const parts = did.split(":"); + return parts.length >= 4 ? parts[2] : undefined; +} + /** * 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 @@ -122,7 +143,14 @@ export async function verifyDid( opts: ResolveDidOptions = {}, ): Promise { const method = didMethod(did); - const domain = method === "webvh" ? didWebvhDomain(did) : method === "scid" ? didScidDomain(did) : undefined; + const domain = + method === "webvh" + ? didWebvhDomain(did) + : method === "webs" + ? didWebsDomain(did) + : method === "scid" + ? didScidDomain(did) + : undefined; const base: VerifyDidResult = { did, method, @@ -134,6 +162,7 @@ export async function verifyDid( } try { if (method === "webvh") assertResolvableWebvhHost(did); + if (method === "webs") assertResolvableWebsHost(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); diff --git a/packages/core/src/did/vm-key.ts b/packages/core/src/did/vm-key.ts new file mode 100644 index 00000000..a5fb9988 --- /dev/null +++ b/packages/core/src/did/vm-key.ts @@ -0,0 +1,286 @@ +// DID document verification methods → key bytes, chosen by relationship. +// +// TO BE REPLACED by `@openvtc/vti-didcomm-js/verification-method` (its +// `publicKeyOf`, `selectKey`, `x25519KeyAgreement`) when this package moves to +// the vti-didcomm-js release that ships it (vti-didcomm-js #39). This is a port +// of that module, rule for rule and with the same signatures, so the swap is +// an import change and a deletion — not a second implementation to keep in +// step (R4.1: don't fork fixes). Change the library first; mirror here only +// until the bump. +// +// The one place this package turns a verification method into a public key. +// Every site that reads a key out of a DID document — Trust-Task proof +// verification, the TSP VID signing key, a member's directory-card keys, a +// Domain Linkage Credential's key — goes through here, so a DID method that +// publishes its keys differently is supported everywhere at once or nowhere. +// +// Two encodings are read: +// +// - `publicKeyMultibase` (multicodec multikey) — did:key, did:peer, +// did:webvh, did:scid. Types `Multikey`, `Ed25519VerificationKey2020`, +// `X25519KeyAgreementKey2020`, `X25519KeyAgreementKey2019`. +// - `publicKeyJwk` — did:webs (`JsonWebKey`, ids `#`), and +// `JsonWebKey2020`. OKP Ed25519 / X25519 and EC P-256. +// +// Anything else fails closed: an unknown or missing `type`, a type whose +// encoding the method does not carry (a `JsonWebKey` with only +// `publicKeyMultibase`), both encodings at once, a curve there is no primitive +// for (secp256k1, P-384), a curve that contradicts the type, a JWK carrying +// private material, or a method marked `revoked`. +// +// A key is chosen by its verification relationship (`keyAgreement`, +// `authentication`, `assertionMethod`, …), never as "the first key of the +// right curve" in `verificationMethod[]` and never by a fragment suffix. Every +// party must be able to resolve a did:webs (VTI-KEY-003), and a did:webs with +// key roles (VTI-KEY-150 / VTI-KEY-173) binds different keys to each +// relationship: a key listed under one is not authorised for another. Entries +// are references (absolute, or relative to the DID) into +// `verificationMethod[]`, or embedded methods, as DID Core allows. + +import { p256 } from "@noble/curves/nist.js"; +import { jwk as jwkLib, multibase, sameReference } from "@openvtc/vti-didcomm-js"; + +/** Curves there is a primitive for. */ +export const ED25519 = "Ed25519"; +export const X25519 = "X25519"; +export const P256 = "P-256"; + +export type KeyCurve = typeof ED25519 | typeof X25519 | typeof P256; + +/** The DID Core verification relationships a key can be chosen from. */ +export type VerificationRelationship = + | "keyAgreement" + | "authentication" + | "assertionMethod" + | "capabilityInvocation" + | "capabilityDelegation"; + +export const VERIFICATION_RELATIONSHIPS: readonly VerificationRelationship[] = [ + "keyAgreement", + "authentication", + "assertionMethod", + "capabilityInvocation", + "capabilityDelegation", +]; + +/** Whether `value` names a DID Core verification relationship. */ +export function isVerificationRelationship(value: unknown): value is VerificationRelationship { + return typeof value === "string" && (VERIFICATION_RELATIONSHIPS as readonly string[]).includes(value); +} + +/** Verification method types that carry `publicKeyMultibase`, and the curve each pins (null: any). */ +const MULTIKEY_TYPES = new Map([ + ["Multikey", null], + ["Ed25519VerificationKey2020", ED25519], + ["X25519KeyAgreementKey2020", X25519], + ["X25519KeyAgreementKey2019", X25519], +]); + +/** Verification method types that carry `publicKeyJwk`. */ +const JWK_TYPES = new Set(["JsonWebKey", "JsonWebKey2020"]); + +const CODECS: ReadonlyArray = [ + [multibase.MULTICODEC.ED25519_PUB, ED25519], + [multibase.MULTICODEC.X25519_PUB, X25519], + [multibase.MULTICODEC.P256_PUB, P256], +]; + +const PREFIX = "verification-method: "; + +/** A public key read from a verification method. `publicKey` is 32 raw bytes + * for Ed25519 / X25519 and the 65-byte uncompressed SEC1 point for P-256. */ +export interface VerificationMethodKey { + crv: KeyCurve; + publicKey: Uint8Array; +} + +/** A key chosen from a relationship: the method's absolute id beside it. */ +export interface SelectedKey extends VerificationMethodKey { + kid: string; + publicJwk: Record; +} + +export interface SelectKeyOptions { + /** Absolute key id to select. */ + kid?: string; + /** Acceptable curves (default: any supported). */ + curves?: readonly KeyCurve[]; + /** Prefix for error messages, in place of `verification-method`. */ + label?: string; +} + +/** + * The public key a verification method carries. + * + * @throws {Error} on anything it cannot read unambiguously (see the module + * documentation). + */ +export function publicKeyOf(vm: unknown): VerificationMethodKey { + if (!vm || typeof vm !== "object" || Array.isArray(vm)) { + throw new TypeError(`${PREFIX}a verification method must be an object`); + } + const m = vm as Record; + const label = typeof m.id === "string" ? m.id : "(no id)"; + if (m.revoked !== undefined && m.revoked !== null) { + throw new Error(`${PREFIX}${label} is revoked`); + } + const hasMultibase = m.publicKeyMultibase !== undefined; + const hasJwk = m.publicKeyJwk !== undefined; + if (hasMultibase && hasJwk) { + throw new Error(`${PREFIX}${label} carries both publicKeyMultibase and publicKeyJwk`); + } + const type = m.type; + if (typeof type === "string" && MULTIKEY_TYPES.has(type)) { + if (!hasMultibase) throw new Error(`${PREFIX}${label} is a ${type} without publicKeyMultibase`); + const key = fromMultikey(m.publicKeyMultibase, label); + const pinned = MULTIKEY_TYPES.get(type); + if (pinned && key.crv !== pinned) { + throw new Error(`${PREFIX}${label} is a ${type} holding a ${key.crv} key`); + } + return key; + } + if (typeof type === "string" && JWK_TYPES.has(type)) { + if (!hasJwk) throw new Error(`${PREFIX}${label} is a ${type} without publicKeyJwk`); + return fromJwk(m.publicKeyJwk, label); + } + throw new Error(`${PREFIX}${label} has unsupported type ${JSON.stringify(type)}`); +} + +/** + * Choose a key from `relationship` of a DID document. + * + * With `kid`, the entry whose id is exactly `kid` (a did:scid `src` aside), + * which must be readable and of one of `curves`; it is refused if it is listed + * only under another relationship. Without `kid`, the first entry that is + * readable and of one of `curves`, in document order — an entry of another + * curve or of a type that cannot be read is passed over, never used. + * + * @param did - the DID the document was resolved for; the base of relative ids. + * @throws {Error} when no entry qualifies. + */ +export function selectKey( + didDocument: unknown, + did: string, + relationship: VerificationRelationship, + { kid, curves, label }: SelectKeyOptions = {}, +): SelectedKey { + try { + return select(didDocument, did, relationship, kid, curves); + } catch (e) { + if (label && e instanceof Error && e.message.startsWith(PREFIX)) { + e.message = `${label}: ${e.message.slice(PREFIX.length)}`; + } + throw e; + } +} + +/** The X25519 `keyAgreement` key of a DID document — or the one whose id is `kid`. */ +export function x25519KeyAgreement( + didDocument: unknown, + did: string, + { kid, label }: { kid?: string; label?: string } = {}, +): { kid: string; x25519Pub: Uint8Array } { + const key = selectKey(didDocument, did, "keyAgreement", { + ...(kid !== undefined ? { kid } : {}), + curves: [X25519], + ...(label !== undefined ? { label } : {}), + }); + return { kid: key.kid, x25519Pub: key.publicKey }; +} + +// ─── Internals ────────────────────────────────────────────────────────── + +function select( + didDocument: unknown, + did: string, + relationship: VerificationRelationship, + kid: string | undefined, + curves: readonly KeyCurve[] | undefined, +): SelectedKey { + if (!didDocument || typeof didDocument !== "object") { + throw new TypeError(`${PREFIX}no DID document for ${did}`); + } + const doc = didDocument as Record; + const entries = doc[relationship]; + if (!Array.isArray(entries) || entries.length === 0) { + throw new Error(`${PREFIX}${did} has no ${relationship} entries`); + } + const allowed = curves ? new Set(curves) : null; + const methods: unknown[] = Array.isArray(doc.verificationMethod) ? doc.verificationMethod : []; + const abs = (id: unknown): unknown => (typeof id === "string" && id.startsWith("#") ? `${did}${id}` : id); + const idOf = (v: unknown): unknown => (v && typeof v === "object" ? (v as { id?: unknown }).id : undefined); + const reasons: string[] = []; + for (const entry of entries) { + const id = abs(typeof entry === "string" ? entry : idOf(entry)); + if (typeof id !== "string") { + reasons.push("an entry without an id"); + continue; + } + if (kid && !sameReference(id, kid)) continue; + const vm = typeof entry === "string" ? methods.find((v) => sameReference(abs(idOf(v)) as string, id)) : entry; + let key: VerificationMethodKey; + try { + if (!vm) throw new Error(`${PREFIX}${relationship} reference ${id} is not in verificationMethod[]`); + key = publicKeyOf(vm); + if (allowed && !allowed.has(key.crv)) { + throw new Error(`${PREFIX}${id} is ${key.crv}, not ${[...allowed].join(" or ")}`); + } + } catch (e) { + // An explicitly named key is used or refused, never substituted. + if (kid) throw e; + reasons.push(e instanceof Error ? e.message : String(e)); + continue; + } + return { + kid: kid ?? id, + crv: key.crv, + publicKey: key.publicKey, + publicJwk: jwkLib.publicJwk(key.crv, key.publicKey) as unknown as Record, + }; + } + if (kid) throw new Error(`${PREFIX}${did} lists no ${relationship} key ${kid}`); + const wanted = allowed ? ` ${[...allowed].join("/")}` : ""; + throw new Error( + `${PREFIX}${did} has no usable${wanted} ${relationship} key` + (reasons.length ? ` (${reasons.join("; ")})` : ""), + ); +} + +function fromMultikey(value: unknown, label: string): VerificationMethodKey { + if (typeof value !== "string") { + throw new Error(`${PREFIX}${label} publicKeyMultibase must be a string`); + } + const { codec, key } = multibase.decodeMultikey(value); + const match = CODECS.find(([c]) => c.length === codec.length && c.every((b, i) => b === codec[i])); + if (!match) { + const hex = Array.from(codec, (b) => b.toString(16).padStart(2, "0")).join(""); + throw new Error(`${PREFIX}${label} uses unsupported multicodec 0x${hex}`); + } + const crv = match[1]; + if (crv === P256) { + if (key.length !== 33 && key.length !== 65) { + throw new Error(`${PREFIX}${label} P-256 key must be a 33- or 65-byte SEC1 point`); + } + // vti-didcomm-js #39 adds `p256.uncompressedPoint` for this; 0.14 lacks it. + return { crv, publicKey: p256.Point.fromBytes(key).toBytes(false) }; + } + if (key.length !== 32) { + throw new Error(`${PREFIX}${label} ${crv} key must be 32 bytes, got ${key.length}`); + } + return { crv, publicKey: key }; +} + +function fromJwk(value: unknown, label: string): VerificationMethodKey { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${PREFIX}${label} publicKeyJwk must be an object`); + } + if ((value as { d?: unknown }).d !== undefined) { + throw new Error(`${PREFIX}${label} publicKeyJwk carries private key material`); + } + try { + const jwk = value as Parameters[0]; + const crv = jwkLib.curveOf(jwk) as KeyCurve; + return { crv, publicKey: jwkLib.rawPublic(jwk) }; + } catch (e) { + throw new Error(`${PREFIX}${label} publicKeyJwk: ${e instanceof Error ? e.message : String(e)}`); + } +} diff --git a/packages/core/src/trust-tasks/verify.ts b/packages/core/src/trust-tasks/verify.ts index 5f8c5234..f3f801bb 100644 --- a/packages/core/src/trust-tasks/verify.ts +++ b/packages/core/src/trust-tasks/verify.ts @@ -8,15 +8,16 @@ // Same canonicalization (JCS / RFC 8785) and signing input as `sign.ts`: // SHA-256(JCS(proofConfig-minus-proofValue)) || SHA-256(JCS(doc-minus-proof)), // verified with Ed25519 against the key the proof's `verificationMethod` -// resolves to. +// resolves to — read from the signer's DID document under the relationship +// the proof is for, in either encoding (`publicKeyMultibase`, or a did:webs +// `JsonWebKey`'s `publicKeyJwk`; VTI-KEY-003), through `did/vm-key.ts`. import { ed25519 } from "@noble/curves/ed25519.js"; -import { didKey, multibase, sameReference, stripScidSrc } from "@openvtc/vti-didcomm-js"; +import { didKey, sameReference, stripScidSrc } from "@openvtc/vti-didcomm-js"; +import { ED25519, isVerificationRelationship, selectKey, type VerificationRelationship } from "../did/vm-key.js"; import { resolveDidDocument } from "../didcomm/index.js"; import { base58btcDecode, jcsCanonicalize, sha256 } from "./canonical.js"; -const ED25519_PUB = multibase.MULTICODEC.ED25519_PUB; - /** A Data Integrity proof as produced by {@link signTrustTask}. */ interface DataIntegrityProof { type?: string; @@ -40,10 +41,14 @@ export interface VerifyTrustTaskProofResult { export interface VerifyTrustTaskProofOptions { /** The proof purpose the caller requires (e.g. `"assertionMethod"`). When - * set, a proof with a different `proofPurpose` fails verification, **and so - * does one whose `verificationMethod` the signer's DID document does not - * list under that relationship**. A proof that says `authentication` is an - * authentication by the DID only when its controller put the key there. */ + * set, a proof with a different `proofPurpose` fails verification. + * + * Either way, the key is read only from the relationship the proof is for + * (this purpose, or the `proofPurpose` the proof states when none is + * required), so a proof whose `verificationMethod` the signer's DID document + * does not list there fails. A proof that says `authentication` is an + * authentication by the DID only when its controller put the key there — and + * a did:webs with key roles puts different keys under each relationship. */ expectedProofPurpose?: "assertionMethod" | "authentication"; /** Resolve a DID to its DID document. Defaults to the wallet's built-in * resolver ({@link resolveDidDocument}, covers did:key / did:peer / did:webvh). @@ -79,29 +84,43 @@ export async function verifyTrustTaskProof( return { verified: false, reason: "missing/invalid proofValue (expected multibase 'z')" }; } + // The relationship the key must be listed under: the purpose the caller + // requires, or failing that the one the proof claims (Data Integrity: a + // verification method is used only for a purpose its controller authorised). + const relationship = opts.expectedProofPurpose ?? proof.proofPurpose; + if (!isVerificationRelationship(relationship)) { + return { verified: false, reason: `unsupported proofPurpose: ${String(proof.proofPurpose)}` }; + } + // 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)); + didDocument = controller.startsWith("did:key:") + ? (didKey.resolve(controller).didDocument as Record) + : await (opts.resolveDid ?? resolveDidDocument)(controller); } catch (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 - // it the same way (VTI #1740, affinidi-webvh-service #213). - if (opts.expectedProofPurpose && !listsMethodUnder(didDocument, vm, opts.expectedProofPurpose)) { + // The VTA, the VTC and the did-hosting RP check the relationship the same + // way (VTI #1740, affinidi-webvh-service #213). + if (!listsMethodUnder(didDocument, vm, relationship)) { return { verified: false, signer, - reason: `${vm} is not listed under ${opts.expectedProofPurpose} in the DID document of ${controller}`, + reason: `${vm} is not listed under ${relationship} in the DID document of ${controller}`, }; } + let publicKey: Uint8Array; + try { + ({ publicKey } = selectKey(didDocument, controller, relationship, { kid: vm, curves: [ED25519] })); + } catch (e) { + return { verified: false, signer, reason: `verificationMethod resolution failed: ${e instanceof Error ? e.message : String(e)}` }; + } // Reconstruct the signing input exactly as sign.ts built it: the proofConfig // is the proof object with `proofValue` removed. @@ -134,7 +153,7 @@ export async function verifyTrustTaskProof( export function listsMethodUnder( doc: Record, vm: string, - relationship: "assertionMethod" | "authentication", + relationship: VerificationRelationship, ): boolean { const hash = vm.indexOf("#"); const fragment = hash >= 0 ? vm.slice(hash) : undefined; @@ -148,27 +167,3 @@ export function listsMethodUnder( : typeof entry === "object" && entry !== null && names((entry as { id?: unknown }).id), ); } - -/** Resolve a `verificationMethod` DID URL to its Ed25519 public-key bytes, and - * return the DID document it was read from. */ -async function resolveEd25519Key( - vm: string, - controller: string, - resolveDid?: (did: string) => Promise>, -): Promise<{ key: Uint8Array; doc: Record }> { - const doc = controller.startsWith("did:key:") - ? (didKey.resolve(controller).didDocument as Record) - : await (resolveDid ?? resolveDidDocument)(controller); - - const methods = (doc.verificationMethod as { id: string; publicKeyMultibase?: string }[] | undefined) ?? []; - 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`); - } - const { codec, key } = multibase.decodeMultikey(method.publicKeyMultibase); - if (codec[0] !== ED25519_PUB[0] || codec[1] !== ED25519_PUB[1]) { - throw new Error("verificationMethod key is not Ed25519"); - } - return { key, doc }; -} diff --git a/packages/core/src/vta/tsp-vid.ts b/packages/core/src/vta/tsp-vid.ts index fdf5ad00..0b52a96b 100644 --- a/packages/core/src/vta/tsp-vid.ts +++ b/packages/core/src/vta/tsp-vid.ts @@ -4,23 +4,27 @@ // // TSP VIDs are DIDs, so this reuses the plugin's DID resolver. The X25519 key // is the DID's key-agreement VM (same one DIDComm authcrypts to). The Ed25519 -// key is the DID's authentication/assertion VM. +// key is the DID's `authentication` key: a TSP VID's keys are the DID's +// existing "Ed25519 (auth) + X25519 (keyAgreement)" pair +// (verifiable-trust-infrastructure `docs/05-design-notes/tsp-enablement.md`, +// D5), and the VTA's operational key — the one it signs as itself with — is +// the key it lists there (VTI-KEY-106). // -// The key-format conversions (OKP JWK -> raw, Multikey -> raw) are universal -// and unit-tested. The DID-document VM *selection* is a reasonable heuristic -// that needs validation against a live TSP-enabled VTA's DID document. +// The key is read through `did/vm-key.ts`, by relationship: never "any Ed25519 +// key" in `verificationMethod[]`, never by a fragment suffix, and failing +// closed on a method it cannot read. A did:webs with key roles (VTI-KEY-173) +// lists a different Ed25519 key under `assertionMethod` (attestation) than +// under `authentication` (operational), so a fallback would verify TSP frames +// against the wrong key. import { base58 } from "@scure/base"; +import { ED25519, publicKeyOf, selectKey } from "../did/vm-key.js"; import { resolveDidDocument, resolveKeyAgreement } from "../didcomm/index.js"; import { base64urlToBytes } from "../util/base64url.js"; import { VtaClientError } from "./errors.js"; import type { TspRemoteEndpoint } from "./tsp-channel.js"; -// Multicodec codes (unsigned-varint) for the raw public-key Multikey forms. -const MULTICODEC_ED25519_PUB = 0xed; -const MULTICODEC_X25519_PUB = 0xec; - /** Decode a leading unsigned-varint; returns the value + bytes consumed. */ function decodeVarint(bytes: Uint8Array): { value: number; length: number } { let value = 0; @@ -47,9 +51,11 @@ export function rawFromMultikey(multibase: string): { codec: number; key: Uint8A return { codec, key: decoded.slice(length) }; } -/** Extract the raw 32-byte public key of an OKP JWK, checking the curve. */ +/** Extract the raw 32-byte public key of an OKP **public** JWK, checking the + * curve. A JWK carrying `d` is refused: a public key's place is no place for + * a secret. */ export function rawFromOkpJwk( - jwk: { kty?: string; crv?: string; x?: string }, + jwk: { kty?: string; crv?: string; x?: string; d?: string }, expectedCrv: "X25519" | "Ed25519", ): Uint8Array { if (jwk.kty !== "OKP" || jwk.crv !== expectedCrv || !jwk.x) { @@ -58,6 +64,9 @@ export function rawFromOkpJwk( `tsp-vid: expected OKP ${expectedCrv} JWK, got kty=${jwk.kty} crv=${jwk.crv}`, ); } + if (jwk.d !== undefined) { + throw new VtaClientError("e.client.parse", `tsp-vid: the ${expectedCrv} JWK carries private key material`); + } const raw = base64urlToBytes(jwk.x); if (raw.length !== 32) { throw new VtaClientError("e.client.parse", `tsp-vid: ${expectedCrv} key is ${raw.length} bytes, want 32`); @@ -65,74 +74,37 @@ export function rawFromOkpJwk( return raw; } -/** Extract a raw 32-byte Ed25519 public key from a DID-document verification - * method (either `publicKeyMultibase` Multikey or `publicKeyJwk` OKP). */ -export function ed25519FromVerificationMethod(vm: { - publicKeyMultibase?: string; - publicKeyJwk?: { kty?: string; crv?: string; x?: string }; -}): Uint8Array { - if (vm.publicKeyMultibase) { - const { codec, key } = rawFromMultikey(vm.publicKeyMultibase); - if (codec !== MULTICODEC_ED25519_PUB) { - throw new VtaClientError("e.client.parse", `tsp-vid: VM is not Ed25519 (multicodec 0x${codec.toString(16)})`); - } - if (key.length !== 32) { - throw new VtaClientError("e.client.parse", `tsp-vid: Ed25519 key is ${key.length} bytes, want 32`); - } - return key; +/** The raw 32-byte Ed25519 public key of a DID-document verification method: + * a `Multikey` / `Ed25519VerificationKey2020` (`publicKeyMultibase`) or a + * `JsonWebKey` / `JsonWebKey2020` (`publicKeyJwk`). Fails closed on a missing + * or unknown `type`, an encoding the type does not carry, a revoked method, a + * JWK with `d`, or any curve but Ed25519. */ +export function ed25519FromVerificationMethod(vm: unknown): Uint8Array { + let key; + try { + key = publicKeyOf(vm); + } catch (e) { + throw new VtaClientError("e.client.parse", `tsp-vid: ${e instanceof Error ? e.message : String(e)}`); } - if (vm.publicKeyJwk) return rawFromOkpJwk(vm.publicKeyJwk, "Ed25519"); - throw new VtaClientError("e.client.parse", "tsp-vid: VM has no publicKeyMultibase or publicKeyJwk"); -} - -// Minimal DID-document shape we read. -interface DidVerificationMethod { - id?: string; - type?: string; - publicKeyMultibase?: string; - publicKeyJwk?: { kty?: string; crv?: string; x?: string }; -} -interface DidDocument { - verificationMethod?: DidVerificationMethod[]; - authentication?: Array; - assertionMethod?: Array; -} - -/** Resolve a verification-method reference (a `#fragment` id or an inline VM) - * against the doc's `verificationMethod` array. */ -function derefVm( - ref: string | DidVerificationMethod, - vms: DidVerificationMethod[], -): DidVerificationMethod | undefined { - if (typeof ref !== "string") return ref; - return vms.find((v) => v.id === ref || v.id?.endsWith(ref)); + if (key.crv !== ED25519) { + throw new VtaClientError("e.client.parse", `tsp-vid: the verification method is ${key.crv}, not Ed25519`); + } + return key.publicKey; } -/** Find the DID's Ed25519 signing key: prefer an `authentication` VM, then - * `assertionMethod`, then any Ed25519 verification method. */ -function findEd25519(doc: DidDocument): Uint8Array { - const vms = doc.verificationMethod ?? []; - for (const list of [doc.authentication, doc.assertionMethod]) { - for (const ref of list ?? []) { - const vm = derefVm(ref, vms); - if (!vm) continue; - try { - return ed25519FromVerificationMethod(vm); - } catch { - // not Ed25519 — keep looking - } - } +/** The VID's Ed25519 signing key: the first readable Ed25519 key its DID + * document lists under `authentication`. Nothing else is a substitute. */ +function findEd25519(vid: string, doc: unknown): Uint8Array { + try { + return selectKey(doc, vid, "authentication", { curves: [ED25519], label: "tsp-vid" }).publicKey; + } catch (e) { + throw new VtaClientError("e.client.parse", e instanceof Error ? e.message : String(e)); } - for (const vm of vms) { - try { - return ed25519FromVerificationMethod(vm); - } catch { - // keep looking - } - } - throw new VtaClientError("e.client.parse", "tsp-vid: DID document has no Ed25519 verification method"); } +// Minimal DID-document shape we read; the key reading itself is `vm-key`'s. +type DidDocument = Record; + /** * Assemble a {@link TspRemoteEndpoint} from already-resolved pieces: the DID, * its X25519 key-agreement JWK, and its DID document. Pure — no network. @@ -145,7 +117,7 @@ export function tspEndpointFromResolved( return { vid, encryptionPublicKey: rawFromOkpJwk(keyAgreementPublicJwk, "X25519"), - signingPublicKey: findEd25519(didDocument), + signingPublicKey: findEd25519(vid, didDocument), }; } diff --git a/packages/core/src/vtc/directory-card.ts b/packages/core/src/vtc/directory-card.ts index 78e0ce90..fba81812 100644 --- a/packages/core/src/vtc/directory-card.ts +++ b/packages/core/src/vtc/directory-card.ts @@ -36,6 +36,7 @@ import { TYPE_URI as KEYS_SIGN_DOCUMENT, RESPONSE_TYPE_URI as KEYS_SIGN_DOCUMENT_RESPONSE, } from "../specs/keys/sign-document/0.1/payload.js"; +import { ED25519, selectKey } from "../did/vm-key.js"; import type { TrustTaskSender } from "../vta/channel.js"; import { buildTrustTask } from "../vta/trust-task.js"; import { decodeMultibase } from "../trust-tasks/digest.js"; @@ -307,7 +308,14 @@ function keyBytes(multibase: string): string | null { } /** The Ed25519 keys `memberDoc` lists under `assertionMethod`, as hex. A - * `did:key` lists its one key under every relationship. */ + * `did:key` lists its one key under every relationship. + * + * Each entry is read through `did/vm-key.ts`, in either encoding — a did:webs + * lists `JsonWebKey` methods (VTI-KEY-003) — and only under + * `assertionMethod`: a did:webs with key roles lists its attestation key there + * and a different, operational key under `authentication` (VTI-KEY-173). An + * entry that cannot be read (unknown type, revoked, a JWK with `d`, another + * curve) contributes nothing. */ export function assertionKeys(memberDid: string, memberDoc: Record | null): Set { const out = new Set(); if (memberDid.startsWith("did:key:")) { @@ -316,29 +324,17 @@ export function assertionKeys(memberDid: string, memberDoc: Record (id.startsWith("#") ? `${memberDid}${id}` : id); - const methods = new Map(); - const add = (vm: unknown) => { - const m = vm as { id?: unknown; publicKeyMultibase?: unknown }; - if (typeof m?.id === "string" && typeof m.publicKeyMultibase === "string") { - const k = keyBytes(m.publicKeyMultibase); - if (k) methods.set(absolute(m.id), k); - } - }; - const vms = memberDoc.verificationMethod; - if (Array.isArray(vms)) vms.forEach(add); - const assertion = memberDoc.assertionMethod; - if (Array.isArray(assertion)) { - for (const entry of assertion) { - if (typeof entry === "string") { - const k = methods.get(absolute(entry)); - if (k) out.add(k); - } else { - add(entry); - const id = (entry as { id?: unknown })?.id; - const k = typeof id === "string" ? methods.get(absolute(id)) : undefined; - if (k) out.add(k); - } + const entries = memberDoc.assertionMethod; + if (!Array.isArray(entries)) return out; + for (const entry of entries) { + const ref = typeof entry === "string" ? entry : (entry as { id?: unknown } | null)?.id; + if (typeof ref !== "string") continue; + const kid = ref.startsWith("#") ? `${memberDid}${ref}` : ref; + try { + const { publicKey } = selectKey(memberDoc, memberDid, "assertionMethod", { kid, curves: [ED25519] }); + out.add(Array.from(publicKey, (b) => b.toString(16).padStart(2, "0")).join("")); + } catch { + // not a readable Ed25519 assertion key — it names no key the agent can sign as } } return out; diff --git a/packages/core/src/vtc/domain-linkage.ts b/packages/core/src/vtc/domain-linkage.ts index 5074f2bc..58dbf60e 100644 --- a/packages/core/src/vtc/domain-linkage.ts +++ b/packages/core/src/vtc/domain-linkage.ts @@ -21,8 +21,8 @@ import { ed25519 } from "@noble/curves/ed25519.js"; import { p256 } from "@noble/curves/nist.js"; -import { multibase } from "@openvtc/vti-didcomm-js"; +import { ED25519, P256, selectKey } from "../did/vm-key.js"; import { base64urlToBytes } from "../util/base64url.js"; import { withFetchTimeout, isFetchTimeout } from "../http/timeout-fetch.js"; import { listsMethodUnder, verifyTrustTaskProof } from "../trust-tasks/verify.js"; @@ -315,7 +315,7 @@ async function checkJwt(jwt: string, want: DomainLinkageExpectations): Promise, vm: string): PublicKey { +/** The public key of `vm`, which must be listed under `assertionMethod`. + * + * Read through `did/vm-key.ts`: either encoding (`publicKeyMultibase`, or a + * did:webs `JsonWebKey`'s `publicKeyJwk`; VTI-KEY-003), and refused for an + * unknown or missing type, a revoked method, a JWK carrying `d`, or a curve + * other than Ed25519 or P-256. */ +function assertionKey(doc: Record, did: string, vm: string): PublicKey { if (!listsMethodUnder(doc, vm, "assertionMethod")) fail("a key not under assertionMethod"); - const fragment = vm.slice(vm.indexOf("#")); - const names = (m: unknown) => { - const id = (m as { id?: unknown } | null)?.id; - return id === vm || id === fragment; - }; - const embedded = Array.isArray(doc.assertionMethod) - ? doc.assertionMethod.find((m) => m && typeof m === "object" && names(m)) - : undefined; - const listed = Array.isArray(doc.verificationMethod) ? doc.verificationMethod.find(names) : undefined; - const method = (embedded ?? listed) as - | { publicKeyMultibase?: unknown; publicKeyJwk?: { kty?: unknown; crv?: unknown; x?: unknown; y?: unknown } } - | undefined; - if (!method) return fail("a key the document does not have"); + let key; try { - if (typeof method.publicKeyMultibase === "string") { - const { codec, key } = multibase.decodeMultikey(method.publicKeyMultibase); - const is = (c: Uint8Array) => codec.length === c.length && codec.every((b, i) => b === c[i]); - if (is(multibase.MULTICODEC.ED25519_PUB) && key.length === 32) return { curve: "Ed25519", bytes: key }; - if (is(multibase.MULTICODEC.P256_PUB)) return { curve: "P-256", bytes: key }; - return fail("an unsupported key type"); - } - const jwk = method.publicKeyJwk; - if (jwk && typeof jwk === "object") { - if (jwk.kty === "OKP" && jwk.crv === "Ed25519" && typeof jwk.x === "string") { - const x = base64urlToBytes(jwk.x); - if (x.length === 32) return { curve: "Ed25519", bytes: x }; - } - if (jwk.kty === "EC" && jwk.crv === "P-256" && typeof jwk.x === "string" && typeof jwk.y === "string") { - const x = base64urlToBytes(jwk.x); - const y = base64urlToBytes(jwk.y); - if (x.length === 32 && y.length === 32) { - const bytes = new Uint8Array(65); - bytes[0] = 0x04; - bytes.set(x, 1); - bytes.set(y, 33); - return { curve: "P-256", bytes }; - } - } - } - } catch (err) { - if (err instanceof LinkageError) throw err; - return fail("a malformed key"); + key = selectKey(doc, did, "assertionMethod", { kid: vm }); + } catch (e) { + return fail(`an unusable key (${e instanceof Error ? e.message : String(e)})`); } + if (key.crv === ED25519) return { curve: "Ed25519", bytes: key.publicKey }; + if (key.crv === P256) return { curve: "P-256", bytes: key.publicKey }; return fail("an unsupported key type"); } diff --git a/packages/core/tests/did.webs-keys.mjs b/packages/core/tests/did.webs-keys.mjs new file mode 100644 index 00000000..1c722d9f --- /dev/null +++ b/packages/core/tests/did.webs-keys.mjs @@ -0,0 +1,333 @@ +// did:webs keys, read by relationship, at every site that reads a key out of a +// DID document (VTI-KEY-003). +// +// A did:webs publishes `JsonWebKey` verification methods (`publicKeyJwk`, ids +// `#`), and a VTA with key roles (VTI-KEY-173) keeps one key per +// relationship: an attestation key under `assertionMethod`, an operational key +// under `authentication`, and key agreement keys — P-256 before X25519 — under +// `keyAgreement`. So each site must pick its key by relationship: a key read as +// "the first Ed25519 key" or by fragment suffix is, for this document, the +// wrong key. Each site also keeps a did:webvh (Multikey) regression, and fails +// closed on what it cannot read. +// +// The party documents are vti-didcomm-js #39's (`fixtures/did-webs/README.md`); +// the VTA's Ed25519 secrets are known (seeds of 0x32 / 0x33), so its proofs here +// are real signatures. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { ed25519, x25519 } from "@noble/curves/ed25519.js"; +import { multibase } from "@openvtc/vti-didcomm-js"; + +import { + deriveSigningKeyId, + didMethod, + didWebsDomain, + publicKeyOf, + selectKey, + verifyDid, + x25519KeyAgreement, +} from "../dist/did/index.js"; +import { signTrustTask, verifyTrustTaskProof } from "../dist/trust-tasks/index.js"; +import { tspEndpointFromResolved } from "../dist/index.js"; +import { assertionKeys, verifyDomainLinkage, DID_CONFIGURATION_CONTEXT } from "../dist/vtc/index.js"; +import { bytesToBase64url } from "../dist/util/index.js"; + +const fixture = (party) => { + const dir = new URL(`./fixtures/did-webs/${party}/`, import.meta.url); + return { + did: readFileSync(new URL("did.txt", dir), "utf8").trim(), + doc: JSON.parse(readFileSync(new URL("resolved.json", dir), "utf8")), + }; +}; +const VTA = fixture("vta"); +const MEDIATOR = fixture("mediator"); +const clone = (o) => structuredClone(o); + +const ATTESTATION_SK = new Uint8Array(32).fill(0x32); +const OPERATIONAL_SK = new Uint8Array(32).fill(0x33); +const ATTESTATION_PK = ed25519.getPublicKey(ATTESTATION_SK); +const OPERATIONAL_PK = ed25519.getPublicKey(OPERATIONAL_SK); +const X25519_PK = x25519.getPublicKey(new Uint8Array(32).fill(0x31)); +const ATTESTATION_KID = VTA.doc.assertionMethod[0]; +const OPERATIONAL_KID = VTA.doc.authentication[0]; +const hex = (u8) => Buffer.from(u8).toString("hex"); +const b64url = (u8) => Buffer.from(u8).toString("base64url"); + +/** Rewrite the method `kid` names in a copy of `doc`. */ +function withMethod(doc, kid, edit) { + const out = clone(doc); + out.verificationMethod = out.verificationMethod.map((vm) => (vm.id === kid ? edit({ ...vm }) : vm)); + return out; +} +/** The fail-closed rewrites every site must refuse. */ +const UNREADABLE = { + "no type": (vm) => (delete vm.type, vm), + "an unknown type": (vm) => ({ ...vm, type: "EcdsaSecp256k1VerificationKey2019" }), + "a Multikey type on a JWK": (vm) => ({ ...vm, type: "Multikey" }), + "a JWK carrying d": (vm) => ({ ...vm, publicKeyJwk: { ...vm.publicKeyJwk, d: b64url(new Uint8Array(32)) } }), + revoked: (vm) => ({ ...vm, revoked: "2026-01-01T00:00:00Z" }), +}; + +// ── The shared reader (did/vm-key.ts) ─────────────────────────────────────── + +test("did:webs: each relationship yields its own key", () => { + const a = selectKey(VTA.doc, VTA.did, "assertionMethod", { curves: ["Ed25519"] }); + assert.equal(a.kid, ATTESTATION_KID); + assert.deepEqual(a.publicKey, ATTESTATION_PK); + const o = selectKey(VTA.doc, VTA.did, "authentication", { curves: ["Ed25519"] }); + assert.equal(o.kid, OPERATIONAL_KID); + assert.deepEqual(o.publicKey, OPERATIONAL_PK); + // The X25519 key is found by relationship and curve, past the P-256 one listed first. + const ka = x25519KeyAgreement(VTA.doc, VTA.did); + assert.equal(ka.kid, VTA.doc.keyAgreement[1]); + assert.deepEqual(ka.x25519Pub, X25519_PK); + assert.equal(selectKey(VTA.doc, VTA.did, "keyAgreement").crv, "P-256"); + // A key named under another relationship is refused, never substituted. + assert.throws(() => selectKey(VTA.doc, VTA.did, "authentication", { kid: ATTESTATION_KID }), /lists no authentication key/); +}); + +test("did:webs: an unreadable method is refused", () => { + for (const [why, edit] of Object.entries(UNREADABLE)) { + const doc = withMethod(VTA.doc, OPERATIONAL_KID, edit); + const vm = doc.verificationMethod.find((m) => m.id === OPERATIONAL_KID); + assert.throws(() => publicKeyOf(vm), why); + assert.throws(() => selectKey(doc, VTA.did, "authentication", { kid: OPERATIONAL_KID }), why); + assert.throws(() => selectKey(doc, VTA.did, "authentication"), why); + } + // A curve there is no primitive for. + const k1 = withMethod(VTA.doc, OPERATIONAL_KID, (vm) => ({ + ...vm, + publicKeyJwk: { kty: "EC", crv: "secp256k1", x: b64url(new Uint8Array(32)), y: b64url(new Uint8Array(32)) }, + })); + assert.throws(() => selectKey(k1, VTA.did, "authentication")); +}); + +// ── 1. Trust-Task proofs (trust-tasks/verify.ts) ──────────────────────────── + +async function signedBy(sk, kid, proofPurpose, issuer = VTA.did) { + const envelope = { id: "urn:uuid:1", type: "https://example.org/t/0.1", issuer, payload: { a: 1 } }; + await signTrustTask({ + envelope, + signing: { did: issuer, kid, privateKey: sk, publicKey: ed25519.getPublicKey(sk) }, + proofPurpose, + }); + return envelope; +} +const resolverOf = (doc) => async () => clone(doc); + +test("proof: a did:webs VTA's attestation key verifies for assertionMethod, its operational key for authentication", async () => { + const resolveDid = resolverOf(VTA.doc); + const assertion = await signedBy(ATTESTATION_SK, ATTESTATION_KID, "assertionMethod"); + const r1 = await verifyTrustTaskProof(assertion, { expectedProofPurpose: "assertionMethod", resolveDid }); + assert.equal(r1.verified, true, r1.reason); + assert.equal(r1.signer, VTA.did); + const auth = await signedBy(OPERATIONAL_SK, OPERATIONAL_KID, "authentication"); + const r2 = await verifyTrustTaskProof(auth, { expectedProofPurpose: "authentication", resolveDid }); + assert.equal(r2.verified, true, r2.reason); + // With no purpose required, the proof's own purpose names the relationship. + assert.equal((await verifyTrustTaskProof(auth, { resolveDid })).verified, true); +}); + +test("proof: a did:webs key is refused for the relationship it is not listed under", async () => { + const resolveDid = resolverOf(VTA.doc); + // The attestation key claiming to authenticate, and the operational key claiming to attest. + const asAuth = await signedBy(ATTESTATION_SK, ATTESTATION_KID, "authentication"); + for (const opts of [{ expectedProofPurpose: "authentication", resolveDid }, { resolveDid }]) { + const r = await verifyTrustTaskProof(asAuth, opts); + assert.equal(r.verified, false); + assert.match(r.reason, /not listed under authentication/); + } + const asAssertion = await signedBy(OPERATIONAL_SK, OPERATIONAL_KID, "assertionMethod"); + const r = await verifyTrustTaskProof(asAssertion, { expectedProofPurpose: "assertionMethod", resolveDid }); + assert.equal(r.verified, false); + // A key-agreement key is no signing key, whichever relationship lists it. + const ka = await signedBy(ATTESTATION_SK, VTA.doc.keyAgreement[1], "keyAgreement"); + assert.equal((await verifyTrustTaskProof(ka, { resolveDid })).verified, false); +}); + +test("proof: a did:webs method that cannot be read fails closed", async () => { + const doc = await signedBy(OPERATIONAL_SK, OPERATIONAL_KID, "authentication"); + for (const [why, edit] of Object.entries(UNREADABLE)) { + const r = await verifyTrustTaskProof(doc, { + expectedProofPurpose: "authentication", + resolveDid: resolverOf(withMethod(VTA.doc, OPERATIONAL_KID, edit)), + }); + assert.equal(r.verified, false, why); + } +}); + +test("proof: a did:webvh Multikey document still verifies (regression), and an id is never matched by suffix", async () => { + const did = "did:webvh:QmScid:example.com"; + const sk = ed25519.utils.randomSecretKey(); + const pk = ed25519.getPublicKey(sk); + const publicKeyMultibase = multibase.encodeMultikey(multibase.MULTICODEC.ED25519_PUB, pk); + const doc = { + id: did, + verificationMethod: [{ id: `${did}#key-0`, type: "Multikey", controller: did, publicKeyMultibase }], + authentication: [`${did}#key-0`], + assertionMethod: ["#key-0"], + }; + for (const purpose of ["authentication", "assertionMethod"]) { + const env = await signedBy(sk, `${did}#key-0`, purpose, did); + const r = await verifyTrustTaskProof(env, { expectedProofPurpose: purpose, resolveDid: resolverOf(doc) }); + assert.equal(r.verified, true, r.reason); + } + // A method whose id only *ends* in the fragment the proof names is another key. + const elsewhere = { + ...doc, + verificationMethod: [{ ...doc.verificationMethod[0], id: "did:webvh:QmOther:other.example#key-0" }], + authentication: ["#key-0"], + }; + const env = await signedBy(sk, `${did}#key-0`, "authentication", did); + assert.equal((await verifyTrustTaskProof(env, { resolveDid: resolverOf(elsewhere) })).verified, false); +}); + +// ── 2. The TSP VID signing key (vta/tsp-vid.ts) ───────────────────────────── + +const okp = (crv, pub) => ({ kty: "OKP", crv, x: b64url(pub) }); + +test("tsp: a did:webs VID signs with its authentication (operational) key, not its attestation key", () => { + const ep = tspEndpointFromResolved(VTA.did, okp("X25519", X25519_PK), clone(VTA.doc)); + assert.deepEqual(ep.signingPublicKey, OPERATIONAL_PK); + assert.notDeepEqual(ep.signingPublicKey, ATTESTATION_PK); + const med = MEDIATOR.doc.verificationMethod.find((vm) => vm.id === MEDIATOR.doc.authentication[0]); + const medEp = tspEndpointFromResolved(MEDIATOR.did, okp("X25519", X25519_PK), clone(MEDIATOR.doc)); + assert.equal(b64url(medEp.signingPublicKey), med.publicKeyJwk.x); +}); + +test("tsp: a did:webs VID with no readable authentication key has no endpoint", () => { + for (const [why, edit] of Object.entries(UNREADABLE)) { + const doc = withMethod(VTA.doc, OPERATIONAL_KID, edit); + assert.throws(() => tspEndpointFromResolved(VTA.did, okp("X25519", X25519_PK), doc), why); + } + // No authentication at all: the attestation key is not a substitute. + const noAuth = clone(VTA.doc); + delete noAuth.authentication; + assert.throws(() => tspEndpointFromResolved(VTA.did, okp("X25519", X25519_PK), noAuth)); +}); + +// ── 3. A member's directory-card keys (vtc/directory-card.ts) ─────────────── + +test("directory card: a did:webs member's assertion keys are exactly its attestation key", () => { + assert.deepEqual([...assertionKeys(VTA.did, clone(VTA.doc))], [hex(ATTESTATION_PK)]); + for (const [why, edit] of Object.entries(UNREADABLE)) { + const doc = withMethod(VTA.doc, ATTESTATION_KID, edit); + assert.equal(assertionKeys(VTA.did, doc).size, 0, why); + } +}); + +test("directory card: a did:webvh member's Multikey assertion keys (regression)", () => { + const did = "did:webvh:QmScid:example.com"; + const pk = ed25519.getPublicKey(ed25519.utils.randomSecretKey()); + const other = ed25519.getPublicKey(ed25519.utils.randomSecretKey()); + const mb = (k) => multibase.encodeMultikey(multibase.MULTICODEC.ED25519_PUB, k); + const doc = { + id: did, + verificationMethod: [ + { id: "#a", type: "Multikey", controller: did, publicKeyMultibase: mb(pk) }, + { id: `${did}#auth`, type: "Multikey", controller: did, publicKeyMultibase: mb(other) }, + ], + assertionMethod: [`${did}#a`], + authentication: ["#auth"], + }; + assert.deepEqual([...assertionKeys(did, doc)], [hex(pk)]); + // An embedded assertion method counts too. + const embedded = { ...doc, assertionMethod: [{ id: `${did}#e`, type: "Multikey", controller: did, publicKeyMultibase: mb(other) }] }; + assert.deepEqual([...assertionKeys(did, embedded)], [hex(other)]); +}); + +// ── 4. Domain Linkage Credentials (vtc/domain-linkage.ts) ─────────────────── + +const ORIGIN = "https://portal.example.org"; +const NOW = 1_791_460_900_000; +const DAY = 86_400_000; +const enc = (o) => bytesToBase64url(new TextEncoder().encode(JSON.stringify(o))); +function linkageJwt(did, kid, sk) { + const h = enc({ alg: "EdDSA", kid, typ: "JWT" }); + const p = enc({ + iss: did, + sub: did, + nbf: Math.floor((NOW - DAY) / 1000), + exp: Math.floor((NOW + DAY) / 1000), + vc: { + "@context": ["https://www.w3.org/2018/credentials/v1", DID_CONFIGURATION_CONTEXT], + type: ["VerifiableCredential", "DomainLinkageCredential"], + issuer: did, + issuanceDate: new Date(NOW - DAY).toISOString(), + expirationDate: new Date(NOW + DAY).toISOString(), + credentialSubject: { id: did, origin: ORIGIN }, + }, + }); + const sig = ed25519.sign(new TextEncoder().encode(`${h}.${p}`), sk); + return `${h}.${p}.${bytesToBase64url(sig)}`; +} +const linkage = (did, doc, token) => + verifyDomainLinkage({ + did, + didDocument: doc, + origin: ORIGIN, + now: NOW, + fetch: async () => new Response(JSON.stringify({ "@context": DID_CONFIGURATION_CONTEXT, linked_dids: [token] })), + }); + +test("domain linkage: a did:webs credential verifies with its attestation key only", async () => { + assert.deepEqual(await linkage(VTA.did, clone(VTA.doc), linkageJwt(VTA.did, ATTESTATION_KID, ATTESTATION_SK)), { + ok: true, + format: "jwt", + }); + const op = await linkage(VTA.did, clone(VTA.doc), linkageJwt(VTA.did, OPERATIONAL_KID, OPERATIONAL_SK)); + assert.equal(op.ok, false); + assert.equal(op.detail, "a key not under assertionMethod"); +}); + +test("domain linkage: a did:webs assertion key that cannot be read fails closed", async () => { + const token = linkageJwt(VTA.did, ATTESTATION_KID, ATTESTATION_SK); + for (const [why, edit] of Object.entries(UNREADABLE)) { + const r = await linkage(VTA.did, withMethod(VTA.doc, ATTESTATION_KID, edit), token); + assert.equal(r.ok, false, why); + assert.match(r.detail, /^an unusable key/, why); + } +}); + +// ── 5. DID methods (did/verify.ts, did/derive-signing-key.ts) ─────────────── + +const resolving = (doc) => { + const calls = []; + return { calls, resolveDid: async (d) => (calls.push(d), { didDocument: clone(doc), didResolutionMetadata: {} }) }; +}; + +test("didMethod knows did:webs, and verifyDid judges its host before resolving", async () => { + assert.equal(didMethod(VTA.did), "webs"); + assert.equal(didWebsDomain(VTA.did), "vta.example.com"); + assert.equal(didWebsDomain("did:webs:example.com%3A8443:people:EAid"), "example.com%3A8443"); + const ok = resolving(VTA.doc); + const r = await verifyDid(VTA.did, { resolveDid: ok.resolveDid }); + assert.equal(r.method, "webs"); + assert.equal(r.resolved, true, r.error); + assert.equal(r.domain, "vta.example.com"); + for (const host of ["localhost", "127.0.0.1", "10.0.0.1", "intranet"]) { + const blocked = resolving(VTA.doc); + const did = `did:webs:${host}:EGmHgrgkre3Xw39L0_4q7ijQaX3eCdOFI1MizIr-Z9vT`; + const res = await verifyDid(did, { resolveDid: blocked.resolveDid }); + assert.equal(res.resolved, false, host); + assert.deepEqual(blocked.calls, [], `${host} reached the resolver`); + assert.deepEqual((await deriveSigningKeyId(did, { resolveDid: blocked.resolveDid })).candidates, [], host); + assert.deepEqual(blocked.calls, [], `${host} reached the resolver`); + } +}); + +test("deriveSigningKeyId: a did:webs offers its authentication key, never any verification method", async () => { + assert.deepEqual(await deriveSigningKeyId(VTA.did, { resolveDid: resolving(VTA.doc).resolveDid }), { + did: VTA.did, + candidates: [OPERATIONAL_KID], + }); + // With neither signing relationship, nothing — not its key-agreement keys. + const bare = clone(VTA.doc); + delete bare.authentication; + delete bare.assertionMethod; + const r = await deriveSigningKeyId(VTA.did, { resolveDid: resolving(bare).resolveDid }); + assert.deepEqual(r.candidates, []); + assert.ok(r.error); +}); diff --git a/packages/core/tests/fixtures/did-webs/README.md b/packages/core/tests/fixtures/did-webs/README.md new file mode 100644 index 00000000..344f6435 --- /dev/null +++ b/packages/core/tests/fixtures/did-webs/README.md @@ -0,0 +1,22 @@ +# did:webs party documents + +Copied from `@openvtc/vti-didcomm-js` +(, +`test/fixtures/did-webs/parties/`, commit `f283fa1`, vti-didcomm-js #39), +Apache-2.0. Only the DID (`did.txt`) and the document the Rust resolver +(`affinidi-did-webs` 0.13.3) derived from the published artifacts +(`resolved.json`) are copied; the generator and the `keri.cesr` / `did.json` +artifacts are in that repository's README beside the originals. + +- `vta/` — a VTA with **key roles** (VTI-KEY-173): the `attestation` key + (`assertionMethod`) and the `operational` key (`authentication`) are + different Ed25519 keys, and `keyAgreement` lists a P-256 key before an X25519 + key. + - Ed25519 attestation secret: the 32-byte seed of `0x32`. + - Ed25519 operational secret: the 32-byte seed of `0x33`. + - X25519 key agreement secret: 32 bytes of `0x31`; P-256: 32 bytes of `0x34`. +- `mediator/` — one Ed25519 key (under `authentication` and + `assertionMethod`), one X25519 key agreement key (secret: 32 bytes of + `0x41`) and a `DIDCommMessaging` service. + +These are test keys derived from constant bytes; they must never hold anything. diff --git a/packages/core/tests/fixtures/did-webs/mediator/did.txt b/packages/core/tests/fixtures/did-webs/mediator/did.txt new file mode 100644 index 00000000..9f152726 --- /dev/null +++ b/packages/core/tests/fixtures/did-webs/mediator/did.txt @@ -0,0 +1 @@ +did:webs:mediator.example.com:EOTLDI3hP4_7x_QNCwCCbZ_jhqVuBToi_RBwRhXDaebP \ No newline at end of file diff --git a/packages/core/tests/fixtures/did-webs/mediator/resolved.json b/packages/core/tests/fixtures/did-webs/mediator/resolved.json new file mode 100644 index 00000000..92e55328 --- /dev/null +++ b/packages/core/tests/fixtures/did-webs/mediator/resolved.json @@ -0,0 +1,65 @@ +{ + "id": "did:webs:mediator.example.com:EOTLDI3hP4_7x_QNCwCCbZ_jhqVuBToi_RBwRhXDaebP", + "alsoKnownAs": [ + "did:web:mediator.example.com:EOTLDI3hP4_7x_QNCwCCbZ_jhqVuBToi_RBwRhXDaebP", + "did:keri:EOTLDI3hP4_7x_QNCwCCbZ_jhqVuBToi_RBwRhXDaebP" + ], + "verificationMethod": [ + { + "id": "did:webs:mediator.example.com:EOTLDI3hP4_7x_QNCwCCbZ_jhqVuBToi_RBwRhXDaebP#DJhzYCh_3AXEzNNGyUHONmb5IowV4N5St5UMYUMxJvmP", + "type": "JsonWebKey", + "controller": "did:webs:mediator.example.com:EOTLDI3hP4_7x_QNCwCCbZ_jhqVuBToi_RBwRhXDaebP", + "publicKeyJwk": { + "crv": "Ed25519", + "kid": "DJhzYCh_3AXEzNNGyUHONmb5IowV4N5St5UMYUMxJvmP", + "kty": "OKP", + "x": "mHNgKH_cBcTM00bJQc42ZvkijBXg3lK3lQxhQzEm-Y8" + } + }, + { + "id": "did:webs:mediator.example.com:EOTLDI3hP4_7x_QNCwCCbZ_jhqVuBToi_RBwRhXDaebP#CHoaTnCb8IWsSUq6BGm5se2gqx94sWqrt5_-2pBiPoUi", + "type": "JsonWebKey", + "controller": "did:webs:mediator.example.com:EOTLDI3hP4_7x_QNCwCCbZ_jhqVuBToi_RBwRhXDaebP", + "publicKeyJwk": { + "crv": "X25519", + "kid": "CHoaTnCb8IWsSUq6BGm5se2gqx94sWqrt5_-2pBiPoUi", + "kty": "OKP", + "x": "ehpOcJvwhaxJSroEabmx7aCrH3ixaqu3n_7akGI-hSI" + } + } + ], + "authentication": [ + "did:webs:mediator.example.com:EOTLDI3hP4_7x_QNCwCCbZ_jhqVuBToi_RBwRhXDaebP#DJhzYCh_3AXEzNNGyUHONmb5IowV4N5St5UMYUMxJvmP" + ], + "assertionMethod": [ + "did:webs:mediator.example.com:EOTLDI3hP4_7x_QNCwCCbZ_jhqVuBToi_RBwRhXDaebP#DJhzYCh_3AXEzNNGyUHONmb5IowV4N5St5UMYUMxJvmP" + ], + "keyAgreement": [ + "did:webs:mediator.example.com:EOTLDI3hP4_7x_QNCwCCbZ_jhqVuBToi_RBwRhXDaebP#CHoaTnCb8IWsSUq6BGm5se2gqx94sWqrt5_-2pBiPoUi" + ], + "service": [ + { + "id": "did:webs:mediator.example.com:EOTLDI3hP4_7x_QNCwCCbZ_jhqVuBToi_RBwRhXDaebP#didcomm", + "type": [ + "DIDCommMessaging" + ], + "serviceEndpoint": [ + { + "accept": [ + "didcomm/v2" + ], + "routingKeys": [], + "uri": "https://mediator.example.com" + }, + { + "accept": [ + "didcomm/v2" + ], + "routingKeys": [], + "uri": "wss://mediator.example.com/ws" + } + ] + } + ], + "controller": "did:webs:mediator.example.com:EOTLDI3hP4_7x_QNCwCCbZ_jhqVuBToi_RBwRhXDaebP" +} \ No newline at end of file diff --git a/packages/core/tests/fixtures/did-webs/vta/did.txt b/packages/core/tests/fixtures/did-webs/vta/did.txt new file mode 100644 index 00000000..062854ee --- /dev/null +++ b/packages/core/tests/fixtures/did-webs/vta/did.txt @@ -0,0 +1 @@ +did:webs:vta.example.com:EGmHgrgkre3Xw39L0_4q7ijQaX3eCdOFI1MizIr-Z9vT \ No newline at end of file diff --git a/packages/core/tests/fixtures/did-webs/vta/resolved.json b/packages/core/tests/fixtures/did-webs/vta/resolved.json new file mode 100644 index 00000000..623fd9c1 --- /dev/null +++ b/packages/core/tests/fixtures/did-webs/vta/resolved.json @@ -0,0 +1,77 @@ +{ + "id": "did:webs:vta.example.com:EGmHgrgkre3Xw39L0_4q7ijQaX3eCdOFI1MizIr-Z9vT", + "alsoKnownAs": [ + "did:web:vta.example.com:EGmHgrgkre3Xw39L0_4q7ijQaX3eCdOFI1MizIr-Z9vT", + "did:keri:EGmHgrgkre3Xw39L0_4q7ijQaX3eCdOFI1MizIr-Z9vT" + ], + "verificationMethod": [ + { + "id": "did:webs:vta.example.com:EGmHgrgkre3Xw39L0_4q7ijQaX3eCdOFI1MizIr-Z9vT#DF4hLAmA5LOfwJchE0qgIQk3Tt_SYMDT0Dy1AcjWVFep", + "type": "JsonWebKey", + "controller": "did:webs:vta.example.com:EGmHgrgkre3Xw39L0_4q7ijQaX3eCdOFI1MizIr-Z9vT", + "publicKeyJwk": { + "crv": "Ed25519", + "kid": "DF4hLAmA5LOfwJchE0qgIQk3Tt_SYMDT0Dy1AcjWVFep", + "kty": "OKP", + "x": "XiEsCYDks5_AlyETSqAhCTdO39JgwNPQPLUByNZUV6k" + } + }, + { + "id": "did:webs:vta.example.com:EGmHgrgkre3Xw39L0_4q7ijQaX3eCdOFI1MizIr-Z9vT#DBfLefsrQSDysexl5BmNbgiyjoE_6wHkpACDm4XhgIDO", + "type": "JsonWebKey", + "controller": "did:webs:vta.example.com:EGmHgrgkre3Xw39L0_4q7ijQaX3eCdOFI1MizIr-Z9vT", + "publicKeyJwk": { + "crv": "Ed25519", + "kid": "DBfLefsrQSDysexl5BmNbgiyjoE_6wHkpACDm4XhgIDO", + "kty": "OKP", + "x": "F8t5-ytBIPKx7GXkGY1uCLKOgT_rAeSkAIObheGAgM4" + } + }, + { + "id": "did:webs:vta.example.com:EGmHgrgkre3Xw39L0_4q7ijQaX3eCdOFI1MizIr-Z9vT#1AAJAxAExOYTxnMlFtF30ibtc4LWFn92rfoHJDanK2UgZM61", + "type": "JsonWebKey", + "controller": "did:webs:vta.example.com:EGmHgrgkre3Xw39L0_4q7ijQaX3eCdOFI1MizIr-Z9vT", + "publicKeyJwk": { + "crv": "P-256", + "kid": "1AAJAxAExOYTxnMlFtF30ibtc4LWFn92rfoHJDanK2UgZM61", + "kty": "EC", + "x": "EATE5hPGcyUW0XfSJu1zgtYWf3at-gckNqcrZSBkzrU", + "y": "cukFvLyw50nE1l0xPIHPdzVcMAEe_wlEewONNSLOtz0" + } + }, + { + "id": "did:webs:vta.example.com:EGmHgrgkre3Xw39L0_4q7ijQaX3eCdOFI1MizIr-Z9vT#CAT18pFiwxqN76GObnQiJO6Ab8FxiieL6Fm6ViBAK486", + "type": "JsonWebKey", + "controller": "did:webs:vta.example.com:EGmHgrgkre3Xw39L0_4q7ijQaX3eCdOFI1MizIr-Z9vT", + "publicKeyJwk": { + "crv": "X25519", + "kid": "CAT18pFiwxqN76GObnQiJO6Ab8FxiieL6Fm6ViBAK486", + "kty": "OKP", + "x": "BPXykWLDGo3voY5udCIk7oBvwXGKJ4voWbpWIEArjzo" + } + } + ], + "authentication": [ + "did:webs:vta.example.com:EGmHgrgkre3Xw39L0_4q7ijQaX3eCdOFI1MizIr-Z9vT#DBfLefsrQSDysexl5BmNbgiyjoE_6wHkpACDm4XhgIDO" + ], + "assertionMethod": [ + "did:webs:vta.example.com:EGmHgrgkre3Xw39L0_4q7ijQaX3eCdOFI1MizIr-Z9vT#DF4hLAmA5LOfwJchE0qgIQk3Tt_SYMDT0Dy1AcjWVFep" + ], + "keyAgreement": [ + "did:webs:vta.example.com:EGmHgrgkre3Xw39L0_4q7ijQaX3eCdOFI1MizIr-Z9vT#1AAJAxAExOYTxnMlFtF30ibtc4LWFn92rfoHJDanK2UgZM61", + "did:webs:vta.example.com:EGmHgrgkre3Xw39L0_4q7ijQaX3eCdOFI1MizIr-Z9vT#CAT18pFiwxqN76GObnQiJO6Ab8FxiieL6Fm6ViBAK486" + ], + "keyRoles": { + "attestation": [ + "did:webs:vta.example.com:EGmHgrgkre3Xw39L0_4q7ijQaX3eCdOFI1MizIr-Z9vT#DF4hLAmA5LOfwJchE0qgIQk3Tt_SYMDT0Dy1AcjWVFep" + ], + "messaging": [ + "did:webs:vta.example.com:EGmHgrgkre3Xw39L0_4q7ijQaX3eCdOFI1MizIr-Z9vT#1AAJAxAExOYTxnMlFtF30ibtc4LWFn92rfoHJDanK2UgZM61", + "did:webs:vta.example.com:EGmHgrgkre3Xw39L0_4q7ijQaX3eCdOFI1MizIr-Z9vT#CAT18pFiwxqN76GObnQiJO6Ab8FxiieL6Fm6ViBAK486" + ], + "operational": [ + "did:webs:vta.example.com:EGmHgrgkre3Xw39L0_4q7ijQaX3eCdOFI1MizIr-Z9vT#DBfLefsrQSDysexl5BmNbgiyjoE_6wHkpACDm4XhgIDO" + ] + }, + "controller": "did:webs:vta.example.com:EGmHgrgkre3Xw39L0_4q7ijQaX3eCdOFI1MizIr-Z9vT" +} \ No newline at end of file diff --git a/packages/core/tests/tsp.vid.mjs b/packages/core/tests/tsp.vid.mjs index 1d059342..920939a5 100644 --- a/packages/core/tests/tsp.vid.mjs +++ b/packages/core/tests/tsp.vid.mjs @@ -43,19 +43,42 @@ test("rawFromMultikey decodes the multicodec + raw key (round-trips encodeMultik assert.throws(() => rawFromMultikey("Qnot-base58btc")); // no 'z' prefix }); -test("ed25519FromVerificationMethod reads Multikey and JWK forms", () => { +test("ed25519FromVerificationMethod reads Multikey and JWK forms, by type", () => { const pub = ed25519.getPublicKey(ed25519.utils.randomSecretKey()); + const mk = encodeMultikey(ED25519_PUB, pub); + assert.deepEqual(ed25519FromVerificationMethod({ id: "#a", type: "Multikey", publicKeyMultibase: mk }), pub); assert.deepEqual( - ed25519FromVerificationMethod({ publicKeyMultibase: encodeMultikey(ED25519_PUB, pub) }), + ed25519FromVerificationMethod({ id: "#a", type: "Ed25519VerificationKey2020", publicKeyMultibase: mk }), pub, ); - assert.deepEqual(ed25519FromVerificationMethod({ publicKeyJwk: okpJwk("Ed25519", pub) }), pub); + for (const type of ["JsonWebKey", "JsonWebKey2020"]) { + assert.deepEqual(ed25519FromVerificationMethod({ id: "#a", type, publicKeyJwk: okpJwk("Ed25519", pub) }), pub); + } // A non-Ed25519 Multikey (X25519) must be rejected. const xpub = x25519.getPublicKey(x25519.utils.randomSecretKey()); assert.throws(() => - ed25519FromVerificationMethod({ publicKeyMultibase: encodeMultikey(X25519_PUB, xpub) }), + ed25519FromVerificationMethod({ id: "#x", type: "Multikey", publicKeyMultibase: encodeMultikey(X25519_PUB, xpub) }), ); + // Fail closed: no type, an unknown type, the encoding the type does not + // carry, both encodings, a JWK with `d`, a revoked method. + const fails = [ + { publicKeyMultibase: mk }, + { type: "EcdsaSecp256k1VerificationKey2019", publicKeyMultibase: mk }, + { type: "JsonWebKey", publicKeyMultibase: mk }, + { type: "Multikey", publicKeyJwk: okpJwk("Ed25519", pub) }, + { type: "Multikey", publicKeyMultibase: mk, publicKeyJwk: okpJwk("Ed25519", pub) }, + { type: "JsonWebKey", publicKeyJwk: { ...okpJwk("Ed25519", pub), d: b64url(new Uint8Array(32)) } }, + { type: "Multikey", publicKeyMultibase: mk, revoked: "2026-01-01T00:00:00Z" }, + ]; + for (const vm of fails) { + assert.throws(() => ed25519FromVerificationMethod({ id: "#a", ...vm }), JSON.stringify(vm)); + } +}); + +test("rawFromOkpJwk refuses a JWK carrying private material", () => { + const xpub = x25519.getPublicKey(x25519.utils.randomSecretKey()); + assert.throws(() => rawFromOkpJwk({ ...okpJwk("X25519", xpub), d: b64url(new Uint8Array(32)) }, "X25519")); }); test("tspEndpointFromResolved: X25519 from JWK + Ed25519 via authentication VM", () => { @@ -77,23 +100,61 @@ test("tspEndpointFromResolved: X25519 from JWK + Ed25519 via authentication VM", assert.deepEqual(ep.signingPublicKey, edPub); }); -test("tspEndpointFromResolved: falls back to any Ed25519 VM when no auth ref", () => { +test("tspEndpointFromResolved: no authentication key means no endpoint — never any Ed25519 VM", () => { const edPub = ed25519.getPublicKey(ed25519.utils.randomSecretKey()); const xPub = x25519.getPublicKey(x25519.utils.randomSecretKey()); - const doc = { - verificationMethod: [ - { id: "#x", type: "Multikey", publicKeyMultibase: encodeMultikey(X25519_PUB, xPub) }, - { id: "#e", type: "Multikey", publicKeyMultibase: encodeMultikey(ED25519_PUB, edPub) }, - ], - }; - const ep = tspEndpointFromResolved("did:key:z", okpJwk("X25519", xPub), doc); - assert.deepEqual(ep.signingPublicKey, edPub); + const did = "did:web:vta.example"; + const vms = [ + { id: "#x", type: "Multikey", publicKeyMultibase: encodeMultikey(X25519_PUB, xPub) }, + { id: "#e", type: "Multikey", publicKeyMultibase: encodeMultikey(ED25519_PUB, edPub) }, + ]; + // An Ed25519 key under no relationship, or only under assertionMethod, is + // not the VID's signing key. + assert.throws(() => tspEndpointFromResolved(did, okpJwk("X25519", xPub), { verificationMethod: vms })); + assert.throws(() => + tspEndpointFromResolved(did, okpJwk("X25519", xPub), { verificationMethod: vms, assertionMethod: ["#e"] }), + ); + // The same key under authentication, relative or absolute, is. + for (const ref of ["#e", `${did}#e`]) { + const ep = tspEndpointFromResolved(did, okpJwk("X25519", xPub), { verificationMethod: vms, authentication: [ref] }); + assert.deepEqual(ep.signingPublicKey, edPub); + } +}); + +test("tspEndpointFromResolved dereferences an id exactly, never by fragment suffix", () => { + const edPub = ed25519.getPublicKey(ed25519.utils.randomSecretKey()); + const xPub = x25519.getPublicKey(x25519.utils.randomSecretKey()); + const did = "did:web:vta.example"; + // `#e` must not match another DID's `#e`, nor `#key-e`. + for (const id of ["did:web:other.example#e", `${did}#key-e`]) { + const doc = { + verificationMethod: [{ id, type: "Multikey", publicKeyMultibase: encodeMultikey(ED25519_PUB, edPub) }], + authentication: ["#e"], + }; + assert.throws(() => tspEndpointFromResolved(did, okpJwk("X25519", xPub), doc), id); + } +}); + +test("tspEndpointFromResolved fails closed on an authentication key it cannot read", () => { + const edPub = ed25519.getPublicKey(ed25519.utils.randomSecretKey()); + const xPub = x25519.getPublicKey(x25519.utils.randomSecretKey()); + const did = "did:web:vta.example"; + const mk = encodeMultikey(ED25519_PUB, edPub); + for (const vm of [ + { publicKeyMultibase: mk }, + { type: "Multikey", publicKeyMultibase: mk, revoked: "2026-01-01T00:00:00Z" }, + { type: "JsonWebKey", publicKeyJwk: { ...okpJwk("Ed25519", edPub), d: b64url(new Uint8Array(32)) } }, + ]) { + const doc = { verificationMethod: [{ id: `${did}#e`, ...vm }], authentication: ["#e"] }; + assert.throws(() => tspEndpointFromResolved(did, okpJwk("X25519", xPub), doc), JSON.stringify(vm)); + } }); test("tspEndpointFromResolved throws when no Ed25519 key is present", () => { const xPub = x25519.getPublicKey(x25519.utils.randomSecretKey()); const doc = { verificationMethod: [{ id: "#x", type: "Multikey", publicKeyMultibase: encodeMultikey(X25519_PUB, xPub) }], + authentication: ["#x"], }; assert.throws(() => tspEndpointFromResolved("did:web:x", okpJwk("X25519", xPub), doc)); }); diff --git a/packages/core/tests/vta.outbound-signing.mjs b/packages/core/tests/vta.outbound-signing.mjs index ac711992..179873e6 100644 --- a/packages/core/tests/vta.outbound-signing.mjs +++ b/packages/core/tests/vta.outbound-signing.mjs @@ -451,9 +451,15 @@ test("a proof counts for a purpose only when the signer lists the key under it", assert.equal(assertionOnly.verified, false); assert.match(assertionOnly.reason, /not listed under authentication/); - // Without a required purpose nothing about relationships is asked, as before. + // Without a required purpose the key is still read from the relationship + // the proof claims (Data Integrity): an `authentication` proof by a key + // listed only under `assertionMethod` is no proof at all, so a did:webs with + // key roles cannot have its attestation key pass as its operational one. const any = await verifyTrustTaskProof(envelope, { resolveDid: docFor({ assertionMethod: [vm] }) }); - assert.equal(any.verified, true, any.reason); + assert.equal(any.verified, false); + assert.match(any.reason, /not listed under authentication/); + const listed = await verifyTrustTaskProof(envelope, { resolveDid: docFor({ authentication: [vm] }) }); + assert.equal(listed.verified, true, listed.reason); }); test("a reply is evidence only under authentication, unless the channel says otherwise", async () => { diff --git a/packages/extension/src/bridge-protocol.ts b/packages/extension/src/bridge-protocol.ts index 45be8dfd..4f965d16 100644 --- a/packages/extension/src/bridge-protocol.ts +++ b/packages/extension/src/bridge-protocol.ts @@ -679,7 +679,7 @@ export interface RuntimeVerifyRpDidRequest { * depend on `@openvtc/pnm-core` types directly. */ export interface VerifyRpDidResult { did: string; - method: "webvh" | "scid" | "peer" | "key" | "unknown"; + method: "webvh" | "webs" | "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 b1525a72..acdf9fda 100644 --- a/packages/extension/src/confirm.tsx +++ b/packages/extension/src/confirm.tsx @@ -455,6 +455,7 @@ function VerificationDetails({ state }: { state: VerificationState }) { Method: did:{result.method} {(result.method === "webvh" || result.method === "scid") && " — log chain + proofs verified"} + {result.method === "webs" && " — key event log verified"} {(result.method === "peer" || result.method === "key") && " — self-certifying identifier"} , );