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.
- package/LICENSE +21 -0
- package/dist/authentication.d.ts +20 -0
- package/dist/authentication.js +150 -0
- package/dist/authentication.js.map +1 -0
- package/dist/binary.js +1 -1
- package/dist/binary.js.map +1 -1
- package/dist/connection.d.ts +7 -4
- package/dist/connection.js +221 -31
- package/dist/connection.js.map +1 -1
- package/dist/connectionTypes.d.ts +17 -1
- package/dist/connectionTypes.js +2 -0
- package/dist/connectionTypes.js.map +1 -1
- package/dist/constants.d.ts +7 -5
- package/dist/constants.js +6 -4
- package/dist/constants.js.map +1 -1
- package/dist/entityParser.d.ts +10 -2
- package/dist/entityParser.js +2 -2
- package/dist/entityParser.js.map +1 -1
- package/dist/handshakeUtils.d.ts +2 -1
- package/dist/handshakeUtils.js +10 -2
- package/dist/handshakeUtils.js.map +1 -1
- package/dist/index.d.ts +1 -6
- package/dist/index.js +0 -4
- package/dist/index.js.map +1 -1
- package/dist/receiveChannel.d.ts +2 -0
- package/dist/receiveChannel.js +20 -0
- package/dist/receiveChannel.js.map +1 -1
- package/dist/secureChannel.d.ts +91 -0
- package/dist/secureChannel.js +722 -0
- package/dist/secureChannel.js.map +1 -0
- package/dist/sendChannel.d.ts +1 -0
- package/dist/sendChannel.js +4 -1
- package/dist/sendChannel.js.map +1 -1
- package/dist/wordlist.d.ts +1 -0
- package/dist/wordlist.js +2051 -0
- package/dist/wordlist.js.map +1 -0
- package/package.json +4 -1
|
@@ -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
|