agent-dag 3.14.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.
@@ -0,0 +1,620 @@
1
+ // The rules for finding other decks on this network and agreeing who is in the
2
+ // group. No sockets, no timers, no filesystem — those live in lan-socket.mjs,
3
+ // and everything here is a pure function so the suite can run all of it on one
4
+ // machine, which is the only machine there is.
5
+ //
6
+ // WHAT THIS IS FOR. An account's login dies on a machine that has not used it
7
+ // for a while, while the same account stays alive on a machine that has. Today
8
+ // the fix is a blob copied out of one deck and pasted into another. This is
9
+ // that, without the copying: decks on one network find each other, agree they
10
+ // belong to the same group, say which accounts they hold and how fresh each
11
+ // one is, and hand over the fresher copy.
12
+ //
13
+ // WHAT IT IS NOT. Not a way to keep one account working on two machines at
14
+ // once — a quota is the account's, not the machine's, so a second machine
15
+ // buys no capacity. Not a backup. Not a way to reach a deck across the
16
+ // internet: everything here is link-local by construction.
17
+ //
18
+ // ── the shape of it, and where each piece came from ──────────────────────────
19
+ //
20
+ // Three shipped systems solve nearly this problem and were read before any of
21
+ // it was written, because the failure modes are all in the parts that look
22
+ // simple.
23
+ //
24
+ // SYNCTHING (docs.syncthing.net/specs/localdisco-v4.html) announces on UDP
25
+ // with NO authentication in the announcement at all, and puts the trust
26
+ // somewhere else entirely: the announcement's only identity claim is a hash of
27
+ // the device's long-term key. That is exactly right and it is what this does.
28
+ // An announcement is a shout in a room; anything it asserts is a claim by
29
+ // whoever shouted.
30
+ //
31
+ // It also broadcasts on IPv4 rather than multicasting — 255.255.255.255, port
32
+ // 21027 — and multicasts only on IPv6. That looked like an oddity and is not:
33
+ // consumer switches and access points forward broadcast where they drop
34
+ // unregistered multicast groups, so the "modern" choice is the one that fails
35
+ // on more real networks. Copied.
36
+ //
37
+ // And it re-announces IMMEDIATELY on start rather than waiting for the next
38
+ // interval, so a deck that just came up appears at once instead of up to a
39
+ // minute later. Copied.
40
+ //
41
+ // LOCALSEND (github.com/localsend/protocol) is the closest thing to this that
42
+ // people actually run. Its fingerprint is a hash of a long-term key, used both
43
+ // to recognise a peer across restarts and to ignore its own announcements —
44
+ // the second of which is not obvious until you watch a deck discover itself.
45
+ // Copied.
46
+ //
47
+ // Its other lesson is about WHERE a check goes. The group secret authenticates
48
+ // the channel; LocalSend still puts a separate PIN on the transfer endpoint
49
+ // itself, so the sensitive operation carries its own proof rather than
50
+ // inheriting one from a session that may be old. See `transferChallenge`.
51
+ //
52
+ // KDE CONNECT had CVE-2020-26164: several issues in the daemon that listens on
53
+ // the LAN, in a design whose protocol was fine. The lesson is not "do TLS
54
+ // better", it is that a process listening on a hostile network must do as
55
+ // close to nothing as possible before it knows who it is talking to. So
56
+ // `readBeacon` refuses on length before it parses, refuses on magic before it
57
+ // reads a field, and never allocates anything sized by the packet.
58
+ //
59
+ // ── what is deliberately NOT here ───────────────────────────────────────────
60
+ //
61
+ // NO SUBNET SCAN. LocalSend falls back to POSTing to every address on the
62
+ // subnet when multicast finds nobody, and it is a defensible choice for
63
+ // consumer software. It is a port scan, which on a corporate network is the
64
+ // thing that gets a machine quarantined by the very systems that exist to
65
+ // notice it. A manual address covers the routed and VPN cases that a scan
66
+ // cannot reach anyway, and it costs one text field.
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.
73
+ //
74
+ // NO DEPENDENCY. node:crypto has X25519, HKDF, scrypt, AES-256-GCM and
75
+ // timingSafeEqual, all of which this needs and none of which it should be
76
+ // implementing. The one PAKE package on npm was last published in 2022, has 37
77
+ // downloads a week and four dependencies of its own; running unmaintained
78
+ // cryptography to protect live credentials is worse than the plain construction
79
+ // below.
80
+ import {
81
+ createCipheriv, createDecipheriv, createHash, createHmac,
82
+ randomBytes, scryptSync, timingSafeEqual,
83
+ } from "node:crypto";
84
+
85
+ /** Marks a packet as ours before anything reads a field of it. Four bytes of
86
+ * ASCII rather than a number, so a stray packet on the port is recognisable
87
+ * in a capture as "something else's" rather than as a corrupt one of ours. */
88
+ export const MAGIC = "CCDK";
89
+
90
+ /** The wire format. Bumped when a field changes meaning, never when one is
91
+ * added — a reader that does not know a field ignores it, which is what makes
92
+ * a deck a version behind still discoverable. */
93
+ export const PROTOCOL = 1;
94
+
95
+ /** The most a beacon may be. A beacon is a name, a port and two hashes; the
96
+ * worst realistic case is under 300 bytes. Refusing at 512 before parsing is
97
+ * the cheapest possible answer to a packet built to be expensive to read. */
98
+ export const MAX_BEACON_BYTES = 512;
99
+
100
+ /** How often a deck announces itself, and how long a peer is remembered as
101
+ * present after its last one.
102
+ *
103
+ * 30 seconds is Syncthing's floor and it is chosen for the same reason: a
104
+ * quiet network should not carry more of this than it has to, and a deck that
105
+ * went away is not urgent news. `PRESENT_MS` is three intervals, so two lost
106
+ * packets do not make a live deck flicker out of the list. */
107
+ export const ANNOUNCE_MS = 30_000;
108
+ export const PRESENT_MS = ANNOUNCE_MS * 3 + 5_000;
109
+
110
+ /** Longest a display name may be, in characters rather than bytes so the limit
111
+ * means the same thing in every script. A name is chosen by whoever is
112
+ * shouting, so it is untrusted text: capped here, and never rendered as
113
+ * anything but text at the other end. */
114
+ export const MAX_NAME = 40;
115
+
116
+ /** Longest a manifest may be. Fifty accounts, each an email, an org id and a
117
+ * few numbers, comes to roughly 12 KB; this is an order of magnitude over
118
+ * that and still nothing to allocate by accident. */
119
+ export const MAX_MANIFEST_BYTES = 128 * 1024;
120
+
121
+ // ── identity ────────────────────────────────────────────────────────────────
122
+
123
+ /**
124
+ * What a deck calls itself on the wire, given its long-term public key.
125
+ *
126
+ * A hash of the key rather than the key: it is short enough to read out over a
127
+ * phone, it is stable across restarts, and — the part that matters — it is the
128
+ * only identity claim in a beacon that cannot be forged, because a deck that
129
+ * cannot do the handshake cannot use somebody else's fingerprint for anything.
130
+ *
131
+ * Twelve hex characters, in threes. 48 bits is far past what an accident
132
+ * reaches on one network, and a person can compare four groups at a glance
133
+ * where they cannot compare sixty-four characters. Syncthing and LocalSend both
134
+ * make the same trade at a similar length.
135
+ */
136
+ export function fingerprint(publicKeyRaw) {
137
+ const hex = createHash("sha256").update(publicKeyRaw).digest("hex").slice(0, 12);
138
+ return hex.replace(/(.{3})(?=.)/g, "$1-");
139
+ }
140
+
141
+ /**
142
+ * The name a deck shows, cleaned.
143
+ *
144
+ * Control characters go, because this string is written into a log and read in
145
+ * a terminal as well as rendered in a page, and a name carrying an escape
146
+ * sequence is a name that can move a cursor. Whitespace collapses so a peer
147
+ * cannot pad itself into looking like two entries. Empty falls back to the
148
+ * fingerprint, which is never empty and is the honest answer for a deck that
149
+ * did not say who it was.
150
+ */
151
+ export function cleanName(raw, fallback = "unnamed deck") {
152
+ if (typeof raw !== "string") return fallback;
153
+ // Escape sequences and NUL, spelled by code point rather than by literal:
154
+ // this string reaches a terminal log as well as a page, and a name carrying
155
+ // an ANSI escape can move a cursor or repaint a line. A literal control
156
+ // character in the source would be invisible to whoever reads this next.
157
+ const stripped = raw
158
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
159
+ .replace(/\s+/g, " ")
160
+ .trim();
161
+ return stripped ? [...stripped].slice(0, MAX_NAME).join("") : fallback;
162
+ }
163
+
164
+ // ── the group secret ────────────────────────────────────────────────────────
165
+
166
+ /**
167
+ * Turn a passphrase into the key everything else hangs off.
168
+ *
169
+ * scrypt rather than a plain hash, and the parameters are the point: this is a
170
+ * passphrase a person typed, on a network where anybody can capture the
171
+ * handshake and grind at it offline. N=2^15 costs about 100ms and 32 MB per
172
+ * guess here, which is nothing once per deck start and is the difference
173
+ * between a weak passphrase falling in seconds and falling in weeks.
174
+ *
175
+ * The salt is FIXED and that is deliberate, not an oversight. A per-deck salt
176
+ * would mean two decks with the same passphrase deriving different keys, which
177
+ * is the one thing this must never do. What a salt buys — that one rainbow
178
+ * table cannot cover every deployment — is bought instead by the passphrase
179
+ * being generated at 128 bits by default; a table against a random 128-bit
180
+ * secret is not a thing that exists.
181
+ */
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 });
184
+
185
+ export function groupKey(passphrase) {
186
+ if (typeof passphrase !== "string" || passphrase === "") return null;
187
+ return scryptSync(passphrase.normalize("NFKC"), GROUP_SALT, 32, { ...SCRYPT_PARAMS });
188
+ }
189
+
190
+ /**
191
+ * A passphrase the deck makes up, so most groups never have a weak one.
192
+ *
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.
198
+ *
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.
201
+ */
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;
229
+ }
230
+ }
231
+ return out.join("-");
232
+ }
233
+
234
+ // ── the beacon ──────────────────────────────────────────────────────────────
235
+
236
+ /**
237
+ * What a deck shouts, which anybody on the network can hear.
238
+ *
239
+ * NO ACCOUNTS AND NO SECRET. This is the packet a stranger on the same wifi
240
+ * 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.
252
+ */
253
+ export function beaconPayload({ name, fp, port, group, instance }) {
254
+ return {
255
+ m: MAGIC,
256
+ v: PROTOCOL,
257
+ n: cleanName(name),
258
+ f: fp,
259
+ p: port,
260
+ g: group,
261
+ // Randomised at start. Two beacons from one fingerprint with different
262
+ // instance ids mean the deck restarted between them, which is the signal to
263
+ // drop whatever session state was held for it rather than trying to resume
264
+ // into a process that no longer exists. Syncthing's field, same job.
265
+ i: instance,
266
+ };
267
+ }
268
+
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
+ /**
275
+ * Read a packet off the wire, refusing before understanding.
276
+ *
277
+ * The order is the whole point and it is the CVE-2020-26164 lesson: length
278
+ * first, because that costs nothing; then magic, because that is one string
279
+ * compare; then a parse; then the fields. Nothing here allocates anything
280
+ * sized by the packet, and a packet that fails at any step produces `null`
281
+ * rather than a reason — a listener that explained itself to whoever is
282
+ * probing it would be a listener helping them.
283
+ */
284
+ export function readBeacon(buf, { maxBytes = MAX_BEACON_BYTES } = {}) {
285
+ if (!buf || buf.length === 0 || buf.length > maxBytes) return null;
286
+ // One byte before a parse. The payload is JSON, so anything not starting with
287
+ // `{` is somebody else's traffic on the port and costs nothing to refuse —
288
+ // which on a busy network is most of what arrives.
289
+ if (buf[0] !== 0x7b) return null;
290
+ let raw;
291
+ try { raw = JSON.parse(buf.toString("utf8")); }
292
+ catch { return null; }
293
+ if (!raw || typeof raw !== "object") return null;
294
+ if (raw.m !== MAGIC) return null;
295
+ if (raw.v !== PROTOCOL) return null;
296
+ // The TYPE as well as the value. `Number("4319")` is a valid port and this
297
+ // used to take it, which is leniency of exactly the kind this whole reader
298
+ // exists to refuse: a field arriving as a type nobody sends is a field built
299
+ // by hand, and the only safe answer to a hand-built packet is no.
300
+ if (typeof raw.p !== "number" || !Number.isInteger(raw.p) || raw.p < 1 || raw.p > 65_535) return null;
301
+ const port = raw.p;
302
+ 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
+ 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 };
306
+ }
307
+
308
+ /**
309
+ * Whether a beacon is one this deck should act on.
310
+ *
311
+ * Three separate refusals and they mean different things, which is why this
312
+ * returns a reason rather than a boolean — the panel says "found a deck that is
313
+ * not in your group" where it would otherwise say nothing at all, and "not in
314
+ * your group" is the single most useful sentence when somebody has mistyped the
315
+ * passphrase on one machine.
316
+ *
317
+ * Self-recognition is by fingerprint, not by address: a deck hears its own
318
+ * broadcast on every interface it owns, and filtering by address would need a
319
+ * list of them that changes when a VPN comes up.
320
+ */
321
+ export function beaconVerdict(beacon, { selfFp, selfGroup, selfInstance }) {
322
+ if (!beacon) return "unreadable";
323
+ 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.
330
+ //
331
+ // `instance` is what separates the cases: it is fresh per process, so our
332
+ // own packet carries the instance we are running and another deck's cannot.
333
+ // Told apart here rather than healed here — this function decides, and
334
+ // regenerating an id is the engine's to do.
335
+ return selfInstance && beacon.instance !== selfInstance ? "id-clash" : "self";
336
+ }
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";
345
+ }
346
+
347
+ // ── the peer table ──────────────────────────────────────────────────────────
348
+
349
+ /**
350
+ * Fold one heard beacon into what is known, and say whether anything changed.
351
+ *
352
+ * PEERS ARE REMEMBERED, NOT MERELY SEEN. A deck that is switched off should
353
+ * stay in the list with "last seen yesterday" rather than vanishing, because
354
+ * the question the list answers is "who is in my group" and the answer to that
355
+ * does not change when a laptop closes. Presence is a field, not membership.
356
+ *
357
+ * `changed` is returned rather than inferred by the caller so a beacon that
358
+ * says nothing new — which is most of them, one every thirty seconds per peer
359
+ * forever — costs no render and no write.
360
+ */
361
+ export function notePeer(peers, beacon, addr, now) {
362
+ const prev = peers.get(beacon.fp);
363
+ const next = {
364
+ fp: beacon.fp,
365
+ name: beacon.name,
366
+ addr,
367
+ port: beacon.port,
368
+ instance: beacon.instance,
369
+ firstSeen: prev?.firstSeen ?? now,
370
+ lastSeen: now,
371
+ // Kept across a name change so the panel can say "was Laptop-birou", which
372
+ // is what stops a renamed deck reading as a new one that appeared.
373
+ prevName: prev && prev.name !== beacon.name ? prev.name : prev?.prevName,
374
+ // A manual peer stays manual once found by beacon, so removing it from the
375
+ // list means removing the address the user typed rather than waiting for a
376
+ // packet that will re-add it.
377
+ manual: prev?.manual ?? false,
378
+ };
379
+ const changed = !prev
380
+ || prev.name !== next.name
381
+ || prev.addr !== next.addr
382
+ || prev.port !== next.port
383
+ || prev.instance !== next.instance;
384
+ peers.set(beacon.fp, next);
385
+ return { peer: next, changed, restarted: !!prev && prev.instance !== next.instance };
386
+ }
387
+
388
+ /** Whether a peer counts as here right now. Separate from being in the list at
389
+ * all — see notePeer. */
390
+ export function isPresent(peer, now) {
391
+ return peer.lastSeen != null && now - peer.lastSeen < PRESENT_MS;
392
+ }
393
+
394
+ /** How long a deck stays in the list after its last beacon. See stillListed. */
395
+ export const FORGET_MS = 24 * 60 * 60_000;
396
+
397
+ /**
398
+ * Whether a peer still belongs in the list at all.
399
+ *
400
+ * The list answers "who is in my group", and that does not change when a laptop
401
+ * closes — so a deck heard this morning is still there tonight with the time
402
+ * beside it. A day later it is not news that it once existed.
403
+ *
404
+ * WITHOUT THIS the list is a graveyard, and that was measured rather than
405
+ * imagined: on a machine where decks had been restarted a few times, every
406
+ * peer's list held a row per restart, each reporting ECONNREFUSED once a minute
407
+ * against a port nothing had listened on for an hour. A stable deck id stopped
408
+ * new rows appearing; this is what clears the ones that are genuinely gone.
409
+ *
410
+ * A TYPED ADDRESS IS EXEMPT, and that is the whole reason this is a rule rather
411
+ * than a comparison inlined at the call site: an address somebody typed is a
412
+ * decision they made, and a deck that has been off for a week is exactly the
413
+ * case they typed it for. Only the user takes those off.
414
+ */
415
+ export function stillListed(peer, now, forgetMs = FORGET_MS) {
416
+ if (peer?.manual) return true;
417
+ return peer?.lastSeen != null && now - peer.lastSeen < forgetMs;
418
+ }
419
+
420
+ /**
421
+ * The list as the panel shows it: present decks first, then by name.
422
+ *
423
+ * Not by last-seen within each group, which was the obvious ordering and is
424
+ * wrong: a list that reorders itself every thirty seconds as beacons land in
425
+ * whatever order the network delivers them is a list nobody can point at.
426
+ * Name is stable, and the presence split is the only thing that should move a
427
+ * row.
428
+ */
429
+ export function peerRows(peers, now) {
430
+ return [...peers.values()]
431
+ .map(p => ({ ...p, present: isPresent(p, now) }))
432
+ .sort((a, b) => (Number(b.present) - Number(a.present))
433
+ || a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
434
+ || a.fp.localeCompare(b.fp));
435
+ }
436
+
437
+ // ── the handshake ───────────────────────────────────────────────────────────
438
+
439
+ /**
440
+ * The proof a deck offers to show it holds the group passphrase.
441
+ *
442
+ * Challenge-response over the derived key rather than sending anything derived
443
+ * from the passphrase directly, so a recording of one exchange is worth nothing
444
+ * for the next: the challenge is fresh random from the side being convinced.
445
+ *
446
+ * BOTH SIDES PROVE. The obvious version has the caller prove itself to the
447
+ * listener, which stops a stranger reading a manifest and stops nothing else —
448
+ * a stranger can still stand up a listener, wait for a real deck to connect,
449
+ * and be handed one. So the response covers a challenge from each side and the
450
+ * initiator checks the answer with the same care.
451
+ *
452
+ * The transcript is in the MAC, not just the challenges. Without it the same
453
+ * proof is valid on a different port, for a different peer, in a different
454
+ * direction — three distinct ways for a recording to be replayed somewhere it
455
+ * was not made.
456
+ */
457
+ export function proof(key, { challenge, peerChallenge, fromFp, toFp, direction }) {
458
+ return createHmac("sha256", key)
459
+ .update(`ccdeck-lan-v${PROTOCOL}|${direction}|${fromFp}|${toFp}|${challenge}|${peerChallenge}`)
460
+ .digest("hex");
461
+ }
462
+
463
+ /** Whether a proof is the one expected, compared in constant time for the
464
+ * reason `beaconVerdict` gives. Length-checked first because timingSafeEqual
465
+ * throws on a mismatch, and a throw here is a crash rather than a refusal. */
466
+ export function proofOk(expected, offered) {
467
+ if (typeof offered !== "string" || offered.length !== expected.length) return false;
468
+ return timingSafeEqual(Buffer.from(expected, "utf8"), Buffer.from(offered, "utf8"));
469
+ }
470
+
471
+ /**
472
+ * A fresh challenge for the sensitive operation, separate from the session.
473
+ *
474
+ * LocalSend's lesson, and the reason this exists rather than the transfer
475
+ * riding on the handshake: a session proves who connected, at the moment they
476
+ * connected. A credential leaving the machine is a different question, asked
477
+ * later, sometimes much later — a long-lived connection that was authenticated
478
+ * an hour ago is not a statement about now. So the transfer carries its own
479
+ * round trip, over the same key, naming the account being asked for.
480
+ */
481
+ export function transferChallenge(key, { nonce, accountKey, fromFp, toFp }) {
482
+ return createHmac("sha256", key)
483
+ .update(`ccdeck-lan-transfer-v${PROTOCOL}|${fromFp}|${toFp}|${accountKey}|${nonce}`)
484
+ .digest("hex");
485
+ }
486
+
487
+ // ── the payload ─────────────────────────────────────────────────────────────
488
+
489
+ /**
490
+ * Wrap a share blob so only the group can read it.
491
+ *
492
+ * AES-256-GCM over the group key with a random 12-byte nonce. The alternative
493
+ * — per-peer keys from the X25519 exchange — is better cryptography and worse
494
+ * for this: it would make the encryption depend on a session that the transfer
495
+ * check above deliberately does not trust, and the thing being protected is
496
+ * already group-wide by definition. Anybody who can decrypt this is somebody
497
+ * the passphrase already admits.
498
+ *
499
+ * The additional data binds the ciphertext to the two decks and the account, so
500
+ * a blob captured on one exchange cannot be replayed into another as if it were
501
+ * about something else.
502
+ */
503
+ export function seal(key, plaintext, aad) {
504
+ const iv = randomBytes(12);
505
+ const c = createCipheriv("aes-256-gcm", key, iv);
506
+ c.setAAD(Buffer.from(aad, "utf8"));
507
+ const body = Buffer.concat([c.update(plaintext, "utf8"), c.final()]);
508
+ return { iv: iv.toString("base64"), tag: c.getAuthTag().toString("base64"), body: body.toString("base64") };
509
+ }
510
+
511
+ /** The other half. Returns null rather than throwing on every failure — a
512
+ * wrong tag, a wrong key, a truncated body and a hand-built packet all mean
513
+ * the same thing to the caller, which is "do not use this". */
514
+ export function open(key, { iv, tag, body }, aad) {
515
+ try {
516
+ const d = createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "base64"));
517
+ d.setAAD(Buffer.from(aad, "utf8"));
518
+ d.setAuthTag(Buffer.from(tag, "base64"));
519
+ return Buffer.concat([d.update(Buffer.from(body, "base64")), d.final()]).toString("utf8");
520
+ } catch {
521
+ return null;
522
+ }
523
+ }
524
+
525
+ // ── which copy wins ─────────────────────────────────────────────────────────
526
+
527
+ /**
528
+ * The identity of one account, which is NOT its slot number.
529
+ *
530
+ * claude-swap keys on `(email, organizationUuid)` — same email under two orgs
531
+ * is two accounts on purpose — and assigns slots as max+1 per store, so the
532
+ * account that is 4 here is 2 there. Anything keyed on the number would swap
533
+ * the wrong pair the first time two stores had grown in a different order.
534
+ */
535
+ export function accountKey(email, orgUuid) {
536
+ return `${String(email ?? "").trim().toLowerCase()}@@${String(orgUuid ?? "")}`;
537
+ }
538
+
539
+ /**
540
+ * What to do about one account, given what I have and what a peer has.
541
+ *
542
+ * TWO OUTCOMES, AND NEITHER OVERWRITES SOMETHING THAT WORKS.
543
+ *
544
+ * "add" I do not have this account at all.
545
+ * "heal" I have it and claude-swap has quarantined it — refresh token dead,
546
+ * verified by an invalid_grant from Anthropic rather than guessed
547
+ * from an expiry field — and the peer's copy is alive.
548
+ * null Anything else, which is most of the time.
549
+ *
550
+ * A third outcome was designed and dropped, and the reason is worth keeping
551
+ * because it looks like a feature being given up. It was "replace": take a
552
+ * peer's copy when it is strictly newer than mine, so the freshest copy wins
553
+ * everywhere. Measuring "newer" needs the OAuth payload's `expiresAt`, which
554
+ * means the deck opening a credential — something it does not do, claude-swap
555
+ * owns that — and on macOS that credential is in the Keychain, which a
556
+ * background process cannot reliably read at all.
557
+ *
558
+ * What settled it is not the obstacle. It is that a working credential replaced
559
+ * by a newer working credential changes nothing today. It would only matter if
560
+ * mine were about to die — and a login dies from not being used, so if I am not
561
+ * using it I do not care, and if I am using it the refresh keeps it alive. The
562
+ * whole value is in the account that is already dead.
563
+ *
564
+ * So this never returns an outcome that needs `cswap import --force`, which
565
+ * makes claude-swap's own rule the entire safety property: a plain import skips
566
+ * an account that is present and healthy, and replaces exactly one that is
567
+ * quarantined. A peer cannot overwrite a credential of mine that works, because
568
+ * nothing here ever asks for that.
569
+ */
570
+ export function syncAction(mine, theirs) {
571
+ if (!theirs || !theirs.alive) return null;
572
+ if (!mine) return "add";
573
+ return mine.alive ? null : "heal";
574
+ }
575
+
576
+ /**
577
+ * Everything to do this round, over one peer's manifest.
578
+ *
579
+ * Sorted by account key rather than left in manifest order, so two decks
580
+ * reconciling the same pair of stores do the same work in the same sequence —
581
+ * which is what makes a failure halfway through repeatable rather than a
582
+ * different half each time.
583
+ */
584
+ export function plan(local, remote) {
585
+ const mine = new Map(local.map(a => [a.key, a]));
586
+ const out = [];
587
+ for (const theirs of remote) {
588
+ const action = syncAction(mine.get(theirs.key), theirs);
589
+ if (action) out.push({ key: theirs.key, email: theirs.email, action });
590
+ }
591
+ return out.sort((a, b) => a.key.localeCompare(b.key));
592
+ }
593
+
594
+ /**
595
+ * What this deck publishes about its own accounts — to the group, and only to
596
+ * the group.
597
+ *
598
+ * Three fields, and `alive` is the only one that is a judgement: it is
599
+ * claude-swap's own verdict on this machine's copy, not a guess. An account
600
+ * this deck cannot use is worth nothing to a peer, so saying so plainly is what
601
+ * stops a peer asking for it.
602
+ *
603
+ * Emails are in it, in the clear inside the encrypted channel. That is a
604
+ * deliberate line: a manifest only ever reaches a deck that has already proved
605
+ * it holds the passphrase, and the panel has to name the account it is offering
606
+ * to heal. Hashing the email would buy nothing against that reader and would
607
+ * cost the one thing the row needs to say.
608
+ *
609
+ * `shared` is the user's list. An account absent from it is absent from the
610
+ * manifest entirely — not listed as withheld, which would tell the group that
611
+ * an account exists and is being kept back, and that is itself the fact being
612
+ * kept back.
613
+ */
614
+ export function manifestFor(accounts, shared) {
615
+ const want = new Set(shared);
616
+ return accounts
617
+ .filter(a => want.has(a.key))
618
+ .map(a => ({ key: a.key, email: a.email, alive: !!a.alive }))
619
+ .sort((a, b) => a.key.localeCompare(b.key));
620
+ }