@livedesk/hub 0.1.29 → 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.
@@ -0,0 +1,432 @@
1
+ import crypto from 'node:crypto';
2
+ import {
3
+ SECURE_SESSION_MAX_CLOCK_SKEW_MS,
4
+ SECURE_SESSION_MAX_HANDSHAKE_BYTES,
5
+ SECURE_SESSION_PROTOCOL,
6
+ SecureRecordSocket,
7
+ decodeCanonicalBase64Url,
8
+ deriveSecureSessionKey,
9
+ enrollmentProof,
10
+ fixedTimeBase64UrlEqual,
11
+ normalizeSecureChannel,
12
+ secureClientTranscript,
13
+ secureServerTranscript
14
+ } from '@livedesk/runtime-core';
15
+
16
+ const HANDSHAKE_TIMEOUT_MS = 5_000;
17
+ const REPLAY_TTL_MS = 2 * 60 * 1000;
18
+ const MAX_REPLAY_ENTRIES = 8192;
19
+ const DEFAULT_MAX_PENDING_HANDSHAKES = 128;
20
+ const DEFAULT_MAX_CONNECTIONS_PER_IP_PER_MINUTE = 60;
21
+ const DEFAULT_MAX_CONSECUTIVE_FAILURES = 8;
22
+ const DEFAULT_IP_BLOCK_MS = 60_000;
23
+ const MAX_TRACKED_IPS = 4096;
24
+
25
+ function directError(code) {
26
+ const error = new Error(code);
27
+ error.code = code;
28
+ return error;
29
+ }
30
+
31
+ function clean(value, maximum = 256) {
32
+ return String(value || '').replace(/[\0\r\n]/g, '').trim().slice(0, maximum);
33
+ }
34
+
35
+ function requireP256PublicKey(value) {
36
+ const der = decodeCanonicalBase64Url(value, 80, 160);
37
+ let key;
38
+ try {
39
+ key = crypto.createPublicKey({ key: der, format: 'der', type: 'spki' });
40
+ } catch {
41
+ throw directError('secure-public-key-invalid');
42
+ }
43
+ if (key.asymmetricKeyType !== 'ec' || key.asymmetricKeyDetails?.namedCurve !== 'prime256v1') {
44
+ throw directError('secure-public-key-invalid');
45
+ }
46
+ return { key, text: der.toString('base64url') };
47
+ }
48
+
49
+ function verifyP256Signature(publicKey, message, signature) {
50
+ const bytes = decodeCanonicalBase64Url(signature, 64, 64);
51
+ if (!crypto.verify('sha256', Buffer.from(String(message || ''), 'utf8'), {
52
+ key: publicKey,
53
+ dsaEncoding: 'ieee-p1363'
54
+ }, bytes)) {
55
+ throw directError('device-signature-invalid');
56
+ }
57
+ }
58
+
59
+ function safeHandshakeError(error) {
60
+ const code = clean(error?.code || error?.message, 100).toLowerCase();
61
+ return /^[-a-z0-9]+$/.test(code) ? code : 'secure-handshake-rejected';
62
+ }
63
+
64
+ export function createSecureDirectAcceptor({
65
+ authority,
66
+ getIdentity = () => ({}),
67
+ getEnrollmentToken = () => '',
68
+ consumeEnrollmentToken = () => false,
69
+ onSecureSocket = () => {},
70
+ onAudit = () => {},
71
+ logWarn = () => {},
72
+ now = () => Date.now(),
73
+ abuseLimits = {}
74
+ } = {}) {
75
+ if (!authority?.verifyCredential || !authority?.issueCredential || !authority?.signHubMessage) {
76
+ throw directError('secure-device-authority-required');
77
+ }
78
+ const replayCache = new Map();
79
+ const ipStates = new Map();
80
+ const maxPendingHandshakes = Math.max(1, Number(abuseLimits.maxPendingHandshakes)
81
+ || DEFAULT_MAX_PENDING_HANDSHAKES);
82
+ const maxConnectionsPerIpPerMinute = Math.max(1, Number(abuseLimits.maxConnectionsPerIpPerMinute)
83
+ || DEFAULT_MAX_CONNECTIONS_PER_IP_PER_MINUTE);
84
+ const maxConsecutiveFailures = Math.max(1, Number(abuseLimits.maxConsecutiveFailures)
85
+ || DEFAULT_MAX_CONSECUTIVE_FAILURES);
86
+ const ipBlockMs = Math.max(1_000, Number(abuseLimits.ipBlockMs) || DEFAULT_IP_BLOCK_MS);
87
+ let acceptedSessions = 0;
88
+ let rejectedSessions = 0;
89
+ let enrollmentCount = 0;
90
+ let resumedSessions = 0;
91
+ let replayRejections = 0;
92
+ let pendingHandshakes = 0;
93
+ let capacityRejections = 0;
94
+ let rateLimitRejections = 0;
95
+ let blockedIpRejections = 0;
96
+
97
+ function remoteAddress(rawSocket) {
98
+ return clean(rawSocket?.remoteAddress, 100).replace(/^::ffff:/, '') || 'unknown';
99
+ }
100
+
101
+ function pruneIpStates(current) {
102
+ for (const [ip, state] of ipStates) {
103
+ if (current - state.lastSeenAt >= Math.max(ipBlockMs * 2, 5 * 60_000)) {
104
+ ipStates.delete(ip);
105
+ }
106
+ }
107
+ while (ipStates.size > MAX_TRACKED_IPS) {
108
+ const oldest = ipStates.keys().next().value;
109
+ if (!oldest) break;
110
+ ipStates.delete(oldest);
111
+ }
112
+ }
113
+
114
+ function getIpState(ip, current) {
115
+ pruneIpStates(current);
116
+ let state = ipStates.get(ip);
117
+ if (!state) {
118
+ state = {
119
+ windowStartedAt: current,
120
+ connectionCount: 0,
121
+ consecutiveFailures: 0,
122
+ blockedUntil: 0,
123
+ lastSeenAt: current
124
+ };
125
+ ipStates.set(ip, state);
126
+ }
127
+ state.lastSeenAt = current;
128
+ if (current - state.windowStartedAt >= 60_000) {
129
+ state.windowStartedAt = current;
130
+ state.connectionCount = 0;
131
+ }
132
+ return state;
133
+ }
134
+
135
+ function admissionError(ip, current) {
136
+ const state = getIpState(ip, current);
137
+ if (state.blockedUntil > current) {
138
+ blockedIpRejections += 1;
139
+ return 'secure-ip-temporarily-blocked';
140
+ }
141
+ if (pendingHandshakes >= maxPendingHandshakes) {
142
+ capacityRejections += 1;
143
+ return 'secure-handshake-capacity';
144
+ }
145
+ state.connectionCount += 1;
146
+ if (state.connectionCount > maxConnectionsPerIpPerMinute) {
147
+ rateLimitRejections += 1;
148
+ return 'secure-ip-rate-limited';
149
+ }
150
+ return '';
151
+ }
152
+
153
+ function recordFailure(ip, current) {
154
+ const state = getIpState(ip, current);
155
+ state.consecutiveFailures += 1;
156
+ if (state.consecutiveFailures >= maxConsecutiveFailures) {
157
+ state.blockedUntil = Math.max(state.blockedUntil, current + ipBlockMs);
158
+ }
159
+ }
160
+
161
+ function recordSuccess(ip, current) {
162
+ const state = getIpState(ip, current);
163
+ state.consecutiveFailures = 0;
164
+ state.blockedUntil = 0;
165
+ }
166
+
167
+ function rejectSocket(rawSocket, code, ip, observedDeviceId = '') {
168
+ rejectedSessions += 1;
169
+ onAudit({
170
+ transport: 'direct-tcp',
171
+ action: code === 'secure-ip-rate-limited'
172
+ || code === 'secure-ip-temporarily-blocked'
173
+ || code === 'secure-handshake-capacity'
174
+ ? 'remote.abuse-defense'
175
+ : 'remote.handshake',
176
+ result: 'rejected',
177
+ reason: code,
178
+ deviceId: observedDeviceId,
179
+ remoteAddress: ip
180
+ });
181
+ try { rawSocket.write(`${JSON.stringify({ type: 'secure.error', protocol: SECURE_SESSION_PROTOCOL, error: code })}\n`); } catch {}
182
+ logWarn('security', `Secure Direct handshake rejected reason=${code}`);
183
+ rawSocket.destroy();
184
+ }
185
+
186
+ function pruneReplay(current) {
187
+ for (const [key, expiresAt] of replayCache) {
188
+ if (expiresAt > current && replayCache.size <= MAX_REPLAY_ENTRIES) break;
189
+ replayCache.delete(key);
190
+ }
191
+ }
192
+
193
+ function claimReplayOwner(message, current) {
194
+ const nonce = decodeCanonicalBase64Url(message.nonce, 16, 32).toString('base64url');
195
+ const owner = crypto.createHash('sha256')
196
+ .update(String(message.credential || getEnrollmentToken() || ''), 'utf8')
197
+ .digest('base64url');
198
+ const key = `${owner}:${nonce}`;
199
+ pruneReplay(current);
200
+ if (replayCache.has(key)) {
201
+ replayRejections += 1;
202
+ throw directError('secure-handshake-replay');
203
+ }
204
+ if (replayCache.size >= MAX_REPLAY_ENTRIES) throw directError('secure-replay-cache-capacity');
205
+ replayCache.set(key, current + REPLAY_TTL_MS);
206
+ }
207
+
208
+ function handleHello(rawSocket, hello, remainder) {
209
+ if (hello?.type !== 'secure.client-hello' || hello?.protocol !== SECURE_SESSION_PROTOCOL) {
210
+ throw directError('secure-client-hello-required');
211
+ }
212
+ const current = Math.floor(now());
213
+ const timestamp = Number(hello.timestamp);
214
+ if (!Number.isSafeInteger(timestamp) || Math.abs(current - timestamp) > SECURE_SESSION_MAX_CLOCK_SKEW_MS) {
215
+ throw directError('secure-handshake-timestamp-invalid');
216
+ }
217
+ claimReplayOwner(hello, current);
218
+ const channel = normalizeSecureChannel(hello.channel);
219
+ const deviceId = clean(hello.deviceId, 128);
220
+ if (!deviceId) throw directError('device-id-required');
221
+ const devicePublic = requireP256PublicKey(hello.devicePublicKey);
222
+ const clientEphemeral = requireP256PublicKey(hello.clientEphemeralPublicKey);
223
+ const clientTranscript = secureClientTranscript(hello);
224
+ const identity = getIdentity() || {};
225
+ const accountId = clean(identity.accountId, 128);
226
+ if (!accountId) throw directError('secure-account-session-required');
227
+
228
+ let credential;
229
+ let credentialPayload;
230
+ let enrollmentSecret = '';
231
+ if (hello.mode === 'enroll') {
232
+ if (channel !== 'control') throw directError('secure-enrollment-control-channel-required');
233
+ const token = String(getEnrollmentToken() || '');
234
+ enrollmentSecret = token;
235
+ const expectedProof = enrollmentProof(token, clientTranscript);
236
+ if (!fixedTimeBase64UrlEqual(hello.enrollmentProof, expectedProof, 32)) {
237
+ throw directError('secure-enrollment-proof-invalid');
238
+ }
239
+ verifyP256Signature(devicePublic.key, clientTranscript, hello.deviceSignature);
240
+ if (!consumeEnrollmentToken(token)) throw directError('secure-enrollment-token-used');
241
+ const issued = authority.issueCredential({ accountId, deviceId, devicePublicKey: devicePublic.text, replace: true });
242
+ credential = issued.credential;
243
+ credentialPayload = issued.payload;
244
+ enrollmentCount += 1;
245
+ } else if (hello.mode === 'resume') {
246
+ const verified = authority.verifyCredential(hello.credential, { accountId, deviceId });
247
+ if (verified.payload.devicePublicKey !== devicePublic.text) throw directError('device-credential-binding-invalid');
248
+ authority.verifyDeviceSignature(verified, clientTranscript, hello.deviceSignature);
249
+ credential = verified.text;
250
+ credentialPayload = verified.payload;
251
+ resumedSessions += 1;
252
+ } else {
253
+ throw directError('secure-handshake-mode-invalid');
254
+ }
255
+
256
+ const { privateKey: serverEphemeralPrivate, publicKey: serverEphemeralPublic } = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
257
+ const sessionId = crypto.randomBytes(16).toString('base64url');
258
+ const response = {
259
+ type: 'secure.server-hello',
260
+ protocol: SECURE_SESSION_PROTOCOL,
261
+ sessionId,
262
+ channel,
263
+ timestamp: current,
264
+ serverNonce: crypto.randomBytes(16).toString('base64url'),
265
+ serverEphemeralPublicKey: serverEphemeralPublic.export({ format: 'der', type: 'spki' }).toString('base64url'),
266
+ credential,
267
+ hubId: authority.hubId,
268
+ accountId,
269
+ deviceId
270
+ };
271
+ const serverTranscript = secureServerTranscript(response, clientTranscript);
272
+ response.hubSignature = authority.signHubMessage(serverTranscript);
273
+ if (hello.mode === 'enroll') {
274
+ response.enrollmentServerProof = enrollmentProof(enrollmentSecret, serverTranscript);
275
+ }
276
+ const sharedSecret = crypto.diffieHellman({ privateKey: serverEphemeralPrivate, publicKey: clientEphemeral.key });
277
+ let sessionKey;
278
+ try {
279
+ sessionKey = deriveSecureSessionKey({ sharedSecret, clientTranscript, serverTranscript, sessionId, channel });
280
+ } finally {
281
+ sharedSecret.fill(0);
282
+ }
283
+ const secureSocket = new SecureRecordSocket(rawSocket, {
284
+ sessionKey,
285
+ sessionId,
286
+ channel,
287
+ role: 'hub',
288
+ securityContext: {
289
+ protocol: SECURE_SESSION_PROTOCOL,
290
+ accountId,
291
+ hubId: authority.hubId,
292
+ deviceId,
293
+ credentialSerial: credentialPayload.serial,
294
+ authenticated: true,
295
+ encrypted: true
296
+ }
297
+ });
298
+ sessionKey.fill(0);
299
+ secureSocket.__liveDeskDirectSecure = true;
300
+ rawSocket.write(`${JSON.stringify(response)}\n`);
301
+ onSecureSocket(secureSocket, {
302
+ protocol: SECURE_SESSION_PROTOCOL,
303
+ accountId,
304
+ hubId: authority.hubId,
305
+ deviceId,
306
+ channel,
307
+ credentialSerial: credentialPayload.serial,
308
+ enrolled: hello.mode === 'enroll'
309
+ });
310
+ onAudit({
311
+ transport: 'direct-tcp',
312
+ action: hello.mode === 'enroll' ? 'remote.device.enroll' : 'remote.device.resume',
313
+ result: 'accepted',
314
+ accountId,
315
+ hubId: authority.hubId,
316
+ deviceId,
317
+ sessionId,
318
+ channel,
319
+ credentialSerial: credentialPayload.serial
320
+ });
321
+ authority.markConnected(deviceId);
322
+ acceptedSessions += 1;
323
+ if (remainder.length > 0) secureSocket.feedEncrypted(remainder);
324
+ }
325
+
326
+ function accept(rawSocket, firstChunk = null) {
327
+ const ip = remoteAddress(rawSocket);
328
+ const admittedAt = Math.floor(now());
329
+ const denied = admissionError(ip, admittedAt);
330
+ if (denied) {
331
+ rejectSocket(rawSocket, denied, ip);
332
+ return false;
333
+ }
334
+ pendingHandshakes += 1;
335
+ let chunks = [];
336
+ let bytes = 0;
337
+ let settled = false;
338
+ let pendingHeld = true;
339
+ let observedDeviceId = '';
340
+ const timer = setTimeout(() => fail(directError('secure-handshake-timeout')), HANDSHAKE_TIMEOUT_MS);
341
+ timer.unref?.();
342
+
343
+ const releasePending = () => {
344
+ if (!pendingHeld) return;
345
+ pendingHeld = false;
346
+ pendingHandshakes = Math.max(0, pendingHandshakes - 1);
347
+ };
348
+
349
+ const cleanup = () => {
350
+ clearTimeout(timer);
351
+ rawSocket.removeListener('data', onData);
352
+ rawSocket.removeListener('close', onClose);
353
+ releasePending();
354
+ };
355
+
356
+ const fail = error => {
357
+ if (settled) return;
358
+ settled = true;
359
+ cleanup();
360
+ const code = safeHandshakeError(error);
361
+ recordFailure(ip, Math.floor(now()));
362
+ rejectSocket(rawSocket, code, ip, observedDeviceId);
363
+ };
364
+
365
+ const onClose = () => {
366
+ if (!settled) fail(directError('secure-handshake-closed'));
367
+ };
368
+
369
+ const onData = chunk => {
370
+ if (settled) return;
371
+ const incoming = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk || []);
372
+ bytes += incoming.length;
373
+ if (bytes > SECURE_SESSION_MAX_HANDSHAKE_BYTES) {
374
+ fail(directError('secure-handshake-too-large'));
375
+ return;
376
+ }
377
+ chunks.push(incoming);
378
+ const combined = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks, bytes);
379
+ const newline = combined.indexOf(0x0a);
380
+ if (newline < 0) return;
381
+ settled = true;
382
+ cleanup();
383
+ let hello;
384
+ try {
385
+ hello = JSON.parse(combined.subarray(0, newline).toString('utf8'));
386
+ observedDeviceId = clean(hello?.deviceId, 128);
387
+ } catch {
388
+ settled = false;
389
+ fail(directError('secure-handshake-json-invalid'));
390
+ return;
391
+ }
392
+ try {
393
+ handleHello(rawSocket, hello, combined.subarray(newline + 1));
394
+ recordSuccess(ip, Math.floor(now()));
395
+ } catch (error) {
396
+ // settled is reset only for the common failure path so it can emit the
397
+ // bounded rejection response and release the raw socket.
398
+ settled = false;
399
+ fail(error);
400
+ }
401
+ };
402
+
403
+ rawSocket.on('data', onData);
404
+ rawSocket.once('close', onClose);
405
+ if (firstChunk) onData(firstChunk);
406
+ }
407
+
408
+ return Object.freeze({
409
+ accept,
410
+ getStatus: () => ({
411
+ protocol: SECURE_SESSION_PROTOCOL,
412
+ acceptedSessions,
413
+ rejectedSessions,
414
+ enrollmentCount,
415
+ resumedSessions,
416
+ replayCacheSize: replayCache.size,
417
+ replayRejections,
418
+ pendingHandshakes,
419
+ trackedIpCount: ipStates.size,
420
+ capacityRejections,
421
+ rateLimitRejections,
422
+ blockedIpRejections,
423
+ limits: {
424
+ maxPendingHandshakes,
425
+ maxConnectionsPerIpPerMinute,
426
+ maxConsecutiveFailures,
427
+ ipBlockMs,
428
+ handshakeTimeoutMs: HANDSHAKE_TIMEOUT_MS
429
+ }
430
+ })
431
+ });
432
+ }
@@ -96,7 +96,8 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
96
96
  let rendezvousAddresses = new Set();
97
97
  let rendezvousRefreshTimer = null;
98
98
  let rendezvousRefreshPromise = null;
99
- let frameExpiryTimer = null;
99
+ let frameExpiryTimer = null;
100
+ let rendezvousProofIssuer = null;
100
101
  const peerProbeBurstDelaysMs = [0, 120, 360];
101
102
 
102
103
  function emit(type, event = {}) {
@@ -440,6 +441,12 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
440
441
  udpSessionId = crypto.randomUUID();
441
442
  routeKey = udpSessionRouteKey(udpSessionId);
442
443
  } while (sessionsByRouteKey.has(routeKey));
444
+ const proof = typeof rendezvousProofIssuer === 'function'
445
+ ? rendezvousProofIssuer({ roomId: udpSessionId, deviceId: id, ttlMs: 60_000 })
446
+ : null;
447
+ const signedRendezvousProof = safeText(proof?.proof, 2048);
448
+ const allowLegacyUnsignedProofForTests = env.LIVEDESK_TEST_MODE === '1'
449
+ || env.LIVEDESK_TEST_ALLOW_LEGACY_UDP_RENDEZVOUS === '1';
443
450
  const session = {
444
451
  deviceId: id,
445
452
  tcpSessionId: safeText(tcpSessionId, 160),
@@ -448,7 +455,9 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
448
455
  routeKey,
449
456
  key,
450
457
  keyBase64: key.toString('base64'),
451
- rendezvousToken: crypto.randomBytes(16).toString('hex'),
458
+ rendezvousToken: signedRendezvousProof
459
+ || (allowLegacyUnsignedProofForTests ? crypto.randomBytes(16).toString('hex') : ''),
460
+ rendezvousProofExpiresAt: Number(proof?.payload?.expiresAt || 0),
452
461
  sendControl,
453
462
  clientEndpoint: null,
454
463
  peerEndpoint: null,
@@ -484,9 +493,9 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
484
493
  frameChunkHeader: UDP_FRAME_CHUNK_PROTOCOL,
485
494
  frameTimeoutMs,
486
495
  maxPendingFrames,
487
- rendezvous: rendezvousHost
488
- ? { host: rendezvousHost, port: rendezvousPort, roomId: session.sessionId, token: session.rendezvousToken }
489
- : null
496
+ rendezvous: rendezvousHost && session.rendezvousToken
497
+ ? { host: rendezvousHost, port: rendezvousPort, roomId: session.sessionId, token: session.rendezvousToken }
498
+ : null
490
499
  });
491
500
  sendRendezvousRegister(session);
492
501
  emit('UdpSessionCreated', { deviceId: id, state: 'udp-session-created' });
@@ -615,6 +624,9 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
615
624
  refreshDeviceDiagnosticNetwork,
616
625
  getDeviceDiagnosticNetwork,
617
626
  getStatus,
627
+ setRendezvousProofIssuer: issuer => {
628
+ rendezvousProofIssuer = typeof issuer === 'function' ? issuer : null;
629
+ },
618
630
  setFrameHandler: handler => { frameHandler = typeof handler === 'function' ? handler : () => {}; },
619
631
  setEventHandler: handler => { eventHandler = typeof handler === 'function' ? handler : () => {}; }
620
632
  };
@@ -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,52 @@ 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 verifyRendezvousProof(proof, roomId, current) {
66
+ try {
67
+ const parts = String(proof || '').split('.');
68
+ if (parts.length !== 2) return false;
69
+ const payloadBytes = decodeCanonicalBase64Url(parts[0], 64, 1536);
70
+ const signature = decodeCanonicalBase64Url(parts[1], 64, 64);
71
+ if (!payloadBytes || !signature) return false;
72
+ const payload = JSON.parse(payloadBytes.toString('utf8'));
73
+ if (!payload || payload.version !== 1
74
+ || payload.protocol !== 'livedesk.udp.rendezvous-proof.v1'
75
+ || payload.roomId !== roomId
76
+ || !safeText(payload.accountId, 128)
77
+ || !safeText(payload.hubId, 128)
78
+ || !safeText(payload.deviceId, 128)
79
+ || !Number.isSafeInteger(payload.issuedAt)
80
+ || !Number.isSafeInteger(payload.expiresAt)
81
+ || payload.issuedAt > current + 30_000
82
+ || payload.expiresAt <= current
83
+ || payload.expiresAt <= payload.issuedAt
84
+ || payload.expiresAt - payload.issuedAt > 120_000
85
+ || !decodeCanonicalBase64Url(payload.nonce, 16, 32)) {
86
+ return false;
87
+ }
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,
95
+ dsaEncoding: 'ieee-p1363'
96
+ }, signature);
97
+ } catch {
98
+ return false;
99
+ }
100
+ }
101
+
55
102
  export function createUdpRendezvousServer({
56
103
  host = '0.0.0.0',
57
104
  port = 5199,
@@ -68,7 +115,8 @@ export function createUdpRendezvousServer({
68
115
  perSourceRate = DEFAULT_LIMITS.perSourceRate,
69
116
  perSourceBurst = DEFAULT_LIMITS.perSourceBurst,
70
117
  globalRate = DEFAULT_LIMITS.globalRate,
71
- globalBurst = DEFAULT_LIMITS.globalBurst
118
+ globalBurst = DEFAULT_LIMITS.globalBurst,
119
+ allowLegacyTokens = false
72
120
  } = {}) {
73
121
  const socket = dgram.createSocket('udp4');
74
122
  const rooms = new Map();
@@ -264,11 +312,15 @@ export function createUdpRendezvousServer({
264
312
  }
265
313
  const roomId = safeText(message.roomId, 160);
266
314
  const role = safeText(message.role, 16).toLowerCase();
267
- const token = safeText(message.token, 256);
315
+ const token = safeText(message.token, 2048);
268
316
  if (!roomId || !token || !['hub', 'client'].includes(role)) {
269
317
  counters.invalidDatagrams += 1;
270
318
  return;
271
319
  }
320
+ if (!allowLegacyTokens && !verifyRendezvousProof(token, roomId, at)) {
321
+ counters.invalidDatagrams += 1;
322
+ return;
323
+ }
272
324
  let room = rooms.get(roomId);
273
325
  if (room && at - room.updatedAt >= limits.roomTtlMs) {
274
326
  deleteRoom(roomId, 'expired');