@livedesk/hub 0.1.31 → 0.1.32
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/filesystem/transfer-jobs.js +5 -1
- package/src/remote-hub.js +64 -7
- package/src/security/device-credential-authority.js +75 -27
- package/src/security/security-audit-store.js +24 -2
- package/src/server.js +298 -127
- package/src/transport/relay-hub-control.js +2 -1
- package/src/transport/secure-direct-acceptor.js +2 -1
- package/src/transport/udp-hub-transport.js +19 -8
- package/src/transport/udp-rendezvous.js +143 -29
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@livedesk/hub",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.32",
|
|
4
4
|
"description": "LiveDesk local Hub API and browser frame bridge",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/server.js",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
18
|
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
|
19
|
-
"@livedesk/runtime-core": "0.1.
|
|
19
|
+
"@livedesk/runtime-core": "0.1.4",
|
|
20
20
|
"@openai/codex-sdk": "0.145.0",
|
|
21
21
|
"cors": "^2.8.5",
|
|
22
22
|
"express": "^4.21.2",
|
|
@@ -322,7 +322,11 @@ export class HubTransferJobs {
|
|
|
322
322
|
try {
|
|
323
323
|
await job.onComplete({ completed, job: snapshot(job) });
|
|
324
324
|
} catch (error) {
|
|
325
|
-
|
|
325
|
+
// A completion callback can be the durable security-audit gate. Never
|
|
326
|
+
// leave a job looking successful when that final record was not stored.
|
|
327
|
+
job.state = 'failed';
|
|
328
|
+
job.error = `completion-callback-failed:${error instanceof Error ? error.message : String(error)}`;
|
|
329
|
+
job.completedAt ||= new Date().toISOString();
|
|
326
330
|
markUpdated(job);
|
|
327
331
|
}
|
|
328
332
|
}
|
package/src/remote-hub.js
CHANGED
|
@@ -2331,11 +2331,12 @@ export function createRemoteHub(options = {}) {
|
|
|
2331
2331
|
const getSecurityIdentity = typeof options.getSecurityIdentity === 'function'
|
|
2332
2332
|
? options.getSecurityIdentity
|
|
2333
2333
|
: () => ({ accountId: safeString(options.accountId || env.LIVEDESK_ACCOUNT_ID, 128) });
|
|
2334
|
-
udpTransport?.setRendezvousProofIssuer?.(({ roomId, deviceId, ttlMs }) => {
|
|
2334
|
+
udpTransport?.setRendezvousProofIssuer?.(({ roomId, deviceId, role, ttlMs }) => {
|
|
2335
2335
|
const identity = getSecurityIdentity() || {};
|
|
2336
2336
|
return deviceCredentialAuthority.issueRendezvousProof({
|
|
2337
2337
|
roomId,
|
|
2338
2338
|
deviceId,
|
|
2339
|
+
role,
|
|
2339
2340
|
ttlMs,
|
|
2340
2341
|
accountId: safeString(identity.accountId, 128)
|
|
2341
2342
|
});
|
|
@@ -8321,7 +8322,7 @@ export function createRemoteHub(options = {}) {
|
|
|
8321
8322
|
};
|
|
8322
8323
|
}
|
|
8323
8324
|
|
|
8324
|
-
function sendCommand(deviceId, command) {
|
|
8325
|
+
function sendCommand(deviceId, command) {
|
|
8325
8326
|
const device = devices.get(String(deviceId || ''));
|
|
8326
8327
|
const commandName = safeString(command?.command || 'ping', 80);
|
|
8327
8328
|
const requiredPermission = commandName === 'input.control'
|
|
@@ -8387,8 +8388,62 @@ export function createRemoteHub(options = {}) {
|
|
|
8387
8388
|
command: payload.command,
|
|
8388
8389
|
channel: dedicatedFileSocket ? 'file' : 'control'
|
|
8389
8390
|
});
|
|
8390
|
-
return { ok: true, commandId };
|
|
8391
|
-
}
|
|
8391
|
+
return { ok: true, commandId };
|
|
8392
|
+
}
|
|
8393
|
+
|
|
8394
|
+
async function sendCommandAwaitResult(deviceId, command, options = {}) {
|
|
8395
|
+
const normalizedDeviceId = safeString(deviceId, 160);
|
|
8396
|
+
const device = devices.get(normalizedDeviceId);
|
|
8397
|
+
const commandId = safeString(command?.commandId, 128) || crypto.randomUUID();
|
|
8398
|
+
const commandWithId = { ...(command || {}), commandId };
|
|
8399
|
+
|
|
8400
|
+
if (device?.synthetic === true && device.connected) {
|
|
8401
|
+
const sent = sendCommand(normalizedDeviceId, commandWithId);
|
|
8402
|
+
return {
|
|
8403
|
+
...sent,
|
|
8404
|
+
queued: sent.ok === true,
|
|
8405
|
+
acknowledged: sent.ok === true,
|
|
8406
|
+
acknowledgement: sent.ok === true
|
|
8407
|
+
? { ok: true, commandId, result: { ok: true, synthetic: true }, error: '' }
|
|
8408
|
+
: { ok: false, commandId, result: null, error: sent.error || 'command-not-sent' }
|
|
8409
|
+
};
|
|
8410
|
+
}
|
|
8411
|
+
|
|
8412
|
+
if (!device?.socket || device.socket.destroyed || !device.connected) {
|
|
8413
|
+
const sent = sendCommand(normalizedDeviceId, commandWithId);
|
|
8414
|
+
return {
|
|
8415
|
+
...sent,
|
|
8416
|
+
queued: false,
|
|
8417
|
+
acknowledged: false,
|
|
8418
|
+
acknowledgement: {
|
|
8419
|
+
ok: false,
|
|
8420
|
+
commandId,
|
|
8421
|
+
result: null,
|
|
8422
|
+
error: sent.error || 'device-not-connected'
|
|
8423
|
+
}
|
|
8424
|
+
};
|
|
8425
|
+
}
|
|
8426
|
+
|
|
8427
|
+
const timeoutMs = clampNumber(options.timeoutMs, 1000, 120_000, 30_000);
|
|
8428
|
+
// Register before writing: a loopback Agent can return command.result in
|
|
8429
|
+
// the same event-loop turn as the command write.
|
|
8430
|
+
const acknowledgementPromise = waitForCommandResult(device, commandId, timeoutMs);
|
|
8431
|
+
const sent = sendCommand(normalizedDeviceId, commandWithId);
|
|
8432
|
+
if (!sent.ok) {
|
|
8433
|
+
failPendingCommandResultWaiter(device, commandId, sent.error || 'command-not-sent');
|
|
8434
|
+
}
|
|
8435
|
+
const acknowledgement = await acknowledgementPromise;
|
|
8436
|
+
return {
|
|
8437
|
+
...sent,
|
|
8438
|
+
ok: sent.ok === true && acknowledgement.ok === true,
|
|
8439
|
+
queued: sent.ok === true,
|
|
8440
|
+
acknowledged: acknowledgement.ok === true,
|
|
8441
|
+
acknowledgement,
|
|
8442
|
+
error: acknowledgement.ok === true
|
|
8443
|
+
? undefined
|
|
8444
|
+
: acknowledgement.error || sent.error || 'command-result-failed'
|
|
8445
|
+
};
|
|
8446
|
+
}
|
|
8392
8447
|
|
|
8393
8448
|
function refreshDevicePolicies(deviceIds = undefined) {
|
|
8394
8449
|
const requestedIds = Array.isArray(deviceIds) && deviceIds.length > 0
|
|
@@ -11277,9 +11332,10 @@ export function createRemoteHub(options = {}) {
|
|
|
11277
11332
|
retryTaskBatch,
|
|
11278
11333
|
listDeviceFrames,
|
|
11279
11334
|
disconnectDevice,
|
|
11280
|
-
assignDeviceSlot,
|
|
11281
|
-
sendCommand,
|
|
11282
|
-
|
|
11335
|
+
assignDeviceSlot,
|
|
11336
|
+
sendCommand,
|
|
11337
|
+
sendCommandAwaitResult,
|
|
11338
|
+
refreshDevicePolicies,
|
|
11283
11339
|
sendLegacyClientUpdate,
|
|
11284
11340
|
sendInputControl,
|
|
11285
11341
|
releaseInputOwner,
|
|
@@ -11311,6 +11367,7 @@ export function createRemoteHub(options = {}) {
|
|
|
11311
11367
|
getSecurityStatus: () => ({
|
|
11312
11368
|
hubId: deviceCredentialAuthority.hubId,
|
|
11313
11369
|
hubPublicKey: deviceCredentialAuthority.hubPublicKey,
|
|
11370
|
+
hubIssuerKeyId: deviceCredentialAuthority.hubIssuerKeyId,
|
|
11314
11371
|
direct: secureDirectAcceptor.getStatus(),
|
|
11315
11372
|
devices: deviceCredentialAuthority.listDevices()
|
|
11316
11373
|
}),
|
|
@@ -174,6 +174,9 @@ export function createDeviceCredentialAuthority({
|
|
|
174
174
|
if (!privateKeyText) throw securityError('hub-private-key-secure-store-unavailable');
|
|
175
175
|
const hubPrivateKey = requireP256PrivateKey(privateKeyText);
|
|
176
176
|
const hubPublic = requireP256PublicKey(authority.publicKey);
|
|
177
|
+
const hubIssuerKeyId = crypto.createHash('sha256')
|
|
178
|
+
.update(Buffer.from(hubPublic.text, 'base64url'))
|
|
179
|
+
.digest('base64url');
|
|
177
180
|
const derivedPublic = crypto.createPublicKey(hubPrivateKey).export({ format: 'der', type: 'spki' }).toString('base64url');
|
|
178
181
|
if (derivedPublic !== hubPublic.text) throw securityError('hub-device-authority-key-mismatch');
|
|
179
182
|
const hubId = clean(authority.hubId, 128);
|
|
@@ -183,18 +186,33 @@ export function createDeviceCredentialAuthority({
|
|
|
183
186
|
registry = { version: AUTHORITY_VERSION, devices: {} };
|
|
184
187
|
}
|
|
185
188
|
|
|
186
|
-
function persistRegistry() {
|
|
187
|
-
atomicPrivateJson(registryPath,
|
|
189
|
+
function persistRegistry(nextRegistry = registry) {
|
|
190
|
+
atomicPrivateJson(registryPath, nextRegistry);
|
|
188
191
|
}
|
|
189
192
|
|
|
190
|
-
function
|
|
193
|
+
function normalizeCredentialRequest({ accountId, deviceId, devicePublicKey } = {}) {
|
|
191
194
|
const owner = clean(accountId, 128);
|
|
192
195
|
const id = clean(deviceId, 128);
|
|
193
196
|
if (!owner) throw securityError('device-account-required');
|
|
194
197
|
if (!id) throw securityError('device-id-required');
|
|
195
198
|
const deviceKey = requireP256PublicKey(devicePublicKey);
|
|
199
|
+
return { owner, id, deviceKey };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function assertEnrollmentAllowed(request) {
|
|
203
|
+
const normalized = normalizeCredentialRequest(request);
|
|
204
|
+
const existing = registry.devices[normalized.id];
|
|
205
|
+
if (existing && !existing.revokedAt) throw securityError('device-already-enrolled');
|
|
206
|
+
if (!existing && Object.keys(registry.devices).length >= MAX_DEVICES) {
|
|
207
|
+
throw securityError('device-registry-capacity');
|
|
208
|
+
}
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function issueCredential({ accountId, deviceId, devicePublicKey }) {
|
|
213
|
+
const { owner, id, deviceKey } = normalizeCredentialRequest({ accountId, deviceId, devicePublicKey });
|
|
196
214
|
const existing = registry.devices[id];
|
|
197
|
-
if (existing && !existing.revokedAt
|
|
215
|
+
if (existing && !existing.revokedAt) throw securityError('device-already-enrolled');
|
|
198
216
|
if (!existing && Object.keys(registry.devices).length >= MAX_DEVICES) throw securityError('device-registry-capacity');
|
|
199
217
|
const issuedAt = Math.floor(now());
|
|
200
218
|
const payload = {
|
|
@@ -213,18 +231,25 @@ export function createDeviceCredentialAuthority({
|
|
|
213
231
|
dsaEncoding: 'ieee-p1363'
|
|
214
232
|
}).toString('base64url');
|
|
215
233
|
const credential = `${payloadText}.${signature}`;
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
234
|
+
const nextRegistry = {
|
|
235
|
+
...registry,
|
|
236
|
+
devices: {
|
|
237
|
+
...registry.devices,
|
|
238
|
+
[id]: {
|
|
239
|
+
serial: payload.serial,
|
|
240
|
+
accountId: owner,
|
|
241
|
+
hubId,
|
|
242
|
+
devicePublicKey: deviceKey.text,
|
|
243
|
+
credential,
|
|
244
|
+
issuedAt,
|
|
245
|
+
expiresAt: payload.expiresAt,
|
|
246
|
+
revokedAt: '',
|
|
247
|
+
lastConnectedAt: ''
|
|
248
|
+
}
|
|
249
|
+
}
|
|
226
250
|
};
|
|
227
|
-
persistRegistry();
|
|
251
|
+
persistRegistry(nextRegistry);
|
|
252
|
+
registry = nextRegistry;
|
|
228
253
|
return { credential, payload: { ...payload }, hubPublicKey: hubPublic.text };
|
|
229
254
|
}
|
|
230
255
|
|
|
@@ -279,23 +304,26 @@ export function createDeviceCredentialAuthority({
|
|
|
279
304
|
}).toString('base64url');
|
|
280
305
|
}
|
|
281
306
|
|
|
282
|
-
function issueRendezvousProof({ roomId, accountId, deviceId, ttlMs = 60_000 } = {}) {
|
|
307
|
+
function issueRendezvousProof({ roomId, accountId, deviceId, role, ttlMs = 60_000 } = {}) {
|
|
283
308
|
const room = clean(roomId, 160);
|
|
284
309
|
const owner = clean(accountId, 128);
|
|
285
310
|
const device = clean(deviceId, 128);
|
|
311
|
+
const normalizedRole = clean(role, 16).toLowerCase();
|
|
286
312
|
if (!room || !owner || !device) throw securityError('rendezvous-proof-binding-required');
|
|
313
|
+
if (!['hub', 'client'].includes(normalizedRole)) throw securityError('rendezvous-proof-role-required');
|
|
287
314
|
const issuedAt = Math.floor(now());
|
|
288
315
|
const payload = {
|
|
289
|
-
version:
|
|
290
|
-
protocol: 'livedesk.udp.rendezvous-proof.
|
|
316
|
+
version: 2,
|
|
317
|
+
protocol: 'livedesk.udp.rendezvous-proof.v2',
|
|
318
|
+
issuerKeyId: hubIssuerKeyId,
|
|
291
319
|
roomId: room,
|
|
320
|
+
role: normalizedRole,
|
|
292
321
|
accountId: owner,
|
|
293
322
|
hubId,
|
|
294
323
|
deviceId: device,
|
|
295
324
|
issuedAt,
|
|
296
325
|
expiresAt: issuedAt + Math.max(10_000, Math.min(120_000, Number(ttlMs) || 60_000)),
|
|
297
|
-
nonce: crypto.randomBytes(16).toString('base64url')
|
|
298
|
-
hubPublicKey: hubPublic.text
|
|
326
|
+
nonce: crypto.randomBytes(16).toString('base64url')
|
|
299
327
|
};
|
|
300
328
|
const payloadText = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
|
301
329
|
return { proof: `${payloadText}.${signHubMessage(payloadText)}`, payload };
|
|
@@ -305,9 +333,19 @@ export function createDeviceCredentialAuthority({
|
|
|
305
333
|
const id = clean(deviceId, 128);
|
|
306
334
|
const record = registry.devices[id];
|
|
307
335
|
if (!record || record.revokedAt) return false;
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
336
|
+
const nextRegistry = {
|
|
337
|
+
...registry,
|
|
338
|
+
devices: {
|
|
339
|
+
...registry.devices,
|
|
340
|
+
[id]: {
|
|
341
|
+
...record,
|
|
342
|
+
revokedAt: new Date(now()).toISOString(),
|
|
343
|
+
revokeReason: clean(reason, 160)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
persistRegistry(nextRegistry);
|
|
348
|
+
registry = nextRegistry;
|
|
311
349
|
return true;
|
|
312
350
|
}
|
|
313
351
|
|
|
@@ -315,8 +353,15 @@ export function createDeviceCredentialAuthority({
|
|
|
315
353
|
const id = clean(deviceId, 128);
|
|
316
354
|
const record = registry.devices[id];
|
|
317
355
|
if (!record || record.revokedAt) return false;
|
|
318
|
-
|
|
319
|
-
|
|
356
|
+
const nextRegistry = {
|
|
357
|
+
...registry,
|
|
358
|
+
devices: {
|
|
359
|
+
...registry.devices,
|
|
360
|
+
[id]: { ...record, lastConnectedAt: new Date(now()).toISOString() }
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
persistRegistry(nextRegistry);
|
|
364
|
+
registry = nextRegistry;
|
|
320
365
|
return true;
|
|
321
366
|
}
|
|
322
367
|
|
|
@@ -335,14 +380,17 @@ export function createDeviceCredentialAuthority({
|
|
|
335
380
|
|
|
336
381
|
function clearDevices() {
|
|
337
382
|
const removed = Object.keys(registry.devices).length;
|
|
338
|
-
|
|
339
|
-
persistRegistry();
|
|
383
|
+
const nextRegistry = { version: AUTHORITY_VERSION, devices: {} };
|
|
384
|
+
persistRegistry(nextRegistry);
|
|
385
|
+
registry = nextRegistry;
|
|
340
386
|
return removed;
|
|
341
387
|
}
|
|
342
388
|
|
|
343
389
|
return Object.freeze({
|
|
344
390
|
hubId,
|
|
345
391
|
hubPublicKey: hubPublic.text,
|
|
392
|
+
hubIssuerKeyId,
|
|
393
|
+
assertEnrollmentAllowed,
|
|
346
394
|
issueCredential,
|
|
347
395
|
verifyCredential,
|
|
348
396
|
verifyDeviceSignature,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
|
-
import {
|
|
2
|
+
import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
@@ -85,6 +85,25 @@ function parseSeal(raw) {
|
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
async function syncFile(filePath, flags = 'r') {
|
|
89
|
+
const handle = await open(filePath, flags, 0o600);
|
|
90
|
+
try {
|
|
91
|
+
await handle.sync();
|
|
92
|
+
} finally {
|
|
93
|
+
await handle.close();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function appendDurably(filePath, value) {
|
|
98
|
+
const handle = await open(filePath, 'a', 0o600);
|
|
99
|
+
try {
|
|
100
|
+
await handle.writeFile(value, { encoding: 'utf8' });
|
|
101
|
+
await handle.sync();
|
|
102
|
+
} finally {
|
|
103
|
+
await handle.close();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
88
107
|
export function createSecurityAuditStore({ dataDir = path.join(os.homedir(), '.livedesk') } = {}) {
|
|
89
108
|
const filePath = path.join(dataDir, 'security', 'security-audit-v1.jsonl');
|
|
90
109
|
const sealStore = createOsSecretStore({
|
|
@@ -175,6 +194,7 @@ export function createSecurityAuditStore({ dataDir = path.join(os.homedir(), '.l
|
|
|
175
194
|
}
|
|
176
195
|
const temporary = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
177
196
|
await writeFile(temporary, `${rebuilt.map(record => JSON.stringify(record)).join('\n')}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
197
|
+
await syncFile(temporary);
|
|
178
198
|
await rename(temporary, filePath);
|
|
179
199
|
records = rebuilt;
|
|
180
200
|
seal.headHash = previousHash;
|
|
@@ -195,7 +215,9 @@ export function createSecurityAuditStore({ dataDir = path.join(os.homedir(), '.l
|
|
|
195
215
|
const recordHash = hashRecord(previousHash, payloadHash, mac);
|
|
196
216
|
written = { version: AUDIT_VERSION, previousHash, payloadHash, mac, recordHash, payload };
|
|
197
217
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
198
|
-
|
|
218
|
+
// A successful record() is the mutation gate for security-sensitive
|
|
219
|
+
// operations. Resolve only after the JSONL record is on stable storage.
|
|
220
|
+
await appendDurably(filePath, `${JSON.stringify(written)}\n`);
|
|
199
221
|
current.push(written);
|
|
200
222
|
seal.headHash = recordHash;
|
|
201
223
|
seal.recordCount = current.length;
|
package/src/server.js
CHANGED
|
@@ -814,17 +814,41 @@ function requestAuditHash(value) {
|
|
|
814
814
|
}
|
|
815
815
|
}
|
|
816
816
|
|
|
817
|
-
function
|
|
818
|
-
if (!securityAuditStore) return;
|
|
817
|
+
function securityAuditEvent(event = {}) {
|
|
819
818
|
const runtime = runtimeManager.getSnapshot();
|
|
820
|
-
|
|
819
|
+
return {
|
|
821
820
|
actorAccountId: event.actorAccountId || runtime.userId || '',
|
|
822
821
|
actorUserId: event.actorUserId || runtime.userId || '',
|
|
823
822
|
hubId: event.hubId || runtime.deviceId || '',
|
|
824
823
|
...event
|
|
825
|
-
}
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function markSecurityAuditFailed(error) {
|
|
828
|
+
if (securityAuditHealthy) {
|
|
826
829
|
securityAuditHealthy = false;
|
|
827
830
|
console.error(`[LiveDesk Hub] SECURITY AUDIT FAILURE: ${error?.message || error}`);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
async function recordSecurityAuditRequired(event = {}) {
|
|
835
|
+
if (!securityAuditStore || !securityAuditHealthy) {
|
|
836
|
+
const error = new Error('security-audit-unavailable');
|
|
837
|
+
error.code = 'security-audit-unavailable';
|
|
838
|
+
throw error;
|
|
839
|
+
}
|
|
840
|
+
try {
|
|
841
|
+
return await securityAuditStore.record(securityAuditEvent(event));
|
|
842
|
+
} catch (error) {
|
|
843
|
+
markSecurityAuditFailed(error);
|
|
844
|
+
throw error;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function recordSecurityAudit(event = {}) {
|
|
849
|
+
void recordSecurityAuditRequired(event).catch(() => {
|
|
850
|
+
// Required mutation paths await recordSecurityAuditRequired directly.
|
|
851
|
+
// Best-effort lifecycle telemetry still marks the global audit gate failed.
|
|
828
852
|
});
|
|
829
853
|
}
|
|
830
854
|
|
|
@@ -1519,12 +1543,16 @@ async function synchronizeAgentEnablement() {
|
|
|
1519
1543
|
return enabled;
|
|
1520
1544
|
}
|
|
1521
1545
|
|
|
1522
|
-
const hubFilesystem = createHubFilesystem();
|
|
1546
|
+
const hubFilesystem = createHubFilesystem();
|
|
1547
|
+
const fileTransferCommandAckTimeoutMs = readPositiveIntegerEnv(
|
|
1548
|
+
'LIVEDESK_FILE_CHUNK_ACK_TIMEOUT_MS',
|
|
1549
|
+
30_000
|
|
1550
|
+
);
|
|
1523
1551
|
hubTransferJobs = createHubTransferJobs({
|
|
1524
1552
|
filesystem: hubFilesystem,
|
|
1525
1553
|
remoteHub,
|
|
1526
1554
|
maxConcurrent: Number(process.env.LIVEDESK_MAX_FILE_JOBS || 2),
|
|
1527
|
-
commandResultTimeoutMs:
|
|
1555
|
+
commandResultTimeoutMs: fileTransferCommandAckTimeoutMs,
|
|
1528
1556
|
getMaxFileSizeBytes: () => Number(
|
|
1529
1557
|
liveDeskSettingsStore.getCached()?.filesAudio?.maxFileSizeBytes
|
|
1530
1558
|
|| 1024 * 1024 * 1024)
|
|
@@ -1677,7 +1705,7 @@ app.use((req, res, next) => {
|
|
|
1677
1705
|
}
|
|
1678
1706
|
const mutating = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method);
|
|
1679
1707
|
const sensitiveRead = req.method === 'GET'
|
|
1680
|
-
&& ['/api/security/audit', '/api/privacy/inventory'].includes(req.path);
|
|
1708
|
+
&& ['/api/security/audit', '/api/security/rendezvous-issuer', '/api/privacy/inventory'].includes(req.path);
|
|
1681
1709
|
if (!mutating && !sensitiveRead) {
|
|
1682
1710
|
next();
|
|
1683
1711
|
return;
|
|
@@ -1713,7 +1741,58 @@ app.use((req, res, next) => {
|
|
|
1713
1741
|
return;
|
|
1714
1742
|
}
|
|
1715
1743
|
req.liveDeskAdminSessionId = localAdminSessionId;
|
|
1716
|
-
|
|
1744
|
+
if (!mutating) {
|
|
1745
|
+
next();
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
const mutationRequestHash = requestAuditHash({ method: req.method, path: req.path });
|
|
1750
|
+
void recordSecurityAuditRequired({
|
|
1751
|
+
action: 'local-admin.mutation',
|
|
1752
|
+
phase: 'accepted',
|
|
1753
|
+
result: 'accepted',
|
|
1754
|
+
sessionId: localAdminSessionId,
|
|
1755
|
+
requestHash: mutationRequestHash,
|
|
1756
|
+
authMethod: 'local-admin-session',
|
|
1757
|
+
details: { method: req.method, path: req.path }
|
|
1758
|
+
}).then(() => {
|
|
1759
|
+
const originalEnd = res.end.bind(res);
|
|
1760
|
+
let completionStarted = false;
|
|
1761
|
+
res.end = (...args) => {
|
|
1762
|
+
if (completionStarted) return res;
|
|
1763
|
+
completionStarted = true;
|
|
1764
|
+
const statusCode = Number(res.statusCode || 200);
|
|
1765
|
+
void recordSecurityAuditRequired({
|
|
1766
|
+
action: 'local-admin.mutation',
|
|
1767
|
+
phase: 'complete',
|
|
1768
|
+
result: statusCode >= 200 && statusCode < 400 ? 'success' : 'rejected',
|
|
1769
|
+
reason: statusCode >= 400 ? `http-${statusCode}` : '',
|
|
1770
|
+
sessionId: localAdminSessionId,
|
|
1771
|
+
requestHash: mutationRequestHash,
|
|
1772
|
+
authMethod: 'local-admin-session',
|
|
1773
|
+
details: { method: req.method, path: req.path, statusCode }
|
|
1774
|
+
}).then(() => {
|
|
1775
|
+
originalEnd(...args);
|
|
1776
|
+
}).catch(error => {
|
|
1777
|
+
if (res.headersSent) {
|
|
1778
|
+
res.destroy(error);
|
|
1779
|
+
return;
|
|
1780
|
+
}
|
|
1781
|
+
const callback = [...args].reverse().find(value => typeof value === 'function');
|
|
1782
|
+
const body = JSON.stringify({ ok: false, error: 'security-audit-unavailable' });
|
|
1783
|
+
res.statusCode = 503;
|
|
1784
|
+
res.removeHeader('Content-Length');
|
|
1785
|
+
res.removeHeader('Transfer-Encoding');
|
|
1786
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1787
|
+
res.setHeader('Content-Length', Buffer.byteLength(body));
|
|
1788
|
+
originalEnd(body, 'utf8', callback);
|
|
1789
|
+
});
|
|
1790
|
+
return res;
|
|
1791
|
+
};
|
|
1792
|
+
next();
|
|
1793
|
+
}).catch(() => {
|
|
1794
|
+
res.status(503).json({ ok: false, error: 'security-audit-unavailable' });
|
|
1795
|
+
});
|
|
1717
1796
|
});
|
|
1718
1797
|
app.use(express.json({ limit: '32mb' }));
|
|
1719
1798
|
app.use((req, res, next) => {
|
|
@@ -4247,6 +4326,19 @@ app.get('/api/security/trusted-devices', (_req, res) => {
|
|
|
4247
4326
|
res.json({ ok: true, devices });
|
|
4248
4327
|
});
|
|
4249
4328
|
|
|
4329
|
+
app.get('/api/security/rendezvous-issuer', (_req, res) => {
|
|
4330
|
+
noStore(res);
|
|
4331
|
+
const security = remoteHub.getSecurityStatus();
|
|
4332
|
+
res.json({
|
|
4333
|
+
ok: true,
|
|
4334
|
+
hubId: security.hubId,
|
|
4335
|
+
issuerKeyId: security.hubIssuerKeyId,
|
|
4336
|
+
publicKey: security.hubPublicKey,
|
|
4337
|
+
algorithm: 'P-256/SHA-256',
|
|
4338
|
+
environmentVariable: 'LIVEDESK_UDP_RENDEZVOUS_TRUSTED_ISSUER_PUBLIC_KEYS'
|
|
4339
|
+
});
|
|
4340
|
+
});
|
|
4341
|
+
|
|
4250
4342
|
app.get('/api/security/audit', async (req, res) => {
|
|
4251
4343
|
noStore(res);
|
|
4252
4344
|
try {
|
|
@@ -5479,11 +5571,37 @@ app.post('/api/remote/files/from-hub', requireHubFeatureAccess, (req, res) => {
|
|
|
5479
5571
|
res.status(400).json({ ok: false, error: 'no-filesystem-items-selected' });
|
|
5480
5572
|
return;
|
|
5481
5573
|
}
|
|
5482
|
-
const job = hubTransferJobs.create({
|
|
5483
|
-
itemIds,
|
|
5484
|
-
deviceIds,
|
|
5485
|
-
remoteDirectory: req.body?.remoteDirectory
|
|
5486
|
-
|
|
5574
|
+
const job = hubTransferJobs.create({
|
|
5575
|
+
itemIds,
|
|
5576
|
+
deviceIds,
|
|
5577
|
+
remoteDirectory: req.body?.remoteDirectory,
|
|
5578
|
+
onComplete: async ({ completed, job: completedJob }) => {
|
|
5579
|
+
await recordSecurityAuditRequired({
|
|
5580
|
+
action: 'remote.file.job',
|
|
5581
|
+
phase: 'complete',
|
|
5582
|
+
result: completed ? 'success' : 'failed',
|
|
5583
|
+
reason: completed ? '' : completedJob?.error || 'file-transfer-failed',
|
|
5584
|
+
deviceIds,
|
|
5585
|
+
sessionId: completedJob?.jobId || '',
|
|
5586
|
+
requestHash: requestAuditHash({
|
|
5587
|
+
jobId: completedJob?.jobId || '',
|
|
5588
|
+
deviceIds,
|
|
5589
|
+
remoteDirectory: req.body?.remoteDirectory || ''
|
|
5590
|
+
}),
|
|
5591
|
+
authMethod: 'local-admin-session',
|
|
5592
|
+
details: {
|
|
5593
|
+
state: completedJob?.state || '',
|
|
5594
|
+
totalFiles: Number(completedJob?.totalFiles || 0),
|
|
5595
|
+
completedFiles: Number(completedJob?.completedFiles || 0),
|
|
5596
|
+
totalBytes: Number(completedJob?.totalBytes || 0),
|
|
5597
|
+
sentBytes: Number(completedJob?.sentBytes || 0),
|
|
5598
|
+
failedTargets: Array.isArray(completedJob?.failedTargets)
|
|
5599
|
+
? completedJob.failedTargets.length
|
|
5600
|
+
: 0
|
|
5601
|
+
}
|
|
5602
|
+
});
|
|
5603
|
+
}
|
|
5604
|
+
});
|
|
5487
5605
|
res.status(202).json({ ok: true, jobId: job.jobId, state: job.state });
|
|
5488
5606
|
} catch (error) {
|
|
5489
5607
|
sendFilesystemError(res, error);
|
|
@@ -5564,7 +5682,7 @@ app.post('/api/remote/filesystem/shared-folders/:folderId/sync', requireHubFeatu
|
|
|
5564
5682
|
}
|
|
5565
5683
|
});
|
|
5566
5684
|
|
|
5567
|
-
app.post('/api/remote/files/transfer', requireHubFeatureAccess, (req, res) => {
|
|
5685
|
+
app.post('/api/remote/files/transfer', requireHubFeatureAccess, async (req, res) => {
|
|
5568
5686
|
noStore(res);
|
|
5569
5687
|
const deviceIds = normalizeDeviceIds(req.body?.deviceIds);
|
|
5570
5688
|
if (deviceIds.length === 0) {
|
|
@@ -5581,44 +5699,53 @@ app.post('/api/remote/files/transfer', requireHubFeatureAccess, (req, res) => {
|
|
|
5581
5699
|
const transferId = String(req.body?.transferId || crypto.randomUUID()).replace(/[^a-zA-Z0-9_.:-]/g, '-').slice(0, 128);
|
|
5582
5700
|
const remoteDirectory = String(req.body?.remoteDirectory || '').replace(/[\0\r\n\t]/g, ' ').trim().slice(0, 600);
|
|
5583
5701
|
const queuedAt = new Date().toISOString();
|
|
5584
|
-
|
|
5585
|
-
deviceId
|
|
5586
|
-
|
|
5587
|
-
|
|
5588
|
-
|
|
5589
|
-
|
|
5590
|
-
|
|
5591
|
-
|
|
5592
|
-
|
|
5593
|
-
|
|
5594
|
-
|
|
5595
|
-
|
|
5596
|
-
|
|
5597
|
-
|
|
5598
|
-
|
|
5599
|
-
|
|
5600
|
-
|
|
5601
|
-
|
|
5602
|
-
|
|
5603
|
-
|
|
5604
|
-
|
|
5605
|
-
|
|
5606
|
-
|
|
5607
|
-
|
|
5608
|
-
|
|
5609
|
-
|
|
5610
|
-
|
|
5611
|
-
|
|
5612
|
-
|
|
5613
|
-
|
|
5614
|
-
|
|
5615
|
-
|
|
5616
|
-
|
|
5617
|
-
|
|
5618
|
-
|
|
5619
|
-
|
|
5620
|
-
|
|
5621
|
-
|
|
5702
|
+
try {
|
|
5703
|
+
const results = await Promise.all(deviceIds.map(async deviceId => ({
|
|
5704
|
+
deviceId,
|
|
5705
|
+
...await remoteHub.sendCommandAwaitResult(deviceId, {
|
|
5706
|
+
command: 'file.transfer',
|
|
5707
|
+
payload: {
|
|
5708
|
+
transferId,
|
|
5709
|
+
remoteDirectory,
|
|
5710
|
+
files: normalized.files,
|
|
5711
|
+
totalBytes: normalized.totalBytes,
|
|
5712
|
+
requestedAt: queuedAt
|
|
5713
|
+
}
|
|
5714
|
+
}, { timeoutMs: fileTransferCommandAckTimeoutMs })
|
|
5715
|
+
})));
|
|
5716
|
+
const queued = results.filter(result => result.queued).length;
|
|
5717
|
+
const acknowledged = results.filter(result => result.acknowledged).length;
|
|
5718
|
+
const success = acknowledged === deviceIds.length;
|
|
5719
|
+
const partial = acknowledged > 0 && !success;
|
|
5720
|
+
const error = success ? undefined : partial ? 'partial-transfer-failed' : 'no-transfer-acknowledged';
|
|
5721
|
+
await recordSecurityAuditRequired({
|
|
5722
|
+
action: 'remote.file.transfer',
|
|
5723
|
+
phase: 'complete',
|
|
5724
|
+
result: success ? 'success' : partial ? 'partial' : 'rejected',
|
|
5725
|
+
reason: error || '',
|
|
5726
|
+
deviceIds,
|
|
5727
|
+
sessionId: transferId,
|
|
5728
|
+
requestHash: requestAuditHash({ transferId, remoteDirectory, files: normalized.files.map(file => ({ name: file.name, relativePath: file.relativePath, byteLength: file.size })), totalBytes: normalized.totalBytes }),
|
|
5729
|
+
authMethod: 'local-admin-session',
|
|
5730
|
+
details: { queued, acknowledged, total: deviceIds.length, totalBytes: normalized.totalBytes, files: normalized.files.length }
|
|
5731
|
+
});
|
|
5732
|
+
res.json({
|
|
5733
|
+
ok: success,
|
|
5734
|
+
transferId,
|
|
5735
|
+
queued,
|
|
5736
|
+
acknowledged,
|
|
5737
|
+
total: deviceIds.length,
|
|
5738
|
+
totalBytes: normalized.totalBytes,
|
|
5739
|
+
files: normalized.files.length,
|
|
5740
|
+
results,
|
|
5741
|
+
error
|
|
5742
|
+
});
|
|
5743
|
+
} catch (error) {
|
|
5744
|
+
res.status(503).json({ ok: false, error: error?.code || 'security-audit-unavailable' });
|
|
5745
|
+
}
|
|
5746
|
+
});
|
|
5747
|
+
|
|
5748
|
+
app.post('/api/remote/files/chunk', requireHubFeatureAccess, async (req, res) => {
|
|
5622
5749
|
noStore(res);
|
|
5623
5750
|
const deviceIds = normalizeDeviceIds(req.body?.deviceIds);
|
|
5624
5751
|
if (deviceIds.length === 0) {
|
|
@@ -5636,42 +5763,51 @@ app.post('/api/remote/files/chunk', requireHubFeatureAccess, (req, res) => {
|
|
|
5636
5763
|
const transferId = String(req.body?.transferId || crypto.randomUUID()).replace(/[^a-zA-Z0-9_.:-]/g, '-').slice(0, 128);
|
|
5637
5764
|
const remoteDirectory = String(req.body?.remoteDirectory || '').replace(/[\0\r\n\t]/g, ' ').trim().slice(0, 600);
|
|
5638
5765
|
const queuedAt = new Date().toISOString();
|
|
5639
|
-
|
|
5640
|
-
deviceId
|
|
5641
|
-
|
|
5642
|
-
|
|
5643
|
-
|
|
5644
|
-
|
|
5645
|
-
|
|
5646
|
-
|
|
5647
|
-
|
|
5648
|
-
|
|
5649
|
-
|
|
5650
|
-
|
|
5651
|
-
|
|
5652
|
-
|
|
5653
|
-
|
|
5654
|
-
|
|
5655
|
-
|
|
5656
|
-
|
|
5657
|
-
|
|
5658
|
-
|
|
5659
|
-
|
|
5660
|
-
|
|
5661
|
-
|
|
5662
|
-
|
|
5663
|
-
|
|
5664
|
-
|
|
5665
|
-
|
|
5666
|
-
|
|
5667
|
-
|
|
5668
|
-
|
|
5669
|
-
|
|
5670
|
-
|
|
5671
|
-
|
|
5672
|
-
|
|
5673
|
-
|
|
5674
|
-
|
|
5766
|
+
try {
|
|
5767
|
+
const results = await Promise.all(deviceIds.map(async deviceId => ({
|
|
5768
|
+
deviceId,
|
|
5769
|
+
...await remoteHub.sendCommandAwaitResult(deviceId, {
|
|
5770
|
+
command: 'file.transfer.chunk',
|
|
5771
|
+
payload: {
|
|
5772
|
+
transferId,
|
|
5773
|
+
remoteDirectory,
|
|
5774
|
+
...normalized.chunk,
|
|
5775
|
+
requestedAt: queuedAt
|
|
5776
|
+
}
|
|
5777
|
+
}, { timeoutMs: fileTransferCommandAckTimeoutMs })
|
|
5778
|
+
})));
|
|
5779
|
+
const queued = results.filter(result => result.queued).length;
|
|
5780
|
+
const acknowledged = results.filter(result => result.acknowledged).length;
|
|
5781
|
+
const success = acknowledged === deviceIds.length;
|
|
5782
|
+
const partial = acknowledged > 0 && !success;
|
|
5783
|
+
const error = success ? undefined : partial ? 'partial-transfer-failed' : 'no-transfer-acknowledged';
|
|
5784
|
+
await recordSecurityAuditRequired({
|
|
5785
|
+
action: 'remote.file.chunk',
|
|
5786
|
+
phase: normalized.chunk.final ? 'complete' : 'progress',
|
|
5787
|
+
result: success ? 'success' : partial ? 'partial' : 'rejected',
|
|
5788
|
+
reason: error || '',
|
|
5789
|
+
deviceIds,
|
|
5790
|
+
sessionId: transferId,
|
|
5791
|
+
requestHash: requestAuditHash({ transferId, remoteDirectory, offset: normalized.chunk.offset, byteLength: normalized.chunk.byteLength, final: normalized.chunk.final }),
|
|
5792
|
+
authMethod: 'local-admin-session',
|
|
5793
|
+
details: { queued, acknowledged, total: deviceIds.length, byteLength: normalized.chunk.byteLength, offset: normalized.chunk.offset, final: normalized.chunk.final }
|
|
5794
|
+
});
|
|
5795
|
+
res.json({
|
|
5796
|
+
ok: success,
|
|
5797
|
+
transferId,
|
|
5798
|
+
queued,
|
|
5799
|
+
acknowledged,
|
|
5800
|
+
total: deviceIds.length,
|
|
5801
|
+
byteLength: normalized.chunk.byteLength,
|
|
5802
|
+
offset: normalized.chunk.offset,
|
|
5803
|
+
final: normalized.chunk.final,
|
|
5804
|
+
results,
|
|
5805
|
+
error
|
|
5806
|
+
});
|
|
5807
|
+
} catch (error) {
|
|
5808
|
+
res.status(503).json({ ok: false, error: error?.code || 'security-audit-unavailable' });
|
|
5809
|
+
}
|
|
5810
|
+
});
|
|
5675
5811
|
|
|
5676
5812
|
const MANUAL_POWER_ACTIONS = new Set(['lock', 'sleep', 'restart', 'shutdown']);
|
|
5677
5813
|
|
|
@@ -5990,11 +6126,37 @@ httpServer.on('upgrade', (req, socket, head) => {
|
|
|
5990
6126
|
socket.destroy();
|
|
5991
6127
|
return;
|
|
5992
6128
|
}
|
|
5993
|
-
|
|
5994
|
-
|
|
5995
|
-
|
|
5996
|
-
|
|
5997
|
-
|
|
6129
|
+
let parsed;
|
|
6130
|
+
try {
|
|
6131
|
+
parsed = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
|
|
6132
|
+
} catch {
|
|
6133
|
+
socket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
|
|
6134
|
+
socket.destroy();
|
|
6135
|
+
return;
|
|
6136
|
+
}
|
|
6137
|
+
const websocketPath = parsed.pathname;
|
|
6138
|
+
if (![
|
|
6139
|
+
'/api/remote/frames/ws',
|
|
6140
|
+
'/api/remote/atlas/ws',
|
|
6141
|
+
'/api/remote/input/ws',
|
|
6142
|
+
'/api/remote/audio/ws'
|
|
6143
|
+
].includes(websocketPath)) {
|
|
6144
|
+
socket.write('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n');
|
|
6145
|
+
socket.destroy();
|
|
6146
|
+
return;
|
|
6147
|
+
}
|
|
6148
|
+
void recordSecurityAuditRequired({
|
|
6149
|
+
action: 'local-admin.websocket',
|
|
6150
|
+
phase: 'accepted',
|
|
6151
|
+
result: 'accepted',
|
|
6152
|
+
sessionId: localAdminSessionId,
|
|
6153
|
+
requestHash: requestAuditHash({ path: websocketPath }),
|
|
6154
|
+
authMethod: 'local-admin-websocket',
|
|
6155
|
+
details: { path: websocketPath }
|
|
6156
|
+
}).then(() => {
|
|
6157
|
+
if (parsed.pathname === '/api/remote/frames/ws') {
|
|
6158
|
+
frameWss.handleUpgrade(req, socket, head, ws => frameWss.emit('connection', ws, req));
|
|
6159
|
+
return;
|
|
5998
6160
|
}
|
|
5999
6161
|
if (parsed.pathname === '/api/remote/atlas/ws') {
|
|
6000
6162
|
atlasWss.handleUpgrade(req, socket, head, ws => atlasWss.emit('connection', ws, req));
|
|
@@ -6005,16 +6167,14 @@ httpServer.on('upgrade', (req, socket, head) => {
|
|
|
6005
6167
|
return;
|
|
6006
6168
|
}
|
|
6007
6169
|
if (parsed.pathname === '/api/remote/audio/ws') {
|
|
6008
|
-
audioWss.handleUpgrade(req, socket, head, ws => audioWss.emit('connection', ws, req));
|
|
6009
|
-
return;
|
|
6010
|
-
}
|
|
6011
|
-
|
|
6012
|
-
socket.
|
|
6013
|
-
|
|
6014
|
-
|
|
6015
|
-
|
|
6016
|
-
}
|
|
6017
|
-
});
|
|
6170
|
+
audioWss.handleUpgrade(req, socket, head, ws => audioWss.emit('connection', ws, req));
|
|
6171
|
+
return;
|
|
6172
|
+
}
|
|
6173
|
+
}).catch(() => {
|
|
6174
|
+
socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');
|
|
6175
|
+
socket.destroy();
|
|
6176
|
+
});
|
|
6177
|
+
});
|
|
6018
6178
|
|
|
6019
6179
|
frameWss.on('connection', (ws, req) => {
|
|
6020
6180
|
frameClients.add(ws);
|
|
@@ -6127,23 +6287,21 @@ audioWss.on('connection', (ws, req) => {
|
|
|
6127
6287
|
});
|
|
6128
6288
|
|
|
6129
6289
|
inputWss.on('connection', ws => {
|
|
6130
|
-
inputClients.add(ws);
|
|
6131
|
-
ws.liveDeskInputClientId = `riws-${++inputClientSeq}`;
|
|
6290
|
+
inputClients.add(ws);
|
|
6291
|
+
ws.liveDeskInputClientId = `riws-${++inputClientSeq}`;
|
|
6132
6292
|
ws.liveDeskInputDeviceIds = new Set();
|
|
6133
|
-
|
|
6134
|
-
action: 'remote.control.browser-session',
|
|
6135
|
-
phase: 'start',
|
|
6136
|
-
result: 'success',
|
|
6137
|
-
sessionId: ws.liveDeskInputClientId,
|
|
6138
|
-
authMethod: 'local-admin-websocket'
|
|
6139
|
-
});
|
|
6293
|
+
ws.liveDeskInputAuditReady = false;
|
|
6140
6294
|
try {
|
|
6141
6295
|
ws._socket?.setNoDelay?.(true);
|
|
6142
6296
|
} catch {
|
|
6143
6297
|
// Best-effort latency hint for browser input sockets.
|
|
6144
|
-
}
|
|
6145
|
-
ws.on('message', data => {
|
|
6146
|
-
|
|
6298
|
+
}
|
|
6299
|
+
ws.on('message', data => {
|
|
6300
|
+
if (ws.liveDeskInputAuditReady !== true) {
|
|
6301
|
+
sendJson(ws, { type: 'RemoteInputError', error: 'security-audit-pending' });
|
|
6302
|
+
return;
|
|
6303
|
+
}
|
|
6304
|
+
let payload;
|
|
6147
6305
|
try {
|
|
6148
6306
|
payload = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data || ''));
|
|
6149
6307
|
} catch {
|
|
@@ -6189,7 +6347,7 @@ inputWss.on('connection', ws => {
|
|
|
6189
6347
|
for (const deviceId of ws.liveDeskInputDeviceIds || []) {
|
|
6190
6348
|
remoteHub.releaseInputOwner(deviceId, ws.liveDeskInputClientId, reason);
|
|
6191
6349
|
}
|
|
6192
|
-
|
|
6350
|
+
void recordSecurityAuditRequired({
|
|
6193
6351
|
action: 'remote.control.browser-session',
|
|
6194
6352
|
phase: 'complete',
|
|
6195
6353
|
result: 'success',
|
|
@@ -6197,18 +6355,31 @@ inputWss.on('connection', ws => {
|
|
|
6197
6355
|
deviceIds: [...(ws.liveDeskInputDeviceIds || [])],
|
|
6198
6356
|
sessionId: ws.liveDeskInputClientId,
|
|
6199
6357
|
authMethod: 'local-admin-websocket'
|
|
6358
|
+
}).catch(() => {
|
|
6359
|
+
// The global fail-closed gate is marked by recordSecurityAuditRequired.
|
|
6200
6360
|
});
|
|
6201
|
-
ws.liveDeskInputDeviceIds?.clear?.();
|
|
6202
|
-
};
|
|
6203
|
-
ws.on('close', () => releaseBrowserInputOwner('browser-input-websocket-closed'));
|
|
6204
|
-
ws.on('error', () => releaseBrowserInputOwner('browser-input-websocket-error'));
|
|
6205
|
-
|
|
6206
|
-
|
|
6207
|
-
|
|
6208
|
-
|
|
6209
|
-
|
|
6210
|
-
|
|
6211
|
-
})
|
|
6361
|
+
ws.liveDeskInputDeviceIds?.clear?.();
|
|
6362
|
+
};
|
|
6363
|
+
ws.on('close', () => releaseBrowserInputOwner('browser-input-websocket-closed'));
|
|
6364
|
+
ws.on('error', () => releaseBrowserInputOwner('browser-input-websocket-error'));
|
|
6365
|
+
void recordSecurityAuditRequired({
|
|
6366
|
+
action: 'remote.control.browser-session',
|
|
6367
|
+
phase: 'start',
|
|
6368
|
+
result: 'success',
|
|
6369
|
+
sessionId: ws.liveDeskInputClientId,
|
|
6370
|
+
authMethod: 'local-admin-websocket'
|
|
6371
|
+
}).then(() => {
|
|
6372
|
+
ws.liveDeskInputAuditReady = true;
|
|
6373
|
+
sendJson(ws, {
|
|
6374
|
+
type: 'RemoteInputSocketReady',
|
|
6375
|
+
protocol: 'livedesk.remote.input.json.v1',
|
|
6376
|
+
clientId: ws.liveDeskInputClientId,
|
|
6377
|
+
timestamp: new Date().toISOString()
|
|
6378
|
+
});
|
|
6379
|
+
}).catch(() => {
|
|
6380
|
+
ws.close(1011, 'security-audit-unavailable');
|
|
6381
|
+
});
|
|
6382
|
+
});
|
|
6212
6383
|
|
|
6213
6384
|
await remoteHub.start();
|
|
6214
6385
|
connectedDeviceCount = Number(remoteHub.getStatus({ includeSecrets: false }).connectedDeviceCount || 0);
|
|
@@ -1029,8 +1029,9 @@ export function createHubRelayControl({
|
|
|
1029
1029
|
throw relayError('secure-enrollment-proof-invalid');
|
|
1030
1030
|
}
|
|
1031
1031
|
verifyP256Signature(devicePublic.key, clientTranscript, message.deviceSignature);
|
|
1032
|
+
authority.assertEnrollmentAllowed?.({ accountId, deviceId, devicePublicKey: devicePublic.text });
|
|
1032
1033
|
if (!secureSession.consumeEnrollmentToken?.(enrollmentSecret)) throw relayError('secure-enrollment-token-used');
|
|
1033
|
-
issued = authority.issueCredential({ accountId, deviceId, devicePublicKey: devicePublic.text
|
|
1034
|
+
issued = authority.issueCredential({ accountId, deviceId, devicePublicKey: devicePublic.text });
|
|
1034
1035
|
} else {
|
|
1035
1036
|
const verified = authority.verifyCredential(message.credential, { accountId, deviceId });
|
|
1036
1037
|
if (verified.payload.devicePublicKey !== devicePublic.text) throw relayError('device-credential-binding-invalid');
|
|
@@ -237,8 +237,9 @@ export function createSecureDirectAcceptor({
|
|
|
237
237
|
throw directError('secure-enrollment-proof-invalid');
|
|
238
238
|
}
|
|
239
239
|
verifyP256Signature(devicePublic.key, clientTranscript, hello.deviceSignature);
|
|
240
|
+
authority.assertEnrollmentAllowed?.({ accountId, deviceId, devicePublicKey: devicePublic.text });
|
|
240
241
|
if (!consumeEnrollmentToken(token)) throw directError('secure-enrollment-token-used');
|
|
241
|
-
const issued = authority.issueCredential({ accountId, deviceId, devicePublicKey: devicePublic.text
|
|
242
|
+
const issued = authority.issueCredential({ accountId, deviceId, devicePublicKey: devicePublic.text });
|
|
242
243
|
credential = issued.credential;
|
|
243
244
|
credentialPayload = issued.payload;
|
|
244
245
|
enrollmentCount += 1;
|
|
@@ -441,12 +441,23 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
|
|
|
441
441
|
udpSessionId = crypto.randomUUID();
|
|
442
442
|
routeKey = udpSessionRouteKey(udpSessionId);
|
|
443
443
|
} while (sessionsByRouteKey.has(routeKey));
|
|
444
|
-
const
|
|
445
|
-
? rendezvousProofIssuer({ roomId: udpSessionId, deviceId: id, ttlMs: 60_000 })
|
|
444
|
+
const hubProof = typeof rendezvousProofIssuer === 'function'
|
|
445
|
+
? rendezvousProofIssuer({ roomId: udpSessionId, deviceId: id, role: 'hub', ttlMs: 60_000 })
|
|
446
446
|
: null;
|
|
447
|
-
const
|
|
447
|
+
const clientProof = typeof rendezvousProofIssuer === 'function'
|
|
448
|
+
? rendezvousProofIssuer({ roomId: udpSessionId, deviceId: id, role: 'client', ttlMs: 60_000 })
|
|
449
|
+
: null;
|
|
450
|
+
const signedHubRendezvousProof = safeText(hubProof?.proof, 2048);
|
|
451
|
+
const signedClientRendezvousProof = safeText(clientProof?.proof, 2048);
|
|
448
452
|
const allowLegacyUnsignedProofForTests = env.LIVEDESK_TEST_MODE === '1'
|
|
449
453
|
|| env.LIVEDESK_TEST_ALLOW_LEGACY_UDP_RENDEZVOUS === '1';
|
|
454
|
+
const legacyRendezvousToken = allowLegacyUnsignedProofForTests
|
|
455
|
+
&& (!signedHubRendezvousProof || !signedClientRendezvousProof)
|
|
456
|
+
? crypto.randomBytes(16).toString('hex')
|
|
457
|
+
: '';
|
|
458
|
+
const proofExpiries = [hubProof?.payload?.expiresAt, clientProof?.payload?.expiresAt]
|
|
459
|
+
.map(Number)
|
|
460
|
+
.filter(Number.isSafeInteger);
|
|
450
461
|
const session = {
|
|
451
462
|
deviceId: id,
|
|
452
463
|
tcpSessionId: safeText(tcpSessionId, 160),
|
|
@@ -455,9 +466,9 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
|
|
|
455
466
|
routeKey,
|
|
456
467
|
key,
|
|
457
468
|
keyBase64: key.toString('base64'),
|
|
458
|
-
rendezvousToken:
|
|
459
|
-
|
|
460
|
-
rendezvousProofExpiresAt:
|
|
469
|
+
rendezvousToken: signedHubRendezvousProof || legacyRendezvousToken,
|
|
470
|
+
clientRendezvousToken: signedClientRendezvousProof || legacyRendezvousToken,
|
|
471
|
+
rendezvousProofExpiresAt: proofExpiries.length === 2 ? Math.min(...proofExpiries) : 0,
|
|
461
472
|
sendControl,
|
|
462
473
|
clientEndpoint: null,
|
|
463
474
|
peerEndpoint: null,
|
|
@@ -493,8 +504,8 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
|
|
|
493
504
|
frameChunkHeader: UDP_FRAME_CHUNK_PROTOCOL,
|
|
494
505
|
frameTimeoutMs,
|
|
495
506
|
maxPendingFrames,
|
|
496
|
-
rendezvous: rendezvousHost && session.
|
|
497
|
-
? { host: rendezvousHost, port: rendezvousPort, roomId: session.sessionId, token: session.
|
|
507
|
+
rendezvous: rendezvousHost && session.clientRendezvousToken
|
|
508
|
+
? { host: rendezvousHost, port: rendezvousPort, roomId: session.sessionId, token: session.clientRendezvousToken }
|
|
498
509
|
: null
|
|
499
510
|
});
|
|
500
511
|
sendRendezvousRegister(session);
|
|
@@ -62,20 +62,52 @@ function decodeCanonicalBase64Url(value, minimum, maximum = minimum) {
|
|
|
62
62
|
: null;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
function
|
|
65
|
+
function trustedIssuerMap(values) {
|
|
66
|
+
const entries = Array.isArray(values)
|
|
67
|
+
? values
|
|
68
|
+
: String(values || '').split(/[\s,]+/u).filter(Boolean);
|
|
69
|
+
if (entries.length > 10_000) throw new Error('udp-rendezvous-trusted-issuer-capacity');
|
|
70
|
+
const issuers = new Map();
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
const publicKeyBytes = decodeCanonicalBase64Url(entry, 80, 160);
|
|
73
|
+
if (!publicKeyBytes) throw new Error('udp-rendezvous-trusted-issuer-invalid');
|
|
74
|
+
let publicKey;
|
|
75
|
+
try {
|
|
76
|
+
publicKey = crypto.createPublicKey({ key: publicKeyBytes, format: 'der', type: 'spki' });
|
|
77
|
+
} catch {
|
|
78
|
+
throw new Error('udp-rendezvous-trusted-issuer-invalid');
|
|
79
|
+
}
|
|
80
|
+
if (publicKey.asymmetricKeyType !== 'ec'
|
|
81
|
+
|| publicKey.asymmetricKeyDetails?.namedCurve !== 'prime256v1') {
|
|
82
|
+
throw new Error('udp-rendezvous-trusted-issuer-invalid');
|
|
83
|
+
}
|
|
84
|
+
const keyId = crypto.createHash('sha256').update(publicKeyBytes).digest('base64url');
|
|
85
|
+
issuers.set(keyId, publicKey);
|
|
86
|
+
}
|
|
87
|
+
return issuers;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function exactSafeText(value, maximum) {
|
|
91
|
+
return typeof value === 'string' && value.length > 0 && safeText(value, maximum) === value;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function verifyRendezvousProof(proof, roomId, role, current, trustedIssuers) {
|
|
66
95
|
try {
|
|
67
96
|
const parts = String(proof || '').split('.');
|
|
68
|
-
if (parts.length !== 2) return false;
|
|
97
|
+
if (parts.length !== 2) return { ok: false, reason: 'invalid-proof' };
|
|
69
98
|
const payloadBytes = decodeCanonicalBase64Url(parts[0], 64, 1536);
|
|
70
99
|
const signature = decodeCanonicalBase64Url(parts[1], 64, 64);
|
|
71
|
-
if (!payloadBytes || !signature) return false;
|
|
100
|
+
if (!payloadBytes || !signature) return { ok: false, reason: 'invalid-proof' };
|
|
72
101
|
const payload = JSON.parse(payloadBytes.toString('utf8'));
|
|
73
|
-
if (!payload || payload.version !==
|
|
74
|
-
|| payload.protocol !== 'livedesk.udp.rendezvous-proof.
|
|
102
|
+
if (!payload || payload.version !== 2
|
|
103
|
+
|| payload.protocol !== 'livedesk.udp.rendezvous-proof.v2'
|
|
75
104
|
|| payload.roomId !== roomId
|
|
76
|
-
|| !
|
|
77
|
-
|| !
|
|
78
|
-
|| !
|
|
105
|
+
|| !exactSafeText(payload.issuerKeyId, 64)
|
|
106
|
+
|| !exactSafeText(payload.roomId, 160)
|
|
107
|
+
|| !exactSafeText(payload.role, 16)
|
|
108
|
+
|| !exactSafeText(payload.accountId, 128)
|
|
109
|
+
|| !exactSafeText(payload.hubId, 128)
|
|
110
|
+
|| !exactSafeText(payload.deviceId, 128)
|
|
79
111
|
|| !Number.isSafeInteger(payload.issuedAt)
|
|
80
112
|
|| !Number.isSafeInteger(payload.expiresAt)
|
|
81
113
|
|| payload.issuedAt > current + 30_000
|
|
@@ -83,19 +115,26 @@ function verifyRendezvousProof(proof, roomId, current) {
|
|
|
83
115
|
|| payload.expiresAt <= payload.issuedAt
|
|
84
116
|
|| payload.expiresAt - payload.issuedAt > 120_000
|
|
85
117
|
|| !decodeCanonicalBase64Url(payload.nonce, 16, 32)) {
|
|
86
|
-
return false;
|
|
118
|
+
return { ok: false, reason: 'invalid-proof' };
|
|
87
119
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
key:
|
|
120
|
+
if (payload.role !== role) return { ok: false, reason: 'role-mismatch' };
|
|
121
|
+
const issuerKeyIdBytes = decodeCanonicalBase64Url(payload.issuerKeyId, 32, 32);
|
|
122
|
+
if (!issuerKeyIdBytes) return { ok: false, reason: 'invalid-proof' };
|
|
123
|
+
const issuerKey = trustedIssuers.get(payload.issuerKeyId);
|
|
124
|
+
if (!issuerKey) return { ok: false, reason: 'untrusted-issuer' };
|
|
125
|
+
const valid = crypto.verify('sha256', Buffer.from(parts[0], 'utf8'), {
|
|
126
|
+
key: issuerKey,
|
|
95
127
|
dsaEncoding: 'ieee-p1363'
|
|
96
128
|
}, signature);
|
|
129
|
+
if (!valid) return { ok: false, reason: 'invalid-signature' };
|
|
130
|
+
return {
|
|
131
|
+
ok: true,
|
|
132
|
+
payload,
|
|
133
|
+
proofKey: `${payload.issuerKeyId}:${payload.nonce}:${payload.roomId}:${payload.role}`,
|
|
134
|
+
proofHash: crypto.createHash('sha256').update(String(proof), 'utf8').digest('base64url')
|
|
135
|
+
};
|
|
97
136
|
} catch {
|
|
98
|
-
return false;
|
|
137
|
+
return { ok: false, reason: 'invalid-proof' };
|
|
99
138
|
}
|
|
100
139
|
}
|
|
101
140
|
|
|
@@ -116,6 +155,7 @@ export function createUdpRendezvousServer({
|
|
|
116
155
|
perSourceBurst = DEFAULT_LIMITS.perSourceBurst,
|
|
117
156
|
globalRate = DEFAULT_LIMITS.globalRate,
|
|
118
157
|
globalBurst = DEFAULT_LIMITS.globalBurst,
|
|
158
|
+
trustedIssuerPublicKeys = [],
|
|
119
159
|
allowLegacyTokens = false
|
|
120
160
|
} = {}) {
|
|
121
161
|
const socket = dgram.createSocket('udp4');
|
|
@@ -123,12 +163,15 @@ export function createUdpRendezvousServer({
|
|
|
123
163
|
const unpairedRooms = new Map();
|
|
124
164
|
const sources = new Map();
|
|
125
165
|
const evictableSources = new Map();
|
|
166
|
+
const consumedProofs = new Map();
|
|
167
|
+
const trustedIssuers = trustedIssuerMap(trustedIssuerPublicKeys);
|
|
126
168
|
const limits = Object.freeze({
|
|
127
169
|
roomTtlMs: normalizeInteger(roomTtlMs, DEFAULT_LIMITS.roomTtlMs),
|
|
128
170
|
sourceTtlMs: normalizeInteger(sourceTtlMs, DEFAULT_LIMITS.sourceTtlMs),
|
|
129
171
|
sweepIntervalMs: normalizeInteger(sweepIntervalMs, DEFAULT_LIMITS.sweepIntervalMs),
|
|
130
172
|
statusIntervalMs: normalizeInteger(statusIntervalMs, DEFAULT_LIMITS.statusIntervalMs, 0),
|
|
131
173
|
maxRooms: normalizeInteger(maxRooms, DEFAULT_LIMITS.maxRooms),
|
|
174
|
+
maxConsumedProofs: Math.min(200_000, normalizeInteger(maxRooms, DEFAULT_LIMITS.maxRooms) * 2),
|
|
132
175
|
maxSources: normalizeInteger(maxSources, DEFAULT_LIMITS.maxSources),
|
|
133
176
|
maxRoomsPerSource: normalizeInteger(maxRoomsPerSource, DEFAULT_LIMITS.maxRoomsPerSource),
|
|
134
177
|
perSourceRate: normalizeRate(perSourceRate, DEFAULT_LIMITS.perSourceRate),
|
|
@@ -151,6 +194,13 @@ export function createUdpRendezvousServer({
|
|
|
151
194
|
sourceRoomLimitDrops: 0,
|
|
152
195
|
roomCapacityDrops: 0,
|
|
153
196
|
tokenMismatchDrops: 0,
|
|
197
|
+
untrustedIssuerDrops: 0,
|
|
198
|
+
roleMismatchDrops: 0,
|
|
199
|
+
bindingMismatchDrops: 0,
|
|
200
|
+
proofReplayDrops: 0,
|
|
201
|
+
endpointOverwriteDrops: 0,
|
|
202
|
+
idempotentRefreshes: 0,
|
|
203
|
+
proofCapacityDrops: 0,
|
|
154
204
|
expiredRooms: 0,
|
|
155
205
|
evictedRooms: 0,
|
|
156
206
|
expiredSources: 0,
|
|
@@ -194,6 +244,9 @@ export function createUdpRendezvousServer({
|
|
|
194
244
|
for (const [roomId, room] of rooms) {
|
|
195
245
|
if (at - room.updatedAt >= limits.roomTtlMs) deleteRoom(roomId, 'expired');
|
|
196
246
|
}
|
|
247
|
+
for (const [proofKey, consumed] of consumedProofs) {
|
|
248
|
+
if (consumed.expiresAt <= at) consumedProofs.delete(proofKey);
|
|
249
|
+
}
|
|
197
250
|
for (const [key, source] of sources) {
|
|
198
251
|
if (source.activeRooms === 0 && at - source.lastSeenAt >= limits.sourceTtlMs) {
|
|
199
252
|
sources.delete(key);
|
|
@@ -259,7 +312,7 @@ export function createUdpRendezvousServer({
|
|
|
259
312
|
});
|
|
260
313
|
}
|
|
261
314
|
|
|
262
|
-
function createRoom(roomId,
|
|
315
|
+
function createRoom(roomId, binding, source, at, legacyToken = '') {
|
|
263
316
|
if (source.activeRooms >= limits.maxRoomsPerSource) {
|
|
264
317
|
counters.sourceRoomLimitDrops += 1;
|
|
265
318
|
return null;
|
|
@@ -274,7 +327,8 @@ export function createUdpRendezvousServer({
|
|
|
274
327
|
}
|
|
275
328
|
}
|
|
276
329
|
const room = {
|
|
277
|
-
|
|
330
|
+
legacyToken,
|
|
331
|
+
binding,
|
|
278
332
|
ownerSource: source.key,
|
|
279
333
|
peers: new Map(),
|
|
280
334
|
createdAt: at,
|
|
@@ -317,8 +371,13 @@ export function createUdpRendezvousServer({
|
|
|
317
371
|
counters.invalidDatagrams += 1;
|
|
318
372
|
return;
|
|
319
373
|
}
|
|
320
|
-
|
|
374
|
+
const verification = allowLegacyTokens
|
|
375
|
+
? { ok: true, legacy: true, token }
|
|
376
|
+
: verifyRendezvousProof(token, roomId, role, at, trustedIssuers);
|
|
377
|
+
if (!verification.ok) {
|
|
321
378
|
counters.invalidDatagrams += 1;
|
|
379
|
+
if (verification.reason === 'untrusted-issuer') counters.untrustedIssuerDrops += 1;
|
|
380
|
+
if (verification.reason === 'role-mismatch') counters.roleMismatchDrops += 1;
|
|
322
381
|
return;
|
|
323
382
|
}
|
|
324
383
|
let room = rooms.get(roomId);
|
|
@@ -326,16 +385,64 @@ export function createUdpRendezvousServer({
|
|
|
326
385
|
deleteRoom(roomId, 'expired');
|
|
327
386
|
room = null;
|
|
328
387
|
}
|
|
329
|
-
if (
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
388
|
+
if (verification.legacy) {
|
|
389
|
+
if (room && room.legacyToken !== token) {
|
|
390
|
+
counters.tokenMismatchDrops += 1;
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (!room) {
|
|
394
|
+
room = createRoom(roomId, null, source, at, token);
|
|
395
|
+
if (!room) return;
|
|
396
|
+
}
|
|
397
|
+
room.updatedAt = at;
|
|
398
|
+
room.peers.set(role, { address: rinfo.address, port: rinfo.port });
|
|
399
|
+
} else {
|
|
400
|
+
const binding = {
|
|
401
|
+
issuerKeyId: verification.payload.issuerKeyId,
|
|
402
|
+
accountId: verification.payload.accountId,
|
|
403
|
+
hubId: verification.payload.hubId,
|
|
404
|
+
deviceId: verification.payload.deviceId
|
|
405
|
+
};
|
|
406
|
+
if (room && JSON.stringify(room.binding) !== JSON.stringify(binding)) {
|
|
407
|
+
counters.bindingMismatchDrops += 1;
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const endpoint = { address: rinfo.address, port: rinfo.port };
|
|
411
|
+
const consumed = consumedProofs.get(verification.proofKey);
|
|
412
|
+
const existingPeer = room?.peers.get(role);
|
|
413
|
+
if (consumed) {
|
|
414
|
+
const sameEndpoint = consumed.address === endpoint.address && consumed.port === endpoint.port;
|
|
415
|
+
const sameOwner = existingPeer?.proofKey === verification.proofKey
|
|
416
|
+
&& existingPeer.address === endpoint.address
|
|
417
|
+
&& existingPeer.port === endpoint.port;
|
|
418
|
+
if (sameEndpoint && sameOwner) {
|
|
419
|
+
room.updatedAt = at;
|
|
420
|
+
counters.idempotentRefreshes += 1;
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
counters.proofReplayDrops += 1;
|
|
424
|
+
if (existingPeer && !sameEndpoint) counters.endpointOverwriteDrops += 1;
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (consumedProofs.size >= limits.maxConsumedProofs) {
|
|
428
|
+
counters.proofCapacityDrops += 1;
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (existingPeer) {
|
|
432
|
+
counters.endpointOverwriteDrops += 1;
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (!room) {
|
|
436
|
+
room = createRoom(roomId, binding, source, at);
|
|
437
|
+
if (!room) return;
|
|
438
|
+
}
|
|
439
|
+
room.updatedAt = at;
|
|
440
|
+
room.peers.set(role, { ...endpoint, proofKey: verification.proofKey, proofHash: verification.proofHash });
|
|
441
|
+
consumedProofs.set(verification.proofKey, {
|
|
442
|
+
...endpoint,
|
|
443
|
+
expiresAt: verification.payload.expiresAt
|
|
444
|
+
});
|
|
336
445
|
}
|
|
337
|
-
room.updatedAt = at;
|
|
338
|
-
room.peers.set(role, { address: rinfo.address, port: rinfo.port });
|
|
339
446
|
counters.acceptedRegistrations += 1;
|
|
340
447
|
if (room.peers.size < 2) {
|
|
341
448
|
unpairedRooms.delete(roomId);
|
|
@@ -391,6 +498,9 @@ export function createUdpRendezvousServer({
|
|
|
391
498
|
unpairedRooms: unpairedRooms.size,
|
|
392
499
|
sources: sources.size,
|
|
393
500
|
evictableSources: evictableSources.size,
|
|
501
|
+
consumedProofs: consumedProofs.size,
|
|
502
|
+
trustedIssuerCount: trustedIssuers.size,
|
|
503
|
+
securityReady: allowLegacyTokens || trustedIssuers.size > 0,
|
|
394
504
|
limits: { ...limits },
|
|
395
505
|
counters: { ...counters },
|
|
396
506
|
lastError
|
|
@@ -419,6 +529,9 @@ export function createUdpRendezvousServer({
|
|
|
419
529
|
|
|
420
530
|
async function start() {
|
|
421
531
|
if (started) return { host, port: boundPort };
|
|
532
|
+
if (!allowLegacyTokens && trustedIssuers.size === 0) {
|
|
533
|
+
throw new Error('udp-rendezvous-trusted-issuer-required');
|
|
534
|
+
}
|
|
422
535
|
await new Promise((resolve, reject) => {
|
|
423
536
|
const onError = error => { socket.off('listening', onListening); reject(error); };
|
|
424
537
|
const onListening = () => { socket.off('error', onError); resolve(); };
|
|
@@ -443,6 +556,7 @@ export function createUdpRendezvousServer({
|
|
|
443
556
|
unpairedRooms.clear();
|
|
444
557
|
sources.clear();
|
|
445
558
|
evictableSources.clear();
|
|
559
|
+
consumedProofs.clear();
|
|
446
560
|
if (started) {
|
|
447
561
|
await new Promise(resolve => socket.close(() => resolve()));
|
|
448
562
|
started = false;
|