diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 1d679aa..09280d8 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 1850b68..5f00ffd 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 56faeee..fc597f3 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 d2ba8e7..18dd075 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 a8323f7..37505ed 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 0000000..a5fb998 --- /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 5f8c523..f3f801b 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 fdf5ad0..0b52a96 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 78e0ce9..fba8181 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 5074f2b..58dbf60 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 0000000..1c722d9 --- /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 0000000..344f643 --- /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 0000000..9f15272 --- /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 0000000..92e5532 --- /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 0000000..062854e --- /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 0000000..623fd9c --- /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 1d05934..920939a 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 ac71199..179873e 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 45be8df..4f965d1 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 b1525a7..acdf9fd 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"} , );