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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions packages/core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<did>#<CESR key>`), 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
Expand Down
25 changes: 18 additions & 7 deletions packages/core/src/did/derive-signing-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<did>#<CESR key>`, 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.
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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" };
Expand Down
20 changes: 17 additions & 3 deletions packages/core/src/did/egress-guard.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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). */
Expand Down Expand Up @@ -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:<host>[:<path>]:<AID>`:
* 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`,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/did/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
41 changes: 35 additions & 6 deletions packages/core/src/did/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,26 +18,34 @@
// its `src` names (`did:scid:vh:1:<scid>?src=example.com/alice`). Its identity
// is the bare DID: the resolved document's `id` is `did:scid:vh:1:<scid>`, and
// a bare DID resolves only through a source the resolver already verified.
//
// did:webs (`did:webs:<host>[:<path>]:<AID>`) 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. */
did: string;
/** 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:<scid>:<host>[:<path>...]`. For a did:scid
* DID URL, the host its `src` names; a bare did:scid names none. */
* for). The DID is `did:webvh:<scid>:<host>[:<path>...]`. For a did:webs,
* likewise the host it names (`did:webs:<host>[:<path>...]:<AID>`). 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;
Expand All @@ -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";
Expand All @@ -86,6 +95,18 @@ export function didWebvhDomain(did: string): string | undefined {
return parts[3];
}

/**
* The host portion of a `did:webs:<host>[:<path>...]:<AID>`, 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", "<host>", ...path, "<AID>"]
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
Expand Down Expand Up @@ -122,7 +143,14 @@ export async function verifyDid(
opts: ResolveDidOptions = {},
): Promise<VerifyDidResult> {
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,
Expand All @@ -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);
Expand Down
Loading
Loading