@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,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, token, source, at) {
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
- token,
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, 256);
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 (room && room.token !== token) {
278
- counters.tokenMismatchDrops += 1;
279
- return;
280
- }
281
- if (!room) {
282
- room = createRoom(roomId, token, source, at);
283
- 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
+ });
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;