livedesk 0.1.613 → 0.1.615
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/bin/livedesk.js +28 -15
- package/client/bin/livedesk-client-node.js +65 -76
- package/client/bin/livedesk-client.js +29 -20
- package/client/package.json +6 -6
- package/electron/electron-builder.linux-x64-dev.yml +9 -0
- package/electron/electron-builder.linux-x64.yml +17 -11
- package/electron/electron-builder.mac-arm64-dev.yml +13 -0
- package/electron/electron-builder.mac-x64-dev.yml +13 -0
- package/hub/package.json +2 -2
- package/hub/src/remote-hub.js +94 -28
- package/package.json +6 -6
- package/runtime-core/package.json +2 -2
- package/runtime-core/src/direct-secure-transport.js +752 -0
- package/runtime-core/src/index.js +11 -1
- package/scripts/sync-web-dist.js +4 -2
- package/web/dist/app.webmanifest +1 -1
- package/web/dist/assets/{LiveDeskApp-DSNzN6O2.js → LiveDeskApp-D3LPrsCg.js} +1 -1
- package/web/dist/assets/index-CKNX8KEY.css +1 -0
- package/web/dist/assets/{index-C5gpdiTy.js → index-Dw4bFnPQ.js} +8 -8
- package/web/dist/index.html +2 -2
- package/web/dist/livedesk-build-evidence.json +17 -17
- package/web/dist/sw.js +1 -1
- package/web/dist/assets/index-CpHNEjHy.css +0 -1
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import { Duplex } from 'node:stream';
|
|
4
|
+
|
|
5
|
+
export const DIRECT_SECURE_PROTOCOL = 'livedesk.direct.secure.v1';
|
|
6
|
+
export const DIRECT_SECURE_MAGIC = Buffer.from('LDS1', 'ascii');
|
|
7
|
+
|
|
8
|
+
const VALID_CHANNELS = new Set(['control', 'frame', 'audio', 'file', 'input']);
|
|
9
|
+
const HANDSHAKE_HEADER_BYTES = 8;
|
|
10
|
+
const MAX_HANDSHAKE_BYTES = 8 * 1024;
|
|
11
|
+
const RECORD_HEADER_BYTES = 12;
|
|
12
|
+
const RECORD_TAG_BYTES = 16;
|
|
13
|
+
const MAX_RECORD_PLAINTEXT_BYTES = 256 * 1024;
|
|
14
|
+
const MAX_ENCRYPTED_ACCUMULATOR_BYTES = 4 * 1024 * 1024;
|
|
15
|
+
const MAX_HANDSHAKE_ACCUMULATOR_BYTES = HANDSHAKE_HEADER_BYTES
|
|
16
|
+
+ MAX_HANDSHAKE_BYTES
|
|
17
|
+
+ MAX_ENCRYPTED_ACCUMULATOR_BYTES;
|
|
18
|
+
const MAX_SAFE_SEQUENCE = 9_007_199_254_740_991n;
|
|
19
|
+
const CLIENT_NONCE_BYTES = 16;
|
|
20
|
+
const HUB_NONCE_BYTES = 16;
|
|
21
|
+
const PUBLIC_KEY_BYTES = 65;
|
|
22
|
+
const PROOF_BYTES = 32;
|
|
23
|
+
const CLIENT_DIRECTION = 'client-to-hub';
|
|
24
|
+
const HUB_DIRECTION = 'hub-to-client';
|
|
25
|
+
const CLIENT_NONCE_PREFIX = Buffer.from('LDCH', 'ascii');
|
|
26
|
+
const HUB_NONCE_PREFIX = Buffer.from('LDHC', 'ascii');
|
|
27
|
+
|
|
28
|
+
class BoundedByteSegments {
|
|
29
|
+
constructor(maxBytes) {
|
|
30
|
+
this.maxBytes = maxBytes;
|
|
31
|
+
this.chunks = [];
|
|
32
|
+
this.headIndex = 0;
|
|
33
|
+
this.headOffset = 0;
|
|
34
|
+
this.byteLength = 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
get length() {
|
|
38
|
+
return this.byteLength;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
append(value) {
|
|
42
|
+
const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value || []);
|
|
43
|
+
if (chunk.length === 0) return true;
|
|
44
|
+
if (chunk.length > this.maxBytes - this.byteLength) return false;
|
|
45
|
+
this.chunks.push(chunk);
|
|
46
|
+
this.byteLength += chunk.length;
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
byteAt(index) {
|
|
51
|
+
if (!Number.isInteger(index) || index < 0 || index >= this.byteLength) return undefined;
|
|
52
|
+
let remaining = index;
|
|
53
|
+
for (let chunkIndex = this.headIndex; chunkIndex < this.chunks.length; chunkIndex += 1) {
|
|
54
|
+
const chunk = this.chunks[chunkIndex];
|
|
55
|
+
const start = chunkIndex === this.headIndex ? this.headOffset : 0;
|
|
56
|
+
const available = chunk.length - start;
|
|
57
|
+
if (remaining < available) return chunk[start + remaining];
|
|
58
|
+
remaining -= available;
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
readUInt32BE(index) {
|
|
64
|
+
const bytes = [0, 1, 2, 3].map(offset => this.byteAt(index + offset));
|
|
65
|
+
if (bytes.some(value => value === undefined)) return null;
|
|
66
|
+
return (((bytes[0] << 24) >>> 0)
|
|
67
|
+
| (bytes[1] << 16)
|
|
68
|
+
| (bytes[2] << 8)
|
|
69
|
+
| bytes[3]) >>> 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
readBigUInt64BE(index) {
|
|
73
|
+
let value = 0n;
|
|
74
|
+
for (let offset = 0; offset < 8; offset += 1) {
|
|
75
|
+
const byte = this.byteAt(index + offset);
|
|
76
|
+
if (byte === undefined) return null;
|
|
77
|
+
value = (value << 8n) | BigInt(byte);
|
|
78
|
+
}
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
readExact(index, length) {
|
|
83
|
+
if (!Number.isInteger(index)
|
|
84
|
+
|| !Number.isInteger(length)
|
|
85
|
+
|| index < 0
|
|
86
|
+
|| length < 0
|
|
87
|
+
|| index + length > this.byteLength) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const output = Buffer.allocUnsafeSlow(length);
|
|
91
|
+
let skip = index;
|
|
92
|
+
let written = 0;
|
|
93
|
+
for (let chunkIndex = this.headIndex; chunkIndex < this.chunks.length && written < length; chunkIndex += 1) {
|
|
94
|
+
const chunk = this.chunks[chunkIndex];
|
|
95
|
+
const baseOffset = chunkIndex === this.headIndex ? this.headOffset : 0;
|
|
96
|
+
const available = chunk.length - baseOffset;
|
|
97
|
+
if (skip >= available) {
|
|
98
|
+
skip -= available;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const sourceStart = baseOffset + skip;
|
|
102
|
+
const copyLength = Math.min(length - written, chunk.length - sourceStart);
|
|
103
|
+
chunk.copy(output, written, sourceStart, sourceStart + copyLength);
|
|
104
|
+
written += copyLength;
|
|
105
|
+
skip = 0;
|
|
106
|
+
}
|
|
107
|
+
return written === length ? output : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
discard(length) {
|
|
111
|
+
let remaining = Math.max(0, Math.min(this.byteLength, Number(length) || 0));
|
|
112
|
+
const discarded = remaining;
|
|
113
|
+
while (remaining > 0 && this.headIndex < this.chunks.length) {
|
|
114
|
+
const chunk = this.chunks[this.headIndex];
|
|
115
|
+
const available = chunk.length - this.headOffset;
|
|
116
|
+
if (remaining < available) {
|
|
117
|
+
this.headOffset += remaining;
|
|
118
|
+
remaining = 0;
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
remaining -= available;
|
|
122
|
+
this.chunks[this.headIndex] = null;
|
|
123
|
+
this.headIndex += 1;
|
|
124
|
+
this.headOffset = 0;
|
|
125
|
+
}
|
|
126
|
+
this.byteLength -= discarded;
|
|
127
|
+
if (this.byteLength === 0) {
|
|
128
|
+
this.clear();
|
|
129
|
+
} else if (this.headIndex >= 64 && this.headIndex * 2 >= this.chunks.length) {
|
|
130
|
+
this.chunks = this.chunks.slice(this.headIndex);
|
|
131
|
+
this.headIndex = 0;
|
|
132
|
+
}
|
|
133
|
+
return discarded;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
clear() {
|
|
137
|
+
this.chunks = [];
|
|
138
|
+
this.headIndex = 0;
|
|
139
|
+
this.headOffset = 0;
|
|
140
|
+
this.byteLength = 0;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function base64Url(value) {
|
|
145
|
+
return Buffer.from(value).toString('base64url');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function isDirectSecureChannel(value) {
|
|
149
|
+
return VALID_CHANNELS.has(String(value || '').trim().toLowerCase());
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function requireSecureChannel(value) {
|
|
153
|
+
const channel = String(value || '').trim().toLowerCase();
|
|
154
|
+
if (!VALID_CHANNELS.has(channel)) throw new Error('direct-secure-channel-invalid');
|
|
155
|
+
return channel;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function decodeBase64Url(value, expectedBytes) {
|
|
159
|
+
const text = String(value || '').trim();
|
|
160
|
+
if (!/^[A-Za-z0-9_-]+$/.test(text)) throw new Error('direct-secure-base64-invalid');
|
|
161
|
+
const decoded = Buffer.from(text, 'base64url');
|
|
162
|
+
if (decoded.length !== expectedBytes || base64Url(decoded) !== text) {
|
|
163
|
+
throw new Error('direct-secure-base64-invalid');
|
|
164
|
+
}
|
|
165
|
+
return decoded;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function hmac(key, value) {
|
|
169
|
+
return crypto.createHmac('sha256', key).update(value, 'utf8').digest();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function timingSafeEqual(left, right) {
|
|
173
|
+
return Buffer.isBuffer(left)
|
|
174
|
+
&& Buffer.isBuffer(right)
|
|
175
|
+
&& left.length === right.length
|
|
176
|
+
&& crypto.timingSafeEqual(left, right);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function deriveKey(sharedSecret, salt, info) {
|
|
180
|
+
return Buffer.from(crypto.hkdfSync(
|
|
181
|
+
'sha256',
|
|
182
|
+
sharedSecret,
|
|
183
|
+
salt,
|
|
184
|
+
Buffer.from(info, 'utf8'),
|
|
185
|
+
32));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function buildNonce(prefix, sequence) {
|
|
189
|
+
const nonce = Buffer.allocUnsafe(12);
|
|
190
|
+
prefix.copy(nonce, 0);
|
|
191
|
+
nonce.writeBigUInt64BE(sequence, 4);
|
|
192
|
+
return nonce;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function buildAad(direction, header) {
|
|
196
|
+
return Buffer.concat([
|
|
197
|
+
Buffer.from(DIRECT_SECURE_PROTOCOL, 'utf8'),
|
|
198
|
+
Buffer.from([0]),
|
|
199
|
+
Buffer.from(direction, 'utf8'),
|
|
200
|
+
Buffer.from([0]),
|
|
201
|
+
header
|
|
202
|
+
]);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function encodeHandshake(payload) {
|
|
206
|
+
const json = Buffer.from(JSON.stringify(payload), 'utf8');
|
|
207
|
+
if (json.length < 1 || json.length > MAX_HANDSHAKE_BYTES) {
|
|
208
|
+
throw new Error('direct-secure-handshake-size-invalid');
|
|
209
|
+
}
|
|
210
|
+
const frame = Buffer.allocUnsafe(HANDSHAKE_HEADER_BYTES + json.length);
|
|
211
|
+
DIRECT_SECURE_MAGIC.copy(frame, 0);
|
|
212
|
+
frame.writeUInt32BE(json.length, 4);
|
|
213
|
+
json.copy(frame, HANDSHAKE_HEADER_BYTES);
|
|
214
|
+
return frame;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function isMagicPrefix(value) {
|
|
218
|
+
const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value || []);
|
|
219
|
+
const compareLength = Math.min(chunk.length, DIRECT_SECURE_MAGIC.length);
|
|
220
|
+
return compareLength > 0
|
|
221
|
+
&& chunk.subarray(0, compareLength).equals(DIRECT_SECURE_MAGIC.subarray(0, compareLength));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function isSecureDirectHandshakeStart(firstChunk) {
|
|
225
|
+
return isMagicPrefix(firstChunk);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function readHandshake(socket, firstChunk, timeoutMs) {
|
|
229
|
+
return new Promise((resolve, reject) => {
|
|
230
|
+
const buffer = new BoundedByteSegments(MAX_HANDSHAKE_ACCUMULATOR_BYTES);
|
|
231
|
+
let settled = false;
|
|
232
|
+
let expectedLength = -1;
|
|
233
|
+
const timer = setTimeout(
|
|
234
|
+
() => fail(new Error('direct-secure-handshake-timeout')),
|
|
235
|
+
Math.max(500, Math.min(10_000, Number(timeoutMs) || 4_000)));
|
|
236
|
+
timer.unref?.();
|
|
237
|
+
|
|
238
|
+
const cleanup = () => {
|
|
239
|
+
clearTimeout(timer);
|
|
240
|
+
socket.removeListener('data', onData);
|
|
241
|
+
socket.removeListener('error', onError);
|
|
242
|
+
socket.removeListener('close', onClose);
|
|
243
|
+
};
|
|
244
|
+
const fail = error => {
|
|
245
|
+
if (settled) return;
|
|
246
|
+
settled = true;
|
|
247
|
+
cleanup();
|
|
248
|
+
buffer.clear();
|
|
249
|
+
reject(error);
|
|
250
|
+
};
|
|
251
|
+
const complete = () => {
|
|
252
|
+
const total = HANDSHAKE_HEADER_BYTES + expectedLength;
|
|
253
|
+
if (settled || expectedLength < 0 || buffer.length < total) return;
|
|
254
|
+
const jsonBytes = buffer.readExact(HANDSHAKE_HEADER_BYTES, expectedLength);
|
|
255
|
+
const remainderLength = buffer.length - total;
|
|
256
|
+
const remainder = remainderLength > 0 ? buffer.readExact(total, remainderLength) : Buffer.alloc(0);
|
|
257
|
+
if (!jsonBytes || !remainder) {
|
|
258
|
+
fail(new Error('direct-secure-handshake-read-failed'));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
let message;
|
|
262
|
+
try {
|
|
263
|
+
message = JSON.parse(jsonBytes.toString('utf8'));
|
|
264
|
+
} catch {
|
|
265
|
+
fail(new Error('direct-secure-handshake-json-invalid'));
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
// A successful handshake changes the owner of this raw stream. Stop the
|
|
269
|
+
// flowing socket before removing our data listener so bytes arriving in
|
|
270
|
+
// the short handoff window are retained for SecureDirectDuplex.
|
|
271
|
+
socket.pause();
|
|
272
|
+
settled = true;
|
|
273
|
+
cleanup();
|
|
274
|
+
buffer.clear();
|
|
275
|
+
resolve({ message, remainder });
|
|
276
|
+
};
|
|
277
|
+
const append = value => {
|
|
278
|
+
if (settled) return;
|
|
279
|
+
if (!buffer.append(value)) {
|
|
280
|
+
fail(new Error('direct-secure-handshake-too-large'));
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (buffer.length >= DIRECT_SECURE_MAGIC.length) {
|
|
284
|
+
const magic = buffer.readExact(0, DIRECT_SECURE_MAGIC.length);
|
|
285
|
+
if (!magic?.equals(DIRECT_SECURE_MAGIC)) {
|
|
286
|
+
fail(new Error('direct-secure-magic-invalid'));
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (buffer.length >= HANDSHAKE_HEADER_BYTES && expectedLength < 0) {
|
|
291
|
+
expectedLength = buffer.readUInt32BE(4);
|
|
292
|
+
if (!Number.isInteger(expectedLength)
|
|
293
|
+
|| expectedLength < 1
|
|
294
|
+
|| expectedLength > MAX_HANDSHAKE_BYTES) {
|
|
295
|
+
fail(new Error('direct-secure-handshake-size-invalid'));
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
complete();
|
|
300
|
+
};
|
|
301
|
+
const onData = chunk => append(chunk);
|
|
302
|
+
const onError = error => fail(error);
|
|
303
|
+
const onClose = () => fail(new Error('direct-secure-handshake-closed'));
|
|
304
|
+
socket.on('data', onData);
|
|
305
|
+
socket.once('error', onError);
|
|
306
|
+
socket.once('close', onClose);
|
|
307
|
+
append(firstChunk);
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function writeRaw(socket, payload) {
|
|
312
|
+
return new Promise((resolve, reject) => {
|
|
313
|
+
let settled = false;
|
|
314
|
+
const cleanup = () => socket.removeListener('error', onError);
|
|
315
|
+
const finish = error => {
|
|
316
|
+
if (settled) return;
|
|
317
|
+
settled = true;
|
|
318
|
+
cleanup();
|
|
319
|
+
if (error) reject(error);
|
|
320
|
+
else resolve();
|
|
321
|
+
};
|
|
322
|
+
const onError = error => finish(error);
|
|
323
|
+
socket.once('error', onError);
|
|
324
|
+
socket.write(payload, error => finish(error || null));
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function combineCipherOutput(updated, finalized) {
|
|
329
|
+
if (updated.length === 0) return finalized;
|
|
330
|
+
if (finalized.length === 0) return updated;
|
|
331
|
+
return Buffer.concat([updated, finalized], updated.length + finalized.length);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
class SecureDirectDuplex extends Duplex {
|
|
335
|
+
constructor(rawSocket, options) {
|
|
336
|
+
super({ allowHalfOpen: false });
|
|
337
|
+
this.rawSocket = rawSocket;
|
|
338
|
+
this.sendKey = Buffer.from(options.sendKey);
|
|
339
|
+
this.receiveKey = Buffer.from(options.receiveKey);
|
|
340
|
+
this.sendDirection = options.sendDirection;
|
|
341
|
+
this.receiveDirection = options.receiveDirection;
|
|
342
|
+
this.sendNoncePrefix = options.sendNoncePrefix;
|
|
343
|
+
this.receiveNoncePrefix = options.receiveNoncePrefix;
|
|
344
|
+
this.sendSequence = 1n;
|
|
345
|
+
this.receiveSequence = 1n;
|
|
346
|
+
this.encrypted = new BoundedByteSegments(MAX_ENCRYPTED_ACCUMULATOR_BYTES);
|
|
347
|
+
this.processing = false;
|
|
348
|
+
this.rawPaused = true;
|
|
349
|
+
this.readBackpressured = false;
|
|
350
|
+
this.__liveDeskSecureDirect = true;
|
|
351
|
+
this.__liveDeskSecureChannel = options.channel;
|
|
352
|
+
|
|
353
|
+
this.onRawData = chunk => {
|
|
354
|
+
if (!this.encrypted.append(chunk)) {
|
|
355
|
+
this.destroy(new Error('direct-secure-record-buffer-overflow'));
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
this.processEncrypted();
|
|
359
|
+
};
|
|
360
|
+
this.onRawError = error => this.destroy(error);
|
|
361
|
+
this.onRawEnd = () => {
|
|
362
|
+
if (this.encrypted.length !== 0) {
|
|
363
|
+
this.destroy(new Error('direct-secure-truncated-record'));
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
this.push(null);
|
|
367
|
+
};
|
|
368
|
+
this.onRawClose = () => {
|
|
369
|
+
if (!this.destroyed) this.destroy();
|
|
370
|
+
};
|
|
371
|
+
this.onRawTimeout = () => this.emit('timeout');
|
|
372
|
+
|
|
373
|
+
rawSocket.pause();
|
|
374
|
+
rawSocket.on('data', this.onRawData);
|
|
375
|
+
rawSocket.on('error', this.onRawError);
|
|
376
|
+
rawSocket.on('end', this.onRawEnd);
|
|
377
|
+
rawSocket.on('close', this.onRawClose);
|
|
378
|
+
rawSocket.on('timeout', this.onRawTimeout);
|
|
379
|
+
if (options.initialEncryptedBytes?.length
|
|
380
|
+
&& !this.encrypted.append(options.initialEncryptedBytes)) {
|
|
381
|
+
throw new Error('direct-secure-record-buffer-overflow');
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
get remoteAddress() { return this.rawSocket.remoteAddress; }
|
|
386
|
+
get remoteFamily() { return this.rawSocket.remoteFamily; }
|
|
387
|
+
get remotePort() { return this.rawSocket.remotePort; }
|
|
388
|
+
get localAddress() { return this.rawSocket.localAddress; }
|
|
389
|
+
get localPort() { return this.rawSocket.localPort; }
|
|
390
|
+
get bufferSize() { return this.rawSocket.bufferSize; }
|
|
391
|
+
|
|
392
|
+
setNoDelay(value = true) {
|
|
393
|
+
this.rawSocket.setNoDelay(value);
|
|
394
|
+
return this;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
setKeepAlive(enable = false, initialDelay = 0) {
|
|
398
|
+
this.rawSocket.setKeepAlive(enable, initialDelay);
|
|
399
|
+
return this;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
setTimeout(timeout, callback) {
|
|
403
|
+
if (typeof callback === 'function') this.once('timeout', callback);
|
|
404
|
+
this.rawSocket.setTimeout(timeout);
|
|
405
|
+
return this;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
_read() {
|
|
409
|
+
this.readBackpressured = false;
|
|
410
|
+
this.processEncrypted();
|
|
411
|
+
if (this.rawPaused && !this.readBackpressured && !this.destroyed) {
|
|
412
|
+
this.rawPaused = false;
|
|
413
|
+
this.rawSocket.resume();
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
_write(chunk, encoding, callback) {
|
|
418
|
+
void this.writeEncrypted(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding))
|
|
419
|
+
.then(() => callback(), callback);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async writeEncrypted(plaintext) {
|
|
423
|
+
let offset = 0;
|
|
424
|
+
while (offset < plaintext.length) {
|
|
425
|
+
if (this.sendSequence > MAX_SAFE_SEQUENCE) {
|
|
426
|
+
throw new Error('direct-secure-send-sequence-exhausted');
|
|
427
|
+
}
|
|
428
|
+
const length = Math.min(MAX_RECORD_PLAINTEXT_BYTES, plaintext.length - offset);
|
|
429
|
+
const sequence = this.sendSequence;
|
|
430
|
+
this.sendSequence += 1n;
|
|
431
|
+
const header = Buffer.allocUnsafe(RECORD_HEADER_BYTES);
|
|
432
|
+
header.writeUInt32BE(length, 0);
|
|
433
|
+
header.writeBigUInt64BE(sequence, 4);
|
|
434
|
+
const nonce = buildNonce(this.sendNoncePrefix, sequence);
|
|
435
|
+
const aad = buildAad(this.sendDirection, header);
|
|
436
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', this.sendKey, nonce, {
|
|
437
|
+
authTagLength: RECORD_TAG_BYTES
|
|
438
|
+
});
|
|
439
|
+
cipher.setAAD(aad, { plaintextLength: length });
|
|
440
|
+
const ciphertext = combineCipherOutput(
|
|
441
|
+
cipher.update(plaintext.subarray(offset, offset + length)),
|
|
442
|
+
cipher.final());
|
|
443
|
+
const wire = Buffer.concat([header, ciphertext, cipher.getAuthTag()]);
|
|
444
|
+
await writeRaw(this.rawSocket, wire);
|
|
445
|
+
offset += length;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
processEncrypted() {
|
|
450
|
+
if (this.processing || this.destroyed) return;
|
|
451
|
+
this.processing = true;
|
|
452
|
+
try {
|
|
453
|
+
while (!this.destroyed) {
|
|
454
|
+
if (this.encrypted.length < RECORD_HEADER_BYTES) return;
|
|
455
|
+
const length = this.encrypted.readUInt32BE(0);
|
|
456
|
+
const sequence = this.encrypted.readBigUInt64BE(4);
|
|
457
|
+
if (!Number.isInteger(length) || length < 1 || length > MAX_RECORD_PLAINTEXT_BYTES) {
|
|
458
|
+
throw new Error('direct-secure-record-size-invalid');
|
|
459
|
+
}
|
|
460
|
+
if (sequence !== this.receiveSequence) {
|
|
461
|
+
throw new Error('direct-secure-record-sequence-invalid');
|
|
462
|
+
}
|
|
463
|
+
const total = RECORD_HEADER_BYTES + length + RECORD_TAG_BYTES;
|
|
464
|
+
if (this.encrypted.length < total) return;
|
|
465
|
+
const record = this.encrypted.readExact(0, total);
|
|
466
|
+
if (!record) throw new Error('direct-secure-record-read-failed');
|
|
467
|
+
this.encrypted.discard(total);
|
|
468
|
+
const header = record.subarray(0, RECORD_HEADER_BYTES);
|
|
469
|
+
const ciphertext = record.subarray(RECORD_HEADER_BYTES, RECORD_HEADER_BYTES + length);
|
|
470
|
+
const tag = record.subarray(RECORD_HEADER_BYTES + length, total);
|
|
471
|
+
const nonce = buildNonce(this.receiveNoncePrefix, sequence);
|
|
472
|
+
const aad = buildAad(this.receiveDirection, header);
|
|
473
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', this.receiveKey, nonce, {
|
|
474
|
+
authTagLength: RECORD_TAG_BYTES
|
|
475
|
+
});
|
|
476
|
+
decipher.setAAD(aad, { plaintextLength: length });
|
|
477
|
+
decipher.setAuthTag(tag);
|
|
478
|
+
const plaintext = combineCipherOutput(decipher.update(ciphertext), decipher.final());
|
|
479
|
+
this.receiveSequence += 1n;
|
|
480
|
+
if (!this.push(plaintext)) {
|
|
481
|
+
this.readBackpressured = true;
|
|
482
|
+
this.rawPaused = true;
|
|
483
|
+
this.rawSocket.pause();
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
} catch (error) {
|
|
488
|
+
this.destroy(error);
|
|
489
|
+
} finally {
|
|
490
|
+
this.processing = false;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
_final(callback) {
|
|
495
|
+
this.rawSocket.end(callback);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
_destroy(error, callback) {
|
|
499
|
+
this.rawSocket.removeListener('data', this.onRawData);
|
|
500
|
+
this.rawSocket.removeListener('error', this.onRawError);
|
|
501
|
+
this.rawSocket.removeListener('end', this.onRawEnd);
|
|
502
|
+
this.rawSocket.removeListener('close', this.onRawClose);
|
|
503
|
+
this.rawSocket.removeListener('timeout', this.onRawTimeout);
|
|
504
|
+
this.sendKey.fill(0);
|
|
505
|
+
this.receiveKey.fill(0);
|
|
506
|
+
this.encrypted.clear();
|
|
507
|
+
if (!this.rawSocket.destroyed) this.rawSocket.destroy();
|
|
508
|
+
callback(error);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function secureDuplexOptions(role, channel, sendKey, receiveKey, initialEncryptedBytes) {
|
|
513
|
+
const isClient = role === 'client';
|
|
514
|
+
return {
|
|
515
|
+
channel,
|
|
516
|
+
sendKey,
|
|
517
|
+
receiveKey,
|
|
518
|
+
initialEncryptedBytes,
|
|
519
|
+
sendDirection: isClient ? CLIENT_DIRECTION : HUB_DIRECTION,
|
|
520
|
+
receiveDirection: isClient ? HUB_DIRECTION : CLIENT_DIRECTION,
|
|
521
|
+
sendNoncePrefix: isClient ? CLIENT_NONCE_PREFIX : HUB_NONCE_PREFIX,
|
|
522
|
+
receiveNoncePrefix: isClient ? HUB_NONCE_PREFIX : CLIENT_NONCE_PREFIX
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
export async function acceptSecureDirectSocket(rawSocket, firstChunk, pairToken, options = {}) {
|
|
527
|
+
const tokenBytes = Buffer.from(String(pairToken || ''), 'utf8');
|
|
528
|
+
let actualClientProof;
|
|
529
|
+
let expectedClientProof;
|
|
530
|
+
let hubProof;
|
|
531
|
+
let sharedSecret;
|
|
532
|
+
let salt;
|
|
533
|
+
let clientToHubKey;
|
|
534
|
+
let hubToClientKey;
|
|
535
|
+
try {
|
|
536
|
+
if (tokenBytes.length === 0) throw new Error('direct-secure-pair-token-required');
|
|
537
|
+
const { message, remainder } = await readHandshake(rawSocket, firstChunk, options.timeoutMs);
|
|
538
|
+
if (message?.type !== 'direct.secure.hello' || message?.protocol !== DIRECT_SECURE_PROTOCOL) {
|
|
539
|
+
throw new Error('direct-secure-client-hello-invalid');
|
|
540
|
+
}
|
|
541
|
+
const channel = requireSecureChannel(message.channel);
|
|
542
|
+
const clientPublicKeyText = String(message.clientPublicKey || '');
|
|
543
|
+
const clientNonceText = String(message.clientNonce || '');
|
|
544
|
+
const clientPublicKey = decodeBase64Url(clientPublicKeyText, PUBLIC_KEY_BYTES);
|
|
545
|
+
decodeBase64Url(clientNonceText, CLIENT_NONCE_BYTES).fill(0);
|
|
546
|
+
actualClientProof = decodeBase64Url(message.proof, PROOF_BYTES);
|
|
547
|
+
expectedClientProof = hmac(
|
|
548
|
+
tokenBytes,
|
|
549
|
+
`livedesk-direct-client-v1\0${channel}\0${clientPublicKeyText}\0${clientNonceText}`);
|
|
550
|
+
if (!timingSafeEqual(actualClientProof, expectedClientProof)) {
|
|
551
|
+
await writeRaw(rawSocket, encodeHandshake({
|
|
552
|
+
type: 'direct.secure.reject',
|
|
553
|
+
protocol: DIRECT_SECURE_PROTOCOL,
|
|
554
|
+
channel,
|
|
555
|
+
error: 'invalid-pair-token'
|
|
556
|
+
}));
|
|
557
|
+
const error = new Error('direct-secure-client-proof-invalid');
|
|
558
|
+
error.code = 'LIVEDESK_INVALID_PAIR_TOKEN_REJECTED';
|
|
559
|
+
throw error;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const hubEcdh = crypto.createECDH('prime256v1');
|
|
563
|
+
hubEcdh.generateKeys();
|
|
564
|
+
const hubPublicKeyText = base64Url(hubEcdh.getPublicKey(null, 'uncompressed'));
|
|
565
|
+
const hubNonceText = base64Url(crypto.randomBytes(HUB_NONCE_BYTES));
|
|
566
|
+
hubProof = hmac(
|
|
567
|
+
tokenBytes,
|
|
568
|
+
`livedesk-direct-hub-v1\0${channel}\0${clientPublicKeyText}\0${clientNonceText}\0${hubPublicKeyText}\0${hubNonceText}`);
|
|
569
|
+
sharedSecret = hubEcdh.computeSecret(clientPublicKey);
|
|
570
|
+
salt = hmac(
|
|
571
|
+
tokenBytes,
|
|
572
|
+
`livedesk-direct-salt-v1\0${channel}\0${clientNonceText}\0${hubNonceText}`);
|
|
573
|
+
clientToHubKey = deriveKey(
|
|
574
|
+
sharedSecret,
|
|
575
|
+
salt,
|
|
576
|
+
`livedesk-direct-e2e-v1\0${channel}\0${CLIENT_DIRECTION}\0${clientPublicKeyText}\0${hubPublicKeyText}`);
|
|
577
|
+
hubToClientKey = deriveKey(
|
|
578
|
+
sharedSecret,
|
|
579
|
+
salt,
|
|
580
|
+
`livedesk-direct-e2e-v1\0${channel}\0${HUB_DIRECTION}\0${clientPublicKeyText}\0${hubPublicKeyText}`);
|
|
581
|
+
|
|
582
|
+
await writeRaw(rawSocket, encodeHandshake({
|
|
583
|
+
type: 'direct.secure.welcome',
|
|
584
|
+
protocol: DIRECT_SECURE_PROTOCOL,
|
|
585
|
+
channel,
|
|
586
|
+
clientPublicKey: clientPublicKeyText,
|
|
587
|
+
clientNonce: clientNonceText,
|
|
588
|
+
hubPublicKey: hubPublicKeyText,
|
|
589
|
+
hubNonce: hubNonceText,
|
|
590
|
+
proof: base64Url(hubProof)
|
|
591
|
+
}));
|
|
592
|
+
return new SecureDirectDuplex(rawSocket, secureDuplexOptions(
|
|
593
|
+
'hub', channel, hubToClientKey, clientToHubKey, remainder));
|
|
594
|
+
} finally {
|
|
595
|
+
tokenBytes.fill(0);
|
|
596
|
+
actualClientProof?.fill(0);
|
|
597
|
+
expectedClientProof?.fill(0);
|
|
598
|
+
hubProof?.fill(0);
|
|
599
|
+
sharedSecret?.fill(0);
|
|
600
|
+
salt?.fill(0);
|
|
601
|
+
clientToHubKey?.fill(0);
|
|
602
|
+
hubToClientKey?.fill(0);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function waitForConnect(socket, timeoutMs) {
|
|
607
|
+
return new Promise((resolve, reject) => {
|
|
608
|
+
let settled = false;
|
|
609
|
+
const timer = setTimeout(
|
|
610
|
+
() => finish(new Error('direct-secure-connect-timeout')),
|
|
611
|
+
Math.max(500, Math.min(30_000, Number(timeoutMs) || 10_000)));
|
|
612
|
+
timer.unref?.();
|
|
613
|
+
const cleanup = () => {
|
|
614
|
+
clearTimeout(timer);
|
|
615
|
+
socket.removeListener('connect', onConnect);
|
|
616
|
+
socket.removeListener('error', onError);
|
|
617
|
+
socket.removeListener('close', onClose);
|
|
618
|
+
};
|
|
619
|
+
const finish = error => {
|
|
620
|
+
if (settled) return;
|
|
621
|
+
settled = true;
|
|
622
|
+
cleanup();
|
|
623
|
+
if (error) reject(error);
|
|
624
|
+
else resolve();
|
|
625
|
+
};
|
|
626
|
+
const onConnect = () => finish();
|
|
627
|
+
const onError = error => finish(error);
|
|
628
|
+
const onClose = () => finish(new Error('direct-secure-connect-closed'));
|
|
629
|
+
socket.once('connect', onConnect);
|
|
630
|
+
socket.once('error', onError);
|
|
631
|
+
socket.once('close', onClose);
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
export async function connectSecureDirectSocket(endpoint, pairToken, requestedChannel, options = {}) {
|
|
636
|
+
const channel = requireSecureChannel(requestedChannel);
|
|
637
|
+
const tokenBytes = Buffer.from(String(pairToken || ''), 'utf8');
|
|
638
|
+
const rawSocket = net.createConnection(endpoint);
|
|
639
|
+
let clientNonce;
|
|
640
|
+
let clientPublicKeyBytes;
|
|
641
|
+
let clientProof;
|
|
642
|
+
let actualHubProof;
|
|
643
|
+
let expectedHubProof;
|
|
644
|
+
let hubPublicKeyBytes;
|
|
645
|
+
let hubNonce;
|
|
646
|
+
let sharedSecret;
|
|
647
|
+
let salt;
|
|
648
|
+
let clientToHubKey;
|
|
649
|
+
let hubToClientKey;
|
|
650
|
+
try {
|
|
651
|
+
if (tokenBytes.length === 0) throw new Error('direct-secure-pair-token-required');
|
|
652
|
+
await waitForConnect(rawSocket, options.connectTimeoutMs);
|
|
653
|
+
rawSocket.setNoDelay(true);
|
|
654
|
+
const clientEcdh = crypto.createECDH('prime256v1');
|
|
655
|
+
clientEcdh.generateKeys();
|
|
656
|
+
clientNonce = crypto.randomBytes(CLIENT_NONCE_BYTES);
|
|
657
|
+
clientPublicKeyBytes = clientEcdh.getPublicKey(null, 'uncompressed');
|
|
658
|
+
const clientPublicKeyText = base64Url(clientPublicKeyBytes);
|
|
659
|
+
const clientNonceText = base64Url(clientNonce);
|
|
660
|
+
clientProof = hmac(
|
|
661
|
+
tokenBytes,
|
|
662
|
+
`livedesk-direct-client-v1\0${channel}\0${clientPublicKeyText}\0${clientNonceText}`);
|
|
663
|
+
await writeRaw(rawSocket, encodeHandshake({
|
|
664
|
+
type: 'direct.secure.hello',
|
|
665
|
+
protocol: DIRECT_SECURE_PROTOCOL,
|
|
666
|
+
channel,
|
|
667
|
+
clientPublicKey: clientPublicKeyText,
|
|
668
|
+
clientNonce: clientNonceText,
|
|
669
|
+
proof: base64Url(clientProof)
|
|
670
|
+
}));
|
|
671
|
+
|
|
672
|
+
const { message, remainder } = await readHandshake(rawSocket, null, options.handshakeTimeoutMs);
|
|
673
|
+
if (message?.type === 'direct.secure.reject'
|
|
674
|
+
&& message?.protocol === DIRECT_SECURE_PROTOCOL
|
|
675
|
+
&& message?.error === 'invalid-pair-token') {
|
|
676
|
+
const error = new Error('invalid-pair-token');
|
|
677
|
+
error.code = 'LIVEDESK_INVALID_PAIR_TOKEN';
|
|
678
|
+
throw error;
|
|
679
|
+
}
|
|
680
|
+
if (message?.type !== 'direct.secure.welcome' || message?.protocol !== DIRECT_SECURE_PROTOCOL) {
|
|
681
|
+
throw new Error('direct-secure-welcome-invalid');
|
|
682
|
+
}
|
|
683
|
+
if (message.channel !== channel
|
|
684
|
+
|| message.clientPublicKey !== clientPublicKeyText
|
|
685
|
+
|| message.clientNonce !== clientNonceText) {
|
|
686
|
+
throw new Error('direct-secure-client-echo-mismatch');
|
|
687
|
+
}
|
|
688
|
+
const hubPublicKeyText = String(message.hubPublicKey || '');
|
|
689
|
+
const hubNonceText = String(message.hubNonce || '');
|
|
690
|
+
hubPublicKeyBytes = decodeBase64Url(hubPublicKeyText, PUBLIC_KEY_BYTES);
|
|
691
|
+
hubNonce = decodeBase64Url(hubNonceText, HUB_NONCE_BYTES);
|
|
692
|
+
actualHubProof = decodeBase64Url(message.proof, PROOF_BYTES);
|
|
693
|
+
expectedHubProof = hmac(
|
|
694
|
+
tokenBytes,
|
|
695
|
+
`livedesk-direct-hub-v1\0${channel}\0${clientPublicKeyText}\0${clientNonceText}\0${hubPublicKeyText}\0${hubNonceText}`);
|
|
696
|
+
if (!timingSafeEqual(actualHubProof, expectedHubProof)) {
|
|
697
|
+
throw new Error('direct-secure-hub-proof-invalid');
|
|
698
|
+
}
|
|
699
|
+
sharedSecret = clientEcdh.computeSecret(hubPublicKeyBytes);
|
|
700
|
+
salt = hmac(
|
|
701
|
+
tokenBytes,
|
|
702
|
+
`livedesk-direct-salt-v1\0${channel}\0${clientNonceText}\0${hubNonceText}`);
|
|
703
|
+
clientToHubKey = deriveKey(
|
|
704
|
+
sharedSecret,
|
|
705
|
+
salt,
|
|
706
|
+
`livedesk-direct-e2e-v1\0${channel}\0${CLIENT_DIRECTION}\0${clientPublicKeyText}\0${hubPublicKeyText}`);
|
|
707
|
+
hubToClientKey = deriveKey(
|
|
708
|
+
sharedSecret,
|
|
709
|
+
salt,
|
|
710
|
+
`livedesk-direct-e2e-v1\0${channel}\0${HUB_DIRECTION}\0${clientPublicKeyText}\0${hubPublicKeyText}`);
|
|
711
|
+
|
|
712
|
+
return new SecureDirectDuplex(rawSocket, secureDuplexOptions(
|
|
713
|
+
'client', channel, clientToHubKey, hubToClientKey, remainder));
|
|
714
|
+
} catch (error) {
|
|
715
|
+
if (!rawSocket.destroyed) rawSocket.destroy();
|
|
716
|
+
throw error;
|
|
717
|
+
} finally {
|
|
718
|
+
tokenBytes.fill(0);
|
|
719
|
+
clientNonce?.fill(0);
|
|
720
|
+
clientPublicKeyBytes?.fill(0);
|
|
721
|
+
clientProof?.fill(0);
|
|
722
|
+
actualHubProof?.fill(0);
|
|
723
|
+
expectedHubProof?.fill(0);
|
|
724
|
+
hubPublicKeyBytes?.fill(0);
|
|
725
|
+
hubNonce?.fill(0);
|
|
726
|
+
sharedSecret?.fill(0);
|
|
727
|
+
salt?.fill(0);
|
|
728
|
+
clientToHubKey?.fill(0);
|
|
729
|
+
hubToClientKey?.fill(0);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
export function isLoopbackSocketAddress(value) {
|
|
734
|
+
const address = String(value || '').trim().toLowerCase();
|
|
735
|
+
return address === '127.0.0.1'
|
|
736
|
+
|| address === '::1'
|
|
737
|
+
|| address === '::ffff:127.0.0.1';
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
export function isPrivateSocketAddress(value) {
|
|
741
|
+
const address = String(value || '').trim().toLowerCase();
|
|
742
|
+
if (isLoopbackSocketAddress(address)) return true;
|
|
743
|
+
const ipv4 = address.startsWith('::ffff:') ? address.slice(7) : address;
|
|
744
|
+
if (net.isIPv4(ipv4)) {
|
|
745
|
+
const [first, second] = ipv4.split('.').map(Number);
|
|
746
|
+
return first === 10
|
|
747
|
+
|| (first === 172 && second >= 16 && second <= 31)
|
|
748
|
+
|| (first === 192 && second === 168)
|
|
749
|
+
|| (first === 169 && second === 254);
|
|
750
|
+
}
|
|
751
|
+
return address.startsWith('fc') || address.startsWith('fd') || address.startsWith('fe80:');
|
|
752
|
+
}
|