quico 0.3.0 → 0.7.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.
@@ -14,6 +14,8 @@
14
14
 
15
15
  import dgram from 'node:dgram';
16
16
  import { QUICConnection } from './quic_connection.js';
17
+ import lemonTls from 'lemon-tls';
18
+ import { SUPPORTED_VERSIONS, parseVersionList, versionName } from './quic_version.js';
17
19
 
18
20
 
19
21
  /**
@@ -27,6 +29,26 @@ import { QUICConnection } from './quic_connection.js';
27
29
  * opts:
28
30
  * remoteIp, remotePort — where to send packets
29
31
  * hostname — SNI / :authority for the QUIC/TLS handshake
32
+ * alpn — ALPN protocol(s) to offer. String or array.
33
+ * Defaults to ['h3']. Non-HTTP/3 consumers pass
34
+ * their own (e.g. ['hq-interop'] for HTTP/0.9,
35
+ * ['doq'] for DNS-over-QUIC).
36
+ * rejectUnauthorized — verify the server certificate (default true)
37
+ * ca — CA certificate(s) for verification
38
+ * idleTimeout, keepAlive — as QUICConnection's (the idle timeout in force
39
+ * is the smaller of the two sides', RFC 9000 §10.1)
40
+ * key, cert, passphrase — a client certificate, sent if the server asks
41
+ * for one (requestCert), as with tls.connect
42
+ * quicVersions — QUIC versions, the first one's first flight
43
+ * and the rest offered (default ['v1', 'v2'])
44
+ * token — a token from an earlier connection's
45
+ * 'new_token' event (NEW_TOKEN), used once
46
+ * session — a session saved from an earlier connection's
47
+ * 'session' event, to resume (tls.connect's name)
48
+ * onSocket(quic, socket) — called as soon as the QUICConnection exists,
49
+ * BEFORE the handshake starts. Use this to attach
50
+ * listeners that must observe the handshake, such
51
+ * as 'keylog'. onConnect is too late for those.
30
52
  * onConnect(quic, socket) — called once the QUIC handshake completes
31
53
  * onClose() — called when the connection closes (the socket is
32
54
  * already closed by the time this runs)
@@ -36,6 +58,14 @@ import { QUICConnection } from './quic_connection.js';
36
58
  * socket binds, so it is delivered via onConnect rather than returned here.
37
59
  */
38
60
  function createQuicClientSocket(opts) {
61
+ // ecdhCurve (tls.connect's): the key-exchange groups to offer, in order —
62
+ // 'X25519MLKEM768:X25519' for the post-quantum hybrid first. Unset or
63
+ // 'auto': X25519, P-256, P-384. A name not implemented (or the hybrid in a
64
+ // Node without ML-KEM) throws here, as tls.connect's does.
65
+ var groups = lemonTls.parseEcdhCurve(opts.ecdhCurve);
66
+ // sigalgs (tls.connect's): the signature schemes to offer, in order; unset,
67
+ // lemon-tls's list. A name it does not know throws here.
68
+ var sigalgs = lemonTls.parseSigalgs(opts.sigalgs);
39
69
  var isIPv6 = opts.remoteIp.indexOf(':') >= 0;
40
70
  var udpSocket = dgram.createSocket(isIPv6 ? 'udp6' : 'udp4');
41
71
  var quic = null;
@@ -49,25 +79,171 @@ function createQuicClientSocket(opts) {
49
79
  });
50
80
 
51
81
  udpSocket.bind(0, function () {
52
- quic = new QUICConnection({ isServer: false, hostname: opts.hostname });
82
+ // Grow the kernel socket buffers before any traffic flows.
83
+ //
84
+ // Node's defaults are small (and notably small on Windows). When the send
85
+ // buffer overflows, the datagram is dropped locally with no error — it
86
+ // looks exactly like network loss, so the loss-recovery machinery kicks in
87
+ // and the connection crawls. This was the failure that killed the third
88
+ // pacer-calibration attempt (see LOSS_PTO_PLAN 7.4): larger bursts spilled
89
+ // over the buffer and turned a lossless link into a slow one.
90
+ //
91
+ // Wrapped in try/catch because the call can throw on platforms that cap
92
+ // the size; failing to enlarge the buffer is not a reason to fail the
93
+ // connection.
94
+ try { udpSocket.setSendBufferSize(2 * 1024 * 1024); } catch (e) {}
95
+ try { udpSocket.setRecvBufferSize(2 * 1024 * 1024); } catch (e) {}
53
96
 
54
- quic.on('packet', function (data) {
97
+ // One attempt; after a Version Negotiation, a second in a version the
98
+ // server offered (RFC 9000 §6.2) — once.
99
+ start(null, null);
100
+ });
101
+
102
+ function start(version, vnOffered) {
103
+ var q = quic = new QUICConnection({
104
+ isServer: false,
105
+ hostname: opts.hostname,
106
+ // Without this the client always offered 'h3' regardless of what the
107
+ // caller wanted, because TLSBridge falls back to ['h3'] when alpn is
108
+ // undefined. Normalize a bare string into an array here so callers can
109
+ // pass either form.
110
+ alpn: (function () {
111
+ var a = opts.alpn || ['h3'];
112
+ return Array.isArray(a) ? a : [a];
113
+ })(),
114
+ // Defaults to true. Pass false for self-signed certificates, or supply
115
+ // `ca` for a private CA.
116
+ rejectUnauthorized: opts.rejectUnauthorized !== false,
117
+ // A client certificate, for a server that asks (tls.connect's names).
118
+ key: opts.key || null, cert: opts.cert || null, passphrase: opts.passphrase,
119
+ ca: opts.ca || null,
120
+ session: opts.session || null,
121
+ requestOCSP: opts.requestOCSP === true,
122
+ // Encrypted Client Hello: the server's ECHConfigList, or GREASE ECH.
123
+ ech: opts.ech || null,
124
+ echGrease: opts.echGrease === true,
125
+ // The groups to offer (ecdhCurve, above); null: the default.
126
+ groups: groups,
127
+ sigalgs: sigalgs,
128
+ // A NEW_TOKEN token from an earlier connection to the server (RFC 9000
129
+ // §8.1.3), for the Initial packets.
130
+ token: opts.token || null,
131
+ // quic.remoteAddress / remotePort, as on a net.Socket.
132
+ remoteAddress: opts.remoteIp,
133
+ remotePort: opts.remotePort,
134
+ // Our transport parameters, where the caller set them (tls_bridge).
135
+ transportParams: opts.transportParams || null,
136
+ // The connection's own options: idleTimeout (ms; or
137
+ // transportParams.maxIdleTimeout) and keepAlive (true: PING at half the
138
+ // idle timeout in force; a number: every that many ms).
139
+ idleTimeout: opts.idleTimeout,
140
+ keepAlive: opts.keepAlive,
141
+ // The congestion window it starts with, where the caller set it.
142
+ initialCongestionWindow: opts.initialCongestionWindow,
143
+ // The connection's options (quic_connection's CONNECTION_OPTIONS).
144
+ startupCap: opts.startupCap,
145
+ pmtuDiscovery: opts.pmtuDiscovery,
146
+ maxUdpPayloadSize: opts.maxUdpPayloadSize,
147
+ initialRtt: opts.initialRtt,
148
+ reorderWindow: opts.reorderWindow,
149
+ // 0-RTT with `session` when its ticket allows it (RFC 9001 §4.6).
150
+ earlyData: opts.earlyData === true,
151
+ // The QUIC versions: the first flight's, then the others accepted
152
+ // (offered through version_information, RFC 9368). ['v1', 'v2'].
153
+ quicVersions: opts.quicVersions,
154
+ version: version || undefined,
155
+ versionNegotiation: vnOffered || undefined
156
+ });
157
+
158
+ // Hand the connection to the caller BEFORE connect() runs.
159
+ //
160
+ // onConnect fires after the handshake, which is too late for anything that
161
+ // has to observe the handshake itself. Key logging is the concrete case:
162
+ // lemon-tls skips building NSS lines while nothing is listening, and the
163
+ // handshake secrets are emitted during connect() — so a listener attached
164
+ // in onConnect receives nothing at all, and the client half of a capture
165
+ // is undecryptable. onSocket gives callers a hook at the only moment that
166
+ // works.
167
+ if (opts.onSocket) opts.onSocket(q, udpSocket);
168
+
169
+ q.on('packet', function (data) {
55
170
  udpSocket.send(data, opts.remotePort, opts.remoteIp, function (err) {
56
171
  if (err && opts.onError) opts.onError(err);
57
172
  });
58
173
  });
59
174
 
60
- quic.on('connect', function () {
61
- if (opts.onConnect) opts.onConnect(quic, udpSocket);
175
+ // A connection that never connects is a failure the caller must hear
176
+ // about, once: the TLS reason when there is one (a refused certificate,
177
+ // with node:tls's code), the handshake timeout, or — when the peer simply
178
+ // closed — its CONNECTION_CLOSE. Before this a refused certificate closed
179
+ // the connection in silence, and an HTTP/3 request or a WebTransport
180
+ // `ready` waited on it forever.
181
+ var connected = false, failed = false;
182
+ function fail(err) {
183
+ if (connected || failed) return;
184
+ failed = true;
185
+ if (opts.onError) opts.onError(err);
186
+ }
187
+
188
+ q.on('error', function (err) { if (q === quic) fail(err); });
189
+
190
+ q.on('connect', function () {
191
+ connected = true;
192
+ if (opts.onConnect) opts.onConnect(q, udpSocket);
193
+ });
194
+
195
+ // 0-RTT: the connection may carry application data now, before the
196
+ // handshake — onEarly(quic, socket). Refused by the server, what went is
197
+ // forgotten and 'early_rejected' says so (send it again after onConnect).
198
+ q.on('early', function () { if (opts.onEarly) opts.onEarly(q, udpSocket); });
199
+
200
+ // The server speaks none of the versions offered in this attempt: a new
201
+ // one, in the first of ours it offers. A Version Negotiation that
202
+ // answers the second attempt is not followed (§6.2: once).
203
+ q.on('version_negotiation', function (offered) {
204
+ if (vnOffered) return;
205
+ var prefs = opts.quicVersions ? parseVersionList(opts.quicVersions, 'quicVersions') : SUPPORTED_VERSIONS;
206
+ var pick = null;
207
+ for (var i = 0; i < prefs.length && pick === null; i++) if (offered.indexOf(prefs[i]) >= 0) pick = prefs[i];
208
+ if (pick === null) {
209
+ var err = new Error('the server supports none of our QUIC versions (it offers ' + offered.map(versionName).join(', ') + ')');
210
+ err.code = 'ERR_QUIC_VERSION_NEGOTIATION';
211
+ fail(err);
212
+ return;
213
+ }
214
+ start(pick, offered);
62
215
  });
63
216
 
64
- quic.on('close', function () {
217
+ function closedError() {
218
+ var pc = quic.context && quic.context.peer_close;
219
+ var lc = !pc && quic.context && quic.context.close_frame;
220
+ var err = new Error('QUIC connection closed before the handshake completed' +
221
+ (pc ? ' (peer error 0x' + pc.error.toString(16) + (pc.reason ? ': ' + pc.reason : '') + ')' :
222
+ lc ? ' (error 0x' + lc.error.toString(16) + (lc.reason ? ': ' + lc.reason : '') + ')' : ''));
223
+ err.code = 'ERR_QUIC_HANDSHAKE_FAILED';
224
+ return err;
225
+ }
226
+
227
+ // A handshake that will not complete is reported when that is known —
228
+ // the peer's CONNECTION_CLOSE, or ours — not when the connection's drain
229
+ // (RFC 9000 §10.2, up to 3 s) ends and 'close' comes: a server that
230
+ // refused the ClientHello was heard 3 s late, and an HTTP/3 request
231
+ // waited that long to fail. On the next turn: a TLS alert of ours closes
232
+ // the connection first and gives its reason ('error') right after.
233
+ q.on('handshake_ended', function (confirmed) {
234
+ if (confirmed || q !== quic) return;
235
+ setImmediate(function () { if (q === quic && !connected) fail(closedError()); });
236
+ });
237
+
238
+ q.on('close', function () {
239
+ if (q !== quic) return; // an attempt given up for another
65
240
  try { udpSocket.close(); } catch (e) {}
241
+ if (!connected) fail(closedError());
66
242
  if (opts.onClose) opts.onClose();
67
243
  });
68
244
 
69
- quic.connect();
70
- });
245
+ q.connect();
246
+ }
71
247
 
72
248
  return udpSocket;
73
249
  }
@@ -0,0 +1,143 @@
1
+ /*
2
+ * quico: HTTP/3 and QUIC implementation for Node.js
3
+ * Copyright 2025 colocohen
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at:
8
+ *
9
+ * https://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * This file is part of the open-source project hosted at:
12
+ * https://github.com/colocohen/quico
13
+ */
14
+
15
+
16
+ // ============================================================
17
+ // QUIC versions — what differs between them, in one place
18
+ //
19
+ // QUIC v1 (RFC 9000/9001) and v2 (RFC 9369) are one protocol: the same
20
+ // frames, the same handshake, the same recovery. v2 changes what an
21
+ // on-path device could have learned to depend on — the version number, the
22
+ // salt the Initial keys come from, the labels every packet key is derived
23
+ // with, the Retry integrity key, and which long-header type bits mean
24
+ // Initial, 0-RTT, Handshake and Retry — so that QUIC stays free to change
25
+ // (anti-ossification). Everything a version decides lives in its profile
26
+ // here, and every place that builds or reads a packet, or derives a key,
27
+ // asks the connection's profile: the rest of the code is the same for all
28
+ // versions, as lemon-tls's is for TLS 1.2 and 1.3.
29
+ //
30
+ // A new version is a profile here, and its entry in COMPATIBLE when a first
31
+ // flight in one can be read as the other (RFC 9368 §2.3).
32
+ // ============================================================
33
+
34
+ var QUIC_V1 = 0x00000001;
35
+ var QUIC_V2 = 0x6b3343cf;
36
+
37
+ function hex(s) { return new Uint8Array(Buffer.from(s, 'hex')); }
38
+
39
+ var PROFILES = {};
40
+
41
+ PROFILES[QUIC_V1] = {
42
+ version: QUIC_V1,
43
+ name: 'v1',
44
+ // RFC 9001 §5.2
45
+ initialSalt: hex('38762cf7f55934b34d179ae6a4c80cadccbb7f0a'),
46
+ // RFC 9001 §5.1, §6.1
47
+ labels: { key: 'quic key', iv: 'quic iv', hp: 'quic hp', ku: 'quic ku' },
48
+ // RFC 9001 §5.8
49
+ retryKey: hex('be0c690b9f66575a1d766b54e368c84e'),
50
+ retryNonce: hex('461599d35d632bf2239825bb'),
51
+ // RFC 9000 §17.2: long header type bits → packet type
52
+ longTypes: ['initial', '0rtt', 'handshake', 'retry']
53
+ };
54
+
55
+ PROFILES[QUIC_V2] = {
56
+ version: QUIC_V2,
57
+ name: 'v2',
58
+ // RFC 9369 §3.3.1
59
+ initialSalt: hex('0dede3def700a6db819381be6e269dcbf9bd2ed9'),
60
+ // RFC 9369 §3.3.2
61
+ labels: { key: 'quicv2 key', iv: 'quicv2 iv', hp: 'quicv2 hp', ku: 'quicv2 ku' },
62
+ // RFC 9369 §3.3.3
63
+ retryKey: hex('8fb4b01b56ac48e260fbcbcead7ccc92'),
64
+ retryNonce: hex('d86969bc2d7c6d9990efb04a'),
65
+ // RFC 9369 §3.2: Retry 0b00, Initial 0b01, 0-RTT 0b10, Handshake 0b11
66
+ longTypes: ['retry', 'initial', '0rtt', 'handshake']
67
+ };
68
+
69
+ // Which first flights can be read as another version's (RFC 9368 §2.3):
70
+ // v1 and v2 differ in nothing a ClientHello carries, both ways (RFC 9369 §4).
71
+ var COMPATIBLE = {};
72
+ COMPATIBLE[QUIC_V1] = [QUIC_V2];
73
+ COMPATIBLE[QUIC_V2] = [QUIC_V1];
74
+
75
+ /** The versions this implementation speaks, in its default preference. */
76
+ var SUPPORTED_VERSIONS = [QUIC_V1, QUIC_V2];
77
+
78
+ function versionProfile(version) {
79
+ return PROFILES[version >>> 0] || null;
80
+ }
81
+
82
+ function isSupportedVersion(version) {
83
+ return !!PROFILES[version >>> 0];
84
+ }
85
+
86
+ /** Whether a first flight in `from` can be taken as one in `to`. */
87
+ function isCompatible(from, to) {
88
+ from >>>= 0; to >>>= 0;
89
+ return from === to || (COMPATIBLE[from] || []).indexOf(to) >= 0;
90
+ }
91
+
92
+ /** Long header type bits → 'initial' | '0rtt' | 'handshake' | 'retry', or null. */
93
+ function longPacketType(version, typeBits) {
94
+ var p = PROFILES[version >>> 0];
95
+ return p ? p.longTypes[typeBits & 3] : null;
96
+ }
97
+
98
+ /** Packet type → its long header type bits in `version`. */
99
+ function longTypeBits(version, packetType) {
100
+ var p = PROFILES[version >>> 0];
101
+ var i = p ? p.longTypes.indexOf(packetType) : -1;
102
+ if (i < 0) throw new Error('no long header type ' + packetType + ' in QUIC version 0x' + (version >>> 0).toString(16));
103
+ return i;
104
+ }
105
+
106
+ /**
107
+ * A version as an application names it — 1, 0x6b3343cf, 'v1', 'v2' — as a
108
+ * version number; throws on one this implementation does not speak.
109
+ */
110
+ function parseVersion(v, what) {
111
+ var n = null;
112
+ if (typeof v === 'number' && Number.isInteger(v)) n = v >>> 0;
113
+ else if (typeof v === 'string') {
114
+ var s = v.toLowerCase();
115
+ for (var k in PROFILES) if (PROFILES[k].name === s) n = PROFILES[k].version;
116
+ }
117
+ if (n === null || !PROFILES[n]) throw new TypeError((what || 'quicVersions') + ': unsupported QUIC version ' + JSON.stringify(v) + ' (v1 or v2)');
118
+ return n;
119
+ }
120
+
121
+ /** An option listing versions in preference order → [number, …]. */
122
+ function parseVersionList(list, what) {
123
+ if (list === undefined || list === null) return null;
124
+ if (!Array.isArray(list) || list.length === 0) throw new TypeError((what || 'quicVersions') + ' must be a non-empty array of QUIC versions');
125
+ var out = [];
126
+ for (var i = 0; i < list.length; i++) {
127
+ var n = parseVersion(list[i], what);
128
+ if (out.indexOf(n) < 0) out.push(n);
129
+ }
130
+ return out;
131
+ }
132
+
133
+ function versionName(version) {
134
+ var p = PROFILES[version >>> 0];
135
+ return p ? p.name : '0x' + (version >>> 0).toString(16);
136
+ }
137
+
138
+ export {
139
+ QUIC_V1, QUIC_V2, SUPPORTED_VERSIONS,
140
+ versionProfile, isSupportedVersion, isCompatible,
141
+ longPacketType, longTypeBits,
142
+ parseVersion, parseVersionList, versionName
143
+ };