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.
@@ -44,12 +44,14 @@ function DTLSSocket(udpSocket, options) {
44
44
  cert: options.cert,
45
45
  key: options.key,
46
46
  rejectUnauthorized: options.rejectUnauthorized,
47
+ checkServerIdentity: options.checkServerIdentity,
47
48
  requestCert: options.requestCert,
48
49
  ca: options.ca,
49
50
  alpnProtocols: options.alpnProtocols,
50
51
  minVersion: options.minVersion,
51
52
  maxVersion: options.maxVersion,
52
53
  mtu: options.mtu,
54
+ groups: options.groups,
53
55
  ticketKeys: options.ticketKeys,
54
56
  useCookies: options.useCookies,
55
57
  srtpProfiles: options.srtpProfiles,
@@ -130,6 +132,33 @@ function DTLSSocket(udpSocket, options) {
130
132
  self.getALPN = function() { return session.getALPN(); };
131
133
  self.getPeerCertificate = function() { return session.getPeerCertificate(); };
132
134
 
135
+ /** TLS fingerprints, { hash, raw } or null (see TLSSession): JA4 and JA5
136
+ * of the ClientHello, JA4S of the ServerHello — in either role. */
137
+ self.getJA4 = function(opts) { return session.getJA4(opts); };
138
+ self.getJA5 = function(opts) { return session.getJA5(opts); };
139
+ self.getJA4S = function(opts) { return session.getJA4S(opts); };
140
+
141
+ /** Set the key and certificate for this connection (Node parity). */
142
+ self.setKeyCert = function(ctxOrOpts) {
143
+ if (session.setKeyCert) session.setKeyCert(ctxOrOpts);
144
+ };
145
+
146
+ /** Finished verify_data — the tls-unique channel binding of RFC 5929.
147
+ * DTLS-SRTP callers use this alongside exportKeyingMaterial(). */
148
+ self.getFinished = function() {
149
+ let v = session.getFinished ? session.getFinished() : null;
150
+ return v == null ? undefined : v;
151
+ };
152
+ self.getPeerFinished = function() {
153
+ let v = session.getPeerFinished ? session.getPeerFinished() : null;
154
+ return v == null ? undefined : v;
155
+ };
156
+
157
+ /** Whether this connection resumed an earlier session. */
158
+ self.isSessionReused = function() {
159
+ return session.isSessionReused ? session.isSessionReused() : false;
160
+ };
161
+
133
162
  /** RFC 5705 / RFC 8446 §7.5 exporter (Node tls.TLSSocket parity).
134
163
  * DTLS-SRTP: exportKeyingMaterial(len, 'EXTRACTOR-dtls_srtp') — no context.
135
164
  * Returns Buffer, or null before secrets are available. */
@@ -194,6 +223,7 @@ function createDTLSServer(options, connectionListener) {
194
223
  minVersion: options.minVersion,
195
224
  maxVersion: options.maxVersion,
196
225
  mtu: options.mtu,
226
+ groups: options.groups,
197
227
  ticketKeys: options.ticketKeys,
198
228
  useCookies: options.useCookies,
199
229
  srtpProfiles: options.srtpProfiles,
@@ -270,12 +300,14 @@ function connectDTLS(options, callback) {
270
300
  remotePort: port,
271
301
  servername: options.servername || host,
272
302
  rejectUnauthorized: options.rejectUnauthorized,
303
+ checkServerIdentity: options.checkServerIdentity,
273
304
  requestCert: options.requestCert,
274
305
  ca: options.ca,
275
306
  alpnProtocols: options.alpnProtocols,
276
307
  minVersion: options.minVersion,
277
308
  maxVersion: options.maxVersion,
278
309
  mtu: options.mtu,
310
+ groups: options.groups,
279
311
  });
280
312
 
281
313
  if (callback) socket.on('connect', callback);
@@ -0,0 +1,138 @@
1
+ // ============================================================
2
+ // node:tls option strings → the codes lemon-tls works with
3
+ // ============================================================
4
+ //
5
+ // node:tls takes `ciphers`, `ecdhCurve` and `sigalgs` as OpenSSL strings,
6
+ // colon-separated; lemon-tls works with code points (allowedCipherSuites,
7
+ // groups, signatureAlgorithms). compat.js promised Node's API and never read
8
+ // the three — a server given `ciphers` or `ecdhCurve` ran with the defaults
9
+ // and said nothing. These turn them into the lists, once, where an option is
10
+ // taken (compat.js, and QUICO for both TCP and QUIC), and refuse what cannot
11
+ // be honoured, as Node does ("no cipher match", "Failed to set ECDH curve").
12
+ //
13
+ // What is understood:
14
+ // ciphers suite names — OpenSSL's for TLS 1.2 (ECDHE-RSA-AES128-GCM-SHA256)
15
+ // and the standard ones (TLS_AES_128_GCM_SHA256, or for 1.2
16
+ // TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256), in the order given;
17
+ // `!name` / `-name` take one out; DEFAULT, HIGH and ALL stand for
18
+ // every suite this library implements — it implements no weak
19
+ // ones. Other OpenSSL keywords (aNULL, MD5, …) select nothing
20
+ // here and are skipped: what remains must name something.
21
+ // ecdhCurve 'auto' (the default list) or names: X25519, P-256
22
+ // (prime256v1, secp256r1), P-384 (secp384r1), and post-quantum:
23
+ // X25519MLKEM768, SecP256r1MLKEM768, SecP384r1MLKEM1024,
24
+ // MLKEM512, MLKEM768, MLKEM1024 (OpenSSL's names; case ignored).
25
+ // sigalgs scheme names (ecdsa_secp256r1_sha256, rsa_pss_rsae_sha256,
26
+ // ed25519, …) or OpenSSL's SIG+HASH form (ECDSA+SHA256,
27
+ // RSA-PSS+SHA256, RSA+SHA256).
28
+
29
+ import { TLS_CIPHER_SUITES, DEFAULT_CIPHER_SUITES_TLS13, DEFAULT_CIPHER_SUITES_TLS12 } from './crypto.js';
30
+ import { SERVER_DEFAULT_GROUPS } from './session/ecdh.js';
31
+ import { MLDSA_AVAILABLE } from './session/signing.js';
32
+
33
+ function splitList(s) {
34
+ return String(s).split(/[:,\s]+/).filter(function (x) { return x !== ''; });
35
+ }
36
+
37
+ var cipherByName = null;
38
+ function cipherTable() {
39
+ if (cipherByName) return cipherByName;
40
+ cipherByName = {};
41
+ for (var code in TLS_CIPHER_SUITES) {
42
+ var info = TLS_CIPHER_SUITES[code];
43
+ if (info.name) cipherByName[info.name.toUpperCase()] = Number(code);
44
+ if (info.standardName) cipherByName[info.standardName.toUpperCase()] = Number(code);
45
+ }
46
+ return cipherByName;
47
+ }
48
+
49
+ /** `ciphers` → [code, …] in the order given. Throws when nothing is left. */
50
+ function parseCiphers(str) {
51
+ if (str === undefined || str === null) return null;
52
+ if (typeof str !== 'string') throw new TypeError('ciphers must be a string, got ' + typeof str);
53
+ var table = cipherTable(), all = DEFAULT_CIPHER_SUITES_TLS13.concat(DEFAULT_CIPHER_SUITES_TLS12);
54
+ var out = [], removed = [];
55
+ var tokens = splitList(str);
56
+ for (var i = 0; i < tokens.length; i++) {
57
+ var t = tokens[i], neg = t[0] === '!' || t[0] === '-';
58
+ if (neg || t[0] === '+') t = t.slice(1);
59
+ var u = t.toUpperCase(), codes;
60
+ if (u === 'DEFAULT' || u === 'HIGH' || u === 'ALL') codes = all;
61
+ else if (u in table) codes = [table[u]];
62
+ else continue; // a keyword this library has nothing for
63
+ for (var j = 0; j < codes.length; j++) {
64
+ if (neg) { removed.push(codes[j]); var k = out.indexOf(codes[j]); if (k >= 0) out.splice(k, 1); }
65
+ else if (out.indexOf(codes[j]) < 0 && removed.indexOf(codes[j]) < 0) out.push(codes[j]);
66
+ }
67
+ }
68
+ if (out.length === 0) throw new Error('ciphers: no cipher match in "' + str + '"');
69
+ return out;
70
+ }
71
+
72
+ var GROUPS = {
73
+ 'X25519': 0x001d,
74
+ 'P-256': 0x0017, 'PRIME256V1': 0x0017, 'SECP256R1': 0x0017,
75
+ 'P-384': 0x0018, 'SECP384R1': 0x0018,
76
+ 'X25519MLKEM768': 0x11ec,
77
+ 'SECP256R1MLKEM768': 0x11eb, 'SECP384R1MLKEM1024': 0x11ed,
78
+ 'MLKEM512': 0x0200, 'MLKEM768': 0x0201, 'MLKEM1024': 0x0202,
79
+ };
80
+
81
+ /** `ecdhCurve` → [group, …]; 'auto' → null, the role's own default list
82
+ * (a server's has the hybrid first, a client's does not offer it unasked).
83
+ * Throws on a name it does not implement. */
84
+ function parseEcdhCurve(str) {
85
+ if (str === undefined || str === null) return null;
86
+ if (typeof str !== 'string') throw new TypeError('ecdhCurve must be a string, got ' + typeof str);
87
+ if (str.trim().toLowerCase() === 'auto') return null;
88
+ var out = [], tokens = splitList(str);
89
+ for (var i = 0; i < tokens.length; i++) {
90
+ var g = GROUPS[tokens[i].toUpperCase()];
91
+ if (g === undefined) throw new Error('ecdhCurve: unsupported curve "' + tokens[i] + '"');
92
+ // The hybrid group only where node:crypto has ML-KEM (session/ecdh.js).
93
+ if (SERVER_DEFAULT_GROUPS.indexOf(g) < 0) throw new Error('ecdhCurve: "' + tokens[i] + '" is not available in this Node.js');
94
+ if (out.indexOf(g) < 0) out.push(g);
95
+ }
96
+ if (out.length === 0) throw new Error('ecdhCurve: no curve in "' + str + '"');
97
+ return out;
98
+ }
99
+
100
+ var SCHEMES = {
101
+ 'RSA_PKCS1_SHA256': 0x0401, 'RSA_PKCS1_SHA384': 0x0501, 'RSA_PKCS1_SHA512': 0x0601,
102
+ 'ECDSA_SECP256R1_SHA256': 0x0403, 'ECDSA_SECP384R1_SHA384': 0x0503, 'ECDSA_SECP521R1_SHA512': 0x0603,
103
+ 'RSA_PSS_RSAE_SHA256': 0x0804, 'RSA_PSS_RSAE_SHA384': 0x0805, 'RSA_PSS_RSAE_SHA512': 0x0806,
104
+ 'ED25519': 0x0807, 'ED448': 0x0808,
105
+ 'RSA_PSS_PSS_SHA256': 0x0809, 'RSA_PSS_PSS_SHA384': 0x080a, 'RSA_PSS_PSS_SHA512': 0x080b,
106
+ // ML-DSA (FIPS 204), as OpenSSL names it.
107
+ 'MLDSA44': 0x0904, 'MLDSA65': 0x0905, 'MLDSA87': 0x0906,
108
+ // OpenSSL's SIG+HASH spelling.
109
+ 'ECDSA+SHA256': 0x0403, 'ECDSA+SHA384': 0x0503, 'ECDSA+SHA512': 0x0603,
110
+ 'RSA-PSS+SHA256': 0x0804, 'RSA-PSS+SHA384': 0x0805, 'RSA-PSS+SHA512': 0x0806,
111
+ 'RSA+SHA256': 0x0401, 'RSA+SHA384': 0x0501, 'RSA+SHA512': 0x0601,
112
+ };
113
+
114
+ /** `sigalgs` → [scheme, …]. Throws on a name it does not know. */
115
+ function parseSigalgs(str) {
116
+ if (str === undefined || str === null) return null;
117
+ if (typeof str !== 'string') throw new TypeError('sigalgs must be a string, got ' + typeof str);
118
+ var out = [], tokens = splitList(str);
119
+ for (var i = 0; i < tokens.length; i++) {
120
+ var s = SCHEMES[tokens[i].toUpperCase()];
121
+ if (s === undefined) throw new Error('sigalgs: unsupported signature algorithm "' + tokens[i] + '"');
122
+ // ML-DSA only where node:crypto has it (session/signing.js).
123
+ if (s >= 0x0904 && s <= 0x0906 && !MLDSA_AVAILABLE) throw new Error('sigalgs: "' + tokens[i] + '" is not available in this Node.js');
124
+ if (out.indexOf(s) < 0) out.push(s);
125
+ }
126
+ if (out.length === 0) throw new Error('sigalgs: none in "' + str + '"');
127
+ return out;
128
+ }
129
+
130
+ /** 'TLSv1.2' / 'TLSv1.3' → 0x0303 / 0x0304, as node:tls names them. */
131
+ function parseTlsVersion(v, name) {
132
+ if (v === undefined || v === null) return null;
133
+ if (v === 'TLSv1.3') return 0x0304;
134
+ if (v === 'TLSv1.2') return 0x0303;
135
+ throw new Error(name + ': "' + v + '" is not supported (TLSv1.2 or TLSv1.3)');
136
+ }
137
+
138
+ export { parseCiphers, parseEcdhCurve, parseSigalgs, parseTlsVersion };
package/src/record.js CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  w_u8,
17
17
  w_u16,
18
18
  w_u48,
19
+ w_u64,
19
20
  } from './wire.js';
20
21
 
21
22
  // ===================== AEAD algorithm resolution =====================
@@ -372,10 +373,52 @@ function buildAad12(seqNum, recordType, plaintextLen) {
372
373
  }
373
374
 
374
375
  /**
375
- * Encrypt a TLS 1.2 GCM record fragment. Returns explicit_nonce(8) || ciphertext || tag(16).
376
+ * TLS 1.2 ChaCha20-Poly1305 (RFC 7905), shared by the three TLS 1.2 record
377
+ * functions below. Told apart from AES-GCM the way the DTLS 1.2 functions do
378
+ * it: by the write IV the key block gave the suite — 12 bytes for ChaCha20,
379
+ * a 4-byte salt for GCM. No explicit nonce on the wire; the nonce is the IV
380
+ * XOR the sequence number; the AAD is the same as GCM's.
381
+ *
382
+ * The TLS 1.2 functions handled GCM only. A ChaCha20 suite was negotiated —
383
+ * it is in the default offer, and clients without AES hardware prefer it —
384
+ * and then every record failed: the peer's Finished did not decrypt
385
+ * (bad_record_mac), and ours could not have been read either.
386
+ */
387
+ function chacha12Seal(pt, key, iv12, seqNum, recordType) {
388
+ const cipher = crypto.createCipheriv('chacha20-poly1305', key, getChachaNonce12(iv12, seqNum), { authTagLength: 16 });
389
+ cipher.setAAD(buildAad12(seqNum, recordType, pt.length), { plaintextLength: pt.length });
390
+ const ct = cipher.update(pt);
391
+ cipher.final();
392
+ return { ct: ct, tag: cipher.getAuthTag() };
393
+ }
394
+
395
+ function chacha12Open(fragment, key, iv12, seqNum, recordType) {
396
+ if (fragment.length < 16) throw new Error('TLS 1.2 ChaCha20 fragment too short');
397
+ const ct = fragment.subarray(0, fragment.length - 16);
398
+ const tag = fragment.subarray(fragment.length - 16);
399
+ const decipher = crypto.createDecipheriv('chacha20-poly1305', key, getChachaNonce12(iv12, seqNum), { authTagLength: 16 });
400
+ decipher.setAAD(buildAad12(seqNum, recordType, ct.length), { plaintextLength: ct.length });
401
+ decipher.setAuthTag(tag);
402
+ const pt = decipher.update(ct);
403
+ decipher.final();
404
+ return pt;
405
+ }
406
+
407
+ /**
408
+ * Encrypt a TLS 1.2 AEAD record fragment.
409
+ * AES-GCM (4-byte salt): explicit_nonce(8) || ciphertext || tag(16).
410
+ * ChaCha20-Poly1305 (12-byte IV): ciphertext || tag(16).
376
411
  * Perf: computes seq-bytes once for both explicit nonce and AAD (was computed twice).
377
412
  */
378
413
  function encrypt12(pt, key, salt4, seqNum, recordType) {
414
+ if (salt4.length === 12) {
415
+ const sealed = chacha12Seal(pt, key, salt4, seqNum, recordType);
416
+ const out12 = new Uint8Array(sealed.ct.length + 16);
417
+ out12.set(sealed.ct, 0);
418
+ out12.set(sealed.tag, sealed.ct.length);
419
+ return out12;
420
+ }
421
+
379
422
  // Shared big-endian seq encoding (used as both explicit_nonce and AAD[0..8])
380
423
  const hi = (seqNum / 0x100000000) | 0;
381
424
  const lo = seqNum >>> 0;
@@ -436,6 +479,23 @@ function encrypt12(pt, key, salt4, seqNum, recordType) {
436
479
  */
437
480
  function encryptCompleteRecord12(pt, key, salt4, seqNum, recordType, version) {
438
481
  const ptLen = pt.length;
482
+
483
+ // ChaCha20-Poly1305: header || ciphertext || tag — no explicit IV.
484
+ if (salt4.length === 12) {
485
+ const sealed = chacha12Seal(pt, key, salt4, seqNum, recordType);
486
+ const cBodyLen = ptLen + 16;
487
+ const cVer = version || 0x0303;
488
+ const cRec = Buffer.allocUnsafe(5 + cBodyLen);
489
+ cRec[0] = recordType & 0xff;
490
+ cRec[1] = (cVer >>> 8) & 0xff;
491
+ cRec[2] = cVer & 0xff;
492
+ cRec[3] = (cBodyLen >>> 8) & 0xff;
493
+ cRec[4] = cBodyLen & 0xff;
494
+ sealed.ct.copy(cRec, 5);
495
+ sealed.tag.copy(cRec, 5 + ptLen);
496
+ return cRec;
497
+ }
498
+
439
499
  // Record body: 8 explicit_iv + ptLen ciphertext + 16 tag
440
500
  const bodyLen = 8 + ptLen + 16;
441
501
  const ver = version || 0x0303;
@@ -492,11 +552,15 @@ function encryptCompleteRecord12(pt, key, salt4, seqNum, recordType, version) {
492
552
  }
493
553
 
494
554
  /**
495
- * Decrypt a TLS 1.2 GCM record fragment. Input: explicit_nonce(8) || ciphertext || tag(16).
555
+ * Decrypt a TLS 1.2 AEAD record fragment.
556
+ * AES-GCM input: explicit_nonce(8) || ciphertext || tag(16).
557
+ * ChaCha20-Poly1305 input: ciphertext || tag(16).
496
558
  * Perf: subarray (zero-copy views) instead of slice (copies); returns Node Buffer
497
559
  * (which IS a Uint8Array subclass) instead of re-copying to a plain Uint8Array.
498
560
  */
499
561
  function decrypt12(fragment, key, salt4, seqNum, recordType) {
562
+ if (salt4.length === 12) return chacha12Open(fragment, key, salt4, seqNum, recordType);
563
+
500
564
  if (fragment.length < 24) throw new Error('TLS 1.2 fragment too short');
501
565
 
502
566
  // Zero-copy views over the input
@@ -589,6 +653,11 @@ function writeRecord(transport, type, payload, version) {
589
653
  // readU16, readU48 return value only (no offset tracking), unlike wire.js r_u16 which returns [value, offset]
590
654
 
591
655
  function readU16(buf, off) { return ((buf[off] << 8) | buf[off+1]) >>> 0; }
656
+ function readU64(buf, off) {
657
+ let hi = ((buf[off] << 24) | (buf[off+1] << 16) | (buf[off+2] << 8) | buf[off+3]) >>> 0;
658
+ let lo = ((buf[off+4] << 24) | (buf[off+5] << 16) | (buf[off+6] << 8) | buf[off+7]) >>> 0;
659
+ return hi * 0x100000000 + lo;
660
+ }
592
661
  function readU48(buf, off) {
593
662
  let hi = ((buf[off] << 8) | buf[off+1]) >>> 0;
594
663
  let lo = ((buf[off+2] << 24) | (buf[off+3] << 16) | (buf[off+4] << 8) | buf[off+5]) >>> 0;
@@ -726,7 +795,9 @@ function decryptDtls12(fragment, key, ivSalt, epoch, seq, type) {
726
795
  decipher.setAuthTag(tag);
727
796
  let pt = decipher.update(ct);
728
797
  decipher.final();
729
- return new Uint8Array(pt);
798
+ // A view, not a copy: pt is a fresh Buffer that owns its whole
799
+ // ArrayBuffer and nothing else holds it. Same Uint8Array type as before.
800
+ return new Uint8Array(pt.buffer, pt.byteOffset, pt.byteLength);
730
801
  }
731
802
 
732
803
  // AES-GCM: strip the 8-byte explicit nonce.
@@ -742,7 +813,8 @@ function decryptDtls12(fragment, key, ivSalt, epoch, seq, type) {
742
813
  decipher.setAuthTag(tag);
743
814
  let pt = decipher.update(ct);
744
815
  decipher.final();
745
- return new Uint8Array(pt);
816
+ // A view, not a copy — see the ChaCha branch above.
817
+ return new Uint8Array(pt.buffer, pt.byteOffset, pt.byteLength);
746
818
  }
747
819
 
748
820
  /** Build complete encrypted DTLS 1.2 record (header + encrypted payload). */
@@ -800,10 +872,35 @@ function isUnifiedHdr(b) { return (b & 0xE0) === UNIFIED_HDR_FIXED; }
800
872
  * mask = AES-ECB(snKey, ciphertext[0..15])
801
873
  * result = rnBytes XOR mask[0..len-1]
802
874
  */
803
- function maskRecordNumber(snKey, rnBytes, ciphertext) {
804
- let sample = new Uint8Array(16);
805
- sample.set(ciphertext.subarray(0, Math.min(16, ciphertext.length)), 0);
806
- let mask = aesEcbEncrypt(snKey, sample);
875
+ function maskRecordNumber(snKey, rnBytes, ciphertext, algo) {
876
+ // RFC 9147 §4.2.3: "This procedure requires the ciphertext length to be at
877
+ // least 16 bytes. Receivers MUST reject shorter records as if they had
878
+ // failed deprotection." Zero-padding a short sample instead (the previous
879
+ // behaviour) both diverges from the spec and yields a predictable mask.
880
+ // Senders must pad short plaintexts; with a 16-byte tag that never happens
881
+ // for the AEADs we support, so this is a guard, not a code path.
882
+ if (!ciphertext || ciphertext.length < 16) return null;
883
+
884
+ let mask;
885
+ if (algo === 'chacha20-poly1305') {
886
+ // Mask = ChaCha20(sn_key, Ciphertext[0..3], Ciphertext[4..15])
887
+ // — first 4 bytes are the block counter, next 12 the nonce. Node's
888
+ // 'chacha20' cipher takes exactly that as a 16-byte IV, so the sample is
889
+ // passed through unchanged and we encrypt a zero block to read off the
890
+ // keystream.
891
+ //
892
+ // The AES branch below is well covered by the DTLS 1.3 suites we
893
+ // negotiate in practice; this branch is not, so cross-check it against a
894
+ // BoringSSL peer before trusting it — in particular the counter's byte
895
+ // order, which the RFC states as a byte range and OpenSSL interprets as a
896
+ // little-endian u32.
897
+ let iv = Buffer.from(ciphertext.subarray(0, 16));
898
+ let c = crypto.createCipheriv('chacha20', snKey, iv);
899
+ mask = new Uint8Array(c.update(Buffer.alloc(16)));
900
+ } else {
901
+ mask = aesEcbEncrypt(snKey, ciphertext.subarray(0, 16));
902
+ }
903
+
807
904
  let out = new Uint8Array(rnBytes.length);
808
905
  for (let i = 0; i < rnBytes.length; i++) out[i] = rnBytes[i] ^ mask[i];
809
906
  return out;
@@ -864,7 +961,32 @@ function buildEncryptedDtls13(innerType, plaintext, seq, epoch, keys) {
864
961
  ciphertext.set(new Uint8Array(tag), ct.length);
865
962
 
866
963
  // Encrypt record number
867
- let encRn = maskRecordNumber(keys.snKey, rn, ciphertext);
964
+ let encRn = maskRecordNumber(keys.snKey, rn, ciphertext, algo);
965
+
966
+ // ── DIAGNOSTIC (LEMON_DEBUG=1 / WEBRTC_DEBUG=1) ──────────────────────
967
+ // Everything a peer needs to PARSE this record, printed as we build it.
968
+ // A decode_error alert means the peer could not make structural sense of
969
+ // what arrived, so the fields to compare are the info byte (which
970
+ // declares whether a CID, a length and a 16-bit sequence number are
971
+ // present), the sequence number before and after masking, and the
972
+ // declared length against the actual ciphertext size. If two records
973
+ // are accepted and the third is not, the difference between them shows
974
+ // up here.
975
+ try {
976
+ if (typeof process !== 'undefined' && process.env &&
977
+ (process.env.LEMON_DEBUG === '1' || process.env.WEBRTC_DEBUG === '1')) {
978
+ var _h = function (b) { return ('0' + (b & 0xFF).toString(16)).slice(-2); };
979
+ console.log('[lemon-rec] TX info=0x' + _h(info) +
980
+ ' [C=' + ((info >> 4) & 1) + ' S=' + ((info >> 3) & 1) +
981
+ ' L=' + ((info >> 2) & 1) + ' epoch=' + (info & 0x03) + ']' +
982
+ ' seqPlain=' + _h(rn[0]) + _h(rn[1]) +
983
+ ' seqMasked=' + _h(encRn[0]) + _h(encRn[1]) +
984
+ ' declaredLen=' + ciphertext.length +
985
+ ' aadLen=' + (aad ? aad.length : 'null') +
986
+ ' snKey=' + (keys.snKey ? 'set' : 'MISSING'));
987
+ }
988
+ } catch (_e) {}
989
+ // ─────────────────────────────────────────────────────────────────────
868
990
 
869
991
  // Assemble: info(1) + encrypted_rn(2) + length(2) + ciphertext
870
992
  let record = new Uint8Array(5 + ciphertext.length);
@@ -909,13 +1031,46 @@ function reconstructDtlsSeq(truncated, bits, maxSeq) {
909
1031
  }
910
1032
 
911
1033
 
1034
+ /**
1035
+ * Resolve the full epoch from the 2 bits carried in the unified header.
1036
+ *
1037
+ * RFC 9147 §4.2.2: the header holds only the low 2 bits of the epoch, so
1038
+ * epoch 4 is indistinguishable from epoch 0 on the wire, 5 from 1, and so on.
1039
+ * The spec's rule is to use "the most recent past epoch which has matching
1040
+ * bits" — i.e. among the epochs we actually hold read keys for, the highest
1041
+ * one whose low 2 bits match.
1042
+ *
1043
+ * Until KeyUpdate existed, only epochs 0/2/3 were ever in play and each had a
1044
+ * unique bit pattern, so indexing the key table directly by the 2 bits worked
1045
+ * by accident. The first KeyUpdate moves a sender to epoch 4, whose bits are
1046
+ * 00 — and a lookup by bits then hands back the epoch-0 (plaintext) entry.
1047
+ *
1048
+ * @param epochBits the 2-bit value from the header
1049
+ * @param keysByEpoch object keyed by FULL epoch number
1050
+ * @returns the full epoch number, or null if we hold no matching keys
1051
+ */
1052
+ function resolveReadEpoch(epochBits, keysByEpoch) {
1053
+ if (!keysByEpoch) return null;
1054
+ let best = null;
1055
+ for (let k in keysByEpoch) {
1056
+ if (!Object.prototype.hasOwnProperty.call(keysByEpoch, k)) continue;
1057
+ let e = Number(k);
1058
+ if (!Number.isFinite(e)) continue;
1059
+ if ((e & 0x03) !== (epochBits & 0x03)) continue;
1060
+ if (keysByEpoch[k] == null) continue;
1061
+ if (best === null || e > best) best = e;
1062
+ }
1063
+ return best;
1064
+ }
1065
+
1066
+
912
1067
  /**
913
1068
  * Decrypt a DTLS 1.3 encrypted record.
914
1069
  * data: full record bytes (starting with unified header byte)
915
1070
  * keys: { key, iv, snKey, algo?, maxReadSeq? }
916
1071
  * Returns { epoch, seq, type, content } or null on failure.
917
1072
  */
918
- function decryptEncryptedDtls13(data, keys) {
1073
+ function decryptEncryptedDtls13(data, keys, fullEpoch) {
919
1074
  if (data.length < 1) return null;
920
1075
 
921
1076
  let hdr = parseUnifiedHdr(data[0]);
@@ -940,8 +1095,16 @@ function decryptEncryptedDtls13(data, keys) {
940
1095
  if (off + ctLen > data.length) return null;
941
1096
  let ciphertext = data.slice(off, off + ctLen);
942
1097
 
943
- // Decrypt record number
944
- let rn = maskRecordNumber(keys.snKey, encRn, ciphertext);
1098
+ // Resolve the AEAD before unmasking: the record-number mask is built with
1099
+ // the same primitive as the AEAD (AES-ECB vs ChaCha20), so `algo` is needed
1100
+ // here, not just at deprotection time further down.
1101
+ let algo = keys.algo || (keys.key.length === 32 ? 'aes-256-gcm' : 'aes-128-gcm');
1102
+ let isChaCha = algo === 'chacha20-poly1305';
1103
+
1104
+ // Decrypt record number. Returns null for a ciphertext under 16 bytes, which
1105
+ // RFC 9147 §4.2.3 requires us to reject as a deprotection failure.
1106
+ let rn = maskRecordNumber(keys.snKey, encRn, ciphertext, algo);
1107
+ if (rn === null) return null;
945
1108
  let truncated = hdr.seqLen2 ? ((rn[0] << 8) | rn[1]) : rn[0];
946
1109
 
947
1110
  // RFC 9147 §4.2.2: the wire carries only the low 8 or 16 bits of the record
@@ -968,10 +1131,6 @@ function decryptEncryptedDtls13(data, keys) {
968
1131
  aad[1 + rnLen + 1] = ctLen & 0xFF;
969
1132
  }
970
1133
 
971
- let algo = keys.algo || (keys.key.length === 32 ? 'aes-256-gcm' : 'aes-128-gcm');
972
- let isChaCha = algo === 'chacha20-poly1305';
973
-
974
- if (ciphertext.length < 16) return null;
975
1134
  let ct = ciphertext.subarray(0, ciphertext.length - 16);
976
1135
  let tag = ciphertext.subarray(ciphertext.length - 16);
977
1136
 
@@ -1009,7 +1168,8 @@ function decryptEncryptedDtls13(data, keys) {
1009
1168
  seq = acceptedSeq;
1010
1169
 
1011
1170
  {
1012
- let inner = parseInnerPlaintext(new Uint8Array(pt));
1171
+ // A view, not a copy: pt is the decipher's fresh Buffer, owned by no one else.
1172
+ let inner = parseInnerPlaintext(new Uint8Array(pt.buffer, pt.byteOffset, pt.byteLength));
1013
1173
 
1014
1174
  // RFC 9147 §4.5.1 anti-replay: only AFTER the tag verifies do we consult
1015
1175
  // and update the sliding window. Checking before deprotection would let a
@@ -1038,7 +1198,9 @@ function decryptEncryptedDtls13(data, keys) {
1038
1198
  }
1039
1199
 
1040
1200
  return {
1041
- epoch: hdr.epoch,
1201
+ // The caller resolves the full epoch (resolveReadEpoch); hdr.epoch is
1202
+ // only its low 2 bits and must never escape this function as an epoch.
1203
+ epoch: (fullEpoch === undefined || fullEpoch === null) ? hdr.epoch : fullEpoch,
1042
1204
  seq: seq,
1043
1205
  type: inner.type,
1044
1206
  content: inner.content,
@@ -1066,7 +1228,9 @@ function parseDtlsDatagram(data, keysByEpoch) {
1066
1228
 
1067
1229
  if (isUnifiedHdr(first)) {
1068
1230
  let hdr = parseUnifiedHdr(first);
1069
- let keys = keysByEpoch ? keysByEpoch[hdr.epoch] : null;
1231
+ // Header carries 2 epoch bits; map them back to a real epoch first.
1232
+ let fullEpoch = resolveReadEpoch(hdr.epoch, keysByEpoch);
1233
+ let keys = (fullEpoch === null) ? null : keysByEpoch[fullEpoch];
1070
1234
 
1071
1235
  if (!keys) {
1072
1236
  // No keys for this epoch YET. Do not discard the record: a DTLS peer
@@ -1090,8 +1254,11 @@ function parseDtlsDatagram(data, keysByEpoch) {
1090
1254
  if (total <= 0 || off + total > data.length) total = data.length - off;
1091
1255
 
1092
1256
  records.push({
1257
+ // Unresolvable epoch: keep the raw bits so the deferred retry can
1258
+ // resolve again once keys for that generation are installed.
1093
1259
  type: null,
1094
1260
  epoch: hdr.epoch,
1261
+ epochBits: hdr.epoch,
1095
1262
  seq: null,
1096
1263
  content: null,
1097
1264
  encrypted: false,
@@ -1102,7 +1269,7 @@ function parseDtlsDatagram(data, keysByEpoch) {
1102
1269
  continue;
1103
1270
  }
1104
1271
 
1105
- let result = decryptEncryptedDtls13(data.subarray(off), keys);
1272
+ let result = decryptEncryptedDtls13(data.subarray(off), keys, fullEpoch);
1106
1273
  if (result) {
1107
1274
  records.push({
1108
1275
  type: result.type,
@@ -1167,13 +1334,25 @@ function parseDtlsDatagram(data, keysByEpoch) {
1167
1334
 
1168
1335
  /** Build ACK payload. acks: [{ epoch, seq }, ...] */
1169
1336
  function buildDtlsAck(acks) {
1170
- let bodyLen = acks.length * 8;
1337
+ // RFC 9147 §7:
1338
+ // struct { uint64 epoch; uint64 sequence_number; } RecordNumber;
1339
+ // struct { RecordNumber record_numbers<0..2^16-1>; } ACK;
1340
+ // SIXTEEN bytes per entry. This wrote eight — a uint16 epoch and a uint48
1341
+ // sequence number, which is the DTLS *1.2* record-number layout carried over
1342
+ // by mistake. A conforming peer reads a record_numbers vector whose length is
1343
+ // not a multiple of 16 and answers decode_error.
1344
+ //
1345
+ // It stayed hidden because the only ACKs this stack sent were during the
1346
+ // handshake, where they are usually redundant, and because parseDtlsAck made
1347
+ // the identical mistake — so two copies of this library agreed with each
1348
+ // other and the peer's own well-formed ACKs were quietly misread.
1349
+ let bodyLen = acks.length * 16;
1171
1350
  let out = new Uint8Array(2 + bodyLen);
1172
1351
  let off = 0;
1173
1352
  off = w_u16(out, off, bodyLen);
1174
1353
  for (let i = 0; i < acks.length; i++) {
1175
- off = w_u16(out, off, acks[i].epoch);
1176
- off = w_u48(out, off, acks[i].seq);
1354
+ off = w_u64(out, off, acks[i].epoch);
1355
+ off = w_u64(out, off, acks[i].seq);
1177
1356
  }
1178
1357
  return out;
1179
1358
  }
@@ -1184,9 +1363,10 @@ function parseDtlsAck(data) {
1184
1363
  let off = 2;
1185
1364
  let end = off + bodyLen;
1186
1365
  let acks = [];
1187
- while (off + 8 <= end) {
1188
- let epoch = readU16(data, off); off += 2;
1189
- let seq = readU48(data, off); off += 6;
1366
+ // 16 bytes per RecordNumber — see buildDtlsAck.
1367
+ while (off + 16 <= end && off + 16 <= data.length) {
1368
+ let epoch = readU64(data, off); off += 8;
1369
+ let seq = readU64(data, off); off += 8;
1190
1370
  acks.push({ epoch, seq });
1191
1371
  }
1192
1372
  return acks;
@@ -1199,6 +1379,9 @@ export {
1199
1379
  // AEAD
1200
1380
  getAeadAlgo,
1201
1381
 
1382
+ // DTLS 1.3 epoch resolution
1383
+ resolveReadEpoch,
1384
+
1202
1385
  // TLS 1.3
1203
1386
  deriveKeys,
1204
1387
  getNonce,
@@ -1253,4 +1436,4 @@ export {
1253
1436
  // DTLS 1.3 ACK
1254
1437
  buildDtlsAck,
1255
1438
  parseDtlsAck,
1256
- };
1439
+ };