@johnhenry/wsh 0.16.1 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/mlkem.mjs CHANGED
@@ -66,7 +66,34 @@ function bytesEqual(a, b) {
66
66
  return diff === 0;
67
67
  }
68
68
 
69
+ /**
70
+ * Which implementation serves ML-KEM-768, with an override for testing.
71
+ *
72
+ * The noble branch is the ONLY path a browser can take -- no browser
73
+ * implements the experimental WebCrypto ML-KEM draft -- and until this
74
+ * override existed no test had ever executed a line of it. `getBackend()`
75
+ * memoised a probe that always succeeds on the repo's target runtime, so
76
+ * every assertion ran against native, and every noble call
77
+ * (`ml_kem768.keygen`, `.encapsulate`, `.decapsulate`) was untouched. A
78
+ * change in noble's argument order or seed semantics -- it is pinned only to
79
+ * `^0.7.0` and called through three positional APIs -- would break hybrid PQ
80
+ * key exchange for every browser consumer while this repo stayed green.
81
+ *
82
+ * `WSH_MLKEM_BACKEND=noble` forces it. Read defensively: this module runs in
83
+ * browsers, where `process` does not exist.
84
+ */
85
+ function backendOverride() {
86
+ try {
87
+ const value = globalThis.process?.env?.WSH_MLKEM_BACKEND;
88
+ return value === 'noble' || value === 'native' ? value : null;
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
69
94
  async function getBackend() {
95
+ const forced = backendOverride();
96
+ if (forced) return forced;
70
97
  if (!backendPromise) {
71
98
  backendPromise = probeNativeSupport().then((supported) => (supported ? 'native' : 'noble'));
72
99
  }
package/src/session.mjs CHANGED
@@ -153,6 +153,8 @@ export class WshSession {
153
153
  * @type {ReturnType<typeof setTimeout>|null}
154
154
  */
155
155
  #closeGraceTimer = null;
156
+ /** Why the session closed, when it closed for a reason worth naming. */
157
+ #closeReason = null;
156
158
 
157
159
  // ── Callbacks ───────────────────────────────────────────────────────
158
160
 
@@ -475,7 +477,7 @@ export class WshSession {
475
477
  for (const { nonce, ciphertext } of wireChunks) {
476
478
  const counter = this.#e2eRecvCounter++;
477
479
  try {
478
- const plaintext = await openFrame(this.#e2eKey, this.#sessionId, counter, { nonce, ciphertext });
480
+ const plaintext = await openFrame(this.#e2eKey, this.#sessionId, counter, { nonce, ciphertext, expectedRoleTag: this.#e2eRecvRoleTag });
479
481
  plaintexts.push(plaintext);
480
482
  } catch (err) {
481
483
  console.error(
@@ -682,7 +684,7 @@ export class WshSession {
682
684
  // delivery of *this* frame's plaintext to onData is deferred by
683
685
  // one microtask/macrotask relative to synchronous cases.
684
686
  const counter = this.#e2eRecvCounter++;
685
- openFrame(this.#e2eKey, this.#sessionId, counter, { nonce: msg.nonce, ciphertext: msg.ciphertext })
687
+ openFrame(this.#e2eKey, this.#sessionId, counter, { nonce: msg.nonce, ciphertext: msg.ciphertext, expectedRoleTag: this.#e2eRecvRoleTag })
686
688
  .then((plaintext) => {
687
689
  this.#virtualBackend?.pushData(plaintext);
688
690
  try {
@@ -724,14 +726,7 @@ export class WshSession {
724
726
  // any pending data-EOF grace timer, regardless of which arrived
725
727
  // first.
726
728
  this.#clearCloseGraceTimer();
727
- if (this.#state !== STATE_CLOSED) {
728
- this.#state = STATE_CLOSED;
729
- this.#abort.abort();
730
- this.#virtualBackend?.close();
731
- this.#releaseStreams();
732
- this.#resolvePendingFileChunk(null);
733
- this.#emitClose();
734
- }
729
+ this.#closeNow();
735
730
  break;
736
731
  }
737
732
 
@@ -874,11 +869,32 @@ export class WshSession {
874
869
  if (this.#e2eKey !== null && this.#streamAccumulator) {
875
870
  const deliverable = await this.#openStreamChunks(value);
876
871
  if (deliverable === null) {
877
- // Authentication failure (or corrupt framing) -- matches
878
- // the strict "no skip-and-continue" philosophy of
879
- // virtual-mode's counter check: tear the session down
880
- // rather than deliver anything from this point on.
881
- break;
872
+ /*
873
+ * Authentication failure (or corrupt framing) -- the strict
874
+ * "no skip-and-continue" philosophy of virtual-mode's counter
875
+ * check: tear the session down rather than deliver anything
876
+ * from this point on.
877
+ *
878
+ * `break` alone did not tear anything down. It left the loop,
879
+ * and control fell through to the data-EOF grace timer, so the
880
+ * session stayed `active` and writable for the full 300ms and
881
+ * then closed as an ordinary EOF: onClose with no error,
882
+ * onExit never fired, exitCode still null. An application could
883
+ * not tell "the peer forged data at me" from "the process
884
+ * exited and the server forgot to send CLOSE" -- the two were
885
+ * byte-identical to observe.
886
+ *
887
+ * Closed immediately, and the reason recorded so the two are
888
+ * distinguishable. `StreamAuthenticationError` was already
889
+ * built here and handed only to console.error; it is now the
890
+ * close reason as well.
891
+ */
892
+ this.#closeReason = new StreamAuthenticationError(
893
+ 'wsh: stream authentication failed -- session torn down'
894
+ );
895
+ this.#clearCloseGraceTimer();
896
+ this.#closeNow();
897
+ return;
882
898
  }
883
899
  for (const plaintext of deliverable) {
884
900
  try {
@@ -918,11 +934,7 @@ export class WshSession {
918
934
  if (this.#state !== STATE_CLOSED && this.#closeGraceTimer === null) {
919
935
  this.#closeGraceTimer = setTimeout(() => {
920
936
  this.#closeGraceTimer = null;
921
- if (this.#state !== STATE_CLOSED) {
922
- this.#state = STATE_CLOSED;
923
- this.#releaseStreams();
924
- this.#emitClose();
925
- }
937
+ this.#closeNow();
926
938
  }, DATA_EOF_CLOSE_GRACE_MS);
927
939
  this.#closeGraceTimer?.unref?.();
928
940
  }
@@ -935,6 +947,36 @@ export class WshSession {
935
947
  * whether or not a timer is currently scheduled.
936
948
  * @private
937
949
  */
950
+ /**
951
+ * Close the session once, releasing everything a close has to release.
952
+ *
953
+ * There were two ways a stream-mode session reached `closed` and they did
954
+ * not agree. The CLOSE control handler aborted, closed the virtual backend,
955
+ * released the streams AND settled any parked file chunk. The data-EOF
956
+ * grace fallback -- the path taken when a server ends the data stream
957
+ * without sending CLOSE, which the comment above it says is real -- only
958
+ * released the streams and fired onClose.
959
+ *
960
+ * So `download()`, parked on `_readFileChunk()`, was never settled on that
961
+ * path. It documents returning `null` for a truncated transfer and
962
+ * `download()` turns that into a thrown error; via the fallback it returned
963
+ * nothing at all and the download hung forever, while `onClose` had already
964
+ * fired and the session read `closed`.
965
+ *
966
+ * One method rather than two call sites kept in step by hand: the drift is
967
+ * the bug, and the next field added to a close would have drifted the same
968
+ * way.
969
+ */
970
+ #closeNow() {
971
+ if (this.#state === STATE_CLOSED) return;
972
+ this.#state = STATE_CLOSED;
973
+ this.#abort.abort();
974
+ this.#virtualBackend?.close();
975
+ this.#releaseStreams();
976
+ this.#resolvePendingFileChunk(null);
977
+ this.#emitClose();
978
+ }
979
+
938
980
  #clearCloseGraceTimer() {
939
981
  if (this.#closeGraceTimer !== null) {
940
982
  clearTimeout(this.#closeGraceTimer);
@@ -961,9 +1003,19 @@ export class WshSession {
961
1003
  * Emit the onClose callback exactly once.
962
1004
  * @private
963
1005
  */
1006
+ /**
1007
+ * Why the session closed, or null for an ordinary close.
1008
+ *
1009
+ * A torn-down session and a clean exit used to be indistinguishable from
1010
+ * the outside; this is what tells them apart.
1011
+ */
1012
+ get closeReason() {
1013
+ return this.#closeReason;
1014
+ }
1015
+
964
1016
  #emitClose() {
965
1017
  try {
966
- this.onClose?.();
1018
+ this.onClose?.(this.#closeReason);
967
1019
  } catch (err) {
968
1020
  console.error('[wsh:session] onClose handler error:', err);
969
1021
  }
@@ -214,8 +214,22 @@ export class WebSocketTransport extends WshTransport {
214
214
  // own stream objects -- without this, any reader still waiting on a
215
215
  // stream's readable side would hang forever, since nothing else
216
216
  // errors or closes it once the peer can no longer respond.
217
- this.#qmux?.close();
218
- this.#qmux?.destroy(new Error('Transport closed'));
217
+ //
218
+ // Each step is guarded separately. The courtesy CONNECTION_CLOSE is
219
+ // the one that can realistically throw -- it writes to the socket,
220
+ // and a socket that is already broken is the single most likely
221
+ // reason we are closing at all. Letting that throw would skip both
222
+ // the local `destroy()` (hanging every waiting reader, the exact
223
+ // thing above) and the socket close (leaking the connection), which
224
+ // makes the failure mode strictly worse than the failure that
225
+ // triggered it.
226
+ try {
227
+ this.#qmux?.close();
228
+ } catch { /* peer is unreachable; the local teardown below still matters */ }
229
+
230
+ try {
231
+ this.#qmux?.destroy(new Error('Transport closed'));
232
+ } catch { /* a stream's error callback threw; keep tearing down */ }
219
233
 
220
234
  if (this.#ws) {
221
235
  try {
package/src/transport.mjs CHANGED
@@ -111,7 +111,7 @@ const STATE_CLOSED = 'closed';
111
111
  * Abstract transport for the wsh protocol.
112
112
  *
113
113
  * Subclasses must implement:
114
- * - _doConnect(url)
114
+ * - _doConnect(url, options)
115
115
  * - _doClose()
116
116
  * - _doSendControl(msg)
117
117
  * - _doOpenStream()
@@ -144,17 +144,40 @@ export class WshTransport {
144
144
  /**
145
145
  * Connect to a wsh server.
146
146
  * @param {string} url
147
+ * @param {object} [options] - Transport-specific connect options.
148
+ * Ignored by transports that have none; `WebTransportTransport`
149
+ * forwards them to the `WebTransport` constructor (see its docs for
150
+ * `serverCertificateHashes`).
147
151
  */
148
- async connect(url) {
152
+ async connect(url, options) {
149
153
  if (this.#state === STATE_CONNECTED || this.#state === STATE_CONNECTING) {
150
154
  throw new Error(`Transport already ${this.#state}`);
151
155
  }
152
156
  this.#state = STATE_CONNECTING;
153
157
  try {
154
- await this._doConnect(url);
158
+ await this._doConnect(url, options);
155
159
  this.#state = STATE_CONNECTED;
156
160
  } catch (err) {
161
+ // A connect can fail *after* acquiring a real resource -- an open
162
+ // WebSocket whose QMux handshake then threw, a WebTransport whose
163
+ // session came up but whose control stream did not. Tear that down
164
+ // here, because a later `close()` cannot: it treats the `closed`
165
+ // state this line sets as "already torn down" and returns without
166
+ // calling `_doClose()`, so the socket would stay open for the life
167
+ // of the page. Every rung of WshClient's transport ladder that
168
+ // fails hits this path.
157
169
  this.#state = STATE_CLOSED;
170
+ try {
171
+ await this._doClose();
172
+ } catch {
173
+ // Best effort: the connect error is the one worth reporting, and
174
+ // tearing down a half-built transport can fail for its own
175
+ // uninteresting reasons.
176
+ }
177
+ // Deliberately no `_emitClose()`: a transport that never reached
178
+ // `connected` never announced itself, callers have not attached
179
+ // their handlers yet, and a close event for a connection that never
180
+ // opened is a lie.
158
181
  throw err;
159
182
  }
160
183
  }
@@ -235,7 +258,7 @@ export class WshTransport {
235
258
  // ── Abstract methods (must be overridden) ────────────────────────────
236
259
 
237
260
  /** @protected */
238
- async _doConnect(_url) {
261
+ async _doConnect(_url, _options) {
239
262
  throw new Error('_doConnect not implemented');
240
263
  }
241
264
 
@@ -257,6 +280,140 @@ export class WshTransport {
257
280
 
258
281
  // ── WebTransport implementation ──────────────────────────────────────
259
282
 
283
+ // ── WebTransport options ─────────────────────────────────────────────
284
+
285
+ /** Digest length in bytes, keyed by the algorithm name WebTransport accepts. */
286
+ const CERT_HASH_LENGTHS = Object.freeze({ 'sha-256': 32 });
287
+
288
+ /**
289
+ * Decode one `serverCertificateHashes` value into the `BufferSource` the
290
+ * WebTransport constructor requires.
291
+ *
292
+ * Accepts, in addition to a `BufferSource` passed straight through:
293
+ *
294
+ * - **Hex**, with or without separators — this is what
295
+ * `openssl x509 -fingerprint -sha256` prints
296
+ * (`SHA256 Fingerprint=AB:CD:...`), and pasting that string in is the
297
+ * single most likely thing a caller will try.
298
+ * - **Base64** or **base64url**, which is how the same digest usually
299
+ * arrives over a QR code or a pairing payload.
300
+ *
301
+ * Throws on anything else, and on a digest whose decoded length is wrong
302
+ * for the algorithm. Failing here is the whole point: a wrong hash
303
+ * otherwise surfaces as an opaque `WebTransportError` from `wt.ready`
304
+ * with no indication that the *input* was malformed rather than the
305
+ * certificate mismatched.
306
+ *
307
+ * @param {*} value
308
+ * @param {string} algorithm - Lower-cased algorithm name (e.g. 'sha-256').
309
+ * @returns {Uint8Array}
310
+ */
311
+ export function parseCertificateHash(value, algorithm = 'sha-256') {
312
+ const expected = CERT_HASH_LENGTHS[algorithm];
313
+ let bytes;
314
+
315
+ if (value instanceof Uint8Array) {
316
+ bytes = value;
317
+ } else if (ArrayBuffer.isView(value)) {
318
+ bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
319
+ } else if (value instanceof ArrayBuffer) {
320
+ bytes = new Uint8Array(value);
321
+ } else if (typeof value === 'string') {
322
+ bytes = decodeHashString(value);
323
+ } else {
324
+ throw new TypeError(
325
+ 'serverCertificateHashes value must be a BufferSource or a hex/base64 string, ' +
326
+ `got ${value === null ? 'null' : typeof value}`
327
+ );
328
+ }
329
+
330
+ if (expected !== undefined && bytes.length !== expected) {
331
+ throw new RangeError(
332
+ `serverCertificateHashes value for "${algorithm}" must be ${expected} bytes, got ${bytes.length}`
333
+ );
334
+ }
335
+ return bytes;
336
+ }
337
+
338
+ /**
339
+ * @param {string} str
340
+ * @returns {Uint8Array}
341
+ */
342
+ function decodeHashString(str) {
343
+ // Strip the separators OpenSSL and friends emit, plus any surrounding
344
+ // whitespace: "SHA256 Fingerprint=" style output is colon-separated,
345
+ // and hand-copied values often carry spaces or dashes.
346
+ const compact = str.trim().replace(/^[^=]*fingerprint[^=]*=\s*/i, '');
347
+ const hexish = compact.replace(/[\s:-]/g, '');
348
+
349
+ if (/^[0-9a-fA-F]+$/.test(hexish) && hexish.length % 2 === 0) {
350
+ const out = new Uint8Array(hexish.length / 2);
351
+ for (let i = 0; i < out.length; i++) {
352
+ out[i] = Number.parseInt(hexish.slice(i * 2, i * 2 + 2), 16);
353
+ }
354
+ return out;
355
+ }
356
+
357
+ // Not hex — try base64 / base64url.
358
+ const b64 = compact.replace(/-/g, '+').replace(/_/g, '/');
359
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(b64)) {
360
+ throw new TypeError(
361
+ `serverCertificateHashes value is neither hex nor base64: ${JSON.stringify(str)}`
362
+ );
363
+ }
364
+ const bin = atob(b64);
365
+ const out = new Uint8Array(bin.length);
366
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
367
+ return out;
368
+ }
369
+
370
+ /**
371
+ * Normalise a caller-supplied options bag into a `WebTransportOptions`
372
+ * dictionary, or `undefined` when there is nothing to pass.
373
+ *
374
+ * Only `serverCertificateHashes` is transformed; every other key is
375
+ * forwarded verbatim, so options the platform gains later
376
+ * (`congestionControl`, `allowPooling`, `requireUnreliable`, the
377
+ * `anticipatedConcurrentIncoming*Streams` hints, …) work without a
378
+ * change here.
379
+ *
380
+ * @param {object} [options]
381
+ * @returns {object|undefined}
382
+ */
383
+ export function normalizeWebTransportOptions(options) {
384
+ if (!options) return undefined;
385
+ const keys = Object.keys(options).filter((k) => options[k] !== undefined);
386
+ if (keys.length === 0) return undefined;
387
+
388
+ const out = {};
389
+ for (const key of keys) out[key] = options[key];
390
+
391
+ if (out.serverCertificateHashes !== undefined) {
392
+ const list = out.serverCertificateHashes;
393
+ if (!Array.isArray(list)) {
394
+ throw new TypeError('serverCertificateHashes must be an array');
395
+ }
396
+ if (list.length === 0) {
397
+ throw new TypeError(
398
+ 'serverCertificateHashes must not be empty -- omit the option entirely to use the ' +
399
+ 'normal certificate-authority path'
400
+ );
401
+ }
402
+ out.serverCertificateHashes = list.map((entry) => {
403
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry) ||
404
+ ArrayBuffer.isView(entry) || entry instanceof ArrayBuffer) {
405
+ // A bare digest is a natural thing to write; accept it rather
406
+ // than making the caller wrap every value in { algorithm, value }.
407
+ return { algorithm: 'sha-256', value: parseCertificateHash(entry, 'sha-256') };
408
+ }
409
+ const algorithm = String(entry.algorithm ?? 'sha-256').toLowerCase();
410
+ return { algorithm, value: parseCertificateHash(entry.value, algorithm) };
411
+ });
412
+ }
413
+
414
+ return out;
415
+ }
416
+
260
417
  /**
261
418
  * wsh transport over the WebTransport API.
262
419
  *
@@ -287,16 +444,53 @@ export class WebTransportTransport extends WshTransport {
287
444
  /** @type {Promise<void>} Resolves when the incoming-stream acceptor finishes. */
288
445
  #incomingAcceptorDone = null;
289
446
 
447
+ /** @type {object|undefined} Default WebTransport options for every connect. */
448
+ #options;
449
+
450
+ /**
451
+ * @param {object} [options] - Forwarded to the `WebTransport`
452
+ * constructor. Most usefully `serverCertificateHashes`, which lets
453
+ * page JavaScript pin a specific short-lived self-signed certificate
454
+ * by digest instead of requiring one a certificate authority signed:
455
+ *
456
+ * ```js
457
+ * new WebTransportTransport({
458
+ * serverCertificateHashes: [
459
+ * // hex (with or without colons), base64, or raw bytes
460
+ * 'a1:b2:c3:...',
461
+ * ],
462
+ * });
463
+ * ```
464
+ *
465
+ * The web platform imposes the rules, not wsh: the URL must be
466
+ * `https:`, the connection is HTTP/3 only (there is no fallback to
467
+ * HTTP/2 for a pinned certificate), the certificate must use an
468
+ * ECDSA P-256 key and be valid for no more than 14 days, and
469
+ * connection pooling is disabled. Options passed to `connect()`
470
+ * override these per call.
471
+ */
472
+ constructor(options) {
473
+ super();
474
+ this.#options = options;
475
+ }
476
+
290
477
  // ── Lifecycle ──────────────────────────────────────────────────────
291
478
 
292
479
  /** @override */
293
- async _doConnect(url) {
480
+ async _doConnect(url, options) {
294
481
  this.#abort = new AbortController();
295
482
  this.#decoder.reset();
296
483
  this.#nextStreamId = 1;
297
484
 
298
- // Create the WebTransport session.
299
- const wt = new WebTransport(url);
485
+ // Create the WebTransport session. Pass the options dictionary only
486
+ // when there is something in it: `new WebTransport(url)` and
487
+ // `new WebTransport(url, {})` are equivalent per spec, but an empty
488
+ // second argument is noise in a stack trace and in any engine that
489
+ // predates the dictionary.
490
+ const wtOptions = normalizeWebTransportOptions(
491
+ (this.#options || options) && { ...this.#options, ...options }
492
+ );
493
+ const wt = wtOptions ? new WebTransport(url, wtOptions) : new WebTransport(url);
300
494
  this.#wt = wt;
301
495
 
302
496
  // Wait for the connection to be ready.