@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.
Files changed (84) hide show
  1. package/CHANGELOG.md +694 -0
  2. package/LICENSE +661 -0
  3. package/README.md +111 -0
  4. package/dist/node/index.cjs +1 -0
  5. package/dist/node/index.d.cts +1650 -0
  6. package/dist/node/index.d.cts.map +1 -0
  7. package/dist/node/index.d.ts +1650 -0
  8. package/dist/node/index.d.ts.map +1 -0
  9. package/dist/node/index.js +2 -0
  10. package/dist/node/index.js.map +1 -0
  11. package/package.json +93 -0
  12. package/src/__tests__/admission-secure-by-default.test.ts +63 -0
  13. package/src/__tests__/admission-steps.test.ts +46 -0
  14. package/src/__tests__/device-mesh-relay.test.ts +441 -0
  15. package/src/__tests__/device-mesh.test.ts +682 -0
  16. package/src/__tests__/discovering-mesh-relay.test.ts +234 -0
  17. package/src/__tests__/enrollment-link.test.ts +103 -0
  18. package/src/__tests__/fake-datachannel.ts +83 -0
  19. package/src/__tests__/handoff-grant-gate.test.ts +403 -0
  20. package/src/__tests__/local-peer-proof-gate.test.ts +215 -0
  21. package/src/__tests__/mesh-admission-contract.test.ts +38 -0
  22. package/src/__tests__/mesh-discovery.test.ts +820 -0
  23. package/src/__tests__/mesh-fixtures.ts +148 -0
  24. package/src/__tests__/mesh-internet.test.ts +942 -0
  25. package/src/__tests__/mesh-turn-relay.test.ts +161 -0
  26. package/src/__tests__/negotiated-certificate.test.ts +76 -0
  27. package/src/__tests__/nostr-source-condition.test.ts +27 -0
  28. package/src/__tests__/operator-approval-gate.test.ts +308 -0
  29. package/src/__tests__/outbound-delivery-carrier.test.ts +155 -0
  30. package/src/__tests__/pairing-e2e.test.ts +237 -0
  31. package/src/__tests__/pairing-gate-e3.test.ts +194 -0
  32. package/src/__tests__/pairing-gate-e4.test.ts +110 -0
  33. package/src/__tests__/pairing-gate.test.ts +296 -0
  34. package/src/__tests__/pairing-relay-in-the-middle.test.ts +225 -0
  35. package/src/__tests__/rtc-channel-close.test.ts +52 -0
  36. package/src/__tests__/rtc-peer-candidates.test.ts +61 -0
  37. package/src/__tests__/session-attachment-app-view.test.ts +66 -0
  38. package/src/__tests__/session-attachment-budget.test.ts +136 -0
  39. package/src/__tests__/turn-server-bind.test.ts +59 -0
  40. package/src/__tests__/turn-server.test.ts +335 -0
  41. package/src/__tests__/turn-test-client.ts +256 -0
  42. package/src/__tests__/webrtc-implementation.test.ts +83 -0
  43. package/src/__tests__/webrtc-transport.test.ts +349 -0
  44. package/src/__tests__/ws-mesh-relay-client.test.ts +95 -0
  45. package/src/__tests__/ws-signaling-client.test.ts +108 -0
  46. package/src/admission-steps.ts +55 -0
  47. package/src/channel-delivery.ts +44 -0
  48. package/src/datachannel-loader.ts +86 -0
  49. package/src/device-mesh-node.ts +1019 -0
  50. package/src/discovering-mesh-relay.ts +675 -0
  51. package/src/enrollment-link.ts +465 -0
  52. package/src/handoff-grant-gate.ts +186 -0
  53. package/src/index.ts +139 -0
  54. package/src/local-peer-proof.ts +135 -0
  55. package/src/mainline-dht-store.ts +174 -0
  56. package/src/mesh-dht.ts +446 -0
  57. package/src/mesh-discovery.ts +87 -0
  58. package/src/mesh-item-store.ts +287 -0
  59. package/src/mesh-lan-listener.ts +193 -0
  60. package/src/mesh-mdns.ts +406 -0
  61. package/src/mesh-peer-link.ts +600 -0
  62. package/src/mesh-public-relays.ts +25 -0
  63. package/src/mesh-records.ts +357 -0
  64. package/src/mesh-relay.ts +98 -0
  65. package/src/mesh-signal.ts +116 -0
  66. package/src/mesh-turn-relay.ts +238 -0
  67. package/src/negotiated-certificate.ts +72 -0
  68. package/src/nostr-mesh-relay.ts +391 -0
  69. package/src/pairing-channel-lifecycle.ts +31 -0
  70. package/src/pairing-controllers.ts +81 -0
  71. package/src/pairing-frames.ts +34 -0
  72. package/src/pairing-gate-options.ts +133 -0
  73. package/src/pairing-gate.ts +397 -0
  74. package/src/rtc-peer.ts +429 -0
  75. package/src/session-attachment.ts +96 -0
  76. package/src/signaling.ts +62 -0
  77. package/src/stun-message.ts +341 -0
  78. package/src/transport-lifecycle-error.ts +11 -0
  79. package/src/turn-server.ts +1028 -0
  80. package/src/webrtc-delivery-lifecycle.ts +54 -0
  81. package/src/webrtc-transport-options.ts +87 -0
  82. package/src/webrtc-transport.ts +368 -0
  83. package/src/ws-mesh-relay-client.ts +117 -0
  84. package/src/ws-signaling-client.ts +139 -0
@@ -0,0 +1,161 @@
1
+ import { createIdentityContext } from '@robota-sdk/agent-remote-pairing';
2
+ const testIdentity = createIdentityContext('test-product');
3
+ import { derivePairRendezvous, type IPairRendezvous } from '@robota-sdk/agent-remote-pairing';
4
+ import { afterEach, beforeAll, describe, expect, it } from 'vitest';
5
+
6
+ import {
7
+ MAX_RELAY_CREDENTIAL_TTL_MS,
8
+ MeshTurnRelay,
9
+ meshRelayCredential,
10
+ meshRelayIceServers,
11
+ } from '../mesh-turn-relay.js';
12
+ import { buildMeshWorld, type IMeshTestDevice, type IMeshTestWorld } from './mesh-fixtures.js';
13
+ import { TurnTestClient } from './turn-test-client.js';
14
+
15
+ let world: IMeshTestWorld;
16
+ let stranger: IMeshTestDevice;
17
+ const relays: MeshTurnRelay[] = [];
18
+ const clients: TurnTestClient[] = [];
19
+
20
+ beforeAll(async () => {
21
+ world = await buildMeshWorld();
22
+ // A device of the same user that the lists in force do not name.
23
+ stranger = (await buildMeshWorld()).third;
24
+ });
25
+
26
+ afterEach(async () => {
27
+ for (const client of clients.splice(0)) client.close();
28
+ for (const relay of relays.splice(0)) await relay.close();
29
+ });
30
+
31
+ /** `own`'s rendezvous with `peer`, under the world's lists or the ones given. */
32
+ function pair(
33
+ own: IMeshTestDevice,
34
+ peer: IMeshTestDevice,
35
+ devices: readonly IMeshTestDevice[] = [world.low, world.high, world.third],
36
+ ): Promise<IPairRendezvous> {
37
+ return derivePairRendezvous(testIdentity, {
38
+ ownKaPrivateKey: own.ka.privateKey,
39
+ own: own.cert,
40
+ peerDeviceId: peer.cert.deviceId,
41
+ lists: {
42
+ roster: { devices: devices.map((d) => d.cert) },
43
+ revocation: { revokedDeviceIds: [] },
44
+ },
45
+ });
46
+ }
47
+
48
+ /** `third` runs the relay for `low` and `high`. */
49
+ async function relayOnThird(now: () => number = Date.now): Promise<MeshTurnRelay> {
50
+ const relay = await MeshTurnRelay.start({ host: '127.0.0.1', port: 0, now });
51
+ relays.push(relay);
52
+ relay.declarePeers([
53
+ { deviceId: world.low.cert.deviceId, rendezvous: await pair(world.third, world.low) },
54
+ { deviceId: world.high.cert.deviceId, rendezvous: await pair(world.third, world.high) },
55
+ ]);
56
+ return relay;
57
+ }
58
+
59
+ async function client(
60
+ relay: MeshTurnRelay,
61
+ credential: { readonly username: string; readonly credential: string },
62
+ ): Promise<TurnTestClient> {
63
+ const created = await TurnTestClient.connect(
64
+ { address: relay.endpoint.host, port: relay.endpoint.port },
65
+ credential.username,
66
+ credential.credential,
67
+ );
68
+ clients.push(created);
69
+ return created;
70
+ }
71
+
72
+ describe('embedded TURN relay — pairwise, short-lived credentials', () => {
73
+ it('a roster device allocates with the credential it derives for the pair; the username names no device', async () => {
74
+ const relay = await relayOnThird();
75
+ const credential = await meshRelayCredential(await pair(world.low, world.third), Date.now());
76
+ expect(credential.username).toMatch(/^\d+:[A-Za-z0-9_-]{22}$/);
77
+ expect(credential.username).not.toContain(world.low.cert.deviceId.slice(0, 8));
78
+
79
+ const answer = await (await client(relay, credential)).allocate();
80
+
81
+ expect(answer.ok).toBe(true);
82
+ expect(relay.allocationCount(world.low.cert.deviceId)).toBe(1);
83
+ expect(relay.allocationCount(world.high.cert.deviceId)).toBe(0);
84
+ });
85
+
86
+ it('ICE servers carry the credential for every advertised endpoint of the relay', async () => {
87
+ const servers = await meshRelayIceServers(
88
+ await pair(world.low, world.third),
89
+ [
90
+ { host: '192.0.2.1', port: 3478 },
91
+ { host: '2001:db8::1', port: 3479 },
92
+ ],
93
+ Date.now(),
94
+ );
95
+ expect(servers.map((s) => s.urls)).toEqual([
96
+ 'turn:192.0.2.1:3478?transport=udp',
97
+ 'turn:[2001:db8::1]:3479?transport=udp',
98
+ ]);
99
+ expect(new Set(servers.map((s) => s.username)).size).toBe(1);
100
+ });
101
+
102
+ it('a device outside the roster is refused, even with a genuine pairwise secret', async () => {
103
+ const relay = await relayOnThird();
104
+ // The stranger agrees a secret with the relay device under lists of its own making.
105
+ const own = await pair(stranger, world.third, [world.third, stranger]);
106
+ const answer = await (
107
+ await client(relay, await meshRelayCredential(own, Date.now()))
108
+ ).allocate();
109
+ expect(answer.ok).toBe(false);
110
+ expect(answer.code).toBe(401);
111
+ expect(relay.allocationCount()).toBe(0);
112
+ });
113
+
114
+ it("another pair's credential, or a wrong password, is refused", async () => {
115
+ const relay = await relayOnThird();
116
+ // low's credential for its pair with high is not a credential for the relay device.
117
+ const other = await meshRelayCredential(await pair(world.low, world.high), Date.now());
118
+ expect((await (await client(relay, other)).allocate()).code).toBe(401);
119
+ const right = await meshRelayCredential(await pair(world.low, world.third), Date.now());
120
+ const wrong = await client(relay, { username: right.username, credential: 'x'.repeat(43) });
121
+ expect((await wrong.allocate()).code).toBe(401);
122
+ expect(relay.allocationCount()).toBe(0);
123
+ });
124
+
125
+ it('an expired credential, or one that claims to live longer than a relay accepts, is refused', async () => {
126
+ let now = Date.now();
127
+ const relay = await relayOnThird(() => now);
128
+ const rendezvous = await pair(world.low, world.third);
129
+ const expiring = await meshRelayCredential(rendezvous, now, 60_000);
130
+ now += 61_000;
131
+ expect((await (await client(relay, expiring)).allocate()).code).toBe(401);
132
+
133
+ // Minted by hand past the longest lifetime (meshRelayCredential clamps it).
134
+ const tag = (await meshRelayCredential(rendezvous, now)).username.split(':')[1]!;
135
+ const username = `${Math.floor((now + MAX_RELAY_CREDENTIAL_TTL_MS + 5 * 60_000) / 1000)}:${tag}`;
136
+ const longLived = {
137
+ username,
138
+ credential: await rendezvous.relayPassword('outbound', username),
139
+ };
140
+ expect((await (await client(relay, longLived)).allocate()).code).toBe(401);
141
+ expect(relay.allocationCount()).toBe(0);
142
+ });
143
+
144
+ it("a revoked device's credential stops working and its allocations close at once", async () => {
145
+ const relay = await relayOnThird();
146
+ const credential = await meshRelayCredential(await pair(world.high, world.third), Date.now());
147
+ const allocated = await client(relay, credential);
148
+ expect((await allocated.allocate()).ok).toBe(true);
149
+
150
+ // The lists in force now revoke high: only low stays a peer of the relay.
151
+ relay.declarePeers([
152
+ { deviceId: world.low.cert.deviceId, rendezvous: await pair(world.third, world.low) },
153
+ ]);
154
+
155
+ expect(relay.allocationCount(world.high.cert.deviceId)).toBe(0);
156
+ // Nothing is left to refresh, and the credential no longer authenticates anything.
157
+ expect((await allocated.refresh(600)).code).toBe(401);
158
+ const again = await client(relay, credential);
159
+ expect((await again.allocate()).code).toBe(401);
160
+ });
161
+ });
@@ -0,0 +1,76 @@
1
+ import { extractDtlsFingerprint } from '@robota-sdk/agent-remote-pairing';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { whenRemoteCertificateVerified } from '../negotiated-certificate.js';
5
+ import { RtcPeer } from '../rtc-peer.js';
6
+
7
+ interface IPair {
8
+ readonly host: RtcPeer;
9
+ readonly remote: RtcPeer;
10
+ readonly offer: string;
11
+ readonly answer: string;
12
+ }
13
+
14
+ /** Connect two real peers directly; `tamperAnswer` rewrites the answer the host consumes. */
15
+ async function connectedPair(tamperAnswer: (sdp: string) => string = (sdp) => sdp): Promise<IPair> {
16
+ const host = new RtcPeer();
17
+ const remote = new RtcPeer();
18
+ host.createDataChannel('agent-session');
19
+ const toRemote: { candidate: string; mid: string }[] = [];
20
+ const toHost: { candidate: string; mid: string }[] = [];
21
+ host.onLocalCandidate((c) => toRemote.push(c));
22
+ remote.onLocalCandidate((c) => toHost.push(c));
23
+ const offer = await host.createOffer();
24
+ const answer = await remote.acceptOffer(offer);
25
+ host.acceptAnswer(tamperAnswer(answer));
26
+ // Candidates keep arriving after the descriptions; deliver them for a while.
27
+ const deliver = setInterval(() => {
28
+ for (const c of toRemote.splice(0)) remote.addRemoteCandidate(c);
29
+ for (const c of toHost.splice(0)) host.addRemoteCandidate(c);
30
+ }, 20);
31
+ setTimeout(() => clearInterval(deliver), 5_000).unref();
32
+ return { host, remote, offer, answer };
33
+ }
34
+
35
+ function verified(peer: RtcPeer, algorithm = 'sha-256'): Promise<string> {
36
+ return new Promise<string>((resolve, reject) => {
37
+ whenRemoteCertificateVerified(peer, algorithm, resolve, (reason) => reject(new Error(reason)));
38
+ });
39
+ }
40
+
41
+ describe('negotiated certificate fingerprint', () => {
42
+ it('reports the fingerprint of the certificate the DTLS layer accepted, on both sides', async () => {
43
+ const { host, remote, offer, answer } = await connectedPair();
44
+ await expect(verified(host)).resolves.toBe(extractDtlsFingerprint(answer));
45
+ await expect(verified(remote)).resolves.toBe(extractDtlsFingerprint(offer));
46
+ host.close();
47
+ remote.close();
48
+ }, 15_000);
49
+
50
+ it('gives every connection a certificate of its own', async () => {
51
+ const first = await connectedPair();
52
+ const second = await connectedPair();
53
+ const fingerprints = new Set(
54
+ [first.offer, first.answer, second.offer, second.answer].map(extractDtlsFingerprint),
55
+ );
56
+ expect(fingerprints.size).toBe(4);
57
+ for (const peer of [first.host, first.remote, second.host, second.remote]) peer.close();
58
+ }, 15_000);
59
+
60
+ it('fails when the certificate was checked with another algorithm than the description names', async () => {
61
+ const { host, remote } = await connectedPair();
62
+ await expect(verified(host, 'sha-384')).rejects.toThrow(/checked with sha-256, not sha-384/);
63
+ host.close();
64
+ remote.close();
65
+ }, 15_000);
66
+
67
+ it('reports a DTLS handshake that fails because the description names another certificate', async () => {
68
+ const bogus = Array.from({ length: 32 }, () => 'AA').join(':');
69
+ const { host, remote } = await connectedPair((sdp) =>
70
+ sdp.replace(/a=fingerprint:(\S+) (\S+)/, `a=fingerprint:$1 ${bogus}`),
71
+ );
72
+ await expect(verified(host)).rejects.toThrow(/ended (failed|closed)/);
73
+ host.close();
74
+ remote.close();
75
+ }, 30_000);
76
+ });
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The workspace runs from source with `--conditions=source` (`pnpm cli:dev`, the scenario scripts), and
3
+ * Node applies a condition to every package. `nostr-tools` declares its own `source` condition pointing at
4
+ * `.ts` files its tarball does not ship, so every source run failed on `nostr-tools/pool.ts`. The root
5
+ * `patchedDependencies` entry removes that condition; this loads the subpaths the transport imports the
6
+ * way a source run does.
7
+ */
8
+
9
+ import { spawnSync } from 'node:child_process';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ import { describe, expect, it } from 'vitest';
13
+
14
+ const PACKAGE_DIR = fileURLToPath(new URL('../..', import.meta.url));
15
+
16
+ describe('nostr-tools under the source export condition', () => {
17
+ it.each(['nostr-tools/pool', 'nostr-tools/pure'])('loads %s', (specifier) => {
18
+ const result = spawnSync(
19
+ process.execPath,
20
+ ['--conditions=source', '--input-type=module', '-e', `await import('${specifier}');`],
21
+ { cwd: PACKAGE_DIR, encoding: 'utf8', timeout: 20_000 },
22
+ );
23
+
24
+ expect(result.stderr).not.toContain('ERR_MODULE_NOT_FOUND');
25
+ expect(result.status).toBe(0);
26
+ });
27
+ });
@@ -0,0 +1,308 @@
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
+ import {
5
+ deriveIdentityId,
6
+ exportPublicKey,
7
+ generateIdentityKeyPair,
8
+ importPublicKey,
9
+ startDeviceReconnect,
10
+ type TReconnectFrame,
11
+ } from '@robota-sdk/agent-remote-pairing';
12
+ import { describe, expect, it, vi } from 'vitest';
13
+
14
+ import { nextAdmissionStep } from '../admission-steps.js';
15
+ import {
16
+ PairingGate,
17
+ type IConnectionApproval,
18
+ type IConnectionApprovalContext,
19
+ type IHostReconnectConfig,
20
+ type IPairingGateOptions,
21
+ } from '../pairing-gate.js';
22
+
23
+ import type { createSessionMessageHandler } from '@robota-sdk/agent-transport';
24
+
25
+ /**
26
+ * A connection that would drive the session is the receiving operator's to allow, every time: the
27
+ * handshake proves who is on the other end, and the operator decides whether that device drives now.
28
+ * Until they say yes nothing reaches the session, and a device they refuse is not remembered.
29
+ */
30
+
31
+ const FP = { localFingerprint: 'HOST:FP', remoteFingerprint: 'DEV:FP' };
32
+
33
+ function deferred(): { promise: Promise<boolean>; resolve: (value: boolean) => void } {
34
+ let resolve!: (value: boolean) => void;
35
+ const promise = new Promise<boolean>((res) => {
36
+ resolve = res;
37
+ });
38
+ return { promise, resolve };
39
+ }
40
+
41
+ function makeGate(over: Partial<IPairingGateOptions> = {}) {
42
+ const sent: unknown[] = [];
43
+ let closed = false;
44
+ const channel = {
45
+ send: (d: string) => {
46
+ sent.push(JSON.parse(d));
47
+ },
48
+ close: () => {
49
+ closed = true;
50
+ },
51
+ };
52
+ const sessionOnMessage = vi.fn();
53
+ const createHandler: typeof createSessionMessageHandler = () => ({
54
+ onMessage: sessionOnMessage,
55
+ cleanup: vi.fn(),
56
+ });
57
+ const onAccept = vi.fn();
58
+ const onReject = vi.fn();
59
+ const fakeHandshake = (() => ({
60
+ result: Promise.resolve({ sessionKey: 'k' }),
61
+ onFrame: () => {},
62
+ })) as never;
63
+ const gate = new PairingGate({cryptoContext: testIdentity,
64
+ channel,
65
+ session: createTestInteractiveSession(),
66
+ secret: 's',
67
+ role: 'initiator',
68
+ ...FP,
69
+ startHandshake: fakeHandshake,
70
+ createHandler,
71
+ onAccept,
72
+ onReject,
73
+ ...over,
74
+ });
75
+ return { gate, sent, closed: () => closed, sessionOnMessage, onAccept, onReject };
76
+ }
77
+
78
+ async function flush(): Promise<void> {
79
+ for (let i = 0; i < 5; i += 1) await Promise.resolve();
80
+ }
81
+
82
+ describe('operator approval of a driving connection', () => {
83
+ it('is the last step owed, after every proof the channel owes', () => {
84
+ const configured = { localPeer: {}, handoffGrant: {}, connectionApproval: {} };
85
+ expect(nextAdmissionStep({ connectionApproval: {} }, 'pairing')).toBe('operator-approval');
86
+ expect(nextAdmissionStep(configured, 'pairing')).toBe('local-proof');
87
+ expect(nextAdmissionStep(configured, 'local-proof')).toBe('handoff-grant');
88
+ expect(nextAdmissionStep(configured, 'handoff-grant')).toBe('operator-approval');
89
+ expect(nextAdmissionStep(configured, 'operator-approval')).toBe(null);
90
+ });
91
+
92
+ it('exposes nothing while the operator has not answered', async () => {
93
+ const answer = deferred();
94
+ const approve = vi.fn(() => answer.promise);
95
+ const { gate, sessionOnMessage, onAccept } = makeGate({ connectionApproval: { approve } });
96
+ await flush();
97
+ expect(approve).toHaveBeenCalledTimes(1);
98
+ gate.onInbound(JSON.stringify({ type: 'submit', prompt: 'p' }));
99
+ expect(sessionOnMessage).not.toHaveBeenCalled();
100
+ expect(onAccept).not.toHaveBeenCalled();
101
+
102
+ answer.resolve(true);
103
+ await vi.waitFor(() => expect(onAccept).toHaveBeenCalledTimes(1));
104
+ // What the peer said while waiting reaches the session only now, in order, then live frames.
105
+ expect(sessionOnMessage).toHaveBeenCalledTimes(1);
106
+ gate.onInbound(JSON.stringify({ type: 'get-messages' }));
107
+ expect(sessionOnMessage.mock.calls.map(([frame]) => JSON.parse(frame as string).type)).toEqual([
108
+ 'submit',
109
+ 'get-messages',
110
+ ]);
111
+ });
112
+
113
+ it('drops what the peer said while waiting when the operator refuses', async () => {
114
+ const answer = deferred();
115
+ const { gate, sessionOnMessage, onReject } = makeGate({
116
+ connectionApproval: { approve: () => answer.promise },
117
+ });
118
+ await flush();
119
+ gate.onInbound(JSON.stringify({ type: 'submit', prompt: 'p' }));
120
+ answer.resolve(false);
121
+ await vi.waitFor(() => expect(onReject).toHaveBeenCalledTimes(1));
122
+ expect(sessionOnMessage).not.toHaveBeenCalled();
123
+ });
124
+
125
+ it('withdraws the question and admits nothing when the channel closes while the operator decides', async () => {
126
+ const answer = deferred();
127
+ let signal: AbortSignal | undefined;
128
+ const approve = vi.fn((context: IConnectionApprovalContext) => {
129
+ signal = context.signal;
130
+ return answer.promise;
131
+ });
132
+ const { gate, sessionOnMessage, onAccept, onReject } = makeGate({
133
+ connectionApproval: { approve },
134
+ });
135
+ await flush();
136
+ gate.onInbound(JSON.stringify({ type: 'get-messages' }));
137
+ expect(signal?.aborted).toBe(false);
138
+
139
+ gate.onChannelClosed();
140
+ expect(signal?.aborted).toBe(true);
141
+ expect(onReject).toHaveBeenCalledTimes(1);
142
+
143
+ answer.resolve(true);
144
+ await flush();
145
+ expect(onAccept).not.toHaveBeenCalled();
146
+ expect(sessionOnMessage).not.toHaveBeenCalled();
147
+ });
148
+
149
+ it('does not pin an enrolling device whose channel closed while the operator decided', async () => {
150
+ const cfg = await hostConfig();
151
+ const answer = deferred();
152
+ const approve = vi.fn(() => answer.promise);
153
+ const { gate, onAccept } = makeGate({ reconnect: cfg, connectionApproval: { approve } });
154
+ gate.onInbound(JSON.stringify({ t: 'pair-nonce', nonce: 'n' }));
155
+ await flush();
156
+ const deviceKey = await generateIdentityKeyPair(false);
157
+ gate.onInbound(
158
+ JSON.stringify({ t: 'enroll-key', spki: await exportPublicKey(deviceKey.publicKey) }),
159
+ );
160
+ await vi.waitFor(() => expect(approve).toHaveBeenCalledTimes(1));
161
+ gate.onChannelClosed();
162
+ answer.resolve(true);
163
+ await flush();
164
+ expect(cfg.onEnroll).not.toHaveBeenCalled();
165
+ expect(onAccept).not.toHaveBeenCalled();
166
+ });
167
+
168
+ it('an accepted channel closing is the transport’s drop, not a refusal', async () => {
169
+ const { gate, onAccept, onReject } = makeGate({
170
+ connectionApproval: { approve: async () => true },
171
+ });
172
+ await vi.waitFor(() => expect(onAccept).toHaveBeenCalledTimes(1));
173
+ gate.onChannelClosed();
174
+ expect(onReject).not.toHaveBeenCalled();
175
+ });
176
+
177
+ it('refuses a peer that floods the channel while the operator decides', async () => {
178
+ const answer = deferred();
179
+ const { gate, closed, onReject, sessionOnMessage } = makeGate({
180
+ connectionApproval: { approve: () => answer.promise },
181
+ });
182
+ await flush();
183
+ for (let i = 0; i < 100; i += 1) gate.onInbound(JSON.stringify({ type: 'get-messages' }));
184
+ expect(onReject).toHaveBeenCalledTimes(1);
185
+ expect(closed()).toBe(true);
186
+ answer.resolve(true);
187
+ await flush();
188
+ expect(sessionOnMessage).not.toHaveBeenCalled();
189
+ });
190
+
191
+ it('refuses the connection the operator declines: closed, and the session never reached', async () => {
192
+ const { gate, closed, sessionOnMessage, onAccept, onReject } = makeGate({
193
+ connectionApproval: { approve: async () => false },
194
+ });
195
+ await vi.waitFor(() => expect(onReject).toHaveBeenCalledTimes(1));
196
+ expect(closed()).toBe(true);
197
+ gate.onInbound(JSON.stringify({ type: 'submit', prompt: 'p' }));
198
+ expect(sessionOnMessage).not.toHaveBeenCalled();
199
+ expect(onAccept).not.toHaveBeenCalled();
200
+ });
201
+
202
+ it('refuses when asking the operator fails', async () => {
203
+ const { closed, onAccept, onReject } = makeGate({
204
+ connectionApproval: { approve: () => Promise.reject(new Error('no terminal')) },
205
+ });
206
+ await vi.waitFor(() => expect(onReject).toHaveBeenCalledTimes(1));
207
+ expect(closed()).toBe(true);
208
+ expect(onAccept).not.toHaveBeenCalled();
209
+ });
210
+
211
+ it('does not accept an answer that arrives after the channel was torn down', async () => {
212
+ const answer = deferred();
213
+ const { gate, sessionOnMessage, onAccept } = makeGate({
214
+ connectionApproval: { approve: () => answer.promise },
215
+ });
216
+ await flush();
217
+ gate.cleanup();
218
+ answer.resolve(true);
219
+ await flush();
220
+ expect(onAccept).not.toHaveBeenCalled();
221
+ gate.onInbound(JSON.stringify({ type: 'submit', prompt: 'p' }));
222
+ expect(sessionOnMessage).not.toHaveBeenCalled();
223
+ });
224
+ });
225
+
226
+ async function hostConfig(over: Partial<IHostReconnectConfig> = {}): Promise<IHostReconnectConfig> {
227
+ const hostKeyPair = await generateIdentityKeyPair(true);
228
+ const hostPublicSpki = await exportPublicKey(hostKeyPair.publicKey);
229
+ return {
230
+ hostIdentityId: await deriveIdentityId(hostPublicSpki),
231
+ hostPublicSpki,
232
+ hostPrivateKey: hostKeyPair.privateKey,
233
+ resolveDevicePublicKey: async () => undefined,
234
+ onEnroll: vi.fn(),
235
+ ...over,
236
+ };
237
+ }
238
+
239
+ describe('operator approval with trusted-device enrollment', () => {
240
+ async function enrollingGate(approval: IConnectionApproval) {
241
+ const cfg = await hostConfig();
242
+ const made = makeGate({ reconnect: cfg, connectionApproval: approval });
243
+ made.gate.onInbound(JSON.stringify({ t: 'pair-nonce', nonce: 'n' }));
244
+ await flush();
245
+ const deviceKey = await generateIdentityKeyPair(false);
246
+ const deviceSpki = await exportPublicKey(deviceKey.publicKey);
247
+ made.gate.onInbound(JSON.stringify({ t: 'enroll-key', spki: deviceSpki }));
248
+ return { ...made, cfg, deviceSpki, deviceId: await deriveIdentityId(deviceSpki) };
249
+ }
250
+
251
+ it('asks about the device that enrolled, and pins it only once the operator allows it', async () => {
252
+ const approve = vi.fn(async () => true);
253
+ const { cfg, onAccept, deviceId, deviceSpki } = await enrollingGate({ approve });
254
+ await vi.waitFor(() => expect(onAccept).toHaveBeenCalledTimes(1));
255
+ expect(approve).toHaveBeenCalledWith(
256
+ expect.objectContaining({ deviceId, viaReconnect: false }),
257
+ );
258
+ expect(cfg.onEnroll).toHaveBeenCalledWith(deviceId, deviceSpki);
259
+ });
260
+
261
+ it('does not pin a device the operator refuses', async () => {
262
+ const { cfg, onReject } = await enrollingGate({ approve: async () => false });
263
+ await vi.waitFor(() => expect(onReject).toHaveBeenCalledTimes(1));
264
+ expect(cfg.onEnroll).not.toHaveBeenCalled();
265
+ });
266
+
267
+ it('asks again when a trusted device reconnects, naming it', async () => {
268
+ const keyPair = await generateIdentityKeyPair(false);
269
+ const spki = await exportPublicKey(keyPair.publicKey);
270
+ const deviceId = await deriveIdentityId(spki);
271
+ const publicKey = await importPublicKey(spki);
272
+ const cfg = await hostConfig({
273
+ resolveDevicePublicKey: async (id) => (id === deviceId ? publicKey : undefined),
274
+ });
275
+ const approve = vi.fn(async () => false);
276
+ let deviceCtrl!: ReturnType<typeof startDeviceReconnect>;
277
+ const onAccept = vi.fn();
278
+ const onReject = vi.fn();
279
+ const gate = new PairingGate({cryptoContext: testIdentity,
280
+ channel: {
281
+ send: (d: string) => deviceCtrl.onFrame(JSON.parse(d) as TReconnectFrame),
282
+ close: () => {},
283
+ },
284
+ session: createTestInteractiveSession(),
285
+ secret: 's',
286
+ role: 'initiator',
287
+ ...FP,
288
+ reconnect: cfg,
289
+ connectionApproval: { approve },
290
+ onAccept,
291
+ onReject,
292
+ });
293
+ deviceCtrl = startDeviceReconnect(testIdentity, {
294
+ deviceId,
295
+ hostIdentityId: cfg.hostIdentityId,
296
+ localFingerprint: FP.remoteFingerprint,
297
+ remoteFingerprint: FP.localFingerprint,
298
+ devicePrivateKey: keyPair.privateKey,
299
+ pinnedHostPublicKey: await importPublicKey(cfg.hostPublicSpki),
300
+ send: (frame) => gate.onInbound(JSON.stringify(frame)),
301
+ timeoutMs: 1000,
302
+ });
303
+ await deviceCtrl.result;
304
+ await vi.waitFor(() => expect(onReject).toHaveBeenCalledTimes(1));
305
+ expect(approve).toHaveBeenCalledWith(expect.objectContaining({ deviceId, viaReconnect: true }));
306
+ expect(onAccept).not.toHaveBeenCalled();
307
+ });
308
+ });