@johnhenry/wsh 0.21.0 → 0.23.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.
@@ -0,0 +1,121 @@
1
+ /**
2
+ * A short-lived self-signed certificate for WebTransport's
3
+ * `serverCertificateHashes` pinning, with no dependency: the X.509 structure
4
+ * is small enough to assemble as DER by hand, and `node:crypto` signs it.
5
+ *
6
+ * The web platform's rules for a pinned certificate (what a browser enforces):
7
+ * an ECDSA P-256 key, a validity period of at most 14 days, and the page pins
8
+ * the SHA-256 of the DER certificate. Nothing else about it is checked (no
9
+ * chain, and the name is not matched), but the subject alternative names are
10
+ * filled in anyway so the same certificate also works for a client that does.
11
+ */
12
+
13
+ import { createHash, generateKeyPairSync, randomBytes, sign } from 'node:crypto';
14
+ import net from 'node:net';
15
+
16
+ /** What a browser allows for `serverCertificateHashes`. */
17
+ export const MAX_PINNED_CERT_DAYS = 14;
18
+ const DAY_MS = 24 * 60 * 60 * 1000;
19
+
20
+ const OID = {
21
+ ecdsaWithSha256: '2a8648ce3d040302',
22
+ commonName: '550403',
23
+ subjectAltName: '551d11',
24
+ extKeyUsage: '551d25',
25
+ serverAuth: '2b06010505070301',
26
+ keyUsage: '551d0f',
27
+ };
28
+
29
+ // ── DER ──────────────────────────────────────────────────────────────
30
+
31
+ const concat = (...parts) => Buffer.concat(parts.map((p) => (Buffer.isBuffer(p) ? p : Buffer.from(p))));
32
+ function der(tag, ...content) {
33
+ const body = concat(...content);
34
+ const n = body.length;
35
+ const len = n < 0x80 ? Buffer.from([n]) : n < 0x100 ? Buffer.from([0x81, n]) : Buffer.from([0x82, n >> 8, n & 0xff]);
36
+ return concat(Buffer.from([tag]), len, body);
37
+ }
38
+ const seq = (...c) => der(0x30, ...c);
39
+ const set = (...c) => der(0x31, ...c);
40
+ const oid = (hex) => der(0x06, Buffer.from(hex, 'hex'));
41
+ const utf8 = (s) => der(0x0c, Buffer.from(s, 'utf8'));
42
+ const octets = (b) => der(0x04, b);
43
+ const bitString = (b) => der(0x03, Buffer.from([0]), b);
44
+ /** Positive INTEGER from big-endian bytes. */
45
+ function integer(bytes) {
46
+ let b = Buffer.from(bytes);
47
+ while (b.length > 1 && b[0] === 0) b = b.subarray(1);
48
+ if (b[0] & 0x80) b = concat(Buffer.from([0]), b);
49
+ return der(0x02, b);
50
+ }
51
+ function utcTime(date) {
52
+ const p = (n) => String(n).padStart(2, '0');
53
+ return der(0x17, Buffer.from(`${p(date.getUTCFullYear() % 100)}${p(date.getUTCMonth() + 1)}${p(date.getUTCDate())}${p(date.getUTCHours())}${p(date.getUTCMinutes())}${p(date.getUTCSeconds())}Z`, 'ascii'));
54
+ }
55
+
56
+ function ipBytes(addr) {
57
+ if (net.isIPv4(addr)) return Buffer.from(addr.split('.').map(Number));
58
+ // IPv6, possibly with "::" compression
59
+ const [head, tail = ''] = addr.split('::');
60
+ const h = head ? head.split(':') : [];
61
+ const t = tail ? tail.split(':') : [];
62
+ const groups = [...h, ...Array(8 - h.length - t.length).fill('0'), ...t];
63
+ return Buffer.from(groups.flatMap((g) => { const v = parseInt(g || '0', 16); return [v >> 8, v & 0xff]; }));
64
+ }
65
+
66
+ function pem(label, derBytes) {
67
+ const b64 = derBytes.toString('base64').match(/.{1,64}/g).join('\n');
68
+ return `-----BEGIN ${label}-----\n${b64}\n-----END ${label}-----\n`;
69
+ }
70
+
71
+ /**
72
+ * @param {object} [opts]
73
+ * @param {string[]} [opts.hosts=['localhost', '127.0.0.1', '::1']] - DNS names and IP addresses for the SAN.
74
+ * @param {number} [opts.validityDays=13] - At most 14 (a browser refuses a pinned certificate valid for longer).
75
+ * @param {Date} [opts.now]
76
+ * @returns {{ cert: string, privKey: string, hash: Uint8Array, hashHex: string, notBefore: Date, notAfter: Date }}
77
+ * `cert` / `privKey` are PEM; `hash` is the SHA-256 of the DER certificate, the value to pin.
78
+ */
79
+ export function generateSelfSignedCertificate({ hosts = ['localhost', '127.0.0.1', '::1'], validityDays = 13, now = new Date() } = {}) {
80
+ if (!(validityDays > 0) || validityDays > MAX_PINNED_CERT_DAYS) {
81
+ throw new RangeError(`selfSigned: validityDays must be in (0, ${MAX_PINNED_CERT_DAYS}] -- browsers refuse a pinned certificate valid for longer`);
82
+ }
83
+ const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
84
+ // An hour of back-dating tolerates clock skew; the whole window stays under 14 days for validityDays <= 13.
85
+ const skewMs = Math.min(60 * 60 * 1000, (MAX_PINNED_CERT_DAYS - validityDays) * DAY_MS);
86
+ const notBefore = new Date(Math.floor((now.getTime() - skewMs) / 1000) * 1000);
87
+ const notAfter = new Date(Math.floor((now.getTime() + validityDays * DAY_MS - skewMs) / 1000) * 1000);
88
+
89
+ const name = seq(set(seq(oid(OID.commonName), utf8('wsh self-signed'))));
90
+ const san = seq(...hosts.map((h) => (net.isIP(h) ? der(0x87, ipBytes(h)) : der(0x82, Buffer.from(h, 'ascii')))));
91
+ const extensions = der(0xa3, seq(
92
+ seq(oid(OID.keyUsage), der(0x01, Buffer.from([0xff])), octets(bitString(Buffer.from([0x80])))), // digitalSignature
93
+ seq(oid(OID.extKeyUsage), octets(seq(oid(OID.serverAuth)))),
94
+ seq(oid(OID.subjectAltName), octets(san)),
95
+ ));
96
+ const serial = randomBytes(16);
97
+ serial[0] &= 0x7f;
98
+ serial[0] |= 0x01;
99
+
100
+ const tbs = seq(
101
+ der(0xa0, integer([2])), // v3
102
+ integer(serial),
103
+ seq(oid(OID.ecdsaWithSha256)),
104
+ name,
105
+ seq(utcTime(notBefore), utcTime(notAfter)),
106
+ name,
107
+ publicKey.export({ type: 'spki', format: 'der' }),
108
+ extensions,
109
+ );
110
+ const signature = sign('sha256', tbs, privateKey); // DER-encoded ECDSA-Sig-Value
111
+ const certDer = seq(tbs, seq(oid(OID.ecdsaWithSha256)), bitString(signature));
112
+ const hash = new Uint8Array(createHash('sha256').update(certDer).digest());
113
+ return {
114
+ cert: pem('CERTIFICATE', certDer),
115
+ privKey: privateKey.export({ type: 'pkcs8', format: 'pem' }),
116
+ hash,
117
+ hashHex: Buffer.from(hash).toString('hex'),
118
+ notBefore,
119
+ notAfter,
120
+ };
121
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * WebTransport listener for `@johnhenry/wsh/server` (`createWshServer({ webTransport })`).
3
+ *
4
+ * QUIC already multiplexes, so the QMux layer the WebSocket path needs is not
5
+ * used. A session's FIRST bidirectional stream is the control stream (the
6
+ * client's `WebTransportTransport` opens it first): length-prefixed CBOR
7
+ * frames, exactly what `frameEncode` / `FrameDecoder` speak. Every later
8
+ * bidirectional stream the client opens is a data stream, bound to the next
9
+ * exec channel in `OpenOk` order like a QMux one.
10
+ *
11
+ * Streams a client creates are visible to the host the moment they are
12
+ * created (HTTP/3 sends the stream's WebTransport header with it), so the
13
+ * host's `stream-announce` feature holds here as it does over QMux: the
14
+ * client writes no primer.
15
+ *
16
+ * `@fails-components/webtransport` (and its native HTTP/3 transport package)
17
+ * are optional peers, imported only when `webTransport` is given.
18
+ */
19
+
20
+ import { randomBytes } from 'node:crypto';
21
+ import { FrameDecoder, frameEncode } from '../cbor.mjs';
22
+ import { generateSelfSignedCertificate } from './self-signed.mjs';
23
+
24
+ const DEFAULT_PATH = '/wsh';
25
+
26
+ /** What a QMux stream looks like to the connection code, over a WebTransport bidirectional stream. */
27
+ function adaptStream({ readable, writable }, onGone) {
28
+ const writer = writable.getWriter();
29
+ const reader = readable.getReader();
30
+ const stream = {
31
+ onData: null,
32
+ onEnd: null,
33
+ onReset: null,
34
+ write: (bytes) => writer.write(bytes),
35
+ close: async () => { try { await writer.close(); } catch { /* peer gone or already closed */ } },
36
+ /** Start delivering: after the consumer has had the chance to set its handlers. */
37
+ start() {
38
+ (async () => {
39
+ try {
40
+ for (;;) {
41
+ const { done, value } = await reader.read();
42
+ if (done) { stream.onEnd?.(); return; }
43
+ stream.onData?.(value);
44
+ }
45
+ } catch {
46
+ stream.onReset?.();
47
+ } finally {
48
+ onGone?.();
49
+ }
50
+ })();
51
+ },
52
+ };
53
+ return stream;
54
+ }
55
+
56
+ /**
57
+ * @param {object} opts
58
+ * @param {object} opts.options - `createWshServer`'s `webTransport` option
59
+ * @param {string} opts.host
60
+ * @param {(opts: object) => { receiveMessage: Function, bindStream: Function, close: Function }} opts.attach
61
+ * @param {(line: string) => void} opts.log
62
+ * @returns {Promise<{ port: number, host: string, path: string, certificateHash: Uint8Array | null, certificateHashHex: string | null, notAfter: Date | null, close(): Promise<void> }>}
63
+ */
64
+ export async function startWebTransport({ options, host, attach, log }) {
65
+ const { port = 0, path = DEFAULT_PATH, selfSigned = false } = options;
66
+ let { cert, privKey } = options;
67
+ let generated = null;
68
+ if (selfSigned) {
69
+ if (cert || privKey) throw new TypeError('createWshServer: webTransport takes either selfSigned or cert/privKey, not both');
70
+ generated = generateSelfSignedCertificate(typeof selfSigned === 'object' ? selfSigned : {});
71
+ ({ cert, privKey } = generated);
72
+ }
73
+ if (!cert || !privKey) throw new TypeError('createWshServer: webTransport needs cert and privKey (PEM), or selfSigned: true');
74
+ if (typeof path !== 'string' || !path.startsWith('/')) throw new TypeError('createWshServer: webTransport.path must start with "/"');
75
+
76
+ let wtLib;
77
+ try {
78
+ wtLib = await import('@fails-components/webtransport');
79
+ await wtLib.quicheLoaded;
80
+ } catch (err) {
81
+ throw new Error(
82
+ 'WebTransport needs the optional packages "@fails-components/webtransport" and '
83
+ + '"@fails-components/webtransport-transport-http3-quiche": npm install @fails-components/webtransport @fails-components/webtransport-transport-http3-quiche',
84
+ { cause: err },
85
+ );
86
+ }
87
+
88
+ const server = new wtLib.Http3Server({
89
+ port, host: options.host ?? host, secret: options.secret ?? randomBytes(16).toString('hex'), cert, privKey,
90
+ });
91
+ const sessions = server.sessionStream(path);
92
+ server.startServer();
93
+ try {
94
+ await server.ready;
95
+ } catch (err) {
96
+ try { server.stopServer(); } catch { /* never started */ }
97
+ throw err;
98
+ }
99
+ const addr = server.address();
100
+
101
+ const live = new Set();
102
+ let closing = false;
103
+
104
+ async function serve(session) {
105
+ let conn = null;
106
+ let closed = false;
107
+ const end = () => { if (!closed) { closed = true; live.delete(session); conn?.close(); } };
108
+ live.add(session);
109
+ session.closed.then(end, end);
110
+ try {
111
+ await session.ready;
112
+ } catch { end(); return; }
113
+
114
+ let controlWriter = null;
115
+ const decoder = new FrameDecoder();
116
+ conn = attach({
117
+ sendMessage: (msg) => (controlWriter ? controlWriter.write(frameEncode(msg)) : undefined),
118
+ remote: { address: session.peerAddress, headers: session.header ?? {} },
119
+ closeTransport: () => { try { session.close(); } catch { /* already closed */ } },
120
+ });
121
+
122
+ const incoming = session.incomingBidirectionalStreams.getReader();
123
+ let first = true;
124
+ try {
125
+ for (;;) {
126
+ const { done, value: bidi } = await incoming.read();
127
+ if (done) break;
128
+ if (first) {
129
+ first = false;
130
+ controlWriter = bidi.writable.getWriter();
131
+ const reader = bidi.readable.getReader();
132
+ (async () => {
133
+ try {
134
+ for (;;) {
135
+ const { done: fin, value } = await reader.read();
136
+ if (fin) break;
137
+ let msgs;
138
+ try { msgs = decoder.feed(value); } catch (e) { log(`webtransport frame decode error: ${e.message}`); break; }
139
+ for (const m of msgs) conn.receiveMessage(m);
140
+ }
141
+ } catch { /* session went away */ }
142
+ end();
143
+ })();
144
+ } else {
145
+ const s = adaptStream(bidi);
146
+ conn.bindStream(s);
147
+ s.start();
148
+ }
149
+ }
150
+ } catch { /* session closed under us */ }
151
+ end();
152
+ }
153
+
154
+ (async () => {
155
+ const reader = sessions.getReader();
156
+ try {
157
+ for (;;) {
158
+ const { done, value: session } = await reader.read();
159
+ if (done || closing) break;
160
+ serve(session).catch((e) => log(`webtransport session error: ${e.message}`));
161
+ }
162
+ } catch { /* server stopped */ }
163
+ })();
164
+
165
+ const bound = {
166
+ port: addr.port,
167
+ host: addr.host,
168
+ path,
169
+ certificateHash: generated?.hash ?? null,
170
+ certificateHashHex: generated?.hashHex ?? null,
171
+ notAfter: generated?.notAfter ?? null,
172
+ async close() {
173
+ closing = true;
174
+ const open = [...live];
175
+ for (const s of open) { try { s.close(); } catch { /* gone */ } }
176
+ // Let the close reach the peers before the socket is torn down (bounded: a peer that never acks cannot hold shutdown).
177
+ await Promise.race([
178
+ Promise.allSettled(open.map((s) => s.closed)),
179
+ new Promise((r) => { const t = setTimeout(r, 500); t.unref?.(); }),
180
+ ]);
181
+ live.clear();
182
+ try { server.stopServer(); } catch { /* already stopped */ }
183
+ },
184
+ };
185
+ log(`webtransport listening on https://${addr.host}:${addr.port}${path}`
186
+ + (generated ? ` (self-signed, sha-256 ${generated.hashHex}, until ${generated.notAfter.toISOString()})` : ''));
187
+ return bound;
188
+ }
package/src/transport.mjs CHANGED
@@ -493,6 +493,11 @@ export class WebTransportTransport extends WshTransport {
493
493
  const wt = wtOptions ? new WebTransport(url, wtOptions) : new WebTransport(url);
494
494
  this.#wt = wt;
495
495
 
496
+ // A session that never becomes ready rejects `closed` as well as `ready`. Nothing
497
+ // observes `closed` until #monitorClosed (below, after `ready`), so the second
498
+ // rejection would surface as an unhandled one -- fatal in Node.
499
+ wt.closed?.catch?.(() => {});
500
+
496
501
  // Wait for the connection to be ready.
497
502
  await wt.ready;
498
503