@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,682 @@
|
|
|
1
|
+
import { createIdentityContext } from '@robota-sdk/agent-remote-pairing';
|
|
2
|
+
const testIdentity = createIdentityContext('test-product');
|
|
3
|
+
import {
|
|
4
|
+
DeviceHandshakeError,
|
|
5
|
+
derivePairRendezvous,
|
|
6
|
+
startDeviceHandshake,
|
|
7
|
+
type IListUpdate,
|
|
8
|
+
} from '@robota-sdk/agent-remote-pairing';
|
|
9
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
DeviceMeshNode,
|
|
13
|
+
type IDeviceMeshLink,
|
|
14
|
+
type IDeviceMeshNodeOptions,
|
|
15
|
+
} from '../device-mesh-node.js';
|
|
16
|
+
import { MeshLinkEndedError, MeshPeerLink, type TMeshLinkEnd } from '../mesh-peer-link.js';
|
|
17
|
+
import { createInMemoryMeshRelayHub, type IInMemoryMeshRelayHub } from '../mesh-relay.js';
|
|
18
|
+
import {
|
|
19
|
+
ALL_CAPABILITIES,
|
|
20
|
+
buildMeshWorld,
|
|
21
|
+
type IMeshTestDevice,
|
|
22
|
+
type IMeshTestWorld,
|
|
23
|
+
} from './mesh-fixtures.js';
|
|
24
|
+
|
|
25
|
+
import { loadDataChannel, type IDataChannelModule } from '../datachannel-loader.js';
|
|
26
|
+
import { RtcPeer } from '../rtc-peer.js';
|
|
27
|
+
|
|
28
|
+
let world: IMeshTestWorld;
|
|
29
|
+
const nodes: DeviceMeshNode[] = [];
|
|
30
|
+
|
|
31
|
+
beforeAll(async () => {
|
|
32
|
+
world = await buildMeshWorld();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
for (const node of nodes.splice(0)) node.stop();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/** The WebRTC module, counting the peer connections a node creates. */
|
|
40
|
+
function countingDataChannel(): { load: () => IDataChannelModule; created: () => number } {
|
|
41
|
+
let count = 0;
|
|
42
|
+
const real = loadDataChannel();
|
|
43
|
+
const Counted = function (
|
|
44
|
+
name: string,
|
|
45
|
+
config: ConstructorParameters<IDataChannelModule['PeerConnection']>[1],
|
|
46
|
+
) {
|
|
47
|
+
count += 1;
|
|
48
|
+
return new real.PeerConnection(name, config);
|
|
49
|
+
} as unknown as IDataChannelModule['PeerConnection'];
|
|
50
|
+
return { load: () => ({ PeerConnection: Counted }), created: () => count };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** An offer SDP from a real connection, with its connection for closing. */
|
|
54
|
+
async function realOffer(): Promise<{ sdp: string; close: () => void }> {
|
|
55
|
+
const peer = new RtcPeer();
|
|
56
|
+
peer.createDataChannel('agent-mesh');
|
|
57
|
+
const sdp = await peer.createOffer();
|
|
58
|
+
return { sdp, close: () => peer.close() };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function node(
|
|
62
|
+
hub: IInMemoryMeshRelayHub,
|
|
63
|
+
device: IMeshTestDevice,
|
|
64
|
+
over: Partial<IDeviceMeshNodeOptions> = {},
|
|
65
|
+
): DeviceMeshNode {
|
|
66
|
+
const created = new DeviceMeshNode({
|
|
67
|
+
cryptoContext: testIdentity,
|
|
68
|
+
identity: world.identity(device),
|
|
69
|
+
sessionDescriptor: device.session,
|
|
70
|
+
localPolicy: ALL_CAPABILITIES,
|
|
71
|
+
relay: hub.connect(),
|
|
72
|
+
connectTimeoutMs: 15_000,
|
|
73
|
+
...over,
|
|
74
|
+
});
|
|
75
|
+
nodes.push(created);
|
|
76
|
+
return created;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function nextMessage(link: IDeviceMeshLink): Promise<string> {
|
|
80
|
+
return new Promise((resolve) => {
|
|
81
|
+
const off = link.onMessage((body) => {
|
|
82
|
+
off();
|
|
83
|
+
resolve(body);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe('DeviceMeshNode — CLI↔CLI connection over WebRTC', () => {
|
|
89
|
+
it('two devices connect through the relay, admit each other by the device handshake, and exchange messages', async () => {
|
|
90
|
+
const hub = createInMemoryMeshRelayHub();
|
|
91
|
+
const low = node(hub, world.low);
|
|
92
|
+
const high = node(hub, world.high);
|
|
93
|
+
await Promise.all([low.start(), high.start()]);
|
|
94
|
+
|
|
95
|
+
const [atLow, atHigh] = await Promise.all([
|
|
96
|
+
low.connect(world.high.cert.deviceId),
|
|
97
|
+
high.connect(world.low.cert.deviceId),
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
expect(atLow.admission.deviceId).toBe(world.high.cert.deviceId);
|
|
101
|
+
expect(atHigh.admission.deviceId).toBe(world.low.cert.deviceId);
|
|
102
|
+
expect(atLow.admission.trust).toBe('same-user-different-host');
|
|
103
|
+
expect(atLow.admission.locality).toBe('another-host');
|
|
104
|
+
// Each end names the same connection: its own fingerprint is the one the other end verified.
|
|
105
|
+
expect(atLow.channelBinding.localFingerprint).toBe(atHigh.channelBinding.remoteFingerprint);
|
|
106
|
+
expect(atHigh.channelBinding.localFingerprint).toBe(atLow.channelBinding.remoteFingerprint);
|
|
107
|
+
expect(atLow.channelBinding.localFingerprint).not.toBe(atLow.channelBinding.remoteFingerprint);
|
|
108
|
+
|
|
109
|
+
const toHigh = nextMessage(atHigh);
|
|
110
|
+
atLow.send('hello from low');
|
|
111
|
+
await expect(toHigh).resolves.toBe('hello from low');
|
|
112
|
+
const toLow = nextMessage(atLow);
|
|
113
|
+
atHigh.send('hello from high');
|
|
114
|
+
await expect(toLow).resolves.toBe('hello from high');
|
|
115
|
+
}, 40_000);
|
|
116
|
+
|
|
117
|
+
it('a peer this connection does not allow to message is not heard, and the connection ends', async () => {
|
|
118
|
+
const hub = createInMemoryMeshRelayHub();
|
|
119
|
+
// This device's policy grants the peer presence only.
|
|
120
|
+
const low = node(hub, world.low, { localPolicy: ['presence'] });
|
|
121
|
+
const high = node(hub, world.high);
|
|
122
|
+
await Promise.all([low.start(), high.start()]);
|
|
123
|
+
const [atLow, atHigh] = await Promise.all([
|
|
124
|
+
low.connect(world.high.cert.deviceId),
|
|
125
|
+
high.connect(world.low.cert.deviceId),
|
|
126
|
+
]);
|
|
127
|
+
expect(atLow.admission.capabilities).toEqual(['presence']);
|
|
128
|
+
await expect(atLow.authority.authorize('message')).resolves.toEqual({
|
|
129
|
+
allowed: false,
|
|
130
|
+
reason: 'not-granted',
|
|
131
|
+
});
|
|
132
|
+
const heard: string[] = [];
|
|
133
|
+
let closed = false;
|
|
134
|
+
atLow.onMessage((body) => heard.push(body));
|
|
135
|
+
atLow.onClose(() => (closed = true));
|
|
136
|
+
|
|
137
|
+
atHigh.send('should not be heard');
|
|
138
|
+
|
|
139
|
+
await expect.poll(() => closed).toBe(true);
|
|
140
|
+
expect(heard).toEqual([]);
|
|
141
|
+
}, 40_000);
|
|
142
|
+
|
|
143
|
+
it('glare: both devices reaching for each other at once make exactly one connection, offered by the lower id', async () => {
|
|
144
|
+
const hub = createInMemoryMeshRelayHub();
|
|
145
|
+
const lowRtc = countingDataChannel();
|
|
146
|
+
const highRtc = countingDataChannel();
|
|
147
|
+
const low = node(hub, world.low, { loadDataChannel: lowRtc.load });
|
|
148
|
+
const high = node(hub, world.high, { loadDataChannel: highRtc.load });
|
|
149
|
+
expect(low.roleFor(world.high.cert.deviceId)).toBeUndefined();
|
|
150
|
+
|
|
151
|
+
// Both start (each announcing itself) and both ask for the connection concurrently.
|
|
152
|
+
await Promise.all([low.start(), high.start()]);
|
|
153
|
+
const links = await Promise.all([
|
|
154
|
+
low.connect(world.high.cert.deviceId),
|
|
155
|
+
high.connect(world.low.cert.deviceId),
|
|
156
|
+
low.connect(world.high.cert.deviceId),
|
|
157
|
+
high.connect(world.low.cert.deviceId),
|
|
158
|
+
]);
|
|
159
|
+
// Let any late hello settle; a second connection would be created by now.
|
|
160
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
161
|
+
|
|
162
|
+
expect(low.roleFor(world.high.cert.deviceId)).toBe('offerer');
|
|
163
|
+
expect(high.roleFor(world.low.cert.deviceId)).toBe('answerer');
|
|
164
|
+
expect(lowRtc.created()).toBe(1);
|
|
165
|
+
expect(highRtc.created()).toBe(1);
|
|
166
|
+
expect(links[0]).toBe(links[2]);
|
|
167
|
+
expect(links[1]).toBe(links[3]);
|
|
168
|
+
}, 40_000);
|
|
169
|
+
|
|
170
|
+
it("glare: an offer toward the pair's offerer is never taken", async () => {
|
|
171
|
+
const hub = createInMemoryMeshRelayHub();
|
|
172
|
+
const lowRtc = countingDataChannel();
|
|
173
|
+
const low = node(hub, world.low, { loadDataChannel: lowRtc.load });
|
|
174
|
+
const high = node(hub, world.high);
|
|
175
|
+
await Promise.all([low.start(), high.start()]);
|
|
176
|
+
const established = await low.connect(world.high.cert.deviceId);
|
|
177
|
+
let closed = false;
|
|
178
|
+
established.onClose(() => (closed = true));
|
|
179
|
+
|
|
180
|
+
// The higher-id side of the pair (anyone who can reach the pair's inbox) sends an offer to the
|
|
181
|
+
// lower-id device, as if the higher one had decided to open the connection itself.
|
|
182
|
+
const topics = await (
|
|
183
|
+
await derivePairRendezvous(testIdentity, {
|
|
184
|
+
ownKaPrivateKey: world.high.ka.privateKey,
|
|
185
|
+
own: world.high.cert,
|
|
186
|
+
peerDeviceId: world.low.cert.deviceId,
|
|
187
|
+
lists: world.identity(world.high),
|
|
188
|
+
})
|
|
189
|
+
).relayInbox();
|
|
190
|
+
const rogue = hub.connect();
|
|
191
|
+
const offerer = await realOffer();
|
|
192
|
+
rogue.send(topics.outbound, {
|
|
193
|
+
v: 1,
|
|
194
|
+
kind: 'offer',
|
|
195
|
+
from: 'AAAAAAAAAAAAAAAAAAAAAA',
|
|
196
|
+
to: low.instance,
|
|
197
|
+
cid: 'BBBBBBBBBBBBBBBBBBBBBB',
|
|
198
|
+
sdp: offerer.sdp,
|
|
199
|
+
});
|
|
200
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
201
|
+
|
|
202
|
+
// No second connection, and the pair's one connection is untouched.
|
|
203
|
+
expect(lowRtc.created()).toBe(1);
|
|
204
|
+
expect(closed).toBe(false);
|
|
205
|
+
expect(low.link(world.high.cert.deviceId)).toBe(established);
|
|
206
|
+
offerer.close();
|
|
207
|
+
}, 20_000);
|
|
208
|
+
|
|
209
|
+
it('a revoked device is refused: the freshness lookup brings a revocation and the handshake refuses it', async () => {
|
|
210
|
+
const hub = createInMemoryMeshRelayHub();
|
|
211
|
+
const revocation = await world.revoking(world.high);
|
|
212
|
+
const refusals: unknown[] = [];
|
|
213
|
+
const low = node(hub, world.low, {
|
|
214
|
+
// A signing-key holder answers the freshness lookup with a list that revokes `high`.
|
|
215
|
+
fetchLatestLists: () => Promise.resolve({ revocation }),
|
|
216
|
+
});
|
|
217
|
+
low.onRefusal((refusal) => refusals.push(refusal.error));
|
|
218
|
+
const high = node(hub, world.high);
|
|
219
|
+
const admitted: IDeviceMeshLink[] = [];
|
|
220
|
+
low.onLink((link) => admitted.push(link));
|
|
221
|
+
await Promise.all([low.start(), high.start()]);
|
|
222
|
+
|
|
223
|
+
const [atLow, atHigh] = await Promise.allSettled([
|
|
224
|
+
low.connect(world.high.cert.deviceId),
|
|
225
|
+
high.connect(world.low.cert.deviceId),
|
|
226
|
+
]);
|
|
227
|
+
|
|
228
|
+
// Both sides: a link is admitted only once each side admitted the other.
|
|
229
|
+
expect(atLow.status).toBe('rejected');
|
|
230
|
+
expect(atHigh.status).toBe('rejected');
|
|
231
|
+
expect(admitted).toHaveLength(0);
|
|
232
|
+
// Refused by the handshake's chain check, or dropped as soon as the adopted list is in force.
|
|
233
|
+
const error = refusals[0] as Error;
|
|
234
|
+
if (error instanceof MeshLinkEndedError) {
|
|
235
|
+
// The refusal says what happened: which step, which role, what the connection went through.
|
|
236
|
+
expect(error.end).toBe('handshake');
|
|
237
|
+
expect(error.stage).toBe('handshake');
|
|
238
|
+
expect(error.role).toBe('offerer');
|
|
239
|
+
expect(error.peer?.history).toContain('connected');
|
|
240
|
+
expect(error.cause).toBeInstanceOf(DeviceHandshakeError);
|
|
241
|
+
expect((error.cause as DeviceHandshakeError).chain?.reason).toBe('revoked');
|
|
242
|
+
expect(error.message).toMatch(/during handshake as offerer.*revoked.*connection connected/);
|
|
243
|
+
} else {
|
|
244
|
+
expect(error.message).toMatch(/revoked/);
|
|
245
|
+
}
|
|
246
|
+
// The revocation is in force for the rest of the run: the device is no longer reached for.
|
|
247
|
+
await expect(low.connect(world.high.cert.deviceId)).rejects.toThrow(
|
|
248
|
+
/not a rostered, unrevoked/,
|
|
249
|
+
);
|
|
250
|
+
// Not even when the caller later hands over the older lists it still has stored.
|
|
251
|
+
await low.refresh({ identity: world.identity(world.low) });
|
|
252
|
+
await expect(low.connect(world.high.cert.deviceId)).rejects.toThrow(
|
|
253
|
+
/not a rostered, unrevoked/,
|
|
254
|
+
);
|
|
255
|
+
}, 40_000);
|
|
256
|
+
|
|
257
|
+
it('one connection per pair: a peer that restarts gets a fresh connection that replaces the old one', async () => {
|
|
258
|
+
const hub = createInMemoryMeshRelayHub();
|
|
259
|
+
const approve = vi.fn(async () => true);
|
|
260
|
+
const low = node(hub, world.low, { operatorApprover: { approve } });
|
|
261
|
+
const first = node(hub, world.high);
|
|
262
|
+
await Promise.all([low.start(), first.start()]);
|
|
263
|
+
const before = await low.connect(world.high.cert.deviceId);
|
|
264
|
+
await expect(before.authority.authorize('drive')).resolves.toEqual({ allowed: true });
|
|
265
|
+
let beforeClosed = false;
|
|
266
|
+
before.onClose(() => (beforeClosed = true));
|
|
267
|
+
|
|
268
|
+
// The same device runs again (a new process) while the old connection has not been noticed as gone.
|
|
269
|
+
const second = node(hub, world.high);
|
|
270
|
+
await second.start();
|
|
271
|
+
const atSecond = await second.connect(world.low.cert.deviceId);
|
|
272
|
+
|
|
273
|
+
await expect
|
|
274
|
+
.poll(() => {
|
|
275
|
+
const current = low.link(world.high.cert.deviceId);
|
|
276
|
+
return current !== undefined && current !== before;
|
|
277
|
+
})
|
|
278
|
+
.toBe(true);
|
|
279
|
+
expect(beforeClosed).toBe(true);
|
|
280
|
+
await expect(before.authority.authorize('drive')).resolves.toEqual({
|
|
281
|
+
allowed: false,
|
|
282
|
+
reason: 'declined',
|
|
283
|
+
});
|
|
284
|
+
await expect(before.authority.authorize('message')).resolves.toEqual({
|
|
285
|
+
allowed: false,
|
|
286
|
+
reason: 'declined',
|
|
287
|
+
});
|
|
288
|
+
const after = low.link(world.high.cert.deviceId)!;
|
|
289
|
+
await expect(after.authority.authorize('drive')).resolves.toEqual({ allowed: true });
|
|
290
|
+
expect(approve).toHaveBeenCalledTimes(2);
|
|
291
|
+
const received = nextMessage(atSecond);
|
|
292
|
+
after.send('to the new run');
|
|
293
|
+
await expect(received).resolves.toBe('to the new run');
|
|
294
|
+
}, 40_000);
|
|
295
|
+
|
|
296
|
+
it('forged announcements neither cut an admitted connection nor open connections without bound', async () => {
|
|
297
|
+
const hub = createInMemoryMeshRelayHub();
|
|
298
|
+
const lowRtc = countingDataChannel();
|
|
299
|
+
const low = node(hub, world.low, { loadDataChannel: lowRtc.load });
|
|
300
|
+
const high = node(hub, world.high);
|
|
301
|
+
await Promise.all([low.start(), high.start()]);
|
|
302
|
+
const established = await low.connect(world.high.cert.deviceId);
|
|
303
|
+
let closed = false;
|
|
304
|
+
established.onClose(() => (closed = true));
|
|
305
|
+
expect(lowRtc.created()).toBe(1);
|
|
306
|
+
|
|
307
|
+
// Anyone who can reach the pair's inbox (the relay) announces 50 "new runs" of the peer.
|
|
308
|
+
const topics = await (
|
|
309
|
+
await derivePairRendezvous(testIdentity, {
|
|
310
|
+
ownKaPrivateKey: world.high.ka.privateKey,
|
|
311
|
+
own: world.high.cert,
|
|
312
|
+
peerDeviceId: world.low.cert.deviceId,
|
|
313
|
+
lists: world.identity(world.high),
|
|
314
|
+
})
|
|
315
|
+
).relayInbox();
|
|
316
|
+
const forger = hub.connect();
|
|
317
|
+
for (let i = 0; i < 50; i += 1) {
|
|
318
|
+
forger.send(topics.outbound, {
|
|
319
|
+
v: 1,
|
|
320
|
+
kind: 'hello',
|
|
321
|
+
from: `${String(i).padStart(2, '0')}${'F'.repeat(20)}`,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
await new Promise((resolve) => setTimeout(resolve, 1_500));
|
|
325
|
+
|
|
326
|
+
expect(closed).toBe(false);
|
|
327
|
+
expect(low.link(world.high.cert.deviceId)).toBe(established);
|
|
328
|
+
// Paced: one attempt at once and at most one more per second, not one per announcement.
|
|
329
|
+
expect(lowRtc.created()).toBeLessThanOrEqual(3);
|
|
330
|
+
const received = nextMessage(await high.connect(world.low.cert.deviceId));
|
|
331
|
+
established.send('still connected');
|
|
332
|
+
await expect(received).resolves.toBe('still connected');
|
|
333
|
+
}, 40_000);
|
|
334
|
+
|
|
335
|
+
it('lists handed over later apply to the running node: a revoked peer loses its connection', async () => {
|
|
336
|
+
const hub = createInMemoryMeshRelayHub();
|
|
337
|
+
const low = node(hub, world.low, { operatorApprover: { approve: async () => true } });
|
|
338
|
+
const high = node(hub, world.high);
|
|
339
|
+
await Promise.all([low.start(), high.start()]);
|
|
340
|
+
const established = await low.connect(world.high.cert.deviceId);
|
|
341
|
+
await expect(established.authority.authorize('drive')).resolves.toEqual({ allowed: true });
|
|
342
|
+
let closed = false;
|
|
343
|
+
established.onClose(() => (closed = true));
|
|
344
|
+
|
|
345
|
+
await low.refresh({
|
|
346
|
+
identity: world.identity(world.low, { revocation: await world.revoking(world.high) }),
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
expect(closed).toBe(true);
|
|
350
|
+
await expect(established.authority.authorize('drive')).resolves.toEqual({
|
|
351
|
+
allowed: false,
|
|
352
|
+
reason: 'declined',
|
|
353
|
+
});
|
|
354
|
+
await expect(established.authority.authorize('message')).resolves.toEqual({
|
|
355
|
+
allowed: false,
|
|
356
|
+
reason: 'declined',
|
|
357
|
+
});
|
|
358
|
+
expect(low.link(world.high.cert.deviceId)).toBeUndefined();
|
|
359
|
+
await expect(low.connect(world.high.cert.deviceId)).rejects.toThrow(
|
|
360
|
+
/not a rostered, unrevoked/,
|
|
361
|
+
);
|
|
362
|
+
}, 40_000);
|
|
363
|
+
|
|
364
|
+
it('closing an admitted link withdraws an unresolved operator question and ends its authority', async () => {
|
|
365
|
+
const hub = createInMemoryMeshRelayHub();
|
|
366
|
+
let questionSignal: AbortSignal | undefined;
|
|
367
|
+
const low = node(hub, world.low, {
|
|
368
|
+
operatorApprover: {
|
|
369
|
+
approve: (_request, signal) => {
|
|
370
|
+
questionSignal = signal;
|
|
371
|
+
return new Promise<boolean>(() => {});
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
const high = node(hub, world.high);
|
|
376
|
+
await Promise.all([low.start(), high.start()]);
|
|
377
|
+
const established = await low.connect(world.high.cert.deviceId);
|
|
378
|
+
const pending = established.authority.authorize('drive');
|
|
379
|
+
established.close();
|
|
380
|
+
expect(questionSignal?.aborted).toBe(true);
|
|
381
|
+
await expect(pending).resolves.toEqual({ allowed: false, reason: 'declined' });
|
|
382
|
+
await expect(established.authority.authorize('message')).resolves.toEqual({
|
|
383
|
+
allowed: false,
|
|
384
|
+
reason: 'declined',
|
|
385
|
+
});
|
|
386
|
+
}, 40_000);
|
|
387
|
+
|
|
388
|
+
it('stopping rejects a pending connect at once', async () => {
|
|
389
|
+
const hub = createInMemoryMeshRelayHub();
|
|
390
|
+
const low = node(hub, world.low);
|
|
391
|
+
await low.start();
|
|
392
|
+
const pending = low.connect(world.high.cert.deviceId, 30_000);
|
|
393
|
+
low.stop();
|
|
394
|
+
await expect(pending).rejects.toThrow(/stopped/);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('a device whose own list revokes the peer does not even reach for it', async () => {
|
|
398
|
+
const hub = createInMemoryMeshRelayHub();
|
|
399
|
+
const revocation = await world.revoking(world.high);
|
|
400
|
+
const low = new DeviceMeshNode({
|
|
401
|
+
cryptoContext: testIdentity,
|
|
402
|
+
identity: world.identity(world.low, { revocation }),
|
|
403
|
+
sessionDescriptor: world.low.session,
|
|
404
|
+
localPolicy: ALL_CAPABILITIES,
|
|
405
|
+
relay: hub.connect(),
|
|
406
|
+
});
|
|
407
|
+
nodes.push(low);
|
|
408
|
+
await low.start();
|
|
409
|
+
await expect(low.connect(world.high.cert.deviceId)).rejects.toThrow(
|
|
410
|
+
/not a rostered, unrevoked/,
|
|
411
|
+
);
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
describe('DeviceMeshNode — newer lists reach linked devices over the live connection', () => {
|
|
416
|
+
it('a list one device takes up is pushed to a linked device, whatever that device lets the peer ask', async () => {
|
|
417
|
+
const hub = createInMemoryMeshRelayHub();
|
|
418
|
+
const adopted: IListUpdate[] = [];
|
|
419
|
+
const low = node(hub, world.low);
|
|
420
|
+
// Lists are not a capability: a device that grants its peer presence only still takes them.
|
|
421
|
+
const high = node(hub, world.high, {
|
|
422
|
+
localPolicy: ['presence'],
|
|
423
|
+
onListsAdopted: (update) => adopted.push(update),
|
|
424
|
+
});
|
|
425
|
+
await Promise.all([low.start(), high.start()]);
|
|
426
|
+
const [, atHigh] = await Promise.all([
|
|
427
|
+
low.connect(world.high.cert.deviceId),
|
|
428
|
+
high.connect(world.low.cert.deviceId),
|
|
429
|
+
]);
|
|
430
|
+
const heard: string[] = [];
|
|
431
|
+
let closed = false;
|
|
432
|
+
atHigh.onMessage((body) => heard.push(body));
|
|
433
|
+
atHigh.onClose(() => (closed = true));
|
|
434
|
+
|
|
435
|
+
const revocation = await world.revoking(world.third);
|
|
436
|
+
await low.refresh({ identity: world.identity(world.low, { revocation }) });
|
|
437
|
+
|
|
438
|
+
await expect.poll(() => adopted.length).toBe(1);
|
|
439
|
+
expect(adopted[0]!.revocation).toEqual(revocation);
|
|
440
|
+
expect(adopted[0]!.roster).toBeUndefined();
|
|
441
|
+
await expect(high.connect(world.third.cert.deviceId)).rejects.toThrow(
|
|
442
|
+
/not a rostered, unrevoked/,
|
|
443
|
+
);
|
|
444
|
+
// The lists are the node's own business: the application hears nothing and the link stays.
|
|
445
|
+
expect(heard).toEqual([]);
|
|
446
|
+
expect(closed).toBe(false);
|
|
447
|
+
expect(high.link(world.low.cert.deviceId)).toBe(atHigh);
|
|
448
|
+
}, 40_000);
|
|
449
|
+
|
|
450
|
+
it("a sync that fails while lists advance is the caller's to handle, never an unhandled rejection", async () => {
|
|
451
|
+
const hub = createInMemoryMeshRelayHub();
|
|
452
|
+
const relay = hub.connect();
|
|
453
|
+
let failing = false;
|
|
454
|
+
const low = node(hub, world.low, {
|
|
455
|
+
relay: {
|
|
456
|
+
...relay,
|
|
457
|
+
declarePresence: (topics: readonly string[]) => {
|
|
458
|
+
if (failing) throw new Error('relay gone');
|
|
459
|
+
relay.declarePresence(topics);
|
|
460
|
+
},
|
|
461
|
+
},
|
|
462
|
+
});
|
|
463
|
+
await low.start();
|
|
464
|
+
const unhandled: unknown[] = [];
|
|
465
|
+
const onUnhandled = (reason: unknown): void => void unhandled.push(reason);
|
|
466
|
+
process.on('unhandledRejection', onUnhandled);
|
|
467
|
+
try {
|
|
468
|
+
failing = true;
|
|
469
|
+
const revocation = await world.revoking(world.third);
|
|
470
|
+
await expect(
|
|
471
|
+
low.refresh({ identity: world.identity(world.low, { revocation }) }),
|
|
472
|
+
).rejects.toThrow(/relay gone/);
|
|
473
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
474
|
+
expect(unhandled).toEqual([]);
|
|
475
|
+
} finally {
|
|
476
|
+
process.off('unhandledRejection', onUnhandled);
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it('a pushed list that does not verify, or is not newer, is ignored and the link stays', async () => {
|
|
481
|
+
const hub = createInMemoryMeshRelayHub();
|
|
482
|
+
const adopted: IListUpdate[] = [];
|
|
483
|
+
const low = node(hub, world.low);
|
|
484
|
+
const high = node(hub, world.high, { onListsAdopted: (update) => adopted.push(update) });
|
|
485
|
+
await Promise.all([low.start(), high.start()]);
|
|
486
|
+
const [atLow, atHigh] = await Promise.all([
|
|
487
|
+
low.connect(world.high.cert.deviceId),
|
|
488
|
+
high.connect(world.low.cert.deviceId),
|
|
489
|
+
]);
|
|
490
|
+
const heard: string[] = [];
|
|
491
|
+
atHigh.onMessage((body) => heard.push(body));
|
|
492
|
+
|
|
493
|
+
const revocation = await world.revoking(world.third);
|
|
494
|
+
const current = world.identity(world.high);
|
|
495
|
+
for (const frame of [
|
|
496
|
+
// Signed for seq 11, claiming 12.
|
|
497
|
+
{ t: 'mesh-lists', revocation: { ...revocation, seq: 12 } },
|
|
498
|
+
// The same list the device already holds.
|
|
499
|
+
{ t: 'mesh-lists', roster: current.roster, revocation: current.revocation },
|
|
500
|
+
{ t: 'mesh-lists', revocation: 'not a list' },
|
|
501
|
+
]) {
|
|
502
|
+
atLow.send(JSON.stringify(frame));
|
|
503
|
+
}
|
|
504
|
+
atLow.send('still here');
|
|
505
|
+
|
|
506
|
+
await expect.poll(() => heard).toEqual(['still here']);
|
|
507
|
+
expect(adopted).toEqual([]);
|
|
508
|
+
expect(high.link(world.low.cert.deviceId)).toBe(atHigh);
|
|
509
|
+
await expect(high.connect(world.third.cert.deviceId, 1)).rejects.toThrow(/in time/);
|
|
510
|
+
}, 40_000);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
describe('DeviceMeshNode — the peer is the device its inbox belongs to', () => {
|
|
514
|
+
it("another rostered device reaching the pair's inbox is refused before it is told it was admitted", async () => {
|
|
515
|
+
const hub = createInMemoryMeshRelayHub();
|
|
516
|
+
const high = node(hub, world.high);
|
|
517
|
+
const refusals: string[] = [];
|
|
518
|
+
high.onRefusal((refusal) => refusals.push(refusal.end));
|
|
519
|
+
const linked: IDeviceMeshLink[] = [];
|
|
520
|
+
high.onLink((link) => linked.push(link));
|
|
521
|
+
await high.start();
|
|
522
|
+
|
|
523
|
+
// The relay moves a third device's offer into the low–high inbox and returns high's answer to it.
|
|
524
|
+
const topics = await (
|
|
525
|
+
await derivePairRendezvous(testIdentity, {
|
|
526
|
+
ownKaPrivateKey: world.low.ka.privateKey,
|
|
527
|
+
own: world.low.cert,
|
|
528
|
+
peerDeviceId: world.high.cert.deviceId,
|
|
529
|
+
lists: world.identity(world.low),
|
|
530
|
+
})
|
|
531
|
+
).relayInbox();
|
|
532
|
+
const relay = hub.connect();
|
|
533
|
+
relay.declarePresence([topics.inbound]);
|
|
534
|
+
const instance = 'DDDDDDDDDDDDDDDDDDDDDD';
|
|
535
|
+
const cid = 'EEEEEEEEEEEEEEEEEEEEEE';
|
|
536
|
+
let thirdAdmitted = false;
|
|
537
|
+
const third = new MeshPeerLink({
|
|
538
|
+
role: 'offerer',
|
|
539
|
+
createPeer: () => new RtcPeer(),
|
|
540
|
+
sendSignal: (signal) =>
|
|
541
|
+
relay.send(topics.outbound, { v: 1, from: instance, to: high.instance, cid, ...signal }),
|
|
542
|
+
startHandshake: (binding) =>
|
|
543
|
+
startDeviceHandshake(testIdentity, {
|
|
544
|
+
role: 'initiator',
|
|
545
|
+
identity: world.identity(world.third),
|
|
546
|
+
sessionDescriptor: world.third.session,
|
|
547
|
+
expectedPeerDeviceId: world.high.cert.deviceId,
|
|
548
|
+
localFingerprint: binding.localFingerprint,
|
|
549
|
+
remoteFingerprint: binding.remoteFingerprint,
|
|
550
|
+
locality: 'another-host',
|
|
551
|
+
localPolicy: ALL_CAPABILITIES,
|
|
552
|
+
send: binding.send,
|
|
553
|
+
}),
|
|
554
|
+
connectTimeoutMs: 10_000,
|
|
555
|
+
onAdmitted: () => (thirdAdmitted = true),
|
|
556
|
+
onEnded: () => undefined,
|
|
557
|
+
});
|
|
558
|
+
relay.onMessage((_topic, data) => {
|
|
559
|
+
const signal = data as { kind: string; sdp?: string; candidate?: never };
|
|
560
|
+
if (signal.kind === 'answer') third.onSignal({ kind: 'answer', sdp: signal.sdp! });
|
|
561
|
+
else if (signal.kind === 'ice') third.onSignal({ kind: 'ice', candidate: signal.candidate! });
|
|
562
|
+
});
|
|
563
|
+
await third.offer();
|
|
564
|
+
|
|
565
|
+
await expect.poll(() => refusals, { timeout: 15_000 }).toEqual(['handshake']);
|
|
566
|
+
await expect.poll(() => third.ended, { timeout: 5_000 }).toBe(true);
|
|
567
|
+
expect(thirdAdmitted).toBe(false);
|
|
568
|
+
expect(linked).toEqual([]);
|
|
569
|
+
}, 30_000);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
describe('MeshPeerLink — admission gate', () => {
|
|
573
|
+
it('the answerer refuses an offer advertising more than one DTLS fingerprint before creating a connection', async () => {
|
|
574
|
+
let created = 0;
|
|
575
|
+
const ended: TMeshLinkEnd[] = [];
|
|
576
|
+
const endedWith: MeshLinkEndedError[] = [];
|
|
577
|
+
const link = new MeshPeerLink({
|
|
578
|
+
role: 'answerer',
|
|
579
|
+
createPeer: () => {
|
|
580
|
+
created += 1;
|
|
581
|
+
return new RtcPeer();
|
|
582
|
+
},
|
|
583
|
+
sendSignal: () => undefined,
|
|
584
|
+
startHandshake: () => {
|
|
585
|
+
throw new Error('unreachable');
|
|
586
|
+
},
|
|
587
|
+
connectTimeoutMs: 5_000,
|
|
588
|
+
onAdmitted: () => undefined,
|
|
589
|
+
onEnded: (end, error) => {
|
|
590
|
+
ended.push(end);
|
|
591
|
+
endedWith.push(error);
|
|
592
|
+
},
|
|
593
|
+
});
|
|
594
|
+
const offerer = await realOffer();
|
|
595
|
+
const sdp = offerer.sdp.replace(
|
|
596
|
+
/(a=fingerprint:[^\r\n]+\r\n)/,
|
|
597
|
+
`$1a=fingerprint:sha-256 ${Array.from({ length: 32 }, () => 'AB').join(':')}\r\n`,
|
|
598
|
+
);
|
|
599
|
+
|
|
600
|
+
link.onSignal({ kind: 'offer', sdp });
|
|
601
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
602
|
+
|
|
603
|
+
expect(ended).toEqual(['channel-binding']);
|
|
604
|
+
expect(endedWith[0]).toMatchObject({ stage: 'connecting', role: 'answerer' });
|
|
605
|
+
expect(endedWith[0]?.message).toMatch(/more than one DTLS fingerprint/);
|
|
606
|
+
expect(created).toBe(0);
|
|
607
|
+
offerer.close();
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it('a frame that is not a handshake frame before admission is never delivered and ends the connection', async () => {
|
|
611
|
+
const hub = createInMemoryMeshRelayHub();
|
|
612
|
+
const low = node(hub, world.low);
|
|
613
|
+
const refusals: TMeshLinkEnd[] = [];
|
|
614
|
+
low.onRefusal((refusal) => refusals.push(refusal.end));
|
|
615
|
+
const delivered: string[] = [];
|
|
616
|
+
low.onLink((link) => link.onMessage((body) => delivered.push(body)));
|
|
617
|
+
await low.start();
|
|
618
|
+
|
|
619
|
+
// A peer holding the pair's inbox that smuggles a message ahead of its handshake.
|
|
620
|
+
const topics = await (
|
|
621
|
+
await derivePairRendezvous(testIdentity, {
|
|
622
|
+
ownKaPrivateKey: world.high.ka.privateKey,
|
|
623
|
+
own: world.high.cert,
|
|
624
|
+
peerDeviceId: world.low.cert.deviceId,
|
|
625
|
+
lists: world.identity(world.high),
|
|
626
|
+
})
|
|
627
|
+
).relayInbox();
|
|
628
|
+
const relay = hub.connect();
|
|
629
|
+
const instance = 'CCCCCCCCCCCCCCCCCCCCCC';
|
|
630
|
+
let rogue: MeshPeerLink | undefined;
|
|
631
|
+
relay.onMessage((_topic, data) => {
|
|
632
|
+
const signal = data as {
|
|
633
|
+
kind: string;
|
|
634
|
+
from: string;
|
|
635
|
+
cid: string;
|
|
636
|
+
sdp?: string;
|
|
637
|
+
candidate?: never;
|
|
638
|
+
};
|
|
639
|
+
if (signal.kind !== 'offer' && signal.kind !== 'ice') return;
|
|
640
|
+
if (signal.kind === 'offer') {
|
|
641
|
+
const send = (s: object): void =>
|
|
642
|
+
relay.send(topics.outbound, {
|
|
643
|
+
v: 1,
|
|
644
|
+
from: instance,
|
|
645
|
+
to: signal.from,
|
|
646
|
+
cid: signal.cid,
|
|
647
|
+
...s,
|
|
648
|
+
});
|
|
649
|
+
rogue = new MeshPeerLink({
|
|
650
|
+
role: 'answerer',
|
|
651
|
+
createPeer: () => new RtcPeer(),
|
|
652
|
+
sendSignal: send,
|
|
653
|
+
startHandshake: (binding) => {
|
|
654
|
+
binding.send({ t: 'mesh-msg', body: 'smuggled' });
|
|
655
|
+
return startDeviceHandshake(testIdentity, {
|
|
656
|
+
role: 'responder',
|
|
657
|
+
identity: world.identity(world.high),
|
|
658
|
+
sessionDescriptor: world.high.session,
|
|
659
|
+
localFingerprint: binding.localFingerprint,
|
|
660
|
+
remoteFingerprint: binding.remoteFingerprint,
|
|
661
|
+
locality: 'another-host',
|
|
662
|
+
localPolicy: ALL_CAPABILITIES,
|
|
663
|
+
send: binding.send,
|
|
664
|
+
});
|
|
665
|
+
},
|
|
666
|
+
connectTimeoutMs: 10_000,
|
|
667
|
+
onAdmitted: () => undefined,
|
|
668
|
+
onEnded: () => undefined,
|
|
669
|
+
});
|
|
670
|
+
rogue.onSignal({ kind: 'offer', sdp: signal.sdp! });
|
|
671
|
+
} else {
|
|
672
|
+
rogue?.onSignal({ kind: 'ice', candidate: (data as { candidate: never }).candidate });
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
relay.declarePresence([topics.inbound]);
|
|
676
|
+
relay.send(topics.outbound, { v: 1, kind: 'hello', from: instance });
|
|
677
|
+
|
|
678
|
+
await expect.poll(() => refusals, { timeout: 15_000 }).toEqual(['handshake']);
|
|
679
|
+
expect(delivered).toEqual([]);
|
|
680
|
+
rogue?.close();
|
|
681
|
+
}, 30_000);
|
|
682
|
+
});
|