quico 0.8.0 → 0.9.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 CHANGED
@@ -155,7 +155,7 @@ stack trace ends in code you can open, not in a `.node` binary.
155
155
  |---|---|
156
156
  | **Node.js 18+** | ESM only (`import`). Tested on 18, 20 and 22. |
157
157
  | **No compiler** | Pure JavaScript — no native addons. |
158
- | **Dependencies** | [`lemon-tls`](https://github.com/colocohen/lemon-tls) (TLS 1.3) and [`flat-ranges`](https://www.npmjs.com/package/flat-ranges) (range arithmetic). Nothing else. |
158
+ | **Dependencies** | [`lemon-tls`](https://github.com/colocohen/lemon-tls) (TLS 1.3), [`flat-ranges`](https://www.npmjs.com/package/flat-ranges) (range arithmetic), and `cryptographic-formats` (certificate formats — the dates WebTransport's `serverCertificateHashes` checks; lemon-tls reads certificates with it too). Nothing else. |
159
159
 
160
160
  ```bash
161
161
  npm install quico
@@ -2507,7 +2507,7 @@ and you route datagrams yourself.
2507
2507
  | `quic.earlyDataStatus` | 0-RTT: `null` (none), `'offered'` (a client's, undecided), `'accepted'`, `'rejected'` |
2508
2508
  | `quic.isEarlyStream(id)` | Server: whether the stream was opened by 0-RTT data |
2509
2509
  | `quic.whenConfirmed(cb)` | `cb()` once the handshake is confirmed (RFC 9001 §4.1.2), at once if it is |
2510
- | `quic.alpnProtocol`, `servername`, `getCertificate()`, `getTicketInfo()`, `isSessionReused()` | What the handshake decided, with `TLSSocket`'s names |
2510
+ | `quic.alpnProtocol`, `servername`, `getCertificate()`, `getTicketInfo()`, `isSessionReused()` | What the handshake decided, with `TLSSocket`'s names — `getCertificate()` in its shape too (`subject` and `issuer` as objects, `{ CN, O, … }`), lemon-tls's `certificateObject` |
2511
2511
  | `quic.on(event, fn)` / `off` | Event subscription |
2512
2512
 
2513
2513
  **Events:** `'connect'`, `'stream'` (`id, data, fin`), `'stream_reset'` (`id, code,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quico",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "A pure JavaScript implementation of QUIC, HTTP/3, QPACK, and WebTransport for Node.js",
5
5
  "type": "module",
6
6
  "main": "index.cjs",
@@ -80,7 +80,8 @@
80
80
  }
81
81
  ],
82
82
  "dependencies": {
83
+ "cryptographic-formats": "^0.1.0",
83
84
  "flat-ranges": "^2.2.0",
84
- "lemon-tls": "^0.10.0"
85
+ "lemon-tls": "^0.11.0"
85
86
  }
86
- }
87
+ }
package/src/client.js CHANGED
@@ -479,6 +479,7 @@ function request(url, options, callback) {
479
479
  startupCap: options.startupCap, pmtuDiscovery: options.pmtuDiscovery,
480
480
  maxUdpPayloadSize: options.maxUdpPayloadSize, initialRtt: options.initialRtt,
481
481
  reorderWindow: options.reorderWindow, initialCongestionWindow: options.initialCongestionWindow,
482
+ socketBufferSize: options.socketBufferSize, // the UDP socket's buffers (utils.js)
482
483
  // 0-RTT with the saved session (below), off unless asked for.
483
484
  earlyData: options.earlyData === true
484
485
  };
@@ -53,7 +53,7 @@ import { EventEmitter } from 'node:events';
53
53
  import dgram from 'node:dgram';
54
54
  import dns from 'node:dns';
55
55
  import net from 'node:net';
56
- import { readVarInt, writeVarInt } from './utils.js';
56
+ import { readVarInt, writeVarInt, checkedSocketBufferSize, udpSocketOptions } from './utils.js';
57
57
 
58
58
  var DEFAULT_TEMPLATE = '/.well-known/masque/udp/{target_host}/{target_port}/';
59
59
  var MIN_IDLE_MS = 120000; // §3.1: "SHOULD NOT use a period lower than two minutes"
@@ -369,6 +369,9 @@ function createConnectUdp(options) {
369
369
  }
370
370
  var headers = opts.headers || null;
371
371
  if (opts.relay === false) { this._open(headers); return this; }
372
+ // The socket to the target, when this tunnel makes it (no opts.socket):
373
+ // its buffers (utils.js), 4 MB by default; socketBufferSize 0, the OS's.
374
+ var bufferSize = checkedSocketBufferSize(opts.socketBufferSize, 'accept({ socketBufferSize })');
372
375
 
373
376
  var allowPrivate = opts.allowPrivate != null ? !!opts.allowPrivate : allowPrivateDefault;
374
377
  var host = this.target.host;
@@ -390,7 +393,7 @@ function createConnectUdp(options) {
390
393
  if (opts.socket) self._useSocket(opts.socket, false, pick.address, headers);
391
394
  else {
392
395
  var sock;
393
- try { sock = dgram.createSocket(family === 6 ? 'udp6' : 'udp4'); }
396
+ try { sock = dgram.createSocket(udpSocketOptions({ type: family === 6 ? 'udp6' : 'udp4' }, bufferSize)); }
394
397
  catch (e2) { self._refuse(502, proxyStatus('proxy_internal_error'), e2); return; }
395
398
  sock.once('error', function (e3) {
396
399
  if (self.state === 'pending') self._refuse(502, proxyStatus('destination_ip_unroutable', e3.code), e3);
@@ -395,6 +395,7 @@ function ProxyH3Connection(key, proxy, o, ech) {
395
395
  if (self.state !== 'connecting') return;
396
396
  self.udp = createQuicClientSocket({
397
397
  remoteIp: ip, remotePort: proxy.port, hostname: o.servername || proxy.hostname,
398
+ socketBufferSize: o.socketBufferSize,
398
399
  rejectUnauthorized: o.rejectUnauthorized !== false, ca: o.ca || null,
399
400
  key: o.key || null, cert: o.cert || null, passphrase: o.passphrase,
400
401
  quicVersions: o.quicVersions,
package/src/crypto.js CHANGED
@@ -607,6 +607,12 @@ function decrypt_quic_packet(array, readKey, readIv, readHp, ownCid, largestPn,
607
607
  return {
608
608
  packet_number: packetNumber,
609
609
  key_phase: keyPhase,
610
+ // The first byte with header protection removed: its reserved bits are
611
+ // the caller's to check — only once the AEAD has passed the packet
612
+ // (RFC 9000 §17.2, §17.3.1), since before that anyone could set them.
613
+ first_byte: array[0],
614
+ // null: the AEAD refused the packet. An empty Uint8Array: it passed, and
615
+ // there was nothing in it — a different thing (RFC 9000 §12.4).
610
616
  plaintext: plaintext
611
617
  };
612
618
  }
package/src/h3.js CHANGED
@@ -307,8 +307,17 @@ function parseSettingsPayload(buf) {
307
307
  var list = [];
308
308
  var seen = {};
309
309
  while (offset < buf.length) {
310
- var id = readVarInt(buf, offset); if (!id) break; offset += id.byteLength;
311
- var val = readVarInt(buf, offset); if (!val) break; offset += val.byteLength;
310
+ // A payload that ends inside an identifier or before its value is
311
+ // H3_FRAME_ERROR (RFC 9114 §7.1). It was cut short silently: the setting
312
+ // dropped, the connection on.
313
+ var id = readVarInt(buf, offset);
314
+ var val = id ? readVarInt(buf, offset + id.byteLength) : null;
315
+ if (!id || !val) {
316
+ var te = new Error('SETTINGS payload ends inside a setting (' + (buf.length - offset) + ' byte(s) left)');
317
+ te.h3ErrorCode = 0x0106;
318
+ throw te;
319
+ }
320
+ offset += id.byteLength + val.byteLength;
312
321
  // RFC 9114 §7.2.4: an identifier twice, or one of HTTP/2's that HTTP/3
313
322
  // reserves (0x02-0x05), is H3_SETTINGS_ERROR.
314
323
  if (seen[id.value] || (id.value >= 0x02 && id.value <= 0x05)) {
@@ -646,6 +655,11 @@ function H3Connection(options) {
646
655
  var decoderStreamId = null;
647
656
  var qpackKnownReceived = 0; // insertions the peer has acknowledged
648
657
  var qpackPendingAcks = 0; // insertions of THEIRS we owe an ack for
658
+ // Field sections we sent with a non-zero Required Insert Count, not yet
659
+ // acknowledged: stream id → [RIC, …] in the order sent (RFC 9204 §2.1.1).
660
+ // The decoder acknowledges each (Section Acknowledgment), or cancels the
661
+ // stream's; an acknowledgment for none is an error (§4.4.1).
662
+ var qpackUnacked = new Map();
649
663
 
650
664
  // ---- SETTINGS remembered for 0-RTT (RFC 9114 §7.2.4.2) ----
651
665
  //
@@ -744,9 +758,10 @@ function H3Connection(options) {
744
758
  * reference — and until an entry is below it, we send a literal instead.
745
759
  * That single number is what makes this encoder non-blocking.
746
760
  *
747
- * Section acknowledgements and stream cancellations are parsed and skipped:
748
- * they drive eviction accounting in an encoder that references
749
- * unacknowledged entries, which this one never does.
761
+ * Each instruction is held to what we actually sent (RFC 9204 §4.4): a
762
+ * Section Acknowledgment for a section we have outstanding on that stream,
763
+ * an Insert Count Increment of at least 1 that does not pass the entries we
764
+ * inserted. Anything else is QPACK_DECODER_STREAM_ERROR.
750
765
  */
751
766
  function processQpackDecoderStream(s) {
752
767
  if (s.buffered === 0) return;
@@ -755,14 +770,41 @@ function H3Connection(options) {
755
770
  var buf = Buffer.from(copyBytes(s, s.buffered));
756
771
 
757
772
  var r = qpackParseDecoderInstructions(buf, 0);
758
- if (r.increments > 0) {
759
- qpackKnownReceived += r.increments;
760
- if (DEBUG) console.log('[h3] qpack acked ' + r.increments +
761
- ' (known=' + qpackKnownReceived + ')');
773
+ for (var k = 0; k < r.instructions.length; k++) {
774
+ var ins = r.instructions[k];
775
+ if (ins.type === 'section_ack') {
776
+ var pend = qpackUnacked.get(ins.value);
777
+ if (!pend || pend.length === 0) {
778
+ throw qpackDecoderStreamError('Section Acknowledgment for stream ' + ins.value +
779
+ ', which has no field section awaiting one');
780
+ }
781
+ var ric = pend.shift();
782
+ if (pend.length === 0) qpackUnacked.delete(ins.value);
783
+ // §4.4.1: the acknowledged section's entries are now known received.
784
+ if (ric > qpackKnownReceived) qpackKnownReceived = ric;
785
+ } else if (ins.type === 'stream_cancel') {
786
+ qpackUnacked.delete(ins.value);
787
+ } else {
788
+ // §4.4.3: zero, or past what was inserted, is an error.
789
+ if (ins.value === 0) throw qpackDecoderStreamError('Insert Count Increment of 0');
790
+ if (qpackKnownReceived + ins.value > encoderTable.insertCount) {
791
+ throw qpackDecoderStreamError('Insert Count Increment to ' + (qpackKnownReceived + ins.value) +
792
+ ', beyond the ' + encoderTable.insertCount + ' entries inserted');
793
+ }
794
+ qpackKnownReceived += ins.value;
795
+ }
762
796
  }
797
+ if (DEBUG && r.instructions.length) console.log('[h3] qpack decoder instructions ' + r.instructions.length +
798
+ ' (known=' + qpackKnownReceived + ')');
763
799
  consumeBytes(s, r.consumed);
764
800
  }
765
801
 
802
+ function qpackDecoderStreamError(msg) {
803
+ var err = new Error('QPACK decoder stream: ' + msg);
804
+ err.h3ErrorCode = H3_ERR.QPACK_DECODER_STREAM_ERROR;
805
+ return err;
806
+ }
807
+
766
808
  function evictQpackTable(limit) {
767
809
  // Oldest first, until what remains fits. Sizes in BYTES: measuring by
768
810
  // string length, as this did, undercounts any non-ASCII header value and
@@ -847,7 +889,15 @@ function H3Connection(options) {
847
889
  if (context.control_streams_sent) return;
848
890
  context.control_streams_sent = true;
849
891
 
850
- var settings = buildSettingsPayload(localSettings(context));
892
+ // A reserved identifier among them, of a random N (RFC 9114 §7.2.4.1,
893
+ // 0x1f·N + 0x21): peers must ignore it — sending one keeps them honest.
894
+ // Not part of localSettings: 0-RTT's context (settingsContext) holds the
895
+ // ones that mean something, and this one changes every connection.
896
+ var greaseId = 0x1f * Math.floor(Math.random() * 0x10000) + 0x21;
897
+ var settings = concatUint8Arrays([
898
+ buildSettingsPayload(localSettings(context)),
899
+ writeVarInt(greaseId), writeVarInt(Math.floor(Math.random() * 64))
900
+ ]);
851
901
 
852
902
  var controlFrame = buildH3Frames([{ frame_type: 0x04, payload: settings }]);
853
903
 
@@ -878,6 +928,11 @@ function H3Connection(options) {
878
928
  }
879
929
 
880
930
 
931
+ // Our control and QPACK streams (sendControlStreams).
932
+ function isOwnCriticalStream(sid) {
933
+ return sid === (context.isServer ? 3 : 2) || sid === encoderStreamId || sid === decoderStreamId;
934
+ }
935
+
881
936
  // ---- Graceful shutdown: GOAWAY (RFC 9114 §5.2) ----
882
937
 
883
938
  function sendGoaway(id) {
@@ -1155,6 +1210,15 @@ function H3Connection(options) {
1155
1210
  // a WT stream, so it can be routed to its session.
1156
1211
  listenQuic('stream_reset', function (streamId, code) {
1157
1212
  var sid = Number(streamId);
1213
+ // The peer's control or QPACK stream, reset: closed, which RFC 9114
1214
+ // §6.2.1 and RFC 9204 §4.2 make a connection error however it happens.
1215
+ // Only a FIN was caught (processStream); a RESET_STREAM left the
1216
+ // connection serving on without them.
1217
+ var crit = context.streams[sid];
1218
+ if (crit && (crit.type === 'control' || crit.type === 'qpack_enc' || crit.type === 'qpack_dec')) {
1219
+ connectionError(H3_ERR.H3_CLOSED_CRITICAL_STREAM, 'the peer reset its ' + crit.type + ' stream');
1220
+ return;
1221
+ }
1158
1222
  var wt = _wtStreams[sid];
1159
1223
  var wasParsing = sid in context.streams;
1160
1224
  // A client's request stream reset before its HEADERS came: there is no
@@ -1179,6 +1243,13 @@ function H3Connection(options) {
1179
1243
  // whole cancellation.
1180
1244
  listenQuic('stop_sending', function (streamId, code) {
1181
1245
  var sid = Number(streamId);
1246
+ // Asked to stop one of OUR critical streams: QUIC has answered with
1247
+ // RESET_STREAM, so it is closed — the same connection error (§6.2.1:
1248
+ // "A receiver MUST NOT request that the sender close the control stream").
1249
+ if (context.control_streams_sent && isOwnCriticalStream(sid)) {
1250
+ connectionError(H3_ERR.H3_CLOSED_CRITICAL_STREAM, 'the peer stopped our ' + (sid === encoderStreamId ? 'QPACK encoder' : sid === decoderStreamId ? 'QPACK decoder' : 'control') + ' stream');
1251
+ return;
1252
+ }
1182
1253
  if (_abortedHere.has(sid)) return;
1183
1254
  ev.emit('stop_sending', sid, code);
1184
1255
  });
@@ -2124,6 +2195,11 @@ function H3Connection(options) {
2124
2195
  // Insertions FIRST, on their own stream: they are for the next request,
2125
2196
  // not this one.
2126
2197
  flushQpackInserts(enc.inserts);
2198
+ if (enc.requiredInsertCount > 0) {
2199
+ var pend = qpackUnacked.get(streamId);
2200
+ if (pend) pend.push(enc.requiredInsertCount);
2201
+ else qpackUnacked.set(streamId, [enc.requiredInsertCount]);
2202
+ }
2127
2203
 
2128
2204
  var frame = buildH3Frames([{ frame_type: 1, payload: new Uint8Array(enc.block) }]);
2129
2205
  if (DEBUG) console.log('[h3] sendHeaders stream=' + streamId + ' len=' + frame.byteLength + ' fin=' + !!fin);
package/src/h3_client.js CHANGED
@@ -248,6 +248,7 @@ function request(options, callback) {
248
248
  udpSocket = createQuicClientSocket({
249
249
  remoteIp: remoteIp,
250
250
  remotePort: remotePort,
251
+ socketBufferSize: options.socketBufferSize,
251
252
  // SNI and the name the certificate is checked for: tls.connect's
252
253
  // servername where given.
253
254
  hostname: options.servername || hostname,
package/src/h3_server.js CHANGED
@@ -153,6 +153,7 @@ function createServer(options, handler) {
153
153
  maxUdpPayloadSize: options.maxUdpPayloadSize,
154
154
  initialRtt: options.initialRtt,
155
155
  reorderWindow: options.reorderWindow,
156
+ socketBufferSize: options.socketBufferSize,
156
157
  connectionOptions: options.connectionOptions,
157
158
  quicVersions: options.quicVersions,
158
159
  socket: options.socket || null,
@@ -1704,6 +1705,8 @@ function createServer(options, handler) {
1704
1705
 
1705
1706
  return {
1706
1707
  listen: function (p, h, cb) { closingServer = null; return qserver.listen(p, h, cb); },
1708
+ // The UDP socket's address — after listen(0), the port the OS gave.
1709
+ address: function () { return qserver.address(); },
1707
1710
  close: close,
1708
1711
  closeIdleConnections: closeIdleConnections,
1709
1712
  closeAllConnections: closeAllConnections,
package/src/qpack.js CHANGED
@@ -270,6 +270,9 @@ var DECODER_INSERT_COUNT = 0x00; // 00xxxxxx
270
270
  function parseDecoderInstructions(buf, offset) {
271
271
  var increments = 0;
272
272
  var i = offset || 0;
273
+ // Each instruction in order, for the encoder to check (RFC 9204 §4.4):
274
+ // { type: 'section_ack' | 'stream_cancel' | 'increment', value }.
275
+ var instructions = [];
273
276
 
274
277
  // readPrefixedInt(buf, prefixBits, pos) — prefix width SECOND, position
275
278
  // THIRD, and it returns { value, next } rather than a byte length. Getting
@@ -284,19 +287,22 @@ function parseDecoderInstructions(buf, offset) {
284
287
  r = readPrefixedInt(buf, 7, i);
285
288
  if (!r) break;
286
289
  i = r.next;
290
+ instructions.push({ type: 'section_ack', value: r.value });
287
291
  } else if (b & DECODER_STREAM_CANCEL) {
288
292
  r = readPrefixedInt(buf, 6, i);
289
293
  if (!r) break;
290
294
  i = r.next;
295
+ instructions.push({ type: 'stream_cancel', value: r.value });
291
296
  } else {
292
297
  r = readPrefixedInt(buf, 6, i);
293
298
  if (!r) break;
294
299
  increments += r.value;
295
300
  i = r.next;
301
+ instructions.push({ type: 'increment', value: r.value });
296
302
  }
297
303
  }
298
304
 
299
- return { increments: increments, consumed: i - (offset || 0) };
305
+ return { increments: increments, consumed: i - (offset || 0), instructions: instructions };
300
306
  }
301
307
 
302
308
 
@@ -439,7 +445,10 @@ function encodeHeaderBlock(headerList, table, options) {
439
445
 
440
446
  return {
441
447
  block: Buffer.from(prefix.concat(out)),
442
- inserts: inserts
448
+ inserts: inserts,
449
+ // The section's Required Insert Count: non-zero, the peer acknowledges
450
+ // the section (Section Acknowledgment), which the encoder holds it to.
451
+ requiredInsertCount: maxRef
443
452
  };
444
453
  }
445
454