quico 0.4.0 → 0.8.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
  /**
@@ -33,6 +35,16 @@ import { QUICConnection } from './quic_connection.js';
33
35
  * ['doq'] for DNS-over-QUIC).
34
36
  * rejectUnauthorized — verify the server certificate (default true)
35
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)
36
48
  * onSocket(quic, socket) — called as soon as the QUICConnection exists,
37
49
  * BEFORE the handshake starts. Use this to attach
38
50
  * listeners that must observe the handshake, such
@@ -46,6 +58,14 @@ import { QUICConnection } from './quic_connection.js';
46
58
  * socket binds, so it is delivered via onConnect rather than returned here.
47
59
  */
48
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);
49
69
  var isIPv6 = opts.remoteIp.indexOf(':') >= 0;
50
70
  var udpSocket = dgram.createSocket(isIPv6 ? 'udp6' : 'udp4');
51
71
  var quic = null;
@@ -74,7 +94,13 @@ function createQuicClientSocket(opts) {
74
94
  try { udpSocket.setSendBufferSize(2 * 1024 * 1024); } catch (e) {}
75
95
  try { udpSocket.setRecvBufferSize(2 * 1024 * 1024); } catch (e) {}
76
96
 
77
- quic = new QUICConnection({
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({
78
104
  isServer: false,
79
105
  hostname: opts.hostname,
80
106
  // Without this the client always offered 'h3' regardless of what the
@@ -88,7 +114,45 @@ function createQuicClientSocket(opts) {
88
114
  // Defaults to true. Pass false for self-signed certificates, or supply
89
115
  // `ca` for a private CA.
90
116
  rejectUnauthorized: opts.rejectUnauthorized !== false,
91
- ca: opts.ca || null
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
92
156
  });
93
157
 
94
158
  // Hand the connection to the caller BEFORE connect() runs.
@@ -100,25 +164,86 @@ function createQuicClientSocket(opts) {
100
164
  // in onConnect receives nothing at all, and the client half of a capture
101
165
  // is undecryptable. onSocket gives callers a hook at the only moment that
102
166
  // works.
103
- if (opts.onSocket) opts.onSocket(quic, udpSocket);
167
+ if (opts.onSocket) opts.onSocket(q, udpSocket);
104
168
 
105
- quic.on('packet', function (data) {
169
+ q.on('packet', function (data) {
106
170
  udpSocket.send(data, opts.remotePort, opts.remoteIp, function (err) {
107
171
  if (err && opts.onError) opts.onError(err);
108
172
  });
109
173
  });
110
174
 
111
- quic.on('connect', function () {
112
- 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);
113
193
  });
114
194
 
115
- quic.on('close', function () {
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);
215
+ });
216
+
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
116
240
  try { udpSocket.close(); } catch (e) {}
241
+ if (!connected) fail(closedError());
117
242
  if (opts.onClose) opts.onClose();
118
243
  });
119
244
 
120
- quic.connect();
121
- });
245
+ q.connect();
246
+ }
122
247
 
123
248
  return udpSocket;
124
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
+ };