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.
package/src/tls_bridge.js CHANGED
@@ -12,16 +12,92 @@
12
12
  * https://github.com/colocohen/quico
13
13
  */
14
14
 
15
- import { TLSSession, createSecureContext as _lemonCreateSecureContext } from 'lemon-tls';
16
- import nodeTls from 'node:tls';
15
+ import {
16
+ TLSSession,
17
+ createSecureContext as _lemonCreateSecureContext,
18
+ certificateObject
19
+ } from 'lemon-tls';
20
+ import { X509Certificate } from 'node:crypto';
17
21
  import { DEBUG, Emitter } from './utils.js';
22
+ import { rootCertificates as lemonRoots, parseEcdhCurve as lemonParseEcdhCurve, DEFAULT_SERVER_GROUPS as lemonServerGroups,
23
+ DEFAULT_SIGNATURE_SCHEMES as lemonSigSchemes } from 'lemon-tls';
24
+
25
+ // The platform trust store for a QUIC client given no `ca` — lemon-tls's own
26
+ // export, the list its TCP path uses (compat.js). Not node:tls: quico imports
27
+ // none of Node's network stack (test/integration.mjs checks).
28
+ function defaultRoots() {
29
+ return lemonRoots || [];
30
+ }
18
31
  import { build_transport_params, parse_transport_params } from './transport.js';
19
32
  import {
20
33
  TLS_CIPHER_SUITES
21
34
  } from './crypto.js';
22
35
 
23
36
 
24
- function defaultTransportParams() {
37
+ // The transport parameters we send (RFC 9000 §18.2) — these, with the
38
+ // application's choices in place of the defaults for the ones it may set
39
+ // (options.transportParams, camelCase, RFC 9000's names):
40
+ // initialMaxData the connection's receive window (1 MB)
41
+ // initialMaxStreamDataBidiLocal a bidirectional stream we open (256 KB)
42
+ // initialMaxStreamDataBidiRemote one the peer opens (256 KB)
43
+ // initialMaxStreamDataUni a unidirectional stream the peer opens (128 KB)
44
+ // initialMaxStreamsBidi streams the peer may have open at once (100)
45
+ // initialMaxStreamsUni (100)
46
+ // maxIdleTimeout ms without a packet from the peer before the
47
+ // connection is closed (30000); 0: no limit.
48
+ // The effective one is the smaller of the two
49
+ // sides' (RFC 9000 §10.1)
50
+ // The windows slide as the application reads, keeping these sizes; the
51
+ // stream limits are raised as streams close, keeping these counts.
52
+ var SETTABLE_TRANSPORT_PARAMS = {
53
+ initialMaxData: 'initial_max_data',
54
+ initialMaxStreamDataBidiLocal: 'initial_max_stream_data_bidi_local',
55
+ initialMaxStreamDataBidiRemote: 'initial_max_stream_data_bidi_remote',
56
+ initialMaxStreamDataUni: 'initial_max_stream_data_uni',
57
+ initialMaxStreamsBidi: 'initial_max_streams_bidi',
58
+ initialMaxStreamsUni: 'initial_max_streams_uni',
59
+ maxIdleTimeout: 'max_idle_timeout',
60
+ disableActiveMigration: 'disable_active_migration'
61
+ };
62
+
63
+ // A client's key-exchange groups by default: what node:tls offers (OpenSSL
64
+ // 3.5) and the browsers do — the hybrid post-quantum X25519MLKEM768 first,
65
+ // then X25519, P-256, P-384 — where node:crypto has ML-KEM (Node 24.7+);
66
+ // without it, the classical groups alone. lemon-tls's own client default is
67
+ // the same (session/ecdh.js); named here because the QUIC hello's context is
68
+ // set by this bridge. The hybrid share goes with an X25519 share beside it,
69
+ // so a server without it needs no retry.
70
+ var CLIENT_DEFAULT_GROUPS = (function () {
71
+ try { return lemonParseEcdhCurve('X25519MLKEM768:X25519:P-256:P-384'); }
72
+ catch (e) { return [29, 23, 24]; }
73
+ })();
74
+
75
+ function defaultTransportParams(overrides) {
76
+ var tp = baseTransportParams();
77
+ for (var name in (overrides || {})) {
78
+ if (!Object.prototype.hasOwnProperty.call(overrides, name) || overrides[name] == null) continue;
79
+ var key = SETTABLE_TRANSPORT_PARAMS[name];
80
+ if (!key) throw new TypeError('transportParams.' + name + ' is not a transport parameter that can be set');
81
+ var v = overrides[name];
82
+ if (key === 'disable_active_migration') {
83
+ if (typeof v !== 'boolean') throw new TypeError('transportParams.' + name + ' must be a boolean');
84
+ if (v) tp[key] = true; else delete tp[key];
85
+ continue;
86
+ }
87
+ // Varints (§16); stream counts at most 2^60 (§4.6). A window of 0 would
88
+ // let the peer send nothing; a count of 0 for unidirectional streams
89
+ // would leave HTTP/3 no room for its own.
90
+ var max = key.indexOf('streams') >= 0 ? Math.pow(2, 60) : Number.MAX_SAFE_INTEGER;
91
+ var min = key === 'initial_max_streams_uni' ? 3 : ((key === 'initial_max_streams_bidi' || key === 'max_idle_timeout') ? 0 : 1);
92
+ if (typeof v !== 'number' || Math.floor(v) !== v || v < min || v > max) {
93
+ throw new RangeError('transportParams.' + name + ' must be an integer from ' + min + ' to ' + max);
94
+ }
95
+ tp[key] = v;
96
+ }
97
+ return tp;
98
+ }
99
+
100
+ function baseTransportParams() {
25
101
  return {
26
102
  max_udp_payload_size: 65527,
27
103
  max_idle_timeout: 30000,
@@ -30,16 +106,51 @@ function defaultTransportParams() {
30
106
  initial_max_stream_data_bidi_remote: 262144,
31
107
  initial_max_stream_data_uni: 131072,
32
108
  initial_max_streams_bidi: 100,
33
- initial_max_streams_uni: 3,
109
+ // HTTP/3 takes three of these for itself (control, QPACK encoder and
110
+ // decoder) and keeps them for the whole connection, and credit comes
111
+ // back only as the peer's streams close: with 3, a peer could open no
112
+ // stream of its own — no WebTransport stream, no MOQT. Browsers grant 100.
113
+ initial_max_streams_uni: 100,
34
114
  ack_delay_exponent: 3,
35
115
  max_ack_delay: 25,
36
- disable_active_migration: true,
116
+ // Active migration allowed (RFC 9000 §9): a client may move to another
117
+ // network — Wi-Fi to cellular — and go on in the same connection. Each
118
+ // new address is validated before more than three times what it sent
119
+ // goes to it (§8), with congestion and RTT state started over (§9.4),
120
+ // and addressed by a connection ID of its own (§9.5). It was disabled.
121
+ // transportParams.disableActiveMigration: true says no again.
37
122
  active_connection_id_limit: 4,
38
- max_datagram_frame_size: 65527
123
+ max_datagram_frame_size: 65527,
124
+ // RESET_STREAM_AT taken (draft-ietf-quic-reliable-stream-reset): a peer
125
+ // may reset a stream and still have its first bytes delivered — a
126
+ // WebTransport stream's header, a MOQT subgroup's.
127
+ reset_stream_at: true
39
128
  };
40
129
  }
41
130
 
42
131
 
132
+ // The server's transport parameters a client keeps for 0-RTT (RFC 9000
133
+ // §7.4.1; RFC 9221 §3 for max_datagram_frame_size): its limits on what may
134
+ // be sent before this handshake's parameters arrive.
135
+ var REMEMBERED_PARAMS = ['active_connection_id_limit', 'initial_max_data', 'initial_max_stream_data_bidi_local',
136
+ 'initial_max_stream_data_bidi_remote', 'initial_max_stream_data_uni', 'initial_max_streams_bidi',
137
+ 'initial_max_streams_uni', 'max_datagram_frame_size', 'reset_stream_at'];
138
+ function rememberedParams(tp) {
139
+ var out = {};
140
+ for (var i = 0; i < REMEMBERED_PARAMS.length; i++) {
141
+ var k = REMEMBERED_PARAMS[i];
142
+ // A flag (reset_stream_at, draft-ietf-quic-reliable-stream-reset §3) as 1 or 0.
143
+ out[k] = tp && typeof tp[k] === 'number' ? tp[k] : (tp && tp[k] === true ? 1 : 0);
144
+ }
145
+ return out;
146
+ }
147
+
148
+ // The fingerprint options with QUIC's transport character — see getJA4.
149
+ function quicOpts(opts) {
150
+ return Object.assign({ protocol: 'q' }, opts || {});
151
+ }
152
+
153
+
43
154
  function TLSBridge(options) {
44
155
  options = options || {};
45
156
 
@@ -48,9 +159,28 @@ function TLSBridge(options) {
48
159
  var handshakeSecretsEmitted = false;
49
160
  var appSecretsEmitted = false;
50
161
  var helloHandled = false;
162
+ // A client's: what it keeps with each session it saves — the server's
163
+ // transport parameters (tp), and the application's own (app: HTTP/3's
164
+ // SETTINGS) once it has them — for 0-RTT next time.
165
+ var sessionAppData = null;
166
+ function keepAppData(k, v) {
167
+ sessionAppData = Object.assign({}, sessionAppData || {});
168
+ sessionAppData[k] = v;
169
+ if (session && session.setSessionAppData) session.setSessionAppData(sessionAppData);
170
+ }
171
+ var serverHelloSent = false; // our parameters went out with it (EncryptedExtensions)
51
172
  var isServer = !!options.isServer;
52
173
  var originalDcid = options.originalDcid || new Uint8Array(0);
53
- var localCid = options.localCid || new Uint8Array(0); // client's own SCID
174
+ // The connection ID this endpoint chose for itself — the Source Connection
175
+ // ID of its long headers, echoed as initial_source_connection_id (RFC 9000
176
+ // §7.3). The server's is its own too, not the one the client picked.
177
+ var localCid = options.localCid || new Uint8Array(0);
178
+ // Server: the stateless reset token for localCid (RFC 9000 §10.3), from
179
+ // quic_server, which alone holds the key it is derived from.
180
+ var statelessResetToken = options.statelessResetToken || null;
181
+ // Server, after a Retry: the SCID of that Retry, echoed as
182
+ // retry_source_connection_id (RFC 9000 §7.3).
183
+ var retrySourceCid = options.retrySourceCid || null;
54
184
  var hostname = options.hostname || null;
55
185
  // Peer certificate verification. Defaults to true, matching node:tls — but
56
186
  // until now the option was never plumbed through at all, so quico could not
@@ -60,6 +190,8 @@ function TLSBridge(options) {
60
190
  // server4 / server6, so every implementation disables verification there).
61
191
  var rejectUnauthorized = options.rejectUnauthorized !== false;
62
192
  var ca = options.ca || null;
193
+ // Server: its TLS options as tls_options.js reads them, or null.
194
+ var serverTls = isServer ? (options.tlsOptions || null) : null;
63
195
  // ALPN protocol(s) to advertise. Default ['h3']; normalize string → array.
64
196
  var supportedAlpns = (function () {
65
197
  var a = options.alpn || ['h3'];
@@ -74,42 +206,98 @@ function TLSBridge(options) {
74
206
  rejectUnauthorized: rejectUnauthorized
75
207
  };
76
208
  if (ca) sessionOpts.ca = ca;
209
+ // No `ca`: a client verifies against the platform roots. Without them
210
+ // lemon-tls has no trust anchors and checks only the name in the
211
+ // certificate — a self-signed certificate for the right name passed,
212
+ // so an HTTP/3 request was open to a man-in-the-middle.
213
+ if (!isServer) sessionOpts.defaultCA = defaultRoots;
214
+ // Client: a certificate to send if the server asks for one
215
+ // (CertificateRequest) — tls.connect's key / cert / passphrase.
216
+ if (!isServer && options.key && options.cert) {
217
+ sessionOpts.key = options.key;
218
+ sessionOpts.cert = options.cert;
219
+ if (options.passphrase != null) sessionOpts.passphrase = options.passphrase;
220
+ }
221
+ // Session tickets (server). The keys come from the server, one set for
222
+ // all its connections: without them lemon-tls draws a fresh key per
223
+ // session, so every ticket was sealed with a key that died with its
224
+ // connection and no returning client could ever resume. ticketData is
225
+ // the application's (see lemon-tls): carried in the ticket, returned by
226
+ // getTicketInfo() on the connection that presents it.
227
+ if (isServer && options.ticketKeys) sessionOpts.ticketKeys = options.ticketKeys;
228
+ if (isServer && options.ticketData) sessionOpts.ticketData = options.ticketData;
229
+ // Encrypted Client Hello (RFC 9849): lemon-tls decrypts the real
230
+ // ClientHello — SNI, ALPN, transport parameters — and the handshake
231
+ // runs on it; QUIC needs nothing of its own for it.
232
+ if (isServer && options.ech) sessionOpts.ech = options.ech;
233
+ // OCSP stapling: lemon-tls asks it when a client requests status, and
234
+ // staples the answer in the Certificate (RFC 8446 §4.4.2.1).
235
+ if (isServer && options.ocspRequest) sessionOpts.ocspRequest = options.ocspRequest;
236
+ // What the tickets are for beyond TLS: the QUIC version (RFC 9369 §5),
237
+ // set again once it is negotiated (setTicketContext).
238
+ if (isServer && options.ticketContext) sessionOpts.ticketContext = options.ticketContext;
239
+ // 0-RTT (RFC 9001 §4.6): tickets that allow it, and accepting it —
240
+ // max_early_data_size 0xffffffff, as QUIC requires (§4.6.1); under the
241
+ // transport parameters the client will remember (earlyDataContext:
242
+ // another set and the 0-RTT is refused, RFC 9000 §7.4.1).
243
+ if (isServer && options.earlyData) {
244
+ sessionOpts.earlyData = true;
245
+ sessionOpts.maxEarlyData = 0xffffffff;
246
+ if (options.earlyDataMaxAge != null) sessionOpts.earlyDataMaxAge = options.earlyDataMaxAge;
247
+ if (options.earlyDataReplay) sessionOpts.earlyDataReplay = options.earlyDataReplay;
248
+ sessionOpts.earlyDataContext = options.earlyDataContext || '';
249
+ }
250
+ // The server's TLS options (tls_options.js, read once by quic_server):
251
+ // the ticket lifetime, certificate compression and client certificates
252
+ // here; the suites, groups and signature schemes at 'hello' below.
253
+ if (isServer && serverTls) {
254
+ if (serverTls.sessionTimeout !== undefined) sessionOpts.ticketLifetime = serverTls.sessionTimeout;
255
+ if (serverTls.certificateCompression) sessionOpts.certificateCompression = serverTls.certificateCompression;
256
+ if (serverTls.honorCipherOrder) sessionOpts.honorCipherOrder = true;
257
+ if (serverTls.requestCert) {
258
+ sessionOpts.requestCert = true;
259
+ sessionOpts.rejectUnauthorized = serverTls.rejectUnauthorized;
260
+ }
261
+ }
77
262
  if (!isServer) {
263
+ // The server's certificate chain may come compressed (RFC 8879):
264
+ // brotli, zstd, zlib — what Chrome and Firefox offer. A server here
265
+ // compresses for any client that offers it (lemon-tls's default).
266
+ sessionOpts.certificateCompression = true;
78
267
  sessionOpts.sessionId = new Uint8Array(0); // QUIC: no compatibility mode
79
268
  if (hostname) sessionOpts.servername = hostname;
269
+ // A saved session to resume (lemon-tls offers it only where it may be
270
+ // used: the same server name, within its lifetime, and — when the
271
+ // server must be verified — one that was).
272
+ if (options.session) sessionOpts.session = options.session;
273
+ // A stapled OCSP response for the server's certificate (tls.connect's
274
+ // requestOCSP); it comes as 'OCSPResponse'.
275
+ if (options.requestOCSP) sessionOpts.requestOCSP = true;
276
+ // Encrypted Client Hello (RFC 9849): the server's ECHConfigList — the
277
+ // real ClientHello, its name and transport parameters, goes
278
+ // encrypted — or GREASE ECH without one. lemon-tls builds both hellos.
279
+ if (options.ech) sessionOpts.ech = options.ech;
280
+ if (options.echGrease) sessionOpts.echGrease = true;
281
+ // 0-RTT with a session whose ticket allows it.
282
+ if (options.earlyData) sessionOpts.earlyData = true;
80
283
  }
81
284
  session = new TLSSession(sessionOpts);
82
285
  setupSessionEvents();
83
286
 
84
287
  // Client: set ALPN + transport params NOW, before setTimeout builds ClientHello
85
288
  if (!isServer) {
86
- var tp = defaultTransportParams();
87
- tp.initial_source_connection_id = localCid;
88
- var quicParams = build_transport_params(tp);
289
+ var quicParams = localQuicParams();
89
290
 
90
291
  session.set_context({
91
292
  local_supported_versions: [0x0304],
92
293
  local_supported_alpns: supportedAlpns,
93
- local_supported_groups: [29, 23, 24],
94
- local_supported_cipher_suites: [0x1301, 0x1302],
294
+ local_supported_groups: clientGroups(),
295
+ local_supported_cipher_suites: [0x1301, 0x1302, 0x1303],
95
296
  local_extensions: [{ type: 0x39, data: quicParams }],
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
- ],
297
+ // Signature schemes offered: lemon-tls's list, ML-DSA included (see
298
+ // the server's below). A pkcs1-only list here once had Cloudflare's
299
+ // ECDSA-only zones refuse the ClientHello (handshake_failure).
300
+ local_supported_signature_algorithms: (options.sigalgs && options.sigalgs.length) ? options.sigalgs.slice() : lemonSigSchemes.slice(),
113
301
  });
114
302
  helloHandled = true; // ClientHello will be generated from this set_context
115
303
  }
@@ -128,6 +316,45 @@ function TLSBridge(options) {
128
316
  if (data && data.length > 0) session.message(data);
129
317
  }
130
318
 
319
+ // The groups a client offers: the application's (ecdhCurve), or the
320
+ // default — node:tls's and the browsers' (see CLIENT_DEFAULT_GROUPS).
321
+ function clientGroups() {
322
+ return (options.groups && options.groups.length) ? options.groups.slice() : CLIENT_DEFAULT_GROUPS.slice();
323
+ }
324
+
325
+ // Our transport parameters as they go into the handshake: the connection's
326
+ // (defaults and the application's) and the fields of our role.
327
+ function localQuicParams() {
328
+ var tp = Object.assign({}, options.transportParams || defaultTransportParams());
329
+ // version_information (RFC 9368): what the connection says about
330
+ // versions when its parameters go out — the client's first flight, the
331
+ // server's after it has chosen.
332
+ if (typeof options.versionInformation === 'function') tp.version_information = options.versionInformation();
333
+ // grease_quic_bit (RFC 9287): this side accepts packets with the fixed
334
+ // bit 0 — the parser never checks it. Not on a UDP port shared with
335
+ // other protocols (RFC 9443), which tells QUIC apart by that bit.
336
+ if (options.greaseQuicBit !== false) tp.grease_quic_bit = true;
337
+ // min_ack_delay (draft-ietf-quic-ack-frequency): ACK_FREQUENCY is taken.
338
+ if (typeof options.minAckDelay === 'number') tp.min_ack_delay = options.minAckDelay;
339
+ if (isServer) {
340
+ // RFC 9000 §7.3: the server echoes the DCID of the client's first
341
+ // Initial, and names the SCID it put in its own long headers.
342
+ tp.original_destination_connection_id = originalDcid;
343
+ tp.initial_source_connection_id = localCid;
344
+ // §10.3: the token a client will recognise our Stateless Reset by.
345
+ // Only a secret one: a client accepts any datagram ending in these 16
346
+ // bytes as our reset, so a value an attacker can know — the fixed one
347
+ // that stood here — let anyone end a client's connection with a
348
+ // single forged packet. Without one the parameter is left out.
349
+ if (statelessResetToken) tp.stateless_reset_token = statelessResetToken;
350
+ if (retrySourceCid) tp.retry_source_connection_id = retrySourceCid;
351
+ } else {
352
+ // Client: initial_source_connection_id = our SCID (not DCID!)
353
+ tp.initial_source_connection_id = localCid;
354
+ }
355
+ return build_transport_params(tp);
356
+ }
357
+
131
358
  function setupSessionEvents() {
132
359
 
133
360
  // Peer's QUIC transport parameters ride in the TLS quic_transport_parameters
@@ -146,6 +373,10 @@ function TLSBridge(options) {
146
373
  var parsed = parse_transport_params(e.data, 0);
147
374
  peerParamsEmitted = true;
148
375
  if (DEBUG) console.log('[tls] peer transport params parsed (' + e.data.length + 'B)');
376
+ // A client keeps, with the sessions this connection saves, the
377
+ // server's parameters that 0-RTT must stay within (RFC 9000
378
+ // §7.4.1) — given back with the early secret next time.
379
+ if (!isServer) keepAppData('tp', rememberedParams(parsed));
149
380
  ev.emit('peerTransportParams', parsed);
150
381
  } catch (err) {
151
382
  if (DEBUG) console.log('[tls] peer transport params parse failed: ' + err.message);
@@ -160,52 +391,50 @@ function TLSBridge(options) {
160
391
  helloHandled = true;
161
392
  if (DEBUG) console.log('[tls] hello — configuring TLS');
162
393
 
163
- // Transport params — shared defaults + role-specific fields
164
- var tp = defaultTransportParams();
165
-
166
- if (isServer) {
167
- // Server must echo original DCID and provide SCID
168
- tp.original_destination_connection_id = originalDcid;
169
- tp.initial_source_connection_id = originalDcid;
170
- tp.stateless_reset_token = new Uint8Array(16).fill(0xab); // TODO: random per connection
171
- } else {
172
- // Client: initial_source_connection_id = our SCID (not DCID!)
173
- tp.initial_source_connection_id = localCid;
174
- }
175
-
176
- var quicParams = build_transport_params(tp);
394
+ var quicParams = localQuicParams();
177
395
 
178
396
  var tlsContext = {
179
397
  local_supported_versions: [0x0304],
180
398
  local_supported_alpns: supportedAlpns,
181
- local_supported_groups: [29, 23, 24],
182
- local_supported_cipher_suites: [0x1301, 0x1302, 0xc02f, 0xc030, 0xcca8],
399
+ // Server: X25519MLKEM768 (0x11ec, hybrid post-quantum) first — what
400
+ // browsers offer — then the classical groups. Client: classical only;
401
+ // see lemon-tls ecdh.js on why a client offers the hybrid only when
402
+ // asked.
403
+ local_supported_groups: (serverTls && serverTls.groups) || (isServer ? lemonServerGroups.slice() : clientGroups()),
404
+ // QUIC is TLS 1.3 (RFC 9001 §4.2): its three suites, ChaCha20 among
405
+ // them (crypto.js protects packets with either AEAD). The TLS 1.2
406
+ // suites that were listed could never be chosen. The server's
407
+ // `ciphers`, narrowed to these, where it set them.
408
+ local_supported_cipher_suites: (serverTls && serverTls.cipherSuites) || [0x1301, 0x1302, 0x1303],
183
409
  local_extensions: [
184
410
  { type: 0x39, data: quicParams }
185
411
  ],
186
412
  // 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
- ],
413
+ // server: in CertificateRequest): lemon-tls's list, as over TCP —
414
+ // ECDSA, EdDSA, RSA-PSS, then ML-DSA where Node has it, the legacy
415
+ // schemes last (accepted on certificates in the chain, never used in
416
+ // a TLS 1.3 CertificateVerify). The short list kept here by hand had
417
+ // no ML-DSA: a server with an ML-DSA certificate could not be
418
+ // verified over QUIC. The HRR's second ClientHello repeats it exactly
419
+ // (RFC 8446 §4.1.2).
420
+ local_supported_signature_algorithms: (serverTls && serverTls.sigalgs) || lemonSigSchemes.slice(),
203
421
  };
204
422
 
205
423
  session.set_context(tlsContext);
206
424
  });
207
425
 
208
426
  session.on('message', function (epoch, seq, type, data) {
427
+ if (isServer && type === 'hello') serverHelloSent = true; // lemon-tls names the ServerHello so
428
+ // A TLS alert is never CRYPTO data in QUIC (RFC 9001 §4.8): it becomes
429
+ // a CONNECTION_CLOSE carrying 0x100 + the alert code. It was sent down
430
+ // the CRYPTO stream like a handshake message — two bytes the peer could
431
+ // only read as a broken handshake — while this side went on waiting:
432
+ // a certificate refused, a handshake failed, and the connection stayed
433
+ // open until the handshake timeout with nothing reported.
434
+ if (type === 'alert') {
435
+ if (data && data.length >= 2 && data[0] === 2) ev.emit('alert', data[1]);
436
+ return;
437
+ }
209
438
  var quicEpoch;
210
439
  if (epoch === 0) quicEpoch = 'initial';
211
440
  else if (epoch === 1) quicEpoch = 'handshake';
@@ -239,6 +468,38 @@ function TLSBridge(options) {
239
468
  ev.emit('secureConnect');
240
469
  });
241
470
 
471
+ // 0-RTT: the client_early_traffic_secret — the client's to write with,
472
+ // as soon as its ClientHello offers early data (with what it saved last
473
+ // time: the server's parameters, `remembered`); the server's to read
474
+ // with, once it has decided to take it. Then the verdict ('earlyData').
475
+ session.on('earlySecret', function (secret, info) {
476
+ var cipher = info && info.cipher ? info.cipher : session.getCipher();
477
+ var hashName = TLS_CIPHER_SUITES[cipher] ? TLS_CIPHER_SUITES[cipher].hash : 'sha256';
478
+ ev.emit('earlySecret', { secret: secret, cipher: cipher, hash: hashName,
479
+ remembered: info && info.appData && info.appData.tp ? info.appData.tp : null,
480
+ rememberedApp: info && info.appData && info.appData.app != null ? info.appData.app : null });
481
+ });
482
+ session.on('earlyData', function (accepted) { ev.emit('earlyData', accepted); });
483
+
484
+ // Why the handshake failed: a certificate refused, a signature that does
485
+ // not verify, a size limit. lemon-tls has already sent the alert (it
486
+ // arrives above as 'alert' and closes the connection); this is the reason.
487
+ // Nothing listened here, so the reason was lost and the caller saw only a
488
+ // connection that went away. The code is the one node:tls reports for the
489
+ // same failure (authorizationError, e.g. DEPTH_ZERO_SELF_SIGNED_CERT).
490
+ session.on('error', function (err) {
491
+ if (err && !err.code && session.authorizationError) err.code = session.authorizationError;
492
+ ev.emit('error', err);
493
+ });
494
+
495
+ // Client: each NewSessionTicket, as the session to resume with (a
496
+ // Buffer, as node:tls gives it). lemon-tls emits it on a server too, for
497
+ // each ticket it issues — that is not ours to hand on.
498
+ if (!isServer) {
499
+ session.on('session', function (blob) { ev.emit('session', Buffer.from(blob)); });
500
+ session.on('OCSPResponse', function (r) { ev.emit('OCSPResponse', Buffer.from(r)); });
501
+ }
502
+
242
503
  session.on('keyUpdate', function (info) {
243
504
  ev.emit('keyUpdate', info.direction, info.secret);
244
505
  });
@@ -282,14 +543,165 @@ function TLSBridge(options) {
282
543
  return { local: secrets.localAppSecret, remote: secrets.remoteAppSecret, cipher: cipher, hash: hashName };
283
544
  },
284
545
  getCipher: function () { return session ? session.getCipher() : null; },
546
+
547
+ /** The ALPN protocol the handshake settled on, or null before it has. */
548
+ getALPN: function () { return session ? (session.getALPN() || null) : null; },
549
+
550
+ /** Encrypted Client Hello (server): null (not offered) | 'accepted' | 'rejected'. */
551
+ getEchStatus: function () { return session ? session.getEchStatus() : null; },
552
+
553
+ /** This connection's ticket: { data, resumed, offered } (lemon-tls
554
+ * TLSSession.getTicketInfo); null before the handshake has started. */
555
+ getTicketInfo: function () { return session && session.getTicketInfo ? session.getTicketInfo() : null; },
556
+
557
+ /** Server: replace our transport parameters before the ServerHello is
558
+ * built — connectionOptions answering after the ClientHello, while the
559
+ * handshake waits for it (see quic_connection). Returns whether it was
560
+ * still in time. */
561
+ updateTransportParams: function (tp) {
562
+ options.transportParams = tp;
563
+ if (!session || !helloHandled) return true; // taken at 'hello'
564
+ if (serverHelloSent) return false;
565
+ session.set_context({ local_extensions: [{ type: 0x39, data: localQuicParams() }] });
566
+ return true;
567
+ },
568
+
569
+ /** Server: the transport's own state in the tickets issued from now on
570
+ * (lemon-tls setTicketState — apart from the application's ticketData),
571
+ * and another ticket sent now to carry it (sendTicket). Careful Resume's
572
+ * path measurements go this way. Each returns whether it was done. */
573
+ /** Server: the context tickets are issued and accepted under (lemon-tls
574
+ * setTicketContext) — the negotiated QUIC version. */
575
+ setTicketContext: function (ctx) { if (session && session.setTicketContext) session.setTicketContext(ctx); },
576
+ /** Server: the context early data is accepted under (lemon-tls
577
+ * setEarlyDataContext) — changed with the transport parameters. */
578
+ /** Client: the application protocol's settings to keep with the
579
+ * sessions saved from now on (RFC 9114 §7.2.4.2), for 0-RTT. */
580
+ setRememberedApp: function (v) { if (!isServer) keepAppData('app', v); },
581
+ setEarlyDataContext: function (ctx) { options.earlyDataContext = ctx; if (session && session.setEarlyDataContext) session.setEarlyDataContext(ctx); },
582
+ setTicketState: function (st) { return !!(session && session.setTicketState && session.setTicketState(st)); },
583
+ sendTicket: function () { return !!(session && session.sendTicket && session.sendTicket()); },
584
+
585
+ /** Whether the handshake resumed a session (a ticket was accepted). */
586
+ isResumed: function () { return !!(session && session.isResumed); },
587
+
588
+ /** 'TLSv1.3' — as TLSSocket.getProtocol() words it; QUIC has no other. */
589
+ getProtocol: function () {
590
+ var v = session ? session.getVersion() : null;
591
+ return v === 0x0304 ? 'TLSv1.3' : v === 0x0303 ? 'TLSv1.2' : null;
592
+ },
593
+
594
+ /** { name, standardName, version } — TLSSocket.getCipher()'s shape. */
595
+ getCipherInfo: function () {
596
+ var code = session ? session.getCipher() : null;
597
+ if (code == null) return null;
598
+ var info = TLS_CIPHER_SUITES[code];
599
+ var protocol = this.getProtocol();
600
+ if (!info) return { name: '0x' + code.toString(16), standardName: 'unknown', version: protocol };
601
+ return {
602
+ name: info.name || info.cipher || '0x' + code.toString(16),
603
+ standardName: info.standardName || info.cipher || 'unknown',
604
+ version: protocol
605
+ };
606
+ },
607
+
608
+ /**
609
+ * The TLS fingerprints of this QUIC handshake: JA4 and JA5 of the
610
+ * ClientHello, JA4S of the ServerHello — { hash, raw }, or null.
611
+ *
612
+ * The same methods a TLSSocket has over TCP — the session computes them
613
+ * either way, and only the path out to the caller was missing. Over QUIC
614
+ * there is no socket object to hang them on, so they come from here.
615
+ *
616
+ * `protocol: 'q'` is not cosmetic: the first character encodes the
617
+ * transport, and the same client produces a different fingerprint over
618
+ * QUIC than over TCP. Reporting a 't' for a QUIC connection would file it
619
+ * under the wrong client entirely.
620
+ */
621
+ getJA4: function (opts) { return session && session.getJA4 ? session.getJA4(quicOpts(opts)) : null; },
622
+
623
+ /** TLS keying material (RFC 8446 §7.5), as node:tls's exportKeyingMaterial(length, label, context) — or null before the handshake has the secret. */
624
+ exportKeyingMaterial: function (length, label, context) {
625
+ return session && session.exportKeyingMaterial ? session.exportKeyingMaterial(length, label, context) : null;
626
+ },
627
+ /** The peer's certificate chain, leaf first, as DER — or null. */
628
+ getPeerCertificateChain: function () {
629
+ var chain = session && session.getPeerCertificate ? session.getPeerCertificate() : null;
630
+ return chain ? chain.map(function (e) { return e.cert; }) : null;
631
+ },
632
+ /** The peer's certificate as node:tls's getPeerCertificate() describes
633
+ * it (lemon-tls certificateObject) — {} when it sent none, as Node
634
+ * answers. On a resumed connection, the one its ticket carried. */
635
+ getPeerCertificate: function () {
636
+ var chain = session && session.getPeerCertificate ? session.getPeerCertificate() : null;
637
+ return (chain && chain.length > 0 && certificateObject(chain[0].cert)) || {};
638
+ },
639
+ /** Whether the peer's certificate was verified (TLSSocket.authorized):
640
+ * false when it sent none. And why not (authorizationError). */
641
+ isAuthorized: function () { return !!(session && session.authorized); },
642
+ authorizationError: function () { return session && session.authorized ? null : ((session && session.authorizationError) || null); },
643
+ getJA5: function (opts) { return session && session.getJA5 ? session.getJA5(quicOpts(opts)) : null; },
644
+ getJA4S: function (opts) { return session && session.getJA4S ? session.getJA4S(quicOpts(opts)) : null; },
645
+ /** What the handshake settled — version, cipher, group, ALPN, resumed,
646
+ * and the certificate compression used each way — as a TLSSocket's
647
+ * getNegotiationResult() (lemon-tls); null before there is a session. */
648
+ getNegotiationResult: function () { return session && session.getNegotiationResult ? session.getNegotiationResult() : null; },
649
+
650
+ /**
651
+ * The name the client asked for in its ClientHello (SNI), or false —
652
+ * TLSSocket.servername, same values. A client dialling a bare IP address
653
+ * sends none (RFC 6066 §3 forbids an IP literal there).
654
+ */
655
+ getServername: function () {
656
+ return (session && session.context && session.context.remote_sni) || false;
657
+ },
658
+
659
+ /**
660
+ * The certificate this connection presents — TLSSocket.getCertificate()'s
661
+ * shape: { subject, issuer, subjectaltname, valid_from, valid_to,
662
+ * fingerprint, fingerprint256, serialNumber, raw }, or {} when none. On a
663
+ * server, the one the SNI lookup chose; on a resumed connection, the one
664
+ * the lookup chose again for the same name.
665
+ */
666
+ getCertificate: function () {
667
+ var chain = session && session.getCertificate ? session.getCertificate() : null;
668
+ if (!chain || chain.length === 0) return {};
669
+ try {
670
+ var der = chain[0].cert || chain[0];
671
+ var x509 = new X509Certificate(Buffer.isBuffer(der) ? der : Buffer.from(der));
672
+ return {
673
+ subject: x509.subject,
674
+ issuer: x509.issuer,
675
+ subjectaltname: x509.subjectAltName,
676
+ valid_from: x509.validFrom,
677
+ valid_to: x509.validTo,
678
+ fingerprint: x509.fingerprint,
679
+ fingerprint256: x509.fingerprint256,
680
+ serialNumber: x509.serialNumber,
681
+ raw: der
682
+ };
683
+ } catch (e) {
684
+ return {};
685
+ }
686
+ },
687
+
285
688
  close: function () { if (session && session.close) session.close(); }
286
689
  };
287
690
  }
288
691
 
289
692
  /**
290
693
  * Drop-in replacement for tls.createSecureContext.
291
- * Builds both a lemon-tls context (for H3/QUIC) and a node:tls
292
- * context (for HTTP/2 + HTTP/1.1 over TCP) from the same credentials.
694
+ *
695
+ * One context now serves every protocol. It used to build a second, node:tls
696
+ * context alongside the lemon-tls one, because HTTP/2 and HTTP/1.1 ran on
697
+ * node:http2 and node:https and those need node's own context object. Both are
698
+ * gone — h2_server and h1_server run on lemon-tls through tcp_listener — so
699
+ * the duplicate context, and the three-way bridge in server.js that had to
700
+ * work out which kind it was holding, go with them.
701
+ *
702
+ * The raw credentials are still kept on the context: an SNICallback may hand
703
+ * one to a layer that needs to rebuild it, and throwing away the inputs would
704
+ * make that impossible.
293
705
  */
294
706
  function createSecureContext(options) {
295
707
  var ctx = _lemonCreateSecureContext(options);
@@ -297,17 +709,10 @@ function createSecureContext(options) {
297
709
  ctx._rawKey = options.key || null;
298
710
  ctx._rawCert = options.cert || null;
299
711
  ctx._rawCa = options.ca || null;
300
- try {
301
- var nodeOpts = {};
302
- if (options.key) nodeOpts.key = options.key;
303
- if (options.cert) nodeOpts.cert = options.cert;
304
- if (options.ca) nodeOpts.ca = options.ca;
305
- ctx._nodeContext = nodeTls.createSecureContext(nodeOpts);
306
- } catch (e) {
307
- ctx._nodeContext = null;
308
- }
309
712
  }
310
713
  return ctx;
311
714
  }
312
715
 
313
- export { TLSBridge, createSecureContext };
716
+ // defaultTransportParams is exported so quic_connection raises its stream
717
+ // limits from the same numbers it advertised, rather than a second copy.
718
+ export { TLSBridge, createSecureContext, defaultTransportParams, rememberedParams };