@livedesk/hub 0.1.30 → 0.1.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/agents/agent-audit-store.js +16 -6
- package/src/agents/agent-permissions.js +9 -3
- package/src/agents/agent-tool-registry.js +19 -1
- package/src/captures/capture-store.js +50 -3
- package/src/filesystem/shared-folders.js +8 -0
- package/src/filesystem/transfer-jobs.js +31 -4
- package/src/live-desk-update.js +87 -19
- package/src/remote-hub.js +658 -166
- package/src/security/device-credential-authority.js +358 -0
- package/src/security/security-audit-store.js +238 -0
- package/src/server.js +755 -191
- package/src/settings/settings-schema.js +19 -39
- package/src/transport/relay-hub-control.js +329 -3
- package/src/transport/secure-direct-acceptor.js +432 -0
- package/src/transport/udp-hub-transport.js +17 -5
- package/src/transport/udp-rendezvous.js +54 -2
|
@@ -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 =
|
|
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
|
-
|
|
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'
|
|
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(['
|
|
166
|
-
...numbers([['idleControlMinutes', 5, 1440]
|
|
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', '
|
|
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(['
|
|
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
|
-
|
|
251
|
-
|
|
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
|
|
413
|
-
|
|
414
|
-
|
|
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,142 @@ 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
|
+
if (!secureSession.consumeEnrollmentToken?.(enrollmentSecret)) throw relayError('secure-enrollment-token-used');
|
|
1033
|
+
issued = authority.issueCredential({ accountId, deviceId, devicePublicKey: devicePublic.text, replace: true });
|
|
1034
|
+
} else {
|
|
1035
|
+
const verified = authority.verifyCredential(message.credential, { accountId, deviceId });
|
|
1036
|
+
if (verified.payload.devicePublicKey !== devicePublic.text) throw relayError('device-credential-binding-invalid');
|
|
1037
|
+
authority.verifyDeviceSignature(verified, clientTranscript, message.deviceSignature);
|
|
1038
|
+
issued = { credential: verified.text, payload: verified.payload };
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
const { privateKey: serverPrivate, publicKey: serverPublic } = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
|
1042
|
+
const response = {
|
|
1043
|
+
type: 'relay.handshake',
|
|
1044
|
+
protocol: RELAY_CONTROL_PROTOCOL,
|
|
1045
|
+
roomId,
|
|
1046
|
+
peerId,
|
|
1047
|
+
direction: RELAY_CONTROL_DIRECTIONS.hubToClient,
|
|
1048
|
+
stage: 'hub-hello',
|
|
1049
|
+
securityProtocol: SECURE_SESSION_PROTOCOL,
|
|
1050
|
+
sessionId: crypto.randomBytes(16).toString('base64url'),
|
|
1051
|
+
channel,
|
|
1052
|
+
timestamp: current,
|
|
1053
|
+
serverNonce: crypto.randomBytes(16).toString('base64url'),
|
|
1054
|
+
serverEphemeralPublicKey: serverPublic.export({ format: 'der', type: 'spki' }).toString('base64url'),
|
|
1055
|
+
credential: issued.credential,
|
|
1056
|
+
hubId: authority.hubId,
|
|
1057
|
+
accountId,
|
|
1058
|
+
deviceId
|
|
1059
|
+
};
|
|
1060
|
+
const serverTranscript = secureServerTranscript(response, clientTranscript);
|
|
1061
|
+
response.hubSignature = authority.signHubMessage(serverTranscript);
|
|
1062
|
+
if (expectedMode === 'enroll') response.enrollmentServerProof = enrollmentProof(enrollmentSecret, serverTranscript);
|
|
1063
|
+
const sharedSecret = crypto.diffieHellman({ privateKey: serverPrivate, publicKey: clientEphemeral.key });
|
|
1064
|
+
let sessionKey;
|
|
1065
|
+
try {
|
|
1066
|
+
sessionKey = deriveSecureSessionKey({
|
|
1067
|
+
sharedSecret,
|
|
1068
|
+
clientTranscript,
|
|
1069
|
+
serverTranscript,
|
|
1070
|
+
sessionId: response.sessionId,
|
|
1071
|
+
channel
|
|
1072
|
+
});
|
|
1073
|
+
} finally {
|
|
1074
|
+
sharedSecret.fill(0);
|
|
1075
|
+
}
|
|
1076
|
+
const remoteAddress = `relay://${host}:${port}/${peerId}`;
|
|
1077
|
+
const peer = {
|
|
1078
|
+
peerId,
|
|
1079
|
+
sessionKey,
|
|
1080
|
+
receiveSequence: 0,
|
|
1081
|
+
sendSequence: 0,
|
|
1082
|
+
handshakeState: 'established',
|
|
1083
|
+
createdAt: current,
|
|
1084
|
+
lastActivityAt: current,
|
|
1085
|
+
socket: null
|
|
1086
|
+
};
|
|
1087
|
+
peer.socket = new HubRelayVirtualSocket(api, peerId, remoteAddress, port);
|
|
1088
|
+
peer.socket.__liveDeskSecurityContext = Object.freeze({
|
|
1089
|
+
protocol: SECURE_SESSION_PROTOCOL,
|
|
1090
|
+
accountId,
|
|
1091
|
+
hubId: authority.hubId,
|
|
1092
|
+
deviceId,
|
|
1093
|
+
channel,
|
|
1094
|
+
credentialSerial: issued.payload.serial,
|
|
1095
|
+
authenticated: true,
|
|
1096
|
+
encrypted: true,
|
|
1097
|
+
transport: 'relay'
|
|
1098
|
+
});
|
|
1099
|
+
peers.set(peerId, peer);
|
|
1100
|
+
counters.peerOwnersCreated += 1;
|
|
1101
|
+
peerOwnerHighWater = Math.max(peerOwnerHighWater, peers.size);
|
|
1102
|
+
if (!enqueueEnvelope(response, peerId)) {
|
|
1103
|
+
closePeer(peerId, 'relay-handshake-backpressure', { notify: false });
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
onPeerSocket(peer.socket, {
|
|
1107
|
+
protocol: RELAY_CONTROL_PROTOCOL,
|
|
1108
|
+
securityProtocol: SECURE_SESSION_PROTOCOL,
|
|
1109
|
+
peerId,
|
|
1110
|
+
endpoint: `${host}:${port}`,
|
|
1111
|
+
accountId,
|
|
1112
|
+
hubId: authority.hubId,
|
|
1113
|
+
deviceId,
|
|
1114
|
+
channel
|
|
1115
|
+
});
|
|
1116
|
+
authority.markConnected(deviceId);
|
|
1117
|
+
counters.handshakes += 1;
|
|
1118
|
+
} catch (error) {
|
|
1119
|
+
counters.rejectedPeers += 1;
|
|
1120
|
+
if (peerId) {
|
|
1121
|
+
rememberRetiredPeer(peerId);
|
|
1122
|
+
sendRelayClose(peerId, safeText(error?.code || error?.message, 80) || 'relay-handshake-rejected');
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
920
1127
|
function handleClientData(message) {
|
|
921
1128
|
let peerId = '';
|
|
922
1129
|
try {
|
|
@@ -1374,3 +1581,122 @@ export function createHubRelayControl({
|
|
|
1374
1581
|
};
|
|
1375
1582
|
return api;
|
|
1376
1583
|
}
|
|
1584
|
+
|
|
1585
|
+
export function createSecureHubRelayControl({
|
|
1586
|
+
env = process.env,
|
|
1587
|
+
authority,
|
|
1588
|
+
getIdentity = () => ({}),
|
|
1589
|
+
getEnrollmentToken = () => '',
|
|
1590
|
+
consumeEnrollmentToken = () => false,
|
|
1591
|
+
onPeerSocket = () => {},
|
|
1592
|
+
logEvent = () => {},
|
|
1593
|
+
logWarn = () => {}
|
|
1594
|
+
} = {}) {
|
|
1595
|
+
if (!authority?.hubId || !authority?.hubPublicKey) throw relayError('relay-device-authority-required');
|
|
1596
|
+
let started = false;
|
|
1597
|
+
let closed = false;
|
|
1598
|
+
let enrollment = null;
|
|
1599
|
+
const retired = new Set();
|
|
1600
|
+
const retirementTimers = new Set();
|
|
1601
|
+
const stable = createHubRelayControl({
|
|
1602
|
+
env,
|
|
1603
|
+
pairToken: authority.hubPublicKey,
|
|
1604
|
+
secureSession: { mode: 'credential', authority, getIdentity },
|
|
1605
|
+
onPeerSocket,
|
|
1606
|
+
logEvent,
|
|
1607
|
+
logWarn
|
|
1608
|
+
});
|
|
1609
|
+
|
|
1610
|
+
function makeEnrollmentControl(token) {
|
|
1611
|
+
return createHubRelayControl({
|
|
1612
|
+
env,
|
|
1613
|
+
pairToken: token,
|
|
1614
|
+
secureSession: {
|
|
1615
|
+
mode: 'enroll',
|
|
1616
|
+
authority,
|
|
1617
|
+
getIdentity,
|
|
1618
|
+
getEnrollmentToken: () => token,
|
|
1619
|
+
consumeEnrollmentToken: expected => {
|
|
1620
|
+
if (expected !== token || !consumeEnrollmentToken(expected)) return false;
|
|
1621
|
+
queueMicrotask(() => { void rotateEnrollment(); });
|
|
1622
|
+
return true;
|
|
1623
|
+
}
|
|
1624
|
+
},
|
|
1625
|
+
onPeerSocket,
|
|
1626
|
+
logEvent,
|
|
1627
|
+
logWarn
|
|
1628
|
+
});
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
function retireControl(control) {
|
|
1632
|
+
if (!control) return;
|
|
1633
|
+
retired.add(control);
|
|
1634
|
+
const deadline = Date.now() + 10_000;
|
|
1635
|
+
const poll = () => {
|
|
1636
|
+
if (closed || control.getStatus().peerCount === 0 || Date.now() >= deadline) {
|
|
1637
|
+
retired.delete(control);
|
|
1638
|
+
void control.close().catch(error => logWarn('relay', `Secure enrollment relay retirement failed code=${safeText(error?.code, 80) || 'unknown'}`));
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
const timer = setTimeout(() => {
|
|
1642
|
+
retirementTimers.delete(timer);
|
|
1643
|
+
poll();
|
|
1644
|
+
}, 100);
|
|
1645
|
+
timer.unref?.();
|
|
1646
|
+
retirementTimers.add(timer);
|
|
1647
|
+
};
|
|
1648
|
+
poll();
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
async function rotateEnrollment() {
|
|
1652
|
+
if (closed) return;
|
|
1653
|
+
const previous = enrollment;
|
|
1654
|
+
enrollment = makeEnrollmentControl(getEnrollmentToken());
|
|
1655
|
+
if (started) await enrollment.start();
|
|
1656
|
+
retireControl(previous);
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
async function start() {
|
|
1660
|
+
if (started || closed) return getStatus();
|
|
1661
|
+
started = true;
|
|
1662
|
+
enrollment = makeEnrollmentControl(getEnrollmentToken());
|
|
1663
|
+
await Promise.all([stable.start(), enrollment.start()]);
|
|
1664
|
+
return getStatus();
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
async function close() {
|
|
1668
|
+
if (closed) return;
|
|
1669
|
+
closed = true;
|
|
1670
|
+
started = false;
|
|
1671
|
+
for (const timer of retirementTimers) clearTimeout(timer);
|
|
1672
|
+
retirementTimers.clear();
|
|
1673
|
+
const controls = [stable, enrollment, ...retired].filter(Boolean);
|
|
1674
|
+
retired.clear();
|
|
1675
|
+
enrollment = null;
|
|
1676
|
+
const results = await Promise.allSettled(controls.map(control => control.close()));
|
|
1677
|
+
const failure = results.find(result => result.status === 'rejected');
|
|
1678
|
+
if (failure) throw failure.reason;
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
function getStatus() {
|
|
1682
|
+
const stableStatus = stable.getStatus();
|
|
1683
|
+
const enrollmentStatus = enrollment?.getStatus() || null;
|
|
1684
|
+
const retiredStatuses = [...retired].map(control => control.getStatus());
|
|
1685
|
+
const statuses = [stableStatus, enrollmentStatus, ...retiredStatuses].filter(Boolean);
|
|
1686
|
+
return {
|
|
1687
|
+
...stableStatus,
|
|
1688
|
+
started,
|
|
1689
|
+
connected: statuses.some(status => status.connected),
|
|
1690
|
+
state: stableStatus.connected ? 'registered' : enrollmentStatus?.state || stableStatus.state,
|
|
1691
|
+
peerCount: statuses.reduce((sum, status) => sum + Number(status.peerCount || 0), 0),
|
|
1692
|
+
activeSocketCount: statuses.reduce((sum, status) => sum + Number(status.activeSocketCount || 0), 0),
|
|
1693
|
+
secureCredentialRelay: true,
|
|
1694
|
+
credentialRoomId: deriveCredentialRelayRoomId(authority.hubId, authority.hubPublicKey),
|
|
1695
|
+
enrollmentGenerationCount: 1 + retiredStatuses.length,
|
|
1696
|
+
enrollment: enrollmentStatus,
|
|
1697
|
+
retiredEnrollmentCount: retiredStatuses.length
|
|
1698
|
+
};
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
return Object.freeze({ start, close, getStatus });
|
|
1702
|
+
}
|