@robota-sdk/agent-transport-webrtc 3.0.0-beta.100
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 +694 -0
- package/LICENSE +661 -0
- package/README.md +111 -0
- package/dist/node/index.cjs +1 -0
- package/dist/node/index.d.cts +1650 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +1650 -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 +93 -0
- package/src/__tests__/admission-secure-by-default.test.ts +63 -0
- package/src/__tests__/admission-steps.test.ts +46 -0
- package/src/__tests__/device-mesh-relay.test.ts +441 -0
- package/src/__tests__/device-mesh.test.ts +682 -0
- package/src/__tests__/discovering-mesh-relay.test.ts +234 -0
- package/src/__tests__/enrollment-link.test.ts +103 -0
- package/src/__tests__/fake-datachannel.ts +83 -0
- package/src/__tests__/handoff-grant-gate.test.ts +403 -0
- package/src/__tests__/local-peer-proof-gate.test.ts +215 -0
- package/src/__tests__/mesh-admission-contract.test.ts +38 -0
- package/src/__tests__/mesh-discovery.test.ts +820 -0
- package/src/__tests__/mesh-fixtures.ts +148 -0
- package/src/__tests__/mesh-internet.test.ts +942 -0
- package/src/__tests__/mesh-turn-relay.test.ts +161 -0
- package/src/__tests__/negotiated-certificate.test.ts +76 -0
- package/src/__tests__/nostr-source-condition.test.ts +27 -0
- package/src/__tests__/operator-approval-gate.test.ts +308 -0
- package/src/__tests__/outbound-delivery-carrier.test.ts +155 -0
- package/src/__tests__/pairing-e2e.test.ts +237 -0
- package/src/__tests__/pairing-gate-e3.test.ts +194 -0
- package/src/__tests__/pairing-gate-e4.test.ts +110 -0
- package/src/__tests__/pairing-gate.test.ts +296 -0
- package/src/__tests__/pairing-relay-in-the-middle.test.ts +225 -0
- package/src/__tests__/rtc-channel-close.test.ts +52 -0
- package/src/__tests__/rtc-peer-candidates.test.ts +61 -0
- package/src/__tests__/session-attachment-app-view.test.ts +66 -0
- package/src/__tests__/session-attachment-budget.test.ts +136 -0
- package/src/__tests__/turn-server-bind.test.ts +59 -0
- package/src/__tests__/turn-server.test.ts +335 -0
- package/src/__tests__/turn-test-client.ts +256 -0
- package/src/__tests__/webrtc-implementation.test.ts +83 -0
- package/src/__tests__/webrtc-transport.test.ts +349 -0
- package/src/__tests__/ws-mesh-relay-client.test.ts +95 -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/datachannel-loader.ts +86 -0
- package/src/device-mesh-node.ts +1019 -0
- package/src/discovering-mesh-relay.ts +675 -0
- package/src/enrollment-link.ts +465 -0
- package/src/handoff-grant-gate.ts +186 -0
- package/src/index.ts +139 -0
- package/src/local-peer-proof.ts +135 -0
- package/src/mainline-dht-store.ts +174 -0
- package/src/mesh-dht.ts +446 -0
- package/src/mesh-discovery.ts +87 -0
- package/src/mesh-item-store.ts +287 -0
- package/src/mesh-lan-listener.ts +193 -0
- package/src/mesh-mdns.ts +406 -0
- package/src/mesh-peer-link.ts +600 -0
- package/src/mesh-public-relays.ts +25 -0
- package/src/mesh-records.ts +357 -0
- package/src/mesh-relay.ts +98 -0
- package/src/mesh-signal.ts +116 -0
- package/src/mesh-turn-relay.ts +238 -0
- package/src/negotiated-certificate.ts +72 -0
- package/src/nostr-mesh-relay.ts +391 -0
- package/src/pairing-channel-lifecycle.ts +31 -0
- package/src/pairing-controllers.ts +81 -0
- package/src/pairing-frames.ts +34 -0
- package/src/pairing-gate-options.ts +133 -0
- package/src/pairing-gate.ts +397 -0
- package/src/rtc-peer.ts +429 -0
- package/src/session-attachment.ts +96 -0
- package/src/signaling.ts +62 -0
- package/src/stun-message.ts +341 -0
- package/src/transport-lifecycle-error.ts +11 -0
- package/src/turn-server.ts +1028 -0
- package/src/webrtc-delivery-lifecycle.ts +54 -0
- package/src/webrtc-transport-options.ts +87 -0
- package/src/webrtc-transport.ts +368 -0
- package/src/ws-mesh-relay-client.ts +117 -0
- package/src/ws-signaling-client.ts +139 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { createIdentityContext } from '@robota-sdk/agent-remote-pairing';
|
|
2
|
+
const testIdentity = createIdentityContext('test-product');
|
|
3
|
+
/**
|
|
4
|
+
* ARCH-030 at the WebRTC carrier.
|
|
5
|
+
*
|
|
6
|
+
* The item named WebRTC as affected, and its stated reason was wrong in a way worth recording: the
|
|
7
|
+
* PAIRED-with-resume-bridge path was already guarded (the bridge routed replies through its own
|
|
8
|
+
* try/catch). The genuinely unguarded WebRTC exposure was the BARE `createSessionMessageHandler` — `PairingGate`'s
|
|
9
|
+
* no-`resumeBridge` branch, taken after the handshake accepts, and `WebRtcTransport`'s no-secret
|
|
10
|
+
* branch, which is the same construction. This suite drives the gate's branch with a stubbed
|
|
11
|
+
* handshake, the way `pairing-gate.test.ts` does, because that is the one that ships by default.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
15
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
16
|
+
|
|
17
|
+
import { PairingGate } from '../pairing-gate.js';
|
|
18
|
+
|
|
19
|
+
import type { startPairingHandshake, TPairingFrame } from '@robota-sdk/agent-remote-pairing';
|
|
20
|
+
import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
|
|
21
|
+
|
|
22
|
+
/** Collect unhandled rejections while `run` executes, then drain the queue Node reports them on. */
|
|
23
|
+
async function withUnhandledRejectionCapture(run: () => void | Promise<void>): Promise<unknown[]> {
|
|
24
|
+
const captured: unknown[] = [];
|
|
25
|
+
const existing = process.listeners('unhandledRejection');
|
|
26
|
+
for (const listener of existing) process.off('unhandledRejection', listener);
|
|
27
|
+
const collect = (reason: unknown): void => {
|
|
28
|
+
captured.push(reason);
|
|
29
|
+
};
|
|
30
|
+
process.on('unhandledRejection', collect);
|
|
31
|
+
try {
|
|
32
|
+
await run();
|
|
33
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
34
|
+
} finally {
|
|
35
|
+
process.off('unhandledRejection', collect);
|
|
36
|
+
for (const listener of existing)
|
|
37
|
+
process.on('unhandledRejection', listener as NodeJS.UnhandledRejectionListener);
|
|
38
|
+
}
|
|
39
|
+
return captured;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A controllable handshake stub: the test decides when pairing accepts. */
|
|
43
|
+
function makeHandshakeStub(): { start: typeof startPairingHandshake; accept: () => void } {
|
|
44
|
+
let resolveResult!: (value: { sessionKey: string }) => void;
|
|
45
|
+
const start: typeof startPairingHandshake = (_cryptoContext, options) => {
|
|
46
|
+
const controller = {
|
|
47
|
+
result: new Promise<{ sessionKey: string }>((res) => {
|
|
48
|
+
resolveResult = res;
|
|
49
|
+
}),
|
|
50
|
+
onFrame: (_frame: TPairingFrame) => undefined,
|
|
51
|
+
};
|
|
52
|
+
options.send({ t: 'pair-nonce', nonce: 'stub' });
|
|
53
|
+
return controller;
|
|
54
|
+
};
|
|
55
|
+
return { start, accept: () => resolveResult({ sessionKey: 'k' }) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A data channel whose `send` starts throwing once dropped, as a real one does once closed. */
|
|
59
|
+
type TTestChannel = { send: (data: string) => void; close: () => void };
|
|
60
|
+
|
|
61
|
+
function createDroppableChannel(): {
|
|
62
|
+
channel: TTestChannel;
|
|
63
|
+
close: ReturnType<typeof vi.fn>;
|
|
64
|
+
drop: () => void;
|
|
65
|
+
} {
|
|
66
|
+
let open = true;
|
|
67
|
+
const close = vi.fn();
|
|
68
|
+
const channel = {
|
|
69
|
+
send: (data: string) => {
|
|
70
|
+
if (!open) throw new Error('RTCDataChannel is not open');
|
|
71
|
+
void data;
|
|
72
|
+
},
|
|
73
|
+
close,
|
|
74
|
+
};
|
|
75
|
+
return {
|
|
76
|
+
channel,
|
|
77
|
+
close,
|
|
78
|
+
drop: () => {
|
|
79
|
+
open = false;
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Build an accepted gate on its bare-handler branch (no `resumeBridge`). */
|
|
85
|
+
async function acceptedGate(session: IInteractiveSession): Promise<{
|
|
86
|
+
gate: PairingGate;
|
|
87
|
+
channelClose: ReturnType<typeof vi.fn>;
|
|
88
|
+
drop: () => void;
|
|
89
|
+
deliveryErrors: Array<{ message: string; event: string }>;
|
|
90
|
+
}> {
|
|
91
|
+
const { channel, close, drop } = createDroppableChannel();
|
|
92
|
+
const deliveryErrors: Array<{ message: string; event: string }> = [];
|
|
93
|
+
const handshake = makeHandshakeStub();
|
|
94
|
+
const gate = new PairingGate({cryptoContext: testIdentity,
|
|
95
|
+
channel,
|
|
96
|
+
session,
|
|
97
|
+
secret: 's',
|
|
98
|
+
role: 'initiator',
|
|
99
|
+
localFingerprint: 'AA',
|
|
100
|
+
remoteFingerprint: 'BB',
|
|
101
|
+
startHandshake: handshake.start,
|
|
102
|
+
onDeliveryError: (error, event) => deliveryErrors.push({ message: error.message, event }),
|
|
103
|
+
});
|
|
104
|
+
handshake.accept();
|
|
105
|
+
await new Promise((resolve) => setTimeout(resolve, 0)); // the gate accepts on a microtask
|
|
106
|
+
return { gate, channelClose: close, drop, deliveryErrors };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
describe('PairingGate bare-handler branch + the outbound boundary (ARCH-030)', () => {
|
|
110
|
+
it('a command resolving after the channel dropped reports once and leaks no rejection', async () => {
|
|
111
|
+
let releaseCommand: (() => void) | undefined;
|
|
112
|
+
const commandGate = new Promise<void>((resolve) => {
|
|
113
|
+
releaseCommand = resolve;
|
|
114
|
+
});
|
|
115
|
+
const executed: string[] = [];
|
|
116
|
+
const session: IInteractiveSession = createTestInteractiveSession({
|
|
117
|
+
executeCommand: (name: string) =>
|
|
118
|
+
commandGate.then(() => {
|
|
119
|
+
executed.push(name);
|
|
120
|
+
return { success: true, message: 'done' };
|
|
121
|
+
}),
|
|
122
|
+
} as Partial<IInteractiveSession>);
|
|
123
|
+
const { gate, channelClose, drop, deliveryErrors } = await acceptedGate(session);
|
|
124
|
+
|
|
125
|
+
const rejections = await withUnhandledRejectionCapture(async () => {
|
|
126
|
+
gate.onInbound(JSON.stringify({ type: 'command', name: 'status' }));
|
|
127
|
+
drop();
|
|
128
|
+
releaseCommand?.();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(rejections).toEqual([]);
|
|
132
|
+
expect(executed).toEqual(['status']); // the command committed regardless of delivery
|
|
133
|
+
expect(deliveryErrors).toEqual([
|
|
134
|
+
{ message: 'RTCDataChannel is not open', event: 'command_result' },
|
|
135
|
+
]);
|
|
136
|
+
// Carrier cleanup ran: the gate closed its channel on the delivery failure. Before ARCH-030 the
|
|
137
|
+
// reply threw from a Promise continuation and none of this happened.
|
|
138
|
+
expect(channelClose).toHaveBeenCalledTimes(1);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// As on the WS side, this is not the boundary latch's own proof — `PairingGate.handleSessionDeliveryError`
|
|
142
|
+
// returns early once its state is `closed`, so the single report predates ARCH-030. It pins that the
|
|
143
|
+
// boundary above it did not change what the gate observes. The latch is red-proved in the protocol suite.
|
|
144
|
+
it('reports once for a burst of replies after the drop', async () => {
|
|
145
|
+
const session = createTestInteractiveSession();
|
|
146
|
+
const { gate, drop, deliveryErrors } = await acceptedGate(session);
|
|
147
|
+
drop();
|
|
148
|
+
|
|
149
|
+
gate.onInbound(JSON.stringify({ type: 'get-executing' }));
|
|
150
|
+
gate.onInbound(JSON.stringify({ type: 'get-messages' }));
|
|
151
|
+
gate.onInbound(JSON.stringify({ type: 'get-pending' }));
|
|
152
|
+
|
|
153
|
+
expect(deliveryErrors).toEqual([{ message: 'RTCDataChannel is not open', event: 'executing' }]);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { createIdentityContext } from '@robota-sdk/agent-remote-pairing';
|
|
2
|
+
const testIdentity = createIdentityContext('test-product');
|
|
3
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
4
|
+
|
|
5
|
+
import { extractDtlsFingerprint, startPairingHandshake } from '@robota-sdk/agent-remote-pairing';
|
|
6
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { WebRtcTransport } from '../webrtc-transport.js';
|
|
9
|
+
import { RtcPeer } from '../rtc-peer.js';
|
|
10
|
+
import { createInMemorySignalingPair, type ISignalingClient } from '../signaling.js';
|
|
11
|
+
|
|
12
|
+
import type { TPairingFrame } from '@robota-sdk/agent-remote-pairing';
|
|
13
|
+
import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* REMOTE-008 Step 1/5 — end-to-end PAIRED handshake over a REAL data channel. The host is a
|
|
17
|
+
* `WebRtcTransport` with a `secret` (initiator ≡ offerer); the "remote device" here runs the RESPONDER
|
|
18
|
+
* side of the same pairing handshake, then speaks the session protocol only after its own accept. Proves
|
|
19
|
+
* the gate's routing switch works over a real channel: matching secrets → session exposed + round-trips;
|
|
20
|
+
* mismatched secrets → both reject, session never exposed (fail closed).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
function createStubSession(): IInteractiveSession {
|
|
24
|
+
return Object.assign(createTestInteractiveSession(), {
|
|
25
|
+
getMessages: vi.fn().mockReturnValue([{ role: 'user', content: 'hi' }]),
|
|
26
|
+
// ARCH-012: required. This double feeds `subscribeSessionEvents`, which calls it on every
|
|
27
|
+
// turn-authored event — omitting it throws the moment a case emits one.
|
|
28
|
+
getActiveDriverId: () => null,
|
|
29
|
+
on: vi.fn(),
|
|
30
|
+
off: vi.fn(),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface IRemoteResult {
|
|
35
|
+
/** Resolves with the first session `TServerMessage` (only reachable if pairing accepted). */
|
|
36
|
+
session: Promise<Record<string, unknown>>;
|
|
37
|
+
/** Resolves true if the responder's pairing accepted, false if it rejected/closed. */
|
|
38
|
+
paired: Promise<boolean>;
|
|
39
|
+
/** Close the remote's data channel — the device going away. */
|
|
40
|
+
close: () => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The remote device (answerer + pairing RESPONDER). Extracts the host fingerprint from the offer and its
|
|
45
|
+
* own from the answer, runs the responder handshake over the data channel, and — on accept — sends a
|
|
46
|
+
* `get-messages` session frame. Session frames are only sent post-accept (phase separation).
|
|
47
|
+
*/
|
|
48
|
+
function connectRemotePaired(signaling: ISignalingClient, secret: string): IRemoteResult {
|
|
49
|
+
let resolveSession!: (v: Record<string, unknown>) => void;
|
|
50
|
+
let resolvePaired!: (v: boolean) => void;
|
|
51
|
+
const session = new Promise<Record<string, unknown>>((res) => (resolveSession = res));
|
|
52
|
+
const paired = new Promise<boolean>((res) => (resolvePaired = res));
|
|
53
|
+
|
|
54
|
+
const peer = new RtcPeer();
|
|
55
|
+
peer.onLocalCandidate((c) =>
|
|
56
|
+
signaling.send({ kind: 'ice', data: { candidate: c.candidate, sdpMid: c.mid } }),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
let hostFingerprint: string | undefined;
|
|
60
|
+
let localFingerprint: string | undefined;
|
|
61
|
+
let chain: Promise<void> = Promise.resolve();
|
|
62
|
+
signaling.onSignal((message) => {
|
|
63
|
+
chain = chain.then(async () => {
|
|
64
|
+
const data = message.data as { sdp?: string; candidate?: string; sdpMid?: string };
|
|
65
|
+
if (message.kind === 'offer') {
|
|
66
|
+
hostFingerprint = extractDtlsFingerprint(data.sdp!);
|
|
67
|
+
const answer = await peer.acceptOffer(data.sdp!);
|
|
68
|
+
localFingerprint = extractDtlsFingerprint(answer);
|
|
69
|
+
signaling.send({ kind: 'answer', data: { type: 'answer', sdp: answer } });
|
|
70
|
+
} else if (message.kind === 'ice') {
|
|
71
|
+
peer.addRemoteCandidate({ candidate: data.candidate!, mid: data.sdpMid ?? '0' });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
let remoteChannel: { close(): void } | undefined;
|
|
77
|
+
peer.onDataChannel((channel) => {
|
|
78
|
+
remoteChannel = channel;
|
|
79
|
+
let accepted = false;
|
|
80
|
+
const controller = startPairingHandshake(testIdentity, {
|
|
81
|
+
secret,
|
|
82
|
+
role: 'responder',
|
|
83
|
+
localFingerprint: localFingerprint!,
|
|
84
|
+
remoteFingerprint: hostFingerprint!,
|
|
85
|
+
send: (frame: TPairingFrame) => {
|
|
86
|
+
try {
|
|
87
|
+
channel.send(JSON.stringify(frame));
|
|
88
|
+
} catch {
|
|
89
|
+
/* channel closing */
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
timeoutMs: 8000,
|
|
93
|
+
});
|
|
94
|
+
controller.result.then(
|
|
95
|
+
() => {
|
|
96
|
+
accepted = true;
|
|
97
|
+
resolvePaired(true);
|
|
98
|
+
channel.send(JSON.stringify({ type: 'get-messages' }));
|
|
99
|
+
},
|
|
100
|
+
() => resolvePaired(false),
|
|
101
|
+
);
|
|
102
|
+
channel.onMessage((text) => {
|
|
103
|
+
if (!accepted) {
|
|
104
|
+
// Pre-accept only pairing frames arrive; route them to the handshake.
|
|
105
|
+
try {
|
|
106
|
+
controller.onFrame(JSON.parse(text) as TPairingFrame);
|
|
107
|
+
} catch {
|
|
108
|
+
/* ignore */
|
|
109
|
+
}
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
resolveSession(JSON.parse(text) as Record<string, unknown>);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return { session, paired, close: () => remoteChannel?.close() };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
describe('WebRtc pairing end-to-end (REMOTE-008)', () => {
|
|
120
|
+
it('matching secrets: both peers pair, the session is exposed, and get-messages round-trips', async () => {
|
|
121
|
+
const secret = 'shared-secret-256bit-base64url-xyz';
|
|
122
|
+
const [hostSig, remoteSig] = createInMemorySignalingPair();
|
|
123
|
+
const transport = new WebRtcTransport({cryptoContext: testIdentity, signaling: hostSig, secret });
|
|
124
|
+
transport.attach(createStubSession());
|
|
125
|
+
|
|
126
|
+
const remote = connectRemotePaired(remoteSig, secret);
|
|
127
|
+
await transport.start();
|
|
128
|
+
|
|
129
|
+
await expect(remote.paired).resolves.toBe(true);
|
|
130
|
+
const reply = await remote.session;
|
|
131
|
+
expect(reply.type).toBe('messages');
|
|
132
|
+
await transport.stop();
|
|
133
|
+
}, 20000);
|
|
134
|
+
|
|
135
|
+
it('mismatched secrets: both reject, the session is never exposed (fail closed)', async () => {
|
|
136
|
+
const [hostSig, remoteSig] = createInMemorySignalingPair();
|
|
137
|
+
const session = createStubSession();
|
|
138
|
+
const transport = new WebRtcTransport({cryptoContext: testIdentity, signaling: hostSig, secret: 'host-secret' });
|
|
139
|
+
transport.attach(session);
|
|
140
|
+
|
|
141
|
+
const remote = connectRemotePaired(remoteSig, 'different-secret');
|
|
142
|
+
await transport.start();
|
|
143
|
+
|
|
144
|
+
await expect(remote.paired).resolves.toBe(false);
|
|
145
|
+
// The host never built the session bridge → getMessages was never invoked over the channel.
|
|
146
|
+
expect(session.getMessages as ReturnType<typeof vi.fn>).not.toHaveBeenCalled();
|
|
147
|
+
await transport.stop();
|
|
148
|
+
}, 20000);
|
|
149
|
+
|
|
150
|
+
it('a driving connection the host operator does not approve never reaches the session', async () => {
|
|
151
|
+
const secret = 'shared-secret-256bit-base64url-xyz';
|
|
152
|
+
const [hostSig, remoteSig] = createInMemorySignalingPair();
|
|
153
|
+
const session = createStubSession();
|
|
154
|
+
const approve = vi.fn(async () => false);
|
|
155
|
+
const onPaired = vi.fn();
|
|
156
|
+
const transport = new WebRtcTransport({cryptoContext: testIdentity,
|
|
157
|
+
signaling: hostSig,
|
|
158
|
+
secret,
|
|
159
|
+
connectionApproval: { approve },
|
|
160
|
+
onPaired,
|
|
161
|
+
});
|
|
162
|
+
transport.attach(session);
|
|
163
|
+
|
|
164
|
+
const remote = connectRemotePaired(remoteSig, secret);
|
|
165
|
+
await transport.start();
|
|
166
|
+
|
|
167
|
+
// The handshake itself succeeds — the device is who it says — and the operator still decides.
|
|
168
|
+
await expect(remote.paired).resolves.toBe(true);
|
|
169
|
+
await vi.waitFor(() => expect(approve).toHaveBeenCalledTimes(1));
|
|
170
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
171
|
+
expect(session.getMessages as ReturnType<typeof vi.fn>).not.toHaveBeenCalled();
|
|
172
|
+
expect(onPaired).not.toHaveBeenCalled();
|
|
173
|
+
await transport.stop();
|
|
174
|
+
}, 20000);
|
|
175
|
+
|
|
176
|
+
it('a device that goes away while the operator decides withdraws the question and is never admitted', async () => {
|
|
177
|
+
const secret = 'shared-secret-256bit-base64url-xyz';
|
|
178
|
+
const [hostSig, remoteSig] = createInMemorySignalingPair();
|
|
179
|
+
let signal: AbortSignal | undefined;
|
|
180
|
+
let answer!: (value: boolean) => void;
|
|
181
|
+
const approve = vi.fn((context: { signal: AbortSignal }) => {
|
|
182
|
+
signal = context.signal;
|
|
183
|
+
return new Promise<boolean>((resolve) => (answer = resolve));
|
|
184
|
+
});
|
|
185
|
+
const onPaired = vi.fn();
|
|
186
|
+
const onPairingFailed = vi.fn();
|
|
187
|
+
const session = createStubSession();
|
|
188
|
+
const transport = new WebRtcTransport({cryptoContext: testIdentity,
|
|
189
|
+
signaling: hostSig,
|
|
190
|
+
secret,
|
|
191
|
+
connectionApproval: { approve },
|
|
192
|
+
onPaired,
|
|
193
|
+
onPairingFailed,
|
|
194
|
+
});
|
|
195
|
+
transport.attach(session);
|
|
196
|
+
|
|
197
|
+
const remote = connectRemotePaired(remoteSig, secret);
|
|
198
|
+
await transport.start();
|
|
199
|
+
await expect(remote.paired).resolves.toBe(true);
|
|
200
|
+
await vi.waitFor(() => expect(approve).toHaveBeenCalledTimes(1));
|
|
201
|
+
|
|
202
|
+
remote.close();
|
|
203
|
+
await vi.waitFor(() => expect(signal?.aborted).toBe(true), { timeout: 5000 });
|
|
204
|
+
expect(onPairingFailed).toHaveBeenCalledTimes(1);
|
|
205
|
+
answer(true);
|
|
206
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
207
|
+
expect(onPaired).not.toHaveBeenCalled();
|
|
208
|
+
expect(session.getMessages as ReturnType<typeof vi.fn>).not.toHaveBeenCalled();
|
|
209
|
+
await transport.stop();
|
|
210
|
+
}, 20000);
|
|
211
|
+
|
|
212
|
+
it('a driving connection the host operator approves reaches the session', async () => {
|
|
213
|
+
const secret = 'shared-secret-256bit-base64url-xyz';
|
|
214
|
+
const [hostSig, remoteSig] = createInMemorySignalingPair();
|
|
215
|
+
// The operator takes a moment: the device, already paired on its side, asks for the history
|
|
216
|
+
// meanwhile, and gets its answer once the operator says yes.
|
|
217
|
+
const approve = vi.fn(async () => {
|
|
218
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
219
|
+
return true;
|
|
220
|
+
});
|
|
221
|
+
const transport = new WebRtcTransport({cryptoContext: testIdentity,
|
|
222
|
+
signaling: hostSig,
|
|
223
|
+
secret,
|
|
224
|
+
connectionApproval: { approve },
|
|
225
|
+
});
|
|
226
|
+
transport.attach(createStubSession());
|
|
227
|
+
|
|
228
|
+
const remote = connectRemotePaired(remoteSig, secret);
|
|
229
|
+
await transport.start();
|
|
230
|
+
|
|
231
|
+
await expect(remote.paired).resolves.toBe(true);
|
|
232
|
+
const reply = await remote.session;
|
|
233
|
+
expect(reply.type).toBe('messages');
|
|
234
|
+
expect(approve).toHaveBeenCalledTimes(1);
|
|
235
|
+
await transport.stop();
|
|
236
|
+
}, 20000);
|
|
237
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { createIdentityContext } from '@robota-sdk/agent-remote-pairing';
|
|
2
|
+
const testIdentity = createIdentityContext('test-product');
|
|
3
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
deriveIdentityId,
|
|
7
|
+
exportPublicKey,
|
|
8
|
+
generateIdentityKeyPair,
|
|
9
|
+
importPublicKey,
|
|
10
|
+
startDeviceReconnect,
|
|
11
|
+
type TReconnectFrame,
|
|
12
|
+
} from '@robota-sdk/agent-remote-pairing';
|
|
13
|
+
import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
|
|
14
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
15
|
+
|
|
16
|
+
import { PairingGate, type IHostReconnectConfig } from '../pairing-gate.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* REMOTE-012 E3 TC-05 — the host gate's E3 admission paths, in isolation with a stub channel: first-pair
|
|
20
|
+
* ENROLLMENT (pin the device key, expose the session) and mutual RECONNECT (admit a pinned device, deny an
|
|
21
|
+
* unknown/revoked one) — all fail-closed.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
function stubSession(): IInteractiveSession {
|
|
25
|
+
return Object.assign(createTestInteractiveSession(), {
|
|
26
|
+
getMessages: vi.fn().mockReturnValue([]),
|
|
27
|
+
// ARCH-012: required. This double feeds `subscribeSessionEvents`, which calls it on every
|
|
28
|
+
// turn-authored event — omitting it throws the moment a case emits one.
|
|
29
|
+
getActiveDriverId: () => null,
|
|
30
|
+
on: vi.fn(),
|
|
31
|
+
off: vi.fn(),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A channel that captures sends and can route parsed frames to a sink (the counterpart controller). */
|
|
36
|
+
function stubChannel(sink?: (frame: unknown) => void): {
|
|
37
|
+
channel: { send: (d: string) => void; close: () => void };
|
|
38
|
+
sent: unknown[];
|
|
39
|
+
closed: () => boolean;
|
|
40
|
+
} {
|
|
41
|
+
const sent: unknown[] = [];
|
|
42
|
+
let closed = false;
|
|
43
|
+
return {
|
|
44
|
+
channel: {
|
|
45
|
+
send(d: string): void {
|
|
46
|
+
const parsed = JSON.parse(d);
|
|
47
|
+
sent.push(parsed);
|
|
48
|
+
sink?.(parsed);
|
|
49
|
+
},
|
|
50
|
+
close(): void {
|
|
51
|
+
closed = true;
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
sent,
|
|
55
|
+
closed: () => closed,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const FP = { localFingerprint: 'HOST:FP', remoteFingerprint: 'DEV:FP' };
|
|
60
|
+
|
|
61
|
+
async function hostConfig(over: Partial<IHostReconnectConfig> = {}): Promise<{
|
|
62
|
+
cfg: IHostReconnectConfig;
|
|
63
|
+
hostKeyPair: CryptoKeyPair;
|
|
64
|
+
}> {
|
|
65
|
+
const hostKeyPair = await generateIdentityKeyPair(true);
|
|
66
|
+
const hostPublicSpki = await exportPublicKey(hostKeyPair.publicKey);
|
|
67
|
+
return {
|
|
68
|
+
hostKeyPair,
|
|
69
|
+
cfg: {
|
|
70
|
+
hostIdentityId: await deriveIdentityId(hostPublicSpki),
|
|
71
|
+
hostPublicSpki,
|
|
72
|
+
hostPrivateKey: hostKeyPair.privateKey,
|
|
73
|
+
resolveDevicePublicKey: async () => undefined,
|
|
74
|
+
onEnroll: vi.fn(),
|
|
75
|
+
...over,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe('PairingGate E3 first-pair enrollment (REMOTE-012 TC-05)', () => {
|
|
81
|
+
it('after B3 accept, advertises the host key, pins the device key, and exposes the session', async () => {
|
|
82
|
+
const { cfg } = await hostConfig();
|
|
83
|
+
const onAccept = vi.fn();
|
|
84
|
+
// Fake handshake that accepts immediately (isolates the enrollment path from real B3 crypto).
|
|
85
|
+
const fakeHandshake = (() => ({
|
|
86
|
+
result: Promise.resolve({ sessionKey: 'k' }),
|
|
87
|
+
onFrame: () => {},
|
|
88
|
+
})) as never;
|
|
89
|
+
|
|
90
|
+
const { channel, sent } = stubChannel();
|
|
91
|
+
const gate = new PairingGate({cryptoContext: testIdentity,
|
|
92
|
+
channel,
|
|
93
|
+
session: stubSession(),
|
|
94
|
+
secret: 's',
|
|
95
|
+
role: 'initiator',
|
|
96
|
+
...FP,
|
|
97
|
+
reconnect: cfg,
|
|
98
|
+
onAccept,
|
|
99
|
+
startHandshake: fakeHandshake,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Client opens first-pair mode.
|
|
103
|
+
gate.onInbound(JSON.stringify({ t: 'pair-nonce', nonce: 'n' }));
|
|
104
|
+
await Promise.resolve();
|
|
105
|
+
await Promise.resolve();
|
|
106
|
+
// Host advertised its identity key.
|
|
107
|
+
expect(sent.some((f) => (f as { t?: string }).t === 'enroll-key')).toBe(true);
|
|
108
|
+
|
|
109
|
+
// Device replies with its public key → host pins it and exposes the session.
|
|
110
|
+
const deviceKey = await generateIdentityKeyPair(false);
|
|
111
|
+
const deviceSpki = await exportPublicKey(deviceKey.publicKey);
|
|
112
|
+
gate.onInbound(JSON.stringify({ t: 'enroll-key', spki: deviceSpki }));
|
|
113
|
+
await vi.waitFor(() => expect(onAccept).toHaveBeenCalledTimes(1));
|
|
114
|
+
expect(cfg.onEnroll).toHaveBeenCalledWith(await deriveIdentityId(deviceSpki), deviceSpki);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('PairingGate E3 reconnect (REMOTE-012 TC-05)', () => {
|
|
119
|
+
async function pinnedDevice(): Promise<{
|
|
120
|
+
keyPair: CryptoKeyPair;
|
|
121
|
+
deviceId: string;
|
|
122
|
+
publicKey: CryptoKey;
|
|
123
|
+
}> {
|
|
124
|
+
const keyPair = await generateIdentityKeyPair(false);
|
|
125
|
+
const spki = await exportPublicKey(keyPair.publicKey);
|
|
126
|
+
return {
|
|
127
|
+
keyPair,
|
|
128
|
+
deviceId: await deriveIdentityId(spki),
|
|
129
|
+
publicKey: await importPublicKey(spki),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
it('admits a pinned device via mutual reconnect and exposes the session (no re-enroll)', async () => {
|
|
134
|
+
const device = await pinnedDevice();
|
|
135
|
+
const { cfg } = await hostConfig({
|
|
136
|
+
resolveDevicePublicKey: async (id) => (id === device.deviceId ? device.publicKey : undefined),
|
|
137
|
+
});
|
|
138
|
+
const hostPublicKey = await importPublicKey(cfg.hostPublicSpki);
|
|
139
|
+
const onAccept = vi.fn();
|
|
140
|
+
const onReject = vi.fn();
|
|
141
|
+
|
|
142
|
+
let gate!: PairingGate;
|
|
143
|
+
let deviceCtrl!: ReturnType<typeof startDeviceReconnect>;
|
|
144
|
+
// Route host→device frames into the device controller.
|
|
145
|
+
const { channel } = stubChannel((frame) => deviceCtrl.onFrame(frame as TReconnectFrame));
|
|
146
|
+
gate = new PairingGate({cryptoContext: testIdentity,
|
|
147
|
+
channel,
|
|
148
|
+
session: stubSession(),
|
|
149
|
+
secret: 's',
|
|
150
|
+
role: 'initiator',
|
|
151
|
+
...FP,
|
|
152
|
+
reconnect: cfg,
|
|
153
|
+
onAccept,
|
|
154
|
+
onReject,
|
|
155
|
+
});
|
|
156
|
+
// Device drives the reconnect; its frames go into the gate.
|
|
157
|
+
deviceCtrl = startDeviceReconnect(testIdentity, {
|
|
158
|
+
deviceId: device.deviceId,
|
|
159
|
+
hostIdentityId: cfg.hostIdentityId,
|
|
160
|
+
localFingerprint: FP.remoteFingerprint,
|
|
161
|
+
remoteFingerprint: FP.localFingerprint,
|
|
162
|
+
devicePrivateKey: device.keyPair.privateKey,
|
|
163
|
+
pinnedHostPublicKey: hostPublicKey,
|
|
164
|
+
send: (frame) => gate.onInbound(JSON.stringify(frame)),
|
|
165
|
+
timeoutMs: 1000,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await deviceCtrl.result;
|
|
169
|
+
await vi.waitFor(() => expect(onAccept).toHaveBeenCalledTimes(1));
|
|
170
|
+
expect(onReject).not.toHaveBeenCalled();
|
|
171
|
+
expect(cfg.onEnroll).not.toHaveBeenCalled();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('fail-closed: an unknown/revoked device is denied (channel closed, no session)', async () => {
|
|
175
|
+
const { cfg } = await hostConfig({ resolveDevicePublicKey: async () => undefined });
|
|
176
|
+
const onAccept = vi.fn();
|
|
177
|
+
const onReject = vi.fn();
|
|
178
|
+
const { channel, closed } = stubChannel();
|
|
179
|
+
const gate = new PairingGate({cryptoContext: testIdentity,
|
|
180
|
+
channel,
|
|
181
|
+
session: stubSession(),
|
|
182
|
+
secret: 's',
|
|
183
|
+
role: 'initiator',
|
|
184
|
+
...FP,
|
|
185
|
+
reconnect: cfg,
|
|
186
|
+
onAccept,
|
|
187
|
+
onReject,
|
|
188
|
+
});
|
|
189
|
+
gate.onInbound(JSON.stringify({ t: 'rc-hello', deviceId: 'ghost', nonceDevice: 'ZGV2' }));
|
|
190
|
+
await vi.waitFor(() => expect(onReject).toHaveBeenCalledTimes(1));
|
|
191
|
+
expect(onAccept).not.toHaveBeenCalled();
|
|
192
|
+
expect(closed()).toBe(true);
|
|
193
|
+
});
|
|
194
|
+
});
|