agent-dag 3.15.0 → 3.17.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-CqAgutJT.css → index-BwFExw1D.css} +1 -1
- package/dist/web/assets/index-CSpWSOAU.js +260 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/src/server/deck-prefs.mjs +33 -14
- package/src/server/index.mjs +162 -26
- package/src/server/lan-engine.mjs +455 -67
- package/src/server/lan-reach.mjs +256 -0
- package/src/server/lan-socket.mjs +257 -63
- package/src/server/lan-sync.mjs +334 -86
- package/dist/web/assets/index-CbYzt04Y.js +0 -248
|
@@ -25,7 +25,8 @@ import dgram from "node:dgram";
|
|
|
25
25
|
import net from "node:net";
|
|
26
26
|
import { randomBytes } from "node:crypto";
|
|
27
27
|
import {
|
|
28
|
-
beaconPayload, beaconVerdict,
|
|
28
|
+
beaconPayload, beaconVerdict, handshakeTranscript, notePeer, proof, proofOk,
|
|
29
|
+
inviteProof, readBeacon, readPub, sessionKey, trustedPeer,
|
|
29
30
|
ANNOUNCE_MS, MAX_BEACON_BYTES, MAX_MANIFEST_BYTES,
|
|
30
31
|
} from "./lan-sync.mjs";
|
|
31
32
|
|
|
@@ -73,7 +74,10 @@ export const REPLY_COOLDOWN_MS = 2_000;
|
|
|
73
74
|
* panel to be able to say what happened.
|
|
74
75
|
*/
|
|
75
76
|
export function createBeacon({
|
|
76
|
-
port, name, fp,
|
|
77
|
+
port, name, fp, onPeer, onStranger, onError, onIdClash, now = Date.now,
|
|
78
|
+
/** The decks somebody has accepted, read fresh each packet so an accept takes
|
|
79
|
+
* effect immediately rather than at the next restart. */
|
|
80
|
+
trusted = () => [],
|
|
77
81
|
// Injected so the suite can drive this with a socket it controls. CI runners
|
|
78
82
|
// are not a network: GitHub's have no broadcast domain worth the name, and a
|
|
79
83
|
// test that quietly skipped there would be a test that stopped testing
|
|
@@ -81,7 +85,6 @@ export function createBeacon({
|
|
|
81
85
|
// machine, which works because a broadcast comes back to its own host.
|
|
82
86
|
createSocket = opts => dgram.createSocket(opts),
|
|
83
87
|
} = {}) {
|
|
84
|
-
const group = key ? groupTag(key) : null;
|
|
85
88
|
// Randomised per process. Two beacons from one fingerprint with different
|
|
86
89
|
// instance ids mean the deck restarted between them, which is the signal to
|
|
87
90
|
// drop any session held for it rather than resume into a process that is gone.
|
|
@@ -89,14 +92,17 @@ export function createBeacon({
|
|
|
89
92
|
const peers = new Map();
|
|
90
93
|
let sock = null;
|
|
91
94
|
let timer = null;
|
|
92
|
-
/** When this deck last answered a
|
|
93
|
-
* storm
|
|
95
|
+
/** When this deck last answered a deck it had not heard, so answering cannot
|
|
96
|
+
* become a storm, and which decks it has already answered — without the
|
|
97
|
+
* second, a deck that is never accepted is answered again on every packet
|
|
98
|
+
* for as long as both are running. */
|
|
94
99
|
let repliedAt = 0;
|
|
100
|
+
const answered = new Set();
|
|
95
101
|
|
|
96
|
-
const payload = () => Buffer.from(JSON.stringify(beaconPayload({ name, fp, port,
|
|
102
|
+
const payload = () => Buffer.from(JSON.stringify(beaconPayload({ name, fp, port, instance })));
|
|
97
103
|
|
|
98
104
|
const announce = () => {
|
|
99
|
-
if (!sock
|
|
105
|
+
if (!sock) return;
|
|
100
106
|
// 255.255.255.255 rather than a multicast group, and rather than the
|
|
101
107
|
// subnet's own broadcast address. The subnet-directed form needs the
|
|
102
108
|
// netmask of whichever interface the packet leaves by, which changes when a
|
|
@@ -117,31 +123,43 @@ export function createBeacon({
|
|
|
117
123
|
// the bytes and the address and does what it is told.
|
|
118
124
|
if (msg.length > MAX_BEACON_BYTES) return;
|
|
119
125
|
const beacon = readBeacon(msg);
|
|
120
|
-
const verdict = beaconVerdict(beacon, { selfFp: fp,
|
|
126
|
+
const verdict = beaconVerdict(beacon, { selfFp: fp, selfInstance: instance, trusted: trusted() });
|
|
127
|
+
// ANSWER A DECK WE HAVE NEVER HEARD, once, WHOEVER IT IS — and that last
|
|
128
|
+
// part is the change. It used to answer only a deck already in the group,
|
|
129
|
+
// which was fine when a group existed. Now the first thing a new deck has
|
|
130
|
+
// to become is a row on somebody's screen, and it cannot become one if
|
|
131
|
+
// this deck never tells it that it exists.
|
|
132
|
+
//
|
|
133
|
+
// Measured on two real decks before any of this: deck 1 saw deck 2 the
|
|
134
|
+
// instant it started and deck 2 saw nobody, because deck 1's own
|
|
135
|
+
// immediate announce went out before deck 2 was listening. Thirty seconds
|
|
136
|
+
// of an empty list is how a working feature reads as broken.
|
|
137
|
+
//
|
|
138
|
+
// At most once every few seconds, because the obvious version is a shout
|
|
139
|
+
// storm: two decks answering each other's answers forever. A new pair
|
|
140
|
+
// converges in two extra packets.
|
|
141
|
+
const newToUs = beacon && verdict !== "self" && verdict !== "id-clash" && verdict !== "unreadable"
|
|
142
|
+
&& !peers.has(beacon.fp) && !answered.has(beacon.fp);
|
|
143
|
+
if (newToUs && now() - repliedAt > REPLY_COOLDOWN_MS) {
|
|
144
|
+
repliedAt = now();
|
|
145
|
+
answered.add(beacon.fp);
|
|
146
|
+
announce();
|
|
147
|
+
}
|
|
121
148
|
if (verdict !== "peer") {
|
|
122
|
-
|
|
123
|
-
// Another deck is using this one's id — see beaconVerdict. Reported
|
|
149
|
+
// Another deck is using this one's key — see beaconVerdict. Reported
|
|
124
150
|
// rather than fixed here: this file carries packets, and choosing a new
|
|
125
|
-
//
|
|
151
|
+
// identity for the deck belongs to whoever stores it.
|
|
126
152
|
if (verdict === "id-clash") onIdClash?.();
|
|
153
|
+
// A DECK NOBODY HAS ACCEPTED. It is not refused and not silently
|
|
154
|
+
// dropped: it is a name and an address on the same network, which is a
|
|
155
|
+
// row somebody can accept. Nothing is asked of it and nothing is
|
|
156
|
+
// offered to it until they do.
|
|
157
|
+
if (verdict === "stranger") {
|
|
158
|
+
onStranger?.({ fp: beacon.fp, name: beacon.name, addr: rinfo.address, port: beacon.port, at: now() });
|
|
159
|
+
}
|
|
127
160
|
return;
|
|
128
161
|
}
|
|
129
|
-
const known = peers.has(beacon.fp);
|
|
130
162
|
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
163
|
if (noted.changed || noted.restarted) onPeer?.(noted);
|
|
146
164
|
});
|
|
147
165
|
sock.bind(DISCOVERY_PORT, "0.0.0.0", () => {
|
|
@@ -226,18 +244,43 @@ export function sendFrame(sock, obj) {
|
|
|
226
244
|
* `handlers` is called only with authenticated frames, and it never sees the
|
|
227
245
|
* handshake at all.
|
|
228
246
|
*/
|
|
229
|
-
export function createSyncServer({
|
|
247
|
+
export function createSyncServer({
|
|
248
|
+
fp, pub, name, secret, handlers, onError, host = "0.0.0.0", prefer = 0,
|
|
249
|
+
/** The peers somebody has accepted, read fresh on every connection so an
|
|
250
|
+
* accept takes effect on the next one rather than on the next restart. */
|
|
251
|
+
trusted = () => [],
|
|
252
|
+
/** A deck we have never been told to trust, which finished the handshake and
|
|
253
|
+
* is therefore a real deck rather than a port scan. The panel turns this
|
|
254
|
+
* into a row with an accept on it. */
|
|
255
|
+
onPending,
|
|
256
|
+
/** Was this deck already told no? A refusal has to be sent rather than
|
|
257
|
+
* merely held, or the far end reads "declined" and "not answered yet" as one
|
|
258
|
+
* silent wait — they are the same frame otherwise, and only one of them ever
|
|
259
|
+
* comes right by waiting. */
|
|
260
|
+
declined = () => false,
|
|
261
|
+
/** The invite this deck is currently offering, or null. A caller that proves
|
|
262
|
+
* it holds the code is somebody the owner handed a token to, so it is paired
|
|
263
|
+
* on arrival rather than queued behind a press. */
|
|
264
|
+
invite = () => null,
|
|
265
|
+
/** One was used. The caller stores the pairing and retires the invite: a
|
|
266
|
+
* token that pairs twice is a token worth stealing twice. */
|
|
267
|
+
onInviteUsed,
|
|
268
|
+
} = {}) {
|
|
230
269
|
let server = null;
|
|
231
270
|
const live = new Set();
|
|
232
271
|
|
|
233
272
|
const onConnection = sock => {
|
|
234
|
-
if (!
|
|
273
|
+
if (!secret || live.size >= MAX_SOCKETS) { sock.destroy(); return; }
|
|
235
274
|
live.add(sock);
|
|
236
275
|
sock.setEncoding("utf8");
|
|
237
276
|
sock.setNoDelay(true);
|
|
238
277
|
|
|
239
278
|
let authed = false;
|
|
240
279
|
let peerFp = null;
|
|
280
|
+
let peerPub = null;
|
|
281
|
+
let peerName = "";
|
|
282
|
+
let peerPort = null;
|
|
283
|
+
let key = null;
|
|
241
284
|
const myChallenge = randomBytes(16).toString("hex");
|
|
242
285
|
let theirChallenge = null;
|
|
243
286
|
|
|
@@ -251,35 +294,63 @@ export function createSyncServer({ fp, name, key, handlers, onError, host = "0.0
|
|
|
251
294
|
sock.on("close", done);
|
|
252
295
|
sock.on("error", err => { done(); onError?.("peer", err); });
|
|
253
296
|
|
|
254
|
-
|
|
297
|
+
/**
|
|
298
|
+
* Refuse, and SAY SO, which cost two people twenty minutes.
|
|
299
|
+
*
|
|
300
|
+
* This used to destroy the socket without a word, so the caller's only
|
|
301
|
+
* evidence was `peer closed the connection` — true, and useless. Every
|
|
302
|
+
* reason here is a different problem with a different fix, and the caller
|
|
303
|
+
* cannot tell them apart from the outside.
|
|
304
|
+
*
|
|
305
|
+
* It leaks nothing an attacker did not have. "I do not know you" is what
|
|
306
|
+
* the silent close already said, and the fingerprints involved are in every
|
|
307
|
+
* beacon this deck broadcasts.
|
|
308
|
+
*
|
|
309
|
+
* DESTROY, NOT END, and the difference is a caller that never reads. `end`
|
|
310
|
+
* is a FIN, and a peer whose socket is paused — connected, refusing to
|
|
311
|
+
* read, which is exactly the shape of a caller trying to cost something —
|
|
312
|
+
* never notices a FIN and holds the socket open. The callback orders the
|
|
313
|
+
* two: destroying before the write flushes would throw away the sentence
|
|
314
|
+
* that is the whole point. The timer is the backstop for a peer whose
|
|
315
|
+
* receive window is full and whose callback therefore never comes.
|
|
316
|
+
*/
|
|
317
|
+
const refuse = why => {
|
|
318
|
+
onError?.("frame", new Error(why));
|
|
319
|
+
const bye = () => { try { sock.destroy(); } catch { /* already gone */ } };
|
|
320
|
+
try { sock.write(`${JSON.stringify({ t: "no", why })}\n`, bye); }
|
|
321
|
+
catch { bye(); return; }
|
|
322
|
+
setTimeout(bye, 250).unref?.();
|
|
323
|
+
};
|
|
255
324
|
|
|
256
325
|
sock.on("data", frameReader(msg => {
|
|
257
326
|
if (!authed) {
|
|
258
327
|
// FOUR MESSAGES, and the order is chosen so that a stranger who merely
|
|
259
|
-
// connects receives nothing derived from
|
|
328
|
+
// connects receives nothing derived from a key.
|
|
260
329
|
//
|
|
261
|
-
// 1. caller -> hello, its fingerprint
|
|
262
|
-
// 2. us -> challenge, a random number
|
|
263
|
-
// 3. caller -> auth, a proof over
|
|
264
|
-
// 4. us -> ok, our name, and our proof over
|
|
330
|
+
// 1. caller -> hello, its fingerprint, its public key, a challenge
|
|
331
|
+
// 2. us -> challenge, ours, and a random number
|
|
332
|
+
// 3. caller -> auth, a proof over the whole transcript
|
|
333
|
+
// 4. us -> ok, our name, and our proof over the same
|
|
265
334
|
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
335
|
+
// BOTH PUBLIC KEYS TRAVEL IN THE CLEAR and that is fine: a public key
|
|
336
|
+
// is public, and the fingerprint in the beacon is a hash of this exact
|
|
337
|
+
// value. What the exchange establishes is that whoever is on the other
|
|
338
|
+
// end holds the private half of the key they claimed — which is the
|
|
339
|
+
// only thing a pin can later be checked against.
|
|
271
340
|
if (msg.t === "hello") {
|
|
272
|
-
if (theirChallenge || typeof msg.
|
|
273
|
-
|
|
274
|
-
|
|
341
|
+
if (theirChallenge || typeof msg.challenge !== "string") return refuse("bad hello");
|
|
342
|
+
const them = readPub(msg.pub);
|
|
343
|
+
// The fingerprint is a hash of the key, so a hello whose two halves
|
|
344
|
+
// disagree is not a deck with a stale field, it is somebody trying to
|
|
345
|
+
// be announced as one deck and prove they are another.
|
|
346
|
+
if (!them || them.fp !== msg.fp) return refuse("bad hello");
|
|
275
347
|
theirChallenge = msg.challenge;
|
|
276
|
-
peerFp =
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
sendFrame(sock, { t: "challenge", fp, challenge: myChallenge });
|
|
348
|
+
peerFp = them.fp;
|
|
349
|
+
peerPub = them.pub;
|
|
350
|
+
peerName = typeof msg.name === "string" ? msg.name : "";
|
|
351
|
+
peerPort = Number.isInteger(msg.port) && msg.port > 0 && msg.port < 65_536 ? msg.port : null;
|
|
352
|
+
key = sessionKey(secret, peerPub, handshakeTranscript(peerFp, fp, theirChallenge, myChallenge));
|
|
353
|
+
sendFrame(sock, { t: "challenge", fp, pub, name, challenge: myChallenge });
|
|
283
354
|
return;
|
|
284
355
|
}
|
|
285
356
|
if (msg.t !== "auth" || !theirChallenge) return refuse("expected auth");
|
|
@@ -290,10 +361,68 @@ export function createSyncServer({ fp, name, key, handlers, onError, host = "0.0
|
|
|
290
361
|
// A recording of a previous exchange fails here, because `myChallenge`
|
|
291
362
|
// was made when this socket opened and has never been sent before.
|
|
292
363
|
if (!proofOk(want, msg.proof)) return refuse("bad proof");
|
|
364
|
+
|
|
365
|
+
// WHO IS THIS, and it is the only question left. The handshake proves
|
|
366
|
+
// they hold the key they claimed; the trusted list says whether anybody
|
|
367
|
+
// here ever agreed to talk to it.
|
|
368
|
+
const known = trustedPeer(trusted(), peerFp);
|
|
369
|
+
if (known && known.pub !== peerPub) {
|
|
370
|
+
// The fingerprint we pinned, presented with a different key. 48 bits
|
|
371
|
+
// is far past accident, so this is somebody wearing a paired deck's
|
|
372
|
+
// name — refused loudly rather than quietly re-pinned.
|
|
373
|
+
return refuse("impostor");
|
|
374
|
+
}
|
|
375
|
+
if (!known) {
|
|
376
|
+
// AN INVITE THIS DECK HANDED OUT, PRESENTED BACK. Whoever is calling
|
|
377
|
+
// holds a token the owner of this machine copied and sent, which is
|
|
378
|
+
// the same decision the accept button is — made earlier, and made
|
|
379
|
+
// once. So there is nothing to press: the deck is pinned here.
|
|
380
|
+
//
|
|
381
|
+
// The proof is over the transcript, so it is worth nothing to
|
|
382
|
+
// somebody who recorded an earlier exchange, and the code itself
|
|
383
|
+
// never travels.
|
|
384
|
+
const live = invite();
|
|
385
|
+
if (live && typeof msg.invite === "string") {
|
|
386
|
+
const want = inviteProof(live.code, handshakeTranscript(peerFp, fp, theirChallenge, myChallenge));
|
|
387
|
+
if (proofOk(want, msg.invite)) {
|
|
388
|
+
onInviteUsed?.({ fp: peerFp, pub: peerPub, name: peerName, port: peerPort,
|
|
389
|
+
addr: sock.remoteAddress?.replace(/^::ffff:/, "") ?? "" });
|
|
390
|
+
authed = true;
|
|
391
|
+
clearTimeout(deadline);
|
|
392
|
+
sendFrame(sock, {
|
|
393
|
+
t: "ok", fp, name,
|
|
394
|
+
proof: proof(key, {
|
|
395
|
+
challenge: myChallenge, peerChallenge: theirChallenge,
|
|
396
|
+
fromFp: fp, toFp: peerFp, direction: "reply",
|
|
397
|
+
}),
|
|
398
|
+
});
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// A DECK THIS ONE'S OWNER ALREADY ANSWERED, and the answer was no.
|
|
403
|
+
// It is not asked again here, and — the half a held refusal cannot
|
|
404
|
+
// do — the deck that asked is told, so its own panel can stop saying
|
|
405
|
+
// "waiting" about a question that has been answered.
|
|
406
|
+
if (declined(peerFp)) return refuse("declined");
|
|
407
|
+
|
|
408
|
+
// A REAL DECK WE HAVE NOT MET. It finished a handshake, so it is not
|
|
409
|
+
// a port scan, and it told us a name and an address a person can
|
|
410
|
+
// recognise. That is a row with an accept on it, and nothing else
|
|
411
|
+
// happens until somebody presses it.
|
|
412
|
+
onPending?.({
|
|
413
|
+
fp: peerFp, pub: peerPub, name: peerName,
|
|
414
|
+
addr: sock.remoteAddress?.replace(/^::ffff:/, "") ?? "",
|
|
415
|
+
// Where it LISTENS, from the hello — not this socket's remote port,
|
|
416
|
+
// which is ephemeral. This is what lets an accept dial back.
|
|
417
|
+
port: peerPort,
|
|
418
|
+
});
|
|
419
|
+
return refuse("pending");
|
|
420
|
+
}
|
|
421
|
+
|
|
293
422
|
authed = true;
|
|
294
423
|
clearTimeout(deadline);
|
|
295
|
-
// And ours, so the caller knows it reached
|
|
296
|
-
//
|
|
424
|
+
// And ours, so the caller knows it reached the deck it pinned rather
|
|
425
|
+
// than something standing in the way of one.
|
|
297
426
|
sendFrame(sock, {
|
|
298
427
|
t: "ok", fp, name,
|
|
299
428
|
proof: proof(key, {
|
|
@@ -303,17 +432,44 @@ export function createSyncServer({ fp, name, key, handlers, onError, host = "0.0
|
|
|
303
432
|
});
|
|
304
433
|
return;
|
|
305
434
|
}
|
|
306
|
-
handlers?.(msg, { sock, peerFp, send: obj => sendFrame(sock, obj) });
|
|
435
|
+
handlers?.(msg, { sock, peerFp, key, send: obj => sendFrame(sock, obj) });
|
|
307
436
|
}, refuse));
|
|
308
437
|
};
|
|
309
438
|
|
|
310
439
|
return {
|
|
440
|
+
/**
|
|
441
|
+
* Listen, on the same port as last time when that is still possible.
|
|
442
|
+
*
|
|
443
|
+
* IT ASKED FOR PORT 0 EVERY TIME, and the reasoning was sound in isolation:
|
|
444
|
+
* the beacon carries whichever port the OS picked, so nothing needs a fixed
|
|
445
|
+
* one and a fixed one is a thing to collide with. But the beacon is exactly
|
|
446
|
+
* what does not arrive when this feature is hardest to set up — a router
|
|
447
|
+
* or a firewall in the way is the whole reason the panel has an address
|
|
448
|
+
* field — and then the address somebody typed on the other machine stopped
|
|
449
|
+
* working the next time this deck restarted, with `handshake timed out` and
|
|
450
|
+
* nothing to say the port had simply moved.
|
|
451
|
+
*
|
|
452
|
+
* So the caller keeps one and hands it back, and a port already taken falls
|
|
453
|
+
* straight through to 0 rather than refusing to start. The pin is a
|
|
454
|
+
* preference, never a requirement.
|
|
455
|
+
*/
|
|
311
456
|
start: () => new Promise((resolve, reject) => {
|
|
457
|
+
const wanted = Number.isInteger(prefer) && prefer > 0 && prefer < 65_536 ? prefer : 0;
|
|
458
|
+
let retried = wanted === 0;
|
|
312
459
|
server = net.createServer(onConnection);
|
|
313
|
-
server.on("error", err => {
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
460
|
+
server.on("error", err => {
|
|
461
|
+
if (!retried) {
|
|
462
|
+
// Somebody else has it — another deck on this machine, or something
|
|
463
|
+
// unrelated. The pin is not worth failing to start over.
|
|
464
|
+
retried = true;
|
|
465
|
+
onError?.("listen", err);
|
|
466
|
+
try { server.listen(0, host, () => resolve(server.address().port)); } catch { reject(err); }
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
onError?.("listen", err);
|
|
470
|
+
reject(err);
|
|
471
|
+
});
|
|
472
|
+
server.listen(wanted, host, () => resolve(server.address().port));
|
|
317
473
|
}),
|
|
318
474
|
port: () => server?.address()?.port ?? null,
|
|
319
475
|
stop() {
|
|
@@ -335,7 +491,12 @@ export function createSyncServer({ fp, name, key, handlers, onError, host = "0.0
|
|
|
335
491
|
* that deck was going to say. So this checks the reply with the same care the
|
|
336
492
|
* server checks the hello, and gives up if it does not hold.
|
|
337
493
|
*/
|
|
338
|
-
export function connectToPeer({
|
|
494
|
+
export function connectToPeer({
|
|
495
|
+
host, port, fp, pub, secret, name, myPort = null, code = null, timeoutMs = HANDSHAKE_MS,
|
|
496
|
+
/** The public key we pinned for this deck the first time, or null for a deck
|
|
497
|
+
* we are meeting — an address somebody typed. */
|
|
498
|
+
expectPub = null,
|
|
499
|
+
}) {
|
|
339
500
|
return new Promise((resolve, reject) => {
|
|
340
501
|
const myChallenge = randomBytes(16).toString("hex");
|
|
341
502
|
const sock = net.createConnection({ host, port });
|
|
@@ -356,25 +517,54 @@ export function connectToPeer({ host, port, fp, key, timeoutMs = HANDSHAKE_MS })
|
|
|
356
517
|
// No proof in the hello: the caller cannot cover a challenge it has not
|
|
357
518
|
// been given, and a proof over an empty one would be a proof that means
|
|
358
519
|
// nothing. It goes in message three.
|
|
359
|
-
|
|
520
|
+
// `port` is where WE listen, which is not the port this socket came from
|
|
521
|
+
// — that one is ephemeral and useless to dial. Without it a deck can
|
|
522
|
+
// accept an incoming request and still have no way to reach back, so the
|
|
523
|
+
// pairing is mutual on paper and one-way in fact.
|
|
524
|
+
sendFrame(sock, { t: "hello", fp, pub, name, port: myPort, challenge: myChallenge });
|
|
360
525
|
});
|
|
361
526
|
|
|
362
527
|
let theirChallenge = null;
|
|
363
528
|
let theirFp = null;
|
|
529
|
+
let theirPub = null;
|
|
530
|
+
let key = null;
|
|
364
531
|
sock.on("data", frameReader(msg => {
|
|
365
532
|
if (settled) return;
|
|
533
|
+
// A deck that heard us and said no. Each reason is a different problem
|
|
534
|
+
// with a different fix, and until this frame existed they were all one
|
|
535
|
+
// silent close that read as a firewall.
|
|
536
|
+
if (msg.t === "no") {
|
|
537
|
+
return fail(new Error({
|
|
538
|
+
pending: "waiting for the other deck to accept this one",
|
|
539
|
+
declined: "that deck said no",
|
|
540
|
+
impostor: "that deck has this one pinned under a different key",
|
|
541
|
+
"bad proof": "the other deck refused this one's proof",
|
|
542
|
+
}[msg.why] ?? "the other deck refused this handshake"));
|
|
543
|
+
}
|
|
366
544
|
if (msg.t === "challenge") {
|
|
367
|
-
if (theirFp || typeof msg.challenge !== "string"
|
|
368
|
-
|
|
545
|
+
if (theirFp || typeof msg.challenge !== "string") return fail(new Error("bad challenge"));
|
|
546
|
+
const them = readPub(msg.pub);
|
|
547
|
+
if (!them || them.fp !== msg.fp) return fail(new Error("bad challenge"));
|
|
548
|
+
// THE PIN, CHECKED BEFORE ANYTHING ELSE. A deck we have paired with is
|
|
549
|
+
// this key and no other; a key that does not match is not a peer whose
|
|
550
|
+
// details changed, it is a different machine at the same address.
|
|
551
|
+
if (expectPub && expectPub !== them.pub) {
|
|
552
|
+
return fail(new Error("a different deck is answering at that address"));
|
|
369
553
|
}
|
|
370
554
|
theirChallenge = msg.challenge;
|
|
371
|
-
theirFp =
|
|
555
|
+
theirFp = them.fp;
|
|
556
|
+
theirPub = them.pub;
|
|
557
|
+
key = sessionKey(secret, theirPub, handshakeTranscript(fp, theirFp, myChallenge, theirChallenge));
|
|
372
558
|
sendFrame(sock, {
|
|
373
559
|
t: "auth",
|
|
374
560
|
proof: proof(key, {
|
|
375
561
|
challenge: myChallenge, peerChallenge: theirChallenge,
|
|
376
562
|
fromFp: fp, toFp: theirFp, direction: "hello",
|
|
377
563
|
}),
|
|
564
|
+
// Only when joining on an invite. Sent in the same frame as the
|
|
565
|
+
// session proof so a deck that holds a token is paired in one round
|
|
566
|
+
// trip rather than being queued behind somebody else's press.
|
|
567
|
+
...(code ? { invite: inviteProof(code, handshakeTranscript(fp, theirFp, myChallenge, theirChallenge)) } : {}),
|
|
378
568
|
});
|
|
379
569
|
return;
|
|
380
570
|
}
|
|
@@ -385,11 +575,15 @@ export function connectToPeer({ host, port, fp, key, timeoutMs = HANDSHAKE_MS })
|
|
|
385
575
|
challenge: theirChallenge, peerChallenge: myChallenge,
|
|
386
576
|
fromFp: theirFp, toFp: fp, direction: "reply",
|
|
387
577
|
});
|
|
388
|
-
if (!proofOk(want, msg.proof)) return fail(new Error("
|
|
578
|
+
if (!proofOk(want, msg.proof)) return fail(new Error("that deck could not prove its own key"));
|
|
389
579
|
settled = true;
|
|
390
580
|
clearTimeout(timer);
|
|
391
581
|
sock.removeAllListeners("close");
|
|
392
|
-
resolve({
|
|
582
|
+
resolve({
|
|
583
|
+
sock, key,
|
|
584
|
+
peerFp: theirFp, peerPub: theirPub, peerName: msg.name,
|
|
585
|
+
send: obj => sendFrame(sock, obj),
|
|
586
|
+
});
|
|
393
587
|
}, fail));
|
|
394
588
|
});
|
|
395
589
|
}
|