@robota-sdk/agent-transport-webrtc 3.0.0-beta.99 → 3.0.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robota-sdk/agent-transport-webrtc",
3
- "version": "3.0.0-beta.99",
3
+ "version": "3.0.2",
4
4
  "description": "Peer-to-peer WebRTC transport for the Robota SDK (data-channel session bridge for remote control and the device mesh)",
5
5
  "type": "module",
6
6
  "main": "dist/node/index.js",
@@ -44,11 +44,11 @@
44
44
  "multicast-dns": "^7.2.5",
45
45
  "nostr-tools": "^2.25.2",
46
46
  "ws": "^8.21.1",
47
- "@robota-sdk/agent-interface-session": "3.0.0-beta.99",
48
- "@robota-sdk/agent-interface-session-mobility": "3.0.0-beta.99",
49
- "@robota-sdk/agent-remote-pairing": "3.0.0-beta.99",
50
- "@robota-sdk/agent-transport": "3.0.0-beta.99",
51
- "@robota-sdk/agent-interface-transport": "3.0.0-beta.99"
47
+ "@robota-sdk/agent-interface-session-mobility": "3.0.2",
48
+ "@robota-sdk/agent-interface-session": "3.0.2",
49
+ "@robota-sdk/agent-remote-pairing": "3.0.2",
50
+ "@robota-sdk/agent-transport": "3.0.2",
51
+ "@robota-sdk/agent-interface-transport": "3.0.2"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "node-datachannel": "^0.33.4"
@@ -1,4 +1,4 @@
1
- import { afterEach, describe, expect, it } from 'vitest';
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
2
 
3
3
  import { randomBytes } from 'node:crypto';
4
4
  import { createSocket } from 'node:dgram';
@@ -211,6 +211,7 @@ describe('TURN server — requests nobody authenticated', () => {
211
211
  fingerprint: false,
212
212
  });
213
213
  expect((await probe(server, [bareBinding])).answers).toEqual([]);
214
+ expect(server.unauthenticatedSourceCount).toBe(0);
214
215
 
215
216
  // One the size a WebRTC client sends gets its challenge, no larger than itself.
216
217
  const sized = allocateRequest(20);
@@ -218,6 +219,49 @@ describe('TURN server — requests nobody authenticated', () => {
218
219
  expect(answers).toHaveLength(1);
219
220
  expect(answers[0]!.length).toBeLessThanOrEqual(sized.length);
220
221
  expect(readErrorCode(attribute(decodeStun(answers[0]!)!, StunAttr.ErrorCode))).toBe(401);
222
+ expect(server.unauthenticatedSourceCount).toBe(1);
223
+ });
224
+
225
+ it('globally bounds challenge work even when the response cannot fit the request', async () => {
226
+ const now = Date.now();
227
+ const server = await serve({
228
+ now: () => now,
229
+ unauthenticatedLimits: { perSourcePerSecond: 100, totalPerSecond: 2 },
230
+ });
231
+ const nonce = vi.spyOn(
232
+ server as unknown as { nonce(client: { address: string; port: number }): string },
233
+ 'nonce',
234
+ );
235
+ const bare = allocateRequest(0);
236
+ expect((await probe(server, Array.from({ length: 10 }, () => bare))).answers).toEqual([]);
237
+ expect(nonce).not.toHaveBeenCalled();
238
+ expect(server.unauthenticatedSourceCount).toBe(0);
239
+ });
240
+
241
+ it('does not let a saturated source consume the global challenge budget', async () => {
242
+ const now = Date.now();
243
+ const server = await serve({
244
+ now: () => now,
245
+ unauthenticatedLimits: { perSourcePerSecond: 1, totalPerSecond: 2 },
246
+ });
247
+ const internals = server as unknown as {
248
+ challenge(request: ReturnType<typeof decodeStun>, to: { address: string; port: number }, code: 401): void;
249
+ nonce(client: { address: string; port: number }): string;
250
+ send(bytes: Buffer, to: { address: string; port: number }): void;
251
+ };
252
+ const request = decodeStun(allocateRequest(20));
253
+ const undersized = decodeStun(allocateRequest(0));
254
+ if (!request) throw new Error('expected a valid allocate request');
255
+ if (!undersized) throw new Error('expected a valid undersized allocate request');
256
+ const nonce = vi.spyOn(internals, 'nonce');
257
+ vi.spyOn(internals, 'send').mockImplementation(() => undefined);
258
+ for (let index = 0; index < 10; index += 1) {
259
+ internals.challenge(undersized, { address: '192.0.2.3', port: 1000 + index }, 401);
260
+ }
261
+ internals.challenge(request, { address: '192.0.2.1', port: 1000 }, 401);
262
+ internals.challenge(request, { address: '192.0.2.1', port: 1001 }, 401);
263
+ internals.challenge(request, { address: '192.0.2.2', port: 1000 }, 401);
264
+ expect(nonce).toHaveBeenCalledTimes(2);
221
265
  });
222
266
 
223
267
  it('are answered at a limited rate per source address and in all; the rest are dropped', async () => {
@@ -229,8 +273,13 @@ describe('TURN server — requests nobody authenticated', () => {
229
273
  now: () => now,
230
274
  unauthenticatedLimits: { perSourcePerSecond: 3, totalPerSecond: 100 },
231
275
  });
276
+ const nonce = vi.spyOn(
277
+ perSource as unknown as { nonce(client: { address: string; port: number }): string },
278
+ 'nonce',
279
+ );
232
280
  expect((await probe(perSource, burst)).answers).toHaveLength(3);
233
281
  expect((await probe(perSource, burst)).answers).toHaveLength(0);
282
+ expect(nonce).toHaveBeenCalledTimes(3);
234
283
 
235
284
  const inAll = await serve({
236
285
  now: () => now,
@@ -374,6 +374,11 @@ export class TurnServer {
374
374
  return count;
375
375
  }
376
376
 
377
+ /** Diagnostics only: unauthenticated source buckets retained in the current answer window. */
378
+ public get unauthenticatedSourceCount(): number {
379
+ return this.answerBuckets.size;
380
+ }
381
+
377
382
  /**
378
383
  * Close every allocation whose owner is not one of `owners`, and allocate for these owners only
379
384
  * from now on — also for a request whose authorization was answered before this call.
@@ -486,26 +491,30 @@ export class TurnServer {
486
491
  request: IStunMessage,
487
492
  to: ITransportAddress,
488
493
  cls: TStunClass,
494
+ minimumAnswerBytes: number,
489
495
  attrs: () => { type: number; value: Buffer }[],
490
496
  ): void {
491
497
  const now = this.now();
492
- const total = (this.answerBudget ??= { tokens: this.limits.totalPerSecond, at: now });
493
- const source = this.answerBuckets.get(to.address) ?? {
494
- tokens: this.limits.perSourcePerSecond,
495
- at: now,
496
- };
497
- this.answerBuckets.set(to.address, source);
498
- // No answer is smaller than this: a request below it is dropped before any work.
499
- if (request.raw.length < MIN_ANSWER_BYTES) return;
500
- // Both budgets are checked before anything is computed, and charged only for an answer sent.
498
+ // Refuse requests that can never carry an answer before retaining any attacker-selected key.
499
+ if (request.raw.length < minimumAnswerBytes) return;
500
+ // Check an existing source before doing nonce/HMAC and encoding work. A new source stays
501
+ // allocation-free until its answer is known to fit the anti-amplification boundary below.
502
+ const existing = this.answerBuckets.get(to.address);
503
+ const source = existing ?? { tokens: this.limits.perSourcePerSecond, at: now };
501
504
  if (!refill(source, this.limits.perSourcePerSecond, now)) return;
505
+ const total = (this.answerBudget ??= { tokens: this.limits.totalPerSecond, at: now });
506
+ // The global budget is allocation-free: once exhausted, new source identities retain nothing.
502
507
  if (!refill(total, this.limits.totalPerSecond, now)) return;
508
+ // Charge every attempt that can reach nonce/HMAC generation, even when the encoded response is
509
+ // later rejected by the anti-amplification boundary. Otherwise a deliberately undersized request
510
+ // gets unlimited cryptographic work without ever spending an answer token.
511
+ total.tokens -= 1;
503
512
  const response = encodeStun(request.method, cls, request.transactionId, attrs(), {
504
513
  fingerprint: false,
505
514
  });
506
515
  if (response.length > request.raw.length) return;
516
+ if (existing === undefined) this.answerBuckets.set(to.address, source);
507
517
  source.tokens -= 1;
508
- total.tokens -= 1;
509
518
  this.send(response, to);
510
519
  }
511
520
 
@@ -514,16 +523,21 @@ export class TurnServer {
514
523
  request: IStunMessage,
515
524
  to: ITransportAddress,
516
525
  code: number,
526
+ extraBytes = 0,
517
527
  extra: () => { type: number; value: Buffer }[] = () => [],
518
528
  ): void {
519
- this.answerUnauthenticated(request, to, StunClass.Error, () => [
520
- { type: StunAttr.ErrorCode, value: errorCode(code, '') },
521
- ...extra(),
522
- ]);
529
+ this.answerUnauthenticated(
530
+ request,
531
+ to,
532
+ StunClass.Error,
533
+ MIN_ANSWER_BYTES + extraBytes,
534
+ () => [{ type: StunAttr.ErrorCode, value: errorCode(code, '') }, ...extra()],
535
+ );
523
536
  }
524
537
 
525
538
  private challenge(request: IStunMessage, to: ITransportAddress, code: 401 | 438): void {
526
- this.refuseUnauthenticated(request, to, code, () => [
539
+ const paddedRealmBytes = Math.ceil(Buffer.byteLength(this.realm) / 4) * 4;
540
+ this.refuseUnauthenticated(request, to, code, 4 + paddedRealmBytes + 4 + 16, () => [
527
541
  { type: StunAttr.Realm, value: Buffer.from(this.realm) },
528
542
  { type: StunAttr.Nonce, value: Buffer.from(this.nonce(to)) },
529
543
  ]);
@@ -601,9 +615,16 @@ export class TurnServer {
601
615
  private async request(request: IStunMessage, rinfo: RemoteInfo): Promise<void> {
602
616
  const client: ITransportAddress = { address: rinfo.address, port: rinfo.port };
603
617
  if (request.method === StunMethod.Binding) {
604
- this.answerUnauthenticated(request, client, StunClass.Success, () => [
605
- { type: StunAttr.XorMappedAddress, value: encodeXorAddress(client, request.transactionId) },
606
- ]);
618
+ const addressBytes = isIPv4(client.address) ? 8 : 20;
619
+ this.answerUnauthenticated(
620
+ request,
621
+ client,
622
+ StunClass.Success,
623
+ 20 + 4 + addressBytes,
624
+ () => [
625
+ { type: StunAttr.XorMappedAddress, value: encodeXorAddress(client, request.transactionId) },
626
+ ],
627
+ );
607
628
  return;
608
629
  }
609
630
  const known: number[] = [