@livedesk/hub 0.1.36 → 0.1.37

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.
@@ -1,543 +0,0 @@
1
- import crypto from 'node:crypto';
2
- import {
3
- SECURE_SESSION_MAX_CLOCK_SKEW_MS,
4
- SECURE_SESSION_MAX_HANDSHAKE_BYTES,
5
- SECURE_SESSION_PROTOCOL,
6
- SecureRecordSocket,
7
- decodeCanonicalBase64Url,
8
- deriveSecureSessionKey,
9
- enrollmentProof,
10
- fixedTimeBase64UrlEqual,
11
- normalizeSecureChannel,
12
- secureClientTranscript,
13
- secureServerTranscript
14
- } from '@livedesk/runtime-core';
15
-
16
- const HANDSHAKE_TIMEOUT_MS = 5_000;
17
- const REPLAY_TTL_MS = 2 * 60 * 1000;
18
- const MAX_REPLAY_ENTRIES = 8192;
19
- const DEFAULT_MAX_PENDING_HANDSHAKES = 128;
20
- // One Client can open control, frame, input, file, and audio channels.
21
- // A 200-computer site behind one NAT can therefore legitimately create
22
- // more than 1,000 authenticated handshakes during a coordinated restart.
23
- // Keep a generous raw-IP burst ceiling; actual abuse blocking below is
24
- // failure-scoped by device whenever a stable device id is available.
25
- const DEFAULT_MAX_CONNECTIONS_PER_IP_PER_MINUTE = 2400;
26
- const DEFAULT_MAX_CONSECUTIVE_FAILURES = 8;
27
- const DEFAULT_IP_BLOCK_MS = 60_000;
28
- const MAX_TRACKED_IPS = 4096;
29
- const MAX_TRACKED_DEVICE_FAILURES = 16384;
30
- const BLOCKED_IP_RECOVERY_PROBE_MS = 2_000;
31
- const IP_SCOPED_FAILURES = new Set(['secure-handshake-json-invalid', 'secure-handshake-too-large']);
32
-
33
- function directError(code) {
34
- const error = new Error(code);
35
- error.code = code;
36
- return error;
37
- }
38
-
39
- function clean(value, maximum = 256) {
40
- return String(value || '').replace(/[\0\r\n]/g, '').trim().slice(0, maximum);
41
- }
42
-
43
- function requireP256PublicKey(value) {
44
- const der = decodeCanonicalBase64Url(value, 80, 160);
45
- let key;
46
- try {
47
- key = crypto.createPublicKey({ key: der, format: 'der', type: 'spki' });
48
- } catch {
49
- throw directError('secure-public-key-invalid');
50
- }
51
- if (key.asymmetricKeyType !== 'ec' || key.asymmetricKeyDetails?.namedCurve !== 'prime256v1') {
52
- throw directError('secure-public-key-invalid');
53
- }
54
- return { key, text: der.toString('base64url') };
55
- }
56
-
57
- function verifyP256Signature(publicKey, message, signature) {
58
- const bytes = decodeCanonicalBase64Url(signature, 64, 64);
59
- if (!crypto.verify('sha256', Buffer.from(String(message || ''), 'utf8'), {
60
- key: publicKey,
61
- dsaEncoding: 'ieee-p1363'
62
- }, bytes)) {
63
- throw directError('device-signature-invalid');
64
- }
65
- }
66
-
67
- function safeHandshakeError(error) {
68
- const code = clean(error?.code || error?.message, 100).toLowerCase();
69
- return /^[-a-z0-9]+$/.test(code) ? code : 'secure-handshake-rejected';
70
- }
71
- function normalizeIpAddress(value) {
72
- return clean(value, 100).replace(/^::ffff:/, '').toLowerCase() || 'unknown';
73
- }
74
-
75
- function isPrivateNetworkAddress(value) {
76
- const address = normalizeIpAddress(value);
77
- if (address === '127.0.0.1' || address === '::1' || address === 'localhost') return true;
78
- if (address.startsWith('fe80:') || address.startsWith('fc') || address.startsWith('fd')) return true;
79
- const parts = address.split('.').map(part => Number(part));
80
- return parts.length === 4
81
- && parts.every(part => Number.isInteger(part) && part >= 0 && part <= 255)
82
- && (parts[0] === 10
83
- || (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31)
84
- || (parts[0] === 192 && parts[1] === 168));
85
- }
86
-
87
- function failureOwnerKey(ip, deviceId) {
88
- const normalizedDeviceId = clean(deviceId, 128);
89
- return normalizedDeviceId ? `${normalizeIpAddress(ip)}|${normalizedDeviceId}` : '';
90
- }
91
-
92
- export function createSecureDirectAcceptor({
93
- authority,
94
- getIdentity = () => ({}),
95
- getEnrollmentToken = () => '',
96
- consumeEnrollmentToken = () => false,
97
- onSecureSocket = () => {},
98
- onAudit = () => {},
99
- logWarn = () => {},
100
- now = () => Date.now(),
101
- abuseLimits = {}
102
- } = {}) {
103
- if (!authority?.verifyCredential || !authority?.issueCredential || !authority?.signHubMessage) {
104
- throw directError('secure-device-authority-required');
105
- }
106
- const replayCache = new Map();
107
- const ipStates = new Map();
108
- const deviceFailureStates = new Map();
109
- const maxPendingHandshakes = Math.max(1, Number(abuseLimits.maxPendingHandshakes)
110
- || DEFAULT_MAX_PENDING_HANDSHAKES);
111
- const maxConnectionsPerIpPerMinute = Math.max(1, Number(abuseLimits.maxConnectionsPerIpPerMinute)
112
- || DEFAULT_MAX_CONNECTIONS_PER_IP_PER_MINUTE);
113
- const maxConsecutiveFailures = Math.max(1, Number(abuseLimits.maxConsecutiveFailures)
114
- || DEFAULT_MAX_CONSECUTIVE_FAILURES);
115
- const ipBlockMs = Math.max(1_000, Number(abuseLimits.ipBlockMs) || DEFAULT_IP_BLOCK_MS);
116
- let acceptedSessions = 0;
117
- let rejectedSessions = 0;
118
- let enrollmentCount = 0;
119
- let resumedSessions = 0;
120
- let replayRejections = 0;
121
- let pendingHandshakes = 0;
122
- let capacityRejections = 0;
123
- let rateLimitRejections = 0;
124
- let blockedIpRejections = 0;
125
- let blockedDeviceRejections = 0;
126
- let compatibilityRejections = 0;
127
- let recoveryProbeAdmissions = 0;
128
- let probeClosures = 0;
129
-
130
- function remoteAddress(rawSocket) {
131
- return normalizeIpAddress(rawSocket?.remoteAddress);
132
- }
133
-
134
- function pruneIpStates(current) {
135
- for (const [ip, state] of ipStates) {
136
- if (current - state.lastSeenAt >= Math.max(ipBlockMs * 2, 5 * 60_000)) {
137
- ipStates.delete(ip);
138
- }
139
- }
140
- while (ipStates.size > MAX_TRACKED_IPS) {
141
- const oldest = ipStates.keys().next().value;
142
- if (!oldest) break;
143
- ipStates.delete(oldest);
144
- }
145
- }
146
-
147
- function getIpState(ip, current) {
148
- pruneIpStates(current);
149
- let state = ipStates.get(ip);
150
- if (!state) {
151
- state = {
152
- windowStartedAt: current,
153
- connectionCount: 0,
154
- consecutiveFailures: 0,
155
- blockedUntil: 0,
156
- nextRecoveryProbeAt: 0,
157
- lastSeenAt: current
158
- };
159
- ipStates.set(ip, state);
160
- }
161
- state.lastSeenAt = current;
162
- if (current - state.windowStartedAt >= 60_000) {
163
- state.windowStartedAt = current;
164
- state.connectionCount = 0;
165
- }
166
- return state;
167
- }
168
-
169
- function pruneDeviceFailureStates(current) {
170
- for (const [key, state] of deviceFailureStates) {
171
- if (current - state.lastSeenAt >= Math.max(ipBlockMs * 2, 5 * 60_000)) {
172
- deviceFailureStates.delete(key);
173
- }
174
- }
175
- while (deviceFailureStates.size > MAX_TRACKED_DEVICE_FAILURES) {
176
- const oldest = deviceFailureStates.keys().next().value;
177
- if (!oldest) break;
178
- deviceFailureStates.delete(oldest);
179
- }
180
- }
181
-
182
- function getDeviceFailureState(ip, deviceId, current) {
183
- const key = failureOwnerKey(ip, deviceId);
184
- if (!key) return null;
185
- pruneDeviceFailureStates(current);
186
- let state = deviceFailureStates.get(key);
187
- if (!state) {
188
- state = { consecutiveFailures: 0, blockedUntil: 0, lastSeenAt: current };
189
- deviceFailureStates.set(key, state);
190
- }
191
- state.lastSeenAt = current;
192
- return state;
193
- }
194
-
195
- function deviceAdmissionError(ip, deviceId, current) {
196
- const state = getDeviceFailureState(ip, deviceId, current);
197
- if (state?.blockedUntil > current) {
198
- blockedDeviceRejections += 1;
199
- return 'secure-device-temporarily-blocked';
200
- }
201
- return '';
202
- }
203
-
204
- function admissionError(ip, current) {
205
- const state = getIpState(ip, current);
206
- if (state.blockedUntil > current) {
207
- if (state.nextRecoveryProbeAt <= current) {
208
- state.nextRecoveryProbeAt = current + BLOCKED_IP_RECOVERY_PROBE_MS;
209
- recoveryProbeAdmissions += 1;
210
- } else {
211
- blockedIpRejections += 1;
212
- return 'secure-ip-temporarily-blocked';
213
- }
214
- }
215
- if (pendingHandshakes >= maxPendingHandshakes) {
216
- capacityRejections += 1;
217
- return 'secure-handshake-capacity';
218
- }
219
- state.connectionCount += 1;
220
- if (state.connectionCount > maxConnectionsPerIpPerMinute) {
221
- rateLimitRejections += 1;
222
- return 'secure-ip-rate-limited';
223
- }
224
- return '';
225
- }
226
-
227
- function recordFailure(ip, deviceId, code, current) {
228
- if (code === 'secure-client-hello-required' && isPrivateNetworkAddress(ip)) {
229
- compatibilityRejections += 1;
230
- return;
231
- }
232
- const deviceState = !IP_SCOPED_FAILURES.has(code)
233
- ? getDeviceFailureState(ip, deviceId, current)
234
- : null;
235
- if (deviceState) {
236
- deviceState.consecutiveFailures += 1;
237
- if (deviceState.consecutiveFailures >= maxConsecutiveFailures) {
238
- deviceState.blockedUntil = Math.max(deviceState.blockedUntil, current + ipBlockMs);
239
- }
240
- return;
241
- }
242
- const state = getIpState(ip, current);
243
- state.consecutiveFailures += 1;
244
- if (state.consecutiveFailures >= maxConsecutiveFailures) {
245
- state.blockedUntil = Math.max(state.blockedUntil, current + ipBlockMs);
246
- state.nextRecoveryProbeAt = current + BLOCKED_IP_RECOVERY_PROBE_MS;
247
- }
248
- }
249
-
250
- function recordSuccess(ip, deviceId, current) {
251
- const state = getIpState(ip, current);
252
- state.consecutiveFailures = 0;
253
- state.blockedUntil = 0;
254
- state.nextRecoveryProbeAt = 0;
255
- const deviceState = getDeviceFailureState(ip, deviceId, current);
256
- if (deviceState) {
257
- deviceState.consecutiveFailures = 0;
258
- deviceState.blockedUntil = 0;
259
- }
260
- }
261
-
262
- function rejectSocket(rawSocket, code, ip, observedDeviceId = '') {
263
- rejectedSessions += 1;
264
- onAudit({
265
- transport: 'direct-tcp',
266
- action: code === 'secure-ip-rate-limited'
267
- || code === 'secure-ip-temporarily-blocked'
268
- || code === 'secure-handshake-capacity'
269
- ? 'remote.abuse-defense'
270
- : 'remote.handshake',
271
- result: 'rejected',
272
- reason: code,
273
- deviceId: observedDeviceId,
274
- remoteAddress: ip
275
- });
276
- try { rawSocket.write(`${JSON.stringify({ type: 'secure.error', protocol: SECURE_SESSION_PROTOCOL, error: code })}\n`); } catch {}
277
- logWarn('security', `Secure Direct handshake rejected reason=${code} remote=${ip || 'unknown'}`);
278
- rawSocket.destroy();
279
- }
280
-
281
- function pruneReplay(current) {
282
- for (const [key, expiresAt] of replayCache) {
283
- if (expiresAt > current && replayCache.size <= MAX_REPLAY_ENTRIES) break;
284
- replayCache.delete(key);
285
- }
286
- }
287
-
288
- function claimReplayOwner(message, current) {
289
- const nonce = decodeCanonicalBase64Url(message.nonce, 16, 32).toString('base64url');
290
- const owner = crypto.createHash('sha256')
291
- .update(String(message.credential || getEnrollmentToken() || ''), 'utf8')
292
- .digest('base64url');
293
- const key = `${owner}:${nonce}`;
294
- pruneReplay(current);
295
- if (replayCache.has(key)) {
296
- replayRejections += 1;
297
- throw directError('secure-handshake-replay');
298
- }
299
- if (replayCache.size >= MAX_REPLAY_ENTRIES) throw directError('secure-replay-cache-capacity');
300
- replayCache.set(key, current + REPLAY_TTL_MS);
301
- }
302
-
303
- function handleHello(rawSocket, hello, remainder) {
304
- if (hello?.type !== 'secure.client-hello' || hello?.protocol !== SECURE_SESSION_PROTOCOL) {
305
- throw directError('secure-client-hello-required');
306
- }
307
- const current = Math.floor(now());
308
- const timestamp = Number(hello.timestamp);
309
- if (!Number.isSafeInteger(timestamp) || Math.abs(current - timestamp) > SECURE_SESSION_MAX_CLOCK_SKEW_MS) {
310
- throw directError('secure-handshake-timestamp-invalid');
311
- }
312
- claimReplayOwner(hello, current);
313
- const channel = normalizeSecureChannel(hello.channel);
314
- const deviceId = clean(hello.deviceId, 128);
315
- if (!deviceId) throw directError('device-id-required');
316
- const devicePublic = requireP256PublicKey(hello.devicePublicKey);
317
- const clientEphemeral = requireP256PublicKey(hello.clientEphemeralPublicKey);
318
- const clientTranscript = secureClientTranscript(hello);
319
- const identity = getIdentity() || {};
320
- const accountId = clean(identity.accountId, 128);
321
- if (!accountId) throw directError('secure-account-session-required');
322
-
323
- let credential;
324
- let credentialPayload;
325
- let enrollmentSecret = '';
326
- if (hello.mode === 'enroll') {
327
- if (channel !== 'control') throw directError('secure-enrollment-control-channel-required');
328
- const token = String(getEnrollmentToken() || '');
329
- enrollmentSecret = token;
330
- const expectedProof = enrollmentProof(token, clientTranscript);
331
- if (!fixedTimeBase64UrlEqual(hello.enrollmentProof, expectedProof, 32)) {
332
- throw directError('secure-enrollment-proof-invalid');
333
- }
334
- verifyP256Signature(devicePublic.key, clientTranscript, hello.deviceSignature);
335
- authority.assertEnrollmentAllowed?.({ accountId, deviceId, devicePublicKey: devicePublic.text });
336
- if (!consumeEnrollmentToken(token)) throw directError('secure-enrollment-token-used');
337
- const issued = authority.issueCredential({ accountId, deviceId, devicePublicKey: devicePublic.text });
338
- credential = issued.credential;
339
- credentialPayload = issued.payload;
340
- enrollmentCount += 1;
341
- } else if (hello.mode === 'resume') {
342
- const verified = authority.verifyCredential(hello.credential, { accountId, deviceId });
343
- if (verified.payload.devicePublicKey !== devicePublic.text) throw directError('device-credential-binding-invalid');
344
- authority.verifyDeviceSignature(verified, clientTranscript, hello.deviceSignature);
345
- credential = verified.text;
346
- credentialPayload = verified.payload;
347
- resumedSessions += 1;
348
- } else {
349
- throw directError('secure-handshake-mode-invalid');
350
- }
351
-
352
- const { privateKey: serverEphemeralPrivate, publicKey: serverEphemeralPublic } = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
353
- const sessionId = crypto.randomBytes(16).toString('base64url');
354
- const response = {
355
- type: 'secure.server-hello',
356
- protocol: SECURE_SESSION_PROTOCOL,
357
- sessionId,
358
- channel,
359
- timestamp: current,
360
- serverNonce: crypto.randomBytes(16).toString('base64url'),
361
- serverEphemeralPublicKey: serverEphemeralPublic.export({ format: 'der', type: 'spki' }).toString('base64url'),
362
- credential,
363
- hubId: authority.hubId,
364
- accountId,
365
- deviceId
366
- };
367
- const serverTranscript = secureServerTranscript(response, clientTranscript);
368
- response.hubSignature = authority.signHubMessage(serverTranscript);
369
- if (hello.mode === 'enroll') {
370
- response.enrollmentServerProof = enrollmentProof(enrollmentSecret, serverTranscript);
371
- }
372
- const sharedSecret = crypto.diffieHellman({ privateKey: serverEphemeralPrivate, publicKey: clientEphemeral.key });
373
- let sessionKey;
374
- try {
375
- sessionKey = deriveSecureSessionKey({ sharedSecret, clientTranscript, serverTranscript, sessionId, channel });
376
- } finally {
377
- sharedSecret.fill(0);
378
- }
379
- const secureSocket = new SecureRecordSocket(rawSocket, {
380
- sessionKey,
381
- sessionId,
382
- channel,
383
- role: 'hub',
384
- securityContext: {
385
- protocol: SECURE_SESSION_PROTOCOL,
386
- accountId,
387
- hubId: authority.hubId,
388
- deviceId,
389
- credentialSerial: credentialPayload.serial,
390
- authenticated: true,
391
- encrypted: true
392
- }
393
- });
394
- sessionKey.fill(0);
395
- secureSocket.__liveDeskDirectSecure = true;
396
- rawSocket.write(`${JSON.stringify(response)}\n`);
397
- onSecureSocket(secureSocket, {
398
- protocol: SECURE_SESSION_PROTOCOL,
399
- accountId,
400
- hubId: authority.hubId,
401
- deviceId,
402
- channel,
403
- credentialSerial: credentialPayload.serial,
404
- enrolled: hello.mode === 'enroll'
405
- });
406
- onAudit({
407
- transport: 'direct-tcp',
408
- action: hello.mode === 'enroll' ? 'remote.device.enroll' : 'remote.device.resume',
409
- result: 'accepted',
410
- accountId,
411
- hubId: authority.hubId,
412
- deviceId,
413
- sessionId,
414
- channel,
415
- credentialSerial: credentialPayload.serial
416
- });
417
- authority.markConnected(deviceId);
418
- acceptedSessions += 1;
419
- if (remainder.length > 0) secureSocket.feedEncrypted(remainder);
420
- }
421
-
422
- function accept(rawSocket, firstChunk = null) {
423
- const ip = remoteAddress(rawSocket);
424
- const admittedAt = Math.floor(now());
425
- const denied = admissionError(ip, admittedAt);
426
- if (denied) {
427
- rejectSocket(rawSocket, denied, ip);
428
- return false;
429
- }
430
- pendingHandshakes += 1;
431
- let chunks = [];
432
- let bytes = 0;
433
- let settled = false;
434
- let pendingHeld = true;
435
- let observedDeviceId = '';
436
- const timer = setTimeout(() => fail(directError('secure-handshake-timeout')), HANDSHAKE_TIMEOUT_MS);
437
- timer.unref?.();
438
-
439
- const releasePending = () => {
440
- if (!pendingHeld) return;
441
- pendingHeld = false;
442
- pendingHandshakes = Math.max(0, pendingHandshakes - 1);
443
- };
444
-
445
- const cleanup = () => {
446
- clearTimeout(timer);
447
- rawSocket.removeListener('data', onData);
448
- rawSocket.removeListener('close', onClose);
449
- releasePending();
450
- };
451
-
452
- const fail = error => {
453
- if (settled) return;
454
- settled = true;
455
- cleanup();
456
- const code = safeHandshakeError(error);
457
- if ((code === 'secure-handshake-closed' || code === 'secure-handshake-timeout') && bytes === 0) {
458
- probeClosures += 1;
459
- try { rawSocket.destroy(); } catch {}
460
- return;
461
- }
462
- recordFailure(ip, observedDeviceId, code, Math.floor(now()));
463
- rejectSocket(rawSocket, code, ip, observedDeviceId);
464
- };
465
-
466
- const onClose = () => {
467
- if (!settled) fail(directError('secure-handshake-closed'));
468
- };
469
-
470
- const onData = chunk => {
471
- if (settled) return;
472
- const incoming = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk || []);
473
- bytes += incoming.length;
474
- if (bytes > SECURE_SESSION_MAX_HANDSHAKE_BYTES) {
475
- fail(directError('secure-handshake-too-large'));
476
- return;
477
- }
478
- chunks.push(incoming);
479
- const combined = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks, bytes);
480
- const newline = combined.indexOf(0x0a);
481
- if (newline < 0) return;
482
- settled = true;
483
- cleanup();
484
- let hello;
485
- try {
486
- hello = JSON.parse(combined.subarray(0, newline).toString('utf8'));
487
- observedDeviceId = clean(hello?.deviceId, 128); const deviceDenied = deviceAdmissionError(ip, observedDeviceId, Math.floor(now()));
488
- if (deviceDenied) {
489
- settled = false;
490
- fail(directError(deviceDenied));
491
- return;
492
- }
493
- } catch {
494
- settled = false;
495
- fail(directError('secure-handshake-json-invalid'));
496
- return;
497
- }
498
- try {
499
- handleHello(rawSocket, hello, combined.subarray(newline + 1));
500
- recordSuccess(ip, observedDeviceId, Math.floor(now()));
501
- } catch (error) {
502
- // settled is reset only for the common failure path so it can emit the
503
- // bounded rejection response and release the raw socket.
504
- settled = false;
505
- fail(error);
506
- }
507
- };
508
-
509
- rawSocket.on('data', onData);
510
- rawSocket.once('close', onClose);
511
- if (firstChunk) onData(firstChunk);
512
- }
513
-
514
- return Object.freeze({
515
- accept,
516
- getStatus: () => ({
517
- protocol: SECURE_SESSION_PROTOCOL,
518
- acceptedSessions,
519
- rejectedSessions,
520
- enrollmentCount,
521
- resumedSessions,
522
- replayCacheSize: replayCache.size,
523
- replayRejections,
524
- pendingHandshakes,
525
- trackedIpCount: ipStates.size,
526
- trackedDeviceFailureCount: deviceFailureStates.size,
527
- capacityRejections,
528
- rateLimitRejections,
529
- blockedIpRejections,
530
- blockedDeviceRejections,
531
- compatibilityRejections,
532
- recoveryProbeAdmissions,
533
- probeClosures,
534
- limits: {
535
- maxPendingHandshakes,
536
- maxConnectionsPerIpPerMinute,
537
- maxConsecutiveFailures,
538
- ipBlockMs,
539
- handshakeTimeoutMs: HANDSHAKE_TIMEOUT_MS
540
- }
541
- })
542
- });
543
- }