lemon-tls 0.4.1 → 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.
@@ -21,12 +21,16 @@ import {
21
21
  derive_handshake_traffic_secrets_psk_with_hash,
22
22
  hkdf_expand_label,
23
23
  getHashFn,
24
+ derive_exporter_master_secret_with_hash,
25
+ tls13_exporter,
26
+ tls12_exporter,
24
27
  } from './crypto.js';
25
28
 
26
29
  import {
27
30
  concatUint8Arrays,
28
31
  arraysEqual,
29
- uint8Equal
32
+ uint8Equal,
33
+ timingSafeEqualU8
30
34
  } from './utils.js';
31
35
 
32
36
  import * as wire from './wire.js';
@@ -38,6 +42,15 @@ import { x25519_get_public_key, x25519_get_shared_secret, p256_generate_keypair,
38
42
  import { build_tls_message, parse_tls_message } from './session/message.js';
39
43
  import { encrypt_session_blob, decrypt_session_blob, encode_client_session, decode_client_session } from './session/ticket.js';
40
44
 
45
+ // RFC 8446 §4.1.3 downgrade-protection sentinels. A TLS 1.3-capable server that
46
+ // negotiates a lower version stamps one of these into the last 8 bytes of
47
+ // ServerHello.random; a TLS 1.3-capable client that ends up on a lower version
48
+ // MUST reject the handshake if it sees the sentinel. This defeats an active
49
+ // attacker forcing a version downgrade (the sentinel is covered by the server's
50
+ // signature / Finished, so it can't be stripped without breaking the handshake).
51
+ const DOWNGRADE_SENTINEL_TLS12 = new Uint8Array([0x44,0x4F,0x57,0x4E,0x47,0x52,0x44,0x01]); // "DOWNGRD\x01"
52
+ const DOWNGRADE_SENTINEL_TLS11 = new Uint8Array([0x44,0x4F,0x57,0x4E,0x47,0x52,0x44,0x00]); // "DOWNGRD\x00" (TLS 1.1 and below)
53
+
41
54
  // Debug logging — enabled via LEMON_DEBUG=1 env var
42
55
  const LEMON_DEBUG = typeof process !== 'undefined' && process.env && process.env.LEMON_DEBUG === '1';
43
56
  function dbg(tag, ...args) { if (LEMON_DEBUG) console.error('[LEMON ' + tag + ']', ...args); }
@@ -199,6 +212,7 @@ function TLSSession(options){
199
212
 
200
213
  // TLS 1.3 resumption
201
214
  tls13_master_secret: null,
215
+ exporter_master_secret: null, // RFC 8446 §7.1 "exp master" — for exportKeyingMaterial
202
216
  resumption_master_secret: null,
203
217
  ticket_nonce_counter: 0,
204
218
  session_ticket_sent: false,
@@ -363,6 +377,15 @@ function TLSSession(options){
363
377
  // Save raw ClientHello + emit event (server side)
364
378
  if (context.isServer && message.type === 'client_hello') {
365
379
  context.rawClientHello = data;
380
+ // Make the peer's extensions readable INSIDE the 'clienthello'
381
+ // handler (getRemoteExtension/getRemoteExtensions) — the main
382
+ // set_context that stores them runs after this emit, and the
383
+ // whole point of the event is to let callers inspect the offer
384
+ // and set answering local_extensions before the ServerHello is
385
+ // built (e.g. DTLS-SRTP use_srtp, RFC 5764 §4.1.2). The later
386
+ // set_context stores the identical array; its equality check
387
+ // makes that a no-op.
388
+ context.remote_extensions = message.extensions || [];
366
389
  ev.emit('clienthello', data, message);
367
390
  }
368
391
 
@@ -412,12 +435,10 @@ function TLSSession(options){
412
435
  dbg('SRV-PSK', 'expected binder:', hexPreview(expectedBinder, 16));
413
436
  dbg('SRV-PSK', 'received binder:', hexPreview(pskBinder, 16));
414
437
 
415
- let binderOk = pskBinder && expectedBinder.length === pskBinder.length;
416
- if (binderOk) {
417
- for (let bi = 0; bi < expectedBinder.length; bi++) {
418
- if (expectedBinder[bi] !== pskBinder[bi]) { binderOk = false; break; }
419
- }
420
- }
438
+ // Constant-time binder comparison. A byte-by-byte early-exit compare
439
+ // would let an attacker recover a valid binder via timing, one byte at
440
+ // a time. timingSafeEqualU8 always compares the full length.
441
+ let binderOk = timingSafeEqualU8(expectedBinder, pskBinder);
421
442
 
422
443
  dbg('SRV-PSK', binderOk ? '✓ BINDER MATCH — psk_accepted' : '✗ BINDER MISMATCH — full handshake');
423
444
 
@@ -581,6 +602,28 @@ function TLSSession(options){
581
602
  add_remote_key_groups: message.key_groups || []
582
603
  });
583
604
 
605
+ // RFC 8446 §4.1.3: downgrade-protection check (client side).
606
+ // If we offered TLS 1.3 but the server steered us to a lower version, the
607
+ // server's random must NOT carry the downgrade sentinel — if it does, an
608
+ // active attacker forced the downgrade. HRR was already handled and
609
+ // returned above, so `message` here is a genuine ServerHello.
610
+ if (!context.isServer && message.type === 'server_hello' &&
611
+ context.local_supported_versions.indexOf(wire.TLS_VERSION.TLS1_3) >= 0 &&
612
+ context.selected_version !== null &&
613
+ context.selected_version !== wire.TLS_VERSION.TLS1_3 &&
614
+ context.selected_version !== wire.DTLS_VERSION.DTLS1_3 &&
615
+ message.random && message.random.length === 32) {
616
+
617
+ let tail = message.random.subarray(24, 32);
618
+ if (timingSafeEqualU8(tail, DOWNGRADE_SENTINEL_TLS12) ||
619
+ timingSafeEqualU8(tail, DOWNGRADE_SENTINEL_TLS11)) {
620
+ dbg('DOWNGRADE', 'sentinel detected in ServerHello.random — aborting');
621
+ sendAlert(2, wire.TLS_ALERT.ILLEGAL_PARAMETER); // fatal, illegal_parameter (47)
622
+ ev.emit('error', new Error('TLS downgrade attack detected (RFC 8446 §4.1.3 sentinel present)'));
623
+ return;
624
+ }
625
+ }
626
+
584
627
  // Server: TLS 1.2 resumption detection (only makes sense from ClientHello).
585
628
  // This runs BEFORE set_context's reactive loop picks a version, so we mark state
586
629
  // but defer the decision. The reactive loop will check tls12_abbreviated once
@@ -763,6 +806,26 @@ function TLSSession(options){
763
806
 
764
807
  pushTranscript(data);
765
808
 
809
+ // TLS 1.3: most server extensions arrive HERE, not in the
810
+ // ServerHello (RFC 8446 §4.3.1) — including application-protocol
811
+ // ones like use_srtp (RFC 8842 requires it in EncryptedExtensions).
812
+ // Merge them into remote_extensions so getRemoteExtension() sees a
813
+ // version-agnostic union: ServerHello entries first (kept on type
814
+ // collision, which the RFC forbids anyway), then EE entries.
815
+ if (Array.isArray(message.extensions) && message.extensions.length > 0) {
816
+ var _mergedExts = (context.remote_extensions || []).slice();
817
+ for (var _eei = 0; _eei < message.extensions.length; _eei++) {
818
+ var _ee = message.extensions[_eei];
819
+ if (!_ee) continue;
820
+ var _dup = false;
821
+ for (var _mj = 0; _mj < _mergedExts.length; _mj++) {
822
+ if (_mergedExts[_mj] && _mergedExts[_mj].type === _ee.type) { _dup = true; break; }
823
+ }
824
+ if (!_dup) _mergedExts.push(_ee);
825
+ }
826
+ context.remote_extensions = _mergedExts;
827
+ }
828
+
766
829
  set_context({
767
830
  remote_supported_groups: message.supported_groups || [],
768
831
  });
@@ -1187,6 +1250,12 @@ function TLSSession(options){
1187
1250
  }
1188
1251
  }
1189
1252
 
1253
+ if('exporter_master_secret' in options){
1254
+ if(context.exporter_master_secret==null && options.exporter_master_secret!==null){
1255
+ context.exporter_master_secret=options.exporter_master_secret;
1256
+ }
1257
+ }
1258
+
1190
1259
  if('tls13_master_secret' in options){
1191
1260
  if(context.tls13_master_secret==null && options.tls13_master_secret!==null){
1192
1261
  context.tls13_master_secret=options.tls13_master_secret;
@@ -1586,6 +1655,20 @@ function TLSSession(options){
1586
1655
 
1587
1656
  if(context.local_random==null){
1588
1657
  context.local_random=new Uint8Array(crypto.randomBytes(32));
1658
+
1659
+ // RFC 8446 §4.1.3: if we support TLS 1.3 but negotiated something
1660
+ // lower, stamp the downgrade sentinel into the last 8 bytes of the
1661
+ // ServerHello random so a 1.3-capable client can detect the downgrade.
1662
+ // Only meaningful for a real ServerHello (not HRR, which reuses the
1663
+ // magic HRR random), and only for the TLS transport (not DTLS here).
1664
+ if (context.local_supported_versions.indexOf(wire.TLS_VERSION.TLS1_3) >= 0) {
1665
+ if (context.selected_version === wire.TLS_VERSION.TLS1_2) {
1666
+ context.local_random.set(DOWNGRADE_SENTINEL_TLS12, 24);
1667
+ } else if (context.selected_version === wire.TLS_VERSION.TLS1_1 ||
1668
+ context.selected_version === wire.TLS_VERSION.TLS1_0) {
1669
+ context.local_random.set(DOWNGRADE_SENTINEL_TLS11, 24);
1670
+ }
1671
+ }
1589
1672
  }
1590
1673
 
1591
1674
  let build_message_params=null;
@@ -1650,6 +1733,19 @@ function TLSSession(options){
1650
1733
  ext_list.push({ type: 'SESSION_TICKET', value: new Uint8Array(0) });
1651
1734
  }
1652
1735
 
1736
+ // Custom local extensions (generic pass-through — same mechanism
1737
+ // as the ClientHello and TLS 1.3 EncryptedExtensions paths).
1738
+ // This is where server-side answers to client-offered extensions
1739
+ // (e.g. use_srtp for DTLS-SRTP, RFC 5764 §4.1) go on the wire in
1740
+ // (D)TLS 1.2. Previously local_extensions were silently dropped
1741
+ // for the 1.2 ServerHello, so a server could never answer them.
1742
+ // Responsibility for only answering extensions the client
1743
+ // actually offered stays with the caller (it can inspect the
1744
+ // ClientHello via the 'clienthello' event / getRemoteExtension).
1745
+ for (let lei = 0; lei < context.local_extensions.length; lei++) {
1746
+ ext_list.push(context.local_extensions[lei]);
1747
+ }
1748
+
1653
1749
  // Session ID: for abbreviated (resumed) handshake, MUST echo client's session_id
1654
1750
  // (RFC 5077 §3.4). For TLS 1.2 full handshake, generate a fresh 32-byte session_id
1655
1751
  // (matches OpenSSL/Node.js behavior for middlebox compatibility).
@@ -2367,10 +2463,18 @@ function TLSSession(options){
2367
2463
  if((context.isServer==true && context.finished_sent==true && context.remote_finished_ok==false) || (context.isServer==false && context.finished_sent==false && context.remote_finished_ok==true)){
2368
2464
 
2369
2465
  let appHashName = TLS_CIPHER_SUITES[context.selected_cipher_suite].hash;
2370
- let result2 = derive_app_traffic_secrets_with_hash(appHashName, context.base_secret, get_transcript_hash(appHashName));
2466
+ let appTranscriptHash = get_transcript_hash(appHashName);
2467
+ let result2 = derive_app_traffic_secrets_with_hash(appHashName, context.base_secret, appTranscriptHash);
2371
2468
 
2372
2469
  // Save master_secret for resumption before clearing
2373
2470
  params_to_set['tls13_master_secret'] = result2.master_secret;
2471
+
2472
+ // RFC 8446 §7.1: exporter_master_secret uses the SAME transcript
2473
+ // hash (ClientHello..server Finished) as the app traffic secrets.
2474
+ // Kept for the lifetime of the connection so exportKeyingMaterial
2475
+ // (RFC 8446 §7.5) works after handshake completion.
2476
+ params_to_set['exporter_master_secret'] =
2477
+ derive_exporter_master_secret_with_hash(appHashName, result2.master_secret, appTranscriptHash);
2374
2478
  params_to_set['base_secret']=null;
2375
2479
 
2376
2480
  if (context.isServer === true) {
@@ -2426,7 +2530,9 @@ function TLSSession(options){
2426
2530
  //compare finished to expected...
2427
2531
  if(context.remote_finished_ok==false && context.remote_finished!==null && context.expected_remote_finished!==null){
2428
2532
 
2429
- if(uint8Equal(context.remote_finished, context.expected_remote_finished)==true){
2533
+ // Constant-time Finished comparison — verify_data is a MAC; a timing
2534
+ // oracle here would let a peer forge a valid Finished byte by byte.
2535
+ if(timingSafeEqualU8(context.remote_finished, context.expected_remote_finished)==true){
2430
2536
 
2431
2537
  let message_data = build_tls_message({
2432
2538
  type: 'finished',
@@ -3070,12 +3176,80 @@ function TLSSession(options){
3070
3176
  };
3071
3177
  },
3072
3178
 
3179
+ /**
3180
+ * Export keying material (RFC 5705 for TLS 1.2, RFC 8446 §7.5 for
3181
+ * TLS 1.3). Mirrors Node's tls.TLSSocket#exportKeyingMaterial.
3182
+ *
3183
+ * DTLS-SRTP (RFC 5764) usage:
3184
+ * session.exportKeyingMaterial(len, 'EXTRACTOR-dtls_srtp')
3185
+ * with NO context argument. Note the TLS 1.2 subtlety: "no context"
3186
+ * and "empty context" produce different output — omit the argument
3187
+ * (or pass null) for the RFC 5764 form.
3188
+ *
3189
+ * Returns a Buffer of `length` bytes, or null before the secrets
3190
+ * are available (handshake not far enough along).
3191
+ *
3192
+ * NOTE: the previous implementation was TLS 1.3-only AND derived from
3193
+ * the app traffic secret in a single HKDF stage — which is not the
3194
+ * RFC 8446 §7.5 construction and would not interoperate with
3195
+ * OpenSSL/BoringSSL exporters. Both issues are fixed here; if anything
3196
+ * consumed the old output, its derived values will change.
3197
+ *
3198
+ * @param {number} length
3199
+ * @param {string} label
3200
+ * @param {Uint8Array|Buffer|null} [context_value]
3201
+ * @returns {Buffer|null}
3202
+ */
3073
3203
  exportKeyingMaterial: function(length, label, context_value){
3074
- if (!context.local_app_traffic_secret || !context.selected_cipher_suite) return new Uint8Array(0);
3075
- let hashName = TLS_CIPHER_SUITES[context.selected_cipher_suite].hash;
3076
- let hashFn = getHashFn(hashName);
3077
- let ctx_hash = hashFn(context_value || new Uint8Array(0));
3078
- return hkdf_expand_label(hashName, context.local_app_traffic_secret, label || 'exporter', ctx_hash, length || 32);
3204
+ if (!context.selected_cipher_suite || !length || !label) return null;
3205
+ var suite = TLS_CIPHER_SUITES[context.selected_cipher_suite];
3206
+ if (!suite) return null;
3207
+ var is13 = (context.selected_version === wire.TLS_VERSION.TLS1_3 ||
3208
+ context.selected_version === wire.DTLS_VERSION.DTLS1_3);
3209
+
3210
+ if (is13) {
3211
+ // RFC 8446 §7.5 — two-stage derivation from exporter_master_secret.
3212
+ if (!context.exporter_master_secret) return null;
3213
+ return Buffer.from(tls13_exporter(
3214
+ suite.hash, context.exporter_master_secret, label,
3215
+ (context_value != null) ? context_value : null, length
3216
+ ));
3217
+ }
3218
+
3219
+ // TLS 1.2 — RFC 5705 over the master secret + hello randoms.
3220
+ // client_random comes first in the seed regardless of our role.
3221
+ if (!context.base_secret || !context.local_random || !context.remote_random) return null;
3222
+ var clientRandom = context.isServer ? context.remote_random : context.local_random;
3223
+ var serverRandom = context.isServer ? context.local_random : context.remote_random;
3224
+ return Buffer.from(tls12_exporter(
3225
+ suite.hash, context.base_secret, label,
3226
+ clientRandom, serverRandom,
3227
+ (context_value != null) ? context_value : null, length
3228
+ ));
3229
+ },
3230
+
3231
+ /**
3232
+ * All extensions the peer sent in its hello (ClientHello when we're
3233
+ * the server, ServerHello when we're the client), as parsed by
3234
+ * wire.parse_extensions: [{ type, name, data, value }].
3235
+ * `data` is the raw extension payload (Uint8Array); `value` is the
3236
+ * decoded form for known extensions, null for unknown ones.
3237
+ */
3238
+ getRemoteExtensions: function(){
3239
+ return context.remote_extensions ? context.remote_extensions.slice() : [];
3240
+ },
3241
+
3242
+ /**
3243
+ * Look up a single peer extension by its numeric type
3244
+ * (e.g. 14 = use_srtp, 0x39 = QUIC transport parameters).
3245
+ * Returns the { type, name, data, value } entry, or null.
3246
+ */
3247
+ getRemoteExtension: function(type){
3248
+ var list = context.remote_extensions || [];
3249
+ for (var i = 0; i < list.length; i++) {
3250
+ if (list[i] && list[i].type === type) return list[i];
3251
+ }
3252
+ return null;
3079
3253
  },
3080
3254
 
3081
3255
  /** Returns the local Finished verify_data (Buffer), or null. */
package/src/tls_socket.js CHANGED
@@ -331,6 +331,18 @@ function TLSSocket(duplex, options){
331
331
  ? context.isTls13
332
332
  : session.getVersion() === 0x0304; // early records (pre-secureConnect)
333
333
 
334
+ // AEAD decryption can throw on any forged/corrupted record (GCM/ChaCha
335
+ // tag mismatch surfaces as an exception from decipher.final()). Left
336
+ // unhandled, a single injected bad segment would throw out of the
337
+ // transport 'data' handler — an unhandled exception that tears down the
338
+ // connection or the process: a trivial remote DoS.
339
+ //
340
+ // RFC 8446 §5.2: in TLS (unlike DTLS, which silently drops), a record
341
+ // that fails to decrypt MUST be treated as a fatal error. So we catch,
342
+ // send a fatal bad_record_mac alert, destroy the connection, and stop.
343
+ // The read sequence number is intentionally NOT advanced on failure —
344
+ // the rejected record never counts.
345
+ try {
334
346
  if (!isTls13 && context.remote_ccs_seen === true) {
335
347
  // TLS 1.2: decrypt with key_block derived keys
336
348
  if (context.app_read_key === null) {
@@ -394,7 +406,14 @@ function TLSSocket(duplex, options){
394
406
  }
395
407
  context.handshake_read_seq++;
396
408
  }
397
-
409
+ } catch (e) {
410
+ // Record failed authentication → fatal bad_record_mac (RFC 8446 §5.2).
411
+ try { session.sendAlert ? session.sendAlert(2, TLS_ALERT.BAD_RECORD_MAC) : null; } catch (_) {}
412
+ self.emit('error', new Error('TLS record decryption failed (bad_record_mac)'));
413
+ self.destroy();
414
+ return;
415
+ }
416
+
398
417
 
399
418
  if (out !== null) {
400
419
  // Reuse cached isTls13 computed at the top of this function
@@ -1427,6 +1446,30 @@ function TLSSocket(duplex, options){
1427
1446
  /** Full negotiation result — all selected parameters in one object. */
1428
1447
  self.getNegotiationResult = function(){ return session.getNegotiationResult ? session.getNegotiationResult() : null; };
1429
1448
 
1449
+ /**
1450
+ * Export keying material (RFC 5705 for TLS 1.2, RFC 8446 §7.5 for
1451
+ * TLS 1.3). Mirrors Node's tls.TLSSocket#exportKeyingMaterial —
1452
+ * same argument order (length, label, context). One deliberate
1453
+ * difference: Node THROWS before the handshake completes; we return
1454
+ * null (matching this library's null-until-ready convention used by
1455
+ * getTrafficSecrets etc.).
1456
+ */
1457
+ self.exportKeyingMaterial = function(length, label, context_value){
1458
+ return session.exportKeyingMaterial
1459
+ ? session.exportKeyingMaterial(length, label, context_value)
1460
+ : null;
1461
+ };
1462
+
1463
+ /** Peer hello extensions: [{ type, name, data, value }]. */
1464
+ self.getRemoteExtensions = function(){
1465
+ return session.getRemoteExtensions ? session.getRemoteExtensions() : [];
1466
+ };
1467
+
1468
+ /** One peer extension by numeric type, or null. */
1469
+ self.getRemoteExtension = function(type){
1470
+ return session.getRemoteExtension ? session.getRemoteExtension(type) : null;
1471
+ };
1472
+
1430
1473
  /** Request Key Update — refresh outgoing encryption keys (TLS 1.3 only). */
1431
1474
  self.rekeySend = function(){ if (session.requestKeyUpdate) session.requestKeyUpdate(false); };
1432
1475
 
package/src/utils.js CHANGED
@@ -1,3 +1,4 @@
1
+ import crypto from 'node:crypto';
1
2
 
2
3
  function concatUint8Arrays(arrays) {
3
4
  let totalLength = 0;
@@ -93,9 +94,35 @@ function uint8Equal(a, b) {
93
94
  }
94
95
 
95
96
 
97
+ /**
98
+ * Constant-time equality for two byte arrays.
99
+ *
100
+ * Unlike uint8Equal (which short-circuits on the first differing byte and
101
+ * therefore leaks — via timing — how many leading bytes matched), this always
102
+ * compares the full length. Use it wherever a mismatch would otherwise be
103
+ * exploitable as an oracle: Finished verify_data, PSK binders, ticket key_name,
104
+ * and any MAC/tag comparison.
105
+ *
106
+ * Returns false immediately (and safely) on length mismatch — that's public
107
+ * information, not a secret, so short-circuiting there is fine.
108
+ *
109
+ * Note: crypto.timingSafeEqual requires two equal-length Buffers. We wrap the
110
+ * inputs as Buffer views over the same memory (no copy) via byteOffset/length.
111
+ */
112
+ function timingSafeEqualU8(a, b) {
113
+ if (a == null || b == null) return false;
114
+ if (a.length !== b.length) return false;
115
+ if (a.length === 0) return true;
116
+ let ba = Buffer.isBuffer(a) ? a : Buffer.from(a.buffer, a.byteOffset, a.length);
117
+ let bb = Buffer.isBuffer(b) ? b : Buffer.from(b.buffer, b.byteOffset, b.length);
118
+ return crypto.timingSafeEqual(ba, bb);
119
+ }
120
+
121
+
96
122
  export {
97
123
  concatUint8Arrays,
98
124
  arraybufferEqual,
99
125
  arraysEqual,
100
- uint8Equal
126
+ uint8Equal,
127
+ timingSafeEqualU8
101
128
  };