@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,465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The connection a device enrollment runs over: a new device dials the relay topic derived from its
|
|
3
|
+
* enrollment code, an existing device listens there, and each gets a data channel bound to the DTLS
|
|
4
|
+
* fingerprints of the connection — its own, and the one of the certificate the DTLS layer verified
|
|
5
|
+
* for the peer, never SDP text.
|
|
6
|
+
*
|
|
7
|
+
* This file carries frames and implements no policy: what the channel says, and whether the peer is
|
|
8
|
+
* believed, is the enrollment protocol's (`agent-remote-pairing`), which proves knowledge of the code
|
|
9
|
+
* over these very fingerprints before anything else crosses. Until then a peer here is a stranger,
|
|
10
|
+
* so the relay's signals are decoded as hostile input, a remote description must advertise exactly one
|
|
11
|
+
* fingerprint, and a listener serves one attempt at a time.
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
extractDtlsFingerprint,
|
|
15
|
+
extractDtlsFingerprintAttribute,
|
|
16
|
+
} from '@robota-sdk/agent-remote-pairing';
|
|
17
|
+
|
|
18
|
+
import { whenRemoteCertificateVerified } from './negotiated-certificate.js';
|
|
19
|
+
import { RtcPeer, type RtcChannel } from './rtc-peer.js';
|
|
20
|
+
|
|
21
|
+
import type { IDataChannelModule } from './datachannel-loader.js';
|
|
22
|
+
import type { IMeshRelay } from './mesh-relay.js';
|
|
23
|
+
import type { IIceServer } from './webrtc-transport-options.js';
|
|
24
|
+
|
|
25
|
+
const SIGNAL_VERSION = 1;
|
|
26
|
+
const CHANNEL_LABEL = 'agent-enroll';
|
|
27
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 20_000;
|
|
28
|
+
const MAX_CANDIDATES = 64;
|
|
29
|
+
/** Frames kept for a consumer that has not subscribed yet; an enrollment says a handful. */
|
|
30
|
+
const MAX_EARLY_FRAMES = 16;
|
|
31
|
+
/** Largest frame an enrollment channel carries: a grant holds a roster of certificates. */
|
|
32
|
+
const MAX_FRAME_CHARS = 256 * 1024;
|
|
33
|
+
const MAX_SDP_CHARS = 48 * 1024;
|
|
34
|
+
const MAX_CANDIDATE_CHARS = 2048;
|
|
35
|
+
const ATTEMPT_ID = /^[A-Za-z0-9_-]{22}$/;
|
|
36
|
+
/** Time for a channel close to leave before the connection under it is closed. */
|
|
37
|
+
const PEER_CLOSE_GRACE_MS = 250;
|
|
38
|
+
|
|
39
|
+
/** A data channel bound to one negotiated connection, carrying JSON frames. */
|
|
40
|
+
export interface IEnrollmentChannel {
|
|
41
|
+
readonly localFingerprint: string;
|
|
42
|
+
/** Of the certificate the DTLS layer verified. */
|
|
43
|
+
readonly remoteFingerprint: string;
|
|
44
|
+
/** Send one frame; a closed channel drops it (the peer learns of the close instead). */
|
|
45
|
+
send(frame: unknown): void;
|
|
46
|
+
/** Each inbound frame, JSON-decoded; text that is not JSON arrives as `undefined`. */
|
|
47
|
+
onFrame(handler: (frame: unknown) => void): () => void;
|
|
48
|
+
onClose(handler: () => void): () => void;
|
|
49
|
+
close(): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Why no channel came about. `absent`: nobody waits at the topic — the code is wrong, expired or
|
|
54
|
+
* already used.
|
|
55
|
+
*/
|
|
56
|
+
export type TEnrollmentLinkFailure =
|
|
57
|
+
'absent' | 'timeout' | 'signaling' | 'channel-binding' | 'closed';
|
|
58
|
+
|
|
59
|
+
export class EnrollmentLinkError extends Error {
|
|
60
|
+
readonly reason: TEnrollmentLinkFailure;
|
|
61
|
+
|
|
62
|
+
constructor(reason: TEnrollmentLinkFailure, detail?: string) {
|
|
63
|
+
super(`enrollment connection failed (${reason})${detail !== undefined ? `: ${detail}` : ''}`);
|
|
64
|
+
this.name = 'EnrollmentLinkError';
|
|
65
|
+
this.reason = reason;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface IEnrollmentRendezvous {
|
|
70
|
+
readonly relay: IMeshRelay;
|
|
71
|
+
/** The topic this side waits at. */
|
|
72
|
+
readonly inbound: string;
|
|
73
|
+
/** The topic the other side waits at. */
|
|
74
|
+
readonly outbound: string;
|
|
75
|
+
readonly iceServers?: readonly IIceServer[];
|
|
76
|
+
/** Test seam: inject the `node-datachannel` module. */
|
|
77
|
+
readonly loadDataChannel?: () => IDataChannelModule;
|
|
78
|
+
/** From the first signal of an attempt to a bound, open channel (default 20 s). */
|
|
79
|
+
readonly connectTimeoutMs?: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type TSignal =
|
|
83
|
+
| { readonly kind: 'offer' | 'answer'; readonly cid: string; readonly sdp: string }
|
|
84
|
+
| {
|
|
85
|
+
readonly kind: 'ice';
|
|
86
|
+
readonly cid: string;
|
|
87
|
+
readonly candidate: { readonly candidate: string; readonly mid: string };
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
function bounded(value: unknown, max: number): value is string {
|
|
91
|
+
return typeof value === 'string' && value.length > 0 && value.length <= max;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function decodeSignal(value: unknown): TSignal | undefined {
|
|
95
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
|
|
96
|
+
const r = value as Record<string, unknown>;
|
|
97
|
+
if (r['v'] !== SIGNAL_VERSION || typeof r['cid'] !== 'string' || !ATTEMPT_ID.test(r['cid'])) {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
const cid = r['cid'];
|
|
101
|
+
if (r['kind'] === 'offer' || r['kind'] === 'answer') {
|
|
102
|
+
return bounded(r['sdp'], MAX_SDP_CHARS) ? { kind: r['kind'], cid, sdp: r['sdp'] } : undefined;
|
|
103
|
+
}
|
|
104
|
+
if (r['kind'] === 'ice') {
|
|
105
|
+
const c = r['candidate'];
|
|
106
|
+
if (typeof c !== 'object' || c === null || Array.isArray(c)) return undefined;
|
|
107
|
+
const cr = c as Record<string, unknown>;
|
|
108
|
+
if (!bounded(cr['candidate'], MAX_CANDIDATE_CHARS) || !bounded(cr['mid'], 64)) return undefined;
|
|
109
|
+
return { kind: 'ice', cid, candidate: { candidate: cr['candidate'], mid: cr['mid'] } };
|
|
110
|
+
}
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function randomId(): string {
|
|
115
|
+
const bytes = new Uint8Array(16);
|
|
116
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
117
|
+
return Buffer.from(bytes).toString('base64url');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** One connection attempt, in either role, until its channel is bound and open. */
|
|
121
|
+
class EnrollmentAttempt {
|
|
122
|
+
private peer?: RtcPeer;
|
|
123
|
+
private channel?: RtcChannel;
|
|
124
|
+
private localFingerprint?: string;
|
|
125
|
+
private remoteFingerprint?: string;
|
|
126
|
+
private tookRemoteDescription = false;
|
|
127
|
+
private sentDescription = false;
|
|
128
|
+
private readonly localCandidates: TSignal[] = [];
|
|
129
|
+
private remoteCandidates = 0;
|
|
130
|
+
private chain: Promise<void> = Promise.resolve();
|
|
131
|
+
private stopAwaiting?: () => void;
|
|
132
|
+
private readonly timer: ReturnType<typeof setTimeout>;
|
|
133
|
+
private done = false;
|
|
134
|
+
private readonly early: string[] = [];
|
|
135
|
+
private readonly frameHandlers = new Set<(frame: unknown) => void>();
|
|
136
|
+
private readonly closeHandlers = new Set<() => void>();
|
|
137
|
+
|
|
138
|
+
public constructor(
|
|
139
|
+
private readonly role: 'offerer' | 'answerer',
|
|
140
|
+
public readonly cid: string,
|
|
141
|
+
private readonly options: IEnrollmentRendezvous,
|
|
142
|
+
private readonly onBound: (channel: IEnrollmentChannel) => void,
|
|
143
|
+
private readonly onFailed: (error: EnrollmentLinkError) => void,
|
|
144
|
+
) {
|
|
145
|
+
this.timer = setTimeout(
|
|
146
|
+
() => this.fail(new EnrollmentLinkError('timeout')),
|
|
147
|
+
options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
public async offer(): Promise<void> {
|
|
152
|
+
try {
|
|
153
|
+
const peer = this.createPeer();
|
|
154
|
+
this.adopt(peer.createDataChannel(CHANNEL_LABEL));
|
|
155
|
+
const sdp = await peer.createOffer();
|
|
156
|
+
if (this.done) return;
|
|
157
|
+
this.localFingerprint = extractDtlsFingerprint(sdp);
|
|
158
|
+
this.sendDescription({ kind: 'offer', cid: this.cid, sdp });
|
|
159
|
+
} catch (error) {
|
|
160
|
+
this.fail(new EnrollmentLinkError('signaling', describe(error)));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
public onSignal(signal: TSignal): void {
|
|
165
|
+
if (this.done) return;
|
|
166
|
+
this.chain = this.chain
|
|
167
|
+
.then(() => this.apply(signal))
|
|
168
|
+
.catch((error: unknown) => this.fail(new EnrollmentLinkError('signaling', describe(error))));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private async apply(signal: TSignal): Promise<void> {
|
|
172
|
+
if (this.done) return;
|
|
173
|
+
if (signal.kind === 'ice') {
|
|
174
|
+
if (this.peer === undefined || ++this.remoteCandidates > MAX_CANDIDATES) return;
|
|
175
|
+
this.peer.addRemoteCandidate(signal.candidate);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const expected = this.role === 'offerer' ? 'answer' : 'offer';
|
|
179
|
+
// One remote description: the one fingerprint checked is the one the binding names.
|
|
180
|
+
if (signal.kind !== expected || this.tookRemoteDescription) return;
|
|
181
|
+
this.tookRemoteDescription = true;
|
|
182
|
+
let algorithm: string;
|
|
183
|
+
try {
|
|
184
|
+
algorithm = extractDtlsFingerprintAttribute(signal.sdp).algorithm;
|
|
185
|
+
} catch (error) {
|
|
186
|
+
this.fail(new EnrollmentLinkError('channel-binding', describe(error)));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (this.role === 'offerer') {
|
|
190
|
+
const peer = this.peer;
|
|
191
|
+
if (peer === undefined) return;
|
|
192
|
+
peer.acceptAnswer(signal.sdp);
|
|
193
|
+
this.awaitCertificate(peer, algorithm);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const peer = this.createPeer();
|
|
197
|
+
const sdp = await peer.acceptOffer(signal.sdp);
|
|
198
|
+
if (this.done) return;
|
|
199
|
+
this.localFingerprint = extractDtlsFingerprint(sdp);
|
|
200
|
+
this.sendDescription({ kind: 'answer', cid: this.cid, sdp });
|
|
201
|
+
this.awaitCertificate(peer, algorithm);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private createPeer(): RtcPeer {
|
|
205
|
+
// A certificate of its own per connection, so no stable identifier crosses the relay.
|
|
206
|
+
const peer = new RtcPeer({
|
|
207
|
+
...(this.options.iceServers !== undefined ? { iceServers: this.options.iceServers } : {}),
|
|
208
|
+
...(this.options.loadDataChannel !== undefined
|
|
209
|
+
? { loadDataChannel: this.options.loadDataChannel }
|
|
210
|
+
: {}),
|
|
211
|
+
});
|
|
212
|
+
this.peer = peer;
|
|
213
|
+
peer.onLocalCandidate((candidate) => {
|
|
214
|
+
if (this.done) return;
|
|
215
|
+
const signal: TSignal = {
|
|
216
|
+
kind: 'ice',
|
|
217
|
+
cid: this.cid,
|
|
218
|
+
candidate: { candidate: candidate.candidate, mid: candidate.mid },
|
|
219
|
+
};
|
|
220
|
+
if (this.sentDescription) this.send(signal);
|
|
221
|
+
else if (this.localCandidates.length < MAX_CANDIDATES) this.localCandidates.push(signal);
|
|
222
|
+
});
|
|
223
|
+
peer.onStateChange((state) => {
|
|
224
|
+
if (state === 'failed' || state === 'closed') {
|
|
225
|
+
this.fail(new EnrollmentLinkError('closed', `connection ${state}`));
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
peer.onDataChannel((channel) => {
|
|
229
|
+
if (this.role === 'answerer' && this.channel === undefined && !this.done) this.adopt(channel);
|
|
230
|
+
else channel.close();
|
|
231
|
+
});
|
|
232
|
+
return peer;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private send(signal: TSignal): void {
|
|
236
|
+
this.options.relay.send(this.options.outbound, { v: SIGNAL_VERSION, ...signal });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private sendDescription(signal: TSignal): void {
|
|
240
|
+
this.send(signal);
|
|
241
|
+
this.sentDescription = true;
|
|
242
|
+
for (const candidate of this.localCandidates.splice(0)) this.send(candidate);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private awaitCertificate(peer: RtcPeer, algorithm: string): void {
|
|
246
|
+
this.stopAwaiting = whenRemoteCertificateVerified(
|
|
247
|
+
peer,
|
|
248
|
+
algorithm,
|
|
249
|
+
(fingerprint) => {
|
|
250
|
+
this.remoteFingerprint = fingerprint;
|
|
251
|
+
this.maybeBound();
|
|
252
|
+
},
|
|
253
|
+
(reason) => this.fail(new EnrollmentLinkError('channel-binding', reason)),
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private adopt(channel: RtcChannel): void {
|
|
258
|
+
this.channel = channel;
|
|
259
|
+
// Subscribed at once: the implementation does not keep a frame that arrives before a listener.
|
|
260
|
+
channel.onMessage((text) => this.inbound(text));
|
|
261
|
+
channel.onStateChange((state) => {
|
|
262
|
+
if (state === 'open') this.maybeBound();
|
|
263
|
+
else if (state === 'closed') this.closed();
|
|
264
|
+
});
|
|
265
|
+
if (channel.readyState === 'open') this.maybeBound();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private inbound(text: string): void {
|
|
269
|
+
if (text.length > MAX_FRAME_CHARS) {
|
|
270
|
+
this.closeAll();
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (this.frameHandlers.size === 0) {
|
|
274
|
+
if (this.early.length >= MAX_EARLY_FRAMES) {
|
|
275
|
+
this.closeAll();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
this.early.push(text);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
this.deliver(text);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private deliver(text: string): void {
|
|
285
|
+
let frame: unknown;
|
|
286
|
+
try {
|
|
287
|
+
frame = JSON.parse(text);
|
|
288
|
+
} catch {
|
|
289
|
+
frame = undefined;
|
|
290
|
+
}
|
|
291
|
+
for (const handler of this.frameHandlers) handler(frame);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private maybeBound(): void {
|
|
295
|
+
const channel = this.channel;
|
|
296
|
+
if (this.done || channel === undefined || channel.readyState !== 'open') return;
|
|
297
|
+
const localFingerprint = this.localFingerprint;
|
|
298
|
+
const remoteFingerprint = this.remoteFingerprint;
|
|
299
|
+
if (localFingerprint === undefined || remoteFingerprint === undefined) return;
|
|
300
|
+
this.done = true;
|
|
301
|
+
clearTimeout(this.timer);
|
|
302
|
+
this.stopAwaiting?.();
|
|
303
|
+
this.onBound({
|
|
304
|
+
localFingerprint,
|
|
305
|
+
remoteFingerprint,
|
|
306
|
+
send: (frame) => {
|
|
307
|
+
try {
|
|
308
|
+
channel.send(JSON.stringify(frame));
|
|
309
|
+
} catch {
|
|
310
|
+
// allow-fallback: a closed channel drops the frame; its close reaches both sides
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
onFrame: (handler) => {
|
|
314
|
+
this.frameHandlers.add(handler);
|
|
315
|
+
const held = this.early.splice(0);
|
|
316
|
+
if (held.length > 0) queueMicrotask(() => held.forEach((text) => this.deliver(text)));
|
|
317
|
+
return () => this.frameHandlers.delete(handler);
|
|
318
|
+
},
|
|
319
|
+
onClose: (handler) => {
|
|
320
|
+
if (channel.readyState === 'closed') {
|
|
321
|
+
queueMicrotask(handler);
|
|
322
|
+
return () => undefined;
|
|
323
|
+
}
|
|
324
|
+
this.closeHandlers.add(handler);
|
|
325
|
+
return () => this.closeHandlers.delete(handler);
|
|
326
|
+
},
|
|
327
|
+
close: () => this.closeAll(),
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private closed(): void {
|
|
332
|
+
if (!this.done) {
|
|
333
|
+
this.fail(new EnrollmentLinkError('closed', 'data channel closed'));
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
this.closeAll();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private closeAll(): void {
|
|
340
|
+
this.channel?.close();
|
|
341
|
+
const peer = this.peer;
|
|
342
|
+
if (peer !== undefined) setTimeout(() => peer.close(), PEER_CLOSE_GRACE_MS).unref?.();
|
|
343
|
+
const handlers = [...this.closeHandlers];
|
|
344
|
+
this.closeHandlers.clear();
|
|
345
|
+
this.frameHandlers.clear();
|
|
346
|
+
for (const handler of handlers) handler();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
public fail(error: EnrollmentLinkError): void {
|
|
350
|
+
if (this.done) return;
|
|
351
|
+
this.done = true;
|
|
352
|
+
clearTimeout(this.timer);
|
|
353
|
+
this.stopAwaiting?.();
|
|
354
|
+
this.channel?.close();
|
|
355
|
+
this.peer?.close();
|
|
356
|
+
this.onFailed(error);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function describe(error: unknown): string {
|
|
361
|
+
return error instanceof Error ? error.message : String(error);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* New device: connect to whoever waits at `outbound`. Resolves with the bound channel; rejects when
|
|
366
|
+
* nobody waits there (`absent`), on timeout, or when the connection fails. The relay stays the
|
|
367
|
+
* caller's.
|
|
368
|
+
*/
|
|
369
|
+
export function dialEnrollment(options: IEnrollmentRendezvous): Promise<IEnrollmentChannel> {
|
|
370
|
+
return new Promise<IEnrollmentChannel>((resolve, reject) => {
|
|
371
|
+
const unsubscribes: (() => void)[] = [];
|
|
372
|
+
const finish = (): void => {
|
|
373
|
+
for (const unsubscribe of unsubscribes.splice(0)) unsubscribe();
|
|
374
|
+
};
|
|
375
|
+
const attempt = new EnrollmentAttempt(
|
|
376
|
+
'offerer',
|
|
377
|
+
randomId(),
|
|
378
|
+
options,
|
|
379
|
+
(channel) => {
|
|
380
|
+
finish();
|
|
381
|
+
resolve(channel);
|
|
382
|
+
},
|
|
383
|
+
(error) => {
|
|
384
|
+
finish();
|
|
385
|
+
reject(error);
|
|
386
|
+
},
|
|
387
|
+
);
|
|
388
|
+
unsubscribes.push(
|
|
389
|
+
options.relay.onMessage((topic, data) => {
|
|
390
|
+
if (topic !== options.inbound) return;
|
|
391
|
+
const signal = decodeSignal(data);
|
|
392
|
+
if (signal !== undefined && signal.cid === attempt.cid) attempt.onSignal(signal);
|
|
393
|
+
}),
|
|
394
|
+
options.relay.onAbsent((topic) => {
|
|
395
|
+
if (topic === options.outbound) attempt.fail(new EnrollmentLinkError('absent'));
|
|
396
|
+
}),
|
|
397
|
+
);
|
|
398
|
+
options.relay.declarePresence([options.inbound]);
|
|
399
|
+
void attempt.offer();
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export interface IEnrollmentListener {
|
|
404
|
+
/**
|
|
405
|
+
* Stop taking new attempts. A channel already handed over stays open. The topic is held until the
|
|
406
|
+
* caller closes the relay or declares other topics.
|
|
407
|
+
*/
|
|
408
|
+
close(): void;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Existing device: wait at `inbound` for a new device, one attempt at a time. Each bound channel goes
|
|
413
|
+
* to `onChannel`; the next attempt is taken once that channel closes. The relay stays the caller's.
|
|
414
|
+
*/
|
|
415
|
+
export function listenForEnrollment(
|
|
416
|
+
options: IEnrollmentRendezvous & {
|
|
417
|
+
readonly onChannel: (channel: IEnrollmentChannel) => void;
|
|
418
|
+
/** An attempt that ended before its channel was bound. */
|
|
419
|
+
readonly onAttemptFailed?: (error: EnrollmentLinkError) => void;
|
|
420
|
+
},
|
|
421
|
+
): IEnrollmentListener {
|
|
422
|
+
let active: EnrollmentAttempt | undefined;
|
|
423
|
+
let closed = false;
|
|
424
|
+
const unsubscribe = options.relay.onMessage((topic, data) => {
|
|
425
|
+
if (closed || topic !== options.inbound) return;
|
|
426
|
+
const signal = decodeSignal(data);
|
|
427
|
+
if (signal === undefined) return;
|
|
428
|
+
if (active !== undefined) {
|
|
429
|
+
if (signal.cid === active.cid) active.onSignal(signal);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (signal.kind !== 'offer') return;
|
|
433
|
+
const attempt: EnrollmentAttempt = new EnrollmentAttempt(
|
|
434
|
+
'answerer',
|
|
435
|
+
signal.cid,
|
|
436
|
+
options,
|
|
437
|
+
(channel) => {
|
|
438
|
+
if (closed) {
|
|
439
|
+
channel.close();
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
channel.onClose(() => {
|
|
443
|
+
if (active === attempt) active = undefined;
|
|
444
|
+
});
|
|
445
|
+
options.onChannel(channel);
|
|
446
|
+
},
|
|
447
|
+
(error) => {
|
|
448
|
+
if (active === attempt) active = undefined;
|
|
449
|
+
options.onAttemptFailed?.(error);
|
|
450
|
+
},
|
|
451
|
+
);
|
|
452
|
+
active = attempt;
|
|
453
|
+
attempt.onSignal(signal);
|
|
454
|
+
});
|
|
455
|
+
options.relay.declarePresence([options.inbound]);
|
|
456
|
+
return {
|
|
457
|
+
close: () => {
|
|
458
|
+
if (closed) return;
|
|
459
|
+
closed = true;
|
|
460
|
+
unsubscribe();
|
|
461
|
+
// An attempt still connecting is dropped; a bound channel is its consumer's.
|
|
462
|
+
active?.fail(new EnrollmentLinkError('closed', 'no longer listening'));
|
|
463
|
+
},
|
|
464
|
+
};
|
|
465
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEC-011 (issue #1865): the channel gate consumes the cross-device hand-off grant.
|
|
3
|
+
*
|
|
4
|
+
* The same shape SEC-010's local-proof step established, for the same reason. #1810 is explicit that
|
|
5
|
+
* this package must not implement cryptographic policy, so nothing here verifies a signature,
|
|
6
|
+
* decides an expiry, or knows what revocation is. The gate holds the state machine; the verdict is
|
|
7
|
+
* injected and reported.
|
|
8
|
+
*
|
|
9
|
+
* ## What the grant proves, and what the channel proves
|
|
10
|
+
*
|
|
11
|
+
* The pairing handshake binds the CHANNEL: after it, this data channel provably terminates at the
|
|
12
|
+
* peer that knew the secret, and the DTLS fingerprints are covered so it cannot be spliced. It says
|
|
13
|
+
* nothing about who that peer is.
|
|
14
|
+
*
|
|
15
|
+
* The grant binds the TRANSFER: one user, one source device, one destination, one `handoffId`, one
|
|
16
|
+
* `sessionId`, one nonce, and the fingerprint of the channel it may be presented over. It says
|
|
17
|
+
* nothing about whether the channel it arrived on is the one it names.
|
|
18
|
+
*
|
|
19
|
+
* Presenting the grant OVER the already-bound channel is what joins them, and the fingerprint claim
|
|
20
|
+
* is what makes the join checkable: a grant lifted from one channel and replayed on another names a
|
|
21
|
+
* fingerprint the verifier does not observe. That is `channel-substituted`, and it is a distinct
|
|
22
|
+
* rejection precisely so it cannot be softened into a pass.
|
|
23
|
+
*
|
|
24
|
+
* ## Why it runs after the handshake and before the session
|
|
25
|
+
*
|
|
26
|
+
* Both edges are load-bearing, and both for SEC-010's reasons.
|
|
27
|
+
*
|
|
28
|
+
* BEFORE the handshake completes, the channel is not authenticated — a grant presented there would
|
|
29
|
+
* be handed to an unproven counterpart, and this step would weaken what it exists to strengthen.
|
|
30
|
+
*
|
|
31
|
+
* BEFORE the session is exposed, because "fail closed before content" is the first failure rule. A
|
|
32
|
+
* peer that never presents a grant must not have been talking to the session already; refusing it
|
|
33
|
+
* afterwards is not refusing it.
|
|
34
|
+
*
|
|
35
|
+
* ## The trust it produces is its own
|
|
36
|
+
*
|
|
37
|
+
* `same-user-different-host` is not `same-user-same-host`. #1812 pins that they are different values
|
|
38
|
+
* and this gate is the reason: a cross-device authorization must never satisfy a check that wanted
|
|
39
|
+
* same-machine. It travels as a value on the admission rather than being re-derived from a boolean
|
|
40
|
+
* by whoever reads it next.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import type { IPeerAdmission } from '@robota-sdk/agent-interface-session-mobility';
|
|
44
|
+
|
|
45
|
+
/** The frame a source device presents to show it holds a grant for this transfer. */
|
|
46
|
+
export interface IHandoffGrantFrame {
|
|
47
|
+
readonly t: 'handoff-grant';
|
|
48
|
+
/**
|
|
49
|
+
* The signed grant, carried opaquely.
|
|
50
|
+
*
|
|
51
|
+
* `unknown` on purpose: this package cannot import the grant type without importing the package
|
|
52
|
+
* that owns the crypto, and typing it structurally here would create a second declaration of a
|
|
53
|
+
* signed object's shape — which is how a field ends up checked in one copy and not the other.
|
|
54
|
+
* The verifier owns the type; this gate owns the envelope.
|
|
55
|
+
*/
|
|
56
|
+
readonly grant: unknown;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The cross-device authorization this gate requires before exposing the session.
|
|
61
|
+
*
|
|
62
|
+
* Absent → the gate behaves exactly as before. Requiring a grant is opt-in because an ordinary
|
|
63
|
+
* remote peer has no hand-off to authorize, and a gate that demanded one unconditionally would
|
|
64
|
+
* refuse every legitimate remote session.
|
|
65
|
+
*/
|
|
66
|
+
export interface IHandoffGrantProof {
|
|
67
|
+
/**
|
|
68
|
+
* Judge a presented grant. Owned by the verifier — this package asks and does not decide.
|
|
69
|
+
*
|
|
70
|
+
* Async because verifying a signature is: `verifyHandoffGrant` returns a promise, and a
|
|
71
|
+
* synchronous seam here would force the owner to block or to pre-verify, and pre-verifying means
|
|
72
|
+
* deciding before the channel that the grant is bound to even exists.
|
|
73
|
+
*
|
|
74
|
+
* Returns the admission the consumer receives, so the trust level travels as a value.
|
|
75
|
+
*/
|
|
76
|
+
readonly verify: (grant: unknown) => Promise<IPeerAdmission>;
|
|
77
|
+
/**
|
|
78
|
+
* Ask the person at THIS machine whether to accept the transfer. Absent → no consent is required.
|
|
79
|
+
*
|
|
80
|
+
* Called ONLY on a verdict the verifier admitted, and given that verdict, for two reasons that
|
|
81
|
+
* both matter:
|
|
82
|
+
*
|
|
83
|
+
* The prompt can then name a PROVEN origin. Asking before verification would let anyone who can
|
|
84
|
+
* open a channel raise a dialog on someone's machine claiming to be any device they like, which
|
|
85
|
+
* turns the consent step into an attack surface instead of a control.
|
|
86
|
+
*
|
|
87
|
+
* And consent is not cryptographic policy, so it does not belong inside `verify`. A verifier that
|
|
88
|
+
* also asked a human would make "is this grant valid" and "does this person want it" one answer,
|
|
89
|
+
* and the first is the one that must be decidable without a person present.
|
|
90
|
+
*
|
|
91
|
+
* Returning false — or throwing, or having no renderer to ask — refuses. Denial fails CLOSED.
|
|
92
|
+
*/
|
|
93
|
+
readonly consent?: (admission: IPeerAdmission) => Promise<boolean>;
|
|
94
|
+
/**
|
|
95
|
+
* Fired on the outcome, admitted or not.
|
|
96
|
+
*
|
|
97
|
+
* On BOTH, deliberately: a consumer told only about successes cannot distinguish "no hand-off was
|
|
98
|
+
* offered" from "a hand-off was refused", and those call for different operator responses — the
|
|
99
|
+
* second is the one that belongs in front of a person.
|
|
100
|
+
*/
|
|
101
|
+
readonly onAdmission?: (admission: IPeerAdmission) => void;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* True when a parsed pre-accept value is a grant frame.
|
|
106
|
+
*
|
|
107
|
+
* Module-private: `judgeHandoffGrant` is the only thing that should ask. Exporting it would offer a
|
|
108
|
+
* caller a way to check the shape and then act on it without going through the judge, which is how
|
|
109
|
+
* an admission decision ends up made in two places.
|
|
110
|
+
*/
|
|
111
|
+
function isHandoffGrantFrame(value: unknown): value is IHandoffGrantFrame {
|
|
112
|
+
return (
|
|
113
|
+
typeof value === 'object' &&
|
|
114
|
+
value !== null &&
|
|
115
|
+
(value as { t?: unknown }).t === 'handoff-grant' &&
|
|
116
|
+
(value as { grant?: unknown }).grant !== undefined
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** A refusal shaped like every other admission, so no caller has to special-case the failure path. */
|
|
121
|
+
function refuse(reason: string): IPeerAdmission {
|
|
122
|
+
return { admitted: false, trust: 'unproven', reason };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Judge one pre-accept frame against the configured grant proof.
|
|
127
|
+
*
|
|
128
|
+
* Every path that is not an admitted verification returns a refusal — including a missing config,
|
|
129
|
+
* which cannot happen through the gate but would be a fail-OPEN if it ever did.
|
|
130
|
+
*/
|
|
131
|
+
export async function judgeHandoffGrant(
|
|
132
|
+
parsed: unknown,
|
|
133
|
+
proof: IHandoffGrantProof | undefined,
|
|
134
|
+
): Promise<IPeerAdmission> {
|
|
135
|
+
if (proof === undefined)
|
|
136
|
+
return refuse('no hand-off grant verifier is configured for this channel');
|
|
137
|
+
if (!isHandoffGrantFrame(parsed)) {
|
|
138
|
+
return refuse('expected a handoff-grant frame carrying the signed grant');
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
const admission = await proof.verify(parsed.grant);
|
|
142
|
+
// The verifier owns the verdict, but not the ability to widen it. A cross-device grant cannot
|
|
143
|
+
// establish that the peer is on THIS machine, so an admission claiming it did is refused rather
|
|
144
|
+
// than passed on — an implementation that returned the stronger level would otherwise satisfy
|
|
145
|
+
// every check that wanted same-machine, which is the one substitution #1812 forbids by name.
|
|
146
|
+
if (admission.admitted && admission.trust === 'same-user-same-host') {
|
|
147
|
+
return refuse(
|
|
148
|
+
'the grant verifier returned same-user-same-host. A cross-device grant proves the user, ' +
|
|
149
|
+
'not the machine — see SEC-010 for what same-host requires.',
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
if (!admission.admitted || proof.consent === undefined) return admission;
|
|
153
|
+
// The person, after the proof. A refusal here is not a weaker outcome than a cryptographic one:
|
|
154
|
+
// the session is equally not exposed, and the reason says which step declined so the operator is
|
|
155
|
+
// not left reading "unauthorized" when what happened is that they said no.
|
|
156
|
+
if (!(await proof.consent(admission))) {
|
|
157
|
+
return refuse('the person at this machine declined the transfer');
|
|
158
|
+
}
|
|
159
|
+
return admission;
|
|
160
|
+
} catch (error) {
|
|
161
|
+
// allow-fallback: fail-CLOSED, not a fallback to a degraded path. A verifier that throws has not
|
|
162
|
+
// reached a decision, and "not reached" is not "allowed" — the refusal below is strictly more
|
|
163
|
+
// restrictive than the success path, never less. Letting it propagate would be worse: the throw
|
|
164
|
+
// unwinds through the channel's message subscription and leaves the gate parked in its grant
|
|
165
|
+
// state with the channel open, which is a hang — fail-open wearing a crash's clothes.
|
|
166
|
+
return refuse(
|
|
167
|
+
`the grant verifier could not decide: ${
|
|
168
|
+
error instanceof Error ? error.message : 'unknown error'
|
|
169
|
+
}`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Build the frame a source device presents on the channel.
|
|
176
|
+
*
|
|
177
|
+
* Lives beside the judge that reads it, so the two cannot drift: a sender that hand-built the object
|
|
178
|
+
* would keep compiling after the frame gains a field, and the failure would surface as a refusal on
|
|
179
|
+
* the far side with no hint that the SENDER is the stale half.
|
|
180
|
+
*
|
|
181
|
+
* Deliberately not a "send" — it returns the frame and leaves transmission to whoever owns the
|
|
182
|
+
* channel, for the same reason `localProofFrame` does.
|
|
183
|
+
*/
|
|
184
|
+
export function handoffGrantFrame(grant: unknown): IHandoffGrantFrame {
|
|
185
|
+
return { t: 'handoff-grant', grant };
|
|
186
|
+
}
|