quico 0.2.0 → 0.3.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.
@@ -0,0 +1,314 @@
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
+ import dgram from 'node:dgram';
16
+ import { DEBUG, Emitter } from './utils.js';
17
+ import { parse_quic_datagram } from './transport.js';
18
+ import { QUICConnection } from './quic_connection.js';
19
+ import { createSecureContext } from './tls_bridge.js';
20
+
21
+
22
+ // ============================================================
23
+ // createQuicServer(options)
24
+ //
25
+ // Transport-only QUIC server. Owns the UDP socket(s), demultiplexes
26
+ // incoming datagrams to per-peer QUICConnection objects, and emits a
27
+ // 'connection' event carrying the raw QUICConnection. It knows nothing
28
+ // about HTTP/3 — application protocols (HTTP/3, DNS-over-QUIC, ...) are
29
+ // layered on top by listening for 'connection' and then using the
30
+ // connection's own stream/datagram API.
31
+ //
32
+ // This is the connection-management harness that used to live inside
33
+ // h3_server.js. h3_server now builds on it (wrapping each connection in
34
+ // an H3Connection); other consumers (e.g. a DoQ server) can use it
35
+ // directly.
36
+ //
37
+ // Events:
38
+ // 'connection' (quic, peer) — new QUICConnection created for a peer.
39
+ // peer = { address, port }. Handlers
40
+ // attach quic.on('connect'|'stream'|
41
+ // 'datagram'|'close') as needed.
42
+ // 'error' (err) — UDP socket / send error.
43
+ //
44
+ // Options:
45
+ // alpn — ALPN protocol(s) to advertise (default 'h3').
46
+ // DoQ servers pass 'doq'.
47
+ // SNICallback — TLS SNI callback returning a SecureContext.
48
+ // key/cert/ca — convenience: build a default SecureContext when no
49
+ // SNICallback is given.
50
+ // maxConnections — capacity cap (default 10000).
51
+ // socket/socket6 — externally-owned, already-bound UDP sockets
52
+ // (shared-port mode, RFC 9443). When provided, the
53
+ // caller feeds datagrams via handlePacket(msg, rinfo).
54
+ // ============================================================
55
+
56
+ function createQuicServer(options) {
57
+ options = options || {};
58
+
59
+ var ev = Emitter();
60
+ var udp4 = null;
61
+ var udp6 = null;
62
+ var port = null;
63
+
64
+ // ALPN to advertise on every accepted connection. Default ['h3'].
65
+ var alpn = options.alpn || ['h3'];
66
+
67
+ // ── External socket mode (shared UDP port) ──
68
+ // When socket/socket6 are provided, the UDP layer is owned externally.
69
+ // The caller demuxes packets and feeds them via handlePacket(msg, rinfo).
70
+ // See RFC 9443 for the shared-port multiplexing scheme.
71
+ var externalSocket4 = options.socket || null;
72
+ var externalSocket6 = options.socket6 || null;
73
+
74
+ if (externalSocket4) {
75
+ try { externalSocket4.address(); }
76
+ catch (e) { throw new Error('options.socket must be bound before createQuicServer()'); }
77
+ }
78
+ if (externalSocket6) {
79
+ try { externalSocket6.address(); }
80
+ catch (e) { throw new Error('options.socket6 must be bound before createQuicServer()'); }
81
+ }
82
+
83
+ var isExternal = !!(externalSocket4 || externalSocket6);
84
+
85
+ // Build SNICallback
86
+ var sniCallback = options.SNICallback || null;
87
+
88
+ if (!sniCallback && options.key && options.cert) {
89
+ var defaultCtx = createSecureContext({ key: options.key, cert: options.cert, ca: options.ca });
90
+ sniCallback = function (servername, cb) { cb(null, defaultCtx); };
91
+ }
92
+
93
+ // ---- Connection tracking ----
94
+ var connections = {}; // connId → { quic, peer }
95
+ var addressMap = {}; // "ip:port" → connId
96
+ var maxConnections = options.maxConnections || 10000;
97
+ var sweepTimer = null;
98
+
99
+
100
+ // ============================================================
101
+ // UDP → connection routing
102
+ // ============================================================
103
+
104
+ function onUdpMessage(msg, rinfo) {
105
+ var data = new Uint8Array(msg);
106
+ var fromIp = rinfo.address;
107
+ var fromPort = rinfo.port;
108
+ if (DEBUG) console.log('[qserver] UDP from ' + fromIp + ':' + fromPort + ' len=' + data.length);
109
+
110
+ var addressKey = fromIp + ':' + fromPort;
111
+ var packets = parse_quic_datagram(data);
112
+ if (packets.length === 0) return;
113
+
114
+ // Route the whole datagram by its first packet. Coalesced packets in one
115
+ // UDP datagram belong to the same connection (RFC 9000 §12.2), so a single
116
+ // routing decision covers them all — and the datagram is parsed only once
117
+ // here, handing the parsed packets straight to the connection (previously
118
+ // each packet's raw bytes were re-parsed inside feedDatagram).
119
+ var connId = findConnectionId(packets[0], addressKey);
120
+ if (connId === null) return; // stray/unmatched non-Initial packet — drop
121
+
122
+ if (!(connId in connections)) {
123
+ // Reject new connections when at capacity
124
+ if (Object.keys(connections).length >= maxConnections) return;
125
+ createConnection(connId, fromIp, fromPort, addressKey);
126
+ }
127
+
128
+ connections[connId].quic.feedPackets(fromIp, fromPort, packets);
129
+ }
130
+
131
+
132
+ function findConnectionId(pkt, addressKey) {
133
+ var dcidStr = null;
134
+ if (pkt.dcid && pkt.dcid.byteLength > 0) {
135
+ dcidStr = Array.from(pkt.dcid).join('');
136
+ }
137
+
138
+ if (dcidStr && dcidStr in connections) return dcidStr;
139
+ if (addressKey in addressMap && addressMap[addressKey] in connections) return addressMap[addressKey];
140
+
141
+ // No existing connection matches. Only an Initial packet may *start* a new
142
+ // connection; any other unmatched packet (a stray/late short-header packet,
143
+ // or a Handshake whose Initial was lost, etc.) is dropped rather than
144
+ // spawning a phantom connection keyed by a random id.
145
+ if (pkt.type !== 'initial') return null;
146
+
147
+ var id = dcidStr || String(Math.floor(Math.random() * 9007199254740991));
148
+ addressMap[addressKey] = id;
149
+ return id;
150
+ }
151
+
152
+
153
+ function createConnection(connId, fromIp, fromPort, addressKey) {
154
+ var quic = new QUICConnection({ SNICallback: sniCallback, alpn: alpn });
155
+
156
+ connections[connId] = {
157
+ quic: quic,
158
+ peer: { address: fromIp, port: fromPort }
159
+ };
160
+
161
+ addressMap[addressKey] = connId;
162
+
163
+ // Send packets back via UDP
164
+ quic.on('packet', function (data) {
165
+ sendUdp(fromIp, fromPort, data);
166
+ });
167
+
168
+ // Drop our bookkeeping when the connection closes.
169
+ quic.on('close', function () {
170
+ delete connections[connId];
171
+ if (addressMap[addressKey] === connId) delete addressMap[addressKey];
172
+ });
173
+
174
+ // Hand the raw connection to the application layer (H3, DoQ, ...).
175
+ ev.emit('connection', quic, { address: fromIp, port: fromPort });
176
+ }
177
+
178
+
179
+ // ============================================================
180
+ // UDP send
181
+ // ============================================================
182
+
183
+ function sendUdp(toIp, toPort, data) {
184
+ var isV6 = toIp.indexOf(':') >= 0;
185
+ // Prefer external socket (shared mode) over internally-bound socket
186
+ var socket = isV6
187
+ ? (externalSocket6 || udp6)
188
+ : (externalSocket4 || udp4);
189
+ if (!socket) return;
190
+ socket.send(data, toPort, toIp, function (err) {
191
+ if (err) ev.emit('error', err);
192
+ });
193
+ }
194
+
195
+
196
+ // ============================================================
197
+ // listen / close
198
+ // ============================================================
199
+
200
+ function listen(listenPort, host, callback) {
201
+ if (typeof host === 'function') { callback = host; host = null; }
202
+ port = listenPort || 443;
203
+ host = host || '::';
204
+
205
+ if (isExternal) {
206
+ // External sockets already bound — wire up incoming messages.
207
+ // Starting the sweep timer is safe here (idempotent check below).
208
+ if (externalSocket4) {
209
+ externalSocket4.on('message', onUdpMessage);
210
+ }
211
+ if (externalSocket6) {
212
+ externalSocket6.on('message', onUdpMessage);
213
+ }
214
+ startSweepTimer();
215
+ if (typeof callback === 'function') setImmediate(callback);
216
+ return;
217
+ }
218
+
219
+ udp4 = dgram.createSocket('udp4');
220
+ udp4.on('message', onUdpMessage);
221
+ udp4.on('error', function (err) { ev.emit('error', err); });
222
+ var host4 = host.indexOf('.') !== -1 ? host : '0.0.0.0';
223
+ udp4.bind(port, host4);
224
+
225
+ udp6 = dgram.createSocket({ type: 'udp6', ipv6Only: true });
226
+ udp6.on('message', onUdpMessage);
227
+ udp6.on('error', function (err) { ev.emit('error', err); });
228
+ var host6 = host.indexOf(':') !== -1 ? host : '::';
229
+ udp6.bind(port, host6, function () {
230
+ if (typeof callback === 'function') callback();
231
+ });
232
+
233
+ startSweepTimer();
234
+ }
235
+
236
+ // Periodic sweep for dead connections (safety net). Runs independently
237
+ // of socket ownership so external-mode agents get the same cleanup.
238
+ function startSweepTimer() {
239
+ if (sweepTimer) return; // idempotent
240
+ sweepTimer = setInterval(function () {
241
+ for (var id in connections) {
242
+ var st = connections[id].quic.state;
243
+ if (st === 'closed') {
244
+ delete connections[id];
245
+ // Clean address map
246
+ for (var addr in addressMap) {
247
+ if (addressMap[addr] === id) delete addressMap[addr];
248
+ }
249
+ }
250
+ }
251
+ }, 30000);
252
+ if (sweepTimer.unref) sweepTimer.unref();
253
+ }
254
+
255
+ function close(callback) {
256
+ // Stop sweep
257
+ if (sweepTimer) { clearInterval(sweepTimer); sweepTimer = null; }
258
+
259
+ // Close all connections
260
+ for (var id in connections) {
261
+ try { connections[id].quic.close(0, 'server closing'); } catch (e) { }
262
+ }
263
+ connections = {};
264
+ addressMap = {};
265
+
266
+ var closed = 0;
267
+ var total = (udp4 ? 1 : 0) + (udp6 ? 1 : 0);
268
+ if (total === 0) { if (callback) callback(); return; }
269
+
270
+ function onClose() {
271
+ closed++;
272
+ if (closed >= total && typeof callback === 'function') callback();
273
+ }
274
+
275
+ if (udp4) { udp4.close(onClose); udp4 = null; }
276
+ if (udp6) { udp6.close(onClose); udp6 = null; }
277
+ }
278
+
279
+
280
+ // ============================================================
281
+ // External packet API (for shared UDP port scenarios)
282
+ // ============================================================
283
+
284
+ // Query whether the given 5-tuple matches an active QUIC connection.
285
+ // Used by the demuxer to disambiguate byte 0 in range 64-79 between
286
+ // QUIC short header and TURN ChannelData (RFC 9443). Fresh connections
287
+ // must be classified by byte 0 range (80-127 or 192+ = QUIC).
288
+ function hasConnection(rinfo) {
289
+ var key = rinfo.address + ':' + rinfo.port;
290
+ return (key in addressMap) && (addressMap[key] in connections);
291
+ }
292
+
293
+
294
+ // ============================================================
295
+ // Server API
296
+ // ============================================================
297
+
298
+ return {
299
+ listen: listen,
300
+ close: close,
301
+ on: function (name, fn) { ev.on(name, fn); },
302
+
303
+ /** Feed an incoming UDP packet when running in external-socket mode.
304
+ * rinfo must be in Node's dgram format: { address, port, family, size }. */
305
+ handlePacket: onUdpMessage,
306
+
307
+ /** Returns true if the 5-tuple matches an active QUIC connection.
308
+ * Used by demuxers for routing decisions on shared UDP ports. */
309
+ hasConnection: hasConnection
310
+ };
311
+ }
312
+
313
+
314
+ export { createQuicServer };
@@ -0,0 +1,76 @@
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
+ import dgram from 'node:dgram';
16
+ import { QUICConnection } from './quic_connection.js';
17
+
18
+
19
+ /**
20
+ * Create a UDP socket bound to an ephemeral port and wire it to a client-side
21
+ * QUICConnection. Shared by the HTTP/3 client (h3_client.js) and the
22
+ * WebTransport client (webtransport.js), which had near-identical setup code:
23
+ * pick udp4/udp6, create the socket, forward datagrams into the connection,
24
+ * bind, create the client QUICConnection, forward outgoing packets to the
25
+ * socket, and start the handshake.
26
+ *
27
+ * opts:
28
+ * remoteIp, remotePort — where to send packets
29
+ * hostname — SNI / :authority for the QUIC/TLS handshake
30
+ * onConnect(quic, socket) — called once the QUIC handshake completes
31
+ * onClose() — called when the connection closes (the socket is
32
+ * already closed by the time this runs)
33
+ * onError(err) — socket errors and outgoing-packet send errors
34
+ *
35
+ * Returns the UDP socket synchronously. The QUICConnection is created after the
36
+ * socket binds, so it is delivered via onConnect rather than returned here.
37
+ */
38
+ function createQuicClientSocket(opts) {
39
+ var isIPv6 = opts.remoteIp.indexOf(':') >= 0;
40
+ var udpSocket = dgram.createSocket(isIPv6 ? 'udp6' : 'udp4');
41
+ var quic = null;
42
+
43
+ udpSocket.on('message', function (msg, rinfo) {
44
+ if (quic) quic.feedDatagram(rinfo.address, rinfo.port, new Uint8Array(msg));
45
+ });
46
+
47
+ udpSocket.on('error', function (err) {
48
+ if (opts.onError) opts.onError(err);
49
+ });
50
+
51
+ udpSocket.bind(0, function () {
52
+ quic = new QUICConnection({ isServer: false, hostname: opts.hostname });
53
+
54
+ quic.on('packet', function (data) {
55
+ udpSocket.send(data, opts.remotePort, opts.remoteIp, function (err) {
56
+ if (err && opts.onError) opts.onError(err);
57
+ });
58
+ });
59
+
60
+ quic.on('connect', function () {
61
+ if (opts.onConnect) opts.onConnect(quic, udpSocket);
62
+ });
63
+
64
+ quic.on('close', function () {
65
+ try { udpSocket.close(); } catch (e) {}
66
+ if (opts.onClose) opts.onClose();
67
+ });
68
+
69
+ quic.connect();
70
+ });
71
+
72
+ return udpSocket;
73
+ }
74
+
75
+
76
+ export { createQuicClientSocket };
package/src/server.js CHANGED
@@ -19,25 +19,9 @@ import nodeHttps from 'node:https';
19
19
  import nodeTls from 'node:tls';
20
20
 
21
21
  import { createServer as createH3Server } from './h3_server.js';
22
- import { createSecureContext as _lemonCreateSecureContext } from './tls_bridge.js';
23
22
  import { IncomingMessage, ServerResponse, _makeSocketStub } from './streams.js';
24
23
 
25
24
 
26
- /**
27
- * Wrapped createSecureContext — stores raw key/cert on the context
28
- * so server.js can reuse them for TCP (node:tls).
29
- */
30
- function createSecureContext(opts) {
31
- var ctx = _lemonCreateSecureContext(opts);
32
- if (opts) {
33
- ctx._rawKey = opts.key || null;
34
- ctx._rawCert = opts.cert || null;
35
- ctx._rawCa = opts.ca || null;
36
- }
37
- return ctx;
38
- }
39
-
40
-
41
25
  // ============================================================
42
26
  // createServer(options, handler)
43
27
  //
@@ -89,9 +73,16 @@ function createServer(options, handler) {
89
73
  cert: tlsCert,
90
74
  ca: tlsCa,
91
75
  SNICallback: options.SNICallback || null,
92
- maxConnections: options.maxConnections || 10000
76
+ maxConnections: options.maxConnections || 10000,
77
+ // External UDP sockets (shared UDP port scenario). When provided,
78
+ // the UDP layer is owned externally — caller feeds packets via
79
+ // server.handlePacket(msg, rinfo). See RFC 9443.
80
+ socket: options.socket || null,
81
+ socket6: options.socket6 || null
93
82
  };
94
83
 
84
+ var isExternalUdp = !!(options.socket || options.socket6);
85
+
95
86
  var h3srv = createH3Server(h3Options, function (plainReq, plainRes) {
96
87
  var socketStub = _makeSocketStub();
97
88
  socketStub.remoteAddress = plainReq.remoteAddress || null;
@@ -111,10 +102,6 @@ function createServer(options, handler) {
111
102
  plainReq.on('data', function (chunk) { req._pushData(chunk); });
112
103
  plainReq.on('end', function () { req._endData(); });
113
104
 
114
- // Store wrapper references for WebTransport event forwarding
115
- plainReq._wrapper = req;
116
- plainRes._wrapper = res;
117
-
118
105
  // ---- Wrap plain res into ServerResponse (Writable) ----
119
106
  // ServerResponse._write → plainRes.write
120
107
  // ServerResponse._flushHeaders → plainRes.writeHead
@@ -125,6 +112,12 @@ function createServer(options, handler) {
125
112
  });
126
113
  res.req = req;
127
114
 
115
+ // Store wrapper references for WebTransport event forwarding.
116
+ // MUST come after `res` is created (was previously set while res was
117
+ // still undefined due to var hoisting).
118
+ plainReq._wrapper = req;
119
+ plainRes._wrapper = res;
120
+
128
121
  // Override internal methods to delegate to the plain H3 res.
129
122
  // Key insight: we buffer one chunk behind so that _final can
130
123
  // send the last data + FIN together via plainRes.end(chunk),
@@ -285,7 +278,9 @@ function createServer(options, handler) {
285
278
  }
286
279
  }
287
280
 
288
- // H3 (UDP) — always
281
+ // H3 — always. In external mode h3srv.listen() wires up the external
282
+ // sockets' message handler and starts the sweep timer, but does not
283
+ // bind anything new.
289
284
  h3srv.listen(port, host, done);
290
285
 
291
286
  // TCP — if needed
@@ -336,6 +331,19 @@ function createServer(options, handler) {
336
331
  tcpServer.setTimeout(msecs, callback);
337
332
  }
338
333
  return server;
334
+ },
335
+
336
+ /** Feed an incoming UDP packet from a shared/external socket.
337
+ * rinfo must be in Node's dgram format: { address, port, family, size }.
338
+ * Only relevant when the server was created with options.socket/socket6. */
339
+ handlePacket: function (msg, rinfo) {
340
+ return h3srv.handlePacket(msg, rinfo);
341
+ },
342
+
343
+ /** Returns true if the 5-tuple matches an active QUIC connection.
344
+ * Used by demuxers for routing decisions on shared UDP ports. */
345
+ hasConnection: function (rinfo) {
346
+ return h3srv.hasConnection(rinfo);
339
347
  }
340
348
  };
341
349
 
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,6 +52,11 @@ 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
+ // ALPN protocol(s) to advertise. Default ['h3']; normalize string → array.
56
+ var supportedAlpns = (function () {
57
+ var a = options.alpn || ['h3'];
58
+ return Array.isArray(a) ? a : [a];
59
+ })();
55
60
 
56
61
  function feedMessage(data) {
57
62
  if (!session) {
@@ -71,11 +76,27 @@ function TLSBridge(options) {
71
76
 
72
77
  session.set_context({
73
78
  local_supported_versions: [0x0304],
74
- local_supported_alpns: ['h3'],
79
+ local_supported_alpns: supportedAlpns,
75
80
  local_supported_groups: [29, 23, 24],
76
81
  local_supported_cipher_suites: [0x1301, 0x1302],
77
82
  local_extensions: [{ type: 0x39, data: quicParams }],
78
- local_supported_signature_algorithms: [0x0401, 0x0501, 0x0601],
83
+ // Signature schemes offered to the peer (client: in the ClientHello;
84
+ // server: in CertificateRequest). Full modern list, ordered PSS/ECDSA/
85
+ // EdDSA first, legacy pkcs1 last (TLS 1.2 compat only — TLS 1.3 never
86
+ // signs pkcs1). Two bugs lived in the old pkcs1-only list:
87
+ // 1. Cloudflare zones with ECDSA-only certs reject the ClientHello
88
+ // outright (alert 40 handshake_failure — seen live against
89
+ // speed.cloudflare.com; claude.ai worked only because that zone
90
+ // also carries an RSA cert).
91
+ // 2. lemon-tls hardcodes THIS full list in the HRR CH2, and RFC 8446
92
+ // §4.1.2 requires CH2 to match CH1 exactly — offering a different
93
+ // list in CH1 made every HRR flow non-compliant.
94
+ local_supported_signature_algorithms: [
95
+ 0x0804, 0x0805, 0x0806, // rsa_pss_rsae_sha256/384/512
96
+ 0x0403, 0x0503, 0x0603, // ecdsa_secp256r1/384r1/521r1
97
+ 0x0807, 0x0808, // ed25519, ed448
98
+ 0x0401, 0x0501, 0x0601 // rsa_pkcs1 (TLS 1.2 legacy)
99
+ ],
79
100
  });
80
101
  helloHandled = true; // ClientHello will be generated from this set_context
81
102
  }
@@ -85,10 +106,35 @@ function TLSBridge(options) {
85
106
 
86
107
  function setupSessionEvents() {
87
108
 
109
+ // Peer's QUIC transport parameters ride in the TLS quic_transport_parameters
110
+ // extension (type 0x39): client's in the ClientHello, server's in the
111
+ // EncryptedExtensions. lemon-tls surfaces every received handshake message via
112
+ // 'handshakeMessage' with the parsed extensions, so we extract the 0x39 data,
113
+ // parse it, and hand the params to the QUIC layer (which seeds remote_max_data,
114
+ // max_ack_delay, ack_delay_exponent, etc. — previously hardcoded defaults).
115
+ var peerParamsEmitted = false;
116
+ session.on('handshakeMessage', function (type, data, message) {
117
+ if (peerParamsEmitted || !message || !Array.isArray(message.extensions)) return;
118
+ for (var i = 0; i < message.extensions.length; i++) {
119
+ var e = message.extensions[i];
120
+ if (e && (e.type === 0x39 || e.type === 57) && e.data) {
121
+ try {
122
+ var parsed = parse_transport_params(e.data, 0);
123
+ peerParamsEmitted = true;
124
+ if (DEBUG) console.log('[tls] peer transport params parsed (' + e.data.length + 'B)');
125
+ ev.emit('peerTransportParams', parsed);
126
+ } catch (err) {
127
+ if (DEBUG) console.log('[tls] peer transport params parse failed: ' + err.message);
128
+ }
129
+ break;
130
+ }
131
+ }
132
+ });
133
+
88
134
  session.on('hello', function () {
89
135
  if (helloHandled) return; // prevent double-fire
90
136
  helloHandled = true;
91
- console.log('[tls] hello — configuring TLS');
137
+ if (DEBUG) console.log('[tls] hello — configuring TLS');
92
138
 
93
139
  // Transport params — shared defaults + role-specific fields
94
140
  var tp = defaultTransportParams();
@@ -107,13 +153,29 @@ function TLSBridge(options) {
107
153
 
108
154
  var tlsContext = {
109
155
  local_supported_versions: [0x0304],
110
- local_supported_alpns: ['h3'],
156
+ local_supported_alpns: supportedAlpns,
111
157
  local_supported_groups: [29, 23, 24],
112
158
  local_supported_cipher_suites: [0x1301, 0x1302, 0xc02f, 0xc030, 0xcca8],
113
159
  local_extensions: [
114
160
  { type: 0x39, data: quicParams }
115
161
  ],
116
- local_supported_signature_algorithms: [0x0401, 0x0501, 0x0601],
162
+ // Signature schemes offered to the peer (client: in the ClientHello;
163
+ // server: in CertificateRequest). Full modern list, ordered PSS/ECDSA/
164
+ // EdDSA first, legacy pkcs1 last (TLS 1.2 compat only — TLS 1.3 never
165
+ // signs pkcs1). Two bugs lived in the old pkcs1-only list:
166
+ // 1. Cloudflare zones with ECDSA-only certs reject the ClientHello
167
+ // outright (alert 40 handshake_failure — seen live against
168
+ // speed.cloudflare.com; claude.ai worked only because that zone
169
+ // also carries an RSA cert).
170
+ // 2. lemon-tls hardcodes THIS full list in the HRR CH2, and RFC 8446
171
+ // §4.1.2 requires CH2 to match CH1 exactly — offering a different
172
+ // list in CH1 made every HRR flow non-compliant.
173
+ local_supported_signature_algorithms: [
174
+ 0x0804, 0x0805, 0x0806, // rsa_pss_rsae_sha256/384/512
175
+ 0x0403, 0x0503, 0x0603, // ecdsa_secp256r1/384r1/521r1
176
+ 0x0807, 0x0808, // ed25519, ed448
177
+ 0x0401, 0x0501, 0x0601 // rsa_pkcs1 (TLS 1.2 legacy)
178
+ ],
117
179
  };
118
180
 
119
181
  session.set_context(tlsContext);
@@ -125,7 +187,7 @@ function TLSBridge(options) {
125
187
  else if (epoch === 1) quicEpoch = 'handshake';
126
188
  else quicEpoch = 'app';
127
189
 
128
- console.log('[tls] outgoing: epoch=' + quicEpoch + ' type=' + type + ' len=' + data.length);
190
+ if (DEBUG) console.log('[tls] outgoing: epoch=' + quicEpoch + ' type=' + type + ' len=' + data.length);
129
191
  ev.emit('send', quicEpoch, data);
130
192
  });
131
193
 
@@ -135,7 +197,7 @@ function TLSBridge(options) {
135
197
  handshakeSecretsEmitted = true;
136
198
  var cipher = session.getCipher();
137
199
  var hashName = TLS_CIPHER_SUITES[cipher] ? TLS_CIPHER_SUITES[cipher].hash : 'sha256';
138
- console.log('[tls] handshake secrets ready');
200
+ if (DEBUG) console.log('[tls] handshake secrets ready');
139
201
  ev.emit('handshakeSecrets', { local: localSecret, remote: remoteSecret, cipher: cipher, hash: hashName });
140
202
  });
141
203
 
@@ -144,12 +206,12 @@ function TLSBridge(options) {
144
206
  appSecretsEmitted = true;
145
207
  var cipher = session.getCipher();
146
208
  var hashName = TLS_CIPHER_SUITES[cipher] ? TLS_CIPHER_SUITES[cipher].hash : 'sha256';
147
- console.log('[tls] app secrets ready');
209
+ if (DEBUG) console.log('[tls] app secrets ready');
148
210
  ev.emit('appSecrets', { local: localSecret, remote: remoteSecret, cipher: cipher, hash: hashName });
149
211
  });
150
212
 
151
213
  session.on('secureConnect', function () {
152
- console.log('[tls] secureConnect');
214
+ if (DEBUG) console.log('[tls] secureConnect');
153
215
  ev.emit('secureConnect');
154
216
  });
155
217
 
@@ -208,4 +270,4 @@ function createSecureContext(options) {
208
270
  return ctx;
209
271
  }
210
272
 
211
- export { TLSBridge, createSecureContext };
273
+ export { TLSBridge, createSecureContext };