@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/hub",
3
- "version": "0.1.31",
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.3",
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
- job.error = error instanceof Error ? error.message : String(error);
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
- refreshDevicePolicies,
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, registry);
189
+ function persistRegistry(nextRegistry = registry) {
190
+ atomicPrivateJson(registryPath, nextRegistry);
188
191
  }
189
192
 
190
- function issueCredential({ accountId, deviceId, devicePublicKey, replace = false }) {
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 && !replace) throw securityError('device-already-enrolled');
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
- registry.devices[id] = {
217
- serial: payload.serial,
218
- accountId: owner,
219
- hubId,
220
- devicePublicKey: deviceKey.text,
221
- credential,
222
- issuedAt,
223
- expiresAt: payload.expiresAt,
224
- revokedAt: '',
225
- lastConnectedAt: ''
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: 1,
290
- protocol: 'livedesk.udp.rendezvous-proof.v1',
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
- record.revokedAt = new Date(now()).toISOString();
309
- record.revokeReason = clean(reason, 160);
310
- persistRegistry();
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
- record.lastConnectedAt = new Date(now()).toISOString();
319
- persistRegistry();
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
- registry = { version: AUTHORITY_VERSION, devices: {} };
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 { appendFile, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
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
- await appendFile(filePath, `${JSON.stringify(written)}\n`, { encoding: 'utf8', mode: 0o600 });
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 recordSecurityAudit(event = {}) {
818
- if (!securityAuditStore) return;
817
+ function securityAuditEvent(event = {}) {
819
818
  const runtime = runtimeManager.getSnapshot();
820
- void securityAuditStore.record({
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
- }).catch(error => {
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: readPositiveIntegerEnv('LIVEDESK_FILE_CHUNK_ACK_TIMEOUT_MS', 30_000),
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
- next();
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
- const results = deviceIds.map(deviceId => ({
5585
- deviceId,
5586
- ...remoteHub.sendCommand(deviceId, {
5587
- command: 'file.transfer',
5588
- payload: {
5589
- transferId,
5590
- remoteDirectory,
5591
- files: normalized.files,
5592
- totalBytes: normalized.totalBytes,
5593
- requestedAt: queuedAt
5594
- }
5595
- })
5596
- }));
5597
- const queued = results.filter(result => result.ok).length;
5598
- recordSecurityAudit({
5599
- action: 'remote.file.transfer',
5600
- phase: 'start',
5601
- result: queued > 0 ? 'queued' : 'rejected',
5602
- reason: queued > 0 ? '' : 'no-transfer-queued',
5603
- deviceIds,
5604
- sessionId: transferId,
5605
- requestHash: requestAuditHash({ transferId, remoteDirectory, files: normalized.files.map(file => ({ name: file.name, relativePath: file.relativePath, byteLength: file.size })), totalBytes: normalized.totalBytes }),
5606
- authMethod: 'local-admin-session',
5607
- details: { queued, total: deviceIds.length, totalBytes: normalized.totalBytes, files: normalized.files.length }
5608
- });
5609
- res.json({
5610
- ok: queued > 0,
5611
- transferId,
5612
- queued,
5613
- total: deviceIds.length,
5614
- totalBytes: normalized.totalBytes,
5615
- files: normalized.files.length,
5616
- results,
5617
- error: queued > 0 ? undefined : 'no-transfer-queued'
5618
- });
5619
- });
5620
-
5621
- app.post('/api/remote/files/chunk', requireHubFeatureAccess, (req, res) => {
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
- const results = deviceIds.map(deviceId => ({
5640
- deviceId,
5641
- ...remoteHub.sendCommand(deviceId, {
5642
- command: 'file.transfer.chunk',
5643
- payload: {
5644
- transferId,
5645
- remoteDirectory,
5646
- ...normalized.chunk,
5647
- requestedAt: queuedAt
5648
- }
5649
- })
5650
- }));
5651
- const queued = results.filter(result => result.ok).length;
5652
- recordSecurityAudit({
5653
- action: 'remote.file.chunk',
5654
- phase: normalized.chunk.final ? 'complete' : 'progress',
5655
- result: queued > 0 ? 'queued' : 'rejected',
5656
- reason: queued > 0 ? '' : 'no-transfer-queued',
5657
- deviceIds,
5658
- sessionId: transferId,
5659
- requestHash: requestAuditHash({ transferId, remoteDirectory, offset: normalized.chunk.offset, byteLength: normalized.chunk.byteLength, final: normalized.chunk.final }),
5660
- authMethod: 'local-admin-session',
5661
- details: { queued, total: deviceIds.length, byteLength: normalized.chunk.byteLength, offset: normalized.chunk.offset, final: normalized.chunk.final }
5662
- });
5663
- res.json({
5664
- ok: queued > 0,
5665
- transferId,
5666
- queued,
5667
- total: deviceIds.length,
5668
- byteLength: normalized.chunk.byteLength,
5669
- offset: normalized.chunk.offset,
5670
- final: normalized.chunk.final,
5671
- results,
5672
- error: queued > 0 ? undefined : 'no-transfer-queued'
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
- try {
5994
- const parsed = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
5995
- if (parsed.pathname === '/api/remote/frames/ws') {
5996
- frameWss.handleUpgrade(req, socket, head, ws => frameWss.emit('connection', ws, req));
5997
- return;
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
- socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
6012
- socket.destroy();
6013
- } catch {
6014
- socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
6015
- socket.destroy();
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
- recordSecurityAudit({
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
- let payload;
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
- recordSecurityAudit({
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
- sendJson(ws, {
6206
- type: 'RemoteInputSocketReady',
6207
- protocol: 'livedesk.remote.input.json.v1',
6208
- clientId: ws.liveDeskInputClientId,
6209
- timestamp: new Date().toISOString()
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, replace: true });
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, replace: true });
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 proof = typeof rendezvousProofIssuer === 'function'
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 signedRendezvousProof = safeText(proof?.proof, 2048);
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: signedRendezvousProof
459
- || (allowLegacyUnsignedProofForTests ? crypto.randomBytes(16).toString('hex') : ''),
460
- rendezvousProofExpiresAt: Number(proof?.payload?.expiresAt || 0),
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.rendezvousToken
497
- ? { host: rendezvousHost, port: rendezvousPort, roomId: session.sessionId, token: session.rendezvousToken }
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 verifyRendezvousProof(proof, roomId, current) {
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 !== 1
74
- || payload.protocol !== 'livedesk.udp.rendezvous-proof.v1'
102
+ if (!payload || payload.version !== 2
103
+ || payload.protocol !== 'livedesk.udp.rendezvous-proof.v2'
75
104
  || payload.roomId !== roomId
76
- || !safeText(payload.accountId, 128)
77
- || !safeText(payload.hubId, 128)
78
- || !safeText(payload.deviceId, 128)
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
- 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,
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, token, source, at) {
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
- token,
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
- if (!allowLegacyTokens && !verifyRendezvousProof(token, roomId, at)) {
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 (room && room.token !== token) {
330
- counters.tokenMismatchDrops += 1;
331
- return;
332
- }
333
- if (!room) {
334
- room = createRoom(roomId, token, source, at);
335
- if (!room) return;
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;