@livedesk/hub 0.1.32 → 0.1.34

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