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
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 22 additions & 0 deletions packages/core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,28 @@ For history before this file, see `git log` on `packages/core`.

### Added

- **did:scid contacts and DIDs, used natively.** A trigger link's `_from` may
be a `did:scid:vh:1:<scid>?src=<host>[/<path>]` DID URL: it is host-named
(route A, VTI-LNK-119), shown as its `src` host and path (VTI-LNK-050),
resolved through that DID URL with the `src` host guarded like a did:webvh
host (`assertResolvableScidHost`), and recorded and addressed as the bare
`did:scid:vh:1:<scid>` (`contactIdentity`, `didIdentity`). A bare did:scid
`_from` names no host and is `from-not-allowed`; another did:scid format is
`unsupported-vid`. `verifyDid` and `deriveSigningKeyId` take did:scid
(`DidMethod` gains `"scid"`, `didScidDomain`). Needs `@openvtc/vti-didcomm-js`
with did:scid support.
- `SCID_CONTACT_POLICY`, `SCID_CONTACT_METHOD`; a contact policy may say
`supports(did)` (false is `unsupported-vid`), and a host-named contact whose
`hostPath` names no host has no route.

### Changed

- `verifyTrustTaskProof` attributes a proof to its verification method's DID
without a did:scid `src` (`signer` is the identity) and matches the key and
its relationship by DID and fragment, ignoring that `src`. A community's
reply signed under `did:scid:…?src=…#key` is checked against the community's
identity the same way.

- **`@openvtc/pnm-core/rooms/files`** — room files, for a wallet's offscreen
document (it names `rooms/keys/file-key`, so a console must not import it).
The host's blob tasks (`rooms/blobs/*`, `rooms/info`, `rooms/records/{get,put}/0.2`,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@
"@cfworker/json-schema": "^4.1.1",
"@noble/curves": "^2.4.0",
"@openvtc/trust-tasks": "^0.27.2",
"@openvtc/vti-didcomm-js": "^0.12.0",
"@openvtc/vti-didcomm-js": "^0.13.0",
"@openvtc/vti-tsp-js": "^0.4.0",
"@scure/base": "^2.2.0",
"cbor-x": "^1.6.6"
Expand Down
7 changes: 4 additions & 3 deletions packages/core/src/did/derive-signing-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@
// path; this file is a parallel surface focused on extracting the
// VM id rather than just validating resolution.

import { resolve as vtiResolve } from "@openvtc/vti-didcomm-js";
import { resolve as vtiResolve, scidSrc } from "@openvtc/vti-didcomm-js";

import { assertResolvableWebvhHost } from "./egress-guard.js";
import { assertResolvableScidHost, assertResolvableWebvhHost } from "./egress-guard.js";
import { didMethod, type ResolveDidOptions } from "./verify.js";

export interface DeriveSigningKeyIdResult {
Expand Down Expand Up @@ -69,11 +69,12 @@ export async function deriveSigningKeyId(
if (method === "unknown") {
return { did, candidates: [], error: "Unrecognised DID method" };
}
// did:peer, did:webvh: full resolution. did:peer is offline; webvh
// did:peer, did:webvh, did:scid: full resolution. did:peer is offline; webvh
// hits the network and verifies the log — at a host the DID's author
// chose, so the host is judged first.
try {
if (method === "webvh") assertResolvableWebvhHost(did);
if (method === "scid" && scidSrc(did)) assertResolvableScidHost(did);
const resolve = opts.resolveDid ?? ((d: string) => vtiResolve(d, {}));
const resolution = (await resolve(did)) as {
didDocument?: {
Expand Down
20 changes: 20 additions & 0 deletions packages/core/src/did/egress-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
// `net-guard.js` in `@openvtc/vti-didcomm-js`, so this module can be replaced
// by that one once it is released.

import { didScid } from "@openvtc/vti-didcomm-js";

import { didWebvhDomain } from "./verify.js";

/** Stable `code` carried by every {@link BlockedEndpointError}. Match on this,
Expand Down Expand Up @@ -296,3 +298,21 @@ function parseIpv6(input: string): number[] | null {
if (fill < 1) return null;
return [...head, ...new Array<number>(fill).fill(0), ...rest, ...tail];
}

/**
* {@link assertResolvableWebvhHost} for a `did:scid:vh:1` DID URL: its log is
* fetched from the host its `src` names, so that host is judged the same way.
* A did:scid with no usable `src` names nothing to fetch and is refused.
*
* @throws {BlockedEndpointError}
*/
export function assertResolvableScidHost(did: string): void {
const webvh = didScid.scidToWebvhDid(did);
if (!webvh) {
throw new BlockedEndpointError(
"the did:scid names no usable source host, so it was not contacted",
"invalid_url",
);
}
assertResolvableWebvhHost(webvh);
}
55 changes: 42 additions & 13 deletions packages/core/src/did/methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,22 @@
// it to the parser, to `allowsContact` and to resolution, so all three ask the
// same table.
//
// Only `did:webvh` and `did:web` are registered, both host-named (route A).
// A domain-linked method (`did:scid`, a ledger DID) is added by registering a
// policy with `route: "domain-linked"` and a resolver; the flow then confirms
// the portal's DIF DID Configuration after the person continues
// (`vtc/domain-linkage.ts`) without any change to it.
// `did:webvh`, `did:web` and `did:scid` (a `did:scid:vh:1` DID URL with
// `src`) are registered, all host-named (route A). A domain-linked method (a
// ledger DID) is added by registering a policy with `route: "domain-linked"`
// and a resolver; the flow then confirms the portal's DIF DID Configuration
// after the person continues (`vtc/domain-linkage.ts`) without any change to
// it.
//
// A did:scid contact is a DID URL — `did:scid:vh:1:<scid>?src=…` — but its
// identity, and its document's `id`, is the bare DID (VTI-KEY-160, 161): the
// document checks below compare against the identity, not the text.

import { resolve as vtiResolve } from "@openvtc/vti-didcomm-js";
import { resolve as vtiResolve, stripScidSrc } from "@openvtc/vti-didcomm-js";

import {
BUILTIN_CONTACT_POLICIES,
SCID_CONTACT_POLICY,
WEBVH_CONTACT_POLICY,
WEB_CONTACT_POLICY,
meetsHostRules,
Expand All @@ -27,7 +33,7 @@ import {
type ContactMethodTable,
type ContactRoute,
} from "../links/trigger-link.js";
import { assertResolvableWebvhHost } from "./egress-guard.js";
import { assertResolvableScidHost, assertResolvableWebvhHost } from "./egress-guard.js";
import type { ResolveDidOptions } from "./verify.js";

/** A verified DID document, or why there is none. */
Expand All @@ -40,7 +46,8 @@ export interface ContactMethod extends ContactMethodPolicy {
/**
* Resolve `did` and verify its document (VTI-LNK-052). Called only for a
* DID of this method, and — for a host-named method — only once the host it
* names meets the host rules. Must return a document whose `id` is `did`;
* names meets the host rules. Must return a document whose `id` is `did`'s
* identity (for a did:scid DID URL, the bare DID);
* may throw, which counts as `did-document-unverified`. `opts.resolveDid`,
* when given, is the resolver to use (tests inject one).
*/
Expand All @@ -67,7 +74,7 @@ export async function resolveThroughResolver(did: string, opts: ResolveDidOption
};
if (res?.didResolutionMetadata?.error) return { ok: false, reason: "did-document-unverified" };
const doc = res?.didDocument;
if (!doc || doc.id !== did) return { ok: false, reason: "did-document-unverified" };
if (!doc || doc.id !== didIdentity(did)) return { ok: false, reason: "did-document-unverified" };
return { ok: true, doc };
}

Expand All @@ -81,6 +88,24 @@ export const WEBVH_CONTACT_METHOD: ContactMethod = {
},
};

/** `did:scid:vh:1` with `src`, route A. The `src` host is judged like a
* did:webvh host before anything is fetched; the resolver verifies the
* did:webvh log it names and returns the did:scid's own document. */
export const SCID_CONTACT_METHOD: ContactMethod = {
...SCID_CONTACT_POLICY,
resolve: async (did, opts) => {
assertResolvableScidHost(did);
return resolveThroughResolver(did, opts);
},
};

/** The identity of a DID or DID URL: a did:scid without its `src` locator
* (VTI-KEY-160); any other DID as written. Compare identities with this,
* never the text a contact or proof arrived as. */
export function didIdentity(did: string): string {
return stripScidSrc(did);
}

/** `did:web`, route A. */
export const WEB_CONTACT_METHOD: ContactMethod = {
...WEB_CONTACT_POLICY,
Expand Down Expand Up @@ -125,7 +150,10 @@ export class ContactMethodRegistry implements ContactMethodTable {
/** The route a contact `did` qualifies by, or `undefined`. */
routeOf(did: string): ContactRoute | undefined {
const m = this.methodOf(did);
return m?.route !== undefined && m.listsServices && m.verified && m.resolve ? m.route : undefined;
if (m?.route === undefined || !m.listsServices || !m.verified || !m.resolve) return undefined;
// A host-named contact that names no host (a bare did:scid) has no route.
if (m.route === "host-named" && m.hostPath && m.hostPath(did) === undefined) return undefined;
return m.route;
}

/** The registered kinds, in registration order. */
Expand All @@ -134,13 +162,14 @@ export class ContactMethodRegistry implements ContactMethodTable {
}
}

/** A fresh registry holding the built-in methods: `did:webvh` and `did:web`
* (route A, with resolvers), and the policies of `did:key` and `did:peer`,
/** A fresh registry holding the built-in methods: `did:webvh`, `did:web` and
* `did:scid` (route A, with resolvers), and the policies of `did:key` and `did:peer`,
* which no route qualifies. */
export function defaultContactMethods(): ContactMethodRegistry {
const resolvers: ReadonlyMap<string, ContactMethod> = new Map([
[WEBVH_CONTACT_METHOD.kind, WEBVH_CONTACT_METHOD],
[WEB_CONTACT_METHOD.kind, WEB_CONTACT_METHOD],
[SCID_CONTACT_METHOD.kind, SCID_CONTACT_METHOD],
]);
return new ContactMethodRegistry(BUILTIN_CONTACT_POLICIES.map((p) => resolvers.get(p.kind) ?? p));
}
Expand Down Expand Up @@ -177,7 +206,7 @@ export async function resolveContactDocument(did: string, opts: ResolveContactOp
}
try {
const res = await method.resolve(did, opts.resolveDid ? { resolveDid: opts.resolveDid } : {});
if (!res.ok || res.doc.id !== did) return unverified;
if (!res.ok || res.doc.id !== didIdentity(did)) return unverified;
return { ok: true, doc: res.doc, route };
} catch {
return unverified;
Expand Down
33 changes: 28 additions & 5 deletions packages/core/src/did/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,17 @@
// — a successful resolve is a real attestation, not just "fetch returned 200".
// did:peer / did:key are self-certifying (the keys are encoded in the id), so
// resolution is purely structural validation.
//
// did:scid:vh:1 is verified by the same did:webvh log, fetched from the host
// its `src` names (`did:scid:vh:1:<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.

import { resolve as vtiResolve } from "@openvtc/vti-didcomm-js";
import { resolve as vtiResolve, scidSrc } from "@openvtc/vti-didcomm-js";

import { assertResolvableWebvhHost } from "./egress-guard.js";
import { assertResolvableScidHost, assertResolvableWebvhHost } from "./egress-guard.js";

export type DidMethod = "webvh" | "peer" | "key" | "unknown";
export type DidMethod = "webvh" | "scid" | "peer" | "key" | "unknown";

export interface VerifyDidResult {
/** Canonical DID under verification. */
Expand All @@ -31,7 +36,8 @@ export interface VerifyDidResult {
resolved: boolean;
/** For did:webvh — the host the DID identifies (extracted from the DID,
* not the resolved document, so the operator sees what the wallet asked
* for). The DID is `did:webvh:<scid>:<host>[:<path>...]`. */
* 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. */
domain?: string;
/** Human-readable error if resolution failed. */
error?: string;
Expand All @@ -57,6 +63,7 @@ export interface VerifyDidResult {
*/
export function didMethod(did: string): DidMethod {
if (did.startsWith("did:webvh:")) return "webvh";
if (did.startsWith("did:scid:")) return "scid";
if (did.startsWith("did:peer:")) return "peer";
if (did.startsWith("did:key:")) return "key";
return "unknown";
Expand All @@ -79,6 +86,19 @@ export function didWebvhDomain(did: string): string | undefined {
return parts[3];
}

/**
* The host a `did:scid` DID URL's `src` names (`example.com` for
* `?src=https://example.com/alice/`), as written — a port stays on it, as it
* does on a did:webvh host. `undefined` for a bare did:scid or a non-scid DID.
*/
export function didScidDomain(did: string): string | undefined {
if (!did.startsWith("did:scid:")) return undefined;
const src = scidSrc(did);
if (!src) return undefined;
const host = src.replace(/^https?:\/\//, "").split("/")[0];
return host || undefined;
}

/** Options shared by the functions here that resolve a DID. */
export interface ResolveDidOptions {
/** Resolve a DID to its resolution result. Defaults to the
Expand All @@ -102,7 +122,7 @@ export async function verifyDid(
opts: ResolveDidOptions = {},
): Promise<VerifyDidResult> {
const method = didMethod(did);
const domain = method === "webvh" ? didWebvhDomain(did) : undefined;
const domain = method === "webvh" ? didWebvhDomain(did) : method === "scid" ? didScidDomain(did) : undefined;
const base: VerifyDidResult = {
did,
method,
Expand All @@ -114,6 +134,9 @@ export async function verifyDid(
}
try {
if (method === "webvh") assertResolvableWebvhHost(did);
// A bare did:scid fetches from a source the resolver vetted when it
// learned it; a DID URL names its host here.
if (method === "scid" && scidSrc(did)) assertResolvableScidHost(did);
const resolution = (await (opts.resolveDid ?? defaultResolveDid)(did)) as {
didDocument?: { id?: string; alsoKnownAs?: unknown };
didResolutionMetadata?: { error?: string };
Expand Down
Loading
Loading