@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.
@@ -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);
@@ -62,20 +62,52 @@ function decodeCanonicalBase64Url(value, minimum, maximum = minimum) {
62
62
  : null;
63
63
  }
64
64
 
65
- function verifyRendezvousProof(proof, roomId, current) {
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 !== 1
74
- || payload.protocol !== 'livedesk.udp.rendezvous-proof.v1'
102
+ if (!payload || payload.version !== 2
103
+ || payload.protocol !== 'livedesk.udp.rendezvous-proof.v2'
75
104
  || payload.roomId !== roomId
76
- || !safeText(payload.accountId, 128)
77
- || !safeText(payload.hubId, 128)
78
- || !safeText(payload.deviceId, 128)
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
- const hubPublicBytes = decodeCanonicalBase64Url(payload.hubPublicKey, 80, 160);
89
- if (!hubPublicBytes) return false;
90
- const hubKey = crypto.createPublicKey({ key: hubPublicBytes, format: 'der', type: 'spki' });
91
- return hubKey.asymmetricKeyType === 'ec'
92
- && hubKey.asymmetricKeyDetails?.namedCurve === 'prime256v1'
93
- && crypto.verify('sha256', Buffer.from(parts[0], 'utf8'), {
94
- key: hubKey,
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, token, source, at) {
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
- token,
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
- if (!allowLegacyTokens && !verifyRendezvousProof(token, roomId, at)) {
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 (room && room.token !== token) {
330
- counters.tokenMismatchDrops += 1;
331
- return;
332
- }
333
- if (!room) {
334
- room = createRoom(roomId, token, source, at);
335
- if (!room) return;
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;