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.
@@ -65,11 +65,27 @@
65
65
  // notice it. A manual address covers the routed and VPN cases that a scan
66
66
  // cannot reach anyway, and it costs one text field.
67
67
  //
68
- // NO PER-PEER PAIRING. A code typed on both machines is what KDE Connect and
69
- // Syncthing do, and it is the right shape when the two devices are a phone and
70
- // a laptop meeting once. It is the wrong shape for a fleet: every new deck
71
- // means a trip to every existing one. One group passphrase, typed once per
72
- // deck, is the same trust decision made once instead of n² times.
68
+ // PER-PEER PAIRING, AND THIS REVERSES AN EARLIER DECISION. The first version
69
+ // used one group passphrase, on the argument that a fleet of n decks should not
70
+ // cost n² pairings. That argument is sound and it was answering the wrong
71
+ // question. What two people actually hit on real hardware was this: a
72
+ // passphrase that differs by one character produces a closed socket and no
73
+ // other symptom, on both machines, with the panel unable to tell them apart
74
+ // from a firewall — because a secret is the one value the panel must never
75
+ // print, so neither person can check theirs against the other's.
76
+ //
77
+ // A deck is now identified by its own long-term key, and a peer is somebody
78
+ // this deck has been told to trust: an address typed into the panel, or an
79
+ // incoming connection somebody pressed accept on. Nothing is shared before that
80
+ // press, and the press is a decision about a named machine at a named address
81
+ // rather than about a string neither person can see.
82
+ //
83
+ // TRUST ON FIRST USE, and said plainly rather than implied. The first
84
+ // connection to a fingerprint is taken on faith and pinned; every one after it
85
+ // is checked against the pin. That stops a stranger replacing a paired deck and
86
+ // does not stop somebody standing in the middle of the very first exchange. The
87
+ // answer to that is comparing fingerprints out of band, which the panel shows
88
+ // and which is the next thing to build.
73
89
  //
74
90
  // NO DEPENDENCY. node:crypto has X25519, HKDF, scrypt, AES-256-GCM and
75
91
  // timingSafeEqual, all of which this needs and none of which it should be
@@ -78,8 +94,9 @@
78
94
  // cryptography to protect live credentials is worse than the plain construction
79
95
  // below.
80
96
  import {
81
- createCipheriv, createDecipheriv, createHash, createHmac,
82
- randomBytes, scryptSync, timingSafeEqual,
97
+ createCipheriv, createDecipheriv, createHash, createHmac, createPrivateKey,
98
+ createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes,
99
+ timingSafeEqual,
83
100
  } from "node:crypto";
84
101
 
85
102
  /** Marks a packet as ours before anything reads a field of it. Four bytes of
@@ -90,7 +107,7 @@ export const MAGIC = "CCDK";
90
107
  /** The wire format. Bumped when a field changes meaning, never when one is
91
108
  * added — a reader that does not know a field ignores it, which is what makes
92
109
  * a deck a version behind still discoverable. */
93
- export const PROTOCOL = 1;
110
+ export const PROTOCOL = 2;
94
111
 
95
112
  /** The most a beacon may be. A beacon is a name, a port and two hashes; the
96
113
  * worst realistic case is under 300 bytes. Refusing at 512 before parsing is
@@ -161,7 +178,7 @@ export function cleanName(raw, fallback = "unnamed deck") {
161
178
  return stripped ? [...stripped].slice(0, MAX_NAME).join("") : fallback;
162
179
  }
163
180
 
164
- // ── the group secret ────────────────────────────────────────────────────────
181
+ // ── this deck's own key ─────────────────────────────────────────────────────
165
182
 
166
183
  /**
167
184
  * Turn a passphrase into the key everything else hangs off.
@@ -179,56 +196,207 @@ export function cleanName(raw, fallback = "unnamed deck") {
179
196
  * being generated at 128 bits by default; a table against a random 128-bit
180
197
  * secret is not a thing that exists.
181
198
  */
182
- export const GROUP_SALT = "ccdeck-lan-group-v1";
183
- export const SCRYPT_PARAMS = Object.freeze({ N: 1 << 15, r: 8, p: 1, maxmem: 64 << 20 });
199
+ /**
200
+ * A deck's own long-term identity.
201
+ *
202
+ * X25519, because the only thing it is ever used for is agreeing a session key
203
+ * with a peer — never a signature — and node has it built in. The private half
204
+ * lives in prefs.json, which is written 0600 for exactly this reason; the
205
+ * public half is what a peer pins, and its fingerprint is what a person
206
+ * compares.
207
+ */
208
+ export function newKeypair() {
209
+ const { publicKey, privateKey } = generateKeyPairSync("x25519");
210
+ return {
211
+ secret: privateKey.export({ type: "pkcs8", format: "der" }).toString("base64"),
212
+ pub: publicKey.export({ type: "spki", format: "der" }).toString("base64"),
213
+ };
214
+ }
184
215
 
185
- export function groupKey(passphrase) {
186
- if (typeof passphrase !== "string" || passphrase === "") return null;
187
- return scryptSync(passphrase.normalize("NFKC"), GROUP_SALT, 32, { ...SCRYPT_PARAMS });
216
+ /**
217
+ * The keypair on disk, or a fresh one when there is none or it is unusable.
218
+ *
219
+ * A corrupt secret is not an error anybody can act on mid-session, and refusing
220
+ * to start would take the whole feature away over one bad string — so it is
221
+ * replaced. The cost of replacing it is stated rather than hidden: this deck
222
+ * gets a new fingerprint, so every peer that had pinned the old one sees a
223
+ * stranger and asks its owner to accept again.
224
+ */
225
+ export function identityFrom(secret) {
226
+ if (typeof secret === "string" && secret !== "") {
227
+ try {
228
+ const priv = createPrivateKey({ key: Buffer.from(secret, "base64"), format: "der", type: "pkcs8" });
229
+ const pub = createPublicKey(priv).export({ type: "spki", format: "der" });
230
+ return { secret, pub: pub.toString("base64"), fp: fingerprint(pub), fresh: false };
231
+ } catch { /* unusable; fall through and make one */ }
232
+ }
233
+ const made = newKeypair();
234
+ return { ...made, fp: fingerprint(Buffer.from(made.pub, "base64")), fresh: true };
188
235
  }
189
236
 
190
237
  /**
191
- * A passphrase the deck makes up, so most groups never have a weak one.
238
+ * The key two decks use for one connection, and for nothing else.
192
239
  *
193
- * Words rather than characters, because this gets read aloud across a room and
194
- * typed on a phone: `amber-canyon-forty-drift` survives that trip where twenty
195
- * random characters do not. Four words from a 2048-word list is 44 bits, which
196
- * is thin against an offline grind — so the number is six, for 66 bits, which
197
- * at scrypt's cost above is beyond reach.
240
+ * X25519 to a shared secret, then HKDF over the transcript — both fingerprints
241
+ * and both challenges, in a fixed order — so the key is bound to THIS exchange.
242
+ * A recording of an old one derives a different key and proves nothing.
198
243
  *
199
- * The list is short and deliberately boring: no words that differ only by a
200
- * letter, nothing that sounds like another word over a phone.
244
+ * PER CONNECTION, never stored. The old design sealed credentials under one
245
+ * long-lived group key, which meant a single recorded transfer stayed readable
246
+ * to anybody who ever learned the passphrase. This gives forward secrecy for
247
+ * free: the ephemeral halves are gone when the socket is.
201
248
  */
202
- export const WORDS = Object.freeze([
203
- "amber", "anchor", "arrow", "basin", "beacon", "birch", "bridge", "bronze",
204
- "canyon", "cedar", "cinder", "clover", "cobalt", "copper", "coral", "cotton",
205
- "crater", "crimson", "dawn", "delta", "drift", "ember", "falcon", "fern",
206
- "flint", "forest", "garnet", "glacier", "granite", "harbor", "hazel", "hollow",
207
- "indigo", "island", "ivory", "jasper", "juniper", "kettle", "lagoon", "lantern",
208
- "ledger", "lichen", "linen", "lumber", "marble", "meadow", "mesa", "mineral",
209
- "monsoon", "mosaic", "nectar", "nickel", "nimbus", "oasis", "onyx", "opal",
210
- "orchard", "otter", "paddle", "pebble", "pewter", "pigment", "pillar", "pinion",
211
- "plateau", "prairie", "quarry", "quartz", "ravine", "ribbon", "ridge", "rustic",
212
- "saffron", "sandbar", "sapphire", "scarlet", "shadow", "shale", "sierra", "silver",
213
- "solstice", "spruce", "summit", "sunset", "talon", "tandem", "thicket", "thunder",
214
- "timber", "tundra", "umber", "valley", "velvet", "verdant", "walnut", "willow",
215
- "window", "winter", "zenith", "zephyr",
216
- ]);
217
-
218
- export function suggestPassphrase(words = 6, rand = randomBytes) {
219
- const out = [];
220
- // Rejection sampling, so the words are uniform: `% WORDS.length` on a byte
221
- // would make the first 56 words likelier than the rest, which is a real bias
222
- // in the one number here that is supposed to be a security claim.
223
- const limit = Math.floor(256 / WORDS.length) * WORDS.length;
224
- while (out.length < words) {
225
- for (const b of rand(words * 2)) {
226
- if (b >= limit) continue;
227
- out.push(WORDS[b % WORDS.length]);
228
- if (out.length === words) break;
249
+ export function sessionKey(secret, peerPub, transcript) {
250
+ const priv = createPrivateKey({ key: Buffer.from(secret, "base64"), format: "der", type: "pkcs8" });
251
+ const theirs = createPublicKey({ key: Buffer.from(peerPub, "base64"), format: "der", type: "spki" });
252
+ const shared = diffieHellman({ privateKey: priv, publicKey: theirs });
253
+ return Buffer.from(hkdfSync("sha256", shared, Buffer.alloc(0),
254
+ Buffer.from(`ccdeck-lan-v${PROTOCOL}|${transcript}`, "utf8"), 32));
255
+ }
256
+
257
+ /**
258
+ * The string both sides bind their session key to.
259
+ *
260
+ * One definition, used by the caller and the listener, because a transcript the
261
+ * two build differently is a handshake that never agrees and a bug that only
262
+ * appears between two machines. Caller first, always, so the order does not
263
+ * depend on which end is asking.
264
+ */
265
+ export function handshakeTranscript(callerFp, listenerFp, callerChallenge, listenerChallenge) {
266
+ return `${callerFp}|${listenerFp}|${callerChallenge}|${listenerChallenge}`;
267
+ }
268
+
269
+ /**
270
+ * A public key somebody sent, or null.
271
+ *
272
+ * It arrives from the network before anything has been agreed, so it is checked
273
+ * for being an X25519 public key at all rather than trusted to be one — a
274
+ * string that is not gets a refusal here instead of a throw three frames later.
275
+ */
276
+ export function readPub(raw) {
277
+ if (typeof raw !== "string" || raw.length < 40 || raw.length > 128) return null;
278
+ try {
279
+ const der = Buffer.from(raw, "base64");
280
+ createPublicKey({ key: der, format: "der", type: "spki" });
281
+ return { pub: raw, fp: fingerprint(der) };
282
+ } catch { return null; }
283
+ }
284
+
285
+ // ── the invite ──────────────────────────────────────────────────────────────
286
+ //
287
+ // ONE PIECE OF TEXT THAT CARRIES EVERYTHING, and it exists because of a real
288
+ // afternoon. Two people, two machines, a typed address, and `handshake timed
289
+ // out` — because the address on screen was the LAN one and the only route
290
+ // between them was a VPN. Neither of them could have known which of the two to
291
+ // use; the deck could not know either, and printing both made it their problem.
292
+ //
293
+ // So the invite carries EVERY address this deck has, and the deck that receives
294
+ // it tries them in turn. Nobody has to know which one is routable, because the
295
+ // only machine that can find out is the one doing the reaching.
296
+ //
297
+ // AND IT CARRIES A CODE, which is what turns two presses into one. A deck that
298
+ // proves it holds the invite is not a stranger asking to be let in — it is
299
+ // somebody the owner of this machine handed a token to. So it is paired on
300
+ // arrival and nobody presses accept. That also closes the gap trust-on-first-use
301
+ // left open: the first contact is verified rather than believed.
302
+ //
303
+ // THE TOKEN IS THE SECRET, said plainly rather than implied. Whoever holds it
304
+ // can pair with this deck until it expires. It is a door key with a timer, not
305
+ // an identifier — which is why it is short-lived, single-purpose, and replaced
306
+ // the moment it is used.
307
+
308
+ /** How long an invite is good for. Long enough to paste into a chat and have
309
+ * somebody read it, short enough that one left in a channel is not a way in
310
+ * tomorrow. */
311
+ export const INVITE_MS = 10 * 60 * 1000;
312
+
313
+ /** The most addresses an invite may carry. A machine with a VPN, a second card
314
+ * and a container bridge has three or four; ten is far past honest and keeps a
315
+ * hand-built token from being a way to make this deck dial a list. */
316
+ export const MAX_INVITE_ADDRS = 10;
317
+
318
+ /** What the token starts with, so a reader can tell at a glance what they have
319
+ * been sent and a wrong paste is refused before it is parsed. */
320
+ export const INVITE_PREFIX = "ccdeck1.";
321
+
322
+ const b64url = buf => buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
323
+ const unb64url = str => Buffer.from(str.replace(/-/g, "+").replace(/_/g, "/"), "base64");
324
+
325
+ /**
326
+ * A code somebody could read out loud if they had to.
327
+ *
328
+ * Six digits, from rejection sampling rather than modulo — the same argument
329
+ * the word list made and for the same reason: a bias here is a bias in the one
330
+ * number that decides whether a stranger can pair.
331
+ */
332
+ export function inviteCode(rand = randomBytes) {
333
+ let out = "";
334
+ while (out.length < 6) {
335
+ for (const b of rand(12)) {
336
+ if (b >= 250) continue; // 250 = 25 * 10, the largest clean multiple
337
+ out += String(b % 10);
338
+ if (out.length === 6) break;
229
339
  }
230
340
  }
231
- return out.join("-");
341
+ return out;
342
+ }
343
+
344
+ /** Mint one. `addrs` are already `host:port` strings, because which addresses
345
+ * this machine has is not a question this file can answer. */
346
+ export function mintInvite({ addrs, name, now = Date.now(), code = inviteCode() } = {}) {
347
+ const list = (Array.isArray(addrs) ? addrs : []).filter(a => typeof a === "string" && a).slice(0, MAX_INVITE_ADDRS);
348
+ if (!list.length) return null;
349
+ const expiresAt = now + INVITE_MS;
350
+ const body = { v: PROTOCOL, a: list, n: cleanName(name), c: code, x: expiresAt };
351
+ return { token: INVITE_PREFIX + b64url(Buffer.from(JSON.stringify(body), "utf8")), code, expiresAt };
352
+ }
353
+
354
+ /**
355
+ * Read one somebody pasted, or null.
356
+ *
357
+ * REFUSES BEFORE IT UNDERSTANDS, in the order that costs least: the prefix,
358
+ * then the length, then the decode, then the shape, then the clock. A token is
359
+ * text a person pasted out of a chat window, so it is as untrusted as anything
360
+ * that arrives on a socket — and every failure here is the same answer to the
361
+ * reader, which is "that is not an invite".
362
+ */
363
+ export function readInvite(raw, now = Date.now()) {
364
+ if (typeof raw !== "string") return null;
365
+ const text = raw.trim();
366
+ if (!text.startsWith(INVITE_PREFIX)) return null;
367
+ if (text.length > 2048) return null;
368
+ let body = null;
369
+ try { body = JSON.parse(unb64url(text.slice(INVITE_PREFIX.length)).toString("utf8")); }
370
+ catch { return null; }
371
+ if (!body || typeof body !== "object" || Array.isArray(body)) return null;
372
+ if (body.v !== PROTOCOL) return null;
373
+ if (typeof body.c !== "string" || !/^[0-9]{6}$/.test(body.c)) return null;
374
+ if (typeof body.x !== "number" || !Number.isFinite(body.x)) return null;
375
+ // Expired is its own answer and the caller says so — "that invite has run
376
+ // out, ask for a new one" is a different instruction from "that is not an
377
+ // invite", and a reader who cannot tell them apart retypes the same thing.
378
+ const expired = body.x <= now;
379
+ const addrs = (Array.isArray(body.a) ? body.a : [])
380
+ .filter(a => typeof a === "string")
381
+ .slice(0, MAX_INVITE_ADDRS)
382
+ .map(a => ({ raw: a, at: a.lastIndexOf(":") }))
383
+ .filter(p => p.at > 0)
384
+ .map(p => ({ addr: p.raw.slice(0, p.at), port: Number(p.raw.slice(p.at + 1)) }))
385
+ .filter(p => p.addr && Number.isInteger(p.port) && p.port > 0 && p.port < 65_536);
386
+ if (!addrs.length) return null;
387
+ return { addrs, name: cleanName(body.n), code: body.c, expiresAt: body.x, expired };
388
+ }
389
+
390
+ /**
391
+ * The proof that a caller is holding the invite, over the same transcript the
392
+ * session key is bound to.
393
+ *
394
+ * Not the code itself on the wire. A code that travelled in the clear would be
395
+ * replayable by anybody who watched one exchange, and this is the value that
396
+ * decides whether a deck is paired without anybody pressing anything.
397
+ */
398
+ export function inviteProof(code, transcript) {
399
+ return createHmac("sha256", `ccdeck-invite-v${PROTOCOL}`).update(`${code}|${transcript}`).digest("hex");
232
400
  }
233
401
 
234
402
  // ── the beacon ──────────────────────────────────────────────────────────────
@@ -238,26 +406,26 @@ export function suggestPassphrase(words = 6, rand = randomBytes) {
238
406
  *
239
407
  * NO ACCOUNTS AND NO SECRET. This is the packet a stranger on the same wifi
240
408
  * receives, so what is in it is what a stranger learns: that a ccdeck is here,
241
- * what it calls itself, and two hashes. Not which Anthropic accounts exist on
242
- * the machine, not how many, not the group passphrase, and nothing derived
243
- * from the passphrase that could be ground at offline.
244
- *
245
- * `group` IS derived from the passphrase, and that needs saying plainly: it is
246
- * HMAC(groupKey, "beacon-group") — a value that only lets two decks in the same
247
- * group recognise each other. An eavesdropper sees a 64-bit tag; grinding it
248
- * back to the passphrase means grinding scrypt, which is exactly the cost that
249
- * function was chosen for. What it buys is that a deck with the wrong
250
- * passphrase is not asked a single question — the packet is dropped by
251
- * comparison, before any handshake exists to attack.
409
+ * what it calls itself, and a hash of its public key. Not which Anthropic
410
+ * accounts exist on the machine, not how many, and nothing that could be ground
411
+ * at offline, because there is nothing in it derived from a secret.
412
+ *
413
+ * IT NO LONGER CARRIES A GROUP TAG, and that is the shape of the whole feature
414
+ * changing rather than a field going away. The tag let a deck drop a packet
415
+ * from outside its group before any handshake existed to attack, which is a
416
+ * real property and was worth having — but it only worked when both people
417
+ * held the same passphrase, and a passphrase neither of them can see is exactly
418
+ * what nobody could get right. What replaces it is later and cheaper: a beacon
419
+ * from a deck this one does not trust becomes a row somebody can accept, and
420
+ * nothing at all happens until they do.
252
421
  */
253
- export function beaconPayload({ name, fp, port, group, instance }) {
422
+ export function beaconPayload({ name, fp, port, instance }) {
254
423
  return {
255
424
  m: MAGIC,
256
425
  v: PROTOCOL,
257
426
  n: cleanName(name),
258
427
  f: fp,
259
428
  p: port,
260
- g: group,
261
429
  // Randomised at start. Two beacons from one fingerprint with different
262
430
  // instance ids mean the deck restarted between them, which is the signal to
263
431
  // drop whatever session state was held for it rather than trying to resume
@@ -266,11 +434,6 @@ export function beaconPayload({ name, fp, port, group, instance }) {
266
434
  };
267
435
  }
268
436
 
269
- /** The tag that says "same group as me", without saying what the group is. */
270
- export function groupTag(key) {
271
- return createHmac("sha256", key).update("beacon-group").digest("hex").slice(0, 16);
272
- }
273
-
274
437
  /**
275
438
  * Read a packet off the wire, refusing before understanding.
276
439
  *
@@ -300,9 +463,8 @@ export function readBeacon(buf, { maxBytes = MAX_BEACON_BYTES } = {}) {
300
463
  if (typeof raw.p !== "number" || !Number.isInteger(raw.p) || raw.p < 1 || raw.p > 65_535) return null;
301
464
  const port = raw.p;
302
465
  if (typeof raw.f !== "string" || !/^[0-9a-f]{3}(-[0-9a-f]{3}){3}$/.test(raw.f)) return null;
303
- if (typeof raw.g !== "string" || !/^[0-9a-f]{16}$/.test(raw.g)) return null;
304
466
  if (typeof raw.i !== "string" || !/^[0-9a-f]{8,32}$/.test(raw.i)) return null;
305
- return { name: cleanName(raw.n, raw.f), fp: raw.f, port, group: raw.g, instance: raw.i };
467
+ return { name: cleanName(raw.n, raw.f), fp: raw.f, port, instance: raw.i };
306
468
  }
307
469
 
308
470
  /**
@@ -318,30 +480,103 @@ export function readBeacon(buf, { maxBytes = MAX_BEACON_BYTES } = {}) {
318
480
  * broadcast on every interface it owns, and filtering by address would need a
319
481
  * list of them that changes when a VPN comes up.
320
482
  */
321
- export function beaconVerdict(beacon, { selfFp, selfGroup, selfInstance }) {
483
+ export function beaconVerdict(beacon, { selfFp, selfInstance, trusted } = {}) {
322
484
  if (!beacon) return "unreadable";
323
485
  if (beacon.fp === selfFp) {
324
- // OUR OWN NAME, FROM SOMEBODY ELSE'S PROCESS. The id is stored in prefs, so
325
- // two decks sharing a config directory hold the same one — and so does the
326
- // second machine when somebody copies their ~/.claude across, which people
327
- // do. Both then file every one of the other's beacons as "that is me" and
328
- // the two are permanently invisible to each other, with nothing on screen
329
- // to say why.
486
+ // OUR OWN NAME, FROM SOMEBODY ELSE'S PROCESS. Two decks sharing a config
487
+ // directory hold the same key — and so does the second machine when
488
+ // somebody copies their ~/.claude across, which people do. Both then file
489
+ // every one of the other's beacons as "that is me" and the two are
490
+ // permanently invisible to each other, with nothing on screen to say why.
330
491
  //
331
492
  // `instance` is what separates the cases: it is fresh per process, so our
332
493
  // own packet carries the instance we are running and another deck's cannot.
333
494
  // Told apart here rather than healed here — this function decides, and
334
- // regenerating an id is the engine's to do.
495
+ // taking a new key is the engine's to do.
335
496
  return selfInstance && beacon.instance !== selfInstance ? "id-clash" : "self";
336
497
  }
337
- if (!selfGroup) return "no-group";
338
- // Constant-time, because this compares a value derived from a secret against
339
- // one an attacker chooses, once per packet, forever. A byte-at-a-time compare
340
- // here is a genuine oracle: the network lets them send as many as they like.
341
- const a = Buffer.from(beacon.group, "hex");
342
- const b = Buffer.from(selfGroup, "hex");
343
- if (a.length !== b.length || !timingSafeEqual(a, b)) return "other-group";
344
- return "peer";
498
+ // A DECK WE HAVE BEEN TOLD TO TRUST, or one somebody may choose to. There is
499
+ // no third answer any more: the group tag used to sort strangers from peers
500
+ // before a handshake existed, and now every stranger is a row with a name and
501
+ // an address that a person can accept or ignore. Nothing is asked of an
502
+ // unknown deck and nothing is offered to it.
503
+ return Array.isArray(trusted) && trusted.some(t => t.fp === beacon.fp) ? "peer" : "stranger";
504
+ }
505
+
506
+ /** Whether this fingerprint is one somebody has already accepted, and what was
507
+ * recorded about it — the pinned public key above all, which is what makes a
508
+ * second connection from the same fingerprint checkable rather than merely
509
+ * claimed. */
510
+ export function trustedPeer(trusted, fp) {
511
+ return (Array.isArray(trusted) ? trusted : []).find(t => t && t.fp === fp) ?? null;
512
+ }
513
+
514
+ /**
515
+ * Add a deck to the trusted list, or update what is known about one.
516
+ *
517
+ * THE PINNED KEY NEVER CHANGES UNDER US. A second entry claiming a fingerprint
518
+ * we already hold with a different public key is not an update, it is a
519
+ * different deck wearing the name — and 48 bits of fingerprint is far past
520
+ * accident, so it is somebody trying. The old entry stands and the caller is
521
+ * told nothing changed.
522
+ */
523
+ export function addTrusted(trusted, entry) {
524
+ const list = Array.isArray(trusted) ? trusted : [];
525
+ if (!entry || typeof entry.fp !== "string" || typeof entry.pub !== "string") return { list, added: false };
526
+ const had = trustedPeer(list, entry.fp);
527
+ if (had) {
528
+ if (had.pub !== entry.pub) return { list, added: false };
529
+ return {
530
+ list: list.map(t => (t.fp === entry.fp ? { ...t, name: entry.name ?? t.name } : t)),
531
+ added: false,
532
+ };
533
+ }
534
+ return { list: [...list, { fp: entry.fp, pub: entry.pub, name: entry.name ?? "" }], added: true };
535
+ }
536
+
537
+ /** Take one back out. Unpairing stops what has not happened yet and takes back
538
+ * nothing that has — the same sentence the panel says about a shared login,
539
+ * and true here for the same reason. */
540
+ export function dropTrusted(trusted, fp) {
541
+ return (Array.isArray(trusted) ? trusted : []).filter(t => t && t.fp !== fp);
542
+ }
543
+
544
+ /**
545
+ * The decks worth offering to pair with, out of everything that has ever been
546
+ * heard.
547
+ *
548
+ * THIS LIST WAS A WALL. Every deck ever heard stayed in it forever, and a deck
549
+ * that restarts takes a NEW key — so a machine started and stopped seven times
550
+ * was seven rows, all with the same name, all at the same address, none of them
551
+ * reachable any more. The dialog it filled was unreadable, which is exactly
552
+ * what somebody reported.
553
+ *
554
+ * Three rules, in order:
555
+ *
556
+ * PRESENT ONLY. A deck that has not been heard for a couple of announce
557
+ * intervals is not somewhere you can pair right now, so it is not offered. This
558
+ * is the same window the peer table uses, for the same reason.
559
+ *
560
+ * ONE ROW PER MACHINE. A person reading this sees a name and an address, and
561
+ * two rows carrying the same pair are the same machine to them whatever the
562
+ * fingerprints say. The freshest wins, because it is the one still running.
563
+ *
564
+ * NEWEST FIRST, AND CAPPED. The deck somebody just started is the one they are
565
+ * looking for; a list longer than a screen is a list nobody reads.
566
+ */
567
+ export function pairable(strangers, now, { presentMs = PRESENT_MS, limit = 8 } = {}) {
568
+ const live = [...(strangers ?? [])]
569
+ .filter(p => p && typeof p.at === "number" && now - p.at <= presentMs)
570
+ .sort((a, b) => b.at - a.at);
571
+ const seen = new Set();
572
+ const out = [];
573
+ for (const p of live) {
574
+ const key = `${p.name}@${p.addr}`;
575
+ if (seen.has(key)) continue;
576
+ seen.add(key);
577
+ out.push(p);
578
+ }
579
+ return { shown: out.slice(0, limit), more: Math.max(0, out.length - limit) };
345
580
  }
346
581
 
347
582
  // ── the peer table ──────────────────────────────────────────────────────────