cubyz-node-client 1.2.0 → 1.3.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.
@@ -0,0 +1,722 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { createCipheriv, createDecipheriv, createHash, createHmac, createPublicKey, diffieHellman, generateKeyPairSync, randomBytes, } from "node:crypto";
3
+ import { writeInt32BE } from "./binary.js";
4
+ // MSB-first varint encoding (Zig BinaryWriter.writeVarInt format)
5
+ // This is the format used for framing messages sent over the TLS channel.
6
+ export function encodeMsbVarInt(value) {
7
+ if (value === 0)
8
+ return Buffer.from([0]);
9
+ const bits = Math.floor(Math.log2(value)) + 1;
10
+ const numBytes = Math.ceil(bits / 7);
11
+ const result = Buffer.alloc(numBytes);
12
+ for (let i = 0; i < numBytes; i++) {
13
+ const shift = 7 * (numBytes - i - 1);
14
+ result[i] = ((value >> shift) & 0x7f) | (i === numBytes - 1 ? 0 : 0x80);
15
+ }
16
+ return result;
17
+ }
18
+ // ---------------------------------------------------------------------------
19
+ // TLS 1.3 constants
20
+ // ---------------------------------------------------------------------------
21
+ const TLS_VERSION_12 = 0x0303; // used in legacy_version fields
22
+ const _TLS_VERSION_13 = 0x0304;
23
+ const CONTENT_HANDSHAKE = 0x16;
24
+ const CONTENT_CCS = 0x14;
25
+ const CONTENT_APP_DATA = 0x17;
26
+ const HS_CLIENT_HELLO = 1;
27
+ const HS_SERVER_HELLO = 2;
28
+ const HS_FINISHED = 20;
29
+ const EXT_SUPPORTED_VERSIONS = 0x002b;
30
+ const EXT_SUPPORTED_GROUPS = 0x000a;
31
+ const EXT_KEY_SHARE = 0x0033;
32
+ const EXT_SIG_ALGS = 0x000d;
33
+ const GROUP_X25519 = 0x001d;
34
+ const CIPHER_AES256GCM_SHA384 = 0x1302;
35
+ const CIPHER_AES128GCM_SHA256 = 0x1301;
36
+ const CIPHER_CHACHA20_SHA256 = 0x1303;
37
+ // ---------------------------------------------------------------------------
38
+ // TLS 1.3 key schedule helpers (RFC 8446)
39
+ // ---------------------------------------------------------------------------
40
+ // HKDF-Expand-Label: https://www.rfc-editor.org/rfc/rfc8446#section-7.1
41
+ // HKDF-Expand (RFC 5869 §2.3) — pure expand, no extract step.
42
+ // Needed because Node's hkdfSync does Extract+Expand combined.
43
+ function hkdfExpand(prk, info, length) {
44
+ const hashLen = 48; // SHA-384
45
+ const n = Math.ceil(length / hashLen);
46
+ const okm = Buffer.alloc(n * hashLen);
47
+ let t = Buffer.alloc(0);
48
+ for (let i = 1; i <= n; i++) {
49
+ const input = Buffer.concat([t, info, Buffer.from([i])]);
50
+ t = Buffer.from(createHmac("sha384", prk).update(input).digest());
51
+ t.copy(okm, (i - 1) * hashLen);
52
+ }
53
+ return okm.slice(0, length);
54
+ }
55
+ function expandLabel(secret, label, context, length, _hash) {
56
+ const labelBuf = Buffer.from(`tls13 ${label}`, "utf8");
57
+ const hkdfLabelBuf = Buffer.alloc(2 + 1 + labelBuf.length + 1 + context.length);
58
+ let off = 0;
59
+ hkdfLabelBuf.writeUInt16BE(length, off);
60
+ off += 2;
61
+ hkdfLabelBuf[off++] = labelBuf.length;
62
+ labelBuf.copy(hkdfLabelBuf, off);
63
+ off += labelBuf.length;
64
+ hkdfLabelBuf[off++] = context.length;
65
+ context.copy(hkdfLabelBuf, off);
66
+ return hkdfExpand(secret, hkdfLabelBuf, length);
67
+ }
68
+ // HKDF-Extract
69
+ function hkdfExtract(salt, ikm) {
70
+ return createHmac("sha384", salt).update(ikm).digest();
71
+ }
72
+ // Derive-Secret(secret, label, messages)
73
+ function deriveSecret(secret, label, transcript) {
74
+ const hashLen = 48; // SHA-384
75
+ const h = createHash("sha384").update(transcript).digest();
76
+ return expandLabel(secret, label, Buffer.from(h), hashLen, "sha384");
77
+ }
78
+ function deriveTrafficKeys(secret) {
79
+ const key = expandLabel(secret, "key", Buffer.alloc(0), 32, "sha384"); // AES-256 = 32 bytes
80
+ const iv = expandLabel(secret, "iv", Buffer.alloc(0), 12, "sha384");
81
+ return { key, iv };
82
+ }
83
+ // Build per-record nonce: IV XOR seq (RFC 8446 §5.3)
84
+ function buildNonce(iv, seq) {
85
+ const nonce = Buffer.from(iv);
86
+ for (let i = 0; i < 8; i++) {
87
+ nonce[iv.length - 1 - i] ^= Number((seq >> BigInt(8 * i)) & 0xffn);
88
+ }
89
+ return nonce;
90
+ }
91
+ // Encrypt a TLS 1.3 record. Appends the inner content type byte.
92
+ function encryptRecord(plaintext, innerContentType, key, iv, seq) {
93
+ const inner = Buffer.concat([plaintext, Buffer.from([innerContentType])]);
94
+ const nonce = buildNonce(iv, seq);
95
+ // Additional data: TLSCiphertext header (content_type=ApplicationData, version=TLS1.2, length=inner+16)
96
+ const aad = Buffer.alloc(5);
97
+ aad[0] = CONTENT_APP_DATA;
98
+ aad.writeUInt16BE(TLS_VERSION_12, 1);
99
+ aad.writeUInt16BE(inner.length + 16, 3);
100
+ const cipher = createCipheriv("aes-256-gcm", key, nonce);
101
+ cipher.setAAD(aad);
102
+ const encrypted = Buffer.concat([cipher.update(inner), cipher.final()]);
103
+ const authTag = cipher.getAuthTag();
104
+ return Buffer.concat([aad, encrypted, authTag]);
105
+ }
106
+ // Decrypt a TLS 1.3 AppData record. Returns { plaintext, innerContentType }.
107
+ function decryptRecord(record, // full TLS record including 5-byte header
108
+ key, iv, seq) {
109
+ if (record.length < 5 + 16) {
110
+ return null;
111
+ }
112
+ const ciphertextWithTag = record.slice(5);
113
+ const aad = record.slice(0, 5);
114
+ const tagOff = ciphertextWithTag.length - 16;
115
+ const ciphertext = ciphertextWithTag.slice(0, tagOff);
116
+ const authTag = ciphertextWithTag.slice(tagOff);
117
+ const nonce = buildNonce(iv, seq);
118
+ try {
119
+ const decipher = createDecipheriv("aes-256-gcm", key, nonce);
120
+ decipher.setAAD(aad);
121
+ decipher.setAuthTag(authTag);
122
+ const inner = Buffer.concat([
123
+ decipher.update(ciphertext),
124
+ decipher.final(),
125
+ ]);
126
+ // Strip inner content type (last non-zero byte)
127
+ let padEnd = inner.length - 1;
128
+ while (padEnd > 0 && inner[padEnd] === 0) {
129
+ padEnd--;
130
+ }
131
+ const innerContentType = inner[padEnd];
132
+ const plaintext = inner.slice(0, padEnd);
133
+ return { plaintext, innerContentType };
134
+ }
135
+ catch {
136
+ return null;
137
+ }
138
+ }
139
+ // ---------------------------------------------------------------------------
140
+ // TLS record serialisation helpers
141
+ // ---------------------------------------------------------------------------
142
+ function makeTlsRecord(contentType, data) {
143
+ const rec = Buffer.alloc(5 + data.length);
144
+ rec[0] = contentType;
145
+ rec.writeUInt16BE(TLS_VERSION_12, 1);
146
+ rec.writeUInt16BE(data.length, 3);
147
+ data.copy(rec, 5);
148
+ return rec;
149
+ }
150
+ function makeHandshakeMsg(handshakeType, body) {
151
+ const msg = Buffer.alloc(4 + body.length);
152
+ msg[0] = handshakeType;
153
+ msg[1] = (body.length >> 16) & 0xff;
154
+ msg[2] = (body.length >> 8) & 0xff;
155
+ msg[3] = body.length & 0xff;
156
+ body.copy(msg, 4);
157
+ return msg;
158
+ }
159
+ // ---------------------------------------------------------------------------
160
+ // ClientHello builder
161
+ // ---------------------------------------------------------------------------
162
+ function buildClientHello(clientRandom, x25519PubKey) {
163
+ // Extensions
164
+ const extSupportedVersions = (() => {
165
+ const body = Buffer.from([0x02, 0x03, 0x04]); // list-len=2, TLS 1.3
166
+ const ext = Buffer.alloc(4 + body.length);
167
+ ext.writeUInt16BE(EXT_SUPPORTED_VERSIONS, 0);
168
+ ext.writeUInt16BE(body.length, 2);
169
+ body.copy(ext, 4);
170
+ return ext;
171
+ })();
172
+ const extSupportedGroups = (() => {
173
+ // x25519 only
174
+ const body = Buffer.from([0x00, 0x02, 0x00, 0x1d]);
175
+ const ext = Buffer.alloc(4 + body.length);
176
+ ext.writeUInt16BE(EXT_SUPPORTED_GROUPS, 0);
177
+ ext.writeUInt16BE(body.length, 2);
178
+ body.copy(ext, 4);
179
+ return ext;
180
+ })();
181
+ const extSigAlgs = (() => {
182
+ // rsa_pss_rsae_sha256 (0x0804), ecdsa_secp256r1_sha256 (0x0403),
183
+ // rsa_pkcs1_sha256 (0x0401), ed25519 (0x0807)
184
+ const body = Buffer.from([
185
+ 0x00,
186
+ 0x08, // list length
187
+ 0x08,
188
+ 0x04, // rsa_pss_rsae_sha256
189
+ 0x08,
190
+ 0x05, // rsa_pss_rsae_sha384
191
+ 0x04,
192
+ 0x03, // ecdsa_secp256r1_sha256
193
+ 0x08,
194
+ 0x07, // ed25519
195
+ ]);
196
+ const ext = Buffer.alloc(4 + body.length);
197
+ ext.writeUInt16BE(EXT_SIG_ALGS, 0);
198
+ ext.writeUInt16BE(body.length, 2);
199
+ body.copy(ext, 4);
200
+ return ext;
201
+ })();
202
+ const extKeyShare = (() => {
203
+ // key_share: one x25519 entry
204
+ const entry = Buffer.concat([
205
+ Buffer.from([0x00, GROUP_X25519 & 0xff]), // actually 0x00, 0x1d
206
+ Buffer.from([0x00, 0x20]), // key length 32
207
+ x25519PubKey,
208
+ ]);
209
+ const clientSharesLen = entry.length;
210
+ const body = Buffer.alloc(2 + clientSharesLen);
211
+ body.writeUInt16BE(clientSharesLen, 0);
212
+ entry.copy(body, 2);
213
+ const ext = Buffer.alloc(4 + body.length);
214
+ ext.writeUInt16BE(EXT_KEY_SHARE, 0);
215
+ ext.writeUInt16BE(body.length, 2);
216
+ body.copy(ext, 4);
217
+ return ext;
218
+ })();
219
+ const extensions = Buffer.concat([
220
+ extSupportedVersions,
221
+ extSupportedGroups,
222
+ extSigAlgs,
223
+ extKeyShare,
224
+ ]);
225
+ // Cipher suites: TLS_AES_256_GCM_SHA384, TLS_AES_128_GCM_SHA256, TLS_CHACHA20_POLY1305_SHA256
226
+ const cipherSuites = Buffer.from([
227
+ 0x00,
228
+ 0x06, // length 6 = 3 suites
229
+ (CIPHER_AES256GCM_SHA384 >> 8) & 0xff,
230
+ CIPHER_AES256GCM_SHA384 & 0xff,
231
+ (CIPHER_AES128GCM_SHA256 >> 8) & 0xff,
232
+ CIPHER_AES128GCM_SHA256 & 0xff,
233
+ (CIPHER_CHACHA20_SHA256 >> 8) & 0xff,
234
+ CIPHER_CHACHA20_SHA256 & 0xff,
235
+ ]);
236
+ const sessionId = randomBytes(32);
237
+ // Build ClientHello body
238
+ const extLenBuf = Buffer.alloc(2);
239
+ extLenBuf.writeUInt16BE(extensions.length, 0);
240
+ const chBody = Buffer.concat([
241
+ Buffer.from([0x03, 0x03]), // legacy_version = TLS 1.2
242
+ clientRandom,
243
+ Buffer.from([sessionId.length]),
244
+ sessionId,
245
+ cipherSuites,
246
+ Buffer.from([0x01, 0x00]), // compression methods: [null]
247
+ extLenBuf,
248
+ extensions,
249
+ ]);
250
+ return makeHandshakeMsg(HS_CLIENT_HELLO, chBody);
251
+ }
252
+ class TlsRecordParser {
253
+ buf = Buffer.alloc(0);
254
+ feed(data) {
255
+ this.buf = Buffer.concat([this.buf, data]);
256
+ const records = [];
257
+ while (this.buf.length >= 5) {
258
+ const len = this.buf.readUInt16BE(3);
259
+ if (this.buf.length < 5 + len) {
260
+ break;
261
+ }
262
+ records.push({
263
+ contentType: this.buf[0],
264
+ data: this.buf.slice(5, 5 + len),
265
+ });
266
+ this.buf = this.buf.slice(5 + len);
267
+ }
268
+ return records;
269
+ }
270
+ }
271
+ function parseServerHello(data) {
272
+ if (data.length < 38) {
273
+ return null;
274
+ }
275
+ // handshake type (1) + len (3) = 4 bytes header
276
+ if (data[0] !== HS_SERVER_HELLO) {
277
+ return null;
278
+ }
279
+ // legacy_version (2) + random (32) = at offset 4
280
+ const serverRandom = data.slice(4 + 2, 4 + 2 + 32);
281
+ const sessionIdLen = data[4 + 2 + 32];
282
+ const off = 4 + 2 + 32 + 1 + sessionIdLen;
283
+ if (off + 4 > data.length) {
284
+ return null;
285
+ }
286
+ const cipherSuite = data.readUInt16BE(off);
287
+ // Skip compression (1 byte), then extensions
288
+ const extTotalLen = data.readUInt16BE(off + 3);
289
+ let eo = off + 5;
290
+ let serverX25519PubKey = null;
291
+ while (eo + 4 <= off + 5 + extTotalLen && eo + 4 <= data.length) {
292
+ const et = data.readUInt16BE(eo);
293
+ const el = data.readUInt16BE(eo + 2);
294
+ if (et === EXT_KEY_SHARE && eo + 8 <= data.length) {
295
+ // group (2) + key_exchange_length (2) + key_exchange (...)
296
+ const group = data.readUInt16BE(eo + 4);
297
+ const kl = data.readUInt16BE(eo + 6);
298
+ if (group === GROUP_X25519 && kl === 32 && eo + 8 + kl <= data.length) {
299
+ serverX25519PubKey = data.slice(eo + 8, eo + 8 + kl);
300
+ }
301
+ }
302
+ eo += 4 + el;
303
+ }
304
+ return { serverRandom, cipherSuite, serverX25519PubKey };
305
+ }
306
+ class FramedMessageDecoder {
307
+ recvBuf = Buffer.alloc(0);
308
+ partialProtocolId = null;
309
+ partialSize = null;
310
+ onMessage = null;
311
+ onError = null;
312
+ feed(chunk) {
313
+ this.recvBuf = Buffer.concat([this.recvBuf, chunk]);
314
+ this.drain();
315
+ }
316
+ drain() {
317
+ while (true) {
318
+ if (this.partialProtocolId === null) {
319
+ if (this.recvBuf.length < 1) {
320
+ break;
321
+ }
322
+ this.partialProtocolId = this.recvBuf[0];
323
+ this.partialSize = null;
324
+ this.recvBuf = this.recvBuf.slice(1);
325
+ }
326
+ if (this.partialSize === null) {
327
+ let size = 0;
328
+ let bytesRead = 0;
329
+ let found = false;
330
+ while (bytesRead < this.recvBuf.length) {
331
+ const byte = this.recvBuf[bytesRead];
332
+ size = (size << 7) | (byte & 0x7f);
333
+ bytesRead += 1;
334
+ if ((byte & 0x80) === 0) {
335
+ found = true;
336
+ break;
337
+ }
338
+ if (bytesRead > 5) {
339
+ this.onError?.(new Error("SecureChannel: varint size exceeds 5 bytes"));
340
+ return;
341
+ }
342
+ }
343
+ if (!found) {
344
+ break;
345
+ }
346
+ this.partialSize = size;
347
+ this.recvBuf = this.recvBuf.slice(bytesRead);
348
+ }
349
+ if (this.recvBuf.length < this.partialSize) {
350
+ break;
351
+ }
352
+ const payload = this.recvBuf.slice(0, this.partialSize);
353
+ this.recvBuf = this.recvBuf.slice(this.partialSize);
354
+ const protocolId = this.partialProtocolId;
355
+ this.partialProtocolId = null;
356
+ this.partialSize = null;
357
+ this.onMessage?.({ protocolId, payload });
358
+ }
359
+ }
360
+ }
361
+ // ---------------------------------------------------------------------------
362
+ // SecureChannelHandler – manual TLS 1.3
363
+ // ---------------------------------------------------------------------------
364
+ /**
365
+ * SecureChannelHandler drives a manual TLS 1.3 handshake over the Cubyz
366
+ * UDP channel 1 (SECURE).
367
+ *
368
+ * We implement TLS 1.3 ourselves because the Cubyz server generates a
369
+ * self-signed RSA-PSS certificate with an empty serialNumber (ASN.1
370
+ * INTEGER of length 0), which OpenSSL 3 rejects even with
371
+ * `rejectUnauthorized: false` — the error fires during record parsing,
372
+ * not during certificate verification.
373
+ *
374
+ * We only support TLS_AES_256_GCM_SHA384 with X25519 key exchange, which
375
+ * is what mbedTLS 3.x negotiates. We do not validate the server
376
+ * certificate at all (MITM is mitigated by the application-level
377
+ * signature exchange that follows).
378
+ *
379
+ * verificationData = all bytes received from the server on channel 1
380
+ * BEFORE `secureConnect` fires (i.e. every byte pushed via feedRawBytes
381
+ * before the handshake completes). This matches the Zig server's
382
+ * definition on the client side.
383
+ */
384
+ export class SecureChannelHandler {
385
+ udpSocket;
386
+ host;
387
+ port;
388
+ channelId;
389
+ mtu;
390
+ sendSeq;
391
+ // TLS state
392
+ state = "init";
393
+ recordParser = new TlsRecordParser();
394
+ decoder = new FramedMessageDecoder();
395
+ // Transcript hash accumulator (SHA-384 of all Handshake messages)
396
+ transcript = [];
397
+ // Key material (populated after ServerHello + key derivation)
398
+ serverHandshakeKey = null;
399
+ serverHandshakeIv = null;
400
+ serverHandshakeSeq = 0n;
401
+ clientHandshakeKey = null;
402
+ clientHandshakeIv = null;
403
+ clientHandshakeSeq = 0n;
404
+ serverAppKey = null;
405
+ serverAppIv = null;
406
+ serverAppSeq = 0n;
407
+ clientAppKey = null;
408
+ clientAppIv = null;
409
+ clientAppSeq = 0n;
410
+ // verificationData = raw bytes received from server before handshake completes
411
+ verificationDataBufs = [];
412
+ collectingVerificationData = true;
413
+ // Client X25519 keys
414
+ clientX25519PrivKey;
415
+ clientX25519PubKeyBytes;
416
+ clientRandom;
417
+ // Callbacks
418
+ onMessage = null;
419
+ onSecureConnect = null;
420
+ onError = null;
421
+ // Set to the raw bytes received before handshake completion (used by the
422
+ // application layer to verify the server's identity signature).
423
+ verificationDataBuffer = undefined;
424
+ constructor(options) {
425
+ this.udpSocket = options.socket;
426
+ this.host = options.host;
427
+ this.port = options.port;
428
+ this.channelId = options.channelId;
429
+ this.mtu = options.mtu;
430
+ this.sendSeq = options.initialSendSeq;
431
+ // Generate ephemeral X25519 key pair for this handshake
432
+ const { privateKey, publicKey } = generateKeyPairSync("x25519");
433
+ this.clientX25519PrivKey = privateKey;
434
+ // Export raw 32-byte public key from SPKI DER
435
+ const spki = publicKey.export({ type: "spki", format: "der" });
436
+ this.clientX25519PubKeyBytes = Buffer.from(spki.slice(spki.length - 32));
437
+ this.clientRandom = randomBytes(32);
438
+ this.decoder.onMessage = (msg) => {
439
+ this.onMessage?.(msg);
440
+ };
441
+ this.decoder.onError = (err) => {
442
+ this.onError?.(err);
443
+ };
444
+ }
445
+ /**
446
+ * Trigger the TLS handshake. Must be called after the UDP init-ACK packet
447
+ * has been handed to the OS send queue so the server is guaranteed to be in
448
+ * the .connected state before it receives the TLS ClientHello.
449
+ */
450
+ startHandshake() {
451
+ if (this.state !== "init") {
452
+ return;
453
+ }
454
+ this.state = "sentClientHello";
455
+ const helloMsg = buildClientHello(this.clientRandom, this.clientX25519PubKeyBytes);
456
+ // Add to transcript BEFORE sending
457
+ this.transcript.push(Buffer.from(helloMsg));
458
+ const record = makeTlsRecord(CONTENT_HANDSHAKE, helloMsg);
459
+ this.sendRawTlsRecord(record);
460
+ }
461
+ // Feed raw bytes from UDP into the TLS layer (called by connection.ts).
462
+ feedRawBytes(data) {
463
+ if (this.collectingVerificationData) {
464
+ this.verificationDataBufs.push(Buffer.from(data));
465
+ }
466
+ if (this.state === "error") {
467
+ return;
468
+ }
469
+ const records = this.recordParser.feed(data);
470
+ for (const record of records) {
471
+ this.handleRecord(record);
472
+ }
473
+ }
474
+ handleRecord(record) {
475
+ switch (record.contentType) {
476
+ case CONTENT_CCS:
477
+ // ChangeCipherSpec compatibility message — ignore
478
+ break;
479
+ case CONTENT_HANDSHAKE:
480
+ this.handlePlaintextHandshake(record.data);
481
+ break;
482
+ case CONTENT_APP_DATA:
483
+ this.handleEncryptedRecord(record);
484
+ break;
485
+ default:
486
+ break;
487
+ }
488
+ }
489
+ handlePlaintextHandshake(data) {
490
+ // Only ServerHello comes as plaintext in TLS 1.3
491
+ if (data.length < 4 || data[0] !== HS_SERVER_HELLO) {
492
+ return;
493
+ }
494
+ this.transcript.push(Buffer.from(data));
495
+ const info = parseServerHello(data);
496
+ if (!info) {
497
+ this.fail(new Error("TLS13: failed to parse ServerHello"));
498
+ return;
499
+ }
500
+ if (info.cipherSuite !== CIPHER_AES256GCM_SHA384) {
501
+ this.fail(new Error(`TLS13: unexpected cipher suite 0x${info.cipherSuite.toString(16)}`));
502
+ return;
503
+ }
504
+ if (!info.serverX25519PubKey) {
505
+ this.fail(new Error("TLS13: no x25519 key share in ServerHello"));
506
+ return;
507
+ }
508
+ this.deriveHandshakeKeys(info.serverX25519PubKey);
509
+ }
510
+ deriveHandshakeKeys(serverPubKeyBytes) {
511
+ // Build server's X25519 public key object from raw bytes.
512
+ // The raw 32-byte public key has SPKI encoding:
513
+ // 30 2a 30 05 06 03 2b 65 6e 03 21 00 <32 bytes>
514
+ const spkiPrefix = Buffer.from("302a300506032b656e032100", "hex");
515
+ const serverPubKeyDer = Buffer.concat([spkiPrefix, serverPubKeyBytes]);
516
+ const serverPublicKey = createPublicKey({
517
+ key: serverPubKeyDer,
518
+ format: "der",
519
+ type: "spki",
520
+ });
521
+ // ECDH shared secret
522
+ const sharedSecret = diffieHellman({
523
+ privateKey: this.clientX25519PrivKey,
524
+ publicKey: serverPublicKey,
525
+ });
526
+ // TLS 1.3 Key Schedule (RFC 8446 §7.1) using SHA-384
527
+ const hashLen = 48;
528
+ const _emptyHash = createHash("sha384").digest();
529
+ // early_secret = HKDF-Extract(0, 0) — RFC 8446 §7.1: IKM is HashLen zeroes
530
+ const earlySecret = hkdfExtract(Buffer.alloc(hashLen, 0), Buffer.alloc(hashLen, 0));
531
+ // empty_hash = Hash("") for deriving binder_key etc.
532
+ const derivedSecret = deriveSecret(earlySecret, "derived", Buffer.alloc(0));
533
+ // handshake_secret = HKDF-Extract(derived_secret, DHE)
534
+ const handshakeSecret = hkdfExtract(derivedSecret, sharedSecret);
535
+ // Transcript hash up to and including ServerHello
536
+ const transcriptSoFar = Buffer.concat(this.transcript);
537
+ // client_handshake_traffic_secret
538
+ const clientHsSecret = deriveSecret(handshakeSecret, "c hs traffic", transcriptSoFar);
539
+ // server_handshake_traffic_secret
540
+ const serverHsSecret = deriveSecret(handshakeSecret, "s hs traffic", transcriptSoFar);
541
+ const clientHsKeys = deriveTrafficKeys(clientHsSecret);
542
+ const serverHsKeys = deriveTrafficKeys(serverHsSecret);
543
+ this.clientHandshakeKey = clientHsKeys.key;
544
+ this.clientHandshakeIv = clientHsKeys.iv;
545
+ this.serverHandshakeKey = serverHsKeys.key;
546
+ this.serverHandshakeIv = serverHsKeys.iv;
547
+ // Save for application key derivation later
548
+ this._handshakeSecret = handshakeSecret;
549
+ this._clientHsSecret = clientHsSecret;
550
+ this._serverHsSecret = serverHsSecret;
551
+ }
552
+ // Stored for application key derivation (set in deriveHandshakeKeys)
553
+ _handshakeSecret = null;
554
+ _clientHsSecret = null;
555
+ _serverHsSecret = null;
556
+ deriveApplicationKeys() {
557
+ if (!this._handshakeSecret) {
558
+ return;
559
+ }
560
+ const _hashLen = 48;
561
+ const handshakeSecret = this._handshakeSecret;
562
+ // master_secret = HKDF-Extract(derived, 0)
563
+ const derivedFromHs = deriveSecret(handshakeSecret, "derived", Buffer.alloc(0));
564
+ const masterSecret = hkdfExtract(derivedFromHs, Buffer.alloc(48, 0));
565
+ // Use full transcript including all handshake messages processed so far
566
+ const transcriptAll = Buffer.concat(this.transcript);
567
+ const clientAppSecret = deriveSecret(masterSecret, "c ap traffic", transcriptAll);
568
+ const serverAppSecret = deriveSecret(masterSecret, "s ap traffic", transcriptAll);
569
+ const clientAppKeys = deriveTrafficKeys(clientAppSecret);
570
+ const serverAppKeys = deriveTrafficKeys(serverAppSecret);
571
+ this.clientAppKey = clientAppKeys.key;
572
+ this.clientAppIv = clientAppKeys.iv;
573
+ this.serverAppKey = serverAppKeys.key;
574
+ this.serverAppIv = serverAppKeys.iv;
575
+ }
576
+ handleEncryptedRecord(record) {
577
+ if (!this.serverHandshakeKey) {
578
+ // Got encrypted record before key derivation — discard
579
+ return;
580
+ }
581
+ // Try handshake decryption first, then application
582
+ const fullRecord = Buffer.concat([
583
+ (() => {
584
+ const hdr = Buffer.alloc(5);
585
+ hdr[0] = record.contentType;
586
+ hdr.writeUInt16BE(TLS_VERSION_12, 1);
587
+ hdr.writeUInt16BE(record.data.length, 3);
588
+ return hdr;
589
+ })(),
590
+ record.data,
591
+ ]);
592
+ // Try server handshake keys
593
+ if (this.state === "sentClientHello" &&
594
+ this.serverHandshakeKey &&
595
+ this.serverHandshakeIv) {
596
+ const dec = decryptRecord(fullRecord, this.serverHandshakeKey, this.serverHandshakeIv, this.serverHandshakeSeq);
597
+ if (dec) {
598
+ this.serverHandshakeSeq++;
599
+ this.handleDecryptedHandshakeMsg(dec.plaintext, dec.innerContentType);
600
+ return;
601
+ }
602
+ }
603
+ // Try server application keys (after handshake is done)
604
+ if (this.state === "handshakeComplete" &&
605
+ this.serverAppKey &&
606
+ this.serverAppIv) {
607
+ const dec = decryptRecord(fullRecord, this.serverAppKey, this.serverAppIv, this.serverAppSeq);
608
+ if (dec && dec.innerContentType === CONTENT_APP_DATA) {
609
+ this.serverAppSeq++;
610
+ this.decoder.feed(dec.plaintext);
611
+ return;
612
+ }
613
+ if (dec) {
614
+ this.serverAppSeq++;
615
+ }
616
+ }
617
+ }
618
+ handleDecryptedHandshakeMsg(data, innerType) {
619
+ if (innerType !== CONTENT_HANDSHAKE) {
620
+ return;
621
+ }
622
+ // May contain multiple handshake messages
623
+ let off = 0;
624
+ while (off + 4 <= data.length) {
625
+ const hsType = data[off];
626
+ const hsLen = (data[off + 1] << 16) | (data[off + 2] << 8) | data[off + 3];
627
+ if (off + 4 + hsLen > data.length) {
628
+ break;
629
+ }
630
+ const hsMsg = data.slice(off, off + 4 + hsLen);
631
+ this.transcript.push(Buffer.from(hsMsg));
632
+ if (hsType === HS_FINISHED) {
633
+ // Server Finished — verify it, derive app keys, send client Finished
634
+ this.processServerFinished(data.slice(off + 4, off + 4 + hsLen));
635
+ }
636
+ // Other messages (EncryptedExtensions, Certificate, CertificateVerify)
637
+ // are intentionally ignored — we don't validate the server certificate.
638
+ off += 4 + hsLen;
639
+ }
640
+ }
641
+ processServerFinished(verifyData) {
642
+ if (!this._serverHsSecret) {
643
+ this.fail(new Error("TLS13: no server handshake secret for Finished"));
644
+ return;
645
+ }
646
+ // Verify server Finished HMAC
647
+ const transcriptBeforeFinished = Buffer.concat(this.transcript.slice(0, this.transcript.length - 1));
648
+ const expectedVerifyData = this.computeFinishedVerifyData(this._serverHsSecret, transcriptBeforeFinished);
649
+ if (!expectedVerifyData.equals(verifyData)) {
650
+ this.fail(new Error("TLS13: server Finished verify_data mismatch"));
651
+ return;
652
+ }
653
+ // Derive application keys (transcript now includes server Finished)
654
+ this.deriveApplicationKeys();
655
+ // Send client Finished
656
+ this.sendClientFinished();
657
+ // Handshake complete
658
+ this.state = "handshakeComplete";
659
+ this.collectingVerificationData = false;
660
+ const verificationData = Buffer.concat(this.verificationDataBufs);
661
+ this.verificationDataBuffer = verificationData;
662
+ this.onSecureConnect?.(verificationData);
663
+ }
664
+ computeFinishedVerifyData(hsTrafficSecret, transcriptHash) {
665
+ const finishedKey = expandLabel(hsTrafficSecret, "finished", Buffer.alloc(0), 48, "sha384");
666
+ const h = createHash("sha384").update(transcriptHash).digest();
667
+ return createHmac("sha384", finishedKey).update(h).digest();
668
+ }
669
+ sendClientFinished() {
670
+ if (!this._clientHsSecret ||
671
+ !this.clientHandshakeKey ||
672
+ !this.clientHandshakeIv) {
673
+ return;
674
+ }
675
+ const transcriptBeforeClientFinished = Buffer.concat(this.transcript);
676
+ const verifyData = this.computeFinishedVerifyData(this._clientHsSecret, transcriptBeforeClientFinished);
677
+ const finishedMsg = makeHandshakeMsg(HS_FINISHED, verifyData);
678
+ this.transcript.push(Buffer.from(finishedMsg));
679
+ // Send encrypted with client handshake key
680
+ const encrypted = encryptRecord(finishedMsg, CONTENT_HANDSHAKE, this.clientHandshakeKey, this.clientHandshakeIv, this.clientHandshakeSeq);
681
+ this.clientHandshakeSeq++;
682
+ this.sendRawTlsRecord(encrypted);
683
+ }
684
+ // Send a framed message over the TLS-encrypted application channel.
685
+ // Frame format: [protocolId u8][MSB-varint length][payload]
686
+ sendMessage(protocolId, payload) {
687
+ if (!this.clientAppKey || !this.clientAppIv) {
688
+ this.onError?.(new Error("SecureChannel: sendMessage before handshake complete"));
689
+ return;
690
+ }
691
+ const sizeVarInt = encodeMsbVarInt(payload.length);
692
+ const frame = Buffer.concat([
693
+ Buffer.from([protocolId]),
694
+ sizeVarInt,
695
+ payload,
696
+ ]);
697
+ const encrypted = encryptRecord(frame, CONTENT_APP_DATA, this.clientAppKey, this.clientAppIv, this.clientAppSeq);
698
+ this.clientAppSeq++;
699
+ this.sendRawTlsRecord(encrypted);
700
+ }
701
+ // Send a pre-built TLS record (or encrypted wrapper) over UDP.
702
+ sendRawTlsRecord(record) {
703
+ const maxPayload = this.mtu - 5;
704
+ let offset = 0;
705
+ while (offset < record.length) {
706
+ const end = Math.min(offset + maxPayload, record.length);
707
+ const slice = record.slice(offset, end);
708
+ const packet = Buffer.alloc(5 + slice.length);
709
+ packet[0] = this.channelId;
710
+ writeInt32BE(packet, 1, this.sendSeq);
711
+ slice.copy(packet, 5);
712
+ this.udpSocket.send(packet, this.port, this.host);
713
+ this.sendSeq = (this.sendSeq + slice.length) | 0;
714
+ offset = end;
715
+ }
716
+ }
717
+ fail(err) {
718
+ this.state = "error";
719
+ this.onError?.(err);
720
+ }
721
+ }
722
+ //# sourceMappingURL=secureChannel.js.map