lemon-tls 0.6.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +253 -20
- package/index.d.ts +94 -6
- package/index.js +126 -0
- package/package.json +2 -7
- package/src/compat.js +316 -45
- package/src/crypto.js +33 -3
- package/src/dtls_session.js +477 -41
- package/src/dtls_socket.js +32 -0
- package/src/node_options.js +138 -0
- package/src/record.js +210 -27
- package/src/secure_context.js +49 -4
- package/src/session/cert_compression.js +157 -0
- package/src/session/ecdh.js +237 -10
- package/src/session/ech.js +715 -0
- package/src/session/hpke.js +414 -0
- package/src/session/message.js +44 -5
- package/src/session/signing.js +56 -3
- package/src/session/ticket.js +44 -28
- package/src/tls_session.js +3003 -217
- package/src/tls_socket.js +839 -149
- package/src/utils.js +75 -2
- package/src/wire.js +220 -13
package/src/dtls_socket.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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
|
-
//
|
|
944
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
1176
|
-
off =
|
|
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
|
-
|
|
1188
|
-
|
|
1189
|
-
let
|
|
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
|
+
};
|