livedesk 0.1.479 → 0.1.481

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/hub/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);