diff --git a/CLAUDE.md b/CLAUDE.md index 23a27e8..d54025a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,27 @@ Rules that bite hardest here: `errorFromBody(doc, status, statusText)`, never by handing the spent `Response` back to `errorFromResponse` — that throws into a swallowing `catch` and silently degrades to a status-only guess. +- **Endpoints this wallet did not choose are vetted before they are dialed, and + production gets no opt-out.** A mediator's REST, auth and WebSocket URLs come + out of its DID document; a VTA's REST base is written from one at onboarding. + Each goes through `@openvtc/vti-didcomm-js` 0.8's `netPolicy` + (`net-guard.js`): https/wss only, no credentials in the URL, no loopback, + private, link-local, carrier-NAT or local-only host, and no redirect + followed. `walletNetPolicy` (`extension/src/net-policy.ts`) is the single + place that decides, and it keys off the build — `npm run dev` builds with + `--mode development`, so `import.meta.env.DEV` is set and the policy carries + `allowInsecure` **and** `allowPrivate`; every packaged build gets neither. + **Since 0.8 those two flags are independent**: `allowInsecure` admits + `http:`/`ws:` and nothing more, so a local mediator or VTA needs both, and a + dev setup that sets only the first fails on `localhost` where it used to + work. A refusal is `code: "E_BLOCKED_ENDPOINT"` — match it with + `isBlockedEndpointError`, which is structural on the code because the library + and this package's did:webvh guard are two classes carrying one code (R3.7). + `transport-diagnosis.ts` renders it as `mediator/blocked-endpoint`, and the + refusal is deliberately not classified from a probe: nothing was contacted. + What none of this can do in a browser: an extension has no DNS API, so a + public name that *resolves* to a private address still passes. `allowHosts` + is the answer to that and needs a pinned list the wallet does not yet hold. - **R1.6 + MV3 — persist before ack.** Anything that acknowledges a mediator message must durably store it first; assume the worker/offscreen document dies on the next line. **Satisfied — and easy to break again**: see "How diff --git a/package-lock.json b/package-lock.json index 489ef2e..aa40ac1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2310,9 +2310,9 @@ "license": "Apache-2.0" }, "node_modules/@openvtc/vti-didcomm-js": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@openvtc/vti-didcomm-js/-/vti-didcomm-js-0.7.0.tgz", - "integrity": "sha512-SVJtr0XpapI3289eHCqcczaJ3xT3nJYh/q9y2iOJpsrBT+bW3OywduovUWR/v39F3iIxxlnr5W5jrqDDf6RjAA==", + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@openvtc/vti-didcomm-js/-/vti-didcomm-js-0.8.0.tgz", + "integrity": "sha512-4L9aBHPYqx6r1wRhomgclK+KcOJj4cTEGrBM0bm/MKlQMc8z9W4w5TjUEXL0NDLUSnEMfvGJaCwO2hS/S0IjTg==", "license": "Apache-2.0", "dependencies": { "@noble/curves": "^2.2.0", @@ -8035,7 +8035,7 @@ "@cfworker/json-schema": "^4.1.1", "@noble/curves": "^2.4.0", "@openvtc/trust-tasks": "^0.18.3", - "@openvtc/vti-didcomm-js": "^0.7.0", + "@openvtc/vti-didcomm-js": "^0.8.0", "@openvtc/vti-tsp-js": "^0.2.0", "@scure/base": "^2.2.0", "cbor-x": "^1.6.6" diff --git a/packages/core/package.json b/packages/core/package.json index e7570b1..59fc799 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -131,7 +131,7 @@ "@cfworker/json-schema": "^4.1.1", "@noble/curves": "^2.4.0", "@openvtc/trust-tasks": "^0.18.3", - "@openvtc/vti-didcomm-js": "^0.7.0", + "@openvtc/vti-didcomm-js": "^0.8.0", "@openvtc/vti-tsp-js": "^0.2.0", "@scure/base": "^2.2.0", "cbor-x": "^1.6.6" diff --git a/packages/core/src/did/egress-guard.ts b/packages/core/src/did/egress-guard.ts index 588b83d..d669fbf 100644 --- a/packages/core/src/did/egress-guard.ts +++ b/packages/core/src/did/egress-guard.ts @@ -59,6 +59,27 @@ export class BlockedEndpointError extends Error { } } +/** + * True when `err` is a refusal to contact an endpoint. + * + * Structural on `code`, deliberately. There are two guards carrying this one + * code: this module, for the host a did:webvh names, and + * `@openvtc/vti-didcomm-js/net-guard`, for the endpoints a mediator's DID + * document advertises and a VTA's REST base. They are separate classes, so an + * `instanceof` here would silently miss the library's — which is the one a + * hostile mediator document trips. Matching the code is the whole point of + * having a stable one (R3.7). + */ +export function isBlockedEndpointError(err: unknown): err is Error & { + code: typeof BLOCKED_ENDPOINT; + reason?: string; + host?: string; + url?: string; + label?: string; +} { + return err instanceof Error && (err as { code?: unknown }).code === BLOCKED_ENDPOINT; +} + /** * Throw unless `did` is a did:webvh whose host is safe to fetch from: a public * IP address, or a name that is not local-only. diff --git a/packages/core/src/didcomm/index.ts b/packages/core/src/didcomm/index.ts index 44c66aa..bc6c279 100644 --- a/packages/core/src/didcomm/index.ts +++ b/packages/core/src/didcomm/index.ts @@ -28,6 +28,27 @@ import { x25519, jwk as vtiJwk, } from "@openvtc/vti-didcomm-js"; +import type { NetPolicy } from "@openvtc/vti-didcomm-js/net-guard"; + +/** + * Egress policy for endpoints this wallet did not choose: the REST, auth and + * WebSocket URLs a mediator's DID document advertises, and a VTA's REST base. + * + * Every field defaults to the strict setting, so a caller that passes nothing + * gets https/wss on a public host — which is what makes an omitted policy safe + * rather than merely untested. Two opt-outs exist, and since + * `@openvtc/vti-didcomm-js` 0.8 they are independent: `allowInsecure` admits + * `http:`/`ws:` and nothing else, so a mediator on `http://localhost` needs + * `allowPrivate` as well. `allowHosts` narrows to named hosts. + * + * A refusal is a `BlockedEndpointError` carrying `code: "E_BLOCKED_ENDPOINT"` + * — match on that, never on the message ({@link isBlockedEndpointError}). + */ +export type { NetPolicy } from "@openvtc/vti-didcomm-js/net-guard"; + +/** A DID resolver, in the shape the library's `resolve` option takes. + * Injectable so a test can prove a refused endpoint is never dialed. */ +type DidDocumentResolver = (did: string) => Promise<{ didDocument?: unknown }>; export type DidcommCurve = "X25519" | "P-256" | "secp256k1"; @@ -380,18 +401,24 @@ export interface ResolvedMediatorEndpoint extends ResolvedKeyAgreement { /** * Resolve a mediator DID to its key-agreement material + transport - * endpoints. Refuses plaintext (`ws://`/`http://`) endpoints unless - * `allowInsecure` is set (local dev only) — a tampered/stale DID - * document must not be able to downgrade the transport. Throws if the - * mediator advertises no WebSocket endpoint, since the bridge needs one - * for live delivery. + * endpoints. + * + * The endpoints come out of a document this wallet did not write, so each one + * is checked before anything is dialed ({@link NetPolicy}): https/wss only, no + * credentials in the URL, and no loopback, private, link-local, CGNAT or + * local-only host. A tampered or stale document therefore cannot downgrade the + * transport *or* point the wallet at a machine on the user's own network. + * + * Throws if the mediator advertises no WebSocket endpoint, since the bridge + * needs one for live delivery. */ export async function resolveMediatorEndpoint( mediatorDid: string, - options: { allowInsecure?: boolean } = {}, + options: { netPolicy?: NetPolicy; resolve?: DidDocumentResolver } = {}, ): Promise { const m = await vtiResolveMediator(mediatorDid, { - allowInsecure: options.allowInsecure ?? false, + ...(options.netPolicy ? { netPolicy: options.netPolicy } : {}), + ...(options.resolve ? { resolve: options.resolve } : {}), }); if (!m.wsEndpoint) { throw new Error( @@ -495,10 +522,11 @@ export async function resolveVtaServices(did: string): Promise { // in vta/ never imports the library directly. // --------------------------------------------------------------------------- -// The library's mediator-auth `.d.ts` is abbreviated (its `mediator` -// return omits `did`/`x25519Pub`; its args omit `allowInsecure`), though -// the runtime provides both. Re-type accurately here so the rest of the -// file stays cast-free. +// The library types `mediator.wsEndpoint` as nullable, because a mediator may +// advertise none. This facade requires one — live delivery is the wallet's +// whole inbound path — and `MediatorSession` refuses a missing endpoint on +// construction anyway, since its egress check runs on that URL. So the cast +// narrows the type rather than hiding a case. interface VtiResolvedMediator { did: string; restEndpoint: string; @@ -514,7 +542,8 @@ const authenticateToMediator = vtiAuthenticateToMediator as unknown as (args: { clientX25519Public: Uint8Array; clientKid?: string; fetch?: typeof fetch; - allowInsecure?: boolean; + netPolicy?: NetPolicy; + resolve?: DidDocumentResolver; }) => Promise<{ accessToken: string; mediator: VtiResolvedMediator }>; /** WebSocket constructor compatible with the library session (the @@ -621,8 +650,14 @@ export interface ConnectMediatorSessionOptions { fetch?: typeof fetch; /** WebSocket ctor (defaults to globalThis.WebSocket). */ webSocketImpl?: WebSocketCtor; - /** Allow ws://, http:// endpoints. Local dev only. */ - allowInsecure?: boolean; + /** Egress policy for the endpoints the mediator's DID document advertises — + * REST, auth and WebSocket. Strict by default (https/wss, public hosts); a + * dev build pointed at a mediator on localhost needs **both** + * `allowInsecure` and `allowPrivate`. See {@link NetPolicy}. */ + netPolicy?: NetPolicy; + /** DID resolver override. A test seam, named as in `verifyDid`: it lets a + * test prove that a refused endpoint is never dialed. */ + resolve?: DidDocumentResolver; /** Called once if the socket drops unexpectedly (not via `close()`). * A warm-session holder uses this to evict + reconnect. */ onClose?: () => void; @@ -653,7 +688,8 @@ export async function connectMediatorSession( clientX25519Private: clientPrivate, clientX25519Public: clientPublic, clientKid: opts.holder.kid, - allowInsecure: opts.allowInsecure ?? false, + ...(opts.netPolicy ? { netPolicy: opts.netPolicy } : {}), + ...(opts.resolve ? { resolve: opts.resolve } : {}), ...(opts.fetch ? { fetch: opts.fetch } : {}), }); @@ -729,6 +765,11 @@ export async function connectMediatorSession( // Awaited so a handler that persists finishes before the ack. if (inboundTspHandler) await inboundTspHandler(bytes); }, + // The same policy the auth handshake ran under. The session checks + // `wsEndpoint` when it is constructed and again before every socket open, + // so a document whose WebSocket URL names a private host is refused here + // instead of being handed this wallet's mediator JWT. + ...(opts.netPolicy ? { netPolicy: opts.netPolicy } : {}), ...(opts.onClose ? { onClose: opts.onClose } : {}), ...(opts.webSocketImpl ? { WebSocketImpl: opts.webSocketImpl } : {}), }); diff --git a/packages/core/src/onboarding/swap.ts b/packages/core/src/onboarding/swap.ts index d46e091..2ef621a 100644 --- a/packages/core/src/onboarding/swap.ts +++ b/packages/core/src/onboarding/swap.ts @@ -16,7 +16,7 @@ // `runProvisionIntegration` (M2C) rather than self-minting + swapping. Kept // consistent with the channel pattern for when a swap-based flow is needed. -import type { Identity } from "../didcomm/index.js"; +import type { Identity, NetPolicy } from "../didcomm/index.js"; import { issueSwapPresentation, type SigningIdentity } from "../siop/self-issued.js"; import type { TrustTaskSender } from "../vta/channel.js"; import { DidcommVtaTransport, type RemoteDidcommEndpoint } from "../vta/didcomm.js"; @@ -130,8 +130,12 @@ export function swapAclDidcomm(opts: SwapAclDidcommOptions): Promise { holder: opts.ephemeral, signing: opts.ephemeralSigning, service: opts.service, + ...(opts.netPolicy ? { netPolicy: opts.netPolicy } : {}), ...(opts.fetch ? { fetch: opts.fetch } : {}), }); return swapAcl(channel, { diff --git a/packages/core/src/vta/auth.ts b/packages/core/src/vta/auth.ts index d745ac2..78eafcf 100644 --- a/packages/core/src/vta/auth.ts +++ b/packages/core/src/vta/auth.ts @@ -26,7 +26,9 @@ // VTAs never share a token; call `invalidateVtaBearer` after a 401 to // force re-auth. -import { packAuthcrypt, type Identity } from "../didcomm/index.js"; +import { guardedFetch } from "@openvtc/vti-didcomm-js/net-guard"; + +import { packAuthcrypt, type Identity, type NetPolicy } from "../didcomm/index.js"; import type { RemoteDidcommEndpoint } from "./didcomm.js"; import { withFetchTimeout } from "../http/timeout-fetch.js"; @@ -67,6 +69,30 @@ export interface VtaAuthInputs { service: RemoteDidcommEndpoint; /** fetch impl (defaults to global). */ fetch?: typeof fetch; + /** Egress policy for `baseUrl`. Strict by default — https on a public host — + * so a dev build talking to a VTA on `http://localhost` needs both + * `allowInsecure` and `allowPrivate`. See {@link vtaRestEndpointPolicy}. */ + netPolicy?: NetPolicy; +} + +/** + * The egress policy a VTA REST base URL is held to. + * + * `baseUrl` is configuration rather than a document endpoint, but the + * configuration is written from a DID document (or a QR code) at onboarding, so + * it is vetted on the same terms as one: `https:` on a public host unless the + * caller widens it. `guardedFetch` then re-checks every request URL built from + * it and refuses to follow a redirect — this is the one channel that carries a + * bearer token, and an allowed host must not be able to hand it onwards. + * + * Exported so the channel and the bearer handshake are demonstrably held to the + * same policy rather than to two that happen to match. + */ +export function vtaRestEndpointPolicy(netPolicy?: NetPolicy): { + label: string; + schemes: string[]; +} & NetPolicy { + return { label: "VTA REST", schemes: ["https:"], ...netPolicy }; } /** @@ -76,7 +102,13 @@ export interface VtaAuthInputs { * round-trip so it doesn't trip the VTA's per-IP unauth rate limit. */ export async function getVtaBearer(opts: VtaAuthInputs): Promise { - const f = withFetchTimeout(opts.fetch); + // Guard inside the timeout wrapper so both hold: every URL is checked before + // it is dialed (throwing `BlockedEndpointError` with + // `code: "E_BLOCKED_ENDPOINT"`) and every request is still bounded (R1.2). + // The cast states what the library's JSDoc types loosely — `guardedFetch` + // returns a drop-in for the `fetch` it wrapped. + const guarded = guardedFetch(opts.fetch, vtaRestEndpointPolicy(opts.netPolicy)) as typeof fetch; + const f = withFetchTimeout(guarded); const base = opts.baseUrl.replace(/\/+$/, ""); const cacheKey = bearerCacheKey(base, opts.holder.did); diff --git a/packages/core/src/vta/rest-channel.ts b/packages/core/src/vta/rest-channel.ts index 06f954f..e21be60 100644 --- a/packages/core/src/vta/rest-channel.ts +++ b/packages/core/src/vta/rest-channel.ts @@ -19,7 +19,14 @@ import { parseTrustTaskReply, signOutboundTask, verifyTrustTaskReply } from "./t import { asTaskSigner, type ChannelSigner, type TaskSigner } from "./trust-task.js"; import type { SigningIdentity } from "../siop/self-issued.js"; import { isTrustTaskErrorType } from "./protocol.js"; -import { getVtaBearer, makeReauth, type VtaAuthInputs } from "./auth.js"; +import { guardedFetch } from "@openvtc/vti-didcomm-js/net-guard"; + +import { + getVtaBearer, + makeReauth, + vtaRestEndpointPolicy, + type VtaAuthInputs, +} from "./auth.js"; import { withFetchTimeout, isFetchTimeout, DEFAULT_FETCH_TIMEOUT_MS } from "../http/timeout-fetch.js"; export interface RestChannelOptions extends VtaAuthInputs { @@ -66,10 +73,17 @@ export class RestChannel implements TrustTaskChannel { baseUrl: opts.baseUrl, holder: opts.holder, service: opts.service, + ...(opts.netPolicy ? { netPolicy: opts.netPolicy } : {}), ...(opts.fetch ? { fetch: opts.fetch } : {}), }; this.path = opts.trustTasksPath ?? TRUST_TASK_PATH; - this.fetchImpl = withFetchTimeout(opts.fetch); + // The dispatcher URL is composed from the same `baseUrl` the bearer + // handshake uses, so it is held to the same policy — vetted before it is + // dialed, and never followed through a redirect. The cast states what the + // library's JSDoc types loosely (see `getVtaBearer`). + this.fetchImpl = withFetchTimeout( + guardedFetch(opts.fetch, vtaRestEndpointPolicy(opts.netPolicy)) as typeof fetch, + ); } /** diff --git a/packages/core/src/vta/wallet-session.ts b/packages/core/src/vta/wallet-session.ts index 2e2895d..0863f0a 100644 --- a/packages/core/src/vta/wallet-session.ts +++ b/packages/core/src/vta/wallet-session.ts @@ -1,4 +1,4 @@ -import type { Identity } from "../didcomm/index.js"; +import type { Identity, NetPolicy } from "../didcomm/index.js"; import { connectMediatorSession, type MediatorConnection, @@ -30,8 +30,10 @@ export interface WalletSessionFromDidsConfig { fetch?: typeof fetch; /** WebSocket ctor (defaults to globalThis.WebSocket). */ webSocketImpl?: WebSocketCtor; - /** Allow `ws://`/`http://` endpoints. Local dev only. */ - allowInsecure?: boolean; + /** Egress policy for the mediator's advertised endpoints. Strict by default; + * a dev build against a mediator on localhost needs both `allowInsecure` + * and `allowPrivate`. */ + netPolicy?: NetPolicy; /** Per-request timeout. */ timeoutMs?: number; } @@ -110,9 +112,7 @@ export class WalletSession { vtaDid: cfg.vtaDid, ...(cfg.fetch ? { fetch: cfg.fetch } : {}), ...(cfg.webSocketImpl ? { webSocketImpl: cfg.webSocketImpl } : {}), - ...(cfg.allowInsecure !== undefined - ? { allowInsecure: cfg.allowInsecure } - : {}), + ...(cfg.netPolicy ? { netPolicy: cfg.netPolicy } : {}), }); } catch (err) { holder.dispose(); diff --git a/packages/core/tests/didcomm.net-policy.mjs b/packages/core/tests/didcomm.net-policy.mjs new file mode 100644 index 0000000..bb467f4 --- /dev/null +++ b/packages/core/tests/didcomm.net-policy.mjs @@ -0,0 +1,267 @@ +// The egress policy on the endpoints a mediator's DID document names. +// +// `@openvtc/vti-didcomm-js` 0.8 checks every advertised endpoint before it is +// dialed. What needs pinning is the wallet's side of that, because two mistakes +// here look nothing alike from production: +// +// - not passing a policy at all leaves the library's strict default in place, +// so the wallet keeps working and only a developer's local stack breaks — +// which is the failure people fix by reaching for the opt-out; +// - passing `allowInsecure` alone, which WAS sufficient before 0.8, silently +// re-admits a mediator on loopback while looking like a scheme decision. +// +// So both are asserted, and so is the positive control: the same document the +// production policy refuses is accepted under the dev policy, which is what +// says the refusal is the policy at work rather than a broken fixture. +// +// The resolver is injected (`resolve`, the seam `verifyDid` already uses) and +// the fetch and WebSocket are spies, so a refusal is proved by nothing being +// dialed rather than by the error alone. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import * as x25519 from "@openvtc/vti-didcomm-js/x25519"; +import * as multibase from "@openvtc/vti-didcomm-js/multibase"; + +import { + Identity, + connectMediatorSession, + resolveMediatorEndpoint, +} from "../dist/didcomm/index.js"; + +/** The stable code every refusal carries. Spelled out rather than imported so + * the test would notice the constant changing value (R3.7). */ +const BLOCKED_ENDPOINT = "E_BLOCKED_ENDPOINT"; + +/** An X25519 did:key — resolvable offline, so a test needs no network. */ +function keypairDid() { + const kp = x25519.generateKeyPair(); + const mb = multibase.encodeMultikey(multibase.MULTICODEC.X25519_PUB, kp.publicKey); + return { did: `did:key:${mb}`, kid: `did:key:${mb}#${mb}`, multibase: mb, ...kp }; +} + +/** A mediator whose document advertises `rest` + `ws`, with a real X25519 + * keyAgreement key so the parse reaches the endpoint checks rather than + * failing earlier for a reason the test did not intend. */ +function mediator({ rest, ws }) { + const kp = keypairDid(); + return { + ...kp, + resolve: async () => ({ + didDocument: { + id: kp.did, + keyAgreement: [ + { + id: kp.kid, + type: "Multikey", + controller: kp.did, + publicKeyMultibase: kp.multibase, + }, + ], + service: [ + { + id: `${kp.did}#didcomm`, + type: "DIDCommMessaging", + serviceEndpoint: [{ uri: rest }, { uri: ws }], + }, + ], + }, + }), + }; +} + +// TLS on loopback, deliberately: it isolates the address check from the scheme +// check, so a refusal here cannot be the plaintext gate doing the work. This is +// also the shape that was reachable before 0.8 — `allowInsecure: false` said +// nothing about the host. +const LOOPBACK = { rest: "https://127.0.0.1:9099", ws: "wss://127.0.0.1:9099" }; +// What a developer's local stack actually looks like. +const LOCAL_DEV = { rest: "http://localhost:9099", ws: "ws://localhost:9099" }; +// What a dev build passes, and the only combination that admits either. +const DEV_POLICY = { allowInsecure: true, allowPrivate: true }; + +test("a mediator advertising a loopback endpoint is refused by default", async () => { + const m = mediator(LOOPBACK); + await assert.rejects( + () => resolveMediatorEndpoint(m.did, { resolve: m.resolve }), + (err) => { + assert.equal(err.code, BLOCKED_ENDPOINT, "must carry the stable code (R3.7)"); + assert.equal(err.reason, "private_address"); + return true; + }, + ); +}); + +test("allowInsecure alone no longer admits a private host", async () => { + // The breaking change the 0.8 bump carries. Before it, this document was + // refused only because of its scheme, so a caller that wanted plaintext got + // loopback thrown in. + const m = mediator(LOOPBACK); + await assert.rejects( + () => resolveMediatorEndpoint(m.did, { netPolicy: { allowInsecure: true }, resolve: m.resolve }), + (err) => err.code === BLOCKED_ENDPOINT, + ); +}); + +test("the dev policy admits the document the production one refuses", async () => { + const m = mediator(LOOPBACK); + const resolved = await resolveMediatorEndpoint(m.did, { + netPolicy: DEV_POLICY, + resolve: m.resolve, + }); + assert.equal(resolved.restEndpoint, LOOPBACK.rest); + assert.equal(resolved.websocketUrl, LOOPBACK.ws); + // Derived rather than advertised: with no `Authentication` service the parse + // appends `/authenticate` to the REST endpoint — and that derived URL is + // vetted too, which is why it appears here at all. + assert.equal(resolved.authEndpoint, `${LOOPBACK.rest}/authenticate`); +}); + +test("a local stack on http://localhost needs both opt-outs, not either", async () => { + const m = mediator(LOCAL_DEV); + // Neither: refused. + await assert.rejects(() => resolveMediatorEndpoint(m.did, { resolve: m.resolve })); + // Private hosts but not plaintext: still refused, on the scheme. + await assert.rejects(() => + resolveMediatorEndpoint(m.did, { netPolicy: { allowPrivate: true }, resolve: m.resolve }), + ); + // Plaintext but not private hosts: still refused, on the address. + await assert.rejects( + () => resolveMediatorEndpoint(m.did, { netPolicy: { allowInsecure: true }, resolve: m.resolve }), + (err) => err.code === BLOCKED_ENDPOINT, + ); + // Both. This is the line a developer's config has to produce. + const resolved = await resolveMediatorEndpoint(m.did, { + netPolicy: DEV_POLICY, + resolve: m.resolve, + }); + assert.equal(resolved.websocketUrl, LOCAL_DEV.ws); +}); + +test("a public mediator is unaffected by any of this", async () => { + const m = mediator({ rest: "https://mediator.example", ws: "wss://mediator.example/ws" }); + const resolved = await resolveMediatorEndpoint(m.did, { resolve: m.resolve }); + assert.equal(resolved.websocketUrl, "wss://mediator.example/ws"); +}); + +// ─── The session: nothing dialed, and the WebSocket held to the policy ─── + +class FakeWebSocket { + static constructed = []; + + constructor(url, protocols) { + this.url = url; + this.protocols = protocols; + this.sent = []; + this.readyState = 0; + this.onopen = null; + this.onmessage = null; + this.onerror = null; + this.onclose = null; + FakeWebSocket.constructed.push(this); + setTimeout(() => { + this.readyState = 1; + this.onopen && this.onopen(); + }, 0); + } + addEventListener() {} + send(data) { + this.sent.push(data); + } + close() { + this.readyState = 3; + this.onclose && this.onclose(); + } +} + +function spyFetch(bodies) { + const calls = []; + const impl = async (url) => { + calls.push(String(url)); + const body = bodies[calls.length - 1]; + return new Response(JSON.stringify(body ?? {}), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + return { calls, impl }; +} + +test("a refused endpoint is never dialed, by fetch or by WebSocket", async () => { + // The assertion that matters. An error alone would not distinguish "refused + // before the request" from "refused after it", and only the first is a + // control: the second still hands an internal host a connection from the + // user's browser. + FakeWebSocket.constructed = []; + const m = mediator(LOOPBACK); + const vta = keypairDid(); + const holder = Identity.generate("did:example:holder"); + const fetchSpy = spyFetch([]); + + try { + await assert.rejects( + () => + connectMediatorSession({ + holder, + mediatorDid: m.did, + vtaDid: vta.did, + resolve: m.resolve, + fetch: fetchSpy.impl, + webSocketImpl: FakeWebSocket, + }), + (err) => err.code === BLOCKED_ENDPOINT, + ); + assert.deepEqual(fetchSpy.calls, [], "the auth handshake must not reach the network"); + assert.equal(FakeWebSocket.constructed.length, 0, "no socket may be opened"); + } finally { + holder.dispose(); + } +}); + +test("under the dev policy the same session opens, socket and all", async () => { + // The positive control for the whole path, not just the parse: handshake, + // then the WebSocket the session checks separately (it is a second endpoint + // from the same untrusted document, and it carries the mediator JWT). + FakeWebSocket.constructed = []; + const m = mediator(LOCAL_DEV); + const vta = keypairDid(); + const holder = Identity.generate("did:example:holder-dev"); + const now = Math.floor(Date.now() / 1000); + const fetchSpy = spyFetch([ + { data: { challenge: "c-1", session_id: "s-1" } }, + { + data: { + access_token: "med.jwt", + access_expires_at: now + 900, + refresh_token: "r-1", + refresh_expires_at: now + 3600, + }, + }, + ]); + + let conn; + try { + conn = await connectMediatorSession({ + holder, + mediatorDid: m.did, + vtaDid: vta.did, + netPolicy: DEV_POLICY, + resolve: m.resolve, + fetch: fetchSpy.impl, + webSocketImpl: FakeWebSocket, + }); + + assert.deepEqual( + fetchSpy.calls, + [`${LOCAL_DEV.rest}/authenticate/challenge`, `${LOCAL_DEV.rest}/authenticate`], + "the handshake ran against the local mediator", + ); + assert.equal(FakeWebSocket.constructed.length, 1); + assert.equal(FakeWebSocket.constructed[0].url, LOCAL_DEV.ws); + assert.ok(conn.isOpen, "live delivery is enabled once connect() resolves"); + } finally { + conn?.close(); + holder.dispose(); + } +}); diff --git a/packages/core/tests/vta.rest-net-policy.mjs b/packages/core/tests/vta.rest-net-policy.mjs new file mode 100644 index 0000000..d44d7f3 --- /dev/null +++ b/packages/core/tests/vta.rest-net-policy.mjs @@ -0,0 +1,165 @@ +// The egress policy on a VTA's REST base URL. +// +// `baseUrl` is the wallet's own configuration rather than a document endpoint, +// which is exactly why it is easy to leave unchecked — and it is written from a +// DID document (or a QR code) at onboarding, so it is no more chosen by this +// wallet than a mediator's endpoints are. It also matters more per request: +// REST is the one channel that carries a bearer token, so a base URL pointing +// at the user's own network is a token handed to whatever answers there. +// +// These drive `getVtaBearer`, which every REST request runs through before it +// dispatches anything. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import * as x25519 from "@openvtc/vti-didcomm-js/x25519"; +import * as jwk from "@openvtc/vti-didcomm-js/jwk"; +import * as multibase from "@openvtc/vti-didcomm-js/multibase"; + +import { Identity } from "../dist/didcomm/index.js"; +import { getVtaBearer } from "../dist/vta/auth.js"; + +const BLOCKED_ENDPOINT = "E_BLOCKED_ENDPOINT"; +const DEV_POLICY = { allowInsecure: true, allowPrivate: true }; + +/** A VTA keyAgreement endpoint the handshake can authcrypt to. */ +function vtaEndpoint() { + const kp = x25519.generateKeyPair(); + const mb = multibase.encodeMultikey(multibase.MULTICODEC.X25519_PUB, kp.publicKey); + return { + did: `did:key:${mb}`, + keyAgreementKid: `did:key:${mb}#${mb}`, + keyAgreementPublicJwk: jwk.publicJwk("X25519", kp.publicKey), + }; +} + +/** Records every URL it is asked for, and answers with real `Response`s (a + * hand-rolled `{ ok, json }` stub stops representing one the moment the code + * reads the body differently — see CLAUDE.md). */ +function spyFetch(bodies) { + const calls = []; + const impl = async (url) => { + calls.push(String(url)); + return new Response(JSON.stringify(bodies[calls.length - 1] ?? {}), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + return { calls, impl }; +} + +test("a loopback VTA base URL is refused before any request is made", async () => { + const holder = Identity.generate("did:example:holder-loopback"); + const spy = spyFetch([]); + try { + await assert.rejects( + () => + getVtaBearer({ + baseUrl: "https://127.0.0.1:8100", + holder, + service: vtaEndpoint(), + fetch: spy.impl, + }), + (err) => { + assert.equal(err.code, BLOCKED_ENDPOINT); + assert.equal(err.reason, "private_address"); + return true; + }, + ); + // The point of checking the base URL rather than the response: the bearer + // handshake begins with an unauthenticated POST naming the holder's DID, so + // "it failed" is not the same as "it never spoke". + assert.deepEqual(spy.calls, []); + } finally { + holder.dispose(); + } +}); + +test("a local-only name is refused as well, not just an address", async () => { + const holder = Identity.generate("did:example:holder-name"); + const spy = spyFetch([]); + try { + await assert.rejects( + () => + getVtaBearer({ + baseUrl: "https://vta.internal", + holder, + service: vtaEndpoint(), + fetch: spy.impl, + }), + (err) => err.code === BLOCKED_ENDPOINT && err.reason === "private_name", + ); + assert.deepEqual(spy.calls, []); + } finally { + holder.dispose(); + } +}); + +test("plaintext is refused even on a public host", async () => { + // The bearer is the reason: `http:` would put it on the wire in clear. + const holder = Identity.generate("did:example:holder-plaintext"); + const spy = spyFetch([]); + try { + await assert.rejects( + () => + getVtaBearer({ + baseUrl: "http://vta.example", + holder, + service: vtaEndpoint(), + fetch: spy.impl, + }), + (err) => err.code === BLOCKED_ENDPOINT && err.reason === "scheme", + ); + assert.deepEqual(spy.calls, []); + } finally { + holder.dispose(); + } +}); + +test("the dev policy reaches a VTA on localhost", async () => { + // Both flags, and the positive control for the two tests above: the handshake + // completes, so the refusals are the policy rather than a broken fixture. + const holder = Identity.generate("did:example:holder-dev"); + const spy = spyFetch([ + { sessionId: "s-1", challenge: "c-1" }, + { tokens: { accessToken: "vta.jwt" } }, + ]); + try { + const token = await getVtaBearer({ + baseUrl: "http://localhost:8100", + holder, + service: vtaEndpoint(), + fetch: spy.impl, + netPolicy: DEV_POLICY, + }); + assert.equal(token, "vta.jwt"); + assert.deepEqual(spy.calls, [ + "http://localhost:8100/auth/challenge", + "http://localhost:8100/auth/", + ]); + } finally { + holder.dispose(); + } +}); + +test("allowPrivate alone does not admit a plaintext local VTA", async () => { + // The 0.8 split, from the other side: a dev config that sets one flag gets a + // refusal that names the scheme, not silence. + const holder = Identity.generate("did:example:holder-half"); + const spy = spyFetch([]); + try { + await assert.rejects(() => + getVtaBearer({ + baseUrl: "http://localhost:8100", + holder, + service: vtaEndpoint(), + fetch: spy.impl, + netPolicy: { allowPrivate: true }, + }), + ); + assert.deepEqual(spy.calls, []); + } finally { + holder.dispose(); + } +}); diff --git a/packages/extension/src/net-policy.ts b/packages/extension/src/net-policy.ts new file mode 100644 index 0000000..b386517 --- /dev/null +++ b/packages/extension/src/net-policy.ts @@ -0,0 +1,65 @@ +// Which endpoints this wallet is willing to dial — decided in one place. +// +// A mediator's REST, auth and WebSocket URLs come out of a DID document the +// wallet did not write, and its VTA's REST base is written from one at +// onboarding. `@openvtc/vti-didcomm-js` 0.8 checks each of them before it is +// dialed (`netPolicy`, `net-guard.js`): https/wss only, no credentials in the +// URL, no loopback, RFC 1918, link-local/metadata, carrier-NAT or local-only +// name, and no redirect followed. Without that, a mediator DID handed to this +// wallet — from a QR code, or a document that changed after onboarding — is a +// way to make the user's own browser reach a machine on the user's own network +// and hand it this wallet's mediator JWT. +// +// **Production gets neither opt-out, and that is the whole reason this module +// exists rather than a literal at each call site.** There are two flags, +// `allowInsecure` (admit `http:`/`ws:`) and `allowPrivate` (admit a non-public +// host), and since 0.8 the first no longer implies the second. A build that set +// only `allowInsecure` — sufficient before 0.8 — would today refuse a local +// stack while a build that set both in production would accept +// `https://169.254.169.254`. Deciding it once, from the build, is what keeps +// those two states from drifting apart at nine call sites. +// +// The build is the decision: `npm run dev` builds with `--mode development`, so +// vite defines `import.meta.env.DEV`. Every packaged build — `npm run build`, +// which is what CI and the Web Store zip run — is production. +// +// Fails closed. Outside a vite build `import.meta.env` does not exist (under +// `node --test`, for instance), and that reads as production, so a test has to +// ask for the dev policy explicitly rather than inherit it. +// +// What this cannot do in a browser: an extension has no DNS API, so a public +// name that *resolves* to a private address (`127.0.0.1.nip.io`, a rebinding +// domain) passes every check above. `allowHosts` is the only strong control +// against that, and it needs a list of hosts the wallet trusts — which this +// wallet does not have: it records its mediators and agents as DIDs +// (`config.ts` inboxes, the persisted connections), and their hosts are +// whatever those documents say at resolution time. Narrowing to a list derived +// from the same document would check a value against itself. So the parameter +// is plumbed through and left unset, and pinning it is a follow-up that belongs +// beside the inbox in `config.ts`, where an operator can state it. + +import type { NetPolicy } from "@openvtc/pnm-core"; + +/** True in a `--mode development` vite build, false anywhere else. */ +export function isDevBuild(): boolean { + // `import.meta.env` is substituted at build time and simply absent outside a + // vite build, hence the guard rather than a bare property read. + return typeof import.meta.env !== "undefined" && import.meta.env.DEV === true; +} + +/** + * The policy every mediator and VTA endpoint this extension dials is held to. + * + * `allowHosts` is accepted so a caller holding a pinned list can narrow + * further; it never widens, since the guard applies it on top of the address + * and name checks rather than instead of them. + */ +export function walletNetPolicy(opts: { allowHosts?: readonly string[] } = {}): NetPolicy { + const allowHosts = + opts.allowHosts && opts.allowHosts.length > 0 ? [...opts.allowHosts] : undefined; + return { + // Local development only, and both together: see the header. + ...(isDevBuild() ? { allowInsecure: true, allowPrivate: true } : {}), + ...(allowHosts ? { allowHosts } : {}), + }; +} diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 44f7730..b2f7e54 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -85,6 +85,7 @@ import { import { base64url } from "@openvtc/vti-didcomm-js"; import { grantCommand } from "./grant-command.js"; import { forgetInbox, getSettings, inboxFor, inboxToAdopt, setInbox } from "./config.js"; +import { walletNetPolicy } from "./net-policy.js"; import { loadHolder } from "./holder.js"; import { WebAuthnPrfSecretWrap } from "./webauthn-prf-wrap.js"; import type { Transport, TransportHealth, TransportObservation } from "./transports.js"; @@ -698,7 +699,7 @@ async function diagnoseTransportDown( // asks about the thing that broke rather than a health route that may // be served by something else. It answers `405` to the probe's GET — // irrelevant, since an opaque response only has to exist. - probeUrl = await resolveMediatorEndpoint(mediatorDid) + probeUrl = await resolveMediatorEndpoint(mediatorDid, { netPolicy: walletNetPolicy() }) .then((m) => m.authEndpoint) .catch(() => undefined); } @@ -795,7 +796,12 @@ async function diagnoseMediator( let authEndpoint: string | undefined; try { - authEndpoint = (await resolveMediatorEndpoint(mediatorDid)).authEndpoint; + // The self-test resolves under the SAME policy the real path uses, so a + // mediator this wallet would refuse to dial reports as refused here rather + // than passing a check the wallet will not honour. + authEndpoint = ( + await resolveMediatorEndpoint(mediatorDid, { netPolicy: walletNetPolicy() }) + ).authEndpoint; checks.push({ id: `${idBase}.resolve`, label: `${label} mediator DID resolves`, @@ -1125,7 +1131,15 @@ async function buildVtaSession( } const rest = restBaseUrl || services.rest?.baseUrl; if (rest) { - channels.push(new RestChannel({ baseUrl: rest, holder, signing: documentSigner, service })); + channels.push( + new RestChannel({ + baseUrl: rest, + holder, + signing: documentSigner, + service, + netPolicy: walletNetPolicy(), + }), + ); // Deliberately `"unknown"`, not `"up"`. A `RestChannel` is built from a // URL without contacting anything, so construction is not evidence — and // a REST channel that turns out to be unreachable fails the caller's @@ -1828,7 +1842,12 @@ async function doOnboardConnect(params: OnboardConnectParams): Promise { let c = conns.get(m); if (!c) { - c = connectMediatorSession({ holder: ephemeral, mediatorDid: m, vtaDid: pending.vtaDid }); + c = connectMediatorSession({ + holder: ephemeral, + mediatorDid: m, + vtaDid: pending.vtaDid, + netPolicy: walletNetPolicy(), + }); conns.set(m, c); } return c; @@ -1966,7 +1985,12 @@ async function doOnboardContexts(): Promise<{ contexts: Array<{ id: string; name const connect: MediatorConnector = (m) => { let c = conns.get(m); if (!c) { - c = connectMediatorSession({ holder: ephemeral, mediatorDid: m, vtaDid: pending.vtaDid }); + c = connectMediatorSession({ + holder: ephemeral, + mediatorDid: m, + vtaDid: pending.vtaDid, + netPolicy: walletNetPolicy(), + }); conns.set(m, c); } return c; @@ -2290,6 +2314,7 @@ async function createWarmSession( const conn = await connectMediatorSession({ holder: identity, mediatorDid, + netPolicy: walletNetPolicy(), // No fixed peer for a shared session; the session resolves each reply's // sender on demand. Seed with our own DID (harmless) to satisfy the API; // each operation resolves its real VTA target separately (cached). @@ -2436,6 +2461,7 @@ async function createApproverWarmSession(vtaDid: string): Promise { approverPool.delete(key); diff --git a/packages/extension/src/transport-diagnosis.ts b/packages/extension/src/transport-diagnosis.ts index 36d54f8..bcd9c41 100644 --- a/packages/extension/src/transport-diagnosis.ts +++ b/packages/extension/src/transport-diagnosis.ts @@ -41,13 +41,17 @@ // observed rather than asserting a cause, and every remediation it suggests // is safe to attempt if the guess is wrong. -import { isFetchTimeout } from "@openvtc/pnm-core"; +import { isBlockedEndpointError, isFetchTimeout } from "@openvtc/pnm-core"; /** * Stable causes a transport failure is classified into. Match on these — * never on `detail`, which is prose for a human and may be reworded (R3.7). */ export const TRANSPORT_DIAGNOSIS = { + /** Refused by this wallet before anything was dialed: the endpoint named a + * host that is not on the public internet. Not a connectivity failure — + * nothing was contacted, so no probe result applies. */ + blockedEndpoint: "mediator/blocked-endpoint", /** The host answered an opaque probe but refused the real request. Almost * always a CORS allowlist that does not carry this extension's origin. */ originNotAllowed: "mediator/origin-not-allowed", @@ -96,6 +100,30 @@ export function classifyTransportFailure(args: { const { error, reachable, host, origin } = args; const where = host ? ` at ${host}` : ""; + // First, and on the code rather than the class. Two guards carry this one + // code — the library's, over the endpoints a mediator's DID document + // advertises and a VTA's REST base, and this package's, over the host a + // did:webvh names — and an `instanceof` would miss whichever it was not + // written against (R3.7). + // + // Nothing was dialed, so `reachable` says nothing here and is not consulted: + // a probe is about a host that answered, and this endpoint was never asked. + if (isBlockedEndpointError(error)) { + const named = error.host ?? host; + return { + code: TRANSPORT_DIAGNOSIS.blockedEndpoint, + detail: + `This wallet refused the endpoint${named ? ` ${named}` : ""} because it names a host ` + + `that is not on the public internet, so nothing was contacted.`, + remediation: + `Check the endpoints in the mediator's DID document. A wallet will not dial ` + + `loopback, a private, link-local or carrier-NAT address, or a local-only name ` + + `such as \`localhost\` — whoever operates this mediator has to advertise the ` + + `address it is reachable at from the internet. Only a development build of this ` + + `extension talks to a local stack.`, + }; + } + if (isFetchTimeout(error)) { return { code: TRANSPORT_DIAGNOSIS.timeout, @@ -108,7 +136,7 @@ export function classifyTransportFailure(args: { if (!(error instanceof TypeError)) { return { code: TRANSPORT_DIAGNOSIS.rejected, - detail: `The mediator${where} answered and refused the request: ${messageOf(error)}`, + detail: `The mediator${where} answered and refused the request: ${refusalOf(error)}`, }; } @@ -193,3 +221,22 @@ export function originOf(url: string | undefined): string | undefined { function messageOf(err: unknown): string { return err instanceof Error ? err.message : String(err); } + +/** + * What a mediator's refusal amounts to, for someone reading the pane. + * + * `@openvtc/vti-didcomm-js` 0.8 keeps the response body OUT of the message — + * from a hostile endpoint it is attacker-chosen text, and these strings end up + * in logs, a pasted self-test report and the UI — and puts it on `err.body` + * with the HTTP status on `err.status`. So the status is read from the field + * rather than scraped back out of the sentence (R3.7), and the body is quoted + * as a bounded, clearly-delimited excerpt beside the message rather than in + * place of it. + */ +function refusalOf(err: unknown): string { + const { status, body } = err as { status?: unknown; body?: unknown }; + const code = typeof status === "number" ? ` (HTTP ${status})` : ""; + const text = typeof body === "string" ? body.trim() : ""; + const excerpt = text ? ` — it said: ${JSON.stringify(text.slice(0, 200))}` : ""; + return `${messageOf(err)}${code}${excerpt}`; +} diff --git a/packages/extension/tests/transport-diagnosis.test.mts b/packages/extension/tests/transport-diagnosis.test.mts index 8cbdcd6..d702909 100644 --- a/packages/extension/tests/transport-diagnosis.test.mts +++ b/packages/extension/tests/transport-diagnosis.test.mts @@ -111,6 +111,73 @@ test("a probe that cannot run says so rather than blaming the host", async () => assert.equal(await probeReachable("https://mediator.example/auth", fake), "unprobed"); }); +test("an endpoint refused for naming a non-public host is not a connectivity story", () => { + // What `@openvtc/vti-didcomm-js` 0.8 throws when a mediator's DID document + // advertises `https://127.0.0.1:9099`. Structurally it is neither a + // `TypeError` nor a timeout, so before this branch existed it fell through to + // "the mediator answered and refused the request" — a sentence about a host + // that was never contacted, and one that sends the reader to look at a + // mediator's ACL instead of at the address in its document. + const blocked = Object.assign( + new Error( + "net-guard: mediator REST endpoint https://127.0.0.1:9099/ refused: 127.0.0.1 is not a public address (set allowPrivate for local development)", + ), + { code: "E_BLOCKED_ENDPOINT", reason: "private_address", host: "127.0.0.1" }, + ); + + const d = classifyTransportFailure({ error: blocked, reachable: "unprobed" }); + assert.equal(d.code, TRANSPORT_DIAGNOSIS.blockedEndpoint); + // The host, so the reader can see which address was refused. + assert.match(d.detail, /127\.0\.0\.1/); + assert.match(d.detail, /not on the public internet/); + // And that nothing was dialed, which is the part a person acts on. + assert.match(d.detail, /nothing was contacted/); +}); + +test("a probe that happened to land does not re-classify a refusal", () => { + // `reachable: "reachable"` can only be about some other request; this + // endpoint was never asked. Repeating it here would turn a wallet-side + // refusal into an accusation about the mediator's CORS config. + const blocked = Object.assign(new Error("net-guard: refused"), { + code: "E_BLOCKED_ENDPOINT", + reason: "private_name", + host: "mediator.local", + }); + const d = classifyTransportFailure({ + error: blocked, + reachable: "reachable", + host: "https://mediator.local", + origin: "chrome-extension://abc", + }); + assert.equal(d.code, TRANSPORT_DIAGNOSIS.blockedEndpoint); + assert.doesNotMatch(d.remediation ?? "", /cors_allow_origin/); +}); + +test("response detail is read from the error's fields, not its sentence", () => { + // 0.8 keeps an attacker-chosen body out of the message — these strings reach + // logs, the pasted self-test report and the pane — and puts it on `err.body` + // with the status on `err.status`. The pane still has to be able to show + // both, so they are read from the fields rather than scraped back out of the + // message (R3.7). + const refused = Object.assign( + new Error("mediator-auth: 502 from https://mediator.example/authenticate"), + { status: 502, body: "upstream is down" }, + ); + const d = classifyTransportFailure({ error: refused, reachable: "reachable" }); + assert.equal(d.code, TRANSPORT_DIAGNOSIS.rejected); + assert.match(d.detail, /HTTP 502/); + assert.match(d.detail, /upstream is down/); +}); + +test("a long body is excerpted rather than pasted whole", () => { + const refused = Object.assign(new Error("mediator-auth: non-JSON body"), { + status: 200, + body: "x".repeat(5_000), + }); + const d = classifyTransportFailure({ error: refused, reachable: "reachable" }); + assert.ok(d.detail.length < 500, `detail was ${d.detail.length} characters`); +}); + test("originOf keeps the scheme and drops the path", () => { assert.equal(originOf("https://mediator.example/mediator/v1/authenticate"), "https://mediator.example"); assert.equal(originOf("not a url"), undefined);