@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.
- 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 +36 -5
- package/src/live-desk-update.js +87 -19
- package/src/remote-hub.js +722 -173
- package/src/security/device-credential-authority.js +406 -0
- package/src/security/security-audit-store.js +260 -0
- package/src/server.js +1012 -277
- package/src/settings/settings-schema.js +19 -39
- package/src/transport/relay-hub-control.js +330 -3
- package/src/transport/secure-direct-acceptor.js +433 -0
- package/src/transport/udp-hub-transport.js +28 -5
- package/src/transport/udp-rendezvous.js +179 -13
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import dgram from 'node:dgram';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
2
3
|
import {
|
|
3
4
|
decodeRendezvousMessage,
|
|
4
5
|
encodeRendezvousMessage,
|
|
@@ -52,6 +53,91 @@ function consumeToken(bucket, rate, capacity, now) {
|
|
|
52
53
|
return true;
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
function decodeCanonicalBase64Url(value, minimum, maximum = minimum) {
|
|
57
|
+
const text = String(value || '');
|
|
58
|
+
if (!/^[A-Za-z0-9_-]+$/u.test(text)) return null;
|
|
59
|
+
const bytes = Buffer.from(text, 'base64url');
|
|
60
|
+
return bytes.length >= minimum && bytes.length <= maximum && bytes.toString('base64url') === text
|
|
61
|
+
? bytes
|
|
62
|
+
: null;
|
|
63
|
+
}
|
|
64
|
+
|
|
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) {
|
|
95
|
+
try {
|
|
96
|
+
const parts = String(proof || '').split('.');
|
|
97
|
+
if (parts.length !== 2) return { ok: false, reason: 'invalid-proof' };
|
|
98
|
+
const payloadBytes = decodeCanonicalBase64Url(parts[0], 64, 1536);
|
|
99
|
+
const signature = decodeCanonicalBase64Url(parts[1], 64, 64);
|
|
100
|
+
if (!payloadBytes || !signature) return { ok: false, reason: 'invalid-proof' };
|
|
101
|
+
const payload = JSON.parse(payloadBytes.toString('utf8'));
|
|
102
|
+
if (!payload || payload.version !== 2
|
|
103
|
+
|| payload.protocol !== 'livedesk.udp.rendezvous-proof.v2'
|
|
104
|
+
|| payload.roomId !== roomId
|
|
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)
|
|
111
|
+
|| !Number.isSafeInteger(payload.issuedAt)
|
|
112
|
+
|| !Number.isSafeInteger(payload.expiresAt)
|
|
113
|
+
|| payload.issuedAt > current + 30_000
|
|
114
|
+
|| payload.expiresAt <= current
|
|
115
|
+
|| payload.expiresAt <= payload.issuedAt
|
|
116
|
+
|| payload.expiresAt - payload.issuedAt > 120_000
|
|
117
|
+
|| !decodeCanonicalBase64Url(payload.nonce, 16, 32)) {
|
|
118
|
+
return { ok: false, reason: 'invalid-proof' };
|
|
119
|
+
}
|
|
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,
|
|
127
|
+
dsaEncoding: 'ieee-p1363'
|
|
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
|
+
};
|
|
136
|
+
} catch {
|
|
137
|
+
return { ok: false, reason: 'invalid-proof' };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
55
141
|
export function createUdpRendezvousServer({
|
|
56
142
|
host = '0.0.0.0',
|
|
57
143
|
port = 5199,
|
|
@@ -68,19 +154,24 @@ export function createUdpRendezvousServer({
|
|
|
68
154
|
perSourceRate = DEFAULT_LIMITS.perSourceRate,
|
|
69
155
|
perSourceBurst = DEFAULT_LIMITS.perSourceBurst,
|
|
70
156
|
globalRate = DEFAULT_LIMITS.globalRate,
|
|
71
|
-
globalBurst = DEFAULT_LIMITS.globalBurst
|
|
157
|
+
globalBurst = DEFAULT_LIMITS.globalBurst,
|
|
158
|
+
trustedIssuerPublicKeys = [],
|
|
159
|
+
allowLegacyTokens = false
|
|
72
160
|
} = {}) {
|
|
73
161
|
const socket = dgram.createSocket('udp4');
|
|
74
162
|
const rooms = new Map();
|
|
75
163
|
const unpairedRooms = new Map();
|
|
76
164
|
const sources = new Map();
|
|
77
165
|
const evictableSources = new Map();
|
|
166
|
+
const consumedProofs = new Map();
|
|
167
|
+
const trustedIssuers = trustedIssuerMap(trustedIssuerPublicKeys);
|
|
78
168
|
const limits = Object.freeze({
|
|
79
169
|
roomTtlMs: normalizeInteger(roomTtlMs, DEFAULT_LIMITS.roomTtlMs),
|
|
80
170
|
sourceTtlMs: normalizeInteger(sourceTtlMs, DEFAULT_LIMITS.sourceTtlMs),
|
|
81
171
|
sweepIntervalMs: normalizeInteger(sweepIntervalMs, DEFAULT_LIMITS.sweepIntervalMs),
|
|
82
172
|
statusIntervalMs: normalizeInteger(statusIntervalMs, DEFAULT_LIMITS.statusIntervalMs, 0),
|
|
83
173
|
maxRooms: normalizeInteger(maxRooms, DEFAULT_LIMITS.maxRooms),
|
|
174
|
+
maxConsumedProofs: Math.min(200_000, normalizeInteger(maxRooms, DEFAULT_LIMITS.maxRooms) * 2),
|
|
84
175
|
maxSources: normalizeInteger(maxSources, DEFAULT_LIMITS.maxSources),
|
|
85
176
|
maxRoomsPerSource: normalizeInteger(maxRoomsPerSource, DEFAULT_LIMITS.maxRoomsPerSource),
|
|
86
177
|
perSourceRate: normalizeRate(perSourceRate, DEFAULT_LIMITS.perSourceRate),
|
|
@@ -103,6 +194,13 @@ export function createUdpRendezvousServer({
|
|
|
103
194
|
sourceRoomLimitDrops: 0,
|
|
104
195
|
roomCapacityDrops: 0,
|
|
105
196
|
tokenMismatchDrops: 0,
|
|
197
|
+
untrustedIssuerDrops: 0,
|
|
198
|
+
roleMismatchDrops: 0,
|
|
199
|
+
bindingMismatchDrops: 0,
|
|
200
|
+
proofReplayDrops: 0,
|
|
201
|
+
endpointOverwriteDrops: 0,
|
|
202
|
+
idempotentRefreshes: 0,
|
|
203
|
+
proofCapacityDrops: 0,
|
|
106
204
|
expiredRooms: 0,
|
|
107
205
|
evictedRooms: 0,
|
|
108
206
|
expiredSources: 0,
|
|
@@ -146,6 +244,9 @@ export function createUdpRendezvousServer({
|
|
|
146
244
|
for (const [roomId, room] of rooms) {
|
|
147
245
|
if (at - room.updatedAt >= limits.roomTtlMs) deleteRoom(roomId, 'expired');
|
|
148
246
|
}
|
|
247
|
+
for (const [proofKey, consumed] of consumedProofs) {
|
|
248
|
+
if (consumed.expiresAt <= at) consumedProofs.delete(proofKey);
|
|
249
|
+
}
|
|
149
250
|
for (const [key, source] of sources) {
|
|
150
251
|
if (source.activeRooms === 0 && at - source.lastSeenAt >= limits.sourceTtlMs) {
|
|
151
252
|
sources.delete(key);
|
|
@@ -211,7 +312,7 @@ export function createUdpRendezvousServer({
|
|
|
211
312
|
});
|
|
212
313
|
}
|
|
213
314
|
|
|
214
|
-
function createRoom(roomId,
|
|
315
|
+
function createRoom(roomId, binding, source, at, legacyToken = '') {
|
|
215
316
|
if (source.activeRooms >= limits.maxRoomsPerSource) {
|
|
216
317
|
counters.sourceRoomLimitDrops += 1;
|
|
217
318
|
return null;
|
|
@@ -226,7 +327,8 @@ export function createUdpRendezvousServer({
|
|
|
226
327
|
}
|
|
227
328
|
}
|
|
228
329
|
const room = {
|
|
229
|
-
|
|
330
|
+
legacyToken,
|
|
331
|
+
binding,
|
|
230
332
|
ownerSource: source.key,
|
|
231
333
|
peers: new Map(),
|
|
232
334
|
createdAt: at,
|
|
@@ -264,26 +366,83 @@ export function createUdpRendezvousServer({
|
|
|
264
366
|
}
|
|
265
367
|
const roomId = safeText(message.roomId, 160);
|
|
266
368
|
const role = safeText(message.role, 16).toLowerCase();
|
|
267
|
-
const token = safeText(message.token,
|
|
369
|
+
const token = safeText(message.token, 2048);
|
|
268
370
|
if (!roomId || !token || !['hub', 'client'].includes(role)) {
|
|
269
371
|
counters.invalidDatagrams += 1;
|
|
270
372
|
return;
|
|
271
373
|
}
|
|
374
|
+
const verification = allowLegacyTokens
|
|
375
|
+
? { ok: true, legacy: true, token }
|
|
376
|
+
: verifyRendezvousProof(token, roomId, role, at, trustedIssuers);
|
|
377
|
+
if (!verification.ok) {
|
|
378
|
+
counters.invalidDatagrams += 1;
|
|
379
|
+
if (verification.reason === 'untrusted-issuer') counters.untrustedIssuerDrops += 1;
|
|
380
|
+
if (verification.reason === 'role-mismatch') counters.roleMismatchDrops += 1;
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
272
383
|
let room = rooms.get(roomId);
|
|
273
384
|
if (room && at - room.updatedAt >= limits.roomTtlMs) {
|
|
274
385
|
deleteRoom(roomId, 'expired');
|
|
275
386
|
room = null;
|
|
276
387
|
}
|
|
277
|
-
if (
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
+
});
|
|
284
445
|
}
|
|
285
|
-
room.updatedAt = at;
|
|
286
|
-
room.peers.set(role, { address: rinfo.address, port: rinfo.port });
|
|
287
446
|
counters.acceptedRegistrations += 1;
|
|
288
447
|
if (room.peers.size < 2) {
|
|
289
448
|
unpairedRooms.delete(roomId);
|
|
@@ -339,6 +498,9 @@ export function createUdpRendezvousServer({
|
|
|
339
498
|
unpairedRooms: unpairedRooms.size,
|
|
340
499
|
sources: sources.size,
|
|
341
500
|
evictableSources: evictableSources.size,
|
|
501
|
+
consumedProofs: consumedProofs.size,
|
|
502
|
+
trustedIssuerCount: trustedIssuers.size,
|
|
503
|
+
securityReady: allowLegacyTokens || trustedIssuers.size > 0,
|
|
342
504
|
limits: { ...limits },
|
|
343
505
|
counters: { ...counters },
|
|
344
506
|
lastError
|
|
@@ -367,6 +529,9 @@ export function createUdpRendezvousServer({
|
|
|
367
529
|
|
|
368
530
|
async function start() {
|
|
369
531
|
if (started) return { host, port: boundPort };
|
|
532
|
+
if (!allowLegacyTokens && trustedIssuers.size === 0) {
|
|
533
|
+
throw new Error('udp-rendezvous-trusted-issuer-required');
|
|
534
|
+
}
|
|
370
535
|
await new Promise((resolve, reject) => {
|
|
371
536
|
const onError = error => { socket.off('listening', onListening); reject(error); };
|
|
372
537
|
const onListening = () => { socket.off('error', onError); resolve(); };
|
|
@@ -391,6 +556,7 @@ export function createUdpRendezvousServer({
|
|
|
391
556
|
unpairedRooms.clear();
|
|
392
557
|
sources.clear();
|
|
393
558
|
evictableSources.clear();
|
|
559
|
+
consumedProofs.clear();
|
|
394
560
|
if (started) {
|
|
395
561
|
await new Promise(resolve => socket.close(() => resolve()));
|
|
396
562
|
started = false;
|