@livedesk/hub 0.1.31 → 0.1.33
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 +32 -32
- package/src/filesystem/transfer-jobs.js +5 -1
- package/src/remote-hub.js +721 -658
- package/src/security/device-credential-authority.js +75 -27
- package/src/security/security-audit-store.js +24 -2
- package/src/server.js +978 -780
- package/src/settings/settings-schema.js +251 -239
- package/src/settings/settings-store.js +82 -77
- package/src/transport/relay-hub-control.js +2 -1
- package/src/transport/secure-direct-acceptor.js +440 -432
- package/src/transport/udp-hub-transport.js +19 -8
- package/src/transport/udp-rendezvous.js +143 -29
|
@@ -441,12 +441,23 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
|
|
|
441
441
|
udpSessionId = crypto.randomUUID();
|
|
442
442
|
routeKey = udpSessionRouteKey(udpSessionId);
|
|
443
443
|
} while (sessionsByRouteKey.has(routeKey));
|
|
444
|
-
const
|
|
445
|
-
? rendezvousProofIssuer({ roomId: udpSessionId, deviceId: id, ttlMs: 60_000 })
|
|
444
|
+
const hubProof = typeof rendezvousProofIssuer === 'function'
|
|
445
|
+
? rendezvousProofIssuer({ roomId: udpSessionId, deviceId: id, role: 'hub', ttlMs: 60_000 })
|
|
446
446
|
: null;
|
|
447
|
-
const
|
|
447
|
+
const clientProof = typeof rendezvousProofIssuer === 'function'
|
|
448
|
+
? rendezvousProofIssuer({ roomId: udpSessionId, deviceId: id, role: 'client', ttlMs: 60_000 })
|
|
449
|
+
: null;
|
|
450
|
+
const signedHubRendezvousProof = safeText(hubProof?.proof, 2048);
|
|
451
|
+
const signedClientRendezvousProof = safeText(clientProof?.proof, 2048);
|
|
448
452
|
const allowLegacyUnsignedProofForTests = env.LIVEDESK_TEST_MODE === '1'
|
|
449
453
|
|| env.LIVEDESK_TEST_ALLOW_LEGACY_UDP_RENDEZVOUS === '1';
|
|
454
|
+
const legacyRendezvousToken = allowLegacyUnsignedProofForTests
|
|
455
|
+
&& (!signedHubRendezvousProof || !signedClientRendezvousProof)
|
|
456
|
+
? crypto.randomBytes(16).toString('hex')
|
|
457
|
+
: '';
|
|
458
|
+
const proofExpiries = [hubProof?.payload?.expiresAt, clientProof?.payload?.expiresAt]
|
|
459
|
+
.map(Number)
|
|
460
|
+
.filter(Number.isSafeInteger);
|
|
450
461
|
const session = {
|
|
451
462
|
deviceId: id,
|
|
452
463
|
tcpSessionId: safeText(tcpSessionId, 160),
|
|
@@ -455,9 +466,9 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
|
|
|
455
466
|
routeKey,
|
|
456
467
|
key,
|
|
457
468
|
keyBase64: key.toString('base64'),
|
|
458
|
-
rendezvousToken:
|
|
459
|
-
|
|
460
|
-
rendezvousProofExpiresAt:
|
|
469
|
+
rendezvousToken: signedHubRendezvousProof || legacyRendezvousToken,
|
|
470
|
+
clientRendezvousToken: signedClientRendezvousProof || legacyRendezvousToken,
|
|
471
|
+
rendezvousProofExpiresAt: proofExpiries.length === 2 ? Math.min(...proofExpiries) : 0,
|
|
461
472
|
sendControl,
|
|
462
473
|
clientEndpoint: null,
|
|
463
474
|
peerEndpoint: null,
|
|
@@ -493,8 +504,8 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
|
|
|
493
504
|
frameChunkHeader: UDP_FRAME_CHUNK_PROTOCOL,
|
|
494
505
|
frameTimeoutMs,
|
|
495
506
|
maxPendingFrames,
|
|
496
|
-
rendezvous: rendezvousHost && session.
|
|
497
|
-
? { host: rendezvousHost, port: rendezvousPort, roomId: session.sessionId, token: session.
|
|
507
|
+
rendezvous: rendezvousHost && session.clientRendezvousToken
|
|
508
|
+
? { host: rendezvousHost, port: rendezvousPort, roomId: session.sessionId, token: session.clientRendezvousToken }
|
|
498
509
|
: null
|
|
499
510
|
});
|
|
500
511
|
sendRendezvousRegister(session);
|
|
@@ -62,20 +62,52 @@ function decodeCanonicalBase64Url(value, minimum, maximum = minimum) {
|
|
|
62
62
|
: null;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
function
|
|
65
|
+
function trustedIssuerMap(values) {
|
|
66
|
+
const entries = Array.isArray(values)
|
|
67
|
+
? values
|
|
68
|
+
: String(values || '').split(/[\s,]+/u).filter(Boolean);
|
|
69
|
+
if (entries.length > 10_000) throw new Error('udp-rendezvous-trusted-issuer-capacity');
|
|
70
|
+
const issuers = new Map();
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
const publicKeyBytes = decodeCanonicalBase64Url(entry, 80, 160);
|
|
73
|
+
if (!publicKeyBytes) throw new Error('udp-rendezvous-trusted-issuer-invalid');
|
|
74
|
+
let publicKey;
|
|
75
|
+
try {
|
|
76
|
+
publicKey = crypto.createPublicKey({ key: publicKeyBytes, format: 'der', type: 'spki' });
|
|
77
|
+
} catch {
|
|
78
|
+
throw new Error('udp-rendezvous-trusted-issuer-invalid');
|
|
79
|
+
}
|
|
80
|
+
if (publicKey.asymmetricKeyType !== 'ec'
|
|
81
|
+
|| publicKey.asymmetricKeyDetails?.namedCurve !== 'prime256v1') {
|
|
82
|
+
throw new Error('udp-rendezvous-trusted-issuer-invalid');
|
|
83
|
+
}
|
|
84
|
+
const keyId = crypto.createHash('sha256').update(publicKeyBytes).digest('base64url');
|
|
85
|
+
issuers.set(keyId, publicKey);
|
|
86
|
+
}
|
|
87
|
+
return issuers;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function exactSafeText(value, maximum) {
|
|
91
|
+
return typeof value === 'string' && value.length > 0 && safeText(value, maximum) === value;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function verifyRendezvousProof(proof, roomId, role, current, trustedIssuers) {
|
|
66
95
|
try {
|
|
67
96
|
const parts = String(proof || '').split('.');
|
|
68
|
-
if (parts.length !== 2) return false;
|
|
97
|
+
if (parts.length !== 2) return { ok: false, reason: 'invalid-proof' };
|
|
69
98
|
const payloadBytes = decodeCanonicalBase64Url(parts[0], 64, 1536);
|
|
70
99
|
const signature = decodeCanonicalBase64Url(parts[1], 64, 64);
|
|
71
|
-
if (!payloadBytes || !signature) return false;
|
|
100
|
+
if (!payloadBytes || !signature) return { ok: false, reason: 'invalid-proof' };
|
|
72
101
|
const payload = JSON.parse(payloadBytes.toString('utf8'));
|
|
73
|
-
if (!payload || payload.version !==
|
|
74
|
-
|| payload.protocol !== 'livedesk.udp.rendezvous-proof.
|
|
102
|
+
if (!payload || payload.version !== 2
|
|
103
|
+
|| payload.protocol !== 'livedesk.udp.rendezvous-proof.v2'
|
|
75
104
|
|| payload.roomId !== roomId
|
|
76
|
-
|| !
|
|
77
|
-
|| !
|
|
78
|
-
|| !
|
|
105
|
+
|| !exactSafeText(payload.issuerKeyId, 64)
|
|
106
|
+
|| !exactSafeText(payload.roomId, 160)
|
|
107
|
+
|| !exactSafeText(payload.role, 16)
|
|
108
|
+
|| !exactSafeText(payload.accountId, 128)
|
|
109
|
+
|| !exactSafeText(payload.hubId, 128)
|
|
110
|
+
|| !exactSafeText(payload.deviceId, 128)
|
|
79
111
|
|| !Number.isSafeInteger(payload.issuedAt)
|
|
80
112
|
|| !Number.isSafeInteger(payload.expiresAt)
|
|
81
113
|
|| payload.issuedAt > current + 30_000
|
|
@@ -83,19 +115,26 @@ function verifyRendezvousProof(proof, roomId, current) {
|
|
|
83
115
|
|| payload.expiresAt <= payload.issuedAt
|
|
84
116
|
|| payload.expiresAt - payload.issuedAt > 120_000
|
|
85
117
|
|| !decodeCanonicalBase64Url(payload.nonce, 16, 32)) {
|
|
86
|
-
return false;
|
|
118
|
+
return { ok: false, reason: 'invalid-proof' };
|
|
87
119
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
key:
|
|
120
|
+
if (payload.role !== role) return { ok: false, reason: 'role-mismatch' };
|
|
121
|
+
const issuerKeyIdBytes = decodeCanonicalBase64Url(payload.issuerKeyId, 32, 32);
|
|
122
|
+
if (!issuerKeyIdBytes) return { ok: false, reason: 'invalid-proof' };
|
|
123
|
+
const issuerKey = trustedIssuers.get(payload.issuerKeyId);
|
|
124
|
+
if (!issuerKey) return { ok: false, reason: 'untrusted-issuer' };
|
|
125
|
+
const valid = crypto.verify('sha256', Buffer.from(parts[0], 'utf8'), {
|
|
126
|
+
key: issuerKey,
|
|
95
127
|
dsaEncoding: 'ieee-p1363'
|
|
96
128
|
}, signature);
|
|
129
|
+
if (!valid) return { ok: false, reason: 'invalid-signature' };
|
|
130
|
+
return {
|
|
131
|
+
ok: true,
|
|
132
|
+
payload,
|
|
133
|
+
proofKey: `${payload.issuerKeyId}:${payload.nonce}:${payload.roomId}:${payload.role}`,
|
|
134
|
+
proofHash: crypto.createHash('sha256').update(String(proof), 'utf8').digest('base64url')
|
|
135
|
+
};
|
|
97
136
|
} catch {
|
|
98
|
-
return false;
|
|
137
|
+
return { ok: false, reason: 'invalid-proof' };
|
|
99
138
|
}
|
|
100
139
|
}
|
|
101
140
|
|
|
@@ -116,6 +155,7 @@ export function createUdpRendezvousServer({
|
|
|
116
155
|
perSourceBurst = DEFAULT_LIMITS.perSourceBurst,
|
|
117
156
|
globalRate = DEFAULT_LIMITS.globalRate,
|
|
118
157
|
globalBurst = DEFAULT_LIMITS.globalBurst,
|
|
158
|
+
trustedIssuerPublicKeys = [],
|
|
119
159
|
allowLegacyTokens = false
|
|
120
160
|
} = {}) {
|
|
121
161
|
const socket = dgram.createSocket('udp4');
|
|
@@ -123,12 +163,15 @@ export function createUdpRendezvousServer({
|
|
|
123
163
|
const unpairedRooms = new Map();
|
|
124
164
|
const sources = new Map();
|
|
125
165
|
const evictableSources = new Map();
|
|
166
|
+
const consumedProofs = new Map();
|
|
167
|
+
const trustedIssuers = trustedIssuerMap(trustedIssuerPublicKeys);
|
|
126
168
|
const limits = Object.freeze({
|
|
127
169
|
roomTtlMs: normalizeInteger(roomTtlMs, DEFAULT_LIMITS.roomTtlMs),
|
|
128
170
|
sourceTtlMs: normalizeInteger(sourceTtlMs, DEFAULT_LIMITS.sourceTtlMs),
|
|
129
171
|
sweepIntervalMs: normalizeInteger(sweepIntervalMs, DEFAULT_LIMITS.sweepIntervalMs),
|
|
130
172
|
statusIntervalMs: normalizeInteger(statusIntervalMs, DEFAULT_LIMITS.statusIntervalMs, 0),
|
|
131
173
|
maxRooms: normalizeInteger(maxRooms, DEFAULT_LIMITS.maxRooms),
|
|
174
|
+
maxConsumedProofs: Math.min(200_000, normalizeInteger(maxRooms, DEFAULT_LIMITS.maxRooms) * 2),
|
|
132
175
|
maxSources: normalizeInteger(maxSources, DEFAULT_LIMITS.maxSources),
|
|
133
176
|
maxRoomsPerSource: normalizeInteger(maxRoomsPerSource, DEFAULT_LIMITS.maxRoomsPerSource),
|
|
134
177
|
perSourceRate: normalizeRate(perSourceRate, DEFAULT_LIMITS.perSourceRate),
|
|
@@ -151,6 +194,13 @@ export function createUdpRendezvousServer({
|
|
|
151
194
|
sourceRoomLimitDrops: 0,
|
|
152
195
|
roomCapacityDrops: 0,
|
|
153
196
|
tokenMismatchDrops: 0,
|
|
197
|
+
untrustedIssuerDrops: 0,
|
|
198
|
+
roleMismatchDrops: 0,
|
|
199
|
+
bindingMismatchDrops: 0,
|
|
200
|
+
proofReplayDrops: 0,
|
|
201
|
+
endpointOverwriteDrops: 0,
|
|
202
|
+
idempotentRefreshes: 0,
|
|
203
|
+
proofCapacityDrops: 0,
|
|
154
204
|
expiredRooms: 0,
|
|
155
205
|
evictedRooms: 0,
|
|
156
206
|
expiredSources: 0,
|
|
@@ -194,6 +244,9 @@ export function createUdpRendezvousServer({
|
|
|
194
244
|
for (const [roomId, room] of rooms) {
|
|
195
245
|
if (at - room.updatedAt >= limits.roomTtlMs) deleteRoom(roomId, 'expired');
|
|
196
246
|
}
|
|
247
|
+
for (const [proofKey, consumed] of consumedProofs) {
|
|
248
|
+
if (consumed.expiresAt <= at) consumedProofs.delete(proofKey);
|
|
249
|
+
}
|
|
197
250
|
for (const [key, source] of sources) {
|
|
198
251
|
if (source.activeRooms === 0 && at - source.lastSeenAt >= limits.sourceTtlMs) {
|
|
199
252
|
sources.delete(key);
|
|
@@ -259,7 +312,7 @@ export function createUdpRendezvousServer({
|
|
|
259
312
|
});
|
|
260
313
|
}
|
|
261
314
|
|
|
262
|
-
function createRoom(roomId,
|
|
315
|
+
function createRoom(roomId, binding, source, at, legacyToken = '') {
|
|
263
316
|
if (source.activeRooms >= limits.maxRoomsPerSource) {
|
|
264
317
|
counters.sourceRoomLimitDrops += 1;
|
|
265
318
|
return null;
|
|
@@ -274,7 +327,8 @@ export function createUdpRendezvousServer({
|
|
|
274
327
|
}
|
|
275
328
|
}
|
|
276
329
|
const room = {
|
|
277
|
-
|
|
330
|
+
legacyToken,
|
|
331
|
+
binding,
|
|
278
332
|
ownerSource: source.key,
|
|
279
333
|
peers: new Map(),
|
|
280
334
|
createdAt: at,
|
|
@@ -317,8 +371,13 @@ export function createUdpRendezvousServer({
|
|
|
317
371
|
counters.invalidDatagrams += 1;
|
|
318
372
|
return;
|
|
319
373
|
}
|
|
320
|
-
|
|
374
|
+
const verification = allowLegacyTokens
|
|
375
|
+
? { ok: true, legacy: true, token }
|
|
376
|
+
: verifyRendezvousProof(token, roomId, role, at, trustedIssuers);
|
|
377
|
+
if (!verification.ok) {
|
|
321
378
|
counters.invalidDatagrams += 1;
|
|
379
|
+
if (verification.reason === 'untrusted-issuer') counters.untrustedIssuerDrops += 1;
|
|
380
|
+
if (verification.reason === 'role-mismatch') counters.roleMismatchDrops += 1;
|
|
322
381
|
return;
|
|
323
382
|
}
|
|
324
383
|
let room = rooms.get(roomId);
|
|
@@ -326,16 +385,64 @@ export function createUdpRendezvousServer({
|
|
|
326
385
|
deleteRoom(roomId, 'expired');
|
|
327
386
|
room = null;
|
|
328
387
|
}
|
|
329
|
-
if (
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
388
|
+
if (verification.legacy) {
|
|
389
|
+
if (room && room.legacyToken !== token) {
|
|
390
|
+
counters.tokenMismatchDrops += 1;
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (!room) {
|
|
394
|
+
room = createRoom(roomId, null, source, at, token);
|
|
395
|
+
if (!room) return;
|
|
396
|
+
}
|
|
397
|
+
room.updatedAt = at;
|
|
398
|
+
room.peers.set(role, { address: rinfo.address, port: rinfo.port });
|
|
399
|
+
} else {
|
|
400
|
+
const binding = {
|
|
401
|
+
issuerKeyId: verification.payload.issuerKeyId,
|
|
402
|
+
accountId: verification.payload.accountId,
|
|
403
|
+
hubId: verification.payload.hubId,
|
|
404
|
+
deviceId: verification.payload.deviceId
|
|
405
|
+
};
|
|
406
|
+
if (room && JSON.stringify(room.binding) !== JSON.stringify(binding)) {
|
|
407
|
+
counters.bindingMismatchDrops += 1;
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const endpoint = { address: rinfo.address, port: rinfo.port };
|
|
411
|
+
const consumed = consumedProofs.get(verification.proofKey);
|
|
412
|
+
const existingPeer = room?.peers.get(role);
|
|
413
|
+
if (consumed) {
|
|
414
|
+
const sameEndpoint = consumed.address === endpoint.address && consumed.port === endpoint.port;
|
|
415
|
+
const sameOwner = existingPeer?.proofKey === verification.proofKey
|
|
416
|
+
&& existingPeer.address === endpoint.address
|
|
417
|
+
&& existingPeer.port === endpoint.port;
|
|
418
|
+
if (sameEndpoint && sameOwner) {
|
|
419
|
+
room.updatedAt = at;
|
|
420
|
+
counters.idempotentRefreshes += 1;
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
counters.proofReplayDrops += 1;
|
|
424
|
+
if (existingPeer && !sameEndpoint) counters.endpointOverwriteDrops += 1;
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (consumedProofs.size >= limits.maxConsumedProofs) {
|
|
428
|
+
counters.proofCapacityDrops += 1;
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (existingPeer) {
|
|
432
|
+
counters.endpointOverwriteDrops += 1;
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (!room) {
|
|
436
|
+
room = createRoom(roomId, binding, source, at);
|
|
437
|
+
if (!room) return;
|
|
438
|
+
}
|
|
439
|
+
room.updatedAt = at;
|
|
440
|
+
room.peers.set(role, { ...endpoint, proofKey: verification.proofKey, proofHash: verification.proofHash });
|
|
441
|
+
consumedProofs.set(verification.proofKey, {
|
|
442
|
+
...endpoint,
|
|
443
|
+
expiresAt: verification.payload.expiresAt
|
|
444
|
+
});
|
|
336
445
|
}
|
|
337
|
-
room.updatedAt = at;
|
|
338
|
-
room.peers.set(role, { address: rinfo.address, port: rinfo.port });
|
|
339
446
|
counters.acceptedRegistrations += 1;
|
|
340
447
|
if (room.peers.size < 2) {
|
|
341
448
|
unpairedRooms.delete(roomId);
|
|
@@ -391,6 +498,9 @@ export function createUdpRendezvousServer({
|
|
|
391
498
|
unpairedRooms: unpairedRooms.size,
|
|
392
499
|
sources: sources.size,
|
|
393
500
|
evictableSources: evictableSources.size,
|
|
501
|
+
consumedProofs: consumedProofs.size,
|
|
502
|
+
trustedIssuerCount: trustedIssuers.size,
|
|
503
|
+
securityReady: allowLegacyTokens || trustedIssuers.size > 0,
|
|
394
504
|
limits: { ...limits },
|
|
395
505
|
counters: { ...counters },
|
|
396
506
|
lastError
|
|
@@ -419,6 +529,9 @@ export function createUdpRendezvousServer({
|
|
|
419
529
|
|
|
420
530
|
async function start() {
|
|
421
531
|
if (started) return { host, port: boundPort };
|
|
532
|
+
if (!allowLegacyTokens && trustedIssuers.size === 0) {
|
|
533
|
+
throw new Error('udp-rendezvous-trusted-issuer-required');
|
|
534
|
+
}
|
|
422
535
|
await new Promise((resolve, reject) => {
|
|
423
536
|
const onError = error => { socket.off('listening', onListening); reject(error); };
|
|
424
537
|
const onListening = () => { socket.off('error', onError); resolve(); };
|
|
@@ -443,6 +556,7 @@ export function createUdpRendezvousServer({
|
|
|
443
556
|
unpairedRooms.clear();
|
|
444
557
|
sources.clear();
|
|
445
558
|
evictableSources.clear();
|
|
559
|
+
consumedProofs.clear();
|
|
446
560
|
if (started) {
|
|
447
561
|
await new Promise(resolve => socket.close(() => resolve()));
|
|
448
562
|
started = false;
|