From ce49b1d4cae9779323a64ad145445d0818b612f5 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sat, 12 Sep 2026 08:27:13 +0200 Subject: [PATCH] fix(didcomm): refuse mediator and VTA endpoints that name a non-public host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mediator's REST, auth and WebSocket URLs come out of its DID document, and that document is not ours. A mediator DID arriving by QR, or one whose document changed after onboarding, decided where this wallet sent its auth handshake and the mediator JWT it gets back, and the only check was the scheme. An extension fetch is not CORS-bound and the wallet runs in the user's browser, so `https://127.0.0.1`, `https://169.254.169.254` or `https://printer.local` in a mediator document was a request from inside the user's own network with the wallet's credentials attached. A VTA's REST base URL is the same shape of input, written from a document (or a QR code) at onboarding, and it is the one channel that carries a bearer token. `@openvtc/vti-didcomm-js` 0.8 adds the guard (`net-guard.js`) and threads a `netPolicy` through `resolveMediator`, `authenticateToMediator`, `MediatorSession` and VTA REST auth. This adopts it. ## What decides, and where it is passed `walletNetPolicy` (`extension/src/net-policy.ts`) is the only place the policy is chosen, 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 — `npm run build`, which is what CI and the Web Store zip run — gets neither. Outside a vite build `import.meta.env` is absent and that reads as production, so a test has to ask for the dev policy rather than inherit it. It reaches every call site that reaches the library: - `resolveMediatorEndpoint` — the transport diagnosis and the connection self-test, so the self-test resolves under the policy the real path honours rather than passing a check the wallet will not. - `connectMediatorSession` — onboarding (twice), the warm holder session and the approver session. It hands the policy to `authenticateToMediator` *and* to `MediatorSession`, so the socket that carries the JWT is held to the same terms as the handshake; the session re-checks the endpoint before every open. - `RestChannel` — its `baseUrl` reaches `getVtaBearer` and the dispatcher POST through `guardedFetch`, so every URL built from it is vetted before it is dialed and no request follows a redirect. One exported `vtaRestEndpointPolicy` serves both, rather than two policies that happen to match. ## Breaking for a local stack `allowInsecure: true` no longer implies that private hosts are allowed, and the option is gone from `@openvtc/pnm-core` rather than kept as an alias — nothing is deployed, so there is no fold to write. `connectMediatorSession`, `resolveMediatorEndpoint` and `WalletSession.fromDids` take `netPolicy`. A mediator or VTA on loopback now needs both flags: netPolicy: { allowInsecure: true, allowPrivate: true } In the extension that is automatic with `npm run dev`. A wallet built with `npm run build` refuses a local mediator, which is the point of tying it to the build rather than to a setting. ## Surfacing a refusal A refusal carries `code: "E_BLOCKED_ENDPOINT"`. `isBlockedEndpointError` (`core/src/did/egress-guard.ts`) matches the code, not the class: there are now two guards carrying one code — the library's, and this package's did:webvh one — and an `instanceof` would silently miss whichever it was not written against (R3.7). `transport-diagnosis.ts` classifies it first, as `mediator/blocked-endpoint`, and deliberately ignores the reachability probe. Nothing was contacted, so "the mediator is up and refused this request" would be a claim about a request that was never made, and it would send the reader to a mediator's CORS config instead of to the address in its document. The network pane and the pasted self-test report render that line as-is. 0.8 also stops copying a response body into the error message — from a hostile endpoint that is attacker-chosen text, and these strings end up in logs, a pasted report and the UI — and puts it on `err.body` with the HTTP status on `err.status`. The `rejected` branch reads both from the fields and bounds the excerpt. Nothing else in this repo parsed those messages. ## Tests - `core/tests/didcomm.net-policy.mjs`: a mediator document advertising loopback is refused by default and accepted under the dev policy; `allowInsecure` alone is not enough, which is the 0.8 semantics the wallet now depends on; a `http://localhost` stack needs both flags and each alone is refused. The refusal reaches neither the injected `fetch` nor the injected WebSocket — the difference between a control and an error message. The positive control drives the whole path, handshake then socket. - `core/tests/vta.rest-net-policy.mjs`: the same for a VTA REST base URL — loopback, a local-only name and plaintext on a public host, each with zero requests, then the dev policy completing the handshake. - `extension/tests/transport-diagnosis.test.mts`: the blocked-endpoint case as the pane renders it, that a probe result cannot re-classify it, and that the status and body are read from the fields. ## What it cannot do 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 here. `allowHosts` is the answer to that and needs a pinned list of hosts; this wallet records its mediators and agents as DIDs, so it has no such list, and narrowing to one derived from the same document would check a value against itself. The parameter is plumbed through and left unset; pinning it belongs beside the inbox in `config.ts`, where an operator can state it. Signed-off-by: Glenn Gore --- CLAUDE.md | 21 ++ package-lock.json | 8 +- packages/core/package.json | 2 +- packages/core/src/did/egress-guard.ts | 21 ++ packages/core/src/didcomm/index.ts | 71 ++++- packages/core/src/onboarding/swap.ts | 9 +- packages/core/src/vta/auth.ts | 36 ++- packages/core/src/vta/rest-channel.ts | 18 +- packages/core/src/vta/wallet-session.ts | 12 +- packages/core/tests/didcomm.net-policy.mjs | 267 ++++++++++++++++++ packages/core/tests/vta.rest-net-policy.mjs | 165 +++++++++++ packages/extension/src/net-policy.ts | 65 +++++ packages/extension/src/offscreen.ts | 36 ++- packages/extension/src/transport-diagnosis.ts | 51 +++- .../tests/transport-diagnosis.test.mts | 67 +++++ 15 files changed, 810 insertions(+), 39 deletions(-) create mode 100644 packages/core/tests/didcomm.net-policy.mjs create mode 100644 packages/core/tests/vta.rest-net-policy.mjs create mode 100644 packages/extension/src/net-policy.ts 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);