@johnhenry/wsh 0.16.0 → 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/README.md +208 -10
- package/package.json +9 -1
- package/src/auth.mjs +63 -1
- package/src/client.mjs +339 -35
- package/src/control-listener.mjs +130 -0
- package/src/e2e-frame.mjs +32 -1
- package/src/file-transfer.mjs +11 -45
- package/src/index.d.ts +136 -6
- package/src/index.mjs +6 -2
- package/src/mcp-bridge.mjs +70 -51
- package/src/messages.gen.mjs +8 -7
- package/src/mlkem.mjs +27 -0
- package/src/session.mjs +127 -23
- package/src/transport-ws.mjs +16 -2
- package/src/transport.mjs +201 -7
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
|
@@ -20,6 +20,17 @@ const STATE_OPENING = 'opening';
|
|
|
20
20
|
const STATE_ACTIVE = 'active';
|
|
21
21
|
const STATE_CLOSED = 'closed';
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Stream-mode data and control streams are independently-multiplexed
|
|
25
|
+
* transport streams (QUIC/QMux) with no guaranteed relative delivery
|
|
26
|
+
* order (wsh #24). When the data stream reaches EOF, the server-sent
|
|
27
|
+
* `CLOSE` control message -- not the data-stream FIN -- is the
|
|
28
|
+
* authoritative close signal, so `_pumpDataStream()` waits up to this
|
|
29
|
+
* long for `CLOSE` to arrive before falling back to closing on data-EOF
|
|
30
|
+
* alone (a safety net for servers that never send `CLOSE`).
|
|
31
|
+
*/
|
|
32
|
+
const DATA_EOF_CLOSE_GRACE_MS = 300;
|
|
33
|
+
|
|
23
34
|
// ── Text encoding ─────────────────────────────────────────────────────
|
|
24
35
|
|
|
25
36
|
const textEncoder = new TextEncoder();
|
|
@@ -133,6 +144,18 @@ export class WshSession {
|
|
|
133
144
|
/** @type {number|null} Exit code received from the server. */
|
|
134
145
|
#exitCode = null;
|
|
135
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Pending fallback timer scheduled when the data stream hits EOF
|
|
149
|
+
* before a `CLOSE` control message has arrived (wsh #24). Cleared
|
|
150
|
+
* as soon as `CLOSE` arrives (or the session is otherwise closed) so
|
|
151
|
+
* a prompt `CLOSE` short-circuits the wait instead of idling out the
|
|
152
|
+
* full grace period.
|
|
153
|
+
* @type {ReturnType<typeof setTimeout>|null}
|
|
154
|
+
*/
|
|
155
|
+
#closeGraceTimer = null;
|
|
156
|
+
/** Why the session closed, when it closed for a reason worth naming. */
|
|
157
|
+
#closeReason = null;
|
|
158
|
+
|
|
136
159
|
// ── Callbacks ───────────────────────────────────────────────────────
|
|
137
160
|
|
|
138
161
|
/**
|
|
@@ -454,7 +477,7 @@ export class WshSession {
|
|
|
454
477
|
for (const { nonce, ciphertext } of wireChunks) {
|
|
455
478
|
const counter = this.#e2eRecvCounter++;
|
|
456
479
|
try {
|
|
457
|
-
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 });
|
|
458
481
|
plaintexts.push(plaintext);
|
|
459
482
|
} catch (err) {
|
|
460
483
|
console.error(
|
|
@@ -580,6 +603,7 @@ export class WshSession {
|
|
|
580
603
|
// Optimistically mark closed so no further writes are accepted.
|
|
581
604
|
this.#state = STATE_CLOSED;
|
|
582
605
|
this.#abort.abort();
|
|
606
|
+
this.#clearCloseGraceTimer();
|
|
583
607
|
|
|
584
608
|
// Send CLOSE to the server (best-effort).
|
|
585
609
|
try {
|
|
@@ -660,7 +684,7 @@ export class WshSession {
|
|
|
660
684
|
// delivery of *this* frame's plaintext to onData is deferred by
|
|
661
685
|
// one microtask/macrotask relative to synchronous cases.
|
|
662
686
|
const counter = this.#e2eRecvCounter++;
|
|
663
|
-
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 })
|
|
664
688
|
.then((plaintext) => {
|
|
665
689
|
this.#virtualBackend?.pushData(plaintext);
|
|
666
690
|
try {
|
|
@@ -697,15 +721,12 @@ export class WshSession {
|
|
|
697
721
|
}
|
|
698
722
|
|
|
699
723
|
case MSG.CLOSE: {
|
|
700
|
-
// Server-initiated close.
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
this.#resolvePendingFileChunk(null);
|
|
707
|
-
this.#emitClose();
|
|
708
|
-
}
|
|
724
|
+
// Server-initiated close. This is the authoritative close signal
|
|
725
|
+
// for stream-mode sessions (wsh #24) -- it always short-circuits
|
|
726
|
+
// any pending data-EOF grace timer, regardless of which arrived
|
|
727
|
+
// first.
|
|
728
|
+
this.#clearCloseGraceTimer();
|
|
729
|
+
this.#closeNow();
|
|
709
730
|
break;
|
|
710
731
|
}
|
|
711
732
|
|
|
@@ -848,11 +869,32 @@ export class WshSession {
|
|
|
848
869
|
if (this.#e2eKey !== null && this.#streamAccumulator) {
|
|
849
870
|
const deliverable = await this.#openStreamChunks(value);
|
|
850
871
|
if (deliverable === null) {
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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;
|
|
856
898
|
}
|
|
857
899
|
for (const plaintext of deliverable) {
|
|
858
900
|
try {
|
|
@@ -879,17 +921,69 @@ export class WshSession {
|
|
|
879
921
|
reader.releaseLock();
|
|
880
922
|
}
|
|
881
923
|
|
|
882
|
-
//
|
|
883
|
-
//
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
924
|
+
// The data stream ended, but its FIN carries no ordering guarantee
|
|
925
|
+
// relative to the control stream (wsh #24): a server that closes the
|
|
926
|
+
// data stream and sends EXIT/CLOSE around the same time can have
|
|
927
|
+
// this EOF arrive before those control messages. Closing the session
|
|
928
|
+
// here unconditionally would resolve onClose without onExit ever
|
|
929
|
+
// having fired. Instead, give the (expected) CLOSE control message a
|
|
930
|
+
// bounded grace period to arrive -- its handler clears this timer and
|
|
931
|
+
// performs the real transition -- and only self-close on timeout as a
|
|
932
|
+
// fallback for servers that never send CLOSE after ending the data
|
|
933
|
+
// stream.
|
|
934
|
+
if (this.#state !== STATE_CLOSED && this.#closeGraceTimer === null) {
|
|
935
|
+
this.#closeGraceTimer = setTimeout(() => {
|
|
936
|
+
this.#closeGraceTimer = null;
|
|
937
|
+
this.#closeNow();
|
|
938
|
+
}, DATA_EOF_CLOSE_GRACE_MS);
|
|
939
|
+
this.#closeGraceTimer?.unref?.();
|
|
888
940
|
}
|
|
889
941
|
}
|
|
890
942
|
|
|
891
943
|
// ── Private helpers ─────────────────────────────────────────────────
|
|
892
944
|
|
|
945
|
+
/**
|
|
946
|
+
* Cancel any pending data-EOF close-grace timer (wsh #24). Safe to call
|
|
947
|
+
* whether or not a timer is currently scheduled.
|
|
948
|
+
* @private
|
|
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
|
+
|
|
980
|
+
#clearCloseGraceTimer() {
|
|
981
|
+
if (this.#closeGraceTimer !== null) {
|
|
982
|
+
clearTimeout(this.#closeGraceTimer);
|
|
983
|
+
this.#closeGraceTimer = null;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
893
987
|
/**
|
|
894
988
|
* Release stream resources without sending a CLOSE message.
|
|
895
989
|
* @private
|
|
@@ -909,9 +1003,19 @@ export class WshSession {
|
|
|
909
1003
|
* Emit the onClose callback exactly once.
|
|
910
1004
|
* @private
|
|
911
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
|
+
|
|
912
1016
|
#emitClose() {
|
|
913
1017
|
try {
|
|
914
|
-
this.onClose?.();
|
|
1018
|
+
this.onClose?.(this.#closeReason);
|
|
915
1019
|
} catch (err) {
|
|
916
1020
|
console.error('[wsh:session] onClose handler error:', err);
|
|
917
1021
|
}
|
package/src/transport-ws.mjs
CHANGED
|
@@ -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
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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.
|