@robota-sdk/agent-transport-webrtc 3.0.0-beta.81
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/dist/node/index.cjs +1 -0
- package/dist/node/index.d.cts +361 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +361 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +2 -0
- package/dist/node/index.js.map +1 -0
- package/package.json +88 -0
- package/src/__tests__/admission-secure-by-default.test.ts +61 -0
- package/src/__tests__/admission-steps.test.ts +46 -0
- package/src/__tests__/cve-2024-29415-reachability.test.ts +80 -0
- package/src/__tests__/dtls-fingerprint-binding.test.ts +60 -0
- package/src/__tests__/handoff-grant-gate.test.ts +401 -0
- package/src/__tests__/local-peer-proof-gate.test.ts +213 -0
- package/src/__tests__/mesh-admission-contract.test.ts +38 -0
- package/src/__tests__/negotiated-certificate.test.ts +80 -0
- package/src/__tests__/operator-approval-gate.test.ts +306 -0
- package/src/__tests__/outbound-delivery-carrier.test.ts +152 -0
- package/src/__tests__/pairing-e2e.test.ts +238 -0
- package/src/__tests__/pairing-gate-e3.test.ts +192 -0
- package/src/__tests__/pairing-gate-e4.test.ts +108 -0
- package/src/__tests__/pairing-gate.test.ts +276 -0
- package/src/__tests__/pairing-relay-in-the-middle.test.ts +229 -0
- package/src/__tests__/session-attachment-budget.test.ts +136 -0
- package/src/__tests__/webrtc-transport.test.ts +349 -0
- package/src/__tests__/werift-loader.test.ts +18 -0
- package/src/__tests__/ws-signaling-client.test.ts +108 -0
- package/src/admission-steps.ts +55 -0
- package/src/channel-delivery.ts +44 -0
- package/src/handoff-grant-gate.ts +186 -0
- package/src/index.ts +21 -0
- package/src/local-peer-proof.ts +135 -0
- package/src/negotiated-certificate.ts +80 -0
- package/src/pairing-channel-lifecycle.ts +31 -0
- package/src/pairing-controllers.ts +79 -0
- package/src/pairing-frames.ts +34 -0
- package/src/pairing-gate-options.ts +129 -0
- package/src/pairing-gate.ts +376 -0
- package/src/session-attachment.ts +88 -0
- package/src/signaling.ts +62 -0
- package/src/transport-lifecycle-error.ts +11 -0
- package/src/webrtc-delivery-lifecycle.ts +51 -0
- package/src/webrtc-transport-options.ts +84 -0
- package/src/webrtc-transport.ts +328 -0
- package/src/werift-loader.ts +40 -0
- package/src/ws-signaling-client.ts +139 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
handoffGrantFrame,
|
|
7
|
+
judgeHandoffGrant,
|
|
8
|
+
type IHandoffGrantProof,
|
|
9
|
+
} from '../handoff-grant-gate.js';
|
|
10
|
+
import { PairingGate, type IPairingGateOptions } from '../pairing-gate.js';
|
|
11
|
+
import { type ILocalPeerProof } from '../local-peer-proof.js';
|
|
12
|
+
|
|
13
|
+
import type { startPairingHandshake, TPairingFrame } from '@robota-sdk/agent-remote-pairing';
|
|
14
|
+
import type { IPeerAdmission } from '@robota-sdk/agent-interface-session-mobility';
|
|
15
|
+
import type { createSessionMessageHandler } from '@robota-sdk/agent-transport';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* SEC-011 (issue #1865) — the cross-device grant bound to the admitted channel.
|
|
19
|
+
*
|
|
20
|
+
* Driven with a stub handshake and a stub verifier: this package must implement no cryptographic
|
|
21
|
+
* policy, so what is asserted here is the GATE's ordering and fail-closed behaviour, never the
|
|
22
|
+
* grant's signature, expiry or replay rules — those are proven where they live, in
|
|
23
|
+
* `agent-remote-pairing`.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const ADMITTED: IPeerAdmission = {
|
|
27
|
+
admitted: true,
|
|
28
|
+
trust: 'same-user-different-host',
|
|
29
|
+
origin: { sessionId: 'peer_laptop' },
|
|
30
|
+
};
|
|
31
|
+
const REFUSED: IPeerAdmission = {
|
|
32
|
+
admitted: false,
|
|
33
|
+
trust: 'unproven',
|
|
34
|
+
reason: 'channel-substituted',
|
|
35
|
+
};
|
|
36
|
+
const LOCAL_ADMITTED: IPeerAdmission = {
|
|
37
|
+
admitted: true,
|
|
38
|
+
trust: 'same-user-same-host',
|
|
39
|
+
origin: { sessionId: 'peer_a' },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function makeHandshakeStub() {
|
|
43
|
+
let resolveResult!: (value: { sessionKey: string }) => void;
|
|
44
|
+
const start: typeof startPairingHandshake = (options) => {
|
|
45
|
+
const controller = {
|
|
46
|
+
result: new Promise<{ sessionKey: string }>((res) => {
|
|
47
|
+
resolveResult = res;
|
|
48
|
+
}),
|
|
49
|
+
onFrame: (_frame: TPairingFrame) => {},
|
|
50
|
+
};
|
|
51
|
+
options.send({ t: 'pair-nonce', nonce: 'stub' });
|
|
52
|
+
return controller;
|
|
53
|
+
};
|
|
54
|
+
return { start, accept: () => resolveResult({ sessionKey: 'k' }) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function makeGate(handoffGrant?: IHandoffGrantProof, over: Partial<IPairingGateOptions> = {}) {
|
|
58
|
+
const channel = { send: vi.fn(), close: vi.fn() };
|
|
59
|
+
const sessionOnMessage = vi.fn();
|
|
60
|
+
const createHandler: typeof createSessionMessageHandler = () => ({
|
|
61
|
+
onMessage: sessionOnMessage,
|
|
62
|
+
cleanup: vi.fn(),
|
|
63
|
+
});
|
|
64
|
+
const hs = makeHandshakeStub();
|
|
65
|
+
const gate = new PairingGate({
|
|
66
|
+
channel,
|
|
67
|
+
session: createTestInteractiveSession(),
|
|
68
|
+
secret: 's',
|
|
69
|
+
role: 'initiator',
|
|
70
|
+
localFingerprint: 'AA',
|
|
71
|
+
remoteFingerprint: 'BB',
|
|
72
|
+
startHandshake: hs.start,
|
|
73
|
+
createHandler,
|
|
74
|
+
...(handoffGrant ? { handoffGrant } : {}),
|
|
75
|
+
...over,
|
|
76
|
+
});
|
|
77
|
+
return { gate, channel, sessionOnMessage, hs };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const grantFrame = (grant: unknown = { handoffId: 'h1', signature: 'sig' }) =>
|
|
81
|
+
JSON.stringify(handoffGrantFrame(grant));
|
|
82
|
+
|
|
83
|
+
/** The verifier is async, so every case must let its promise settle before asserting. */
|
|
84
|
+
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
85
|
+
|
|
86
|
+
describe('the session is not exposed until a grant is presented and verified', () => {
|
|
87
|
+
it('a handshake that accepts is NOT enough on its own', async () => {
|
|
88
|
+
// The whole point of the binding. The handshake proves the channel terminates at the peer that
|
|
89
|
+
// knew the secret; it says nothing about WHO that peer is, so it must not open the session.
|
|
90
|
+
const verify = vi.fn(async () => ADMITTED);
|
|
91
|
+
const { gate, sessionOnMessage, hs } = makeGate({ verify });
|
|
92
|
+
|
|
93
|
+
hs.accept();
|
|
94
|
+
await settle();
|
|
95
|
+
gate.onInbound(JSON.stringify({ type: 'submit', prompt: 'p' }));
|
|
96
|
+
await settle();
|
|
97
|
+
|
|
98
|
+
expect(verify).not.toHaveBeenCalled();
|
|
99
|
+
expect(sessionOnMessage).not.toHaveBeenCalled();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('presenting an admitted grant after the handshake opens the session', async () => {
|
|
103
|
+
const { gate, sessionOnMessage, hs } = makeGate({ verify: async () => ADMITTED });
|
|
104
|
+
|
|
105
|
+
hs.accept();
|
|
106
|
+
await settle();
|
|
107
|
+
gate.onInbound(grantFrame());
|
|
108
|
+
await settle();
|
|
109
|
+
gate.onInbound(JSON.stringify({ type: 'submit', prompt: 'p' }));
|
|
110
|
+
|
|
111
|
+
expect(sessionOnMessage).toHaveBeenCalledTimes(1);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('a refused grant closes the channel and never builds a session handler', async () => {
|
|
115
|
+
// "Fail closed before content". Refusing after exposure is not refusing.
|
|
116
|
+
const { gate, channel, sessionOnMessage, hs } = makeGate({ verify: async () => REFUSED });
|
|
117
|
+
|
|
118
|
+
hs.accept();
|
|
119
|
+
await settle();
|
|
120
|
+
gate.onInbound(grantFrame());
|
|
121
|
+
await settle();
|
|
122
|
+
gate.onInbound(JSON.stringify({ type: 'submit', prompt: 'p' }));
|
|
123
|
+
|
|
124
|
+
expect(channel.close).toHaveBeenCalled();
|
|
125
|
+
expect(sessionOnMessage).not.toHaveBeenCalled();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('with no grant configured the gate is unchanged — the handshake alone opens the session', async () => {
|
|
129
|
+
// The other direction. Without it every case above could be passing because the gate refuses
|
|
130
|
+
// everything, and requiring a grant is opt-in precisely because an ordinary remote peer has
|
|
131
|
+
// none to present.
|
|
132
|
+
const { gate, sessionOnMessage, hs } = makeGate();
|
|
133
|
+
|
|
134
|
+
hs.accept();
|
|
135
|
+
await settle();
|
|
136
|
+
gate.onInbound(JSON.stringify({ type: 'submit', prompt: 'p' }));
|
|
137
|
+
|
|
138
|
+
expect(sessionOnMessage).toHaveBeenCalledTimes(1);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('what the consumer is told', () => {
|
|
143
|
+
it('is told about a refusal, not only about a success', async () => {
|
|
144
|
+
// A consumer told only about successes cannot distinguish "no hand-off was offered" from "a
|
|
145
|
+
// hand-off was refused", and the second is the one that belongs in front of a person.
|
|
146
|
+
const onAdmission = vi.fn();
|
|
147
|
+
const { gate, hs } = makeGate({ verify: async () => REFUSED, onAdmission });
|
|
148
|
+
|
|
149
|
+
hs.accept();
|
|
150
|
+
await settle();
|
|
151
|
+
gate.onInbound(grantFrame());
|
|
152
|
+
await settle();
|
|
153
|
+
|
|
154
|
+
expect(onAdmission).toHaveBeenCalledWith(REFUSED);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('carries the trust level through, not a boolean', async () => {
|
|
158
|
+
const onAdmission = vi.fn();
|
|
159
|
+
const { gate, hs } = makeGate({ verify: async () => ADMITTED, onAdmission });
|
|
160
|
+
|
|
161
|
+
hs.accept();
|
|
162
|
+
await settle();
|
|
163
|
+
gate.onInbound(grantFrame());
|
|
164
|
+
await settle();
|
|
165
|
+
|
|
166
|
+
expect(onAdmission).toHaveBeenCalledWith(
|
|
167
|
+
expect.objectContaining({ trust: 'same-user-different-host' }),
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('a cross-device grant cannot become a same-host claim', () => {
|
|
173
|
+
it('refuses an admission that claims same-user-same-host', async () => {
|
|
174
|
+
// #1812 pins that the two levels are different values, and this is the substitution it forbids
|
|
175
|
+
// by name: a grant proves the USER, not the machine. A verifier returning the stronger level
|
|
176
|
+
// would satisfy every check that wanted the kernel-enforced rendezvous.
|
|
177
|
+
const onAdmission = vi.fn();
|
|
178
|
+
const { gate, channel, sessionOnMessage, hs } = makeGate({
|
|
179
|
+
verify: async () => LOCAL_ADMITTED,
|
|
180
|
+
onAdmission,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
hs.accept();
|
|
184
|
+
await settle();
|
|
185
|
+
gate.onInbound(grantFrame());
|
|
186
|
+
await settle();
|
|
187
|
+
|
|
188
|
+
expect(sessionOnMessage).not.toHaveBeenCalled();
|
|
189
|
+
expect(channel.close).toHaveBeenCalled();
|
|
190
|
+
expect(onAdmission).toHaveBeenCalledWith(
|
|
191
|
+
expect.objectContaining({ admitted: false, trust: 'unproven' }),
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('admits the same verdict when its trust is the cross-device level', async () => {
|
|
196
|
+
// The paired direction: what is refused above is the LEVEL, not the shape. Without this the
|
|
197
|
+
// case above could pass on a gate that refuses every admission it is handed.
|
|
198
|
+
const { gate, sessionOnMessage, hs } = makeGate({ verify: async () => ADMITTED });
|
|
199
|
+
|
|
200
|
+
hs.accept();
|
|
201
|
+
await settle();
|
|
202
|
+
gate.onInbound(grantFrame());
|
|
203
|
+
await settle();
|
|
204
|
+
gate.onInbound(JSON.stringify({ type: 'submit', prompt: 'p' }));
|
|
205
|
+
|
|
206
|
+
expect(sessionOnMessage).toHaveBeenCalledTimes(1);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('the rendezvous and the grant are demanded in order, not instead of each other', () => {
|
|
211
|
+
const localProof = (admission: IPeerAdmission): ILocalPeerProof => ({ redeem: () => admission });
|
|
212
|
+
|
|
213
|
+
it('the rendezvous alone does not open the session when a grant is also required', async () => {
|
|
214
|
+
const { gate, sessionOnMessage, hs } = makeGate(
|
|
215
|
+
{ verify: async () => ADMITTED },
|
|
216
|
+
{ localPeer: localProof(LOCAL_ADMITTED) },
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
hs.accept();
|
|
220
|
+
await settle();
|
|
221
|
+
gate.onInbound(JSON.stringify({ t: 'local-proof', nonce: 'n1' }));
|
|
222
|
+
await settle();
|
|
223
|
+
// The session frame is sent while the gate is still waiting for the grant. It is refused — and
|
|
224
|
+
// that refusal is the assertion: content reaching the session here would mean the second step
|
|
225
|
+
// was decorative.
|
|
226
|
+
gate.onInbound(JSON.stringify({ type: 'submit', prompt: 'p' }));
|
|
227
|
+
await settle();
|
|
228
|
+
|
|
229
|
+
expect(sessionOnMessage).not.toHaveBeenCalled();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('a hand-off between two sessions on ONE machine opens it once BOTH are satisfied', async () => {
|
|
233
|
+
const { gate, sessionOnMessage, hs } = makeGate(
|
|
234
|
+
{ verify: async () => ADMITTED },
|
|
235
|
+
{ localPeer: localProof(LOCAL_ADMITTED) },
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
hs.accept();
|
|
239
|
+
await settle();
|
|
240
|
+
gate.onInbound(JSON.stringify({ t: 'local-proof', nonce: 'n1' }));
|
|
241
|
+
await settle();
|
|
242
|
+
gate.onInbound(grantFrame());
|
|
243
|
+
await settle();
|
|
244
|
+
gate.onInbound(JSON.stringify({ type: 'submit', prompt: 'p' }));
|
|
245
|
+
|
|
246
|
+
expect(sessionOnMessage).toHaveBeenCalledTimes(1);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('anything that is not a grant, in the grant state, closes the channel', async () => {
|
|
250
|
+
// The strict reading of "fail closed before content": while a grant is owed, a frame that is not
|
|
251
|
+
// one is a refusal, not something to ignore and keep waiting through. Ignoring it would leave a
|
|
252
|
+
// peer able to hold the gate open indefinitely by sending noise.
|
|
253
|
+
const { gate, channel, sessionOnMessage, hs } = makeGate({ verify: async () => ADMITTED });
|
|
254
|
+
|
|
255
|
+
hs.accept();
|
|
256
|
+
await settle();
|
|
257
|
+
gate.onInbound(JSON.stringify({ type: 'submit', prompt: 'p' }));
|
|
258
|
+
await settle();
|
|
259
|
+
|
|
260
|
+
expect(channel.close).toHaveBeenCalled();
|
|
261
|
+
expect(sessionOnMessage).not.toHaveBeenCalled();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('a refused rendezvous closes before the grant is ever asked for', async () => {
|
|
265
|
+
const verify = vi.fn(async () => ADMITTED);
|
|
266
|
+
const { gate, channel, hs } = makeGate(
|
|
267
|
+
{ verify },
|
|
268
|
+
{
|
|
269
|
+
localPeer: localProof({ admitted: false, trust: 'unproven', reason: 'replayed' }),
|
|
270
|
+
},
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
hs.accept();
|
|
274
|
+
await settle();
|
|
275
|
+
gate.onInbound(JSON.stringify({ t: 'local-proof', nonce: 'n1' }));
|
|
276
|
+
await settle();
|
|
277
|
+
|
|
278
|
+
expect(channel.close).toHaveBeenCalled();
|
|
279
|
+
expect(verify).not.toHaveBeenCalled();
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe('the person at this machine, asked after the proof', () => {
|
|
284
|
+
it('is asked only once the grant verified, and with the PROVEN verdict', async () => {
|
|
285
|
+
// Asking first would let anyone who can open a channel raise a dialog claiming to be any device
|
|
286
|
+
// they like. The order is the control.
|
|
287
|
+
const order: string[] = [];
|
|
288
|
+
const consent = vi.fn(async () => {
|
|
289
|
+
order.push('consent');
|
|
290
|
+
return true;
|
|
291
|
+
});
|
|
292
|
+
const admission = await judgeHandoffGrant(handoffGrantFrame({ h: 1 }), {
|
|
293
|
+
verify: async () => {
|
|
294
|
+
order.push('verify');
|
|
295
|
+
return ADMITTED;
|
|
296
|
+
},
|
|
297
|
+
consent,
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
expect(order).toEqual(['verify', 'consent']);
|
|
301
|
+
expect(consent).toHaveBeenCalledWith(ADMITTED);
|
|
302
|
+
expect(admission.admitted).toBe(true);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('is NOT asked when the grant did not verify', async () => {
|
|
306
|
+
const consent = vi.fn(async () => true);
|
|
307
|
+
await judgeHandoffGrant(handoffGrantFrame({ h: 1 }), {
|
|
308
|
+
verify: async () => REFUSED,
|
|
309
|
+
consent,
|
|
310
|
+
});
|
|
311
|
+
expect(consent).not.toHaveBeenCalled();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('refuses when the person declines, and says which step declined', async () => {
|
|
315
|
+
const admission = await judgeHandoffGrant(handoffGrantFrame({ h: 1 }), {
|
|
316
|
+
verify: async () => ADMITTED,
|
|
317
|
+
consent: async () => false,
|
|
318
|
+
});
|
|
319
|
+
expect(admission.admitted).toBe(false);
|
|
320
|
+
// Not reported as an authorization failure: the operator must not be left reading
|
|
321
|
+
// "unauthorized" when what happened is that they said no.
|
|
322
|
+
expect(admission.reason).toContain('declined');
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('refuses when asking throws — a prompt that could not be shown is not a yes', async () => {
|
|
326
|
+
const admission = await judgeHandoffGrant(handoffGrantFrame({ h: 1 }), {
|
|
327
|
+
verify: async () => ADMITTED,
|
|
328
|
+
consent: async () => {
|
|
329
|
+
throw new Error('no interactive renderer is attached');
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
expect(admission.admitted).toBe(false);
|
|
333
|
+
expect(admission.reason).toContain('no interactive renderer is attached');
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('admits with no consent configured, so the requirement is opt-in and not vacuous', async () => {
|
|
337
|
+
// The other direction. Without it every case above could be passing on a judge that refuses
|
|
338
|
+
// everything once a grant verifies.
|
|
339
|
+
const admission = await judgeHandoffGrant(handoffGrantFrame({ h: 1 }), {
|
|
340
|
+
verify: async () => ADMITTED,
|
|
341
|
+
});
|
|
342
|
+
expect(admission.admitted).toBe(true);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('closes the channel when the person declines, through the whole gate', async () => {
|
|
346
|
+
const { gate, channel, sessionOnMessage, hs } = makeGate({
|
|
347
|
+
verify: async () => ADMITTED,
|
|
348
|
+
consent: async () => false,
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
hs.accept();
|
|
352
|
+
await settle();
|
|
353
|
+
gate.onInbound(grantFrame());
|
|
354
|
+
await settle();
|
|
355
|
+
gate.onInbound(JSON.stringify({ type: 'submit', prompt: 'p' }));
|
|
356
|
+
|
|
357
|
+
expect(channel.close).toHaveBeenCalled();
|
|
358
|
+
expect(sessionOnMessage).not.toHaveBeenCalled();
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
describe('the judge, asked directly', () => {
|
|
363
|
+
it('refuses when no verifier is configured', async () => {
|
|
364
|
+
// Unreachable through the gate, and a fail-OPEN if it ever were.
|
|
365
|
+
const admission = await judgeHandoffGrant(handoffGrantFrame({}), undefined);
|
|
366
|
+
expect(admission.admitted).toBe(false);
|
|
367
|
+
expect(admission.trust).toBe('unproven');
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it.each([
|
|
371
|
+
['a frame of the wrong kind', { t: 'local-proof', nonce: 'n1' }],
|
|
372
|
+
['a grant frame with no grant', { t: 'handoff-grant' }],
|
|
373
|
+
['not an object at all', 'handoff-grant'],
|
|
374
|
+
['null', null],
|
|
375
|
+
])('refuses %s', async (_label, parsed) => {
|
|
376
|
+
const admission = await judgeHandoffGrant(parsed, { verify: async () => ADMITTED });
|
|
377
|
+
expect(admission.admitted).toBe(false);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('refuses — rather than throwing — when the verifier throws', async () => {
|
|
381
|
+
// Fail CLOSED. A verifier that throws has not reached a decision, and "not reached" is not
|
|
382
|
+
// "allowed". Letting it propagate would park the gate with the channel open, which is a hang:
|
|
383
|
+
// fail-open wearing a crash's clothes.
|
|
384
|
+
const admission = await judgeHandoffGrant(handoffGrantFrame({ x: 1 }), {
|
|
385
|
+
verify: async () => {
|
|
386
|
+
throw new Error('the key store is unreachable');
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
expect(admission.admitted).toBe(false);
|
|
390
|
+
expect(admission.reason).toContain('the key store is unreachable');
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('hands the verifier exactly what the frame carried', async () => {
|
|
394
|
+
// The gate is an envelope, not an interpreter: a gate that reshaped the grant on the way through
|
|
395
|
+
// would break the signature over claims the verifier then could not check.
|
|
396
|
+
const grant = { handoffId: 'h1', nonce: 'n', signature: 'sig' };
|
|
397
|
+
const verify = vi.fn(async () => ADMITTED);
|
|
398
|
+
await judgeHandoffGrant(handoffGrantFrame(grant), { verify });
|
|
399
|
+
expect(verify).toHaveBeenCalledWith(grant);
|
|
400
|
+
});
|
|
401
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { judgeLocalProof, localProofFrame, type ILocalPeerProof } from '../local-peer-proof.js';
|
|
6
|
+
import { PairingGate, type IPairingGateOptions } from '../pairing-gate.js';
|
|
7
|
+
|
|
8
|
+
import type { startPairingHandshake, TPairingFrame } from '@robota-sdk/agent-remote-pairing';
|
|
9
|
+
import type { IPeerAdmission } from '@robota-sdk/agent-interface-session-mobility';
|
|
10
|
+
import type { createSessionMessageHandler } from '@robota-sdk/agent-transport';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* SEC-010 TC-08 (#1810) — the rendezvous nonce bound to the admitted channel.
|
|
14
|
+
*
|
|
15
|
+
* Driven with a stub handshake and a stub ledger: this package must implement no cryptographic
|
|
16
|
+
* policy, so what is asserted here is the GATE's ordering and fail-closed behaviour, never the
|
|
17
|
+
* ledger's single-use rules (those are proven where they live).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const ADMITTED: IPeerAdmission = {
|
|
21
|
+
admitted: true,
|
|
22
|
+
trust: 'same-user-same-host',
|
|
23
|
+
origin: { sessionId: 'peer_a' },
|
|
24
|
+
};
|
|
25
|
+
const REFUSED: IPeerAdmission = { admitted: false, trust: 'unproven', reason: 'replayed' };
|
|
26
|
+
|
|
27
|
+
function makeHandshakeStub() {
|
|
28
|
+
let resolveResult!: (value: { sessionKey: string }) => void;
|
|
29
|
+
const start: typeof startPairingHandshake = (options) => {
|
|
30
|
+
const controller = {
|
|
31
|
+
result: new Promise<{ sessionKey: string }>((res) => {
|
|
32
|
+
resolveResult = res;
|
|
33
|
+
}),
|
|
34
|
+
onFrame: (_frame: TPairingFrame) => {},
|
|
35
|
+
};
|
|
36
|
+
options.send({ t: 'pair-nonce', nonce: 'stub' });
|
|
37
|
+
return controller;
|
|
38
|
+
};
|
|
39
|
+
return { start, accept: () => resolveResult({ sessionKey: 'k' }) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeGate(localPeer?: ILocalPeerProof, over: Partial<IPairingGateOptions> = {}) {
|
|
43
|
+
const channel = { send: vi.fn(), close: vi.fn() };
|
|
44
|
+
const sessionOnMessage = vi.fn();
|
|
45
|
+
const createHandler: typeof createSessionMessageHandler = () => ({
|
|
46
|
+
onMessage: sessionOnMessage,
|
|
47
|
+
cleanup: vi.fn(),
|
|
48
|
+
});
|
|
49
|
+
const hs = makeHandshakeStub();
|
|
50
|
+
const gate = new PairingGate({
|
|
51
|
+
channel,
|
|
52
|
+
session: createTestInteractiveSession(),
|
|
53
|
+
secret: 's',
|
|
54
|
+
role: 'initiator',
|
|
55
|
+
localFingerprint: 'AA',
|
|
56
|
+
remoteFingerprint: 'BB',
|
|
57
|
+
startHandshake: hs.start,
|
|
58
|
+
createHandler,
|
|
59
|
+
...(localPeer ? { localPeer } : {}),
|
|
60
|
+
...over,
|
|
61
|
+
});
|
|
62
|
+
return { gate, channel, sessionOnMessage, hs };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const proofFrame = (nonce = 'n1') => JSON.stringify({ t: 'local-proof', nonce });
|
|
66
|
+
|
|
67
|
+
describe('SEC-010 TC-08 — the session is not exposed until the rendezvous nonce is presented', () => {
|
|
68
|
+
it('a handshake that accepts is NOT enough on its own', async () => {
|
|
69
|
+
// The whole point of the binding. The handshake proves the channel terminates at the peer that
|
|
70
|
+
// knew the secret; it says nothing about where that peer runs, so it must not open the session.
|
|
71
|
+
const redeem = vi.fn(() => ADMITTED);
|
|
72
|
+
const { gate, sessionOnMessage, hs } = makeGate({ redeem });
|
|
73
|
+
|
|
74
|
+
hs.accept();
|
|
75
|
+
await Promise.resolve();
|
|
76
|
+
gate.onInbound(JSON.stringify({ type: 'submit', prompt: 'p' }));
|
|
77
|
+
|
|
78
|
+
expect(redeem).not.toHaveBeenCalled();
|
|
79
|
+
expect(sessionOnMessage).not.toHaveBeenCalled();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('presenting an admitted nonce after the handshake opens the session', async () => {
|
|
83
|
+
const { gate, sessionOnMessage, hs } = makeGate({ redeem: () => ADMITTED });
|
|
84
|
+
|
|
85
|
+
hs.accept();
|
|
86
|
+
await Promise.resolve();
|
|
87
|
+
gate.onInbound(proofFrame());
|
|
88
|
+
gate.onInbound(JSON.stringify({ type: 'submit', prompt: 'p' }));
|
|
89
|
+
|
|
90
|
+
expect(sessionOnMessage).toHaveBeenCalledTimes(1);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('a refused nonce closes the channel and never builds a session handler', async () => {
|
|
94
|
+
// "Fail closed before content" — SEC-010's first failure rule. Refusing after exposure is not
|
|
95
|
+
// refusing.
|
|
96
|
+
const { gate, channel, sessionOnMessage, hs } = makeGate({ redeem: () => REFUSED });
|
|
97
|
+
|
|
98
|
+
hs.accept();
|
|
99
|
+
await Promise.resolve();
|
|
100
|
+
gate.onInbound(proofFrame());
|
|
101
|
+
gate.onInbound(JSON.stringify({ type: 'submit', prompt: 'p' }));
|
|
102
|
+
|
|
103
|
+
expect(channel.close).toHaveBeenCalled();
|
|
104
|
+
expect(sessionOnMessage).not.toHaveBeenCalled();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('the consumer is told about a refusal, not only about a success', async () => {
|
|
108
|
+
// A consumer told only about successes cannot distinguish "no local peer connected" from "a
|
|
109
|
+
// local peer was refused", and those call for different operator responses.
|
|
110
|
+
const onAdmission = vi.fn();
|
|
111
|
+
const { gate, hs } = makeGate({ redeem: () => REFUSED, onAdmission });
|
|
112
|
+
|
|
113
|
+
hs.accept();
|
|
114
|
+
await Promise.resolve();
|
|
115
|
+
gate.onInbound(proofFrame());
|
|
116
|
+
|
|
117
|
+
expect(onAdmission).toHaveBeenCalledWith(REFUSED);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('carries the trust level through to the consumer, not a boolean', async () => {
|
|
121
|
+
// #1810: "a narrow typed authenticated-peer/admission result, not a generic boolean".
|
|
122
|
+
const onAdmission = vi.fn();
|
|
123
|
+
const { gate, hs } = makeGate({ redeem: () => ADMITTED, onAdmission });
|
|
124
|
+
|
|
125
|
+
hs.accept();
|
|
126
|
+
await Promise.resolve();
|
|
127
|
+
gate.onInbound(proofFrame());
|
|
128
|
+
|
|
129
|
+
expect(onAdmission.mock.calls[0][0].trust).toBe('same-user-same-host');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('a frame that is not a proof frame is refused rather than ignored', async () => {
|
|
133
|
+
// Ignoring would leave the gate parked with the channel open — a hang, which is fail-open
|
|
134
|
+
// wearing a stall's clothes.
|
|
135
|
+
const { gate, channel, hs } = makeGate({ redeem: () => ADMITTED });
|
|
136
|
+
|
|
137
|
+
hs.accept();
|
|
138
|
+
await Promise.resolve();
|
|
139
|
+
gate.onInbound(JSON.stringify({ t: 'enroll-key', spki: 'x' }));
|
|
140
|
+
|
|
141
|
+
expect(channel.close).toHaveBeenCalled();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('without the option configured, the gate behaves exactly as before', async () => {
|
|
145
|
+
// A remote peer over WebRTC has no rendezvous to have reached; demanding one unconditionally
|
|
146
|
+
// would refuse every legitimate remote session.
|
|
147
|
+
const { gate, sessionOnMessage, channel, hs } = makeGate();
|
|
148
|
+
|
|
149
|
+
hs.accept();
|
|
150
|
+
await Promise.resolve();
|
|
151
|
+
gate.onInbound(JSON.stringify({ type: 'submit', prompt: 'p' }));
|
|
152
|
+
|
|
153
|
+
expect(sessionOnMessage).toHaveBeenCalledTimes(1);
|
|
154
|
+
expect(channel.close).not.toHaveBeenCalled();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('SEC-010 TC-08 — the judge refuses everything that is not an admitted redemption', () => {
|
|
159
|
+
it('refuses when no proof is configured', () => {
|
|
160
|
+
// Unreachable through the gate, but a fail-OPEN if it ever were.
|
|
161
|
+
expect(judgeLocalProof({ t: 'local-proof', nonce: 'n' }, undefined).admitted).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it.each([
|
|
165
|
+
['a non-proof frame', { t: 'enroll-key', spki: 'x' }],
|
|
166
|
+
['a proof frame with no nonce', { t: 'local-proof' }],
|
|
167
|
+
['a nonce that is not a string', { t: 'local-proof', nonce: 7 }],
|
|
168
|
+
['null', null],
|
|
169
|
+
])('refuses %s', (_label, value) => {
|
|
170
|
+
expect(judgeLocalProof(value, { redeem: () => ADMITTED }).admitted).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('a ledger that throws is a refusal, not an escape', () => {
|
|
174
|
+
// "Not reached" is not "allowed". Letting it propagate would unwind through the channel's
|
|
175
|
+
// message subscription and leave the gate parked with the channel open.
|
|
176
|
+
const admission = judgeLocalProof(
|
|
177
|
+
{ t: 'local-proof', nonce: 'n' },
|
|
178
|
+
{
|
|
179
|
+
redeem: () => {
|
|
180
|
+
throw new Error('ledger unavailable');
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
expect(admission.admitted).toBe(false);
|
|
186
|
+
expect(admission.trust).toBe('unproven');
|
|
187
|
+
expect(admission.reason).toMatch(/could not decide/);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('SEC-010 — the sender and the judge agree on the frame', () => {
|
|
192
|
+
it('a frame built by the helper is accepted by the judge', () => {
|
|
193
|
+
// The two live in one file so they cannot drift. A sender that hand-built the object would keep
|
|
194
|
+
// compiling after the frame gains a field, and the failure would surface as a refusal on the FAR
|
|
195
|
+
// side with no hint that the sender is the stale half.
|
|
196
|
+
const admission = judgeLocalProof(localProofFrame('n1'), { redeem: () => ADMITTED });
|
|
197
|
+
|
|
198
|
+
expect(admission.admitted).toBe(true);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('the helper carries the nonce through unchanged', () => {
|
|
202
|
+
const seen: string[] = [];
|
|
203
|
+
|
|
204
|
+
judgeLocalProof(localProofFrame('the-nonce'), {
|
|
205
|
+
redeem: (nonce) => {
|
|
206
|
+
seen.push(nonce);
|
|
207
|
+
return ADMITTED;
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
expect(seen).toEqual(['the-nonce']);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The device handshake (`agent-remote-pairing`, no workspace dependencies) produces an admission
|
|
3
|
+
* that this carrier hands on as the session-mobility contract. The two are declared apart, so their
|
|
4
|
+
* agreement is checked here, where both are in scope: the typecheck fails if either drifts.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import type { IMeshAdmission, TMeshCapability } from '@robota-sdk/agent-interface-session-mobility';
|
|
9
|
+
import {
|
|
10
|
+
DEVICE_CAPABILITIES,
|
|
11
|
+
type IDeviceMeshAdmission,
|
|
12
|
+
type TDeviceCapability,
|
|
13
|
+
} from '@robota-sdk/agent-remote-pairing';
|
|
14
|
+
|
|
15
|
+
type TEqual<A, B> =
|
|
16
|
+
(<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
|
|
17
|
+
|
|
18
|
+
const sameCapabilities: TEqual<TMeshCapability, TDeviceCapability> = true;
|
|
19
|
+
const sameLocality: TEqual<IMeshAdmission['locality'], IDeviceMeshAdmission['locality']> = true;
|
|
20
|
+
const sameTrust: TEqual<IMeshAdmission['trust'], IDeviceMeshAdmission['trust']> = true;
|
|
21
|
+
|
|
22
|
+
function asMeshAdmission(admission: IDeviceMeshAdmission): IMeshAdmission {
|
|
23
|
+
return admission;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('mesh admission contract', () => {
|
|
27
|
+
it('the handshake admission is the session-mobility admission', () => {
|
|
28
|
+
expect([sameCapabilities, sameLocality, sameTrust]).toEqual([true, true, true]);
|
|
29
|
+
const admission: IDeviceMeshAdmission = {
|
|
30
|
+
trust: 'same-user-different-host',
|
|
31
|
+
locality: 'another-host',
|
|
32
|
+
deviceId: 'device',
|
|
33
|
+
sessionId: 'session',
|
|
34
|
+
capabilities: [...DEVICE_CAPABILITIES],
|
|
35
|
+
};
|
|
36
|
+
expect(asMeshAdmission(admission)).toBe(admission);
|
|
37
|
+
});
|
|
38
|
+
});
|