lemon-tls 0.2.2 → 0.4.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/src/record.js CHANGED
@@ -42,46 +42,189 @@ function deriveKeys(trafficSecret, cipherSuite) {
42
42
  };
43
43
  }
44
44
 
45
- /** TLS 1.3 nonce: IV XOR zero-padded 64-bit sequence number. */
45
+ /**
46
+ * TLS 1.3 nonce: IV XOR zero-padded 64-bit sequence number.
47
+ *
48
+ * Perf-critical: called on every encrypted record. Uses Number arithmetic
49
+ * (safe for seq < 2^53, vastly exceeds practical TLS limits) instead of BigInt,
50
+ * which is ~20x slower in V8. Single allocation, unrolled XOR loop.
51
+ */
46
52
  function getNonce(iv, seq) {
47
- const seqBuf = new Uint8Array(12);
48
- const view = new DataView(seqBuf.buffer);
49
- view.setBigUint64(4, BigInt(seq));
50
53
  const nonce = new Uint8Array(12);
51
- for (let i = 0; i < 12; i++) nonce[i] = iv[i] ^ seqBuf[i];
54
+ // High 4 bytes of IV are XOR'd with zeros (seq top bits) — just copy
55
+ nonce[0] = iv[0]; nonce[1] = iv[1]; nonce[2] = iv[2]; nonce[3] = iv[3];
56
+ // Split seq into hi/lo 32-bit halves (seq < 2^53 is safe)
57
+ const hi = (seq / 0x100000000) | 0;
58
+ const lo = seq >>> 0;
59
+ nonce[4] = iv[4] ^ ((hi >>> 24) & 0xff);
60
+ nonce[5] = iv[5] ^ ((hi >>> 16) & 0xff);
61
+ nonce[6] = iv[6] ^ ((hi >>> 8) & 0xff);
62
+ nonce[7] = iv[7] ^ ( hi & 0xff);
63
+ nonce[8] = iv[8] ^ ((lo >>> 24) & 0xff);
64
+ nonce[9] = iv[9] ^ ((lo >>> 16) & 0xff);
65
+ nonce[10] = iv[10] ^ ((lo >>> 8) & 0xff);
66
+ nonce[11] = iv[11] ^ ( lo & 0xff);
52
67
  return nonce;
53
68
  }
54
69
 
55
70
  /**
56
- * Encrypt a TLS 1.3 record (TLSInnerPlaintext).
71
+ * Same as getNonce but writes into a provided output buffer instead of
72
+ * allocating a fresh one. Returns the same buffer for chaining.
73
+ *
74
+ * Use this when you have a per-connection reusable nonce scratch buffer —
75
+ * TLS processing is synchronous per connection, and Node's crypto.createCipheriv
76
+ * copies the nonce into OpenSSL state, so we can safely reuse the same buffer
77
+ * across records.
78
+ *
79
+ * Saves 12 bytes × records of allocations. For a 10MB transfer (640 records)
80
+ * that's ~7.7KB of garbage eliminated.
81
+ */
82
+ function getNonceInto(out, iv, seq) {
83
+ out[0] = iv[0]; out[1] = iv[1]; out[2] = iv[2]; out[3] = iv[3];
84
+ const hi = (seq / 0x100000000) | 0;
85
+ const lo = seq >>> 0;
86
+ out[4] = iv[4] ^ ((hi >>> 24) & 0xff);
87
+ out[5] = iv[5] ^ ((hi >>> 16) & 0xff);
88
+ out[6] = iv[6] ^ ((hi >>> 8) & 0xff);
89
+ out[7] = iv[7] ^ ( hi & 0xff);
90
+ out[8] = iv[8] ^ ((lo >>> 24) & 0xff);
91
+ out[9] = iv[9] ^ ((lo >>> 16) & 0xff);
92
+ out[10] = iv[10] ^ ((lo >>> 8) & 0xff);
93
+ out[11] = iv[11] ^ ( lo & 0xff);
94
+ return out;
95
+ }
96
+
97
+ /**
98
+ * Encrypt a TLS 1.3 record (TLSInnerPlaintext = plaintext || inner_content_type).
57
99
  * Returns ciphertext || tag (without record header).
100
+ *
101
+ * Perf: uses two cipher.update() calls (plaintext, then 1-byte inner type) instead
102
+ * of allocating and copying a +1-byte buffer. For 16KB records this saves ~16KB
103
+ * of allocation and copy per encrypted record.
58
104
  */
59
105
  function encryptRecord(innerType, plaintext, key, nonce, algo) {
60
- const full = new Uint8Array(plaintext.length + 1);
61
- full.set(plaintext);
62
- full[plaintext.length] = innerType;
63
-
64
- const recLen = full.length + 16;
65
- const aad = new Uint8Array([0x17, 0x03, 0x03, (recLen >>> 8) & 0xff, recLen & 0xff]);
106
+ const ptLen = plaintext.length;
107
+ const recLen = ptLen + 1 + 16; // plaintext + inner_type + tag
108
+ const aad = new Uint8Array(5);
109
+ aad[0] = 0x17; aad[1] = 0x03; aad[2] = 0x03;
110
+ aad[3] = (recLen >>> 8) & 0xff;
111
+ aad[4] = recLen & 0xff;
66
112
 
67
113
  if (!algo) algo = key.length === 16 ? 'aes-128-gcm' : 'aes-256-gcm';
68
- let isChaCha = algo === 'chacha20-poly1305';
69
- let cipher = crypto.createCipheriv(algo, key, nonce, isChaCha ? { authTagLength: 16 } : undefined);
70
- cipher.setAAD(aad, isChaCha ? { plaintextLength: full.length } : undefined);
71
- let ct = cipher.update(full);
114
+ const isChaCha = algo === 'chacha20-poly1305';
115
+ const cipher = crypto.createCipheriv(algo, key, nonce, isChaCha ? { authTagLength: 16 } : undefined);
116
+ cipher.setAAD(aad, isChaCha ? { plaintextLength: ptLen + 1 } : undefined);
117
+
118
+ // Stream: plaintext first, then the 1-byte inner content type
119
+ const ct1 = cipher.update(plaintext);
120
+ // Reuse a single-byte scratch for inner type — tiny allocation, unavoidable
121
+ const innerBuf = new Uint8Array(1);
122
+ innerBuf[0] = innerType;
123
+ const ct2 = cipher.update(innerBuf);
72
124
  cipher.final();
73
- let tag = cipher.getAuthTag();
125
+ const tag = cipher.getAuthTag();
74
126
 
75
- let out = new Uint8Array(ct.length + tag.length);
76
- out.set(ct, 0);
77
- out.set(tag, ct.length);
127
+ // AES-GCM and ChaCha20-Poly1305 are stream ciphers → ct1.length + ct2.length === ptLen + 1
128
+ const out = new Uint8Array(ct1.length + ct2.length + tag.length);
129
+ out.set(ct1, 0);
130
+ if (ct2.length > 0) out.set(ct2, ct1.length);
131
+ out.set(tag, ct1.length + ct2.length);
78
132
  return out;
79
133
  }
80
134
 
135
+ // Pre-allocated single-byte buffers for inner content type. Avoids allocating a
136
+ // new 1-byte Uint8Array for every encrypted record (25 bytes × records adds up
137
+ // in garbage — 640 records for 10MB = 16KB of pointless allocations).
138
+ const _INNER_TYPE_BUFS = new Array(256);
139
+ for (let i = 0; i < 256; i++) {
140
+ const b = new Uint8Array(1);
141
+ b[0] = i;
142
+ _INNER_TYPE_BUFS[i] = b;
143
+ }
144
+
145
+ /**
146
+ * Encrypt a TLS 1.3 record directly into a complete TLS record buffer (5-byte
147
+ * header + ciphertext + inner type + tag), ready to hand to transport.write().
148
+ *
149
+ * Single-allocation + single-update hot-path version — the biggest throughput
150
+ * optimization we have for TLS 1.3 bulk data.
151
+ *
152
+ * Strategy:
153
+ * 1. Allocate rec of the full final size upfront.
154
+ * 2. Write record header (5 bytes) — also serves as AAD (TLS 1.3 AAD == header).
155
+ * 3. Stage plaintext + inner_type byte into rec[5 .. 5+ptLen+1]. This is a
156
+ * 16KB copy we pay for up front but it lets us call cipher.update ONCE
157
+ * with a single contiguous input.
158
+ * 4. cipher.setAAD(rec.subarray(0, 5)) — view, no alloc.
159
+ * 5. cipher.update(rec.subarray(5, 5+ptLen+1)) — one call, returns ct of
160
+ * same size (AES-GCM is a stream cipher). We then copy ct back into rec,
161
+ * overwriting the staged plaintext with ciphertext.
162
+ * 6. Write the 16-byte auth tag at the end.
163
+ *
164
+ * Why single-update beats two updates (plaintext then [inner_type]):
165
+ * - Each cipher.update call crosses the JS↔C boundary (~1-3μs overhead).
166
+ * For 640 records per 10MB transfer, skipping one call per record saves
167
+ * ~0.6-2ms per transfer.
168
+ * - Avoids the small Buffer object wrapper Node creates for the 1-byte ct2.
169
+ *
170
+ * Why the extra plaintext→rec copy is cheap:
171
+ * - memcpy throughput is ~20GB/s → a 16KB copy is ~800ns.
172
+ * - For 640 records, total extra copy time is ~500μs, dominated by the
173
+ * 2-4ms of cipher.update overhead we avoid.
174
+ *
175
+ * Net savings per record: ~1-2μs. Per 10MB transfer: ~1-2ms on encryption
176
+ * hot path — meaningful on top of the ~25ms actual AES work.
177
+ *
178
+ * Note: outer record type for encrypted TLS 1.3 records is ALWAYS 0x17
179
+ * (application_data) regardless of inner type — the real type is encrypted
180
+ * in the inner byte.
181
+ */
182
+ function encryptCompleteRecord13(innerType, plaintext, key, nonce, algo, version) {
183
+ const ptLen = plaintext.length;
184
+ const payloadLen = ptLen + 1 + 16; // inner_type + tag
185
+ const ver = version || 0x0303;
186
+
187
+ const rec = Buffer.allocUnsafe(5 + payloadLen);
188
+ // Record header (also used as AAD)
189
+ rec[0] = 0x17; // outer type always application_data for encrypted records
190
+ rec[1] = (ver >>> 8) & 0xff;
191
+ rec[2] = ver & 0xff;
192
+ rec[3] = (payloadLen >>> 8) & 0xff;
193
+ rec[4] = payloadLen & 0xff;
194
+
195
+ // Stage plaintext + inner_type byte at rec[5 .. 5+ptLen+1].
196
+ // cipher.update will read from this view, and we'll overwrite it with
197
+ // ciphertext immediately after.
198
+ if (plaintext.length > 0) {
199
+ // Buffer.prototype.copy and Uint8Array.set both work; .set is faster for Uint8Array src.
200
+ if (plaintext.copy) plaintext.copy(rec, 5);
201
+ else rec.set(plaintext, 5);
202
+ }
203
+ rec[5 + ptLen] = innerType & 0xff;
204
+
205
+ if (!algo) algo = key.length === 16 ? 'aes-128-gcm' : 'aes-256-gcm';
206
+ const isChaCha = algo === 'chacha20-poly1305';
207
+ const cipher = crypto.createCipheriv(algo, key, nonce, isChaCha ? { authTagLength: 16 } : undefined);
208
+ cipher.setAAD(rec.subarray(0, 5), isChaCha ? { plaintextLength: ptLen + 1 } : undefined);
209
+
210
+ // Single update reading plaintext+innerType from rec, writing ciphertext
211
+ // back into rec (overwriting the staged plaintext). ct.length === ptLen + 1.
212
+ const ct = cipher.update(rec.subarray(5, 5 + ptLen + 1));
213
+ ct.copy(rec, 5);
214
+
215
+ cipher.final();
216
+ cipher.getAuthTag().copy(rec, 5 + ct.length);
217
+
218
+ return rec;
219
+ }
220
+
81
221
  /**
82
222
  * Decrypt a TLS 1.3 record.
83
223
  * Input: raw ciphertext || tag (without record header).
84
224
  * Returns full TLSInnerPlaintext (content || content_type || padding).
225
+ *
226
+ * Perf: returns Node Buffer directly (which IS a Uint8Array subclass) — avoids
227
+ * a redundant copy into a plain Uint8Array.
85
228
  */
86
229
  function decryptRecord(ciphertext, key, nonce, algo) {
87
230
  const aad = new Uint8Array(5);
@@ -89,25 +232,59 @@ function decryptRecord(ciphertext, key, nonce, algo) {
89
232
  aad[3] = (ciphertext.length >> 8) & 0xff;
90
233
  aad[4] = ciphertext.length & 0xff;
91
234
 
92
- let ct = ciphertext.subarray(0, ciphertext.length - 16);
93
- let tag = ciphertext.subarray(ciphertext.length - 16);
235
+ const ct = ciphertext.subarray(0, ciphertext.length - 16);
236
+ const tag = ciphertext.subarray(ciphertext.length - 16);
94
237
 
95
238
  if (!algo) algo = key.length === 16 ? 'aes-128-gcm' : 'aes-256-gcm';
96
- let isChaCha = algo === 'chacha20-poly1305';
97
- let decipher = crypto.createDecipheriv(algo, key, nonce, isChaCha ? { authTagLength: 16 } : undefined);
239
+ const isChaCha = algo === 'chacha20-poly1305';
240
+ const decipher = crypto.createDecipheriv(algo, key, nonce, isChaCha ? { authTagLength: 16 } : undefined);
98
241
  decipher.setAAD(aad, isChaCha ? { plaintextLength: ct.length } : undefined);
99
242
  decipher.setAuthTag(tag);
100
- let pt = decipher.update(ct);
243
+ const pt = decipher.update(ct);
101
244
  decipher.final();
102
- return new Uint8Array(pt);
245
+ return pt;
246
+ }
247
+
248
+ /**
249
+ * Decrypt a TLS 1.3 record using an AAD view taken directly from the record
250
+ * buffer's header — no fresh AAD allocation. Use this when the caller has
251
+ * access to the original record header bytes (e.g., parseRecordsAndDispatch
252
+ * has the full readBuffer and knows the offset of the record).
253
+ *
254
+ * aadView must be the 5 bytes that precede `ciphertext` in the original record:
255
+ * [type, version_hi, version_lo, length_hi, length_lo]
256
+ *
257
+ * Saves a 5-byte allocation per decrypt. For 640 records in a 10MB transfer,
258
+ * that's 3.2KB of avoided allocations — small on its own but part of the
259
+ * ongoing effort to reduce per-record GC pressure.
260
+ */
261
+ function decryptRecordWithAadView(aadView, ciphertext, key, nonce, algo) {
262
+ const ct = ciphertext.subarray(0, ciphertext.length - 16);
263
+ const tag = ciphertext.subarray(ciphertext.length - 16);
264
+
265
+ if (!algo) algo = key.length === 16 ? 'aes-128-gcm' : 'aes-256-gcm';
266
+ const isChaCha = algo === 'chacha20-poly1305';
267
+ const decipher = crypto.createDecipheriv(algo, key, nonce, isChaCha ? { authTagLength: 16 } : undefined);
268
+ decipher.setAAD(aadView, isChaCha ? { plaintextLength: ct.length } : undefined);
269
+ decipher.setAuthTag(tag);
270
+ const pt = decipher.update(ct);
271
+ decipher.final();
272
+ return pt;
103
273
  }
104
274
 
105
275
  /** Strip trailing zeros and extract content_type from TLSInnerPlaintext. */
276
+ /**
277
+ * Strip trailing zeros and extract content_type from TLSInnerPlaintext.
278
+ *
279
+ * Perf: returns a `subarray` (zero-copy view) rather than `slice`. When `data` is
280
+ * a Node Buffer these are equivalent, but being explicit makes the behaviour
281
+ * uniform across Buffer / Uint8Array inputs and documents the intent.
282
+ */
106
283
  function parseInnerPlaintext(data) {
107
284
  let j = data.length - 1;
108
285
  while (j >= 0 && data[j] === 0) j--;
109
286
  if (j < 0) throw new Error('Malformed TLSInnerPlaintext');
110
- return { type: data[j], content: data.slice(0, j) };
287
+ return { type: data[j], content: data.subarray(0, j) };
111
288
  }
112
289
 
113
290
  // ===================== TLS 1.2 primitives =====================
@@ -120,18 +297,41 @@ function getNonce12(salt4, explicit8) {
120
297
  return out;
121
298
  }
122
299
 
123
- /** Encode sequence number as 8-byte big-endian. */
300
+ /**
301
+ * Encode sequence number as 8-byte big-endian.
302
+ * Perf: uses Number arithmetic (seq < 2^53 is always safe for TLS).
303
+ */
124
304
  function seqToBytes(seq) {
125
305
  const buf = new Uint8Array(8);
126
- let bn = BigInt(seq);
127
- for (let i = 0; i < 8; i++) buf[7 - i] = Number((bn >> BigInt(8 * i)) & 0xffn);
306
+ const hi = (seq / 0x100000000) | 0;
307
+ const lo = seq >>> 0;
308
+ buf[0] = (hi >>> 24) & 0xff;
309
+ buf[1] = (hi >>> 16) & 0xff;
310
+ buf[2] = (hi >>> 8) & 0xff;
311
+ buf[3] = hi & 0xff;
312
+ buf[4] = (lo >>> 24) & 0xff;
313
+ buf[5] = (lo >>> 16) & 0xff;
314
+ buf[6] = (lo >>> 8) & 0xff;
315
+ buf[7] = lo & 0xff;
128
316
  return buf;
129
317
  }
130
318
 
131
- /** TLS 1.2 AAD: seq(8) || type(1) || version(2) || length(2). */
319
+ /**
320
+ * TLS 1.2 AAD: seq(8) || type(1) || version(2) || length(2).
321
+ * Perf: inlines seqToBytes to avoid an extra allocation + copy.
322
+ */
132
323
  function buildAad12(seqNum, recordType, plaintextLen) {
133
324
  const aad = new Uint8Array(13);
134
- aad.set(seqToBytes(seqNum), 0);
325
+ const hi = (seqNum / 0x100000000) | 0;
326
+ const lo = seqNum >>> 0;
327
+ aad[0] = (hi >>> 24) & 0xff;
328
+ aad[1] = (hi >>> 16) & 0xff;
329
+ aad[2] = (hi >>> 8) & 0xff;
330
+ aad[3] = hi & 0xff;
331
+ aad[4] = (lo >>> 24) & 0xff;
332
+ aad[5] = (lo >>> 16) & 0xff;
333
+ aad[6] = (lo >>> 8) & 0xff;
334
+ aad[7] = lo & 0xff;
135
335
  aad[8] = recordType & 0xff;
136
336
  aad[9] = 0x03;
137
337
  aad[10] = 0x03;
@@ -140,44 +340,171 @@ function buildAad12(seqNum, recordType, plaintextLen) {
140
340
  return aad;
141
341
  }
142
342
 
143
- /** Encrypt a TLS 1.2 GCM record fragment. Returns explicit_nonce(8) || ciphertext || tag(16). */
343
+ /**
344
+ * Encrypt a TLS 1.2 GCM record fragment. Returns explicit_nonce(8) || ciphertext || tag(16).
345
+ * Perf: computes seq-bytes once for both explicit nonce and AAD (was computed twice).
346
+ */
144
347
  function encrypt12(pt, key, salt4, seqNum, recordType) {
145
- let explicit = seqToBytes(seqNum);
146
- let nonce = getNonce12(salt4, explicit);
147
- let aad = buildAad12(seqNum, recordType, pt.length);
348
+ // Shared big-endian seq encoding (used as both explicit_nonce and AAD[0..8])
349
+ const hi = (seqNum / 0x100000000) | 0;
350
+ const lo = seqNum >>> 0;
148
351
 
149
- let algo = key.length === 16 ? 'aes-128-gcm' : 'aes-256-gcm';
150
- let cipher = crypto.createCipheriv(algo, key, nonce);
352
+ // Nonce = salt4(4) || seq(8)
353
+ const nonce = new Uint8Array(12);
354
+ nonce[0] = salt4[0]; nonce[1] = salt4[1]; nonce[2] = salt4[2]; nonce[3] = salt4[3];
355
+ nonce[4] = (hi >>> 24) & 0xff;
356
+ nonce[5] = (hi >>> 16) & 0xff;
357
+ nonce[6] = (hi >>> 8) & 0xff;
358
+ nonce[7] = hi & 0xff;
359
+ nonce[8] = (lo >>> 24) & 0xff;
360
+ nonce[9] = (lo >>> 16) & 0xff;
361
+ nonce[10] = (lo >>> 8) & 0xff;
362
+ nonce[11] = lo & 0xff;
363
+
364
+ // AAD = seq(8) || type(1) || 03 03 || length(2)
365
+ const aad = new Uint8Array(13);
366
+ aad[0] = nonce[4]; aad[1] = nonce[5]; aad[2] = nonce[6]; aad[3] = nonce[7];
367
+ aad[4] = nonce[8]; aad[5] = nonce[9]; aad[6] = nonce[10]; aad[7] = nonce[11];
368
+ aad[8] = recordType & 0xff;
369
+ aad[9] = 0x03;
370
+ aad[10] = 0x03;
371
+ aad[11] = (pt.length >>> 8) & 0xff;
372
+ aad[12] = pt.length & 0xff;
373
+
374
+ const algo = key.length === 16 ? 'aes-128-gcm' : 'aes-256-gcm';
375
+ const cipher = crypto.createCipheriv(algo, key, nonce);
151
376
  cipher.setAAD(aad);
152
- let ct = cipher.update(pt);
377
+ const ct = cipher.update(pt);
153
378
  cipher.final();
154
- let tag = cipher.getAuthTag();
379
+ const tag = cipher.getAuthTag();
155
380
 
156
- let out = new Uint8Array(8 + ct.length + tag.length);
157
- out.set(explicit, 0);
381
+ // Output: explicit_nonce(8) || ct || tag(16)
382
+ const out = new Uint8Array(8 + ct.length + tag.length);
383
+ // explicit_nonce = seq bytes (nonce[4..12])
384
+ out[0] = nonce[4]; out[1] = nonce[5]; out[2] = nonce[6]; out[3] = nonce[7];
385
+ out[4] = nonce[8]; out[5] = nonce[9]; out[6] = nonce[10]; out[7] = nonce[11];
158
386
  out.set(ct, 8);
159
387
  out.set(tag, 8 + ct.length);
160
388
  return out;
161
389
  }
162
390
 
163
- /** Decrypt a TLS 1.2 GCM record fragment. Input: explicit_nonce(8) || ciphertext || tag(16). */
391
+ /**
392
+ * Encrypt a TLS 1.2 GCM record directly into a complete TLS record buffer
393
+ * (5-byte header + 8-byte explicit_iv + ciphertext + 16-byte tag).
394
+ *
395
+ * Same "single allocation, single bulk copy" principle as encryptCompleteRecord13,
396
+ * but TLS 1.2 has extra complications:
397
+ * - AAD is NOT the record header (it's seq || type || version || length)
398
+ * - Record body has an 8-byte explicit_iv prefix before the ciphertext
399
+ *
400
+ * The seq bytes used in nonce are the SAME bytes written as explicit_iv —
401
+ * so we write them once into rec[5..13] and reference them from there, and
402
+ * we copy them into the nonce inline. Old flow needed a separate 'out' buffer
403
+ * with explicit_iv || ct || tag plus an 8-byte out[0..7] copy; this eliminates
404
+ * both of those.
405
+ */
406
+ function encryptCompleteRecord12(pt, key, salt4, seqNum, recordType, version) {
407
+ const ptLen = pt.length;
408
+ // Record body: 8 explicit_iv + ptLen ciphertext + 16 tag
409
+ const bodyLen = 8 + ptLen + 16;
410
+ const ver = version || 0x0303;
411
+
412
+ const rec = Buffer.allocUnsafe(5 + bodyLen);
413
+ // Record header
414
+ rec[0] = recordType & 0xff;
415
+ rec[1] = (ver >>> 8) & 0xff;
416
+ rec[2] = ver & 0xff;
417
+ rec[3] = (bodyLen >>> 8) & 0xff;
418
+ rec[4] = bodyLen & 0xff;
419
+
420
+ // Write explicit_iv (= seq bytes) into rec[5..13]
421
+ const hi = (seqNum / 0x100000000) | 0;
422
+ const lo = seqNum >>> 0;
423
+ rec[5] = (hi >>> 24) & 0xff;
424
+ rec[6] = (hi >>> 16) & 0xff;
425
+ rec[7] = (hi >>> 8) & 0xff;
426
+ rec[8] = hi & 0xff;
427
+ rec[9] = (lo >>> 24) & 0xff;
428
+ rec[10] = (lo >>> 16) & 0xff;
429
+ rec[11] = (lo >>> 8) & 0xff;
430
+ rec[12] = lo & 0xff;
431
+
432
+ // Nonce = salt4(4) || seq(8) — use the seq bytes we just wrote
433
+ const nonce = new Uint8Array(12);
434
+ nonce[0] = salt4[0]; nonce[1] = salt4[1]; nonce[2] = salt4[2]; nonce[3] = salt4[3];
435
+ nonce[4] = rec[5]; nonce[5] = rec[6]; nonce[6] = rec[7]; nonce[7] = rec[8];
436
+ nonce[8] = rec[9]; nonce[9] = rec[10]; nonce[10] = rec[11]; nonce[11] = rec[12];
437
+
438
+ // AAD = seq(8) || type(1) || 03 03 || plaintextLen(2)
439
+ // Note: AAD uses the PLAINTEXT length, not the record body length.
440
+ const aad = new Uint8Array(13);
441
+ aad[0] = rec[5]; aad[1] = rec[6]; aad[2] = rec[7]; aad[3] = rec[8];
442
+ aad[4] = rec[9]; aad[5] = rec[10]; aad[6] = rec[11]; aad[7] = rec[12];
443
+ aad[8] = recordType & 0xff;
444
+ aad[9] = 0x03;
445
+ aad[10] = 0x03;
446
+ aad[11] = (ptLen >>> 8) & 0xff;
447
+ aad[12] = ptLen & 0xff;
448
+
449
+ const algo = key.length === 16 ? 'aes-128-gcm' : 'aes-256-gcm';
450
+ const cipher = crypto.createCipheriv(algo, key, nonce);
451
+ cipher.setAAD(aad);
452
+ const ct = cipher.update(pt);
453
+ cipher.final();
454
+
455
+ // Copy ciphertext into rec[13 : 13+ptLen]
456
+ ct.copy(rec, 13);
457
+ // Copy tag into rec[13+ptLen : 13+ptLen+16]
458
+ cipher.getAuthTag().copy(rec, 13 + ct.length);
459
+
460
+ return rec;
461
+ }
462
+
463
+ /**
464
+ * Decrypt a TLS 1.2 GCM record fragment. Input: explicit_nonce(8) || ciphertext || tag(16).
465
+ * Perf: subarray (zero-copy views) instead of slice (copies); returns Node Buffer
466
+ * (which IS a Uint8Array subclass) instead of re-copying to a plain Uint8Array.
467
+ */
164
468
  function decrypt12(fragment, key, salt4, seqNum, recordType) {
165
469
  if (fragment.length < 24) throw new Error('TLS 1.2 fragment too short');
166
470
 
167
- let explicit = fragment.slice(0, 8);
168
- let tag = fragment.slice(fragment.length - 16);
169
- let ct = fragment.slice(8, fragment.length - 16);
471
+ // Zero-copy views over the input
472
+ const explicit = fragment.subarray(0, 8);
473
+ const ct = fragment.subarray(8, fragment.length - 16);
474
+ const tag = fragment.subarray(fragment.length - 16);
475
+
476
+ // Nonce = salt4 || explicit
477
+ const nonce = new Uint8Array(12);
478
+ nonce[0] = salt4[0]; nonce[1] = salt4[1]; nonce[2] = salt4[2]; nonce[3] = salt4[3];
479
+ nonce[4] = explicit[0]; nonce[5] = explicit[1]; nonce[6] = explicit[2]; nonce[7] = explicit[3];
480
+ nonce[8] = explicit[4]; nonce[9] = explicit[5]; nonce[10] = explicit[6]; nonce[11] = explicit[7];
170
481
 
171
- let nonce = getNonce12(salt4, explicit);
172
- let aad = buildAad12(seqNum, recordType, ct.length);
482
+ // AAD = seq(8) || type(1) || 03 03 || ct.length(2)
483
+ const aad = new Uint8Array(13);
484
+ const hi = (seqNum / 0x100000000) | 0;
485
+ const lo = seqNum >>> 0;
486
+ aad[0] = (hi >>> 24) & 0xff;
487
+ aad[1] = (hi >>> 16) & 0xff;
488
+ aad[2] = (hi >>> 8) & 0xff;
489
+ aad[3] = hi & 0xff;
490
+ aad[4] = (lo >>> 24) & 0xff;
491
+ aad[5] = (lo >>> 16) & 0xff;
492
+ aad[6] = (lo >>> 8) & 0xff;
493
+ aad[7] = lo & 0xff;
494
+ aad[8] = recordType & 0xff;
495
+ aad[9] = 0x03;
496
+ aad[10] = 0x03;
497
+ aad[11] = (ct.length >>> 8) & 0xff;
498
+ aad[12] = ct.length & 0xff;
173
499
 
174
- let algo = key.length === 16 ? 'aes-128-gcm' : 'aes-256-gcm';
175
- let decipher = crypto.createDecipheriv(algo, key, nonce);
500
+ const algo = key.length === 16 ? 'aes-128-gcm' : 'aes-256-gcm';
501
+ const decipher = crypto.createDecipheriv(algo, key, nonce);
176
502
  decipher.setAAD(aad);
177
503
  decipher.setAuthTag(tag);
178
- let pt = decipher.update(ct);
504
+ const pt = decipher.update(ct);
179
505
  decipher.final();
180
- return new Uint8Array(pt);
506
+ // Node Buffer extends Uint8Array, so callers that expect Uint8Array work unmodified.
507
+ return pt;
181
508
  }
182
509
 
183
510
  // ===================== Shared: TLS 1.2 key_block =====================
@@ -198,16 +525,32 @@ function deriveKeys12(masterSecret, localRandom, remoteRandom, cipherSuite, isSe
198
525
  /** Content type constants. */
199
526
  const CT = { CHANGE_CIPHER_SPEC: 20, ALERT: 21, HANDSHAKE: 22, APPLICATION_DATA: 23, ACK: 26 };
200
527
 
201
- /** Write a raw TLS record to a writable stream. */
528
+ /**
529
+ * Write a raw TLS record to a writable stream.
530
+ *
531
+ * Returns the transport's backpressure signal (true = ready for more, false = buffer
532
+ * full, wait for 'drain'). Callers should propagate this through the TLS write chain
533
+ * so user-level sock.write() can apply backpressure correctly.
534
+ *
535
+ * Perf: single-copy from payload into the allocated record buffer, using
536
+ * typed-array `.set()` (fast native memcpy) instead of
537
+ * `Buffer.from(payload).copy(rec, 5)` which allocates an intermediate Buffer
538
+ * and does two copies. Also writes the 5-byte header with direct byte
539
+ * assignments to avoid method call overhead.
540
+ */
202
541
  function writeRecord(transport, type, payload, version) {
203
- if (!transport || typeof transport.write !== 'function') return;
204
- let ver = version || 0x0303;
205
- let rec = Buffer.allocUnsafe(5 + payload.length);
206
- rec.writeUInt8(type, 0);
207
- rec.writeUInt16BE(ver, 1);
208
- rec.writeUInt16BE(payload.length, 3);
209
- Buffer.from(payload).copy(rec, 5);
210
- transport.write(rec);
542
+ if (!transport || typeof transport.write !== 'function') return false;
543
+ const ver = version || 0x0303;
544
+ const plen = payload.length;
545
+ const rec = Buffer.allocUnsafe(5 + plen);
546
+ rec[0] = type;
547
+ rec[1] = (ver >>> 8) & 0xff;
548
+ rec[2] = ver & 0xff;
549
+ rec[3] = (plen >>> 8) & 0xff;
550
+ rec[4] = plen & 0xff;
551
+ // Buffer extends Uint8Array → .set() copies in a single pass from any TypedArray/Buffer.
552
+ rec.set(payload, 5);
553
+ return transport.write(rec);
211
554
  }
212
555
 
213
556
  // ===================== DTLS binary helpers =====================
@@ -666,8 +1009,11 @@ export {
666
1009
  // TLS 1.3
667
1010
  deriveKeys,
668
1011
  getNonce,
1012
+ getNonceInto, // write nonce into a caller-provided buffer (avoid alloc per record)
669
1013
  encryptRecord,
1014
+ encryptCompleteRecord13, // fused encrypt + header → single allocation hot path
670
1015
  decryptRecord,
1016
+ decryptRecordWithAadView, // decrypt using caller-provided header view as AAD (no alloc)
671
1017
  parseInnerPlaintext,
672
1018
 
673
1019
  // TLS 1.2
@@ -675,6 +1021,7 @@ export {
675
1021
  seqToBytes,
676
1022
  buildAad12,
677
1023
  encrypt12,
1024
+ encryptCompleteRecord12, // fused encrypt + header → single allocation hot path
678
1025
  decrypt12,
679
1026
  deriveKeys12,
680
1027
 
@@ -29,7 +29,7 @@ function normalize_hello(hello) {
29
29
  } else if (name === 'KEY_SHARE') {
30
30
  if (!('key_groups' in out)) out.key_groups = [];
31
31
  if (!('supported_groups' in out)) out.supported_groups = [];
32
- for (let i2 in value) {
32
+ for (let i2 = 0; i2 < value.length; i2++) {
33
33
  if (out.supported_groups.indexOf(value[i2].group) < 0) {
34
34
  out.supported_groups.push(value[i2].group);
35
35
  }
@@ -68,6 +68,15 @@ function normalize_hello(hello) {
68
68
  out.heartbeat = value;
69
69
  } else if (name === 'USE_SRTP') {
70
70
  out.use_srtp = value;
71
+ } else if (name === 'SESSION_TICKET') {
72
+ // RFC 5077: opaque ticket bytes (TLS 1.2)
73
+ // Empty in ClientHello = client supports tickets / non-empty = resume using this ticket
74
+ // Empty in ServerHello = server will send NewSessionTicket
75
+ out.session_ticket = value;
76
+ out.session_ticket_supported = true;
77
+ } else if (name === 'EXTENDED_MASTER_SECRET') {
78
+ // RFC 7627: presence signals EMS support
79
+ out.extended_master_secret = true;
71
80
  } else {
72
81
  if (!('unknown' in out)) out.unknown = [];
73
82
  out.unknown.push(e);
@@ -133,6 +142,12 @@ function build_tls_message(params) {
133
142
  } else if (params.type == 'hello_retry_request') {
134
143
  type = wire.TLS_MESSAGE_TYPE.SERVER_HELLO; // HRR uses ServerHello type with magic random
135
144
  body = wire.build_hello_retry_request(params);
145
+ } else if (params.type == 'new_session_ticket_tls12') {
146
+ type = wire.TLS_MESSAGE_TYPE.NEW_SESSION_TICKET;
147
+ body = wire.build_new_session_ticket_tls12(params);
148
+ } else if (params.type == 'new_session_ticket') {
149
+ type = wire.TLS_MESSAGE_TYPE.NEW_SESSION_TICKET;
150
+ body = wire.build_new_session_ticket(params);
136
151
  }
137
152
 
138
153
  return wire.build_message(type, body);
@@ -141,8 +156,11 @@ function build_tls_message(params) {
141
156
 
142
157
  /**
143
158
  * Parse a raw TLS handshake message into a typed object.
159
+ * @param {Uint8Array} data — handshake message bytes
160
+ * @param {number} [negotiatedVersion] — optional; 0x0303 for TLS 1.2, 0x0304 for TLS 1.3.
161
+ * Needed to disambiguate NewSessionTicket wire format.
144
162
  */
145
- function parse_tls_message(data) {
163
+ function parse_tls_message(data, negotiatedVersion) {
146
164
  let out = {};
147
165
  let message = wire.parse_message(data);
148
166
 
@@ -183,7 +201,14 @@ function parse_tls_message(data) {
183
201
  out.body = message.body;
184
202
 
185
203
  } else if (message.type == wire.TLS_MESSAGE_TYPE.NEW_SESSION_TICKET) {
186
- out = wire.parse_new_session_ticket(message.body);
204
+ // TLS 1.2 and 1.3 have different wire formats for this message.
205
+ // TLS 1.3: ticket_lifetime | ticket_age_add | ticket_nonce | ticket | extensions
206
+ // TLS 1.2: ticket_lifetime_hint | ticket (RFC 5077)
207
+ if (negotiatedVersion === 0x0303) {
208
+ out = wire.parse_new_session_ticket_tls12(message.body);
209
+ } else {
210
+ out = wire.parse_new_session_ticket(message.body);
211
+ }
187
212
  out.type = 'new_session_ticket';
188
213
 
189
214
  } else if (message.type == wire.TLS_MESSAGE_TYPE.KEY_UPDATE) {