agent-dag 3.13.0 → 3.15.0
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/dist/web/assets/index-CbYzt04Y.js +248 -0
- package/dist/web/assets/index-CqAgutJT.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +40 -0
- package/src/server/claude-accounts.mjs +13 -0
- package/src/server/deck-prefs.mjs +68 -4
- package/src/server/index.mjs +119 -3
- package/src/server/lan-engine.mjs +359 -0
- package/src/server/lan-socket.mjs +395 -0
- package/src/server/lan-sync.mjs +620 -0
- package/src/server/system-metrics.mjs +12 -6
- package/dist/web/assets/index-DgmJovM0.css +0 -1
- package/dist/web/assets/index-DnhBLSJz.js +0 -235
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
// The sockets. Every decision this makes lives in lan-sync.mjs; what is here is
|
|
2
|
+
// the plumbing that decision layer refuses to own — a UDP socket that shouts,
|
|
3
|
+
// a TCP listener that answers, and the deadlines around both.
|
|
4
|
+
//
|
|
5
|
+
// TWO SOCKETS, AND NEITHER IS THE DECK'S HTTP SERVER. That server binds
|
|
6
|
+
// 127.0.0.1 and stays there. It has a mutation guard that deliberately trusts a
|
|
7
|
+
// request carrying no Origin header, so that hook.js and curl keep working —
|
|
8
|
+
// correct on loopback, and total exposure the moment the same server answers
|
|
9
|
+
// the network. So this feature never asks anybody to run `--host`: it opens its
|
|
10
|
+
// own listener, that listener speaks one protocol and nothing else, and it
|
|
11
|
+
// refuses every frame from anybody who has not proved they hold the group
|
|
12
|
+
// passphrase.
|
|
13
|
+
//
|
|
14
|
+
// DISCOVERY IS BROADCAST ON A FIXED PORT; THE SYNC LISTENER IS EPHEMERAL and
|
|
15
|
+
// says its port in the beacon. One fixed port rather than two is one thing to
|
|
16
|
+
// collide with, one firewall dialog, and one number in a support answer.
|
|
17
|
+
//
|
|
18
|
+
// A DECK HEARS ITSELF. Measured, not assumed: a broadcast to 255.255.255.255
|
|
19
|
+
// comes back to every socket on the sending machine bound to that port, from
|
|
20
|
+
// the machine's own LAN address rather than from loopback. That is what makes
|
|
21
|
+
// two decks on one machine find each other — which is how this gets tested at
|
|
22
|
+
// all — and it is why self-recognition is by fingerprint rather than by
|
|
23
|
+
// address.
|
|
24
|
+
import dgram from "node:dgram";
|
|
25
|
+
import net from "node:net";
|
|
26
|
+
import { randomBytes } from "node:crypto";
|
|
27
|
+
import {
|
|
28
|
+
beaconPayload, beaconVerdict, groupTag, notePeer, proof, proofOk, readBeacon,
|
|
29
|
+
ANNOUNCE_MS, MAX_BEACON_BYTES, MAX_MANIFEST_BYTES,
|
|
30
|
+
} from "./lan-sync.mjs";
|
|
31
|
+
|
|
32
|
+
/** The one fixed port in the feature. Out of the deck's HTTP range (4317-4400)
|
|
33
|
+
* so a beacon can never be mistaken for a deck's own traffic, and unassigned:
|
|
34
|
+
* 45317 reads as "4317, elsewhere", which is what a person tracing this in a
|
|
35
|
+
* firewall log needs it to say. */
|
|
36
|
+
export const DISCOVERY_PORT = 45_317;
|
|
37
|
+
|
|
38
|
+
/** How long a connection has to finish the handshake before it is dropped. A
|
|
39
|
+
* handshake is two round trips on a local network — single-digit
|
|
40
|
+
* milliseconds — so five seconds is generous for a slow machine and short
|
|
41
|
+
* enough that holding sockets open costs an attacker something. */
|
|
42
|
+
export const HANDSHAKE_MS = 5_000;
|
|
43
|
+
|
|
44
|
+
/** The most one frame may be, and the most a peer may hold open.
|
|
45
|
+
*
|
|
46
|
+
* Frames are JSON lines. The largest legitimate one is a manifest; the cap is
|
|
47
|
+
* an order of magnitude over the biggest real store, and the buffer is
|
|
48
|
+
* ABANDONED rather than grown past it — a socket that keeps sending without a
|
|
49
|
+
* newline is trying to make this allocate, and the answer is to stop reading
|
|
50
|
+
* rather than to read faster. */
|
|
51
|
+
export const MAX_FRAME_BYTES = MAX_MANIFEST_BYTES;
|
|
52
|
+
|
|
53
|
+
/** Concurrent connections from all peers together. Small on purpose: the real
|
|
54
|
+
* number is one per peer per minute, and anything above this is either a bug
|
|
55
|
+
* in a peer or somebody holding sockets open to see what happens. */
|
|
56
|
+
export const MAX_SOCKETS = 16;
|
|
57
|
+
|
|
58
|
+
/** The shortest gap between two "I am here too" replies to a stranger. Long
|
|
59
|
+
* enough that a burst of decks starting together cannot make a storm, short
|
|
60
|
+
* enough that starting two decks by hand feels instant. */
|
|
61
|
+
export const REPLY_COOLDOWN_MS = 2_000;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The shouting half.
|
|
65
|
+
*
|
|
66
|
+
* `reuseAddr` is not a convenience: without it a second deck on the same
|
|
67
|
+
* machine cannot bind the discovery port at all, and two decks on one machine
|
|
68
|
+
* is both a real setup and the only way this gets tested here.
|
|
69
|
+
*
|
|
70
|
+
* Errors are reported, never thrown. A machine with no route, a firewall that
|
|
71
|
+
* refuses the bind, an interface that comes and goes with a VPN — none of them
|
|
72
|
+
* is a reason for the deck to fall over, and all of them are reasons for the
|
|
73
|
+
* panel to be able to say what happened.
|
|
74
|
+
*/
|
|
75
|
+
export function createBeacon({
|
|
76
|
+
port, name, fp, key, onPeer, onError, onIdClash, now = Date.now,
|
|
77
|
+
// Injected so the suite can drive this with a socket it controls. CI runners
|
|
78
|
+
// are not a network: GitHub's have no broadcast domain worth the name, and a
|
|
79
|
+
// test that quietly skipped there would be a test that stopped testing
|
|
80
|
+
// without saying so. The REAL socket is exercised by hand, two decks on one
|
|
81
|
+
// machine, which works because a broadcast comes back to its own host.
|
|
82
|
+
createSocket = opts => dgram.createSocket(opts),
|
|
83
|
+
} = {}) {
|
|
84
|
+
const group = key ? groupTag(key) : null;
|
|
85
|
+
// Randomised per process. Two beacons from one fingerprint with different
|
|
86
|
+
// instance ids mean the deck restarted between them, which is the signal to
|
|
87
|
+
// drop any session held for it rather than resume into a process that is gone.
|
|
88
|
+
const instance = randomBytes(8).toString("hex");
|
|
89
|
+
const peers = new Map();
|
|
90
|
+
let sock = null;
|
|
91
|
+
let timer = null;
|
|
92
|
+
/** When this deck last answered a stranger, so answering cannot become a
|
|
93
|
+
* storm. See the reply in the message handler. */
|
|
94
|
+
let repliedAt = 0;
|
|
95
|
+
|
|
96
|
+
const payload = () => Buffer.from(JSON.stringify(beaconPayload({ name, fp, port, group, instance })));
|
|
97
|
+
|
|
98
|
+
const announce = () => {
|
|
99
|
+
if (!sock || !group) return;
|
|
100
|
+
// 255.255.255.255 rather than a multicast group, and rather than the
|
|
101
|
+
// subnet's own broadcast address. The subnet-directed form needs the
|
|
102
|
+
// netmask of whichever interface the packet leaves by, which changes when a
|
|
103
|
+
// VPN comes up; the limited broadcast needs nothing and is what Syncthing
|
|
104
|
+
// sends for the same reason.
|
|
105
|
+
sock.send(payload(), DISCOVERY_PORT, "255.255.255.255", err => {
|
|
106
|
+
// ENETUNREACH and EACCES are what a machine with no network, or a
|
|
107
|
+
// firewall, says. Both are states the panel reports rather than crashes.
|
|
108
|
+
if (err) onError?.("announce", err);
|
|
109
|
+
});
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const start = () => new Promise(resolve => {
|
|
113
|
+
sock = createSocket({ type: "udp4", reuseAddr: true });
|
|
114
|
+
sock.on("error", err => { onError?.("socket", err); });
|
|
115
|
+
sock.on("message", (msg, rinfo) => {
|
|
116
|
+
// Everything about whether to care lives in lan-sync.mjs. This hands it
|
|
117
|
+
// the bytes and the address and does what it is told.
|
|
118
|
+
if (msg.length > MAX_BEACON_BYTES) return;
|
|
119
|
+
const beacon = readBeacon(msg);
|
|
120
|
+
const verdict = beaconVerdict(beacon, { selfFp: fp, selfGroup: group, selfInstance: instance });
|
|
121
|
+
if (verdict !== "peer") {
|
|
122
|
+
if (verdict === "other-group") onError?.("other-group", null);
|
|
123
|
+
// Another deck is using this one's id — see beaconVerdict. Reported
|
|
124
|
+
// rather than fixed here: this file carries packets, and choosing a new
|
|
125
|
+
// name for the deck belongs to whoever stores it.
|
|
126
|
+
if (verdict === "id-clash") onIdClash?.();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const known = peers.has(beacon.fp);
|
|
130
|
+
const noted = notePeer(peers, beacon, rinfo.address, now());
|
|
131
|
+
// ANSWER A DECK WE HAVE NEVER SEEN, once. Without this the second deck to
|
|
132
|
+
// start finds nobody until the first one's next interval — measured on
|
|
133
|
+
// two real decks: 1 saw 2 immediately, 2 saw nobody, because 1's own
|
|
134
|
+
// immediate announce went out before 2 was listening. Thirty seconds of
|
|
135
|
+
// an empty list is how a working feature reads as broken.
|
|
136
|
+
//
|
|
137
|
+
// Only for a stranger, and at most once every few seconds, because the
|
|
138
|
+
// obvious version is a shout storm: two decks answering each other's
|
|
139
|
+
// answers forever. A new pair converges in two extra packets — A answers
|
|
140
|
+
// B, B answers A's answer, and A stays quiet because B is no longer new.
|
|
141
|
+
if (!known && now() - repliedAt > REPLY_COOLDOWN_MS) {
|
|
142
|
+
repliedAt = now();
|
|
143
|
+
announce();
|
|
144
|
+
}
|
|
145
|
+
if (noted.changed || noted.restarted) onPeer?.(noted);
|
|
146
|
+
});
|
|
147
|
+
sock.bind(DISCOVERY_PORT, "0.0.0.0", () => {
|
|
148
|
+
try { sock.setBroadcast(true); } catch (err) { onError?.("broadcast", err); }
|
|
149
|
+
// Immediately, not on the next tick. Syncthing's rule: a deck that just
|
|
150
|
+
// came up should appear now rather than up to thirty seconds later, which
|
|
151
|
+
// is the difference between "it works" and "it seems broken" for anybody
|
|
152
|
+
// who starts two decks and watches.
|
|
153
|
+
announce();
|
|
154
|
+
timer = setInterval(announce, ANNOUNCE_MS);
|
|
155
|
+
timer.unref?.();
|
|
156
|
+
resolve();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
start,
|
|
162
|
+
announce,
|
|
163
|
+
peers,
|
|
164
|
+
stop() {
|
|
165
|
+
if (timer) clearInterval(timer);
|
|
166
|
+
timer = null;
|
|
167
|
+
try { sock?.close(); } catch { /* already closed */ }
|
|
168
|
+
sock = null;
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Read newline-delimited JSON off a socket, refusing to be made to allocate.
|
|
175
|
+
*
|
|
176
|
+
* The cap is on the UNTERMINATED buffer rather than on a frame that arrived,
|
|
177
|
+
* which is the distinction that matters: a peer that sends a megabyte with no
|
|
178
|
+
* newline in it is not sending a large frame, it is sending nothing at all,
|
|
179
|
+
* expensively. Past the cap this stops reading and hands the caller a refusal
|
|
180
|
+
* — it does not keep buffering in the hope a newline turns up.
|
|
181
|
+
*/
|
|
182
|
+
export function frameReader(onFrame, onRefuse, max = MAX_FRAME_BYTES) {
|
|
183
|
+
let buf = "";
|
|
184
|
+
let dead = false;
|
|
185
|
+
return chunk => {
|
|
186
|
+
if (dead) return;
|
|
187
|
+
buf += chunk;
|
|
188
|
+
if (buf.length > max) { dead = true; buf = ""; onRefuse("frame too large"); return; }
|
|
189
|
+
let i;
|
|
190
|
+
while ((i = buf.indexOf("\n")) !== -1) {
|
|
191
|
+
const line = buf.slice(0, i);
|
|
192
|
+
buf = buf.slice(i + 1);
|
|
193
|
+
if (!line.trim()) continue;
|
|
194
|
+
let msg;
|
|
195
|
+
try { msg = JSON.parse(line); }
|
|
196
|
+
catch { dead = true; buf = ""; onRefuse("not json"); return; }
|
|
197
|
+
// `typeof [] === "object"`, so an array walks straight past the obvious
|
|
198
|
+
// check and reaches a handler that reads `msg.t` off it — undefined, and
|
|
199
|
+
// then whatever that handler does with a frame that has no type. A frame
|
|
200
|
+
// is a record; anything else is refused.
|
|
201
|
+
if (!msg || typeof msg !== "object" || Array.isArray(msg)) {
|
|
202
|
+
dead = true; buf = ""; onRefuse("not an object"); return;
|
|
203
|
+
}
|
|
204
|
+
onFrame(msg);
|
|
205
|
+
if (dead) return;
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** One line out. Kept in one place so nothing forgets the newline the reader
|
|
211
|
+
* above is waiting for. */
|
|
212
|
+
export function sendFrame(sock, obj) {
|
|
213
|
+
try { sock.write(`${JSON.stringify(obj)}\n`); } catch { /* peer went away */ }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The answering half.
|
|
218
|
+
*
|
|
219
|
+
* WHAT IT DOES BEFORE IT KNOWS WHO IS CALLING, in order, and the order is the
|
|
220
|
+
* whole security argument: accept, arm a deadline, read at most one frame, and
|
|
221
|
+
* check a proof. No manifest, no account, no store read, nothing that touches
|
|
222
|
+
* claude-swap, until `authed` is true. KDE Connect's CVE-2020-26164 was several
|
|
223
|
+
* issues in a daemon whose protocol was fine, and this is the shape that
|
|
224
|
+
* lesson has.
|
|
225
|
+
*
|
|
226
|
+
* `handlers` is called only with authenticated frames, and it never sees the
|
|
227
|
+
* handshake at all.
|
|
228
|
+
*/
|
|
229
|
+
export function createSyncServer({ fp, name, key, handlers, onError, host = "0.0.0.0" } = {}) {
|
|
230
|
+
let server = null;
|
|
231
|
+
const live = new Set();
|
|
232
|
+
|
|
233
|
+
const onConnection = sock => {
|
|
234
|
+
if (!key || live.size >= MAX_SOCKETS) { sock.destroy(); return; }
|
|
235
|
+
live.add(sock);
|
|
236
|
+
sock.setEncoding("utf8");
|
|
237
|
+
sock.setNoDelay(true);
|
|
238
|
+
|
|
239
|
+
let authed = false;
|
|
240
|
+
let peerFp = null;
|
|
241
|
+
const myChallenge = randomBytes(16).toString("hex");
|
|
242
|
+
let theirChallenge = null;
|
|
243
|
+
|
|
244
|
+
// Armed before the first byte is read, and cleared only by a completed
|
|
245
|
+
// handshake. A socket that connects and says nothing is the cheapest
|
|
246
|
+
// possible way to hold a resource, so it is also the first one closed.
|
|
247
|
+
const deadline = setTimeout(() => { if (!authed) sock.destroy(); }, HANDSHAKE_MS);
|
|
248
|
+
deadline.unref?.();
|
|
249
|
+
|
|
250
|
+
const done = () => { clearTimeout(deadline); live.delete(sock); };
|
|
251
|
+
sock.on("close", done);
|
|
252
|
+
sock.on("error", err => { done(); onError?.("peer", err); });
|
|
253
|
+
|
|
254
|
+
const refuse = why => { onError?.("frame", new Error(why)); sock.destroy(); };
|
|
255
|
+
|
|
256
|
+
sock.on("data", frameReader(msg => {
|
|
257
|
+
if (!authed) {
|
|
258
|
+
// FOUR MESSAGES, and the order is chosen so that a stranger who merely
|
|
259
|
+
// connects receives nothing derived from the group key.
|
|
260
|
+
//
|
|
261
|
+
// 1. caller -> hello, its fingerprint and a fresh challenge
|
|
262
|
+
// 2. us -> challenge, a random number and nothing else
|
|
263
|
+
// 3. caller -> auth, a proof over both challenges
|
|
264
|
+
// 4. us -> ok, our name, and our proof over both
|
|
265
|
+
//
|
|
266
|
+
// The tempting three-message version has us answer the hello with our
|
|
267
|
+
// proof already in it. That hands anybody who opens a socket a MAC over
|
|
268
|
+
// values they chose, forever, for free. Here they get a random number:
|
|
269
|
+
// to obtain any keyed material they have to produce a valid proof
|
|
270
|
+
// first, which is the thing they do not have.
|
|
271
|
+
if (msg.t === "hello") {
|
|
272
|
+
if (theirChallenge || typeof msg.fp !== "string" || typeof msg.challenge !== "string") {
|
|
273
|
+
return refuse("bad hello");
|
|
274
|
+
}
|
|
275
|
+
theirChallenge = msg.challenge;
|
|
276
|
+
peerFp = msg.fp;
|
|
277
|
+
// Our fingerprint travels with the challenge. It is already in
|
|
278
|
+
// every beacon we broadcast, so this tells an unauthenticated caller
|
|
279
|
+
// nothing new — and without it the caller cannot name us in its
|
|
280
|
+
// proof, which would leave the transcript covering only one of the
|
|
281
|
+
// two decks and one recorded proof valid at every deck in the group.
|
|
282
|
+
sendFrame(sock, { t: "challenge", fp, challenge: myChallenge });
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (msg.t !== "auth" || !theirChallenge) return refuse("expected auth");
|
|
286
|
+
const want = proof(key, {
|
|
287
|
+
challenge: theirChallenge, peerChallenge: myChallenge,
|
|
288
|
+
fromFp: peerFp, toFp: fp, direction: "hello",
|
|
289
|
+
});
|
|
290
|
+
// A recording of a previous exchange fails here, because `myChallenge`
|
|
291
|
+
// was made when this socket opened and has never been sent before.
|
|
292
|
+
if (!proofOk(want, msg.proof)) return refuse("bad proof");
|
|
293
|
+
authed = true;
|
|
294
|
+
clearTimeout(deadline);
|
|
295
|
+
// And ours, so the caller knows it reached a deck in its own group
|
|
296
|
+
// rather than something standing in the way of one.
|
|
297
|
+
sendFrame(sock, {
|
|
298
|
+
t: "ok", fp, name,
|
|
299
|
+
proof: proof(key, {
|
|
300
|
+
challenge: myChallenge, peerChallenge: theirChallenge,
|
|
301
|
+
fromFp: fp, toFp: peerFp, direction: "reply",
|
|
302
|
+
}),
|
|
303
|
+
});
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
handlers?.(msg, { sock, peerFp, send: obj => sendFrame(sock, obj) });
|
|
307
|
+
}, refuse));
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
start: () => new Promise((resolve, reject) => {
|
|
312
|
+
server = net.createServer(onConnection);
|
|
313
|
+
server.on("error", err => { onError?.("listen", err); reject(err); });
|
|
314
|
+
// Port 0: the OS picks, and the beacon carries whichever it picked. One
|
|
315
|
+
// fixed port in this feature is enough to collide with.
|
|
316
|
+
server.listen(0, host, () => resolve(server.address().port));
|
|
317
|
+
}),
|
|
318
|
+
port: () => server?.address()?.port ?? null,
|
|
319
|
+
stop() {
|
|
320
|
+
for (const s of live) s.destroy();
|
|
321
|
+
live.clear();
|
|
322
|
+
try { server?.close(); } catch { /* not listening */ }
|
|
323
|
+
server = null;
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The calling half: connect, prove, be proved to, then talk.
|
|
330
|
+
*
|
|
331
|
+
* BOTH SIDES PROVE, and the second half is the one that is easy to skip. A
|
|
332
|
+
* handshake where only the caller proves itself stops a stranger reading a
|
|
333
|
+
* manifest and stops nothing else — a stranger can still stand up a listener on
|
|
334
|
+
* the announced port, wait for a real deck to dial it, and be handed whatever
|
|
335
|
+
* that deck was going to say. So this checks the reply with the same care the
|
|
336
|
+
* server checks the hello, and gives up if it does not hold.
|
|
337
|
+
*/
|
|
338
|
+
export function connectToPeer({ host, port, fp, key, timeoutMs = HANDSHAKE_MS }) {
|
|
339
|
+
return new Promise((resolve, reject) => {
|
|
340
|
+
const myChallenge = randomBytes(16).toString("hex");
|
|
341
|
+
const sock = net.createConnection({ host, port });
|
|
342
|
+
sock.setEncoding("utf8");
|
|
343
|
+
let settled = false;
|
|
344
|
+
const fail = err => {
|
|
345
|
+
if (settled) return;
|
|
346
|
+
settled = true;
|
|
347
|
+
sock.destroy();
|
|
348
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
349
|
+
};
|
|
350
|
+
const timer = setTimeout(() => fail(new Error("handshake timed out")), timeoutMs);
|
|
351
|
+
timer.unref?.();
|
|
352
|
+
|
|
353
|
+
sock.on("error", fail);
|
|
354
|
+
sock.on("close", () => fail(new Error("peer closed the connection")));
|
|
355
|
+
sock.on("connect", () => {
|
|
356
|
+
// No proof in the hello: the caller cannot cover a challenge it has not
|
|
357
|
+
// been given, and a proof over an empty one would be a proof that means
|
|
358
|
+
// nothing. It goes in message three.
|
|
359
|
+
sendFrame(sock, { t: "hello", fp, challenge: myChallenge });
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
let theirChallenge = null;
|
|
363
|
+
let theirFp = null;
|
|
364
|
+
sock.on("data", frameReader(msg => {
|
|
365
|
+
if (settled) return;
|
|
366
|
+
if (msg.t === "challenge") {
|
|
367
|
+
if (theirFp || typeof msg.challenge !== "string" || typeof msg.fp !== "string") {
|
|
368
|
+
return fail(new Error("bad challenge"));
|
|
369
|
+
}
|
|
370
|
+
theirChallenge = msg.challenge;
|
|
371
|
+
theirFp = msg.fp;
|
|
372
|
+
sendFrame(sock, {
|
|
373
|
+
t: "auth",
|
|
374
|
+
proof: proof(key, {
|
|
375
|
+
challenge: myChallenge, peerChallenge: theirChallenge,
|
|
376
|
+
fromFp: fp, toFp: theirFp, direction: "hello",
|
|
377
|
+
}),
|
|
378
|
+
});
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
// The same deck that gave us the challenge, or nothing: a reply naming a
|
|
382
|
+
// different fingerprint is a second party in the middle of this.
|
|
383
|
+
if (msg.t !== "ok" || msg.fp !== theirFp) return fail(new Error("expected ok"));
|
|
384
|
+
const want = proof(key, {
|
|
385
|
+
challenge: theirChallenge, peerChallenge: myChallenge,
|
|
386
|
+
fromFp: theirFp, toFp: fp, direction: "reply",
|
|
387
|
+
});
|
|
388
|
+
if (!proofOk(want, msg.proof)) return fail(new Error("peer could not prove the group"));
|
|
389
|
+
settled = true;
|
|
390
|
+
clearTimeout(timer);
|
|
391
|
+
sock.removeAllListeners("close");
|
|
392
|
+
resolve({ sock, peerFp: msg.fp, peerName: msg.name, send: obj => sendFrame(sock, obj) });
|
|
393
|
+
}, fail));
|
|
394
|
+
});
|
|
395
|
+
}
|