@robota-sdk/agent-transport-webrtc 3.0.0-beta.81
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/LICENSE +661 -0
- package/dist/node/index.cjs +1 -0
- package/dist/node/index.d.cts +361 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +361 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +2 -0
- package/dist/node/index.js.map +1 -0
- package/package.json +88 -0
- package/src/__tests__/admission-secure-by-default.test.ts +61 -0
- package/src/__tests__/admission-steps.test.ts +46 -0
- package/src/__tests__/cve-2024-29415-reachability.test.ts +80 -0
- package/src/__tests__/dtls-fingerprint-binding.test.ts +60 -0
- package/src/__tests__/handoff-grant-gate.test.ts +401 -0
- package/src/__tests__/local-peer-proof-gate.test.ts +213 -0
- package/src/__tests__/mesh-admission-contract.test.ts +38 -0
- package/src/__tests__/negotiated-certificate.test.ts +80 -0
- package/src/__tests__/operator-approval-gate.test.ts +306 -0
- package/src/__tests__/outbound-delivery-carrier.test.ts +152 -0
- package/src/__tests__/pairing-e2e.test.ts +238 -0
- package/src/__tests__/pairing-gate-e3.test.ts +192 -0
- package/src/__tests__/pairing-gate-e4.test.ts +108 -0
- package/src/__tests__/pairing-gate.test.ts +276 -0
- package/src/__tests__/pairing-relay-in-the-middle.test.ts +229 -0
- package/src/__tests__/session-attachment-budget.test.ts +136 -0
- package/src/__tests__/webrtc-transport.test.ts +349 -0
- package/src/__tests__/werift-loader.test.ts +18 -0
- package/src/__tests__/ws-signaling-client.test.ts +108 -0
- package/src/admission-steps.ts +55 -0
- package/src/channel-delivery.ts +44 -0
- package/src/handoff-grant-gate.ts +186 -0
- package/src/index.ts +21 -0
- package/src/local-peer-proof.ts +135 -0
- package/src/negotiated-certificate.ts +80 -0
- package/src/pairing-channel-lifecycle.ts +31 -0
- package/src/pairing-controllers.ts +79 -0
- package/src/pairing-frames.ts +34 -0
- package/src/pairing-gate-options.ts +129 -0
- package/src/pairing-gate.ts +376 -0
- package/src/session-attachment.ts +88 -0
- package/src/signaling.ts +62 -0
- package/src/transport-lifecycle-error.ts +11 -0
- package/src/webrtc-delivery-lifecycle.ts +51 -0
- package/src/webrtc-transport-options.ts +84 -0
- package/src/webrtc-transport.ts +328 -0
- package/src/werift-loader.ts +40 -0
- package/src/ws-signaling-client.ts +139 -0
package/package.json
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@robota-sdk/agent-transport-webrtc",
|
|
3
|
+
"version": "3.0.0-beta.81",
|
|
4
|
+
"description": "WebRTC P2P transport for the Robota SDK (data-channel session bridge; REMOTE-001)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/node/index.js",
|
|
7
|
+
"types": "dist/node/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"source": "./src/index.ts",
|
|
11
|
+
"node": {
|
|
12
|
+
"import": {
|
|
13
|
+
"types": "./dist/node/index.d.ts",
|
|
14
|
+
"default": "./dist/node/index.js"
|
|
15
|
+
},
|
|
16
|
+
"require": {
|
|
17
|
+
"types": "./dist/node/index.d.cts",
|
|
18
|
+
"default": "./dist/node/index.cjs"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"default": {
|
|
22
|
+
"import": {
|
|
23
|
+
"types": "./dist/node/index.d.ts",
|
|
24
|
+
"default": "./dist/node/index.js"
|
|
25
|
+
},
|
|
26
|
+
"require": {
|
|
27
|
+
"types": "./dist/node/index.d.cts",
|
|
28
|
+
"default": "./dist/node/index.cjs"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "https://github.com/woojubb/robota.git"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://robota.io/",
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/woojubb/robota/issues"
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=22.0.0"
|
|
43
|
+
},
|
|
44
|
+
"files": [
|
|
45
|
+
"dist",
|
|
46
|
+
"src"
|
|
47
|
+
],
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"ws": "^8.21.1",
|
|
50
|
+
"@robota-sdk/agent-interface-session": "3.0.0-beta.81",
|
|
51
|
+
"@robota-sdk/agent-interface-transport": "3.0.0-beta.81",
|
|
52
|
+
"@robota-sdk/agent-interface-session-mobility": "3.0.0-beta.81",
|
|
53
|
+
"@robota-sdk/agent-transport": "3.0.0-beta.81",
|
|
54
|
+
"@robota-sdk/agent-remote-pairing": "3.0.0-beta.81"
|
|
55
|
+
},
|
|
56
|
+
"peerDependencies": {
|
|
57
|
+
"werift": "^0.24.0"
|
|
58
|
+
},
|
|
59
|
+
"peerDependenciesMeta": {
|
|
60
|
+
"werift": {
|
|
61
|
+
"optional": true
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"@types/ws": "^8.18.1",
|
|
66
|
+
"rimraf": "^5.0.10",
|
|
67
|
+
"tsdown": "^0.22.14",
|
|
68
|
+
"typescript": "^6.0.3",
|
|
69
|
+
"vitest": "^3.2.6",
|
|
70
|
+
"werift": "^0.24.0"
|
|
71
|
+
},
|
|
72
|
+
"license": "AGPL-3.0-only OR LicenseRef-Commercial",
|
|
73
|
+
"publishConfig": {
|
|
74
|
+
"access": "public"
|
|
75
|
+
},
|
|
76
|
+
"volta": {
|
|
77
|
+
"extends": "../../package.json"
|
|
78
|
+
},
|
|
79
|
+
"scripts": {
|
|
80
|
+
"build": "rimraf dist && tsdown",
|
|
81
|
+
"build:js": "pnpm run build",
|
|
82
|
+
"build:types": "pnpm run build",
|
|
83
|
+
"typecheck": "tsgo --noEmit",
|
|
84
|
+
"test": "vitest run --passWithNoTests",
|
|
85
|
+
"test:coverage": "vitest run --coverage --passWithNoTests",
|
|
86
|
+
"clean": "rimraf dist"
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEC-008 — a WebRTC transport with no pairing secret must not expose the session by omission.
|
|
3
|
+
*
|
|
4
|
+
* Two sibling transports made OPPOSITE default choices for one question. The WS transport auto-mints
|
|
5
|
+
* a credential unless told to stay open; this one's `secret` was optional, and without it the data
|
|
6
|
+
* channel was wired straight to the session — a remote peer reaching tool execution because a field
|
|
7
|
+
* was left unset.
|
|
8
|
+
*
|
|
9
|
+
* The defect is the DEFAULT, not the open mode. Running without pairing is legitimate for loopback
|
|
10
|
+
* and for tests; what is not legitimate is that it happened when nobody chose it. So the transport
|
|
11
|
+
* now refuses to construct unless one of the two was chosen, and the refusal names both ways out.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, expect, it } from 'vitest';
|
|
15
|
+
|
|
16
|
+
import { WebRtcTransport } from '../webrtc-transport.js';
|
|
17
|
+
import type { ISignalingClient } from '../signaling.js';
|
|
18
|
+
|
|
19
|
+
/** A signalling channel that is never used — these cases never get past the constructor. */
|
|
20
|
+
const signaling = {
|
|
21
|
+
send: () => {},
|
|
22
|
+
onMessage: () => () => {},
|
|
23
|
+
close: () => {},
|
|
24
|
+
} as unknown as ISignalingClient;
|
|
25
|
+
|
|
26
|
+
describe('SEC-008: the WebRTC transport requires an admission decision', () => {
|
|
27
|
+
it('refuses to construct with neither a secret nor an explicit open', () => {
|
|
28
|
+
expect(() => new WebRtcTransport({ signaling })).toThrow(/openReason|secret/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('constructs with a pairing secret', () => {
|
|
32
|
+
expect(() => new WebRtcTransport({ signaling, secret: 'pairing-secret' })).not.toThrow();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('constructs open when the host says so, in writing', () => {
|
|
36
|
+
expect(
|
|
37
|
+
() =>
|
|
38
|
+
new WebRtcTransport({
|
|
39
|
+
signaling,
|
|
40
|
+
open: true,
|
|
41
|
+
openReason: 'loopback only — no remote peer',
|
|
42
|
+
}),
|
|
43
|
+
).not.toThrow();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('refuses an open transport with no reason', () => {
|
|
47
|
+
// Without this the fix would be an option that means nothing: `open: true` alone would restore
|
|
48
|
+
// exactly the silent exposure the case above forbids, one keyword further along.
|
|
49
|
+
expect(() => new WebRtcTransport({ signaling, open: true })).toThrow(/openReason/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('refuses a secret AND an explicit open together', () => {
|
|
53
|
+
// Silently ignoring one of two contradictory options is how a caller ends up believing the
|
|
54
|
+
// channel is gated when it is, or is not, by whichever option happened to win. The constructor
|
|
55
|
+
// cannot know which they meant, so it does not choose.
|
|
56
|
+
expect(
|
|
57
|
+
() =>
|
|
58
|
+
new WebRtcTransport({ signaling, secret: 'pairing-secret', open: true, openReason: 'x' }),
|
|
59
|
+
).toThrow(/contradictory/);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { nextAdmissionStep } from '../admission-steps.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The ordering carries the security argument, so it is asserted directly rather than only through a
|
|
7
|
+
* channel: each step must run on a channel the previous one has already bound.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const PROOF = {};
|
|
11
|
+
|
|
12
|
+
describe('what a channel still owes', () => {
|
|
13
|
+
it('owes nothing when nothing is configured — the handshake alone admits', () => {
|
|
14
|
+
expect(nextAdmissionStep({}, 'pairing')).toBe(null);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it.each([
|
|
18
|
+
['only the rendezvous', { localPeer: PROOF }, 'local-proof'],
|
|
19
|
+
['only the grant', { handoffGrant: PROOF }, 'handoff-grant'],
|
|
20
|
+
['both — the rendezvous first', { localPeer: PROOF, handoffGrant: PROOF }, 'local-proof'],
|
|
21
|
+
])('with %s configured, owes %s', (_label, configured, expected) => {
|
|
22
|
+
expect(nextAdmissionStep(configured, 'pairing')).toBe(expected);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('owes the grant once the rendezvous has run', () => {
|
|
26
|
+
expect(nextAdmissionStep({ localPeer: PROOF, handoffGrant: PROOF }, 'local-proof')).toBe(
|
|
27
|
+
'handoff-grant',
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('owes nothing once both have run', () => {
|
|
32
|
+
expect(nextAdmissionStep({ localPeer: PROOF, handoffGrant: PROOF }, 'handoff-grant')).toBe(
|
|
33
|
+
null,
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('does not re-demand the rendezvous after the grant state is reached', () => {
|
|
38
|
+
// The loop this prevents: a gate that asked for the rendezvous again from `handoff-grant` would
|
|
39
|
+
// never admit anyone, because the peer has no second nonce to present.
|
|
40
|
+
expect(nextAdmissionStep({ localPeer: PROOF }, 'handoff-grant')).toBe(null);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('owes nothing after a rendezvous-only channel has satisfied it', () => {
|
|
44
|
+
expect(nextAdmissionStep({ localPeer: PROOF }, 'local-proof')).toBe(null);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it } from 'vitest';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* REMOTE-004 B2 TC-05 — regression guard for the CVE-2024-29415 evidence-based re-accept.
|
|
9
|
+
*
|
|
10
|
+
* The `ip` package's SSRF advisory (CVE-2024-29415) affects `ip.isPublic` / `ip.isPrivate` (and `ip.address()`,
|
|
11
|
+
* which calls them). werift/werift-ice reach `ip` only through STUN codec helpers (`isV4Format`/`toBuffer`/
|
|
12
|
+
* `toString`) and `ip.isLoopback` over the host's own `os.networkInterfaces()` — never the vulnerable functions,
|
|
13
|
+
* so the SSRF vector is unreachable and the root `pnpm.auditConfig.ignoreCves` entry is a reviewed re-accept.
|
|
14
|
+
* This test fails if a future werift version starts calling the vulnerable functions, forcing a re-evaluation.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The package root above a resolved entry point.
|
|
19
|
+
*
|
|
20
|
+
* werift 0.24 ships an `exports` map that does not list `./package.json`, so `require.resolve
|
|
21
|
+
* ('werift/package.json')` throws `ERR_PACKAGE_PATH_NOT_EXPORTED` — and did so silently here from
|
|
22
|
+
* the dependabot bump that brought 0.24 (issue #1315) until the resolver was read: the `catch` below
|
|
23
|
+
* used to swallow it and the guard measured zero directories. The entry point IS exported, so the
|
|
24
|
+
* root is found from it, by walking up to the nearest `package.json`.
|
|
25
|
+
*/
|
|
26
|
+
function packageRootOf(entry: string): string {
|
|
27
|
+
let dir = dirname(entry);
|
|
28
|
+
while (!existsSync(join(dir, 'package.json'))) {
|
|
29
|
+
const parent = dirname(dir);
|
|
30
|
+
if (parent === dir) throw new Error(`no package.json above ${entry}`);
|
|
31
|
+
dir = parent;
|
|
32
|
+
}
|
|
33
|
+
return dir;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Both packages that reach `ip`: `werift` (a devDep, resolvable from here) and `werift-ice` (its
|
|
38
|
+
* transitive dependency — under pnpm's strict layout resolvable only from werift's own location,
|
|
39
|
+
* which is where it is asked for). Neither is optional: a resolver that returned fewer directories
|
|
40
|
+
* than it names would let the scan pass over an empty set, which is the failure this guard exists to
|
|
41
|
+
* refuse, so an unresolvable package throws instead of being skipped.
|
|
42
|
+
*/
|
|
43
|
+
function resolveWeriftLibDirs(): string[] {
|
|
44
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
45
|
+
const weriftEntry = requireFromHere.resolve('werift');
|
|
46
|
+
const requireFromWerift = createRequire(weriftEntry);
|
|
47
|
+
return [packageRootOf(weriftEntry), packageRootOf(requireFromWerift.resolve('werift-ice'))];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function collectJsFiles(dir: string, acc: string[] = []): string[] {
|
|
51
|
+
for (const entry of readdirSync(dir)) {
|
|
52
|
+
const full = join(dir, entry);
|
|
53
|
+
const stat = statSync(full);
|
|
54
|
+
if (stat.isDirectory()) {
|
|
55
|
+
if (entry !== 'node_modules') collectJsFiles(full, acc);
|
|
56
|
+
} else if (/\.(js|mjs|cjs)$/.test(entry)) {
|
|
57
|
+
acc.push(full);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return acc;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('CVE-2024-29415 non-reachability in werift (REMOTE-004 B2 — TC-05)', () => {
|
|
64
|
+
it('werift never calls the vulnerable ip.isPublic / ip.isPrivate / ip.address', () => {
|
|
65
|
+
const dirs = resolveWeriftLibDirs();
|
|
66
|
+
expect(dirs, 'werift and werift-ice must both be scanned').toHaveLength(2);
|
|
67
|
+
|
|
68
|
+
const offenders: string[] = [];
|
|
69
|
+
for (const dir of dirs) {
|
|
70
|
+
for (const file of collectJsFiles(dir)) {
|
|
71
|
+
const text = readFileSync(file, 'utf8');
|
|
72
|
+
// Match property access on the `ip` module: `.isPublic(`, `.isPrivate(`, `.address(`.
|
|
73
|
+
if (/\.isPublic\s*\(|\.isPrivate\s*\(|ip[_0-9]*\.(?:default\.)?address\s*\(/.test(text)) {
|
|
74
|
+
offenders.push(file);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
expect(offenders).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { RTCPeerConnection } from 'werift';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* REMOTE-005 B3 TC-09 — the load-bearing premise behind the pairing channel-binding: werift's DTLS verifies the
|
|
6
|
+
* negotiated remote certificate's fingerprint against the SDP-advertised value and FAILS the connection on a
|
|
7
|
+
* mismatch (`verifyRemoteCertificateFingerprint`, RFC 8122). This is what guarantees a MITM relay cannot advertise
|
|
8
|
+
* a fingerprint that differs from the cert it actually presents — so its advertised fingerprint necessarily
|
|
9
|
+
* differs from the honest peer's, which the pairing confirmation detects.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
function tamperFingerprint(sdp: string): string {
|
|
13
|
+
// Flip the advertised fingerprint to a value that will NOT match the real negotiated certificate.
|
|
14
|
+
return sdp.replace(/a=fingerprint:(\S+) (\S+)/, (_m, hash: string) => {
|
|
15
|
+
const bogus = Array.from({ length: 32 }, () => 'AA').join(':');
|
|
16
|
+
return `a=fingerprint:${hash} ${bogus}`;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('werift DTLS fingerprint verification (REMOTE-005 B3 — TC-09)', () => {
|
|
21
|
+
it('fails the connection when the advertised remote fingerprint does not match the negotiated cert', async () => {
|
|
22
|
+
const host = new RTCPeerConnection();
|
|
23
|
+
const remote = new RTCPeerConnection();
|
|
24
|
+
const dc = host.createDataChannel('robota-session');
|
|
25
|
+
|
|
26
|
+
let opened = false;
|
|
27
|
+
dc.stateChanged.subscribe((s) => {
|
|
28
|
+
if (s === 'open') opened = true;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Trickle ICE both ways (serialized on the remote side is unnecessary here — no answer-before-ICE issue since
|
|
32
|
+
// we tamper the answer and expect DTLS to fail regardless).
|
|
33
|
+
host.onIceCandidate.subscribe((c) => {
|
|
34
|
+
if (c) void remote.addIceCandidate(c.toJSON());
|
|
35
|
+
});
|
|
36
|
+
remote.onIceCandidate.subscribe((c) => {
|
|
37
|
+
if (c) void host.addIceCandidate(c.toJSON());
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const offer = await host.createOffer();
|
|
41
|
+
await host.setLocalDescription(offer);
|
|
42
|
+
await remote.setRemoteDescription(host.localDescription!);
|
|
43
|
+
const answer = await remote.createAnswer();
|
|
44
|
+
await remote.setLocalDescription(answer);
|
|
45
|
+
|
|
46
|
+
// MITM substitution: the host consumes an answer whose advertised fingerprint has been tampered.
|
|
47
|
+
const tampered = {
|
|
48
|
+
type: 'answer' as const,
|
|
49
|
+
sdp: tamperFingerprint(remote.localDescription!.sdp),
|
|
50
|
+
};
|
|
51
|
+
await host.setRemoteDescription(tampered);
|
|
52
|
+
|
|
53
|
+
// Give DTLS time to (fail to) establish; the data channel must NEVER open.
|
|
54
|
+
await new Promise((r) => setTimeout(r, 4000));
|
|
55
|
+
expect(opened).toBe(false);
|
|
56
|
+
|
|
57
|
+
await host.close();
|
|
58
|
+
await remote.close();
|
|
59
|
+
}, 15000);
|
|
60
|
+
});
|