From ffdfa80d7dc8e26b08e1316c2dc43951fdb4f8aa Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 31 May 2026 00:54:34 +0800 Subject: [PATCH 1/2] mediator-transport: actionable diagnostics for failed WS upgrades MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connect path collapsed every WebSocket upgrade failure into one opaque "WebSocket failed to open". The browser `error` event is deliberately detail-free; the `close` event that follows carries the `code`/`reason` that says WHY — and the old code discarded it by settling on `error` first. Now settle on `close`, map RFC 6455 codes to causes (1008 → mediator ACL/auth reject, distinct from the target VTA's ACL; 1006 → refused upgrade / TLS / proxy not passing Upgrade), decode the bearer token's `exp` to flag a born-expired/skewed token, attach structured `code`/`reason`/`endpoint` fields, and add a connect timeout so a blackholed upgrade fails fast instead of hanging forever. Adds 4 tests (1008+ACL hint, 1006+proxy, expired bearer, timeout). Signed-off-by: Glenn Gore --- src/mediator-transport.js | 159 +++++++++++++++++++++++++++++--- test/mediator-transport.test.js | 103 +++++++++++++++++++++ 2 files changed, 247 insertions(+), 15 deletions(-) diff --git a/src/mediator-transport.js b/src/mediator-transport.js index 50c703b..048e58b 100644 --- a/src/mediator-transport.js +++ b/src/mediator-transport.js @@ -53,6 +53,53 @@ const MESSAGES_RECEIVED_TYPE = "https://didcomm.org/messagepickup/3.0/messages-r // to satisfy the subprotocol-echo handshake. const WS_APP_SUBPROTOCOL = "didcomm"; +// Human-readable hint for an RFC 6455 close code, oriented at the +// failure modes a mediator client actually hits. The browser hides the +// HTTP status of a rejected upgrade, so the close code is the only +// machine signal distinguishing "auth/ACL reject" from "network/TLS" +// from "proxy misconfig". +function describeCloseCode(code) { + switch (code) { + case undefined: + case null: + return "no close code (the implementation passed no close event)"; + case 1000: + return "normal closure"; + case 1001: + return "endpoint going away"; + case 1002: + return "protocol error — likely a subprotocol mismatch (the mediator must echo a Sec-WebSocket-Protocol)"; + case 1005: + return "no status received"; + case 1006: + return "abnormal closure — no close frame was sent. The HTTP upgrade was most likely refused outright (401/403/426), or a TLS/DNS/network failure occurred, or a reverse proxy is not configured to pass WebSocket upgrades on this path. If REST auth succeeds but the WS gives 1006, suspect a 401/403 on the upgrade (stale/expired bearer, or the client DID is not in the MEDIATOR's ACL — distinct from the VTA's ACL) or a proxy that strips the Upgrade header"; + case 1008: + return "policy violation — the mediator rejected the connection. Re-authenticate to the mediator, and confirm the client DID is permitted by the MEDIATOR's ACL (updating the target VTA's ACL does NOT change the mediator's gate)"; + case 1011: + return "mediator internal error — check mediator logs"; + case 1015: + return "TLS handshake failure — certificate/SNI/protocol problem reaching the wss endpoint"; + default: + if (code >= 4000) return `application-specific close code ${code} — see mediator logs`; + return `close code ${code}`; + } +} + +// Decode a JWT's `exp` (seconds) without verifying the signature — +// purely to surface a born-expired bearer in diagnostics. Returns null +// on any malformed input (never throws). +function decodeJwtExp(jwt) { + if (typeof jwt !== "string") return null; + const parts = jwt.split("."); + if (parts.length < 2) return null; + try { + const payload = JSON.parse(new TextDecoder().decode(b64u.decode(parts[1]))); + return typeof payload.exp === "number" ? payload.exp : null; + } catch { + return null; + } +} + // Cap on un-awaited inbound messages held for a future `waitFor`. A // request/response client buffers at most a handful; this only bounds a // misbehaving mediator pushing unsolicited frames. @@ -189,12 +236,16 @@ export class MediatorSession { * unexpectedly (after a successful open, not via `close()`). Lets a * warm-session holder evict + reconnect. */ - constructor({ mediator, mediatorJwt, client, senderKeys, resolveSender, WebSocketImpl, onMessage, onClose }) { + constructor({ mediator, mediatorJwt, client, senderKeys, resolveSender, WebSocketImpl, onMessage, onClose, connectTimeoutMs }) { if (!mediator?.wsEndpoint) { throw new Error("MediatorSession: mediator.wsEndpoint required (mediator advertises no wss endpoint)"); } this.mediator = mediator; this.mediatorJwt = mediatorJwt; + // Upper bound on the WS upgrade. Without it, a silently-dropped + // upgrade (proxy blackhole, no open/error/close ever fires) would + // hang connect() forever. 0 disables the timeout. + this.connectTimeoutMs = connectTimeoutMs ?? 15000; this.client = client; this.senderKeys = senderKeys ?? new Map(); this.resolveSender = resolveSender; @@ -254,15 +305,21 @@ export class MediatorSession { _openSocket() { return new Promise((resolve, reject) => { - // `connect()` settles exactly once: on the first of open / error / - // close. A strict client that rejects the 101 (e.g. no subprotocol - // echoed) fires `error` then `close` *before* `onopen` — without - // this guard the connect promise would hang forever. After open, - // error/close instead fail any pending waiters (see below). + // `connect()` settles exactly once: on the first of open / close / + // timeout. A strict client that rejects the 101 (e.g. no + // subprotocol echoed) fires `error` then `close` *before* `onopen`. + // We prefer to settle on `close` rather than `error`, because the + // browser `error` event is deliberately information-free (no code, + // no reason — a privacy measure) while the `close` event carries + // the `code`/`reason` that actually says WHY the upgrade failed. + // After open, error/close instead fail any pending waiters. let settled = false; + let sawError = false; + let timer = null; const settleConnect = (fn, arg) => { if (settled) return false; settled = true; + if (timer) clearTimeout(timer); fn(arg); return true; }; @@ -277,20 +334,52 @@ export class MediatorSession { WS_APP_SUBPROTOCOL, ]); this.ws = ws; + + if (this.connectTimeoutMs > 0) { + timer = setTimeout(() => { + settleConnect( + reject, + this._connectError({ + reason: `no open/close within ${this.connectTimeoutMs}ms`, + hint: "the upgrade was silently dropped — a reverse proxy or firewall not configured to pass WebSocket upgrades on this path will hang rather than reject", + }), + ); + try { + ws.close(); + } catch { + // best effort + } + }, this.connectTimeoutMs); + } + ws.onopen = () => settleConnect(resolve); ws.onmessage = (ev) => this._onFrame(ev.data); ws.onerror = () => { - // Before open: fail the connect. After open: fail pending waiters. - if (settleConnect(reject, new Error("mediator-transport: WebSocket failed to open"))) { - return; - } - for (const w of this._waiters.splice(0)) { - clearTimeout(w.timer); - w.reject(new Error("mediator-transport: WebSocket error")); + // The browser `error` event carries no detail. Record that it + // happened and wait for the `close` event (which has the code). + // Only if no close follows do we settle on the bare error. + sawError = true; + if (settled) { + for (const w of this._waiters.splice(0)) { + clearTimeout(w.timer); + w.reject(new Error("mediator-transport: WebSocket error")); + } } }; - ws.onclose = () => { - if (settleConnect(reject, new Error("mediator-transport: WebSocket closed before open"))) { + ws.onclose = (ev) => { + const code = ev?.code; + const reason = ev?.reason; + if ( + settleConnect( + reject, + this._connectError({ + code, + reason, + sawError, + hint: describeCloseCode(code), + }), + ) + ) { return; } for (const w of this._waiters.splice(0)) { @@ -310,6 +399,46 @@ export class MediatorSession { }); } + /** + * Build a rich, actionable error for a failed WS upgrade. The browser + * `error` event is detail-free, so the close `code`/`reason` plus a + * decoded view of the bearer token's expiry is the most we can give a + * caller. Structured fields (`code`, `reason`, `endpoint`) are attached + * so the plugin can branch/log programmatically. + */ + _connectError({ code, reason, sawError, hint }) { + const parts = ["mediator-transport: WebSocket failed to open"]; + if (code != null) parts.push(`(close code ${code}${reason ? ` "${reason}"` : ""})`); + else if (sawError) parts.push("(error before close — no code provided by the browser)"); + if (hint) parts.push(`— ${hint}`); + + // Decode the bearer token's exp so a born-expired / skewed token (a + // common cause of an upgrade reject that REST auth accepts) is + // visible without server logs. + const exp = decodeJwtExp(this.mediatorJwt); + if (exp != null) { + const expMs = exp * 1000; + const skewMs = expMs - this._nowMs(); + if (skewMs <= 0) { + parts.push( + `— bearer token is already EXPIRED (exp ${new Date(expMs).toISOString()}, ${Math.round(-skewMs / 1000)}s ago); re-authenticate, and check client/mediator clock skew`, + ); + } + } + + const err = new Error(parts.join(" ")); + err.code = code; + err.reason = reason; + err.endpoint = this.mediator.wsEndpoint; + return err; + } + + // Wall-clock for skew reporting only (never gates logic). Isolated so + // it's the single Date use and easy to stub in tests. + _nowMs() { + return new Date().getTime(); + } + /** Send a raw packed JWE as a WS text frame. */ send(jweString) { if (!this.ws) throw new Error("mediator-transport: not connected"); diff --git a/test/mediator-transport.test.js b/test/mediator-transport.test.js index 75a78ef..9adaca1 100644 --- a/test/mediator-transport.test.js +++ b/test/mediator-transport.test.js @@ -364,3 +364,106 @@ test("MediatorSession: requires a wsEndpoint", () => { /wsEndpoint required/, ); }); + +// ── Connect-failure diagnostics ──────────────────────────────────── +// +// A WebSocket stand-in whose upgrade FAILS: it fires `error` (detail- +// free, like a browser) then `close` with a caller-chosen code/reason, +// without ever firing `onopen`. +class FailingWebSocket { + static code = 1006; + static reason = ""; + constructor(url, protocols) { + this.url = url; + this.protocols = protocols; + this.onopen = null; + this.onmessage = null; + this.onerror = null; + this.onclose = null; + setTimeout(() => { + this.onerror && this.onerror({}); + this.onclose && this.onclose({ code: FailingWebSocket.code, reason: FailingWebSocket.reason }); + }, 0); + } + addEventListener() {} + send() {} + close() {} +} + +function failingSession(jwt = "med.jwt.token", { connectTimeoutMs } = {}) { + const m = keypairDid(); + return new MediatorSession({ + mediator: { did: m.did, kid: m.kid, x25519Pub: m.publicKey, wsEndpoint: "wss://mediator.test/ws" }, + mediatorJwt: jwt, + client: generateEphemeralClient(), + WebSocketImpl: FailingWebSocket, + connectTimeoutMs, + }); +} + +test("connect failure: 1008 surfaces the close code + mediator-ACL hint", async () => { + FailingWebSocket.code = 1008; + FailingWebSocket.reason = "unauthorized"; + const session = failingSession(); + await assert.rejects( + () => session.connect(), + (err) => { + assert.match(err.message, /close code 1008/); + assert.match(err.message, /unauthorized/); + assert.match(err.message, /MEDIATOR's ACL/); + assert.equal(err.code, 1008); + assert.equal(err.endpoint, "wss://mediator.test/ws"); + return true; + }, + ); +}); + +test("connect failure: 1006 explains a refused/blackholed upgrade", async () => { + FailingWebSocket.code = 1006; + FailingWebSocket.reason = ""; + const session = failingSession(); + await assert.rejects( + () => session.connect(), + (err) => { + assert.match(err.message, /close code 1006/); + assert.match(err.message, /upgrade was most likely refused|proxy/); + return true; + }, + ); +}); + +test("connect failure: a born-expired bearer token is flagged", async () => { + FailingWebSocket.code = 1008; + FailingWebSocket.reason = ""; + // JWT with exp 1h in the past (signature irrelevant — we never verify). + const past = Math.floor(new Date().getTime() / 1000) - 3600; + const b64 = (o) => Buffer.from(JSON.stringify(o)).toString("base64url"); + const jwt = `${b64({ alg: "none" })}.${b64({ exp: past })}.sig`; + const session = failingSession(jwt); + await assert.rejects( + () => session.connect(), + (err) => { + assert.match(err.message, /already EXPIRED/); + return true; + }, + ); +}); + +test("connect failure: times out when no event ever fires", async () => { + // A socket that never opens, errors, or closes. + class SilentWebSocket { + constructor() {} + addEventListener() {} + send() {} + close() {} + } + const m = keypairDid(); + const session = new MediatorSession({ + mediator: { did: m.did, kid: m.kid, x25519Pub: m.publicKey, wsEndpoint: "wss://mediator.test/ws" }, + mediatorJwt: "med.jwt.token", + client: generateEphemeralClient(), + WebSocketImpl: SilentWebSocket, + connectTimeoutMs: 20, + }); + await assert.rejects(() => session.connect(), /silently dropped|within 20ms/); +}); From 01f44adf83d9285b0a3b81debda1fcba101b8309 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 31 May 2026 10:06:52 +0800 Subject: [PATCH 2/2] mediator-transport: per-frame inbound resilience + bump 0.4.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single bad inbound message (undecryptable, malformed, unknown sender, or a throw in dispatch) was silently dropped. Add an `onError` hook (default console.warn) and make `_onFrame` fully defensive — decode, unpack, and dispatch each in their own try/catch with a frame fingerprint — so the session logs the bad message and keeps delivering the rest of the queue instead of getting stuck. Bumps to 0.4.2 (with the WS-upgrade diagnostics from the prior commit) and adds a CHANGELOG entry. Signed-off-by: Glenn Gore --- CHANGELOG.md | 23 ++++++++++++ package.json | 2 +- src/mediator-transport.js | 66 ++++++++++++++++++++++++++++++--- test/mediator-transport.test.js | 45 ++++++++++++++++++++++ 4 files changed, 130 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6242e61..da64c3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,29 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.2] - 2026-05-30 + +### Added + +- **Actionable diagnostics for failed mediator WebSocket upgrades.** A + failed upgrade previously collapsed into one opaque "WebSocket failed + to open". The browser `error` event carries no detail, so the handler + now settles on the `close` event and maps the RFC 6455 close code to a + cause — `1008` → mediator auth/ACL reject (distinct from the target + VTA's ACL), `1006` → refused upgrade / TLS / a proxy not passing the + `Upgrade` header (a CORS-blocked cross-origin upgrade also surfaces + here), `1015` → TLS failure. The bearer token's `exp` is decoded to + flag a born-expired / clock-skewed token, and the error carries + structured `code` / `reason` / `endpoint` fields. Adds a + `connectTimeoutMs` (default 15s) so a silently-dropped upgrade fails + fast instead of hanging. +- **Per-frame inbound resilience.** A single bad inbound message + (undecryptable, malformed, unknown sender, or a throw in dispatch) is + now logged via a new `onError` hook (default `console.warn`) and + skipped, so the session never gets stuck on one poison message and + keeps delivering the rest of the queue. Previously such frames were + silently dropped. + ## [0.4.1] - 2026-05-25 ### Fixed diff --git a/package.json b/package.json index 8e26b4d..a5a8301 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@openvtc/vti-didcomm-js", - "version": "0.4.1", + "version": "0.4.2", "description": "Browser-side DIDComm v2 implementation for the Verifiable Trust Infrastructure. Focused subset: authcrypt/anoncrypt (ECDH-1PU/ECDH-ES + A256CBC-HS512) over X25519 and P-256, did:key + did:peer + did:webvh resolution, VTA REST auth, and ATM mediator transport. Byte-compatible with affinidi-messaging-didcomm.", "type": "module", "main": "src/index.js", diff --git a/src/mediator-transport.js b/src/mediator-transport.js index 048e58b..8a45d77 100644 --- a/src/mediator-transport.js +++ b/src/mediator-transport.js @@ -85,6 +85,15 @@ function describeCloseCode(code) { } } +// Default per-frame error sink: warn to the console if one is available, +// otherwise stay silent. Overridable via the `onError` constructor option +// (pass `() => {}` to silence, or a logger to capture). +function defaultOnError(err) { + if (typeof console !== "undefined" && typeof console.warn === "function") { + console.warn(err?.message ?? err); + } +} + // Decode a JWT's `exp` (seconds) without verifying the signature — // purely to surface a born-expired bearer in diagnostics. Returns null // on any malformed input (never throws). @@ -236,7 +245,7 @@ export class MediatorSession { * unexpectedly (after a successful open, not via `close()`). Lets a * warm-session holder evict + reconnect. */ - constructor({ mediator, mediatorJwt, client, senderKeys, resolveSender, WebSocketImpl, onMessage, onClose, connectTimeoutMs }) { + constructor({ mediator, mediatorJwt, client, senderKeys, resolveSender, WebSocketImpl, onMessage, onClose, onError, connectTimeoutMs }) { if (!mediator?.wsEndpoint) { throw new Error("MediatorSession: mediator.wsEndpoint required (mediator advertises no wss endpoint)"); } @@ -254,6 +263,11 @@ export class MediatorSession { // open, not via close()). Lets a caller holding a warm session evict + // reconnect. Not fired on an intentional close(). this.onClose = onClose; + // Per-frame error sink. A single un-unpackable / malformed inbound + // message must never get stuck or silently vanish: it's logged here + // and processing moves on to the next frame. Defaults to console.warn; + // pass a no-op to silence, or your own logger to capture. + this.onError = onError ?? defaultOnError; this._userClosed = false; this.WebSocketImpl = WebSocketImpl ?? globalThis.WebSocket; if (typeof this.WebSocketImpl !== "function") { @@ -439,6 +453,23 @@ export class MediatorSession { return new Date().getTime(); } + // Report a per-frame failure without throwing. Includes a short, stable + // fingerprint of the offending frame (first 12 chars of its content) so + // a recurring poison message is recognizable across redeliveries in + // logs, without dumping the full (possibly sensitive) ciphertext. + _reportFrameError(stage, err, text) { + const fp = typeof text === "string" ? `${text.slice(0, 12)}…(${text.length}b)` : "n/a"; + try { + this.onError(new Error(`mediator-transport: failed to ${stage} [frame ${fp}]: ${err?.message ?? err}`), { + stage, + cause: err, + frameFingerprint: fp, + }); + } catch { + // The error sink itself must never break the receive loop. + } + } + /** Send a raw packed JWE as a WS text frame. */ send(jweString) { if (!this.ws) throw new Error("mediator-transport: not connected"); @@ -446,7 +477,19 @@ export class MediatorSession { } async _onFrame(data) { - const text = typeof data === "string" ? data : new TextDecoder().decode(data); + // Every inbound frame is processed independently and defensively: a + // single bad message (undecryptable, malformed, unknown sender, or a + // throw anywhere in dispatch) is logged via `onError` and skipped, so + // the session never gets stuck on one poison message and keeps + // delivering the rest of the queue. + let text; + try { + text = typeof data === "string" ? data : new TextDecoder().decode(data); + } catch (err) { + this._reportFrameError("decode inbound frame bytes", err, null); + return; + } + let result; try { result = await unpackInbound(text, { @@ -454,11 +497,24 @@ export class MediatorSession { senderKeys: this.senderKeys, resolveSender: this.resolveSender, }); - } catch { - // Unrelated / unparseable frame (e.g. a sender we don't know). - // Drop it — correlation only cares about the response we await. + } catch (err) { + // Unparseable / undecryptable / unknown-sender frame. Log (so a + // recurring poison message is visible rather than silently dropped) + // and move on — correlation only cares about responses we await. + this._reportFrameError("unpack inbound frame", err, text); return; } + + try { + await this._dispatchFrame(result, text); + } catch (err) { + // A malformed-but-decryptable message (bad thid/id, throwing + // listener, ack failure that escaped) must not break the loop. + this._reportFrameError("dispatch inbound message", err, text); + } + } + + async _dispatchFrame(result, text) { // Ack delivery so the mediator deletes its queued copy and stops // replaying it on the next (re)connection. Two non-obvious points: // diff --git a/test/mediator-transport.test.js b/test/mediator-transport.test.js index 9adaca1..01c17e0 100644 --- a/test/mediator-transport.test.js +++ b/test/mediator-transport.test.js @@ -467,3 +467,48 @@ test("connect failure: times out when no event ever fires", async () => { }); await assert.rejects(() => session.connect(), /silently dropped|within 20ms/); }); + +// ── Inbound resilience: a bad frame must not stick the loop ───────── +test("inbound: a poison frame is logged via onError and the next good frame still resolves", async () => { + const client = generateEphemeralClient(); + const vta = generateEphemeralClient(); + const mediatorKp = keypairDid(); + const mediator = { + did: mediatorKp.did, + kid: mediatorKp.kid, + x25519Pub: mediatorKp.publicKey, + wsEndpoint: "wss://mediator.test/ws", + }; + + const errors = []; + const session = new MediatorSession({ + mediator, + mediatorJwt: "med.jwt.token", + client, + senderKeys: new Map([[vta.did, { publicJwk: jwk.publicJwk("X25519", vta.publicKey) }]]), + WebSocketImpl: FakeWebSocket, + onError: (err) => errors.push(err), + }); + await session.connect(); + const ws = FakeWebSocket.last; + + const reqId = "urn:uuid:after-poison"; + const waiting = session.waitFor(reqId, 1000); + + // 1) A poison frame: not even valid JSON. Must be logged, not thrown. + ws.inject("}{ this is not a JWE"); + // 2) A second poison frame: valid JSON but undecryptable by us. + ws.inject(JSON.stringify({ protected: "x", ciphertext: "y", tag: "z" })); + // 3) A good frame from the VTA with the awaited thid — must still resolve. + const good = await pack({ + message: { id: "urn:uuid:resp", type: "t", from: vta.did, to: [client.did], thid: reqId, body: { ok: true } }, + sender: { kid: vta.kid, privateJwk: jwk.privateJwk("X25519", vta.privateKey, vta.publicKey) }, + recipient: { kid: client.kid, publicJwk: jwk.publicJwk("X25519", client.publicKey) }, + }); + ws.inject(good); + + const msg = await waiting; + assert.equal(msg.body.ok, true, "the good frame after two poison frames still resolves"); + assert.ok(errors.length >= 2, `both poison frames were logged (got ${errors.length})`); + assert.match(errors[0].message, /failed to (unpack|dispatch) inbound/); +});