agent-dag 3.15.0 → 3.16.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.
@@ -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, groupTag, notePeer, proof, proofOk, readBeacon,
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, key, onPeer, onError, onIdClash, now = Date.now,
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 stranger, so answering cannot become a
93
- * storm. See the reply in the message handler. */
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, group, instance })));
102
+ const payload = () => Buffer.from(JSON.stringify(beaconPayload({ name, fp, port, instance })));
97
103
 
98
104
  const announce = () => {
99
- if (!sock || !group) return;
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, selfGroup: group, selfInstance: instance });
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
- if (verdict === "other-group") onError?.("other-group", null);
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
- // name for the deck belongs to whoever stores it.
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,38 @@ 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({ fp, name, key, handlers, onError, host = "0.0.0.0" } = {}) {
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
+ /** The invite this deck is currently offering, or null. A caller that proves
257
+ * it holds the code is somebody the owner handed a token to, so it is paired
258
+ * on arrival rather than queued behind a press. */
259
+ invite = () => null,
260
+ /** One was used. The caller stores the pairing and retires the invite: a
261
+ * token that pairs twice is a token worth stealing twice. */
262
+ onInviteUsed,
263
+ } = {}) {
230
264
  let server = null;
231
265
  const live = new Set();
232
266
 
233
267
  const onConnection = sock => {
234
- if (!key || live.size >= MAX_SOCKETS) { sock.destroy(); return; }
268
+ if (!secret || live.size >= MAX_SOCKETS) { sock.destroy(); return; }
235
269
  live.add(sock);
236
270
  sock.setEncoding("utf8");
237
271
  sock.setNoDelay(true);
238
272
 
239
273
  let authed = false;
240
274
  let peerFp = null;
275
+ let peerPub = null;
276
+ let peerName = "";
277
+ let peerPort = null;
278
+ let key = null;
241
279
  const myChallenge = randomBytes(16).toString("hex");
242
280
  let theirChallenge = null;
243
281
 
@@ -251,35 +289,63 @@ export function createSyncServer({ fp, name, key, handlers, onError, host = "0.0
251
289
  sock.on("close", done);
252
290
  sock.on("error", err => { done(); onError?.("peer", err); });
253
291
 
254
- const refuse = why => { onError?.("frame", new Error(why)); sock.destroy(); };
292
+ /**
293
+ * Refuse, and SAY SO, which cost two people twenty minutes.
294
+ *
295
+ * This used to destroy the socket without a word, so the caller's only
296
+ * evidence was `peer closed the connection` — true, and useless. Every
297
+ * reason here is a different problem with a different fix, and the caller
298
+ * cannot tell them apart from the outside.
299
+ *
300
+ * It leaks nothing an attacker did not have. "I do not know you" is what
301
+ * the silent close already said, and the fingerprints involved are in every
302
+ * beacon this deck broadcasts.
303
+ *
304
+ * DESTROY, NOT END, and the difference is a caller that never reads. `end`
305
+ * is a FIN, and a peer whose socket is paused — connected, refusing to
306
+ * read, which is exactly the shape of a caller trying to cost something —
307
+ * never notices a FIN and holds the socket open. The callback orders the
308
+ * two: destroying before the write flushes would throw away the sentence
309
+ * that is the whole point. The timer is the backstop for a peer whose
310
+ * receive window is full and whose callback therefore never comes.
311
+ */
312
+ const refuse = why => {
313
+ onError?.("frame", new Error(why));
314
+ const bye = () => { try { sock.destroy(); } catch { /* already gone */ } };
315
+ try { sock.write(`${JSON.stringify({ t: "no", why })}\n`, bye); }
316
+ catch { bye(); return; }
317
+ setTimeout(bye, 250).unref?.();
318
+ };
255
319
 
256
320
  sock.on("data", frameReader(msg => {
257
321
  if (!authed) {
258
322
  // FOUR MESSAGES, and the order is chosen so that a stranger who merely
259
- // connects receives nothing derived from the group key.
323
+ // connects receives nothing derived from a key.
260
324
  //
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
325
+ // 1. caller -> hello, its fingerprint, its public key, a challenge
326
+ // 2. us -> challenge, ours, and a random number
327
+ // 3. caller -> auth, a proof over the whole transcript
328
+ // 4. us -> ok, our name, and our proof over the same
265
329
  //
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.
330
+ // BOTH PUBLIC KEYS TRAVEL IN THE CLEAR and that is fine: a public key
331
+ // is public, and the fingerprint in the beacon is a hash of this exact
332
+ // value. What the exchange establishes is that whoever is on the other
333
+ // end holds the private half of the key they claimed — which is the
334
+ // only thing a pin can later be checked against.
271
335
  if (msg.t === "hello") {
272
- if (theirChallenge || typeof msg.fp !== "string" || typeof msg.challenge !== "string") {
273
- return refuse("bad hello");
274
- }
336
+ if (theirChallenge || typeof msg.challenge !== "string") return refuse("bad hello");
337
+ const them = readPub(msg.pub);
338
+ // The fingerprint is a hash of the key, so a hello whose two halves
339
+ // disagree is not a deck with a stale field, it is somebody trying to
340
+ // be announced as one deck and prove they are another.
341
+ if (!them || them.fp !== msg.fp) return refuse("bad hello");
275
342
  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 });
343
+ peerFp = them.fp;
344
+ peerPub = them.pub;
345
+ peerName = typeof msg.name === "string" ? msg.name : "";
346
+ peerPort = Number.isInteger(msg.port) && msg.port > 0 && msg.port < 65_536 ? msg.port : null;
347
+ key = sessionKey(secret, peerPub, handshakeTranscript(peerFp, fp, theirChallenge, myChallenge));
348
+ sendFrame(sock, { t: "challenge", fp, pub, name, challenge: myChallenge });
283
349
  return;
284
350
  }
285
351
  if (msg.t !== "auth" || !theirChallenge) return refuse("expected auth");
@@ -290,10 +356,62 @@ export function createSyncServer({ fp, name, key, handlers, onError, host = "0.0
290
356
  // A recording of a previous exchange fails here, because `myChallenge`
291
357
  // was made when this socket opened and has never been sent before.
292
358
  if (!proofOk(want, msg.proof)) return refuse("bad proof");
359
+
360
+ // WHO IS THIS, and it is the only question left. The handshake proves
361
+ // they hold the key they claimed; the trusted list says whether anybody
362
+ // here ever agreed to talk to it.
363
+ const known = trustedPeer(trusted(), peerFp);
364
+ if (known && known.pub !== peerPub) {
365
+ // The fingerprint we pinned, presented with a different key. 48 bits
366
+ // is far past accident, so this is somebody wearing a paired deck's
367
+ // name — refused loudly rather than quietly re-pinned.
368
+ return refuse("impostor");
369
+ }
370
+ if (!known) {
371
+ // AN INVITE THIS DECK HANDED OUT, PRESENTED BACK. Whoever is calling
372
+ // holds a token the owner of this machine copied and sent, which is
373
+ // the same decision the accept button is — made earlier, and made
374
+ // once. So there is nothing to press: the deck is pinned here.
375
+ //
376
+ // The proof is over the transcript, so it is worth nothing to
377
+ // somebody who recorded an earlier exchange, and the code itself
378
+ // never travels.
379
+ const live = invite();
380
+ if (live && typeof msg.invite === "string") {
381
+ const want = inviteProof(live.code, handshakeTranscript(peerFp, fp, theirChallenge, myChallenge));
382
+ if (proofOk(want, msg.invite)) {
383
+ onInviteUsed?.({ fp: peerFp, pub: peerPub, name: peerName, port: peerPort,
384
+ addr: sock.remoteAddress?.replace(/^::ffff:/, "") ?? "" });
385
+ authed = true;
386
+ clearTimeout(deadline);
387
+ sendFrame(sock, {
388
+ t: "ok", fp, name,
389
+ proof: proof(key, {
390
+ challenge: myChallenge, peerChallenge: theirChallenge,
391
+ fromFp: fp, toFp: peerFp, direction: "reply",
392
+ }),
393
+ });
394
+ return;
395
+ }
396
+ }
397
+ // A REAL DECK WE HAVE NOT MET. It finished a handshake, so it is not
398
+ // a port scan, and it told us a name and an address a person can
399
+ // recognise. That is a row with an accept on it, and nothing else
400
+ // happens until somebody presses it.
401
+ onPending?.({
402
+ fp: peerFp, pub: peerPub, name: peerName,
403
+ addr: sock.remoteAddress?.replace(/^::ffff:/, "") ?? "",
404
+ // Where it LISTENS, from the hello — not this socket's remote port,
405
+ // which is ephemeral. This is what lets an accept dial back.
406
+ port: peerPort,
407
+ });
408
+ return refuse("pending");
409
+ }
410
+
293
411
  authed = true;
294
412
  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.
413
+ // And ours, so the caller knows it reached the deck it pinned rather
414
+ // than something standing in the way of one.
297
415
  sendFrame(sock, {
298
416
  t: "ok", fp, name,
299
417
  proof: proof(key, {
@@ -303,17 +421,44 @@ export function createSyncServer({ fp, name, key, handlers, onError, host = "0.0
303
421
  });
304
422
  return;
305
423
  }
306
- handlers?.(msg, { sock, peerFp, send: obj => sendFrame(sock, obj) });
424
+ handlers?.(msg, { sock, peerFp, key, send: obj => sendFrame(sock, obj) });
307
425
  }, refuse));
308
426
  };
309
427
 
310
428
  return {
429
+ /**
430
+ * Listen, on the same port as last time when that is still possible.
431
+ *
432
+ * IT ASKED FOR PORT 0 EVERY TIME, and the reasoning was sound in isolation:
433
+ * the beacon carries whichever port the OS picked, so nothing needs a fixed
434
+ * one and a fixed one is a thing to collide with. But the beacon is exactly
435
+ * what does not arrive when this feature is hardest to set up — a router
436
+ * or a firewall in the way is the whole reason the panel has an address
437
+ * field — and then the address somebody typed on the other machine stopped
438
+ * working the next time this deck restarted, with `handshake timed out` and
439
+ * nothing to say the port had simply moved.
440
+ *
441
+ * So the caller keeps one and hands it back, and a port already taken falls
442
+ * straight through to 0 rather than refusing to start. The pin is a
443
+ * preference, never a requirement.
444
+ */
311
445
  start: () => new Promise((resolve, reject) => {
446
+ const wanted = Number.isInteger(prefer) && prefer > 0 && prefer < 65_536 ? prefer : 0;
447
+ let retried = wanted === 0;
312
448
  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));
449
+ server.on("error", err => {
450
+ if (!retried) {
451
+ // Somebody else has it — another deck on this machine, or something
452
+ // unrelated. The pin is not worth failing to start over.
453
+ retried = true;
454
+ onError?.("listen", err);
455
+ try { server.listen(0, host, () => resolve(server.address().port)); } catch { reject(err); }
456
+ return;
457
+ }
458
+ onError?.("listen", err);
459
+ reject(err);
460
+ });
461
+ server.listen(wanted, host, () => resolve(server.address().port));
317
462
  }),
318
463
  port: () => server?.address()?.port ?? null,
319
464
  stop() {
@@ -335,7 +480,12 @@ export function createSyncServer({ fp, name, key, handlers, onError, host = "0.0
335
480
  * that deck was going to say. So this checks the reply with the same care the
336
481
  * server checks the hello, and gives up if it does not hold.
337
482
  */
338
- export function connectToPeer({ host, port, fp, key, timeoutMs = HANDSHAKE_MS }) {
483
+ export function connectToPeer({
484
+ host, port, fp, pub, secret, name, myPort = null, code = null, timeoutMs = HANDSHAKE_MS,
485
+ /** The public key we pinned for this deck the first time, or null for a deck
486
+ * we are meeting — an address somebody typed. */
487
+ expectPub = null,
488
+ }) {
339
489
  return new Promise((resolve, reject) => {
340
490
  const myChallenge = randomBytes(16).toString("hex");
341
491
  const sock = net.createConnection({ host, port });
@@ -356,25 +506,53 @@ export function connectToPeer({ host, port, fp, key, timeoutMs = HANDSHAKE_MS })
356
506
  // No proof in the hello: the caller cannot cover a challenge it has not
357
507
  // been given, and a proof over an empty one would be a proof that means
358
508
  // nothing. It goes in message three.
359
- sendFrame(sock, { t: "hello", fp, challenge: myChallenge });
509
+ // `port` is where WE listen, which is not the port this socket came from
510
+ // — that one is ephemeral and useless to dial. Without it a deck can
511
+ // accept an incoming request and still have no way to reach back, so the
512
+ // pairing is mutual on paper and one-way in fact.
513
+ sendFrame(sock, { t: "hello", fp, pub, name, port: myPort, challenge: myChallenge });
360
514
  });
361
515
 
362
516
  let theirChallenge = null;
363
517
  let theirFp = null;
518
+ let theirPub = null;
519
+ let key = null;
364
520
  sock.on("data", frameReader(msg => {
365
521
  if (settled) return;
522
+ // A deck that heard us and said no. Each reason is a different problem
523
+ // with a different fix, and until this frame existed they were all one
524
+ // silent close that read as a firewall.
525
+ if (msg.t === "no") {
526
+ return fail(new Error({
527
+ pending: "waiting for the other deck to accept this one",
528
+ impostor: "that deck has this one pinned under a different key",
529
+ "bad proof": "the other deck refused this one's proof",
530
+ }[msg.why] ?? "the other deck refused this handshake"));
531
+ }
366
532
  if (msg.t === "challenge") {
367
- if (theirFp || typeof msg.challenge !== "string" || typeof msg.fp !== "string") {
368
- return fail(new Error("bad challenge"));
533
+ if (theirFp || typeof msg.challenge !== "string") return fail(new Error("bad challenge"));
534
+ const them = readPub(msg.pub);
535
+ if (!them || them.fp !== msg.fp) return fail(new Error("bad challenge"));
536
+ // THE PIN, CHECKED BEFORE ANYTHING ELSE. A deck we have paired with is
537
+ // this key and no other; a key that does not match is not a peer whose
538
+ // details changed, it is a different machine at the same address.
539
+ if (expectPub && expectPub !== them.pub) {
540
+ return fail(new Error("a different deck is answering at that address"));
369
541
  }
370
542
  theirChallenge = msg.challenge;
371
- theirFp = msg.fp;
543
+ theirFp = them.fp;
544
+ theirPub = them.pub;
545
+ key = sessionKey(secret, theirPub, handshakeTranscript(fp, theirFp, myChallenge, theirChallenge));
372
546
  sendFrame(sock, {
373
547
  t: "auth",
374
548
  proof: proof(key, {
375
549
  challenge: myChallenge, peerChallenge: theirChallenge,
376
550
  fromFp: fp, toFp: theirFp, direction: "hello",
377
551
  }),
552
+ // Only when joining on an invite. Sent in the same frame as the
553
+ // session proof so a deck that holds a token is paired in one round
554
+ // trip rather than being queued behind somebody else's press.
555
+ ...(code ? { invite: inviteProof(code, handshakeTranscript(fp, theirFp, myChallenge, theirChallenge)) } : {}),
378
556
  });
379
557
  return;
380
558
  }
@@ -385,11 +563,15 @@ export function connectToPeer({ host, port, fp, key, timeoutMs = HANDSHAKE_MS })
385
563
  challenge: theirChallenge, peerChallenge: myChallenge,
386
564
  fromFp: theirFp, toFp: fp, direction: "reply",
387
565
  });
388
- if (!proofOk(want, msg.proof)) return fail(new Error("peer could not prove the group"));
566
+ if (!proofOk(want, msg.proof)) return fail(new Error("that deck could not prove its own key"));
389
567
  settled = true;
390
568
  clearTimeout(timer);
391
569
  sock.removeAllListeners("close");
392
- resolve({ sock, peerFp: msg.fp, peerName: msg.name, send: obj => sendFrame(sock, obj) });
570
+ resolve({
571
+ sock, key,
572
+ peerFp: theirFp, peerPub: theirPub, peerName: msg.name,
573
+ send: obj => sendFrame(sock, obj),
574
+ });
393
575
  }, fail));
394
576
  });
395
577
  }