@livedesk/hub 0.1.30 → 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.
@@ -1,34 +1,17 @@
1
1
  import os from 'node:os';
2
2
  import path from 'node:path';
3
3
 
4
- export const SETTINGS_SCHEMA_VERSION = 1;
4
+ export const SETTINGS_SCHEMA_VERSION = 2;
5
5
 
6
6
  export const DEFAULT_LIVEDESK_SETTINGS = Object.freeze({
7
7
  settingsSchemaVersion: SETTINGS_SCHEMA_VERSION,
8
- connection: {
9
- usePinToAddDevices: true,
10
- pinValidityMinutes: 30,
11
- rotatePinAfterPairing: true,
12
- allowNewDevices: true,
13
- approveNewDevices: true,
14
- startWithComputer: true,
15
- keepRunningInTray: true,
16
- showConnectionNotifications: true
17
- },
8
+ connection: {},
18
9
  security: {
19
10
  accessMode: 'trusted-only',
20
- requireEncryptedConnections: true,
21
11
  allowInternetConnections: false,
22
12
  allowLanConnections: true,
23
- allowUnencryptedLanFallback: false,
24
- requireNewDeviceApproval: true,
25
- requireAdministratorForSecurityChanges: true,
26
- visibleControlIndicator: true,
27
13
  lockOnControlEnd: true,
28
- disconnectIdleControlSessions: true,
29
- idleControlMinutes: 30,
30
- keepSessionHistory: true,
31
- sessionHistoryDays: 30
14
+ idleControlMinutes: 30
32
15
  },
33
16
  control: {
34
17
  allowKeyboardMouse: true,
@@ -60,10 +43,8 @@ export const DEFAULT_LIVEDESK_SETTINGS = Object.freeze({
60
43
  filesAudio: {
61
44
  allowFileTransfer: true,
62
45
  allowFolderSync: false,
63
- askBeforeReceivingFiles: true,
64
46
  openReceivedFolder: false,
65
47
  notifyTransferComplete: true,
66
- allowOverwrite: false,
67
48
  defaultReceiveFolder: 'Desktop/LiveDeskFiles',
68
49
  maxFileSizeBytes: 1024 * 1024 * 1024,
69
50
  allowRemoteAudio: true,
@@ -103,7 +84,6 @@ export const DEFAULT_LIVEDESK_SETTINGS = Object.freeze({
103
84
  controlMaxHeight: 1080,
104
85
  controlQuality: 60,
105
86
  transport: 'auto',
106
- allowPlainLanFallback: false,
107
87
  verboseLogs: false,
108
88
  frameStatistics: false
109
89
  }
@@ -115,7 +95,7 @@ const ENUMS = {
115
95
  permissionMode: new Set(['ask', 'safe-auto', 'full-access', 'custom']),
116
96
  wallFrameMode: new Set(['auto', 'mode2-lzo', 'mode3-h264-hw', 'mode4-h264-atlas']),
117
97
  controlFrameMode: new Set(['mode3-h264-hw', 'mode5-lzo-delta']),
118
- transport: new Set(['auto', 'encrypted', 'plain-lan']),
98
+ transport: new Set(['auto', 'encrypted']),
119
99
  recordingQuality: new Set(['standard', 'high']),
120
100
  captureAutoDelete: new Set(['never', '7-days', '30-days'])
121
101
  };
@@ -156,14 +136,11 @@ const bools = keys => Object.fromEntries(keys.map(key => [key, { type: 'boolean'
156
136
  const numbers = (entries) => Object.fromEntries(entries.map(([key, min, max]) => [key, { type: 'number', min, max }]));
157
137
 
158
138
  const RULES = {
159
- connection: {
160
- ...bools(['usePinToAddDevices', 'rotatePinAfterPairing', 'allowNewDevices', 'approveNewDevices', 'startWithComputer', 'keepRunningInTray', 'showConnectionNotifications']),
161
- ...numbers([['pinValidityMinutes', 10, 1440]])
162
- },
139
+ connection: {},
163
140
  security: {
164
141
  accessMode: { type: 'enum', values: ENUMS.accessMode },
165
- ...bools(['requireEncryptedConnections', 'allowInternetConnections', 'allowLanConnections', 'allowUnencryptedLanFallback', 'requireNewDeviceApproval', 'requireAdministratorForSecurityChanges', 'visibleControlIndicator', 'lockOnControlEnd', 'disconnectIdleControlSessions', 'keepSessionHistory']),
166
- ...numbers([['idleControlMinutes', 5, 1440], ['sessionHistoryDays', 7, 365]])
142
+ ...bools(['allowInternetConnections', 'allowLanConnections', 'lockOnControlEnd']),
143
+ ...numbers([['idleControlMinutes', 5, 1440]])
167
144
  },
168
145
  control: {
169
146
  ...bools(['allowKeyboardMouse', 'allowSystemShortcuts', 'allowClipboardText', 'allowRemoteRestart', 'reconnectAfterRemoteRestart', 'allowSwitchingMonitors', 'showConnectionToolbar', 'showRemoteCursor', 'openControlOnDoubleClick', 'startRemoteAudioWithControl', 'fitRemoteScreen', 'rememberLastMonitor', 'keepControlReadyBetweenPages'])
@@ -173,7 +150,7 @@ const RULES = {
173
150
  ...bools(['autoStart', 'connectedOnly', 'keepEmptySlots', 'showDeviceStatus', 'showPerformanceDetails', 'pauseHiddenTiles', 'reduceWhenHidden', 'autoAdjustTileQuality', 'rememberDevicePositions'])
174
151
  },
175
152
  filesAudio: {
176
- ...bools(['allowFileTransfer', 'allowFolderSync', 'askBeforeReceivingFiles', 'openReceivedFolder', 'notifyTransferComplete', 'allowOverwrite', 'allowRemoteAudio', 'startAudioMuted', 'rememberVolume', 'automaticallyRecoverAudio', 'showAudioTroubleshooting', 'rollingBufferEnabled', 'includeRemoteCursor']),
153
+ ...bools(['allowFileTransfer', 'allowFolderSync', 'openReceivedFolder', 'notifyTransferComplete', 'allowRemoteAudio', 'startAudioMuted', 'rememberVolume', 'automaticallyRecoverAudio', 'showAudioTroubleshooting', 'rollingBufferEnabled', 'includeRemoteCursor']),
177
154
  recordingQuality: { type: 'enum', values: ENUMS.recordingQuality },
178
155
  captureAutoDelete: { type: 'enum', values: ENUMS.captureAutoDelete },
179
156
  timelapseIntervalSeconds: { type: 'number', min: 5, max: 60 },
@@ -190,7 +167,7 @@ const RULES = {
190
167
  wallFrameMode: { type: 'enum', values: ENUMS.wallFrameMode },
191
168
  controlFrameMode: { type: 'enum', values: ENUMS.controlFrameMode },
192
169
  transport: { type: 'enum', values: ENUMS.transport },
193
- ...bools(['allowPlainLanFallback', 'verboseLogs', 'frameStatistics']),
170
+ ...bools(['verboseLogs', 'frameStatistics']),
194
171
  ...numbers([['wallFps', 1, 60], ['wallMaxWidth', 320, 3840], ['wallMaxHeight', 180, 2160], ['wallQuality', 20, 95], ['controlFps', 20, 60], ['controlMaxWidth', 640, 3840], ['controlMaxHeight', 360, 2160], ['controlQuality', 20, 95]])
195
172
  }
196
173
  };
@@ -207,11 +184,6 @@ export function normalizeLiveDeskSettings(value = {}) {
207
184
  agent: normalizeSection(source.agent, DEFAULT_LIVEDESK_SETTINGS.agent, RULES.agent),
208
185
  advanced: normalizeSection(source.advanced, DEFAULT_LIVEDESK_SETTINGS.advanced, RULES.advanced)
209
186
  };
210
- // Security invariants are enforced at the authority boundary, not just in
211
- // the browser. Internet access can never use an unencrypted transport.
212
- if (settings.security.requireEncryptedConnections) {
213
- settings.security.allowUnencryptedLanFallback = false;
214
- }
215
187
  if (!settings.security.allowInternetConnections) {
216
188
  settings.advanced.transport = settings.advanced.transport === 'encrypted' ? 'encrypted' : 'auto';
217
189
  }
@@ -247,8 +219,16 @@ export function effectiveDevicePolicy(settings = DEFAULT_LIVEDESK_SETTINGS) {
247
219
  accessMode,
248
220
  allowInternetConnections: normalized.security.allowInternetConnections,
249
221
  allowLanConnections: normalized.security.allowLanConnections,
250
- requireEncryptedConnections: normalized.security.requireEncryptedConnections,
251
- allowUnencryptedLanFallback: normalized.security.allowUnencryptedLanFallback,
222
+ // Encryption and no-overwrite are structural invariants. They are not
223
+ // user preferences and cannot be disabled through a legacy settings file.
224
+ requireEncryptedConnections: true,
225
+ allowUnencryptedLanFallback: false,
226
+ visibleControlIndicator: true,
227
+ lockOnControlEnd: normalized.security.lockOnControlEnd,
228
+ disconnectIdleControlSessions: true,
229
+ idleControlMinutes: normalized.security.idleControlMinutes,
230
+ allowFileOverwrite: false,
231
+ maxFileSizeBytes: normalized.filesAudio.maxFileSizeBytes,
252
232
  allowControl: allowMutation && normalized.control.allowKeyboardMouse,
253
233
  allowClipboardText: allowMutation && normalized.control.allowClipboardText,
254
234
  allowPowerActions: allowMutation && normalized.control.allowRemoteRestart,
@@ -1,6 +1,16 @@
1
1
  import crypto from 'node:crypto';
2
2
  import { EventEmitter } from 'node:events';
3
3
  import net from 'node:net';
4
+ import {
5
+ SECURE_SESSION_MAX_CLOCK_SKEW_MS,
6
+ SECURE_SESSION_PROTOCOL,
7
+ deriveSecureSessionKey,
8
+ enrollmentProof,
9
+ fixedTimeBase64UrlEqual,
10
+ normalizeSecureChannel,
11
+ secureClientTranscript,
12
+ secureServerTranscript
13
+ } from '@livedesk/runtime-core';
4
14
 
5
15
  export const RELAY_CONTROL_PROTOCOL = 'livedesk.relay.v1';
6
16
  export const RELAY_CONTROL_MAX_PLAINTEXT_BYTES = 512 * 1024;
@@ -161,6 +171,47 @@ export function deriveRelayRegistrationToken(pairToken, roomId = deriveRelayRoom
161
171
  .digest('base64url');
162
172
  }
163
173
 
174
+ export function deriveCredentialRelayRoomId(hubId, hubPublicKey) {
175
+ const id = safeText(hubId, 128);
176
+ const key = decodeCanonicalBase64Url(hubPublicKey, 0, 160).toString('base64url');
177
+ if (!id) throw relayError('relay-hub-id-required');
178
+ return crypto.createHash('sha256')
179
+ .update(Buffer.from(`livedesk-relay-credential-room-v1\0${id}\0${key}`, 'utf8'))
180
+ .digest('base64url');
181
+ }
182
+
183
+ export function deriveCredentialRelayRegistrationToken(hubId, hubPublicKey) {
184
+ const roomId = deriveCredentialRelayRoomId(hubId, hubPublicKey);
185
+ return crypto.createHash('sha256')
186
+ .update(Buffer.from(`livedesk-relay-credential-register-v1\0${roomId}\0${hubPublicKey}`, 'utf8'))
187
+ .digest('base64url');
188
+ }
189
+
190
+ function requireP256SpkiPublicKey(value) {
191
+ const der = decodeCanonicalBase64Url(value, 0, 160);
192
+ if (der.length < 80) throw relayError('relay-public-key-invalid');
193
+ let key;
194
+ try {
195
+ key = crypto.createPublicKey({ key: der, format: 'der', type: 'spki' });
196
+ } catch {
197
+ throw relayError('relay-public-key-invalid');
198
+ }
199
+ if (key.asymmetricKeyType !== 'ec' || key.asymmetricKeyDetails?.namedCurve !== 'prime256v1') {
200
+ throw relayError('relay-public-key-invalid');
201
+ }
202
+ return { key, text: der.toString('base64url') };
203
+ }
204
+
205
+ function verifyP256Signature(publicKey, message, signature) {
206
+ const bytes = decodeCanonicalBase64Url(signature, 64, 64);
207
+ if (!crypto.verify('sha256', Buffer.from(String(message || ''), 'utf8'), {
208
+ key: publicKey,
209
+ dsaEncoding: 'ieee-p1363'
210
+ }, bytes)) {
211
+ throw relayError('device-signature-invalid');
212
+ }
213
+ }
214
+
164
215
  export function deriveRelayClientProof(pairToken, {
165
216
  roomId = deriveRelayRoomId(pairToken),
166
217
  peerId,
@@ -403,15 +454,31 @@ export class HubRelayVirtualSocket extends EventEmitter {
403
454
  export function createHubRelayControl({
404
455
  env = process.env,
405
456
  pairToken,
457
+ secureSession = null,
406
458
  onPeerSocket = () => {},
407
459
  logEvent = () => {},
408
460
  logWarn = () => {},
409
461
  connect = options => net.createConnection(options),
410
462
  randomBytes = size => crypto.randomBytes(size)
411
463
  } = {}) {
412
- const token = requirePairToken(pairToken);
413
- const roomId = deriveRelayRoomId(token);
414
- const registrationToken = deriveRelayRegistrationToken(token, roomId);
464
+ const secureMode = secureSession?.mode === 'credential'
465
+ ? 'credential'
466
+ : secureSession?.mode === 'enroll'
467
+ ? 'enroll'
468
+ : '';
469
+ const authority = secureMode ? secureSession?.authority : null;
470
+ if (secureMode && (!authority?.verifyCredential || !authority?.issueCredential || !authority?.signHubMessage)) {
471
+ throw relayError('relay-device-authority-required');
472
+ }
473
+ const token = secureMode === 'credential'
474
+ ? authority.hubPublicKey
475
+ : requirePairToken(pairToken);
476
+ const roomId = secureMode === 'credential'
477
+ ? deriveCredentialRelayRoomId(authority.hubId, authority.hubPublicKey)
478
+ : deriveRelayRoomId(token);
479
+ const registrationToken = secureMode === 'credential'
480
+ ? deriveCredentialRelayRegistrationToken(authority.hubId, authority.hubPublicKey)
481
+ : deriveRelayRegistrationToken(token, roomId);
415
482
  const enabled = isEnabled(
416
483
  env.LIVEDESK_RELAY_ENABLED,
417
484
  isEnabled(env.LIVEDESK_UDP_ENABLED, true));
@@ -817,6 +884,10 @@ export function createHubRelayControl({
817
884
  }
818
885
 
819
886
  function handleClientHandshake(message) {
887
+ if (secureMode) {
888
+ handleSecureClientHandshake(message);
889
+ return;
890
+ }
820
891
  let peerId = '';
821
892
  try {
822
893
  peerId = requirePeerId(message.peerId);
@@ -917,6 +988,143 @@ export function createHubRelayControl({
917
988
  }
918
989
  }
919
990
 
991
+ function handleSecureClientHandshake(message) {
992
+ let peerId = '';
993
+ try {
994
+ peerId = requirePeerId(message.peerId);
995
+ if (message.direction && requireDirection(message.direction) !== RELAY_CONTROL_DIRECTIONS.clientToHub) {
996
+ throw relayError('relay-direction-invalid');
997
+ }
998
+ if (safeText(message.stage, 40).toLowerCase() !== 'client-hello') throw relayError('relay-secure-stage-invalid');
999
+ if (message.securityProtocol !== SECURE_SESSION_PROTOCOL) {
1000
+ lastError = `relay-secure-protocol:${safeText(message.securityProtocol, 80) || 'missing'}`;
1001
+ throw relayError(message.securityProtocol === undefined
1002
+ ? 'relay-secure-protocol-missing'
1003
+ : 'relay-secure-protocol-invalid');
1004
+ }
1005
+ if (peers.has(peerId) || retiredPeerIds.has(peerId)) throw relayError('relay-handshake-replay');
1006
+ if (peers.size >= maxPeers) throw relayError('relay-peer-capacity');
1007
+ const expectedMode = secureMode === 'credential' ? 'resume' : 'enroll';
1008
+ if (message.mode !== expectedMode) throw relayError('relay-secure-mode-invalid');
1009
+ const current = Date.now();
1010
+ const timestamp = Number(message.timestamp);
1011
+ if (!Number.isSafeInteger(timestamp) || Math.abs(current - timestamp) > SECURE_SESSION_MAX_CLOCK_SKEW_MS) {
1012
+ throw relayError('secure-handshake-timestamp-invalid');
1013
+ }
1014
+ const channel = normalizeSecureChannel(message.channel);
1015
+ const deviceId = safeText(message.deviceId, 128);
1016
+ if (!deviceId) throw relayError('device-id-required');
1017
+ const devicePublic = requireP256SpkiPublicKey(message.devicePublicKey);
1018
+ const clientEphemeral = requireP256SpkiPublicKey(message.clientEphemeralPublicKey);
1019
+ const clientTranscript = secureClientTranscript(message);
1020
+ const identity = secureSession.getIdentity?.() || {};
1021
+ const accountId = safeText(identity.accountId, 128);
1022
+ if (!accountId) throw relayError('secure-account-session-required');
1023
+
1024
+ let issued;
1025
+ let enrollmentSecret = '';
1026
+ if (expectedMode === 'enroll') {
1027
+ enrollmentSecret = String(secureSession.getEnrollmentToken?.() || token);
1028
+ if (!fixedTimeBase64UrlEqual(message.enrollmentProof, enrollmentProof(enrollmentSecret, clientTranscript), 32)) {
1029
+ throw relayError('secure-enrollment-proof-invalid');
1030
+ }
1031
+ verifyP256Signature(devicePublic.key, clientTranscript, message.deviceSignature);
1032
+ authority.assertEnrollmentAllowed?.({ accountId, deviceId, devicePublicKey: devicePublic.text });
1033
+ if (!secureSession.consumeEnrollmentToken?.(enrollmentSecret)) throw relayError('secure-enrollment-token-used');
1034
+ issued = authority.issueCredential({ accountId, deviceId, devicePublicKey: devicePublic.text });
1035
+ } else {
1036
+ const verified = authority.verifyCredential(message.credential, { accountId, deviceId });
1037
+ if (verified.payload.devicePublicKey !== devicePublic.text) throw relayError('device-credential-binding-invalid');
1038
+ authority.verifyDeviceSignature(verified, clientTranscript, message.deviceSignature);
1039
+ issued = { credential: verified.text, payload: verified.payload };
1040
+ }
1041
+
1042
+ const { privateKey: serverPrivate, publicKey: serverPublic } = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
1043
+ const response = {
1044
+ type: 'relay.handshake',
1045
+ protocol: RELAY_CONTROL_PROTOCOL,
1046
+ roomId,
1047
+ peerId,
1048
+ direction: RELAY_CONTROL_DIRECTIONS.hubToClient,
1049
+ stage: 'hub-hello',
1050
+ securityProtocol: SECURE_SESSION_PROTOCOL,
1051
+ sessionId: crypto.randomBytes(16).toString('base64url'),
1052
+ channel,
1053
+ timestamp: current,
1054
+ serverNonce: crypto.randomBytes(16).toString('base64url'),
1055
+ serverEphemeralPublicKey: serverPublic.export({ format: 'der', type: 'spki' }).toString('base64url'),
1056
+ credential: issued.credential,
1057
+ hubId: authority.hubId,
1058
+ accountId,
1059
+ deviceId
1060
+ };
1061
+ const serverTranscript = secureServerTranscript(response, clientTranscript);
1062
+ response.hubSignature = authority.signHubMessage(serverTranscript);
1063
+ if (expectedMode === 'enroll') response.enrollmentServerProof = enrollmentProof(enrollmentSecret, serverTranscript);
1064
+ const sharedSecret = crypto.diffieHellman({ privateKey: serverPrivate, publicKey: clientEphemeral.key });
1065
+ let sessionKey;
1066
+ try {
1067
+ sessionKey = deriveSecureSessionKey({
1068
+ sharedSecret,
1069
+ clientTranscript,
1070
+ serverTranscript,
1071
+ sessionId: response.sessionId,
1072
+ channel
1073
+ });
1074
+ } finally {
1075
+ sharedSecret.fill(0);
1076
+ }
1077
+ const remoteAddress = `relay://${host}:${port}/${peerId}`;
1078
+ const peer = {
1079
+ peerId,
1080
+ sessionKey,
1081
+ receiveSequence: 0,
1082
+ sendSequence: 0,
1083
+ handshakeState: 'established',
1084
+ createdAt: current,
1085
+ lastActivityAt: current,
1086
+ socket: null
1087
+ };
1088
+ peer.socket = new HubRelayVirtualSocket(api, peerId, remoteAddress, port);
1089
+ peer.socket.__liveDeskSecurityContext = Object.freeze({
1090
+ protocol: SECURE_SESSION_PROTOCOL,
1091
+ accountId,
1092
+ hubId: authority.hubId,
1093
+ deviceId,
1094
+ channel,
1095
+ credentialSerial: issued.payload.serial,
1096
+ authenticated: true,
1097
+ encrypted: true,
1098
+ transport: 'relay'
1099
+ });
1100
+ peers.set(peerId, peer);
1101
+ counters.peerOwnersCreated += 1;
1102
+ peerOwnerHighWater = Math.max(peerOwnerHighWater, peers.size);
1103
+ if (!enqueueEnvelope(response, peerId)) {
1104
+ closePeer(peerId, 'relay-handshake-backpressure', { notify: false });
1105
+ return;
1106
+ }
1107
+ onPeerSocket(peer.socket, {
1108
+ protocol: RELAY_CONTROL_PROTOCOL,
1109
+ securityProtocol: SECURE_SESSION_PROTOCOL,
1110
+ peerId,
1111
+ endpoint: `${host}:${port}`,
1112
+ accountId,
1113
+ hubId: authority.hubId,
1114
+ deviceId,
1115
+ channel
1116
+ });
1117
+ authority.markConnected(deviceId);
1118
+ counters.handshakes += 1;
1119
+ } catch (error) {
1120
+ counters.rejectedPeers += 1;
1121
+ if (peerId) {
1122
+ rememberRetiredPeer(peerId);
1123
+ sendRelayClose(peerId, safeText(error?.code || error?.message, 80) || 'relay-handshake-rejected');
1124
+ }
1125
+ }
1126
+ }
1127
+
920
1128
  function handleClientData(message) {
921
1129
  let peerId = '';
922
1130
  try {
@@ -1374,3 +1582,122 @@ export function createHubRelayControl({
1374
1582
  };
1375
1583
  return api;
1376
1584
  }
1585
+
1586
+ export function createSecureHubRelayControl({
1587
+ env = process.env,
1588
+ authority,
1589
+ getIdentity = () => ({}),
1590
+ getEnrollmentToken = () => '',
1591
+ consumeEnrollmentToken = () => false,
1592
+ onPeerSocket = () => {},
1593
+ logEvent = () => {},
1594
+ logWarn = () => {}
1595
+ } = {}) {
1596
+ if (!authority?.hubId || !authority?.hubPublicKey) throw relayError('relay-device-authority-required');
1597
+ let started = false;
1598
+ let closed = false;
1599
+ let enrollment = null;
1600
+ const retired = new Set();
1601
+ const retirementTimers = new Set();
1602
+ const stable = createHubRelayControl({
1603
+ env,
1604
+ pairToken: authority.hubPublicKey,
1605
+ secureSession: { mode: 'credential', authority, getIdentity },
1606
+ onPeerSocket,
1607
+ logEvent,
1608
+ logWarn
1609
+ });
1610
+
1611
+ function makeEnrollmentControl(token) {
1612
+ return createHubRelayControl({
1613
+ env,
1614
+ pairToken: token,
1615
+ secureSession: {
1616
+ mode: 'enroll',
1617
+ authority,
1618
+ getIdentity,
1619
+ getEnrollmentToken: () => token,
1620
+ consumeEnrollmentToken: expected => {
1621
+ if (expected !== token || !consumeEnrollmentToken(expected)) return false;
1622
+ queueMicrotask(() => { void rotateEnrollment(); });
1623
+ return true;
1624
+ }
1625
+ },
1626
+ onPeerSocket,
1627
+ logEvent,
1628
+ logWarn
1629
+ });
1630
+ }
1631
+
1632
+ function retireControl(control) {
1633
+ if (!control) return;
1634
+ retired.add(control);
1635
+ const deadline = Date.now() + 10_000;
1636
+ const poll = () => {
1637
+ if (closed || control.getStatus().peerCount === 0 || Date.now() >= deadline) {
1638
+ retired.delete(control);
1639
+ void control.close().catch(error => logWarn('relay', `Secure enrollment relay retirement failed code=${safeText(error?.code, 80) || 'unknown'}`));
1640
+ return;
1641
+ }
1642
+ const timer = setTimeout(() => {
1643
+ retirementTimers.delete(timer);
1644
+ poll();
1645
+ }, 100);
1646
+ timer.unref?.();
1647
+ retirementTimers.add(timer);
1648
+ };
1649
+ poll();
1650
+ }
1651
+
1652
+ async function rotateEnrollment() {
1653
+ if (closed) return;
1654
+ const previous = enrollment;
1655
+ enrollment = makeEnrollmentControl(getEnrollmentToken());
1656
+ if (started) await enrollment.start();
1657
+ retireControl(previous);
1658
+ }
1659
+
1660
+ async function start() {
1661
+ if (started || closed) return getStatus();
1662
+ started = true;
1663
+ enrollment = makeEnrollmentControl(getEnrollmentToken());
1664
+ await Promise.all([stable.start(), enrollment.start()]);
1665
+ return getStatus();
1666
+ }
1667
+
1668
+ async function close() {
1669
+ if (closed) return;
1670
+ closed = true;
1671
+ started = false;
1672
+ for (const timer of retirementTimers) clearTimeout(timer);
1673
+ retirementTimers.clear();
1674
+ const controls = [stable, enrollment, ...retired].filter(Boolean);
1675
+ retired.clear();
1676
+ enrollment = null;
1677
+ const results = await Promise.allSettled(controls.map(control => control.close()));
1678
+ const failure = results.find(result => result.status === 'rejected');
1679
+ if (failure) throw failure.reason;
1680
+ }
1681
+
1682
+ function getStatus() {
1683
+ const stableStatus = stable.getStatus();
1684
+ const enrollmentStatus = enrollment?.getStatus() || null;
1685
+ const retiredStatuses = [...retired].map(control => control.getStatus());
1686
+ const statuses = [stableStatus, enrollmentStatus, ...retiredStatuses].filter(Boolean);
1687
+ return {
1688
+ ...stableStatus,
1689
+ started,
1690
+ connected: statuses.some(status => status.connected),
1691
+ state: stableStatus.connected ? 'registered' : enrollmentStatus?.state || stableStatus.state,
1692
+ peerCount: statuses.reduce((sum, status) => sum + Number(status.peerCount || 0), 0),
1693
+ activeSocketCount: statuses.reduce((sum, status) => sum + Number(status.activeSocketCount || 0), 0),
1694
+ secureCredentialRelay: true,
1695
+ credentialRoomId: deriveCredentialRelayRoomId(authority.hubId, authority.hubPublicKey),
1696
+ enrollmentGenerationCount: 1 + retiredStatuses.length,
1697
+ enrollment: enrollmentStatus,
1698
+ retiredEnrollmentCount: retiredStatuses.length
1699
+ };
1700
+ }
1701
+
1702
+ return Object.freeze({ start, close, getStatus });
1703
+ }