quico 0.2.0 → 0.4.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/tls_bridge.js CHANGED
@@ -14,8 +14,8 @@
14
14
 
15
15
  import { TLSSession, createSecureContext as _lemonCreateSecureContext } from 'lemon-tls';
16
16
  import nodeTls from 'node:tls';
17
- import { Emitter } from './utils.js';
18
- import { build_transport_params } from './transport.js';
17
+ import { DEBUG, Emitter } from './utils.js';
18
+ import { build_transport_params, parse_transport_params } from './transport.js';
19
19
  import {
20
20
  TLS_CIPHER_SUITES
21
21
  } from './crypto.js';
@@ -52,10 +52,28 @@ function TLSBridge(options) {
52
52
  var originalDcid = options.originalDcid || new Uint8Array(0);
53
53
  var localCid = options.localCid || new Uint8Array(0); // client's own SCID
54
54
  var hostname = options.hostname || null;
55
+ // Peer certificate verification. Defaults to true, matching node:tls — but
56
+ // until now the option was never plumbed through at all, so quico could not
57
+ // talk to a server whose certificate wasn't signed by a publicly trusted CA:
58
+ // no local development against self-signed certs, no internal PKI, and no
59
+ // interop (the QUIC interop runner mints its own CA and addresses hosts as
60
+ // server4 / server6, so every implementation disables verification there).
61
+ var rejectUnauthorized = options.rejectUnauthorized !== false;
62
+ var ca = options.ca || null;
63
+ // ALPN protocol(s) to advertise. Default ['h3']; normalize string → array.
64
+ var supportedAlpns = (function () {
65
+ var a = options.alpn || ['h3'];
66
+ return Array.isArray(a) ? a : [a];
67
+ })();
55
68
 
56
69
  function feedMessage(data) {
57
70
  if (!session) {
58
- var sessionOpts = { isServer: isServer, SNICallback: options.SNICallback };
71
+ var sessionOpts = {
72
+ isServer: isServer,
73
+ SNICallback: options.SNICallback,
74
+ rejectUnauthorized: rejectUnauthorized
75
+ };
76
+ if (ca) sessionOpts.ca = ca;
59
77
  if (!isServer) {
60
78
  sessionOpts.sessionId = new Uint8Array(0); // QUIC: no compatibility mode
61
79
  if (hostname) sessionOpts.servername = hostname;
@@ -71,24 +89,76 @@ function TLSBridge(options) {
71
89
 
72
90
  session.set_context({
73
91
  local_supported_versions: [0x0304],
74
- local_supported_alpns: ['h3'],
92
+ local_supported_alpns: supportedAlpns,
75
93
  local_supported_groups: [29, 23, 24],
76
94
  local_supported_cipher_suites: [0x1301, 0x1302],
77
95
  local_extensions: [{ type: 0x39, data: quicParams }],
78
- local_supported_signature_algorithms: [0x0401, 0x0501, 0x0601],
96
+ // Signature schemes offered to the peer (client: in the ClientHello;
97
+ // server: in CertificateRequest). Full modern list, ordered PSS/ECDSA/
98
+ // EdDSA first, legacy pkcs1 last (TLS 1.2 compat only — TLS 1.3 never
99
+ // signs pkcs1). Two bugs lived in the old pkcs1-only list:
100
+ // 1. Cloudflare zones with ECDSA-only certs reject the ClientHello
101
+ // outright (alert 40 handshake_failure — seen live against
102
+ // speed.cloudflare.com; claude.ai worked only because that zone
103
+ // also carries an RSA cert).
104
+ // 2. lemon-tls hardcodes THIS full list in the HRR CH2, and RFC 8446
105
+ // §4.1.2 requires CH2 to match CH1 exactly — offering a different
106
+ // list in CH1 made every HRR flow non-compliant.
107
+ local_supported_signature_algorithms: [
108
+ 0x0804, 0x0805, 0x0806, // rsa_pss_rsae_sha256/384/512
109
+ 0x0403, 0x0503, 0x0603, // ecdsa_secp256r1/384r1/521r1
110
+ 0x0807, 0x0808, // ed25519, ed448
111
+ 0x0401, 0x0501, 0x0601 // rsa_pkcs1 (TLS 1.2 legacy)
112
+ ],
79
113
  });
80
114
  helloHandled = true; // ClientHello will be generated from this set_context
81
115
  }
82
116
  }
83
- session.message(data);
117
+ // Only feed real handshake bytes. QUICConnection.connect() calls
118
+ // feedMessage(new Uint8Array(0)) as a "trigger" to start the handshake,
119
+ // but the ClientHello is actually built by lemon-tls from set_context()
120
+ // above, on its own timer — the empty feed was always a no-op in intent.
121
+ //
122
+ // It stopped being harmless once lemon-tls hardened parse failures into
123
+ // fatalAlert(): an empty buffer fails as "truncated u8 at 0", which sets
124
+ // the session state to 'error' permanently, so the ClientHello timer then
125
+ // early-returns and NO handshake is ever sent. Guard here rather than
126
+ // making lemon-tls lenient — rejecting malformed handshake input is the
127
+ // correct behavior for a TLS stack facing a hostile peer.
128
+ if (data && data.length > 0) session.message(data);
84
129
  }
85
130
 
86
131
  function setupSessionEvents() {
87
132
 
133
+ // Peer's QUIC transport parameters ride in the TLS quic_transport_parameters
134
+ // extension (type 0x39): client's in the ClientHello, server's in the
135
+ // EncryptedExtensions. lemon-tls surfaces every received handshake message via
136
+ // 'handshakeMessage' with the parsed extensions, so we extract the 0x39 data,
137
+ // parse it, and hand the params to the QUIC layer (which seeds remote_max_data,
138
+ // max_ack_delay, ack_delay_exponent, etc. — previously hardcoded defaults).
139
+ var peerParamsEmitted = false;
140
+ session.on('handshakeMessage', function (type, data, message) {
141
+ if (peerParamsEmitted || !message || !Array.isArray(message.extensions)) return;
142
+ for (var i = 0; i < message.extensions.length; i++) {
143
+ var e = message.extensions[i];
144
+ if (e && (e.type === 0x39 || e.type === 57) && e.data) {
145
+ try {
146
+ var parsed = parse_transport_params(e.data, 0);
147
+ peerParamsEmitted = true;
148
+ if (DEBUG) console.log('[tls] peer transport params parsed (' + e.data.length + 'B)');
149
+ ev.emit('peerTransportParams', parsed);
150
+ } catch (err) {
151
+ if (DEBUG) console.log('[tls] peer transport params parse failed: ' + err.message);
152
+ }
153
+ break;
154
+ }
155
+ }
156
+ });
157
+
88
158
  session.on('hello', function () {
89
159
  if (helloHandled) return; // prevent double-fire
90
160
  helloHandled = true;
91
- console.log('[tls] hello — configuring TLS');
161
+ if (DEBUG) console.log('[tls] hello — configuring TLS');
92
162
 
93
163
  // Transport params — shared defaults + role-specific fields
94
164
  var tp = defaultTransportParams();
@@ -107,13 +177,29 @@ function TLSBridge(options) {
107
177
 
108
178
  var tlsContext = {
109
179
  local_supported_versions: [0x0304],
110
- local_supported_alpns: ['h3'],
180
+ local_supported_alpns: supportedAlpns,
111
181
  local_supported_groups: [29, 23, 24],
112
182
  local_supported_cipher_suites: [0x1301, 0x1302, 0xc02f, 0xc030, 0xcca8],
113
183
  local_extensions: [
114
184
  { type: 0x39, data: quicParams }
115
185
  ],
116
- local_supported_signature_algorithms: [0x0401, 0x0501, 0x0601],
186
+ // Signature schemes offered to the peer (client: in the ClientHello;
187
+ // server: in CertificateRequest). Full modern list, ordered PSS/ECDSA/
188
+ // EdDSA first, legacy pkcs1 last (TLS 1.2 compat only — TLS 1.3 never
189
+ // signs pkcs1). Two bugs lived in the old pkcs1-only list:
190
+ // 1. Cloudflare zones with ECDSA-only certs reject the ClientHello
191
+ // outright (alert 40 handshake_failure — seen live against
192
+ // speed.cloudflare.com; claude.ai worked only because that zone
193
+ // also carries an RSA cert).
194
+ // 2. lemon-tls hardcodes THIS full list in the HRR CH2, and RFC 8446
195
+ // §4.1.2 requires CH2 to match CH1 exactly — offering a different
196
+ // list in CH1 made every HRR flow non-compliant.
197
+ local_supported_signature_algorithms: [
198
+ 0x0804, 0x0805, 0x0806, // rsa_pss_rsae_sha256/384/512
199
+ 0x0403, 0x0503, 0x0603, // ecdsa_secp256r1/384r1/521r1
200
+ 0x0807, 0x0808, // ed25519, ed448
201
+ 0x0401, 0x0501, 0x0601 // rsa_pkcs1 (TLS 1.2 legacy)
202
+ ],
117
203
  };
118
204
 
119
205
  session.set_context(tlsContext);
@@ -125,7 +211,7 @@ function TLSBridge(options) {
125
211
  else if (epoch === 1) quicEpoch = 'handshake';
126
212
  else quicEpoch = 'app';
127
213
 
128
- console.log('[tls] outgoing: epoch=' + quicEpoch + ' type=' + type + ' len=' + data.length);
214
+ if (DEBUG) console.log('[tls] outgoing: epoch=' + quicEpoch + ' type=' + type + ' len=' + data.length);
129
215
  ev.emit('send', quicEpoch, data);
130
216
  });
131
217
 
@@ -135,7 +221,7 @@ function TLSBridge(options) {
135
221
  handshakeSecretsEmitted = true;
136
222
  var cipher = session.getCipher();
137
223
  var hashName = TLS_CIPHER_SUITES[cipher] ? TLS_CIPHER_SUITES[cipher].hash : 'sha256';
138
- console.log('[tls] handshake secrets ready');
224
+ if (DEBUG) console.log('[tls] handshake secrets ready');
139
225
  ev.emit('handshakeSecrets', { local: localSecret, remote: remoteSecret, cipher: cipher, hash: hashName });
140
226
  });
141
227
 
@@ -144,18 +230,34 @@ function TLSBridge(options) {
144
230
  appSecretsEmitted = true;
145
231
  var cipher = session.getCipher();
146
232
  var hashName = TLS_CIPHER_SUITES[cipher] ? TLS_CIPHER_SUITES[cipher].hash : 'sha256';
147
- console.log('[tls] app secrets ready');
233
+ if (DEBUG) console.log('[tls] app secrets ready');
148
234
  ev.emit('appSecrets', { local: localSecret, remote: remoteSecret, cipher: cipher, hash: hashName });
149
235
  });
150
236
 
151
237
  session.on('secureConnect', function () {
152
- console.log('[tls] secureConnect');
238
+ if (DEBUG) console.log('[tls] secureConnect');
153
239
  ev.emit('secureConnect');
154
240
  });
155
241
 
156
242
  session.on('keyUpdate', function (info) {
157
243
  ev.emit('keyUpdate', info.direction, info.secret);
158
244
  });
245
+
246
+ // NSS SSLKEYLOGFILE lines, forwarded to whoever owns the connection.
247
+ //
248
+ // lemon-tls already formats these correctly for TLS 1.3 — including
249
+ // picking client_random from the right side based on role — and QUIC uses
250
+ // the identical label set (CLIENT_HANDSHAKE_TRAFFIC_SECRET,
251
+ // SERVER_TRAFFIC_SECRET_0, ...), so no translation is needed here.
252
+ //
253
+ // Note that subscribing has a cost: lemon-tls skips building these strings
254
+ // entirely while listenerCount('keylog') is 0. We accept that, matching
255
+ // node:tls, which also always emits 'keylog'. Two short buffers per
256
+ // handshake is not a measurable expense, and having key logs available
257
+ // without a rebuild is worth far more when debugging a live capture.
258
+ session.on('keylog', function (line) {
259
+ ev.emit('keylog', line);
260
+ });
159
261
  }
160
262
 
161
263
  return {
@@ -208,4 +310,4 @@ function createSecureContext(options) {
208
310
  return ctx;
209
311
  }
210
312
 
211
- export { TLSBridge, createSecureContext };
313
+ export { TLSBridge, createSecureContext };
package/src/transport.js CHANGED
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import {
16
+ DEBUG,
16
17
  concatUint8Arrays,
17
18
  writeVarInt,
18
19
  readVarInt
@@ -142,7 +143,7 @@ function parse_quic_datagram(array) {
142
143
  : array.slice(start, end);
143
144
 
144
145
  if (packets.length > 0) {
145
- console.log('[quic] coalesced pkt #' + packets.length + ' type=' + pkt.type + ' offset=' + start + ' len=' + pkt.totalLength + ' firstByte=0x' + array[start].toString(16).padStart(2,'0'));
146
+ if (DEBUG) console.log('[quic] coalesced pkt #' + packets.length + ' type=' + pkt.type + ' offset=' + start + ' len=' + pkt.totalLength + ' firstByte=0x' + array[start].toString(16).padStart(2,'0'));
146
147
  }
147
148
 
148
149
  packets.push(pkt);
@@ -153,6 +154,94 @@ function parse_quic_datagram(array) {
153
154
  }
154
155
 
155
156
 
157
+ // ============================================================
158
+ // Version Negotiation (RFC 9000 §6, §17.2.1)
159
+ // ============================================================
160
+
161
+ /** QUIC versions this implementation can actually speak. */
162
+ var SUPPORTED_VERSIONS = [0x00000001];
163
+
164
+ /**
165
+ * Is this a version we can handle?
166
+ * Version 0 identifies a Version Negotiation packet, not a real version.
167
+ */
168
+ function is_supported_version(version) {
169
+ return SUPPORTED_VERSIONS.indexOf(version >>> 0) !== -1;
170
+ }
171
+
172
+ /**
173
+ * Build a Version Negotiation packet (RFC 9000 §17.2.1).
174
+ *
175
+ * A server MUST send one when it receives a long-header packet carrying a
176
+ * version it does not support — it is how a client discovers what the server
177
+ * speaks, and how any QUIC prober checks whether a server is alive without
178
+ * completing a handshake. The interop runner's simulator does exactly that
179
+ * before starting a test, so without this the server looks dead.
180
+ *
181
+ * The packet is unusual: no encryption, no packet number, no frames, and no
182
+ * connection state. It is valid only as an immediate response.
183
+ *
184
+ * IMPORTANT: the connection IDs are swapped. The DCID we send is the SCID we
185
+ * received, and vice versa — the client matches the reply against its own
186
+ * Source CID, so getting this backwards makes the packet be ignored.
187
+ *
188
+ * byte 0 1 (long header) + 7 unpredictable bits
189
+ * 1..4 version 0x00000000
190
+ * 5 DCID length, then DCID
191
+ * ... SCID length, then SCID
192
+ * ... list of supported versions, 4 bytes each
193
+ *
194
+ * @param {Uint8Array} recvDcid DCID from the client's packet (becomes our SCID)
195
+ * @param {Uint8Array} recvScid SCID from the client's packet (becomes our DCID)
196
+ */
197
+ function build_version_negotiation(recvDcid, recvScid) {
198
+ var dcid = recvScid || new Uint8Array(0);
199
+ var scid = recvDcid || new Uint8Array(0);
200
+
201
+ // RFC 9000 §17.2.1: the low 7 bits are unused and SHOULD be random, so that
202
+ // clients don't come to depend on a fixed value.
203
+ var first = 0x80 | (Math.floor(Math.random() * 128) & 0x7f);
204
+
205
+ var versions = SUPPORTED_VERSIONS.slice();
206
+
207
+ // A reserved ("greased") version, per RFC 9000 §6.3. Its pattern is
208
+ // 0x?a?a?a?a, and no endpoint will ever support it. Including one keeps
209
+ // clients honest: any client that assumes it understands every version in
210
+ // the list will break here rather than in the field.
211
+ var grease = ((Math.floor(Math.random() * 16) << 28) |
212
+ (0x0a << 24) |
213
+ (Math.floor(Math.random() * 16) << 20) |
214
+ (0x0a << 16) |
215
+ (Math.floor(Math.random() * 16) << 12) |
216
+ (0x0a << 8) |
217
+ (Math.floor(Math.random() * 16) << 4) |
218
+ 0x0a) >>> 0;
219
+ versions.push(grease);
220
+
221
+ var out = new Uint8Array(1 + 4 + 1 + dcid.length + 1 + scid.length + versions.length * 4);
222
+ var o = 0;
223
+
224
+ out[o++] = first;
225
+ out[o++] = 0; out[o++] = 0; out[o++] = 0; out[o++] = 0; // version 0
226
+
227
+ out[o++] = dcid.length;
228
+ out.set(dcid, o); o += dcid.length;
229
+
230
+ out[o++] = scid.length;
231
+ out.set(scid, o); o += scid.length;
232
+
233
+ for (var i = 0; i < versions.length; i++) {
234
+ var v = versions[i] >>> 0;
235
+ out[o++] = (v >>> 24) & 0xff;
236
+ out[o++] = (v >>> 16) & 0xff;
237
+ out[o++] = (v >>> 8) & 0xff;
238
+ out[o++] = v & 0xff;
239
+ }
240
+
241
+ return out;
242
+ }
243
+
244
+
156
245
  // ============================================================
157
246
  // QUIC Frame encoding (RFC 9000 §12.4)
158
247
  // ============================================================
@@ -201,18 +290,25 @@ function encode_quic_frames(frames) {
201
290
  } else if (frame.type === 'stream') {
202
291
  var typeByte = 0x08;
203
292
  var hasOffset = (frame.offset != null && frame.offset > 0);
204
- var hasLen = (frame.data && frame.data.length > 0);
205
293
  var hasFin = !!frame.fin;
206
-
294
+ var dataLen = (frame.data && frame.data.length) ? frame.data.length : 0;
295
+
296
+ // ALWAYS emit the LEN field. A STREAM frame without the LEN bit means
297
+ // "data runs to the end of the packet"; if anything follows it in the
298
+ // packet (another frame — e.g. a zero-length FIN-only batched with
299
+ // others — or padding), the peer reads those trailing bytes as stream
300
+ // data and the offset overshoots the declared final size
301
+ // (FINAL_SIZE / PROTOCOL_VIOLATION 0xa). An explicit length is always
302
+ // safe and self-delimiting.
207
303
  if (hasOffset) typeByte |= 0x04;
208
- if (hasLen) typeByte |= 0x02;
304
+ typeByte |= 0x02; // LEN — always present
209
305
  if (hasFin) typeByte |= 0x01;
210
306
 
211
307
  parts.push(concatUint8Arrays([
212
308
  new Uint8Array([typeByte]),
213
309
  writeVarInt(frame.id),
214
310
  hasOffset ? writeVarInt(frame.offset) : new Uint8Array(0),
215
- hasLen ? writeVarInt(frame.data.length) : new Uint8Array(0),
311
+ writeVarInt(dataLen),
216
312
  frame.data || new Uint8Array(0)
217
313
  ]));
218
314
 
@@ -315,18 +411,14 @@ function encode_quic_frames(frames) {
315
411
  ]));
316
412
 
317
413
  } else if (frame.type === 'datagram') {
318
- if (frame.contextId != null) {
319
- parts.push(concatUint8Arrays([
320
- new Uint8Array([0x31]),
321
- writeVarInt(frame.contextId),
322
- frame.data
323
- ]));
324
- } else {
325
- parts.push(concatUint8Arrays([
326
- new Uint8Array([0x30]),
327
- frame.data
328
- ]));
329
- }
414
+ // Emit the 0x30 form (no Length): the datagram data runs to the end of
415
+ // the packet. QUICO sends each DATAGRAM as the sole/last frame of its
416
+ // packet, so the length-prefixed 0x31 form is not needed here. (The
417
+ // parser above accepts both forms on receive.)
418
+ parts.push(concatUint8Arrays([
419
+ new Uint8Array([0x30]),
420
+ frame.data
421
+ ]));
330
422
  }
331
423
  }
332
424
 
@@ -498,13 +590,21 @@ function parse_quic_frames(buf) {
498
590
  frames.push({ type: 'handshake_done' });
499
591
 
500
592
  } else if (type === 0x30 || type === 0x31) {
501
- var contextId = null;
593
+ // RFC 9221 DATAGRAM frame. The 0x31 form carries an explicit Length
594
+ // varint and the datagram data is exactly that many bytes; the 0x30 form
595
+ // has no Length and the data runs to the end of the packet (so a 0x30
596
+ // DATAGRAM must be the last frame). The optional field is a Length — not
597
+ // a "context ID" (that draft-era concept was removed before RFC 9221).
598
+ var data;
502
599
  if (type === 0x31) {
503
- var cid = safeReadVarInt(); if (!cid) break;
504
- contextId = cid.value;
600
+ var len = safeReadVarInt(); if (!len) break;
601
+ var end = offset + len.value;
602
+ if (end > buf.length) break; // truncated / invalid length
603
+ data = buf.slice(offset, end); offset = end;
604
+ } else {
605
+ data = buf.slice(offset); offset = buf.length;
505
606
  }
506
- var data = buf.slice(offset); offset = buf.length;
507
- frames.push({ type: 'datagram', contextId: contextId, data: data });
607
+ frames.push({ type: 'datagram', data: data });
508
608
 
509
609
  } else {
510
610
  // Unknown frame — stop
@@ -604,24 +704,28 @@ function parse_transport_params(buf, start) {
604
704
 
605
705
  var id = idVar.value;
606
706
 
707
+ // Safe inner varint: a malformed param could carry an empty/short value,
708
+ // in which case readVarInt returns null — never dereference .value blindly.
709
+ function vi(bytes) { var r = readVarInt(bytes, 0); return r ? r.value : 0; }
710
+
607
711
  switch (id) {
608
712
  case 0x00: out.original_destination_connection_id = valueBytes; break;
609
- case 0x01: out.max_idle_timeout = readVarInt(valueBytes, 0).value; break;
713
+ case 0x01: out.max_idle_timeout = vi(valueBytes); break;
610
714
  case 0x02: out.stateless_reset_token = valueBytes; break;
611
- case 0x03: out.max_udp_payload_size = readVarInt(valueBytes, 0).value; break;
612
- case 0x04: out.initial_max_data = readVarInt(valueBytes, 0).value; break;
613
- case 0x05: out.initial_max_stream_data_bidi_local = readVarInt(valueBytes, 0).value; break;
614
- case 0x06: out.initial_max_stream_data_bidi_remote = readVarInt(valueBytes, 0).value; break;
615
- case 0x07: out.initial_max_stream_data_uni = readVarInt(valueBytes, 0).value; break;
616
- case 0x08: out.initial_max_streams_bidi = readVarInt(valueBytes, 0).value; break;
617
- case 0x09: out.initial_max_streams_uni = readVarInt(valueBytes, 0).value; break;
618
- case 0x0a: out.ack_delay_exponent = readVarInt(valueBytes, 0).value; break;
619
- case 0x0b: out.max_ack_delay = readVarInt(valueBytes, 0).value; break;
715
+ case 0x03: out.max_udp_payload_size = vi(valueBytes); break;
716
+ case 0x04: out.initial_max_data = vi(valueBytes); break;
717
+ case 0x05: out.initial_max_stream_data_bidi_local = vi(valueBytes); break;
718
+ case 0x06: out.initial_max_stream_data_bidi_remote = vi(valueBytes); break;
719
+ case 0x07: out.initial_max_stream_data_uni = vi(valueBytes); break;
720
+ case 0x08: out.initial_max_streams_bidi = vi(valueBytes); break;
721
+ case 0x09: out.initial_max_streams_uni = vi(valueBytes); break;
722
+ case 0x0a: out.ack_delay_exponent = vi(valueBytes); break;
723
+ case 0x0b: out.max_ack_delay = vi(valueBytes); break;
620
724
  case 0x0c: out.disable_active_migration = true; break;
621
- case 0x0e: out.active_connection_id_limit = readVarInt(valueBytes, 0).value; break;
725
+ case 0x0e: out.active_connection_id_limit = vi(valueBytes); break;
622
726
  case 0x0f: out.initial_source_connection_id = valueBytes; break;
623
727
  case 0x10: out.retry_source_connection_id = valueBytes; break;
624
- case 0x20: out.max_datagram_frame_size = readVarInt(valueBytes, 0).value; break;
728
+ case 0x20: out.max_datagram_frame_size = vi(valueBytes); break;
625
729
  default:
626
730
  if (!out.unknown) out.unknown = [];
627
731
  out.unknown.push({ id: id, bytes: valueBytes });
@@ -641,6 +745,11 @@ export {
641
745
  parse_quic_datagram,
642
746
  parse_quic_packet,
643
747
 
748
+ // Version negotiation
749
+ SUPPORTED_VERSIONS,
750
+ is_supported_version,
751
+ build_version_negotiation,
752
+
644
753
  // Frame encode/decode
645
754
  encode_quic_frames,
646
755
  parse_quic_frames,
package/src/utils.js CHANGED
@@ -13,6 +13,16 @@
13
13
  */
14
14
 
15
15
 
16
+ // ============================================================
17
+ // Debug flag — set QUICO_DEBUG=1 to enable verbose logging.
18
+ // Read once at module load. Call sites guard with `if (DEBUG)`
19
+ // so the (expensive) log-string construction is skipped entirely
20
+ // when debugging is off.
21
+ // ============================================================
22
+
23
+ var DEBUG = !!(typeof process !== 'undefined' && process.env && process.env.QUICO_DEBUG);
24
+
25
+
16
26
  // ============================================================
17
27
  // Emitter — lightweight event emitter (single shared copy)
18
28
  // ============================================================
@@ -124,10 +134,12 @@ function readVarInt(array, offset) {
124
134
  array[offset + 7]
125
135
  ) >>> 0;
126
136
  var full = BigInt(hi) * 4294967296n + BigInt(lo);
127
- if (full <= BigInt(Number.MAX_SAFE_INTEGER)) {
128
- return { value: Number(full), byteLength: 8 };
129
- }
130
- return { value: full, byteLength: 8 };
137
+ // Always return a Number. QUIC fields (lengths, offsets, IDs, limits) never
138
+ // legitimately exceed 2^53, and a malicious 8-byte value that does will be
139
+ // rejected by the parsers' bounds checks anyway. Returning a BigInt here
140
+ // would throw "Cannot mix BigInt and other types" the moment the value is
141
+ // combined with a Number offset during parsing.
142
+ return { value: Number(full), byteLength: 8 };
131
143
  }
132
144
 
133
145
  return null;
@@ -164,6 +176,46 @@ function uint8Equal(a, b) {
164
176
  }
165
177
 
166
178
 
179
+ // ============================================================
180
+ // WebTransport framing helpers (draft-ietf-webtrans-http3 / RFC 9297)
181
+ //
182
+ // Both client (webtransport.js) and server (h3.js) build the same
183
+ // byte layouts, so they live here to avoid duplicating the prefix
184
+ // construction across the stream and datagram send paths.
185
+ // ============================================================
186
+
187
+ /**
188
+ * Build a WebTransport stream header: VarInt(streamType) + VarInt(sessionId) [+ data].
189
+ * streamType is 0x41 for a bidirectional WT stream, 0x54 for a unidirectional one.
190
+ * The header prefixes the FIRST chunk on a WT stream (or a lone FIN frame); pass
191
+ * `data` for "header + payload", omit it for a header-only (e.g. FIN) frame.
192
+ */
193
+ function wt_stream_header(isBidi, sessionId, data) {
194
+ var typePrefix = writeVarInt(isBidi ? 0x41 : 0x54);
195
+ var sessionPrefix = writeVarInt(sessionId);
196
+ var dataLen = (data && data.byteLength) ? data.byteLength : 0;
197
+ var out = new Uint8Array(typePrefix.length + sessionPrefix.length + dataLen);
198
+ out.set(typePrefix, 0);
199
+ out.set(sessionPrefix, typePrefix.length);
200
+ if (dataLen) out.set(data, typePrefix.length + sessionPrefix.length);
201
+ return out;
202
+ }
203
+
204
+ /**
205
+ * Build a WebTransport datagram payload: VarInt(quarter_stream_id) + data.
206
+ * The quarter-stream-id is the session's CONNECT stream id divided by 4
207
+ * (RFC 9297 §2.1). Pass the session stream id; the division is done here.
208
+ */
209
+ function wt_datagram_payload(sessionStreamId, data) {
210
+ var prefix = writeVarInt(Math.floor(sessionStreamId / 4));
211
+ var dataLen = (data && data.byteLength) ? data.byteLength : 0;
212
+ var out = new Uint8Array(prefix.length + dataLen);
213
+ out.set(prefix, 0);
214
+ if (dataLen) out.set(data, prefix.length);
215
+ return out;
216
+ }
217
+
218
+
167
219
  // ============================================================
168
220
  // ACK range helpers (flat [from,to, from,to, ...] format)
169
221
  // ============================================================
@@ -280,11 +332,14 @@ function ranges_to_ack_frame(flatRanges, ecnStats, ackDelay) {
280
332
 
281
333
 
282
334
  export {
335
+ DEBUG,
283
336
  Emitter,
284
337
  writeVarInt,
285
338
  readVarInt,
286
339
  concatUint8Arrays,
287
340
  uint8Equal,
341
+ wt_stream_header,
342
+ wt_datagram_payload,
288
343
  ack_frame_to_ranges,
289
344
  ranges_to_ack_frame
290
345
  };