lemon-tls 0.7.0 → 0.10.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
 
@@ -324,6 +530,11 @@ Everything above has a DTLS twin. Same TLS state machine underneath, same
324
530
  methods, plus datagram-specific plumbing (retransmission flights, epoch
325
531
  handling, cookie exchange):
326
532
 
533
+ Retransmission under loss: in DTLS 1.3 an ACK that covers only part of a
534
+ flight retires only those records — the rest are resent when the timer
535
+ fires (RFC 9147 §7.1) — and a client keeps resending its final flight until
536
+ the peer ACKs it or sends protected data (§5.8.3).
537
+
327
538
  ```js
328
539
  import { DTLSSession, DTLSSocket, createDTLSServer, connectDTLS } from 'lemon-tls';
329
540
  ```
@@ -469,10 +680,20 @@ tls.createServer({
469
680
  socket.on('keylog', (line) => fs.appendFileSync('keys.log', line));
470
681
  // Wireshark: Edit → Preferences → TLS → Pre-Master-Secret log filename → keys.log
471
682
 
472
- // JA3 fingerprinting (server-side bot detection)
683
+ // Fingerprinting — server-side bot detection
473
684
  server.on('secureConnection', (socket) => {
474
- const ja3 = socket.getJA3();
475
- console.log(ja3.hash); // 'e7d705a3286e19ea42f587b344ee6865'
685
+ const ja4 = socket.getJA4();
686
+ console.log(ja4.hash); // 't13d1516h2_8daaf6152771_e5627ece308c' — the client
687
+ console.log(ja4.raw); // unhashed form: shows which ciphers actually differ
688
+ console.log(socket.getJA5().hash); // the same client, resumed or not
689
+ console.log(socket.getJA4S().hash); // 't130200_1301_a56c5b993250' — how WE look to them
690
+ });
691
+
692
+ // Client side: the same calls, the same values
693
+ const socket = connect({ host: 'example.com', port: 443 });
694
+ socket.on('secureConnect', () => {
695
+ console.log(socket.getJA4().hash); // how this library looks to the server
696
+ console.log(socket.getJA4S().hash); // which server stack answered
476
697
  });
477
698
 
478
699
  // Full negotiation result
@@ -499,6 +720,7 @@ LemonTLS is verified against real-world TLS implementations:
499
720
  | **Node.js `tls`** | Client / Server | 1.2 / 1.3 | Bidirectional interop + session resumption |
500
721
  | **openssl s_client** | Client | 1.2 / 1.3 | All supported ciphers & groups |
501
722
  | **msquic** | (via QUICO) | 1.3 | HRR + P-384 + AES-256-GCM-SHA384 tested |
723
+ | **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
724
 
503
725
  ## ⚡ Performance
504
726
 
@@ -549,6 +771,12 @@ For a pure-JavaScript implementation with zero native dependencies, this is with
549
771
  | ✅ | Zero dependencies - `node:crypto` only |
550
772
  | ✅ | **72 automated tests** (compat, resumption, data transfer) |
551
773
  | ✅ | Browser-tested (Chrome) |
774
+ | ✅ | Encrypted Client Hello (RFC 9849) — server and client, GREASE ECH |
775
+ | ✅ | HPKE (RFC 9180) — all modes, five KEMs |
776
+ | ✅ | Hybrid post-quantum key exchange — X25519MLKEM768 |
777
+ | ✅ | Certificate compression (RFC 8879) — brotli, zlib, zstd |
778
+ | ✅ | OCSP stapling — server (`'OCSPRequest'`) and client (`requestOCSP`) |
779
+ | ✅ | Application data in session tickets (`ticketData`, `getTicketInfo()`) |
552
780
 
553
781
  ### ⏳ Planned
554
782
 
@@ -557,7 +785,6 @@ For a pure-JavaScript implementation with zero native dependencies, this is with
557
785
  | ⏳ | Loss detection + PTO (for QUIC integration) | Buffer ready, needs timers |
558
786
  | ⏳ | 0-RTT Early Data | Risky (replay attacks), low priority |
559
787
  | ⏳ | Full certificate chain validation | Including CA/revocation checks |
560
- | ⏳ | OCSP stapling | Rare in modern web, low priority |
561
788
  | ⏳ | Fuzz testing | Security hardening |
562
789
 
563
790
  ### Compatibility Test Summary
@@ -599,15 +826,20 @@ src/
599
826
  crypto.js - key schedule (HKDF, PRF, resumption primitives)
600
827
  compat.js - Node.js tls API wrappers (connect, createServer,
601
828
  checkServerIdentity, Server)
829
+ node_options.js - node:tls option strings (ciphers, ecdhCurve, sigalgs)
830
+ → code-point lists
602
831
  secure_context.js - PEM/DER cert/key loading
603
- utils.js - array helpers
832
+ utils.js - array helpers, certificateObject
604
833
  dtls_session.js - DTLS state machine
605
834
  dtls_socket.js - DTLS socket wrapper (UDP transport)
606
835
  session/
607
836
  signing.js - signature scheme selection + signing
608
837
  ecdh.js - X25519 / P-256 / P-384 key exchange
609
838
  message.js - high-level message build/parse
610
- ticket.js - TLS 1.2 session ticket encryption
839
+ ticket.js - session ticket encryption
840
+ ech.js - Encrypted Client Hello: configs, keys, both sides
841
+ hpke.js - HPKE (RFC 9180)
842
+ cert_compression.js - certificate compression (RFC 8879)
611
843
  ```
612
844
 
613
845
  ## 🤝 Contributing
@@ -630,6 +862,12 @@ You can support ongoing development via [GitHub Sponsors](https://github.com/spo
630
862
  * [RFC 6125 – Hostname Verification](https://datatracker.ietf.org/doc/html/rfc6125)
631
863
  * [RFC 5705 – Exported Keying Material](https://datatracker.ietf.org/doc/html/rfc5705)
632
864
  * [RFC 5077 – Stateless Session Resumption (TLS 1.2 tickets)](https://datatracker.ietf.org/doc/html/rfc5077)
865
+ * [RFC 9849 – TLS Encrypted Client Hello](https://datatracker.ietf.org/doc/html/rfc9849)
866
+ * [RFC 9180 – Hybrid Public Key Encryption (HPKE)](https://datatracker.ietf.org/doc/html/rfc9180)
867
+ * [RFC 9460 – SVCB and HTTPS DNS records](https://datatracker.ietf.org/doc/html/rfc9460)
868
+ * [RFC 8879 – TLS Certificate Compression](https://datatracker.ietf.org/doc/html/rfc8879)
869
+ * [RFC 8701 – GREASE](https://datatracker.ietf.org/doc/html/rfc8701)
870
+ * [draft-ietf-tls-ecdhe-mlkem – Hybrid ML-KEM key agreement](https://datatracker.ietf.org/doc/draft-ietf-tls-ecdhe-mlkem/)
633
871
 
634
872
  ## 📜 License
635
873
 
package/index.cjs CHANGED
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * CJS wrapper for lemon-tls.
3
- *
4
- * On Node 22+, require('lemon-tls') works natively with the ESM module.
5
- * On Node 16-21, this wrapper provides require() support via dynamic import.
6
3
  *
7
- * Usage:
8
- * const tls = require('lemon-tls'); // sync on Node 22+
9
- * const tls = await require('lemon-tls'); // async on Node 16-21
4
+ * lemon-tls needs Node 24+ (package.json "engines"), where require() of an
5
+ * ES module is built in: require('lemon-tls') returns the module itself,
6
+ * synchronously.
7
+ *
8
+ * const tls = require('lemon-tls');
9
+ *
10
+ * The fallback below — a Promise of the module — is for a runtime or bundler
11
+ * whose require() cannot load ES modules; there, `await require('lemon-tls')`.
10
12
  */
11
13
 
12
14
  let cached = null;
@@ -21,7 +23,7 @@ try {
21
23
  const m = require('./index.js');
22
24
  module.exports = m.default || m;
23
25
  } catch (e) {
24
- // Node 16-21: return a promise that resolves to the module
26
+ // require() of ES modules not available: a promise of the module.
25
27
  // Users need: const tls = await require('lemon-tls');
26
28
  module.exports = load();
27
29
  module.exports.__esModule = true;