@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/CHANGELOG.md +572 -0
- package/dist/node/index.cjs +1 -1
- package/dist/node/index.d.cts +2 -0
- package/dist/node/index.d.cts.map +1 -1
- package/dist/node/index.d.ts +2 -0
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +1 -1
- package/dist/node/index.js.map +1 -1
- package/package.json +6 -6
- package/src/__tests__/turn-server.test.ts +50 -1
- package/src/turn-server.ts +39 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@robota-sdk/agent-transport-webrtc",
|
|
3
|
-
"version": "3.0.
|
|
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.
|
|
48
|
-
"@robota-sdk/agent-interface-session
|
|
49
|
-
"@robota-sdk/agent-remote-pairing": "3.0.
|
|
50
|
-
"@robota-sdk/agent-transport": "3.0.
|
|
51
|
-
"@robota-sdk/agent-interface-transport": "3.0.
|
|
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,
|
package/src/turn-server.ts
CHANGED
|
@@ -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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
this.
|
|
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(
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
|
|
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
|
-
|
|
605
|
-
|
|
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[] = [
|