lemon-tls 0.4.0 → 0.5.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
@@ -210,7 +210,7 @@ High-level wrapper extending `stream.Duplex`, API-compatible with Node.js [`tls.
210
210
  | `session` | `(Buffer)` | **Opaque session blob** — pass back to `connect({ session })` to resume |
211
211
  | `keyUpdate` | `(direction)` | Traffic keys refreshed: `'send'` or `'receive'` |
212
212
  | `keylog` | `(Buffer)` | SSLKEYLOGFILE-format line (for Wireshark) |
213
- | `clienthello` | `(raw, parsed)` | Raw ClientHello received (server, for JA3) |
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`) |
214
214
  | `handshakeMessage` | `(type, raw, parsed)` | Every handshake message (debugging) |
215
215
  | `certificateRequest` | `(msg)` | Server requested a client certificate |
216
216
  | `error` | `(Error)` | TLS or transport error |
@@ -234,7 +234,7 @@ High-level wrapper extending `stream.Duplex`, API-compatible with Node.js [`tls.
234
234
  | `socket.getPeerFinished()` | Peer Finished verify_data (Buffer) |
235
235
  | `socket.getSharedSigalgs()` | Array of shared signature algorithm names (server-side) |
236
236
  | `socket.getEphemeralKeyInfo()` | `{ type: 'X25519', size: 253 }` |
237
- | `socket.exportKeyingMaterial(len, label, ctx)` | RFC 5705 keying material |
237
+ | `socket.exportKeyingMaterial(len, label, ctx)` | Exported keying material — RFC 5705 on (D)TLS 1.2, RFC 8446 §7.5 on (D)TLS 1.3. Returns `null` before secrets are ready (Node throws instead). Omit `ctx` for the no-context form (≠ empty context on 1.2!) — that's what DTLS-SRTP's `'EXTRACTOR-dtls_srtp'` uses |
238
238
  | `socket.isSessionReused()` | `true` if session was resumed |
239
239
  | `socket.setMaxSendFragment(size)` | Cap outgoing record plaintext size `[512, 16384]` |
240
240
  | `socket.setServername(name)` | Set SNI (client-side, before handshake) |
@@ -262,6 +262,8 @@ High-level wrapper extending `stream.Duplex`, API-compatible with Node.js [`tls.
262
262
  | `socket.getNegotiationResult()` | `{ version, cipher, group, sni, alpn, resumed, helloRetried, ... }` |
263
263
  | `socket.rekeySend()` | Refresh outgoing encryption keys (TLS 1.3) |
264
264
  | `socket.rekeyBoth()` | Refresh keys for both directions (TLS 1.3) |
265
+ | `socket.getRemoteExtensions()` | All extensions the peer sent in its hello: `[{ type, name, data, value }]`. On TLS 1.3 clients this is the union of ServerHello + EncryptedExtensions |
266
+ | `socket.getRemoteExtension(type)` | One peer extension by numeric type (e.g. `14` = use_srtp, `0x39` = QUIC transport params), or `null` |
265
267
 
266
268
  ### `TLSSession`
267
269
 
@@ -269,6 +271,8 @@ The **core state machine** for a TLS connection. Performs handshake, key derivat
269
271
 
270
272
  This is the API to use for QUIC, DTLS, or any custom transport.
271
273
 
274
+ Key methods (in addition to `set_context` / `feedDatagram`-style plumbing): `getTrafficSecrets()`, `getHandshakeSecrets()`, `exportKeyingMaterial(length, label, context?)` (RFC 5705 / RFC 8446 §7.5, version-dispatched), `getRemoteExtensions()` / `getRemoteExtension(type)` for inspecting what the peer offered, and the `'clienthello'` event — emitted **synchronously before the ServerHello is built**, so a server can read the client's offered extensions and answer them via `set_context({ local_extensions })`. `DTLSSession` and both socket classes delegate all of these.
275
+
272
276
  ```js
273
277
  import { TLSSession } from 'lemon-tls';
274
278
 
@@ -314,6 +318,45 @@ session.on('clienthello', (raw, parsed) => {
314
318
  });
315
319
  ```
316
320
 
321
+ ### DTLS API
322
+
323
+ Everything above has a DTLS twin. Same TLS state machine underneath, same
324
+ methods, plus datagram-specific plumbing (retransmission flights, epoch
325
+ handling, cookie exchange):
326
+
327
+ ```js
328
+ import { DTLSSession, DTLSSocket, createDTLSServer, connectDTLS } from 'lemon-tls';
329
+ ```
330
+
331
+ **`DTLSSession`** — transport-less state machine (the DTLS analog of
332
+ `TLSSession`). You move the datagrams:
333
+
334
+ ```js
335
+ const dtls = new DTLSSession({
336
+ cert, key,
337
+ isServer: true,
338
+ minVersion: 'DTLSv1.2', // DTLS 1.2 and 1.3 both supported
339
+ maxVersion: 'DTLSv1.3',
340
+ requestCert: true, // mutual auth (WebRTC-style)
341
+ rejectUnauthorized: false, // when doing fingerprint verification yourself
342
+ });
343
+
344
+ dtls.on('packet', (data) => udp.send(data, rport, raddr)); // outgoing datagrams
345
+ udp.on('message', (msg) => dtls.feedDatagram(msg)); // incoming datagrams
346
+ dtls.on('connect', () => { /* handshake done */ });
347
+ dtls.on('data', (plaintext) => { /* decrypted app data */ });
348
+ dtls.send(appData);
349
+ ```
350
+
351
+ Delegated from the underlying `TLSSession` (identical semantics):
352
+ `exportKeyingMaterial`, `getRemoteExtensions` / `getRemoteExtension`,
353
+ `set_context`, `getNegotiationResult`, `getALPN`, `getPeerCertificate`,
354
+ the `'clienthello'` event, plus `.tls` for direct access to the session.
355
+
356
+ **`DTLSSocket` / `createDTLSServer` / `connectDTLS`** — UDP-transport
357
+ wrappers over `DTLSSession`, mirroring the TCP socket layer's shape.
358
+ The same delegated methods and events are available on the socket.
359
+
317
360
  ### Record Layer Module
318
361
 
319
362
  Shared encrypt/decrypt primitives for QUIC, DTLS, and custom transport consumers:
@@ -328,6 +371,62 @@ const algo = getAeadAlgo(cipherSuite); // 'aes-128-gcm' | 'chacha20-poly1305'
328
371
  const encrypted = encryptRecord(contentType, plaintext, key, nonce, algo);
329
372
  ```
330
373
 
374
+
375
+ ### DTLS-SRTP (WebRTC-style key export)
376
+
377
+ The full RFC 5764 flow — negotiate an SRTP profile via the `use_srtp`
378
+ extension, then export the SRTP master keys from the handshake:
379
+
380
+ ```js
381
+ import { DTLSSession } from 'lemon-tls';
382
+
383
+ // Client side: offer profiles (GCM preferred, CM fallback):
384
+ // body = u16 list_len | u16 profiles[] | u8 mki_len(0)
385
+ client.set_context({ local_extensions: [
386
+ { type: 14, data: new Uint8Array([0x00,0x04, 0x00,0x07, 0x00,0x01, 0x00]) },
387
+ ]});
388
+
389
+ // Server side: pick ONE from the client's offer, before ServerHello:
390
+ server.on('clienthello', (raw, parsed) => {
391
+ const offer = parsed.extensions.find(e => e.type === 14);
392
+ // ...parse offer.data, pick a profile `p`...
393
+ server.set_context({ local_extensions: [
394
+ { type: 14, data: new Uint8Array([0x00,0x02, p >> 8, p & 0xFF, 0x00]) },
395
+ ]});
396
+ });
397
+
398
+ // After 'connect' on both sides — identical on client and server:
399
+ const km = session.exportKeyingMaterial(60, 'EXTRACTOR-dtls_srtp');
400
+ // (60 bytes for AES_CM_128_HMAC_SHA1_80, 56 for AEAD_AES_128_GCM.
401
+ // Feed straight into rtp-packet's SrtpSession.fromDtlsKeyingMaterial.)
402
+ ```
403
+
404
+ Works on DTLS 1.2 (answer in ServerHello) and DTLS 1.3 (answer in
405
+ EncryptedExtensions — `getRemoteExtension` sees both transparently).
406
+
407
+ ### Crypto Primitives Module
408
+
409
+ The key-schedule building blocks are exported for consumers that hold raw
410
+ secrets (QUIC stacks, offline capture analysis, custom transports):
411
+
412
+ ```js
413
+ import { crypto } from 'lemon-tls';
414
+
415
+ crypto.hkdf_extract(hash, salt, ikm);
416
+ crypto.hkdf_expand(hash, prk, info, len);
417
+ crypto.hkdf_expand_label(hash, secret, label, context, len); // TLS 1.3 form
418
+ crypto.hmac(hash, key, data);
419
+ crypto.tls12_prf(secret, label, seed, len, hash); // RFC 5246 PRF
420
+ crypto.TLS_CIPHER_SUITES; // suite registry
421
+
422
+ // Standalone keying-material exporters — the same primitives
423
+ // session.exportKeyingMaterial() dispatches to. Use these directly only
424
+ // when you hold the raw secrets yourself; live connections should call
425
+ // the session/socket method, which picks the right one per version:
426
+ crypto.tls12_exporter(hash, masterSecret, label, clientRandom, serverRandom, ctxOrNull, len); // RFC 5705
427
+ crypto.tls13_exporter(hash, exporterMasterSecret, label, ctxOrNull, len); // RFC 8446 §7.5
428
+ ```
429
+
331
430
  ## 🔧 Advanced Options (Not Available in Node.js)
332
431
 
333
432
  LemonTLS gives you control that Node.js doesn't expose - without `openssl.cnf` hacks:
@@ -444,6 +543,9 @@ For a pure-JavaScript implementation with zero native dependencies, this is with
444
543
  | ✅ | Node.js `tls` compat — **41 API methods/properties verified** |
445
544
  | ✅ | TypeScript typings bundled (`index.d.ts`) |
446
545
  | ✅ | DTLS 1.2 baseline (via `DTLSSocket` / `createDTLSServer`) |
546
+ | ✅ | `exportKeyingMaterial` — RFC 5705 (1.2) + RFC 8446 §7.5 (1.3, proper two-stage `exporter_master_secret` derivation) |
547
+ | ✅ | Peer-extension introspection (`getRemoteExtension(s)`) + custom `local_extensions` answered in the 1.2 ServerHello and 1.3 EncryptedExtensions |
548
+ | ✅ | DTLS-SRTP keying flow (use_srtp negotiation + key export) verified end-to-end against rtp-packet |
447
549
  | ✅ | Zero dependencies - `node:crypto` only |
448
550
  | ✅ | **72 automated tests** (compat, resumption, data transfer) |
449
551
  | ✅ | Browser-tested (Chrome) |
package/index.js CHANGED
@@ -9,6 +9,9 @@ import {
9
9
  hkdf_expand,
10
10
  hkdf_expand_label,
11
11
  hmac,
12
+ tls12_prf,
13
+ tls12_exporter,
14
+ tls13_exporter,
12
15
  } from './src/crypto.js';
13
16
  import * as wire from './src/wire.js';
14
17
  import * as record from './src/record.js';
@@ -43,6 +46,13 @@ var crypto = {
43
46
  hkdf_expand,
44
47
  hkdf_expand_label,
45
48
  hmac,
49
+ // Keying-material exporters (RFC 5705 / RFC 8446 §7.5) — standalone
50
+ // primitives for consumers that hold raw secrets (offline analysis,
51
+ // custom transports). Live sessions should prefer
52
+ // session.exportKeyingMaterial(), which picks the right one.
53
+ tls12_prf,
54
+ tls12_exporter,
55
+ tls13_exporter,
46
56
  };
47
57
 
48
58
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lemon-tls",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "TLS 1.3/1.2 implementation for Node.js - full control over cryptographic keys, record layer, and handshake. Drop-in replacement for node:tls with advanced options impossible in OpenSSL.",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/src/compat.js CHANGED
@@ -433,48 +433,61 @@ function toHex(buf) {
433
433
  function addCompatMethods(socket) {
434
434
  let session = socket._getTLSSession();
435
435
 
436
+ // Polyfill semantics: only define a method if the socket doesn't already
437
+ // provide it natively. TLSSocket has been growing first-class
438
+ // implementations (exportKeyingMaterial, getRemoteExtension(s), …) and a
439
+ // blind `socket.x = …` here would clobber them with a thinner shim —
440
+ // exactly the kind of silent duplication this layer must not create.
441
+ // compat.js stays what it claims to be: a gap-filler, never an override.
442
+ function def(name, fn) {
443
+ if (typeof socket[name] !== 'function') socket[name] = fn;
444
+ }
445
+
436
446
  /** Node.js compat: isSessionReused() — method form (same as isResumed getter) */
437
- socket.isSessionReused = function() {
447
+ def('isSessionReused', function() {
438
448
  return socket.isResumed;
439
- };
449
+ });
440
450
 
441
451
  /** Node.js compat: getFinished() */
442
- socket.getFinished = function() {
452
+ def('getFinished', function() {
443
453
  return session.getFinished ? session.getFinished() : null;
444
- };
454
+ });
445
455
 
446
456
  /** Node.js compat: getPeerFinished() */
447
- socket.getPeerFinished = function() {
457
+ def('getPeerFinished', function() {
448
458
  return session.getPeerFinished ? session.getPeerFinished() : null;
449
- };
459
+ });
450
460
 
451
- /** Node.js compat: exportKeyingMaterial(length, label, context) */
452
- socket.exportKeyingMaterial = function(length, label, context) {
453
- return session.exportKeyingMaterial ? session.exportKeyingMaterial(length, label, context) : new Uint8Array(0);
454
- };
461
+ /** Node.js compat: exportKeyingMaterial(length, label, context).
462
+ * TLSSocket now ships this natively (RFC 5705 / RFC 8446 §7.5 via the
463
+ * session) — this shim only covers older socket-likes. Same
464
+ * null-until-ready convention as the native method. */
465
+ def('exportKeyingMaterial', function(length, label, context) {
466
+ return session.exportKeyingMaterial ? session.exportKeyingMaterial(length, label, context) : null;
467
+ });
455
468
 
456
469
  /** Node.js compat: getEphemeralKeyInfo() */
457
- socket.getEphemeralKeyInfo = function() {
470
+ def('getEphemeralKeyInfo', function() {
458
471
  let group = session.context.selected_group;
459
472
  if (group === 0x001d) return { type: 'X25519', size: 253 };
460
473
  if (group === 0x0017) return { type: 'ECDH', name: 'prime256v1', size: 256 };
461
474
  if (group === 0x0018) return { type: 'ECDH', name: 'secp384r1', size: 384 };
462
475
  return {};
463
- };
476
+ });
464
477
 
465
478
  /** Node.js compat: setServername(name) */
466
- socket.setServername = function(name) {
479
+ def('setServername', function(name) {
467
480
  session.set_context({ local_sni: name });
468
- };
481
+ });
469
482
 
470
483
  /** Node.js compat: disableRenegotiation() — no-op (renegotiation not supported) */
471
- socket.disableRenegotiation = function() {};
484
+ def('disableRenegotiation', function() {});
472
485
 
473
486
  /** Node.js compat: address() — delegates to underlying transport */
474
- socket.address = function() {
487
+ def('address', function() {
475
488
  try { return session.context && session.context.transport ? session.context.transport.address() : {}; }
476
489
  catch(e) { return {}; }
477
- };
490
+ });
478
491
  }
479
492
 
480
493
  // ===================== Exports =====================
package/src/crypto.js CHANGED
@@ -136,8 +136,8 @@ const TLS_CIPHER_SUITES = {
136
136
  cipher: 'CHACHA20_POLY1305',
137
137
  aead: true,
138
138
  keylen: 32,
139
- fixed_ivlen: 4,
140
- record_ivlen: 8,
139
+ fixed_ivlen: 12, // RFC 7905: ChaCha20 uses a full 12-byte IV, no explicit nonce
140
+ record_ivlen: 0,
141
141
  ivlen: 12,
142
142
  hash: 'sha256'
143
143
  },
@@ -671,6 +671,93 @@ function get_handshake_finished_with_hash(hashName, traffic_secret, transcript_h
671
671
  // DTLS 1.3: record number encryption key (RFC 9147 §5.9)
672
672
  // ============================================================
673
673
 
674
+
675
+
676
+ // ============================================================
677
+ // Keying-material exporters (RFC 5705 / RFC 8446 §7.5)
678
+ // ============================================================
679
+
680
+ /**
681
+ * TLS 1.3: derive the exporter_master_secret (RFC 8446 §7.1).
682
+ *
683
+ * exporter_master_secret = Derive-Secret(Master Secret, "exp master",
684
+ * ClientHello...server Finished)
685
+ *
686
+ * Called at the same point (and with the same transcript hash) as the
687
+ * application traffic secrets.
688
+ */
689
+ function derive_exporter_master_secret_with_hash(hashName, master_secret, transcript_hash) {
690
+ let hashLen = getHashLen(hashName);
691
+ return hkdf_expand_label(hashName, master_secret, 'exp master', transcript_hash, hashLen);
692
+ }
693
+
694
+ /**
695
+ * TLS 1.3 exporter (RFC 8446 §7.5) — two-stage derivation:
696
+ *
697
+ * TLS-Exporter(label, context_value, key_length) =
698
+ * HKDF-Expand-Label(
699
+ * Derive-Secret(exporter_master_secret, label, ""),
700
+ * "exporter", Hash(context_value), key_length)
701
+ *
702
+ * Note the FIRST stage uses Hash("") as its context (Derive-Secret over an
703
+ * empty transcript), and only the SECOND stage hashes the caller's context.
704
+ * A single-stage HKDF-Expand-Label(secret, label, ...) is NOT conformant
705
+ * and will not interoperate with OpenSSL/BoringSSL exporters.
706
+ *
707
+ * @param {string} hashName 'sha256' | 'sha384'
708
+ * @param {Uint8Array} exporter_master_secret
709
+ * @param {string} label
710
+ * @param {Uint8Array|null} context_value null/undefined ≡ empty (per RFC 8446)
711
+ * @param {number} length
712
+ * @returns {Uint8Array}
713
+ */
714
+ function tls13_exporter(hashName, exporter_master_secret, label, context_value, length) {
715
+ let hashFn = getHashFn(hashName);
716
+ let hashLen = getHashLen(hashName);
717
+ let h_empty = hashFn(new Uint8Array(0));
718
+ let derived = hkdf_expand_label(hashName, exporter_master_secret, label, h_empty, hashLen);
719
+ let h_ctx = hashFn(context_value || new Uint8Array(0));
720
+ return hkdf_expand(hashName, derived, build_hkdf_label('exporter', h_ctx, length), length);
721
+ }
722
+
723
+ /**
724
+ * TLS 1.2 exporter (RFC 5705).
725
+ *
726
+ * If no context: PRF(master_secret, label, client_random + server_random)
727
+ * With context: PRF(master_secret, label,
728
+ * client_random + server_random +
729
+ * uint16(len(context)) + context)
730
+ *
731
+ * "No context" and "empty context" produce DIFFERENT output in TLS 1.2
732
+ * (unlike 1.3) — pass context_value === null/undefined for the no-context
733
+ * form. DTLS-SRTP (RFC 5764) uses the no-context form with label
734
+ * 'EXTRACTOR-dtls_srtp'.
735
+ *
736
+ * The PRF hash follows the negotiated cipher suite (RFC 5246 §5 /
737
+ * RFC 5289): SHA-256 for *_SHA256 suites, SHA-384 for *_SHA384 suites.
738
+ *
739
+ * @param {string} hashName cipher suite's PRF hash
740
+ * @param {Uint8Array} master_secret
741
+ * @param {string} label
742
+ * @param {Uint8Array} client_random 32 bytes
743
+ * @param {Uint8Array} server_random 32 bytes
744
+ * @param {Uint8Array|null} context_value
745
+ * @param {number} length
746
+ * @returns {Uint8Array}
747
+ */
748
+ function tls12_exporter(hashName, master_secret, label, client_random, server_random, context_value, length) {
749
+ let seedLen = 64 + (context_value != null ? 2 + context_value.length : 0);
750
+ let seed = new Uint8Array(seedLen);
751
+ seed.set(client_random, 0);
752
+ seed.set(server_random, 32);
753
+ if (context_value != null) {
754
+ seed[64] = (context_value.length >>> 8) & 0xFF;
755
+ seed[65] = context_value.length & 0xFF;
756
+ seed.set(context_value, 66);
757
+ }
758
+ return tls12_prf(master_secret, label, seed, length, hashName);
759
+ }
760
+
674
761
  function derive_sn_key(hashName, traffic_secret, cipher_suite) {
675
762
  let keylen = TLS_CIPHER_SUITES[cipher_suite].keylen;
676
763
  return hkdf_expand_label(hashName, traffic_secret, 'sn', new Uint8Array(0), keylen);
@@ -708,4 +795,7 @@ export {
708
795
  get_handshake_finished,
709
796
  get_handshake_finished_with_hash,
710
797
  derive_sn_key,
798
+ derive_exporter_master_secret_with_hash,
799
+ tls13_exporter,
800
+ tls12_exporter,
711
801
  };