@livedesk/hub 0.1.30 → 0.1.31
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/package.json +2 -2
- package/src/agents/agent-audit-store.js +16 -6
- package/src/agents/agent-permissions.js +9 -3
- package/src/agents/agent-tool-registry.js +19 -1
- package/src/captures/capture-store.js +50 -3
- package/src/filesystem/shared-folders.js +8 -0
- package/src/filesystem/transfer-jobs.js +31 -4
- package/src/live-desk-update.js +87 -19
- package/src/remote-hub.js +658 -166
- package/src/security/device-credential-authority.js +358 -0
- package/src/security/security-audit-store.js +238 -0
- package/src/server.js +755 -191
- package/src/settings/settings-schema.js +19 -39
- package/src/transport/relay-hub-control.js +329 -3
- package/src/transport/secure-direct-acceptor.js +432 -0
- package/src/transport/udp-hub-transport.js +17 -5
- package/src/transport/udp-rendezvous.js +54 -2
|
@@ -0,0 +1,432 @@
|
|
|
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
|
+
if (!consumeEnrollmentToken(token)) throw directError('secure-enrollment-token-used');
|
|
241
|
+
const issued = authority.issueCredential({ accountId, deviceId, devicePublicKey: devicePublic.text, replace: true });
|
|
242
|
+
credential = issued.credential;
|
|
243
|
+
credentialPayload = issued.payload;
|
|
244
|
+
enrollmentCount += 1;
|
|
245
|
+
} else if (hello.mode === 'resume') {
|
|
246
|
+
const verified = authority.verifyCredential(hello.credential, { accountId, deviceId });
|
|
247
|
+
if (verified.payload.devicePublicKey !== devicePublic.text) throw directError('device-credential-binding-invalid');
|
|
248
|
+
authority.verifyDeviceSignature(verified, clientTranscript, hello.deviceSignature);
|
|
249
|
+
credential = verified.text;
|
|
250
|
+
credentialPayload = verified.payload;
|
|
251
|
+
resumedSessions += 1;
|
|
252
|
+
} else {
|
|
253
|
+
throw directError('secure-handshake-mode-invalid');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const { privateKey: serverEphemeralPrivate, publicKey: serverEphemeralPublic } = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
|
257
|
+
const sessionId = crypto.randomBytes(16).toString('base64url');
|
|
258
|
+
const response = {
|
|
259
|
+
type: 'secure.server-hello',
|
|
260
|
+
protocol: SECURE_SESSION_PROTOCOL,
|
|
261
|
+
sessionId,
|
|
262
|
+
channel,
|
|
263
|
+
timestamp: current,
|
|
264
|
+
serverNonce: crypto.randomBytes(16).toString('base64url'),
|
|
265
|
+
serverEphemeralPublicKey: serverEphemeralPublic.export({ format: 'der', type: 'spki' }).toString('base64url'),
|
|
266
|
+
credential,
|
|
267
|
+
hubId: authority.hubId,
|
|
268
|
+
accountId,
|
|
269
|
+
deviceId
|
|
270
|
+
};
|
|
271
|
+
const serverTranscript = secureServerTranscript(response, clientTranscript);
|
|
272
|
+
response.hubSignature = authority.signHubMessage(serverTranscript);
|
|
273
|
+
if (hello.mode === 'enroll') {
|
|
274
|
+
response.enrollmentServerProof = enrollmentProof(enrollmentSecret, serverTranscript);
|
|
275
|
+
}
|
|
276
|
+
const sharedSecret = crypto.diffieHellman({ privateKey: serverEphemeralPrivate, publicKey: clientEphemeral.key });
|
|
277
|
+
let sessionKey;
|
|
278
|
+
try {
|
|
279
|
+
sessionKey = deriveSecureSessionKey({ sharedSecret, clientTranscript, serverTranscript, sessionId, channel });
|
|
280
|
+
} finally {
|
|
281
|
+
sharedSecret.fill(0);
|
|
282
|
+
}
|
|
283
|
+
const secureSocket = new SecureRecordSocket(rawSocket, {
|
|
284
|
+
sessionKey,
|
|
285
|
+
sessionId,
|
|
286
|
+
channel,
|
|
287
|
+
role: 'hub',
|
|
288
|
+
securityContext: {
|
|
289
|
+
protocol: SECURE_SESSION_PROTOCOL,
|
|
290
|
+
accountId,
|
|
291
|
+
hubId: authority.hubId,
|
|
292
|
+
deviceId,
|
|
293
|
+
credentialSerial: credentialPayload.serial,
|
|
294
|
+
authenticated: true,
|
|
295
|
+
encrypted: true
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
sessionKey.fill(0);
|
|
299
|
+
secureSocket.__liveDeskDirectSecure = true;
|
|
300
|
+
rawSocket.write(`${JSON.stringify(response)}\n`);
|
|
301
|
+
onSecureSocket(secureSocket, {
|
|
302
|
+
protocol: SECURE_SESSION_PROTOCOL,
|
|
303
|
+
accountId,
|
|
304
|
+
hubId: authority.hubId,
|
|
305
|
+
deviceId,
|
|
306
|
+
channel,
|
|
307
|
+
credentialSerial: credentialPayload.serial,
|
|
308
|
+
enrolled: hello.mode === 'enroll'
|
|
309
|
+
});
|
|
310
|
+
onAudit({
|
|
311
|
+
transport: 'direct-tcp',
|
|
312
|
+
action: hello.mode === 'enroll' ? 'remote.device.enroll' : 'remote.device.resume',
|
|
313
|
+
result: 'accepted',
|
|
314
|
+
accountId,
|
|
315
|
+
hubId: authority.hubId,
|
|
316
|
+
deviceId,
|
|
317
|
+
sessionId,
|
|
318
|
+
channel,
|
|
319
|
+
credentialSerial: credentialPayload.serial
|
|
320
|
+
});
|
|
321
|
+
authority.markConnected(deviceId);
|
|
322
|
+
acceptedSessions += 1;
|
|
323
|
+
if (remainder.length > 0) secureSocket.feedEncrypted(remainder);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function accept(rawSocket, firstChunk = null) {
|
|
327
|
+
const ip = remoteAddress(rawSocket);
|
|
328
|
+
const admittedAt = Math.floor(now());
|
|
329
|
+
const denied = admissionError(ip, admittedAt);
|
|
330
|
+
if (denied) {
|
|
331
|
+
rejectSocket(rawSocket, denied, ip);
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
pendingHandshakes += 1;
|
|
335
|
+
let chunks = [];
|
|
336
|
+
let bytes = 0;
|
|
337
|
+
let settled = false;
|
|
338
|
+
let pendingHeld = true;
|
|
339
|
+
let observedDeviceId = '';
|
|
340
|
+
const timer = setTimeout(() => fail(directError('secure-handshake-timeout')), HANDSHAKE_TIMEOUT_MS);
|
|
341
|
+
timer.unref?.();
|
|
342
|
+
|
|
343
|
+
const releasePending = () => {
|
|
344
|
+
if (!pendingHeld) return;
|
|
345
|
+
pendingHeld = false;
|
|
346
|
+
pendingHandshakes = Math.max(0, pendingHandshakes - 1);
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const cleanup = () => {
|
|
350
|
+
clearTimeout(timer);
|
|
351
|
+
rawSocket.removeListener('data', onData);
|
|
352
|
+
rawSocket.removeListener('close', onClose);
|
|
353
|
+
releasePending();
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const fail = error => {
|
|
357
|
+
if (settled) return;
|
|
358
|
+
settled = true;
|
|
359
|
+
cleanup();
|
|
360
|
+
const code = safeHandshakeError(error);
|
|
361
|
+
recordFailure(ip, Math.floor(now()));
|
|
362
|
+
rejectSocket(rawSocket, code, ip, observedDeviceId);
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
const onClose = () => {
|
|
366
|
+
if (!settled) fail(directError('secure-handshake-closed'));
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const onData = chunk => {
|
|
370
|
+
if (settled) return;
|
|
371
|
+
const incoming = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk || []);
|
|
372
|
+
bytes += incoming.length;
|
|
373
|
+
if (bytes > SECURE_SESSION_MAX_HANDSHAKE_BYTES) {
|
|
374
|
+
fail(directError('secure-handshake-too-large'));
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
chunks.push(incoming);
|
|
378
|
+
const combined = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks, bytes);
|
|
379
|
+
const newline = combined.indexOf(0x0a);
|
|
380
|
+
if (newline < 0) return;
|
|
381
|
+
settled = true;
|
|
382
|
+
cleanup();
|
|
383
|
+
let hello;
|
|
384
|
+
try {
|
|
385
|
+
hello = JSON.parse(combined.subarray(0, newline).toString('utf8'));
|
|
386
|
+
observedDeviceId = clean(hello?.deviceId, 128);
|
|
387
|
+
} catch {
|
|
388
|
+
settled = false;
|
|
389
|
+
fail(directError('secure-handshake-json-invalid'));
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
try {
|
|
393
|
+
handleHello(rawSocket, hello, combined.subarray(newline + 1));
|
|
394
|
+
recordSuccess(ip, Math.floor(now()));
|
|
395
|
+
} catch (error) {
|
|
396
|
+
// settled is reset only for the common failure path so it can emit the
|
|
397
|
+
// bounded rejection response and release the raw socket.
|
|
398
|
+
settled = false;
|
|
399
|
+
fail(error);
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
rawSocket.on('data', onData);
|
|
404
|
+
rawSocket.once('close', onClose);
|
|
405
|
+
if (firstChunk) onData(firstChunk);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return Object.freeze({
|
|
409
|
+
accept,
|
|
410
|
+
getStatus: () => ({
|
|
411
|
+
protocol: SECURE_SESSION_PROTOCOL,
|
|
412
|
+
acceptedSessions,
|
|
413
|
+
rejectedSessions,
|
|
414
|
+
enrollmentCount,
|
|
415
|
+
resumedSessions,
|
|
416
|
+
replayCacheSize: replayCache.size,
|
|
417
|
+
replayRejections,
|
|
418
|
+
pendingHandshakes,
|
|
419
|
+
trackedIpCount: ipStates.size,
|
|
420
|
+
capacityRejections,
|
|
421
|
+
rateLimitRejections,
|
|
422
|
+
blockedIpRejections,
|
|
423
|
+
limits: {
|
|
424
|
+
maxPendingHandshakes,
|
|
425
|
+
maxConnectionsPerIpPerMinute,
|
|
426
|
+
maxConsecutiveFailures,
|
|
427
|
+
ipBlockMs,
|
|
428
|
+
handshakeTimeoutMs: HANDSHAKE_TIMEOUT_MS
|
|
429
|
+
}
|
|
430
|
+
})
|
|
431
|
+
});
|
|
432
|
+
}
|
|
@@ -96,7 +96,8 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
|
|
|
96
96
|
let rendezvousAddresses = new Set();
|
|
97
97
|
let rendezvousRefreshTimer = null;
|
|
98
98
|
let rendezvousRefreshPromise = null;
|
|
99
|
-
let frameExpiryTimer = null;
|
|
99
|
+
let frameExpiryTimer = null;
|
|
100
|
+
let rendezvousProofIssuer = null;
|
|
100
101
|
const peerProbeBurstDelaysMs = [0, 120, 360];
|
|
101
102
|
|
|
102
103
|
function emit(type, event = {}) {
|
|
@@ -440,6 +441,12 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
|
|
|
440
441
|
udpSessionId = crypto.randomUUID();
|
|
441
442
|
routeKey = udpSessionRouteKey(udpSessionId);
|
|
442
443
|
} while (sessionsByRouteKey.has(routeKey));
|
|
444
|
+
const proof = typeof rendezvousProofIssuer === 'function'
|
|
445
|
+
? rendezvousProofIssuer({ roomId: udpSessionId, deviceId: id, ttlMs: 60_000 })
|
|
446
|
+
: null;
|
|
447
|
+
const signedRendezvousProof = safeText(proof?.proof, 2048);
|
|
448
|
+
const allowLegacyUnsignedProofForTests = env.LIVEDESK_TEST_MODE === '1'
|
|
449
|
+
|| env.LIVEDESK_TEST_ALLOW_LEGACY_UDP_RENDEZVOUS === '1';
|
|
443
450
|
const session = {
|
|
444
451
|
deviceId: id,
|
|
445
452
|
tcpSessionId: safeText(tcpSessionId, 160),
|
|
@@ -448,7 +455,9 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
|
|
|
448
455
|
routeKey,
|
|
449
456
|
key,
|
|
450
457
|
keyBase64: key.toString('base64'),
|
|
451
|
-
rendezvousToken:
|
|
458
|
+
rendezvousToken: signedRendezvousProof
|
|
459
|
+
|| (allowLegacyUnsignedProofForTests ? crypto.randomBytes(16).toString('hex') : ''),
|
|
460
|
+
rendezvousProofExpiresAt: Number(proof?.payload?.expiresAt || 0),
|
|
452
461
|
sendControl,
|
|
453
462
|
clientEndpoint: null,
|
|
454
463
|
peerEndpoint: null,
|
|
@@ -484,9 +493,9 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
|
|
|
484
493
|
frameChunkHeader: UDP_FRAME_CHUNK_PROTOCOL,
|
|
485
494
|
frameTimeoutMs,
|
|
486
495
|
maxPendingFrames,
|
|
487
|
-
rendezvous: rendezvousHost
|
|
488
|
-
? { host: rendezvousHost, port: rendezvousPort, roomId: session.sessionId, token: session.rendezvousToken }
|
|
489
|
-
: null
|
|
496
|
+
rendezvous: rendezvousHost && session.rendezvousToken
|
|
497
|
+
? { host: rendezvousHost, port: rendezvousPort, roomId: session.sessionId, token: session.rendezvousToken }
|
|
498
|
+
: null
|
|
490
499
|
});
|
|
491
500
|
sendRendezvousRegister(session);
|
|
492
501
|
emit('UdpSessionCreated', { deviceId: id, state: 'udp-session-created' });
|
|
@@ -615,6 +624,9 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
|
|
|
615
624
|
refreshDeviceDiagnosticNetwork,
|
|
616
625
|
getDeviceDiagnosticNetwork,
|
|
617
626
|
getStatus,
|
|
627
|
+
setRendezvousProofIssuer: issuer => {
|
|
628
|
+
rendezvousProofIssuer = typeof issuer === 'function' ? issuer : null;
|
|
629
|
+
},
|
|
618
630
|
setFrameHandler: handler => { frameHandler = typeof handler === 'function' ? handler : () => {}; },
|
|
619
631
|
setEventHandler: handler => { eventHandler = typeof handler === 'function' ? handler : () => {}; }
|
|
620
632
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import dgram from 'node:dgram';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
2
3
|
import {
|
|
3
4
|
decodeRendezvousMessage,
|
|
4
5
|
encodeRendezvousMessage,
|
|
@@ -52,6 +53,52 @@ function consumeToken(bucket, rate, capacity, now) {
|
|
|
52
53
|
return true;
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
function decodeCanonicalBase64Url(value, minimum, maximum = minimum) {
|
|
57
|
+
const text = String(value || '');
|
|
58
|
+
if (!/^[A-Za-z0-9_-]+$/u.test(text)) return null;
|
|
59
|
+
const bytes = Buffer.from(text, 'base64url');
|
|
60
|
+
return bytes.length >= minimum && bytes.length <= maximum && bytes.toString('base64url') === text
|
|
61
|
+
? bytes
|
|
62
|
+
: null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function verifyRendezvousProof(proof, roomId, current) {
|
|
66
|
+
try {
|
|
67
|
+
const parts = String(proof || '').split('.');
|
|
68
|
+
if (parts.length !== 2) return false;
|
|
69
|
+
const payloadBytes = decodeCanonicalBase64Url(parts[0], 64, 1536);
|
|
70
|
+
const signature = decodeCanonicalBase64Url(parts[1], 64, 64);
|
|
71
|
+
if (!payloadBytes || !signature) return false;
|
|
72
|
+
const payload = JSON.parse(payloadBytes.toString('utf8'));
|
|
73
|
+
if (!payload || payload.version !== 1
|
|
74
|
+
|| payload.protocol !== 'livedesk.udp.rendezvous-proof.v1'
|
|
75
|
+
|| payload.roomId !== roomId
|
|
76
|
+
|| !safeText(payload.accountId, 128)
|
|
77
|
+
|| !safeText(payload.hubId, 128)
|
|
78
|
+
|| !safeText(payload.deviceId, 128)
|
|
79
|
+
|| !Number.isSafeInteger(payload.issuedAt)
|
|
80
|
+
|| !Number.isSafeInteger(payload.expiresAt)
|
|
81
|
+
|| payload.issuedAt > current + 30_000
|
|
82
|
+
|| payload.expiresAt <= current
|
|
83
|
+
|| payload.expiresAt <= payload.issuedAt
|
|
84
|
+
|| payload.expiresAt - payload.issuedAt > 120_000
|
|
85
|
+
|| !decodeCanonicalBase64Url(payload.nonce, 16, 32)) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
const hubPublicBytes = decodeCanonicalBase64Url(payload.hubPublicKey, 80, 160);
|
|
89
|
+
if (!hubPublicBytes) return false;
|
|
90
|
+
const hubKey = crypto.createPublicKey({ key: hubPublicBytes, format: 'der', type: 'spki' });
|
|
91
|
+
return hubKey.asymmetricKeyType === 'ec'
|
|
92
|
+
&& hubKey.asymmetricKeyDetails?.namedCurve === 'prime256v1'
|
|
93
|
+
&& crypto.verify('sha256', Buffer.from(parts[0], 'utf8'), {
|
|
94
|
+
key: hubKey,
|
|
95
|
+
dsaEncoding: 'ieee-p1363'
|
|
96
|
+
}, signature);
|
|
97
|
+
} catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
55
102
|
export function createUdpRendezvousServer({
|
|
56
103
|
host = '0.0.0.0',
|
|
57
104
|
port = 5199,
|
|
@@ -68,7 +115,8 @@ export function createUdpRendezvousServer({
|
|
|
68
115
|
perSourceRate = DEFAULT_LIMITS.perSourceRate,
|
|
69
116
|
perSourceBurst = DEFAULT_LIMITS.perSourceBurst,
|
|
70
117
|
globalRate = DEFAULT_LIMITS.globalRate,
|
|
71
|
-
globalBurst = DEFAULT_LIMITS.globalBurst
|
|
118
|
+
globalBurst = DEFAULT_LIMITS.globalBurst,
|
|
119
|
+
allowLegacyTokens = false
|
|
72
120
|
} = {}) {
|
|
73
121
|
const socket = dgram.createSocket('udp4');
|
|
74
122
|
const rooms = new Map();
|
|
@@ -264,11 +312,15 @@ export function createUdpRendezvousServer({
|
|
|
264
312
|
}
|
|
265
313
|
const roomId = safeText(message.roomId, 160);
|
|
266
314
|
const role = safeText(message.role, 16).toLowerCase();
|
|
267
|
-
const token = safeText(message.token,
|
|
315
|
+
const token = safeText(message.token, 2048);
|
|
268
316
|
if (!roomId || !token || !['hub', 'client'].includes(role)) {
|
|
269
317
|
counters.invalidDatagrams += 1;
|
|
270
318
|
return;
|
|
271
319
|
}
|
|
320
|
+
if (!allowLegacyTokens && !verifyRendezvousProof(token, roomId, at)) {
|
|
321
|
+
counters.invalidDatagrams += 1;
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
272
324
|
let room = rooms.get(roomId);
|
|
273
325
|
if (room && at - room.updatedAt >= limits.roomTtlMs) {
|
|
274
326
|
deleteRoom(roomId, 'expired');
|