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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/did/egress-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
71 changes: 56 additions & 15 deletions packages/core/src/didcomm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<ResolvedMediatorEndpoint> {
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(
Expand Down Expand Up @@ -495,10 +522,11 @@ export async function resolveVtaServices(did: string): Promise<VtaServices> {
// 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;
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 } : {}),
});

Expand Down Expand Up @@ -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 } : {}),
});
Expand Down
9 changes: 7 additions & 2 deletions packages/core/src/onboarding/swap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -130,8 +130,12 @@ export function swapAclDidcomm(opts: SwapAclDidcommOptions): Promise<AclSwapResu
}

export interface SwapAclRestOptions {
/** VTA REST base URL (from `#vta-rest`, e.g. `http://localhost:8100`). */
/** VTA REST base URL (from `#vta-rest`). Vetted before it is dialed: https on
* a public host unless `netPolicy` widens it. */
baseUrl: string;
/** Egress policy for `baseUrl`. A local VTA (`http://localhost:8100`) needs
* `{ allowInsecure: true, allowPrivate: true }`. */
netPolicy?: NetPolicy;
/** Authcrypt sender = the OLD DID (the operator-granted ephemeral). */
ephemeral: Identity;
/** Signs the VP-JWT; its DID is the NEW DID (the wallet's holder did:peer). */
Expand Down Expand Up @@ -159,6 +163,7 @@ export function swapAclRest(opts: SwapAclRestOptions): Promise<AclSwapResult> {
holder: opts.ephemeral,
signing: opts.ephemeralSigning,
service: opts.service,
...(opts.netPolicy ? { netPolicy: opts.netPolicy } : {}),
...(opts.fetch ? { fetch: opts.fetch } : {}),
});
return swapAcl(channel, {
Expand Down
36 changes: 34 additions & 2 deletions packages/core/src/vta/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 };
}

/**
Expand All @@ -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<string> {
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);
Expand Down
18 changes: 16 additions & 2 deletions packages/core/src/vta/rest-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
);
}

/**
Expand Down
12 changes: 6 additions & 6 deletions packages/core/src/vta/wallet-session.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Identity } from "../didcomm/index.js";
import type { Identity, NetPolicy } from "../didcomm/index.js";
import {
connectMediatorSession,
type MediatorConnection,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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();
Expand Down
Loading