lemon-tls 0.6.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,29 +11,32 @@
11
11
  <a href="https://www.npmjs.com/package/lemon-tls">
12
12
  <img src="https://img.shields.io/npm/v/lemon-tls?color=blue" alt="npm">
13
13
  </a>
14
- <img src="https://img.shields.io/badge/status-in%20development-yellow" alt="status">
15
14
  <img src="https://img.shields.io/github/license/colocohen/lemon-tls?color=brightgreen" alt="license">
16
15
  </p>
17
16
 
18
17
  ---
19
18
 
20
- > **⚠️ Project status: *Active development*.**
21
- > APIs may change without notice until we reach v1.0.
22
- > Use at your own risk and please report issues!
23
-
24
19
  ## ✨ Features
25
20
 
26
21
  * 🔒 **Pure JavaScript** – no OpenSSL, no native bindings. Zero dependencies.
27
22
  * ⚡ **TLS 1.3 (RFC 8446)** + **TLS 1.2** – both server and client.
23
+ * 📡 **DTLS 1.3 (RFC 9147)** + **DTLS 1.2** – server and client, with cookie exchange, retransmission, and **DTLS-SRTP** (RFC 5764) for WebRTC. Not a shim over TLS: the record layer, epochs, and sequence-number handling are implemented for datagrams.
28
24
  * 🌐 **Browser-tested** – verified interop with Chrome, curl, Node.js, openssl s_client, and msquic.
29
25
  * 🔑 **Key Access** – read handshake secrets, traffic keys, ECDHE shared secret, and resumption data at any point.
30
- * 🔁 **Session Resumption** – session tickets + PSK with binder validation.
26
+ * 🔁 **Session Resumption** – session tickets + PSK with binder validation, with application data carried inside the ticket (`ticketData`, `getTicketInfo()`).
31
27
  * 🔄 **Key Update** – refresh traffic keys on long-lived TLS 1.3 connections.
32
28
  * 🔃 **HelloRetryRequest** – automatic group negotiation fallback (X25519, P-256, P-384).
29
+ * 🛰 **Post-quantum key exchange** – the hybrid **X25519MLKEM768** group, as Chrome, Firefox, Safari and Cloudflare negotiate it, wherever `node:crypto` has ML-KEM (Node 24.7+).
30
+ * 🕶 **Encrypted Client Hello (RFC 9849)** – server *and* client. The real ClientHello — the site's name, the ALPN list — goes encrypted with HPKE to a key published in DNS. Key generation, rotation, `retry_configs`, HelloRetryRequest, resumption inside ECH, and GREASE ECH. Tested against Go's `crypto/tls` in both directions.
31
+ * 🗜 **Certificate compression (RFC 8879)** – brotli, zlib and zstd, both directions; a public CA's chain often shrinks by half — over QUIC that can save a round trip.
32
+ * 📄 **OCSP stapling** – the server staples (`'OCSPRequest'`, as `node:tls`), the client asks for it (`requestOCSP`, `'OCSPResponse'`).
33
33
  * 📜 **Client Certificate Auth** – mutual TLS (mTLS) with `requestCert` / `cert` / `key` options.
34
- * 🛡 **Designed for extensibility** – exposes cryptographic keys and record-layer primitives for QUIC, DTLS, or custom transports.
34
+ * 🛡 **Designed for extensibility** – exposes cryptographic keys and record-layer primitives for QUIC or custom transports (DTLS ships implemented, see above).
35
35
  * 🧩 **Two API levels** – high-level `TLSSocket` (drop-in Node.js Duplex stream) and low-level `TLSSession` (state machine only, you handle the transport).
36
- * 🔧 **Beyond Node.js** – per-connection cipher/sigalg/group selection, JA3 fingerprinting, certificate pinning, and more options that are impossible or require `openssl.cnf` hacks in Node.js.
36
+ * 🔍 **TLS fingerprinting, built in** – `getJA4()`, `getJA5()` and `getJA4S()`: the client's fingerprint, its resumption-stable variant, and the server's. Each is available **in either role**, because both hellos are visible to both peers. Verified byte-for-byte against independent implementations. Node cannot do this at all: OpenSSL parses the ClientHello and discards the offer, so other libraries re-read the raw bytes off the socket in parallel — here the data is already in hand.
37
+ * 🎭 **Handshake shaping** – `permuteExtensions` (Chrome 110+ extension-order randomisation), `grease` (RFC 8701, across all seven insertion points) and `echGrease` (GREASE ECH, RFC 9849 §6.2). All opt-in, all collision-safe against anything you configured by hand.
38
+ * 🧮 **HPKE (RFC 9180)** – exported as a module of its own (`crypto.hpke`): all four modes, the DHKEMs over X25519, X448, P-256, P-384 and P-521 — what ECH is built on, and the base of Oblivious HTTP and MLS.
39
+ * 🔧 **Beyond Node.js** – per-connection cipher/sigalg/group selection, certificate pinning, `ALPNCallback`, `setKeyCert`, and more options that are impossible or require `openssl.cnf` hacks in Node.js.
37
40
  * 📘 **TypeScript support** – full `.d.ts` bundled, type-checked in strict mode.
38
41
 
39
42
  ## 📦 Installation
@@ -122,6 +125,91 @@ const socket = tls.connect(8443, 'localhost', {
122
125
  });
123
126
  ```
124
127
 
128
+ ### Encrypted Client Hello (ECH)
129
+
130
+ Without ECH the first message of every TLS connection — the ClientHello, with the
131
+ site's name — crosses the network in the clear. With ECH (RFC 9849) the client sends
132
+ the real ClientHello encrypted, inside an outer one that names only a shared
133
+ *public name*, to a key the server publishes in DNS (the `ech=` key of its HTTPS
134
+ record, RFC 9460).
135
+
136
+ ```js
137
+ import tls from 'lemon-tls';
138
+
139
+ // Server: a key — keep k.config and k.privateKey across restarts,
140
+ // publish k.configListBase64 as ech= in the domain's HTTPS record.
141
+ const k = tls.generateEchKey({ publicName: 'ech.example.com' });
142
+
143
+ const server = tls.createServer({
144
+ key, cert, // must cover ech.example.com too (see below)
145
+ ech: { keys: [{ config: k.config, privateKey: k.privateKey }] },
146
+ }, (socket) => {
147
+ console.log(socket.servername, socket.echStatus); // 'www.example.com' 'accepted'
148
+ });
149
+
150
+ // Client: the server's key list, from DNS.
151
+ const socket = tls.connect({
152
+ host: 'www.example.com', port: 443, servername: 'www.example.com',
153
+ ech: k.configListBase64,
154
+ }, () => console.log(socket.echStatus)); // 'accepted'
155
+
156
+ socket.on('error', (err) => {
157
+ if (err.code === 'ERR_ECH_REJECTED' && err.retryConfigs) {
158
+ // The server's keys changed: connect again with err.retryConfigs.
159
+ }
160
+ });
161
+ ```
162
+
163
+ **When the server cannot decrypt** (a key it no longer holds), it completes the
164
+ handshake as the public name — so its certificate must cover that name too — and
165
+ sends its current keys (`retry_configs`). The client checks that certificate against
166
+ the public name, sends `ech_required`, and fails with `ERR_ECH_REJECTED`:
167
+ `err.retryConfigs` is the server's new ECHConfigList (given only when the server was
168
+ authenticated), or `null` — the server has turned ECH off. There is no automatic
169
+ fallback to a hello in the clear: that is the application's decision.
170
+
171
+ **What the client does.** Two hellos go in one:
172
+ - **Inner:** the real one, sealed with HPKE.
173
+ - **Outer:** in the clear, naming the public name.
174
+
175
+ Extensions the two share, the `key_share` above all, are sent once: the inner hello
176
+ names them (`ech_outer_extensions`). The inner hello is padded so its length tells
177
+ little about the name. The server says which hello it answered with 8 bytes of its
178
+ random (or of its HelloRetryRequest) that only a holder of the inner hello can compute.
179
+
180
+ **Also covered:**
181
+ - HelloRetryRequest
182
+ - A resumed session inside the inner hello
183
+ - `echGrease: true`: an ECH extension no server can decrypt, as Chrome and Firefox send, so that clients using ECH do not stand out.
184
+
185
+ **Key rotation** — the new key in DNS first, then on the server, the old one kept with
186
+ `retry: false` until the old record's TTL has passed:
187
+
188
+ ```js
189
+ server.setEchKeys({ keys: [
190
+ { config: next.config, privateKey: next.privateKey },
191
+ { config: prev.config, privateKey: prev.privateKey, retry: false },
192
+ ] });
193
+ ```
194
+
195
+ A key is not certified by anyone — its trust comes from DNS. A substituted key could
196
+ reveal which name was asked for, never let anyone impersonate the site: the
197
+ certificate is still checked against that name. TLS 1.3 only, not DTLS.
198
+
199
+ ### OCSP Stapling
200
+
201
+ ```js
202
+ // Server — as node:tls: asked when a client requests status. Fetch and cache
203
+ // responses ahead of time; the handshake waits for the callback.
204
+ server.on('OCSPRequest', (certificate, issuer, callback) => {
205
+ callback(null, cachedOcspResponseFor(certificate)); // DER Buffer, or null for none
206
+ });
207
+
208
+ // Client
209
+ const socket = tls.connect({ host, port, requestOCSP: true });
210
+ socket.on('OCSPResponse', (response) => { /* DER Buffer — check it, e.g. with the `ocsp` package */ });
211
+ ```
212
+
125
213
  ## 📚 API
126
214
 
127
215
  ### Module-Level Functions
@@ -137,7 +225,22 @@ tls.getCiphers() // ['tls_aes_128_gcm_sha256',
137
225
  tls.DEFAULT_MIN_VERSION // 'TLSv1.2'
138
226
  tls.DEFAULT_MAX_VERSION // 'TLSv1.3'
139
227
  tls.DEFAULT_CIPHERS // 'TLS_AES_256_GCM_SHA384:...'
228
+ tls.rootCertificates // the shipped CA bundle (~144 PEMs)
229
+ tls.getCACertificates('default') // same, as a copy
230
+ tls.setDefaultCACertificates([...]) // replace it for later connections
140
231
  tls.DEFAULT_ECDH_CURVE // 'auto'
232
+
233
+ // node:tls's option strings → the code lists lemon-tls works with (what
234
+ // createServer / connect do with `ciphers`, `ecdhCurve`, `sigalgs`)
235
+ tls.parseCiphers('TLS_AES_256_GCM_SHA384:ECDHE-RSA-AES128-GCM-SHA256') // [0x1302, 0xc02f]
236
+ tls.parseEcdhCurve('X25519:P-256') // [0x001d, 0x0017]; 'auto' → null (the default)
237
+ tls.parseSigalgs('ECDSA+SHA256:rsa_pss_rsae_sha256') // [0x0403, 0x0804]
238
+ tls.certificateObject(der) // a DER certificate as getPeerCertificate() describes it
239
+
240
+ // Encrypted Client Hello (RFC 9849)
241
+ tls.generateEchKey({ publicName, configId?, maxNameLength?, kem? })
242
+ // → { config, privateKey, configList, configListBase64 }
243
+ tls.checkEchKeys({ keys: [{ config, privateKey, retry }] }) // throws, saying what is wrong
141
244
  ```
142
245
 
143
246
  ### `tls.Server` (returned by `createServer`)
@@ -148,6 +251,8 @@ server.close(callback?) // Stop accepting new connect
148
251
  server.setSecureContext({ key, cert }) // Runtime cert rotation (Let's Encrypt, etc.)
149
252
  server.getTicketKeys() // 48-byte Buffer (ticket encryption keys)
150
253
  server.setTicketKeys(keys) // For clustered deployments
254
+ server.getEchKeys() // the current `ech` option
255
+ server.setEchKeys(ech) // ECH key rotation; a bad key throws, the old set stays
151
256
  server.address() // { port, family, address }
152
257
  ```
153
258
 
@@ -160,6 +265,7 @@ server.address() // { port, family, address }
160
265
  | `keylog` | `(line, socket)` | SSLKEYLOGFILE-format line (for Wireshark) |
161
266
  | `newSession` | `(id, data, cb)` | Store a TLS 1.2 Session ID (for custom session stores) |
162
267
  | `resumeSession` | `(id, cb)` | Look up a TLS 1.2 Session ID (for custom session stores) |
268
+ | `OCSPRequest` | `(certificate, issuer, callback)` | A client asked for the certificate's OCSP status (as `node:tls`). `certificate` and `issuer` are DER Buffers (`issuer` `null` when the chain has none); `callback(null, response)` staples a DER OCSP response, `callback(null, null)` none, `callback(err)` fails the handshake. Without a listener nothing is stapled |
163
269
  | `error` / `close` | — | Transport-level |
164
270
 
165
271
  ### `TLSSocket`
@@ -185,6 +291,9 @@ High-level wrapper extending `stream.Duplex`, API-compatible with Node.js [`tls.
185
291
  | `requestCert` | boolean | Request client certificate (server) |
186
292
  | `cert` | Buffer/string | Client certificate PEM (for mTLS) |
187
293
  | `key` | Buffer/string | Client private key PEM (for mTLS) |
294
+ | `ciphers`, `ecdhCurve`, `sigalgs` | string | As `node:tls` takes them (`createServer` / `connect`); a value naming nothing usable throws. `sigalgs` limits what the endpoint signs with, as well as what it offers and accepts |
295
+ | `requestOCSP` | boolean | Client: ask for the server's stapled OCSP response; it comes as `'OCSPResponse'` |
296
+ | `sessionTimeout` | number | Server: seconds a session stays good — TLS 1.2 Session IDs, and the ticket lifetime unless `ticketLifetime` is given |
188
297
 
189
298
  **LemonTLS-only (not available in Node.js):**
190
299
 
@@ -197,9 +306,72 @@ High-level wrapper extending `stream.Duplex`, API-compatible with Node.js [`tls.
197
306
  | `maxRecordSize` | number | Max plaintext per TLS record (default: 16384) |
198
307
  | `allowedCipherSuites` | number[] | Whitelist - only these ciphers are offered |
199
308
  | `pins` | string[] | Certificate pinning: `['sha256/AAAA...']` |
200
- | `handshakeTimeout` | number | Abort handshake after N ms |
309
+ | `handshakeTimeout` | number | Abort handshake after N ms. `createServer`: `120000` by default, as in Node |
201
310
  | `maxHandshakeSize` | number | Max handshake bytes - DoS protection |
202
311
  | `certificateCallback` | function | Dynamic cert selection: `(info, cb) => cb(null, ctx)` |
312
+ | `ech` | object / Buffer / string | Encrypted Client Hello (RFC 9849). **Server:** `{ keys: [{ config, privateKey, retry }] }` — from `generateEchKey()`; `retry: false` keeps a key for decrypting without offering it in `retry_configs`. **Client:** the server's ECHConfigList, bytes or base64 (the `ech=` of its HTTPS record); the hello then offers TLS 1.3 only. A malformed list, or one with no config this client can use, throws |
313
+ | `echGrease` | boolean | Client, without `ech`: send GREASE ECH (RFC 9849 §6.2), as browsers do (default `false`) |
314
+ | `certificateCompression` | boolean / string[] | RFC 8879: `['brotli', 'zlib', 'zstd']`, `true` (all this Node has) or `false`. Server: what it compresses its chain with, for a client that offers it (default: all). Client: what it offers (default: none) |
315
+ | `ticketLifetime` | number | Server: seconds a TLS 1.3 ticket is good for (default `7200`, capped at 7 days, RFC 8446 §4.6.1) |
316
+ | `ticketData` | function | Server: `(resumed) => data` — JSON-safe data (≤ 256 bytes serialized) carried inside each ticket, encrypted with `ticketKeys`; `resumed` is the data of the ticket this connection resumed with (`null` on a full handshake). It comes back in `getTicketInfo()` on the connection that presents the ticket |
317
+
318
+ **Client certificates and resumption.** A resumed handshake carries no certificate, so the
319
+ ticket (or TLS 1.2 session) keeps the client's: on a resumed connection `authorized`,
320
+ `authorizationError` and `getPeerCertificate()` answer as on the full handshake that issued it.
321
+ A server that requires an authorized client (`requestCert` + `rejectUnauthorized`) resumes
322
+ only a ticket that says it had one — anything else is a full handshake, where the
323
+ certificate is asked for again.
324
+
325
+ ### Supported cipher suites
326
+
327
+ Sixteen suites are implemented; three are offered by default. Anything else has
328
+ to be asked for explicitly via `local_supported_cipher_suites` or narrowed with
329
+ `allowedCipherSuites` — the default list stays small on purpose, because every
330
+ extra suite widens the offer and changes your JA3/JA4.
331
+
332
+ **TLS 1.3** — offered by default
333
+
334
+ | Code | Suite | AEAD | Hash |
335
+ |---|---|---|---|
336
+ | `0x1301` | `TLS_AES_128_GCM_SHA256` | AES-128-GCM | SHA-256 |
337
+ | `0x1302` | `TLS_AES_256_GCM_SHA384` | AES-256-GCM | SHA-384 |
338
+ | `0x1303` | `TLS_CHACHA20_POLY1305_SHA256` | ChaCha20-Poly1305 | SHA-256 |
339
+
340
+ **TLS 1.2 — AEAD** (opt in)
341
+
342
+ | Code | Suite | Key exchange | AEAD |
343
+ |---|---|---|---|
344
+ | `0xC02B` | `TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256` | ECDHE_ECDSA | AES-128-GCM |
345
+ | `0xC02C` | `TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384` | ECDHE_ECDSA | AES-256-GCM |
346
+ | `0xC02F` | `TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256` | ECDHE_RSA | AES-128-GCM |
347
+ | `0xC030` | `TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384` | ECDHE_RSA | AES-256-GCM |
348
+ | `0xCCA8` | `TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256` | ECDHE_RSA | ChaCha20-Poly1305 |
349
+ | `0xCCA9` | `TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256` | ECDHE_ECDSA | ChaCha20-Poly1305 |
350
+ | `0xCCAA` | `TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256` | DHE_RSA | ChaCha20-Poly1305 |
351
+ | `0x009C` | `TLS_RSA_WITH_AES_128_GCM_SHA256` | RSA | AES-128-GCM |
352
+ | `0x009D` | `TLS_RSA_WITH_AES_256_GCM_SHA384` | RSA | AES-256-GCM |
353
+
354
+ **TLS 1.2 — CBC** (opt in; legacy, no forward secrecy on the RSA ones)
355
+
356
+ | Code | Suite | Key exchange | Cipher | MAC |
357
+ |---|---|---|---|---|
358
+ | `0xC013` | `TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA` | ECDHE_RSA | AES-128-CBC | SHA-1 |
359
+ | `0xC014` | `TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA` | ECDHE_RSA | AES-256-CBC | SHA-1 |
360
+ | `0x003C` | `TLS_RSA_WITH_AES_128_CBC_SHA256` | RSA | AES-128-CBC | SHA-256 |
361
+ | `0x003D` | `TLS_RSA_WITH_AES_256_CBC_SHA256` | RSA | AES-256-CBC | SHA-256 |
362
+
363
+ The `0xCCAA` / `0x009C` / `0x009D` suites and the CBC block carry no forward
364
+ secrecy with an RSA key exchange; they exist for interoperability with older
365
+ peers, not as a recommendation.
366
+
367
+ ChaCha20-Poly1305 uses a full 12-byte IV with no explicit nonce (RFC 7905),
368
+ unlike GCM — the registry records that per suite.
369
+
370
+ `prioritizeChaCha: true` moves the ChaCha suites ahead of AES. Note this
371
+ changes your **JA3** but not your **JA4**: JA4 sorts the cipher list before
372
+ hashing, so only *which* suites you offer matters there, never their order.
373
+
374
+
203
375
 
204
376
  #### Events
205
377
 
@@ -210,9 +382,11 @@ High-level wrapper extending `stream.Duplex`, API-compatible with Node.js [`tls.
210
382
  | `session` | `(Buffer)` | **Opaque session blob** — pass back to `connect({ session })` to resume |
211
383
  | `keyUpdate` | `(direction)` | Traffic keys refreshed: `'send'` or `'receive'` |
212
384
  | `keylog` | `(Buffer)` | SSLKEYLOGFILE-format line (for Wireshark) |
213
- | `clienthello` | `(raw, parsed)` | ClientHello received (server side). Fires **synchronously before the ServerHello is built** — use it for JA3 fingerprinting, or to inspect the client's offered extensions (`parsed.extensions` / `getRemoteExtension`) and answer them via `set_context({ local_extensions })` (e.g. DTLS-SRTP `use_srtp`) |
385
+ | `secure` | `()` | Handshake complete. Fires on **both** sides and on sockets built with `new TLSSocket()` — unlike `secureConnect`, which Node does not emit there. The signal to use when wrapping an existing socket for STARTTLS |
386
+ | `clienthello` | `(raw, parsed)` | ClientHello received (server side). Fires **synchronously before the ServerHello is built** — use it for JA4/JA5 fingerprinting, or to inspect the client's offered extensions (`parsed.extensions` / `getRemoteExtension`) and answer them via `set_context({ local_extensions })` (e.g. DTLS-SRTP `use_srtp`) |
214
387
  | `handshakeMessage` | `(type, raw, parsed)` | Every handshake message (debugging) |
215
388
  | `certificateRequest` | `(msg)` | Server requested a client certificate |
389
+ | `OCSPResponse` | `(Buffer)` | Client, with `requestOCSP`: the server's stapled OCSP response (DER) |
216
390
  | `error` | `(Error)` | TLS or transport error |
217
391
  | `close` | `()` | Connection closed |
218
392
 
@@ -243,7 +417,7 @@ High-level wrapper extending `stream.Duplex`, API-compatible with Node.js [`tls.
243
417
  | `socket.authorized` | `true` if peer certificate is valid |
244
418
  | `socket.authorizationError` | Error string or `null` |
245
419
  | `socket.alpnProtocol` | Negotiated ALPN protocol or `false` |
246
- | `socket.servername` | SNI value (string or `false`) |
420
+ | `socket.servername` | SNI value (string or `false`). Server with ECH accepted: the name that was hidden; rejected: the public name |
247
421
  | `socket.encrypted` | Always `true` |
248
422
  | `socket.remoteAddress` / `.remotePort` | Peer address (delegated to transport) |
249
423
  | `socket.setNoDelay()` / `.setKeepAlive()` / `.setTimeout()` | Transport delegation |
@@ -257,7 +431,27 @@ High-level wrapper extending `stream.Duplex`, API-compatible with Node.js [`tls.
257
431
  | `socket.session` | Access the underlying `TLSSession` (low-level state machine) |
258
432
  | `socket.isResumed` | Alias for `isSessionReused()` |
259
433
  | `socket.handshakeDuration` | Handshake time in ms |
260
- | `socket.getJA3()` | `{ hash, raw }` - JA3 fingerprint (server-side) |
434
+ | `socket.echStatus` | Encrypted Client Hello. Server: `null` (not offered), `'accepted'`, `'rejected'` (the handshake ran as the public name and `retry_configs` went out). Client: `null` (no ECH), `'grease'`, `'accepted'`, `'rejected'` |
435
+ | `socket.echAccepted` | `echStatus === 'accepted'` |
436
+ | `socket.getTicketInfo()` | `{ data, resumed, offered, state }` — the `ticketData` of the ticket this connection resumed with (server), whether it resumed, whether a ticket was offered |
437
+ | `useDefaultCA` | When no `ca` is given, chain against the platform trust store instead of authorizing everything (default `true`). **Breaking change**: a self-signed or private-CA peer that previously connected with no `ca` configured now fails with `UNABLE_TO_GET_ISSUER_CERT`. That is the point — the old behaviour reported a connection as verified while verifying nothing, with `rejectUnauthorized` defaulting to true. Set `false` to opt back out explicitly. DTLS defaults to `false`, since WebRTC peers are self-signed by design and authenticated by SDP fingerprint |
438
+ | `allowHalfOpen` | When `false` (the Node default, and now ours) the writable side ends automatically once the peer stops writing. This was hardcoded `true`, the opposite of Node — code expecting the documented behaviour was left holding connections open |
439
+ | `checkServerIdentity(hostname, cert)` | Replaces the built-in RFC 6125 identity check. Return `undefined` to accept, an `Error` to reject. Runs **after** chain verification, as Node documents — a hook that only checks a pin cannot rescue a chain that failed |
440
+ | `socket` | Run the handshake over an existing `Duplex` instead of dialling out. `host`/`port` are then ignored and nothing is connected for you, so the caller can speak cleartext first and upgrade — STARTTLS for SMTP, IMAP, PostgreSQL |
441
+ | `secureContext` | A context from `createSecureContext()`, reused across connections instead of re-parsing the same PEM each time. Explicit `secureContext` wins over `key`/`cert` |
442
+ | `ALPNCallback` | `({ servername, protocols }) => protocol \| undefined`. Server-side, synchronous. Returning `undefined` refuses with `no_application_protocol`. Mutually exclusive with `ALPNProtocols` |
443
+ | `grease` | Insert GREASE (RFC 8701) reserved values into the ClientHello, as browsers do (default `false`). Covers cipher suites, extension types, supported groups, signature algorithms, supported versions, ALPN, `psk_key_exchange_modes` and a dummy `key_share` entry. **Changes no fingerprint** — JA3 and JA4 both strip GREASE — but its *absence* reads as "not a browser". Any list you already GREASE'd by hand is left alone, so `grease: true` never collides with manual configuration. Safe against public HTTPS servers; leave off for DTLS/WebRTC peers until tested |
444
+ | `permuteExtensions` | Shuffle ClientHello extension order per connection, as Chrome 110+ does (default `false`). Changes your **JA3** on every handshake; leaves your **JA4** byte-for-byte identical, since JA4 sorts before hashing. Order is chosen once and replayed on a post-HRR retry, per RFC 8446 §4.1.2 |
445
+ | `socket.getJA4(opts?)` | The client's **JA4** fingerprint, `{ hash, raw }` (`t13d1516h2_8daaf6152771_e5627ece308c`). Available in either role, since both hellos are visible to both peers; `null` before the ClientHello has been seen. Pass `{ protocol: 'q' }` when driving the session from QUIC |
446
+ | `socket.getJA5(opts?)` | **JA5**: JA4 without the extensions that describe the connection rather than the client (`pre_shared_key`, `session_ticket`, `early_data`, `padding`, `cookie`, `psk_key_exchange_modes`) — the same client build has one JA5 whether it resumed or not — and with the `supported_groups` it offers, which JA4 does not look at. `{ hash, raw }` |
447
+ | `socket.getJA4S(opts?)` | The server's **JA4S** fingerprint, `{ hash, raw }` — `null` until a ServerHello exists |
448
+ | `socket.getFinished()` / `getPeerFinished()` | The Finished verify_data, ours and the peer's — the `tls-unique` channel binding of RFC 5929. `undefined` before the handshake |
449
+ | `socket.isSessionReused()` | Whether this connection resumed an earlier session (same value as the `isResumed` property; Node spells it as a method) |
450
+ | `socket.address()` | `{ port, family, address }` of the underlying transport, `{}` when not connected |
451
+ | `socket.getEphemeralKeyInfo()` | `{ type, name, size }` for the negotiated key agreement. `null` on a server socket, as in Node |
452
+ | `server.injectConnection(stream)` | Feed an arbitrary `Duplex` into the server as if it had just connected. Node does this by emitting `'connection'` by hand; here it is a method, because the server emits that event itself and a self-listener would re-enter on every real connection |
453
+ | `server.addContext(host, ctx)` | Register a certificate for an SNI host or wildcard (`'*.example.com'`, or `'*'` as catch-all). Takes a `createSecureContext()` result or plain `{ key, cert }`. Exact matches beat wildcards; re-registering a host replaces it. Each context is compiled **once** — the hand-written `SNICallback` equivalent usually recompiles per connection, and often reads the cert off disk on the handshake path |
454
+ | `socket.setKeyCert(context)` | Swap the certificate and key for this one connection. Takes a `createSecureContext()` result or a plain `{ key, cert }`. Pairs with `ALPNCallback`, where the protocol is known but `SNICallback` has already run |
261
455
  | `socket.getSharedSecret()` | ECDHE shared secret (Buffer) |
262
456
  | `socket.getNegotiationResult()` | `{ version, cipher, group, sni, alpn, resumed, helloRetried, ... }` |
263
457
  | `socket.rekeySend()` | Refresh outgoing encryption keys (TLS 1.3) |
@@ -306,15 +500,27 @@ session.on('secureConnect', () => {
306
500
  session.requestKeyUpdate(true); // true = request peer to update too
307
501
  session.on('keyUpdate', ({ direction, secret }) => { /* ... */ });
308
502
 
503
+ // Tickets carrying the protocol layer's own state (server, TLS 1.3) —
504
+ // apart from the application's ticketData, ≤ 128 bytes JSON. A QUIC stack
505
+ // keeps its path measurements here; sendTicket() issues another ticket
506
+ // now, after the handshake (OpenSSL's SSL_new_session_ticket), carrying it.
507
+ session.setTicketState({ rtt: 120, bw: 1250000 });
508
+ session.sendTicket();
509
+ // …and on the connection that resumes with it:
510
+ session.getTicketInfo(); // { data, resumed: true, offered: true, state: { rtt: 120, bw: 1250000 } }
511
+
309
512
  // PSK callback - full control over ticket validation (server)
310
513
  session.on('psk', (identity, callback) => {
311
514
  const psk = myTicketStore.lookup(identity);
312
515
  callback(psk ? { psk, cipher: 0x1301 } : null);
313
516
  });
314
517
 
315
- // JA3 fingerprinting (server)
518
+ // Fingerprinting (server) — fires before the ServerHello is built, so this is
519
+ // where you drop a connection without spending a full handshake on it.
316
520
  session.on('clienthello', (raw, parsed) => {
317
- console.log(session.getJA3()); // { hash: 'abc...', raw: '769,47-53,...' }
521
+ console.log(session.getJA4().hash); // 't13d521100_b262b3658495_8e6e362c5eac'
522
+ // getJA4S() is still null here — no ServerHello exists yet. Ask again
523
+ // later in the session and it will be there.
318
524
  });
319
525
  ```
320
526
 
@@ -469,10 +675,20 @@ tls.createServer({
469
675
  socket.on('keylog', (line) => fs.appendFileSync('keys.log', line));
470
676
  // Wireshark: Edit → Preferences → TLS → Pre-Master-Secret log filename → keys.log
471
677
 
472
- // JA3 fingerprinting (server-side bot detection)
678
+ // Fingerprinting — server-side bot detection
473
679
  server.on('secureConnection', (socket) => {
474
- const ja3 = socket.getJA3();
475
- console.log(ja3.hash); // 'e7d705a3286e19ea42f587b344ee6865'
680
+ const ja4 = socket.getJA4();
681
+ console.log(ja4.hash); // 't13d1516h2_8daaf6152771_e5627ece308c' — the client
682
+ console.log(ja4.raw); // unhashed form: shows which ciphers actually differ
683
+ console.log(socket.getJA5().hash); // the same client, resumed or not
684
+ console.log(socket.getJA4S().hash); // 't130200_1301_a56c5b993250' — how WE look to them
685
+ });
686
+
687
+ // Client side: the same calls, the same values
688
+ const socket = connect({ host: 'example.com', port: 443 });
689
+ socket.on('secureConnect', () => {
690
+ console.log(socket.getJA4().hash); // how this library looks to the server
691
+ console.log(socket.getJA4S().hash); // which server stack answered
476
692
  });
477
693
 
478
694
  // Full negotiation result
@@ -499,6 +715,7 @@ LemonTLS is verified against real-world TLS implementations:
499
715
  | **Node.js `tls`** | Client / Server | 1.2 / 1.3 | Bidirectional interop + session resumption |
500
716
  | **openssl s_client** | Client | 1.2 / 1.3 | All supported ciphers & groups |
501
717
  | **msquic** | (via QUICO) | 1.3 | HRR + P-384 + AES-256-GCM-SHA384 tested |
718
+ | **Go `crypto/tls`** | Client / Server | 1.3 | **ECH** both ways: accepted, `retry_configs` then accepted with them, HelloRetryRequest; GREASE ECH (our client), resumption inside ECH (our server) |
502
719
 
503
720
  ## ⚡ Performance
504
721
 
@@ -549,6 +766,12 @@ For a pure-JavaScript implementation with zero native dependencies, this is with
549
766
  | ✅ | Zero dependencies - `node:crypto` only |
550
767
  | ✅ | **72 automated tests** (compat, resumption, data transfer) |
551
768
  | ✅ | Browser-tested (Chrome) |
769
+ | ✅ | Encrypted Client Hello (RFC 9849) — server and client, GREASE ECH |
770
+ | ✅ | HPKE (RFC 9180) — all modes, five KEMs |
771
+ | ✅ | Hybrid post-quantum key exchange — X25519MLKEM768 |
772
+ | ✅ | Certificate compression (RFC 8879) — brotli, zlib, zstd |
773
+ | ✅ | OCSP stapling — server (`'OCSPRequest'`) and client (`requestOCSP`) |
774
+ | ✅ | Application data in session tickets (`ticketData`, `getTicketInfo()`) |
552
775
 
553
776
  ### ⏳ Planned
554
777
 
@@ -557,7 +780,6 @@ For a pure-JavaScript implementation with zero native dependencies, this is with
557
780
  | ⏳ | Loss detection + PTO (for QUIC integration) | Buffer ready, needs timers |
558
781
  | ⏳ | 0-RTT Early Data | Risky (replay attacks), low priority |
559
782
  | ⏳ | Full certificate chain validation | Including CA/revocation checks |
560
- | ⏳ | OCSP stapling | Rare in modern web, low priority |
561
783
  | ⏳ | Fuzz testing | Security hardening |
562
784
 
563
785
  ### Compatibility Test Summary
@@ -599,15 +821,20 @@ src/
599
821
  crypto.js - key schedule (HKDF, PRF, resumption primitives)
600
822
  compat.js - Node.js tls API wrappers (connect, createServer,
601
823
  checkServerIdentity, Server)
824
+ node_options.js - node:tls option strings (ciphers, ecdhCurve, sigalgs)
825
+ → code-point lists
602
826
  secure_context.js - PEM/DER cert/key loading
603
- utils.js - array helpers
827
+ utils.js - array helpers, certificateObject
604
828
  dtls_session.js - DTLS state machine
605
829
  dtls_socket.js - DTLS socket wrapper (UDP transport)
606
830
  session/
607
831
  signing.js - signature scheme selection + signing
608
832
  ecdh.js - X25519 / P-256 / P-384 key exchange
609
833
  message.js - high-level message build/parse
610
- ticket.js - TLS 1.2 session ticket encryption
834
+ ticket.js - session ticket encryption
835
+ ech.js - Encrypted Client Hello: configs, keys, both sides
836
+ hpke.js - HPKE (RFC 9180)
837
+ cert_compression.js - certificate compression (RFC 8879)
611
838
  ```
612
839
 
613
840
  ## 🤝 Contributing
@@ -630,6 +857,12 @@ You can support ongoing development via [GitHub Sponsors](https://github.com/spo
630
857
  * [RFC 6125 – Hostname Verification](https://datatracker.ietf.org/doc/html/rfc6125)
631
858
  * [RFC 5705 – Exported Keying Material](https://datatracker.ietf.org/doc/html/rfc5705)
632
859
  * [RFC 5077 – Stateless Session Resumption (TLS 1.2 tickets)](https://datatracker.ietf.org/doc/html/rfc5077)
860
+ * [RFC 9849 – TLS Encrypted Client Hello](https://datatracker.ietf.org/doc/html/rfc9849)
861
+ * [RFC 9180 – Hybrid Public Key Encryption (HPKE)](https://datatracker.ietf.org/doc/html/rfc9180)
862
+ * [RFC 9460 – SVCB and HTTPS DNS records](https://datatracker.ietf.org/doc/html/rfc9460)
863
+ * [RFC 8879 – TLS Certificate Compression](https://datatracker.ietf.org/doc/html/rfc8879)
864
+ * [RFC 8701 – GREASE](https://datatracker.ietf.org/doc/html/rfc8701)
865
+ * [draft-ietf-tls-ecdhe-mlkem – Hybrid ML-KEM key agreement](https://datatracker.ietf.org/doc/draft-ietf-tls-ecdhe-mlkem/)
633
866
 
634
867
  ## 📜 License
635
868
 
package/index.d.ts CHANGED
@@ -31,16 +31,39 @@ export interface PeerCertificate {
31
31
  }
32
32
 
33
33
  export interface EphemeralKeyInfo {
34
- type: 'X25519' | 'ECDH' | 'DH';
34
+ /** 'TLSGroup' for a group that is not a plain curve, as Node reports it —
35
+ * X25519MLKEM768 among them (no size). Client side only; {} on a server. */
36
+ type: 'X25519' | 'ECDH' | 'DH' | 'TLSGroup';
35
37
  name?: string;
36
- size: number;
38
+ size?: number;
37
39
  }
38
40
 
39
- export interface JA3Result {
41
+ /** A TLS fingerprint: `hash` is the fingerprint, `raw` what was hashed. */
42
+ export interface FingerprintResult {
40
43
  hash: string;
41
44
  raw: string;
42
45
  }
43
46
 
47
+ export interface FingerprintOptions {
48
+ /** Transport character, for a caller driving the session from a transport
49
+ * it cannot see: 't' TCP (default), 'q' QUIC, 'd' DTLS. */
50
+ protocol?: 't' | 'q' | 'd';
51
+ }
52
+
53
+ /**
54
+ * Server: this connection's session ticket, from the first connection on.
55
+ * `data` is what the ticketData option put in the ticket — the one this
56
+ * connection resumed with, or, on a full handshake, the one made for the
57
+ * ticket issued to it (null until the server's Finished is sent).
58
+ * `offered && !resumed`: the client presented a ticket that was turned away
59
+ * (expired, issued before a restart, no compatible cipher).
60
+ */
61
+ export interface TicketInfo {
62
+ data: any;
63
+ resumed: boolean;
64
+ offered: boolean;
65
+ }
66
+
44
67
  export interface NegotiationResult {
45
68
  version: number | null;
46
69
  versionName: string | null;
@@ -91,6 +114,14 @@ export interface TLSSocketOptions {
91
114
  rejectUnauthorized?: boolean;
92
115
  ca?: Buffer | string;
93
116
  ticketKeys?: Buffer;
117
+ /**
118
+ * Server: what to carry inside each session ticket issued. Called once per
119
+ * connection as ticketData(resumed) — `resumed` is the data of the ticket
120
+ * this connection resumed with, null on a full handshake. The return value
121
+ * (JSON-safe, at most 256 bytes serialized) rides in the ticket, encrypted
122
+ * with ticketKeys; read it back with getTicketInfo().
123
+ */
124
+ ticketData?: (resumed: any) => any;
94
125
  /**
95
126
  * Opaque serialized session from a previous 'session' event.
96
127
  * Pass this back to resume a TLS 1.2/1.3 session.
@@ -100,6 +131,21 @@ export interface TLSSocketOptions {
100
131
  cert?: Buffer | string;
101
132
  key?: Buffer | string;
102
133
 
134
+ // TCP options, as in node:net. connect() passes them (and the rest of
135
+ // net.connect's: localAddress, localPort, family, lookup, timeout) to
136
+ // net.connect; createServer() applies noDelay/keepAlive to every
137
+ // connection it accepts. Defaults as in Node: off.
138
+ /** Disable Nagle's algorithm (TCP_NODELAY). Default: false. */
139
+ noDelay?: boolean;
140
+ /** Enable TCP keep-alive. Default: false. */
141
+ keepAlive?: boolean;
142
+ /** Milliseconds before the first keep-alive probe. Default: 0. */
143
+ keepAliveInitialDelay?: number;
144
+ localAddress?: string;
145
+ localPort?: number;
146
+ family?: number;
147
+ timeout?: number;
148
+
103
149
  // LemonTLS-only options
104
150
  noTickets?: boolean;
105
151
  sessionTickets?: boolean;
@@ -179,7 +225,17 @@ export class TLSSocket extends Duplex {
179
225
  /** Returns the underlying TLSSession for low-level access. */
180
226
  session: TLSSession;
181
227
  readonly handshakeDuration: number | null;
182
- getJA3(): JA3Result | null;
228
+ /** JA4 of the ClientHello — the published format, what fingerprint
229
+ * databases match. Changes when a client resumes (pre_shared_key). */
230
+ getJA4(opts?: FingerprintOptions): FingerprintResult | null;
231
+ /** JA5 of the ClientHello (ntop's draft): as JA4 without the extensions that
232
+ * vary per connection, plus supported_groups — the same for a client
233
+ * whether it resumed or not. */
234
+ getJA5(opts?: FingerprintOptions): FingerprintResult | null;
235
+ /** JA4S of the ServerHello. */
236
+ getJA4S(opts?: FingerprintOptions): FingerprintResult | null;
237
+ /** Server: this connection's session ticket — see TicketInfo. */
238
+ getTicketInfo(): TicketInfo;
183
239
  getSharedSecret(): Buffer | null;
184
240
  getNegotiationResult(): NegotiationResult | null;
185
241
  rekeySend(): void;
@@ -246,6 +302,8 @@ export interface TLSSessionOptions {
246
302
  ALPNProtocols?: string[];
247
303
  SNICallback?: (servername: string, cb: (err: Error | null, ctx: SecureContext | null) => void) => void;
248
304
  ticketKeys?: Buffer;
305
+ /** See TLSSocketOptions.ticketData. */
306
+ ticketData?: (resumed: any) => any;
249
307
  session?: Buffer | Uint8Array | SessionTicketData;
250
308
  psk?: any;
251
309
  rejectUnauthorized?: boolean;
@@ -295,6 +353,8 @@ export class TLSSession extends EventEmitter {
295
353
  getCipher(): number | null;
296
354
  getALPN(): string | null;
297
355
  getPeerCertificate(): Array<{ cert: Buffer }> | null;
356
+ /** Our own certificate chain — the one this connection presents. */
357
+ getCertificate(): Array<{ cert: Buffer }> | null;
298
358
  getTrafficSecrets(): TrafficSecrets;
299
359
  getHandshakeSecrets(): HandshakeSecrets;
300
360
  exportKeyingMaterial(length: number, label: string, context: Uint8Array): Uint8Array;
@@ -302,7 +362,11 @@ export class TLSSession extends EventEmitter {
302
362
  getPeerFinished(): Buffer | null;
303
363
  getSharedSecret(): Buffer | null;
304
364
  getNegotiationResult(): NegotiationResult;
305
- getJA3(): JA3Result | null;
365
+ getJA4(opts?: FingerprintOptions): FingerprintResult | null;
366
+ getJA5(opts?: FingerprintOptions): FingerprintResult | null;
367
+ getJA4S(opts?: FingerprintOptions): FingerprintResult | null;
368
+ /** Server: this connection's session ticket — see TicketInfo. */
369
+ getTicketInfo(): TicketInfo;
306
370
  requestKeyUpdate(requestPeer?: boolean): void;
307
371
 
308
372
  // Events
@@ -338,6 +402,19 @@ export function createServer(
338
402
 
339
403
  export function getCiphers(): string[];
340
404
 
405
+ /** Node-compat: the shipped CA bundle (PEM strings) — tls.rootCertificates. */
406
+ export const rootCertificates: readonly string[];
407
+ /**
408
+ * Node-compat: tls.getCACertificates([type]). 'default' and 'bundled' return
409
+ * the CA set clients verify against when given no `ca` (the shipped bundle,
410
+ * or what setDefaultCACertificates put in its place); 'extra' and 'system'
411
+ * are not sourced here and return [].
412
+ */
413
+ export function getCACertificates(type?: 'default' | 'bundled' | 'extra' | 'system'): string[];
414
+ /** Node-compat: tls.setDefaultCACertificates(certs) — replaces that set for
415
+ * every later connection that passes no `ca`. Throws if not an array. */
416
+ export function setDefaultCACertificates(certs: string[]): void;
417
+
341
418
  /**
342
419
  * Validates that the peer certificate matches the hostname (RFC 6125).
343
420
  * Returns undefined on success, or an Error on mismatch.
@@ -363,6 +440,14 @@ export declare const crypto: {
363
440
  hkdf_expand: (hash: string, prk: Uint8Array, info: Uint8Array, length: number) => Uint8Array;
364
441
  hkdf_expand_label: (hash: string, secret: Uint8Array, label: string, context: Uint8Array, length: number) => Uint8Array;
365
442
  hmac: (hash: string, key: Uint8Array, data: Uint8Array) => Uint8Array;
443
+ /** TLS 1.2 PRF (RFC 5246 §5). */
444
+ tls12_prf: (secret: Uint8Array, label: string, seed: Uint8Array, length: number, hash: string) => Uint8Array;
445
+ /** RFC 5705 exporter over a TLS 1.2 master secret. */
446
+ tls12_exporter: (hash: string, masterSecret: Uint8Array, label: string, clientRandom: Uint8Array,
447
+ serverRandom: Uint8Array, context: Uint8Array | null, length: number) => Uint8Array;
448
+ /** RFC 8446 §7.5 exporter; labelPrefix defaults to TLS 1.3's ('tls13 '). */
449
+ tls13_exporter: (hash: string, exporterMasterSecret: Uint8Array, label: string,
450
+ context: Uint8Array | null, length: number, labelPrefix?: string) => Uint8Array;
366
451
  };
367
452
 
368
453
  export declare const wire: any;
@@ -398,6 +483,9 @@ declare const _default: {
398
483
  createServer: typeof createServer;
399
484
  getCiphers: typeof getCiphers;
400
485
  checkServerIdentity: typeof checkServerIdentity;
486
+ getCACertificates: typeof getCACertificates;
487
+ setDefaultCACertificates: typeof setDefaultCACertificates;
488
+ rootCertificates: typeof rootCertificates;
401
489
  DEFAULT_MIN_VERSION: string;
402
490
  DEFAULT_MAX_VERSION: string;
403
491
  DEFAULT_CIPHERS: string;
@@ -411,4 +499,4 @@ declare const _default: {
411
499
  connectDTLS: typeof connectDTLS;
412
500
  };
413
501
 
414
- export default _default;
502
+ export default _default;