ccdeck 3.29.11 → 3.31.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.
Files changed (178) hide show
  1. package/README.md +4 -3
  2. package/bin/agent-dag.js +67 -118
  3. package/bin/cli/help.js +67 -0
  4. package/bin/cli/login-item.js +182 -0
  5. package/bin/cli/one-shot.js +254 -0
  6. package/bin/cli/package.js +39 -0
  7. package/bin/cli/pulse.js +98 -0
  8. package/bin/cli/restart.js +179 -0
  9. package/bin/cli/screen.js +166 -0
  10. package/bin/cli/second-start.js +120 -0
  11. package/bin/cli/startup.js +459 -0
  12. package/bin/cli/uninstall.js +169 -0
  13. package/bin/deck.js +112 -1559
  14. package/dist/web/assets/BrowserWatchModal-n63GjLhv.js +1 -0
  15. package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
  16. package/dist/web/assets/index-CZJf1Cpp.js +299 -0
  17. package/dist/web/assets/index-D4q1ElXU.css +1 -0
  18. package/dist/web/index.html +2 -2
  19. package/hook/hook.js +15 -14
  20. package/package.json +1 -1
  21. package/release-notes.json +40 -0
  22. package/src/server/account-projects.mjs +25 -11
  23. package/src/server/account-routes.mjs +254 -0
  24. package/src/server/activity.mjs +3 -3
  25. package/src/server/append-budget.mjs +185 -0
  26. package/src/server/append-failures.mjs +147 -0
  27. package/src/server/args.mjs +37 -5
  28. package/src/server/atomic-write.mjs +300 -0
  29. package/src/server/block-notify.mjs +0 -5
  30. package/src/server/boot-lock.mjs +1 -1
  31. package/src/server/brand.mjs +7 -6
  32. package/src/server/browser-react.mjs +10 -2
  33. package/src/server/browser-watch-decks.mjs +146 -0
  34. package/src/server/browser-watch-feed.mjs +69 -0
  35. package/src/server/browser-watch-log.mjs +173 -0
  36. package/src/server/browser-watch-mtime.mjs +18 -0
  37. package/src/server/browser-watch-relay.mjs +112 -0
  38. package/src/server/browser-watch-routes.mjs +172 -0
  39. package/src/server/browser-watch-seen.mjs +127 -0
  40. package/src/server/browser-watch-store.mjs +14 -143
  41. package/src/server/browser-watch.mjs +278 -626
  42. package/src/server/canonical-path.mjs +150 -0
  43. package/src/server/ccusage-failure.mjs +64 -0
  44. package/src/server/ccusage-install.mjs +459 -0
  45. package/src/server/ccusage-runner.mjs +271 -0
  46. package/src/server/ccusage.mjs +21 -756
  47. package/src/server/claude-accounts.mjs +220 -464
  48. package/src/server/claude-collector.mjs +189 -0
  49. package/src/server/claude-dir.mjs +7 -3
  50. package/src/server/claude-identity.mjs +5 -4
  51. package/src/server/claude-reset-credits.mjs +130 -0
  52. package/src/server/claude-verdicts.mjs +174 -0
  53. package/src/server/clear-route.mjs +191 -0
  54. package/src/server/codex-auth.mjs +4 -4
  55. package/src/server/codex-dir.mjs +52 -8
  56. package/src/server/codex-enrichment.mjs +310 -0
  57. package/src/server/codex-quota.mjs +5 -3
  58. package/src/server/codex-translate.mjs +407 -0
  59. package/src/server/codex-usage.mjs +8 -5
  60. package/src/server/codex-watch.mjs +496 -0
  61. package/src/server/cswap-auto-loop.mjs +342 -0
  62. package/src/server/cswap-auto-readers.mjs +303 -0
  63. package/src/server/cswap-auto.mjs +51 -609
  64. package/src/server/cswap-install.mjs +69 -265
  65. package/src/server/cswap-layout.mjs +242 -0
  66. package/src/server/deck-home.mjs +2 -2
  67. package/src/server/deck-prefs.mjs +58 -184
  68. package/src/server/deck-scope.mjs +52 -0
  69. package/src/server/desktop-update-routes.mjs +75 -0
  70. package/src/server/detach.mjs +78 -0
  71. package/src/server/discovery.mjs +298 -0
  72. package/src/server/event-log.mjs +328 -0
  73. package/src/server/event-pipeline.mjs +235 -0
  74. package/src/server/event-ring.mjs +214 -0
  75. package/src/server/event-routes.mjs +352 -0
  76. package/src/server/event-sink.mjs +26 -0
  77. package/src/server/exec-children.mjs +128 -0
  78. package/src/server/exec-not-found.mjs +213 -0
  79. package/src/server/exec-output.mjs +23 -0
  80. package/src/server/exec-spec.mjs +341 -0
  81. package/src/server/exec.mjs +74 -661
  82. package/src/server/health-route.mjs +42 -0
  83. package/src/server/hook-challenge.mjs +37 -0
  84. package/src/server/http-io.mjs +104 -0
  85. package/src/server/index.mjs +174 -7620
  86. package/src/server/install-layout.mjs +506 -0
  87. package/src/server/installer.mjs +79 -608
  88. package/src/server/jsonl-chunks.mjs +139 -0
  89. package/src/server/lan-accounts.mjs +27 -0
  90. package/src/server/lan-beacon.mjs +457 -0
  91. package/src/server/lan-call.mjs +239 -0
  92. package/src/server/lan-copies.mjs +270 -0
  93. package/src/server/lan-deck.mjs +583 -0
  94. package/src/server/lan-dials.mjs +161 -0
  95. package/src/server/lan-engine.mjs +475 -1024
  96. package/src/server/lan-hearing.mjs +49 -0
  97. package/src/server/lan-inbound.mjs +100 -0
  98. package/src/server/lan-invite-offer.mjs +77 -0
  99. package/src/server/lan-invite.mjs +207 -0
  100. package/src/server/lan-lines.mjs +73 -0
  101. package/src/server/lan-manifest.mjs +74 -0
  102. package/src/server/lan-reach.mjs +82 -69
  103. package/src/server/lan-requests.mjs +249 -0
  104. package/src/server/lan-round-record.mjs +45 -0
  105. package/src/server/lan-round-timer.mjs +62 -0
  106. package/src/server/lan-routes.mjs +143 -0
  107. package/src/server/lan-serve.mjs +112 -0
  108. package/src/server/lan-socket.mjs +243 -925
  109. package/src/server/lan-sync.mjs +58 -1026
  110. package/src/server/lan-tailnet-poll.mjs +57 -0
  111. package/src/server/lan-turns.mjs +80 -0
  112. package/src/server/lan-wire.mjs +620 -0
  113. package/src/server/lifecycle.mjs +421 -0
  114. package/src/server/listen.mjs +110 -0
  115. package/src/server/live-decks.mjs +138 -0
  116. package/src/server/load-average.mjs +32 -0
  117. package/src/server/log-election.mjs +235 -0
  118. package/src/server/log-replay.mjs +374 -0
  119. package/src/server/log-writer.mjs +72 -557
  120. package/src/server/login-service.mjs +3 -2
  121. package/src/server/macmon.mjs +1 -1
  122. package/src/server/memory-files.mjs +163 -0
  123. package/src/server/memory-metrics.mjs +178 -0
  124. package/src/server/metrics-history.mjs +94 -0
  125. package/src/server/metrics-run.mjs +100 -0
  126. package/src/server/music-routes.mjs +134 -0
  127. package/src/server/network-metrics.mjs +5 -3
  128. package/src/server/network-sampler.mjs +256 -0
  129. package/src/server/npm-latest.mjs +385 -0
  130. package/src/server/npm-upgrade.mjs +352 -0
  131. package/src/server/npx.mjs +41 -7
  132. package/src/server/output-watch.mjs +5 -4
  133. package/src/server/pinned-build.mjs +110 -0
  134. package/src/server/prefs-give-back.mjs +206 -0
  135. package/src/server/prefs-path.mjs +14 -0
  136. package/src/server/prefs-refusal.mjs +86 -0
  137. package/src/server/prefs-routes.mjs +99 -0
  138. package/src/server/prefs-set-aside.mjs +86 -0
  139. package/src/server/prefs-state.mjs +70 -0
  140. package/src/server/process-command.mjs +104 -0
  141. package/src/server/process-list.mjs +547 -0
  142. package/src/server/provider-status.mjs +307 -0
  143. package/src/server/pulse-line.mjs +132 -0
  144. package/src/server/purge-key.mjs +7 -7
  145. package/src/server/quota-cli.mjs +174 -0
  146. package/src/server/quota-oauth.mjs +190 -0
  147. package/src/server/quota-shape.mjs +229 -0
  148. package/src/server/quota-store-resets.mjs +206 -0
  149. package/src/server/quota.mjs +123 -509
  150. package/src/server/relay-guard.mjs +3 -3
  151. package/src/server/request-gates.mjs +441 -0
  152. package/src/server/restart-note.mjs +180 -0
  153. package/src/server/retire-sound-hook.mjs +3 -3
  154. package/src/server/ring-bounds.mjs +215 -0
  155. package/src/server/running-deck.mjs +1 -1
  156. package/src/server/running-version.mjs +24 -0
  157. package/src/server/self-update.mjs +39 -1371
  158. package/src/server/session-enrichment.mjs +622 -0
  159. package/src/server/session-read-gate.mjs +49 -0
  160. package/src/server/session-tracking.mjs +242 -0
  161. package/src/server/sse-clients.mjs +222 -0
  162. package/src/server/stale-discovery.mjs +30 -0
  163. package/src/server/static-serve.mjs +81 -0
  164. package/src/server/supervisor.mjs +73 -12
  165. package/src/server/system-metrics.mjs +169 -1935
  166. package/src/server/term.mjs +11 -249
  167. package/src/server/thermal-metrics.mjs +520 -0
  168. package/src/server/thermal-sampler.mjs +204 -0
  169. package/src/server/token-redact.mjs +123 -0
  170. package/src/server/transcript-gate.mjs +107 -0
  171. package/src/server/transcript-scan.mjs +563 -0
  172. package/src/server/usage-routes.mjs +108 -0
  173. package/src/server/uv-bootstrap.mjs +1 -1
  174. package/src/server/wordmark.mjs +127 -0
  175. package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
  176. package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
  177. package/dist/web/assets/index-Ck2RAla3.css +0 -1
  178. package/dist/web/assets/index-jmrcEsCj.js +0 -298
@@ -1,7 +1,14 @@
1
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.
2
+ // group. No sockets, no timers, no filesystem — those live in lan-beacon.mjs and
3
+ // lan-socket.mjs, and everything here is a pure function so the suite can run
4
+ // all of it on one machine, which is the only machine there is.
5
+ //
6
+ // Three files beside it hold the rest of the rules, pure in the same way: the
7
+ // keys, proofs and seals two decks talk under once one has dialled
8
+ // (lan-wire.mjs), which copy of a login wins and what a deck says about the
9
+ // ones it holds (lan-copies.mjs), and the invite (lan-invite.mjs). This file
10
+ // re-exports the first two, so what imports a rule from here still finds it;
11
+ // the invite builds on this file and is imported from its own.
5
12
  //
6
13
  // WHAT THIS IS FOR. An account's login dies on a machine that has not used it
7
14
  // for a while, while the same account stays alive on a machine that has. Today
@@ -47,7 +54,8 @@
47
54
  // Its other lesson is about WHERE a check goes. The group secret authenticates
48
55
  // the channel; LocalSend still puts a separate PIN on the transfer endpoint
49
56
  // itself, so the sensitive operation carries its own proof rather than
50
- // inheriting one from a session that may be old. See `transferChallenge`.
57
+ // inheriting one from a session that may be old. See `transferChallenge` in
58
+ // lan-wire.mjs.
51
59
  //
52
60
  // KDE CONNECT had CVE-2020-26164: several issues in the daemon that listens on
53
61
  // the LAN, in a design whose protocol was fine. The lesson is not "do TLS
@@ -92,24 +100,35 @@
92
100
  // implementing. The one PAKE package on npm was last published in 2022, has 37
93
101
  // downloads a week and four dependencies of its own; running unmaintained
94
102
  // cryptography to protect live credentials is worse than the plain construction
95
- // below.
96
- import {
97
- createCipheriv, createDecipheriv, createHash, createHmac, createPrivateKey,
98
- createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes,
99
- timingSafeEqual,
100
- } from "node:crypto";
103
+ // in lan-wire.mjs.
104
+ import { createHash } from "node:crypto";
101
105
  import os from "node:os";
106
+ // What two decks say once one has dialled the other — the keys, the proofs and
107
+ // the seals — is lan-wire.mjs's, and so is PROTOCOL, which the beacon below
108
+ // carries: every label there is derived under it, and that file cannot import
109
+ // from this one, which re-exports it. Re-exported, so what imports them from
110
+ // here still does.
111
+ import { PROTOCOL } from "./lan-wire.mjs";
112
+ export {
113
+ challengeFor, credentialAad, ephemeralPair, fingerprint, frameChannel, frameKeys, handshakeTranscript,
114
+ identityFrom, mixesEphemeral, open, proof, proofOk, readChallenge, readEphemeral, readPub, seal, sealsFrames,
115
+ sessionKey, transferChallenge,
116
+ EPHEMERAL, PROTOCOL, SEALS,
117
+ } from "./lan-wire.mjs";
118
+ // Which copy of a login wins, what a deck tells its peers about the logins it
119
+ // holds, and why one did not move, read nothing of the beacon below or of the
120
+ // wire — see lan-copies.mjs. Re-exported, so what imports them from here still
121
+ // does.
122
+ export {
123
+ accountKey, currentFor, heardCurrent, manifestFor, offered, onePerKey, peerWhy, plan, slotFor, syncAction,
124
+ HERE, SENDER_UNREADABLE,
125
+ } from "./lan-copies.mjs";
102
126
 
103
127
  /** Marks a packet as ours before anything reads a field of it. Four bytes of
104
128
  * ASCII rather than a number, so a stray packet on the port is recognisable
105
129
  * in a capture as "something else's" rather than as a corrupt one of ours. */
106
130
  export const MAGIC = "CCDK";
107
131
 
108
- /** The wire format. Bumped when a field changes meaning, never when one is
109
- * added — a reader that does not know a field ignores it, which is what makes
110
- * a deck a version behind still discoverable. */
111
- export const PROTOCOL = 2;
112
-
113
132
  /** The most a beacon may be. A beacon is a name, a port and two hashes; the
114
133
  * worst realistic case is under 300 bytes. Refusing at 512 before parsing is
115
134
  * the cheapest possible answer to a packet built to be expensive to read. */
@@ -138,24 +157,6 @@ export const MAX_MANIFEST_BYTES = 128 * 1024;
138
157
 
139
158
  // ── identity ────────────────────────────────────────────────────────────────
140
159
 
141
- /**
142
- * What a deck calls itself on the wire, given its long-term public key.
143
- *
144
- * A hash of the key rather than the key: it is short enough to read out over a
145
- * phone, it is stable across restarts, and — the part that matters — it is the
146
- * only identity claim in a beacon that cannot be forged, because a deck that
147
- * cannot do the handshake cannot use somebody else's fingerprint for anything.
148
- *
149
- * Twelve hex characters, in threes. 48 bits is far past what an accident
150
- * reaches on one network, and a person can compare four groups at a glance
151
- * where they cannot compare sixty-four characters. Syncthing and LocalSend both
152
- * make the same trade at a similar length.
153
- */
154
- export function fingerprint(publicKeyRaw) {
155
- const hex = createHash("sha256").update(publicKeyRaw).digest("hex").slice(0, 12);
156
- return hex.replace(/(.{3})(?=.)/g, "$1-");
157
- }
158
-
159
160
  /**
160
161
  * The name a deck shows, cleaned.
161
162
  *
@@ -189,434 +190,8 @@ export function cleanName(raw, fallback = "unnamed deck") {
189
190
  return stripped ? [...stripped].slice(0, MAX_NAME).join("") : fallback;
190
191
  }
191
192
 
192
- // ── this deck's own key ─────────────────────────────────────────────────────
193
-
194
- /**
195
- * Turn a passphrase into the key everything else hangs off.
196
- *
197
- * scrypt rather than a plain hash, and the parameters are the point: this is a
198
- * passphrase a person typed, on a network where anybody can capture the
199
- * handshake and grind at it offline. N=2^15 costs about 100ms and 32 MB per
200
- * guess here, which is nothing once per deck start and is the difference
201
- * between a weak passphrase falling in seconds and falling in weeks.
202
- *
203
- * The salt is FIXED and that is deliberate, not an oversight. A per-deck salt
204
- * would mean two decks with the same passphrase deriving different keys, which
205
- * is the one thing this must never do. What a salt buys — that one rainbow
206
- * table cannot cover every deployment — is bought instead by the passphrase
207
- * being generated at 128 bits by default; a table against a random 128-bit
208
- * secret is not a thing that exists.
209
- */
210
- /**
211
- * A deck's own long-term identity.
212
- *
213
- * X25519, because the only thing it is ever used for is agreeing a session key
214
- * with a peer — never a signature — and node has it built in. The private half
215
- * lives in prefs.json, which is written 0600 for exactly this reason; the
216
- * public half is what a peer pins, and its fingerprint is what a person
217
- * compares.
218
- */
219
- function newKeypair() {
220
- const { publicKey, privateKey } = generateKeyPairSync("x25519");
221
- return {
222
- secret: privateKey.export({ type: "pkcs8", format: "der" }).toString("base64"),
223
- pub: publicKey.export({ type: "spki", format: "der" }).toString("base64"),
224
- };
225
- }
226
-
227
- /**
228
- * The keypair on disk, or a fresh one when there is none or it is unusable.
229
- *
230
- * A corrupt secret is not an error anybody can act on mid-session, and refusing
231
- * to start would take the whole feature away over one bad string — so it is
232
- * replaced. The cost of replacing it is stated rather than hidden: this deck
233
- * gets a new fingerprint, so every peer that had pinned the old one sees a
234
- * stranger and asks its owner to accept again.
235
- */
236
- export function identityFrom(secret) {
237
- if (typeof secret === "string" && secret !== "") {
238
- try {
239
- const priv = createPrivateKey({ key: Buffer.from(secret, "base64"), format: "der", type: "pkcs8" });
240
- const pub = createPublicKey(priv).export({ type: "spki", format: "der" });
241
- return { secret, pub: pub.toString("base64"), fp: fingerprint(pub), fresh: false };
242
- } catch { /* unusable; fall through and make one */ }
243
- }
244
- const made = newKeypair();
245
- return { ...made, fp: fingerprint(Buffer.from(made.pub, "base64")), fresh: true };
246
- }
247
-
248
- /**
249
- * The key two decks use for one connection, and for nothing else.
250
- *
251
- * X25519 to a shared secret, then HKDF over the transcript — both fingerprints
252
- * and both challenges, in a fixed order — so the key is bound to THIS exchange.
253
- * A recording of an old one derives a different key and proves nothing.
254
- *
255
- * PER CONNECTION, never stored. The old design sealed credentials under one
256
- * long-lived group key, which meant a single recorded transfer stayed readable
257
- * to anybody who ever learned the passphrase.
258
- *
259
- * AND FORWARD-SECRET BETWEEN TWO DECKS THAT BOTH SAY SO, which until #1120 no
260
- * connection was. Without `ephemeral` this is the key every deck derived
261
- * before: X25519 between the two decks' LONG-TERM keys, over a transcript
262
- * whose every field crossed the network in the clear. So whoever recorded a
263
- * connection and later took either deck's private key — a 0600 file, and also
264
- * a file that rides along in backups and in a `~/.claude` somebody copied —
265
- * could derive that connection's key and open all of it: every frame after the
266
- * handshake, the version card, and each credential a `have` carried, logins
267
- * since rotated or removed included, and the other deck's half of the
268
- * conversation with them.
269
- *
270
- * With `ephemeral`, each end also brings a key pair made for this connection
271
- * alone (ephemeralPair), and the key is HKDF over four X25519 results rather
272
- * than one. Which four is Noise's KK pattern (noiseprotocol.org/noise.html
273
- * §7.5): both static keys known in advance, `-> e, es, ss` and `<- e, ee, se`.
274
- * How they are combined is Signal's X3DH (§2.2, §3.3): concatenated in a fixed
275
- * order, each a fixed 32 bytes so no two orderings read alike, into one HKDF —
276
- * without X3DH's run of 0xFF bytes in front, which it needs only because its
277
- * keys also sign, and a deck's never do. HKDF's info is the transcript, the
278
- * way TLS 1.3 derives every secret over the transcript of the handshake that
279
- * made it (RFC 8446 §7.1).
280
- *
281
- * ss static × static — the one term there was, and still what makes the key
282
- * need a long-term key at all: whoever held both ephemeral halves and
283
- * neither static key would have ee, es and se, and not this.
284
- * ee ephemeral × ephemeral — the forward secrecy, which is #1120. Neither
285
- * deck keeps its half past this call (see lan-socket.mjs), so once a
286
- * connection is over, no key anybody still holds — both long-term keys
287
- * included — recomputes this one.
288
- * es the dialler's ephemeral × the answerer's static key, and
289
- * se the dialler's static key × the answerer's ephemeral. Neither adds
290
- * forward secrecy; each stops a stolen key being worn as SOMEBODY ELSE,
291
- * which ss cannot. Whoever holds B's private key computes DH(b, A) just
292
- * as A does, so with ss and ee alone they could dial B as A — or as any
293
- * deck B has paired — and B would prove itself to them and hand over
294
- * every login it shares. With se they need A's key or B's ephemeral as
295
- * well, and hold neither. es is the same guard the other way round, for
296
- * a deck whose own key is stolen and which dials out. Noise calls it
297
- * resistance to key-compromise impersonation (§7.7, source property 2).
298
- *
299
- * `role` says which end this is — "caller" for the deck that dialled — because
300
- * each end computes es and se from different halves and both must land in the
301
- * same place. Named by who dialled and never by fingerprint, for frameKeys'
302
- * reason: a copied ~/.claude gives both ends one fingerprint.
303
- *
304
- * Its own label in the info string, so a key from this schedule and one from
305
- * the other are never the same key, whatever two transcripts look like. The
306
- * four results are zeroed as soon as HKDF has read them; the ephemeral private
307
- * half never leaves the KeyObject it was made in, and nothing writes it
308
- * anywhere. That is all the forgetting a JavaScript process can promise, and
309
- * it is what forward secrecy asks of it: nothing of the ephemeral side left to
310
- * find once the connection is gone.
311
- *
312
- * THROWS on a key X25519 cannot use: a type other than X25519, which readPub
313
- * now refuses before this, or one of the few low-order points, whose shared
314
- * secret is all zeros (RFC 7748 §6.1) and which OpenSSL refuses rather than
315
- * returns. The socket layer refuses the handshake when it does.
316
- */
317
- export function sessionKey(secret, peerPub, transcript, ephemeral = null) {
318
- const priv = createPrivateKey({ key: Buffer.from(secret, "base64"), format: "der", type: "pkcs8" });
319
- const theirs = createPublicKey({ key: Buffer.from(peerPub, "base64"), format: "der", type: "spki" });
320
- if (!ephemeral) {
321
- const shared = diffieHellman({ privateKey: priv, publicKey: theirs });
322
- return Buffer.from(hkdfSync("sha256", shared, Buffer.alloc(0),
323
- Buffer.from(`ccdeck-lan-v${PROTOCOL}|${transcript}`, "utf8"), 32));
324
- }
325
- const { role, priv: mine, peer } = ephemeral;
326
- if (role !== "caller" && role !== "listener") throw new Error(`no such end: ${role}`);
327
- const theirsNow = createPublicKey({ key: Buffer.from(peer, "base64"), format: "der", type: "spki" });
328
- const made = [];
329
- const dh = (privateKey, publicKey) => {
330
- const out = diffieHellman({ privateKey, publicKey });
331
- made.push(out);
332
- return out;
333
- };
334
- try {
335
- const ss = dh(priv, theirs);
336
- const ee = dh(mine, theirsNow);
337
- const mineWithTheirStatic = dh(mine, theirs);
338
- const staticWithTheirs = dh(priv, theirsNow);
339
- const [es, se] = role === "caller"
340
- ? [mineWithTheirStatic, staticWithTheirs]
341
- : [staticWithTheirs, mineWithTheirStatic];
342
- const ikm = Buffer.concat([ss, ee, es, se]);
343
- made.push(ikm);
344
- return Buffer.from(hkdfSync("sha256", ikm, Buffer.alloc(0),
345
- Buffer.from(`ccdeck-lan-v${PROTOCOL}|${EPHEMERAL}|${transcript}`, "utf8"), 32));
346
- } finally {
347
- for (const b of made) b.fill(0);
348
- }
349
- }
350
-
351
- /**
352
- * The string both sides bind their session key to.
353
- *
354
- * One definition, used by the caller and the listener, because a transcript the
355
- * two build differently is a handshake that never agrees and a bug that only
356
- * appears between two machines. Caller first, always, so the order does not
357
- * depend on which end is asking.
358
- *
359
- * BOTH EPHEMERAL KEYS TOO, when the two decks mix them (#1120), as they
360
- * arrived. Not only inside ee: this string is HKDF's info in sessionKey, and
361
- * each proof is an HMAC under the key that comes out, so the proofs the static
362
- * keys make cover these two strings by name. A middleman who swaps either key
363
- * for one of their own has changed what one end proves over, and that end's
364
- * proof fails at the other whatever the arithmetic would have done. Noise
365
- * mixes every public key it sends into its handshake hash for the same reason
366
- * (§5.3). Base64 has no `|`, and readEphemeral takes a key only in the one
367
- * spelling its bytes encode to, so no field can spill into the next.
368
- *
369
- * Given at all, both are written. One missing prints as `undefined` and
370
- * matches nothing at the other end; it does not fall back to the four-field
371
- * string, because a transcript that quietly dropped a key is the downgrade this
372
- * is here to refuse.
373
- */
374
- export function handshakeTranscript(callerFp, listenerFp, callerChallenge, listenerChallenge,
375
- callerEphemeral, listenerEphemeral) {
376
- const fixed = `${callerFp}|${listenerFp}|${callerChallenge}|${listenerChallenge}`;
377
- return callerEphemeral === undefined && listenerEphemeral === undefined
378
- ? fixed
379
- : `${fixed}|${callerEphemeral}|${listenerEphemeral}`;
380
- }
381
-
382
- /**
383
- * A public key somebody sent, or null.
384
- *
385
- * It arrives from the network before anything has been agreed, so it is checked
386
- * for being an X25519 public key at all rather than trusted to be one — a
387
- * string that is not gets a refusal here instead of a throw three frames later.
388
- *
389
- * AN X25519 KEY, NOT MERELY A KEY, and until #1120 it only checked the second.
390
- * Any SPKI parsed, so an Ed25519 or a P-256 key came through as a deck's
391
- * public key, and the throw this promised to prevent happened one line later,
392
- * in sessionKey: `Incompatible key types for Diffie-Hellman`, inside the
393
- * listener's `data` handler, before anybody was trusted. Nothing catches a
394
- * throw there. Measured on Node 24 with a listener in a process of its own:
395
- * one `hello` carrying an Ed25519 key, and the process exited with code 1.
396
- * No deck has ever sent anything but X25519 (newKeypair), so no deck is
397
- * refused by this.
398
- */
399
- export function readPub(raw) {
400
- const der = x25519(raw);
401
- return der ? { pub: raw, fp: fingerprint(der) } : null;
402
- }
403
-
404
- /**
405
- * An ephemeral public key somebody sent, or null: checked exactly as readPub
406
- * checks a static one, and then held to one spelling.
407
- *
408
- * The spelling matters here and not for a static key, which only ever reaches
409
- * the transcript as its fingerprint. This string goes in as it arrived, among
410
- * fields joined by `|` with no lengths, and base64 decoding skips what it does
411
- * not understand, `|` included — so a key that decoded to the right bytes
412
- * could still carry a separator. Only the exact text those bytes encode to is
413
- * taken.
414
- */
415
- export function readEphemeral(raw) {
416
- const der = x25519(raw);
417
- return der && der.toString("base64") === raw ? raw : null;
418
- }
419
-
420
- /** The DER of an X25519 public key, from the base64 a deck sends, or null. */
421
- function x25519(raw) {
422
- if (typeof raw !== "string" || raw.length < 40 || raw.length > 128) return null;
423
- try {
424
- const der = Buffer.from(raw, "base64");
425
- return createPublicKey({ key: der, format: "der", type: "spki" }).asymmetricKeyType === "x25519" ? der : null;
426
- } catch { return null; }
427
- }
428
-
429
- /**
430
- * A key pair for one connection and no other (#1120).
431
- *
432
- * The private half stays the KeyObject it was made as — never exported, never
433
- * a string, which JavaScript could not wipe — and the end that made it lets go
434
- * of it the moment sessionKey has used it. The public half is what travels, as
435
- * the same base64 SPKI a static key travels as.
436
- */
437
- export function ephemeralPair() {
438
- const { publicKey, privateKey } = generateKeyPairSync("x25519");
439
- return { priv: privateKey, pub: publicKey.export({ type: "spki", format: "der" }).toString("base64") };
440
- }
441
-
442
- // ── the invite ──────────────────────────────────────────────────────────────
443
- //
444
- // ONE PIECE OF TEXT THAT CARRIES EVERYTHING, and it exists because of a real
445
- // afternoon. Two people, two machines, a typed address, and `handshake timed
446
- // out` — because the address on screen was the LAN one and the only route
447
- // between them was a VPN. Neither of them could have known which of the two to
448
- // use; the deck could not know either, and printing both made it their problem.
449
- //
450
- // So the invite carries EVERY address this deck has, and the deck that receives
451
- // it tries them in turn. Nobody has to know which one is routable, because the
452
- // only machine that can find out is the one doing the reaching.
453
- //
454
- // AND IT CARRIES A CODE, which is what turns two presses into one. A deck that
455
- // proves it holds the invite is not a stranger asking to be let in — it is
456
- // somebody the owner of this machine handed a token to. So it is paired on
457
- // arrival and nobody presses accept. That also closes the gap trust-on-first-use
458
- // left open: the first contact is verified rather than believed.
459
- //
460
- // THE TOKEN IS THE SECRET, said plainly rather than implied. Whoever holds it
461
- // can pair with this deck until it expires. It is a door key with a timer, not
462
- // an identifier — which is why it is short-lived, single-purpose, and replaced
463
- // the moment it is used.
464
-
465
- /** How long an invite is good for. Long enough to paste into a chat and have
466
- * somebody read it, short enough that one left in a channel is not a way in
467
- * tomorrow. */
468
- export const INVITE_MS = 10 * 60 * 1000;
469
-
470
- /** The most addresses an invite may carry. A machine with a VPN, a second card
471
- * and a container bridge has three or four; ten is far past honest and keeps a
472
- * hand-built token from being a way to make this deck dial a list. */
473
- export const MAX_INVITE_ADDRS = 10;
474
-
475
- /** What the token starts with, so a reader can tell at a glance what they have
476
- * been sent and a wrong paste is refused before it is parsed. */
477
- export const INVITE_PREFIX = "ccdeck1.";
478
-
479
- const b64url = buf => buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
480
- const unb64url = str => Buffer.from(str.replace(/-/g, "+").replace(/_/g, "/"), "base64");
481
-
482
- /**
483
- * A code somebody could read out loud if they had to.
484
- *
485
- * Six digits, from rejection sampling rather than modulo — the same argument
486
- * the word list made and for the same reason: a bias here is a bias in the one
487
- * number that decides whether a stranger can pair.
488
- */
489
- export function inviteCode(rand = randomBytes) {
490
- let out = "";
491
- while (out.length < 6) {
492
- for (const b of rand(12)) {
493
- if (b >= 250) continue; // 250 = 25 * 10, the largest clean multiple
494
- out += String(b % 10);
495
- if (out.length === 6) break;
496
- }
497
- }
498
- return out;
499
- }
500
-
501
- /** Mint one. `addrs` are already `host:port` strings, because which addresses
502
- * this machine has is not a question this file can answer. */
503
- export function mintInvite({ addrs, name, now = Date.now(), code = inviteCode() } = {}) {
504
- const list = (Array.isArray(addrs) ? addrs : []).filter(a => typeof a === "string" && a).slice(0, MAX_INVITE_ADDRS);
505
- if (!list.length) return null;
506
- const expiresAt = now + INVITE_MS;
507
- // `pb` IS THE MINTER SAYING IT WILL PROVE THE CODE BACK, and it is here
508
- // rather than in PROTOCOL because PROTOCOL is the whole wire. A deck of this
509
- // version answers the handshake with `inviteProofBack`; every deck before it
510
- // speaks the same PROTOCOL and does not. The joiner has no other way to tell
511
- // the two apart, and a joiner that demanded the proof from both would break
512
- // invites exactly across a version boundary — which is when people use them.
513
- //
514
- // It is not a security decision the far end gets to make. The token is text
515
- // the minter handed to the joiner out of band, in the same breath as the
516
- // code; whoever can rewrite it already holds the code and has no use for
517
- // this. What the flag can do is say "the deck that minted me is old", and the
518
- // worst that buys is the behaviour shipped today.
519
- const body = { v: PROTOCOL, a: list, n: cleanName(name), c: code, x: expiresAt, pb: 1 };
520
- return { token: INVITE_PREFIX + b64url(Buffer.from(JSON.stringify(body), "utf8")), code, expiresAt };
521
- }
522
-
523
- /**
524
- * Read one somebody pasted, or null.
525
- *
526
- * REFUSES BEFORE IT UNDERSTANDS, in the order that costs least: the prefix,
527
- * then the length, then the decode, then the shape, then the clock. A token is
528
- * text a person pasted out of a chat window, so it is as untrusted as anything
529
- * that arrives on a socket — and every failure here is the same answer to the
530
- * reader, which is "that is not an invite".
531
- */
532
- export function readInvite(raw, now = Date.now()) {
533
- if (typeof raw !== "string") return null;
534
- const text = raw.trim();
535
- if (!text.startsWith(INVITE_PREFIX)) return null;
536
- if (text.length > 2048) return null;
537
- let body = null;
538
- try { body = JSON.parse(unb64url(text.slice(INVITE_PREFIX.length)).toString("utf8")); }
539
- catch { return null; }
540
- if (!body || typeof body !== "object" || Array.isArray(body)) return null;
541
- if (body.v !== PROTOCOL) return null;
542
- if (typeof body.c !== "string" || !/^[0-9]{6}$/.test(body.c)) return null;
543
- if (typeof body.x !== "number" || !Number.isFinite(body.x)) return null;
544
- // Expired is its own answer and the caller says so — "that invite has run
545
- // out, ask for a new one" is a different instruction from "that is not an
546
- // invite", and a reader who cannot tell them apart retypes the same thing.
547
- const expired = body.x <= now;
548
- const addrs = (Array.isArray(body.a) ? body.a : [])
549
- .filter(a => typeof a === "string")
550
- .slice(0, MAX_INVITE_ADDRS)
551
- .map(a => ({ raw: a, at: a.lastIndexOf(":") }))
552
- .filter(p => p.at > 0)
553
- .map(p => ({ addr: p.raw.slice(0, p.at), port: Number(p.raw.slice(p.at + 1)) }))
554
- .filter(p => p.addr && Number.isInteger(p.port) && p.port > 0 && p.port < 65_536);
555
- if (!addrs.length) return null;
556
- return {
557
- addrs, name: cleanName(body.n), code: body.c, expiresAt: body.x, expired,
558
- // Whether the deck that minted this will prove it holds the code — see
559
- // mintInvite. Absent means a deck older than that, and the caller degrades
560
- // to what it did before rather than refusing the token.
561
- provesBack: body.pb === 1,
562
- };
563
- }
564
-
565
- /**
566
- * The proof that a caller is holding the invite, over the same transcript the
567
- * session key is bound to.
568
- *
569
- * Not the code itself on the wire. A code that travelled in the clear would be
570
- * replayable by anybody who watched one exchange, and this is the value that
571
- * decides whether a deck is paired without anybody pressing anything.
572
- */
573
- export function inviteProof(code, transcript) {
574
- return createHmac("sha256", `ccdeck-invite-v${PROTOCOL}`).update(`${code}|${transcript}`).digest("hex");
575
- }
576
-
577
- /**
578
- * The same proof, the other way round: the deck that MINTED the invite showing
579
- * the caller it holds the code too.
580
- *
581
- * WHY THE CALLER'S PROOF WAS NOT ENOUGH, and it is the whole of #971. The code
582
- * travelled in one message, caller to listener, and the reply carried a session
583
- * proof — an HMAC over an ECDH against whatever public key the responder had
584
- * just presented. That proves the responder holds the private half of the key
585
- * it just chose. Anything with a socket holds the private half of a key it just
586
- * chose. `join` passes no pin, by definition, so the impostor check in
587
- * connectToPeer is inert on this path and whatever answered first was written
588
- * into the trusted list.
589
- *
590
- * A SEPARATE KEY STRING RATHER THAN A DIRECTION FIELD, because the two
591
- * transcripts are byte-identical — handshakeTranscript takes the caller and the
592
- * listener in a fixed order, so both sides compute the same string. Reusing
593
- * `inviteProof` here would let the listener's reply be the caller's own `auth`
594
- * frame echoed back, which is precisely the party this is meant to exclude.
595
- */
596
- export function inviteProofBack(code, transcript) {
597
- return createHmac("sha256", `ccdeck-invite-back-v${PROTOCOL}`).update(`${code}|${transcript}`).digest("hex");
598
- }
599
-
600
193
  // ── the beacon ──────────────────────────────────────────────────────────────
601
194
 
602
- /**
603
- * What a deck shouts, which anybody on the network can hear.
604
- *
605
- * NO ACCOUNTS AND NO SECRET. This is the packet a stranger on the same wifi
606
- * receives, so what is in it is what a stranger learns: that a ccdeck is here,
607
- * what it calls itself, and a hash of its public key. Not which Anthropic
608
- * accounts exist on the machine, not how many, and nothing that could be ground
609
- * at offline, because there is nothing in it derived from a secret.
610
- *
611
- * IT NO LONGER CARRIES A GROUP TAG, and that is the shape of the whole feature
612
- * changing rather than a field going away. The tag let a deck drop a packet
613
- * from outside its group before any handshake existed to attack, which is a
614
- * real property and was worth having — but it only worked when both people
615
- * held the same passphrase, and a passphrase neither of them can see is exactly
616
- * what nobody could get right. What replaces it is later and cheaper: a beacon
617
- * from a deck this one does not trust becomes a row somebody can accept, and
618
- * nothing at all happens until they do.
619
- */
620
195
  /**
621
196
  * WHICH MACHINE THIS IS, as opposed to which PROCESS or which KEY.
622
197
  *
@@ -661,6 +236,24 @@ export function machineName(hostname) {
661
236
  return /^\d+(\.\d+){3}$/.test(h) ? h : h.split(".")[0];
662
237
  }
663
238
 
239
+ /**
240
+ * What a deck shouts, which anybody on the network can hear.
241
+ *
242
+ * NO ACCOUNTS AND NO SECRET. This is the packet a stranger on the same wifi
243
+ * receives, so what is in it is what a stranger learns: that a ccdeck is here,
244
+ * what it calls itself, and a hash of its public key. Not which Anthropic
245
+ * accounts exist on the machine, not how many, and nothing that could be ground
246
+ * at offline, because there is nothing in it derived from a secret.
247
+ *
248
+ * IT NO LONGER CARRIES A GROUP TAG, and that is the shape of the whole feature
249
+ * changing rather than a field going away. The tag let a deck drop a packet
250
+ * from outside its group before any handshake existed to attack, which is a
251
+ * real property and was worth having — but it only worked when both people
252
+ * held the same passphrase, and a passphrase neither of them can see is exactly
253
+ * what nobody could get right. What replaces it is later and cheaper: a beacon
254
+ * from a deck this one does not trust becomes a row somebody can accept, and
255
+ * nothing at all happens until they do.
256
+ */
664
257
  export function beaconPayload({ name, fp, port, instance, host }) {
665
258
  return {
666
259
  m: MAGIC,
@@ -719,13 +312,13 @@ export function readBeacon(buf, { maxBytes = MAX_BEACON_BYTES } = {}) {
719
312
  }
720
313
 
721
314
  /**
722
- * Whether a beacon is one this deck should act on.
315
+ * Whether a beacon is one this deck should act on, and if not, why not.
723
316
  *
724
- * Three separate refusals and they mean different things, which is why this
725
- * returns a reason rather than a boolean — the panel says "found a deck that is
726
- * not in your group" where it would otherwise say nothing at all, and "not in
727
- * your group" is the single most useful sentence when somebody has mistyped the
728
- * passphrase on one machine.
317
+ * A reason rather than a boolean, because each answer asks for something
318
+ * different: a packet that is not a beacon, this deck's own echo, or another
319
+ * deck on this computer is nothing to act on; a deck wearing this one's key
320
+ * (`id-clash`) is a key to replace; a paired deck is a peer to note; and a
321
+ * stranger is a row somebody can accept.
729
322
  *
730
323
  * Self-recognition is by fingerprint, not by address: a deck hears its own
731
324
  * broadcast on every interface it owns, and filtering by address would need a
@@ -936,12 +529,6 @@ export function notePeer(peers, beacon, addr, now, via = "lan") {
936
529
  return { peer: next, changed, restarted: !!prev && prev.instance !== next.instance };
937
530
  }
938
531
 
939
- /** Whether a peer counts as here right now. Separate from being in the list at
940
- * all — see notePeer. */
941
- export function isPresent(peer, now) {
942
- return peer.lastSeen != null && now - peer.lastSeen < PRESENT_MS;
943
- }
944
-
945
532
  /** How long a deck stays in the list after its last beacon. See stillListed. */
946
533
  export const FORGET_MS = 24 * 60 * 60_000;
947
534
 
@@ -967,558 +554,3 @@ export function stillListed(peer, now, forgetMs = FORGET_MS) {
967
554
  if (peer?.manual) return true;
968
555
  return peer?.lastSeen != null && now - peer.lastSeen < forgetMs;
969
556
  }
970
-
971
- /**
972
- * The list as the panel shows it: present decks first, then by name.
973
- *
974
- * Not by last-seen within each group, which was the obvious ordering and is
975
- * wrong: a list that reorders itself every thirty seconds as beacons land in
976
- * whatever order the network delivers them is a list nobody can point at.
977
- * Name is stable, and the presence split is the only thing that should move a
978
- * row.
979
- */
980
- export function peerRows(peers, now) {
981
- return [...peers.values()]
982
- .map(p => ({ ...p, present: isPresent(p, now) }))
983
- .sort((a, b) => (Number(b.present) - Number(a.present))
984
- || a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
985
- || a.fp.localeCompare(b.fp));
986
- }
987
-
988
- // ── the handshake ───────────────────────────────────────────────────────────
989
-
990
- /**
991
- * The proof a deck offers to show it holds the group passphrase.
992
- *
993
- * Challenge-response over the derived key rather than sending anything derived
994
- * from the passphrase directly, so a recording of one exchange is worth nothing
995
- * for the next: the challenge is fresh random from the side being convinced.
996
- *
997
- * BOTH SIDES PROVE. The obvious version has the caller prove itself to the
998
- * listener, which stops a stranger reading a manifest and stops nothing else —
999
- * a stranger can still stand up a listener, wait for a real deck to connect,
1000
- * and be handed one. So the response covers a challenge from each side and the
1001
- * initiator checks the answer with the same care.
1002
- *
1003
- * The transcript is in the MAC, not just the challenges. Without it the same
1004
- * proof is valid on a different port, for a different peer, in a different
1005
- * direction — three distinct ways for a recording to be replayed somewhere it
1006
- * was not made.
1007
- */
1008
- export function proof(key, { challenge, peerChallenge, fromFp, toFp, direction }) {
1009
- return createHmac("sha256", key)
1010
- .update(`ccdeck-lan-v${PROTOCOL}|${direction}|${fromFp}|${toFp}|${challenge}|${peerChallenge}`)
1011
- .digest("hex");
1012
- }
1013
-
1014
- /** Whether a proof is the one expected, compared in constant time for the
1015
- * reason `beaconVerdict` gives. Length-checked first because timingSafeEqual
1016
- * throws on a mismatch, and a throw here is a crash rather than a refusal. */
1017
- export function proofOk(expected, offered) {
1018
- if (typeof offered !== "string" || offered.length !== expected.length) return false;
1019
- return timingSafeEqual(Buffer.from(expected, "utf8"), Buffer.from(offered, "utf8"));
1020
- }
1021
-
1022
- /**
1023
- * A fresh challenge for the sensitive operation, separate from the session.
1024
- *
1025
- * LocalSend's lesson, and the reason this exists rather than the transfer
1026
- * riding on the handshake: a session proves who connected, at the moment they
1027
- * connected. A credential leaving the machine is a different question, asked
1028
- * later, sometimes much later — a long-lived connection that was authenticated
1029
- * an hour ago is not a statement about now. So the transfer carries its own
1030
- * round trip, over the same key, naming the account being asked for.
1031
- */
1032
- export function transferChallenge(key, { nonce, accountKey, fromFp, toFp }) {
1033
- return createHmac("sha256", key)
1034
- .update(`ccdeck-lan-transfer-v${PROTOCOL}|${fromFp}|${toFp}|${accountKey}|${nonce}`)
1035
- .digest("hex");
1036
- }
1037
-
1038
- // ── the payload ─────────────────────────────────────────────────────────────
1039
-
1040
- /**
1041
- * Wrap a share blob so only the group can read it.
1042
- *
1043
- * AES-256-GCM over the group key with a random 12-byte nonce. The alternative
1044
- * — per-peer keys from the X25519 exchange — is better cryptography and worse
1045
- * for this: it would make the encryption depend on a session that the transfer
1046
- * check above deliberately does not trust, and the thing being protected is
1047
- * already group-wide by definition. Anybody who can decrypt this is somebody
1048
- * the passphrase already admits.
1049
- *
1050
- * The additional data binds the ciphertext to the two decks and the account, so
1051
- * a blob captured on one exchange cannot be replayed into another as if it were
1052
- * about something else.
1053
- *
1054
- * `iv` IS RANDOM UNLESS THE CALLER OWNS A COUNTER, and exactly one does. A
1055
- * random 96-bit nonce is safe for the handful of seals a connection makes this
1056
- * way; frameChannel seals every frame of a connection and derives each nonce
1057
- * from the frame's number instead, so that a repeat is impossible rather than
1058
- * unlikely and a frame's place in the conversation is part of what its tag
1059
- * proves. Nothing else passes one.
1060
- */
1061
- export function seal(key, plaintext, aad, iv = randomBytes(12)) {
1062
- const c = createCipheriv("aes-256-gcm", key, iv);
1063
- c.setAAD(Buffer.from(aad, "utf8"));
1064
- const body = Buffer.concat([c.update(plaintext, "utf8"), c.final()]);
1065
- return { iv: iv.toString("base64"), tag: c.getAuthTag().toString("base64"), body: body.toString("base64") };
1066
- }
1067
-
1068
- /** The other half. Returns null rather than throwing on every failure — a
1069
- * wrong tag, a wrong key, a truncated body and a hand-built packet all mean
1070
- * the same thing to the caller, which is "do not use this".
1071
- *
1072
- * THE TAG IS ALL SIXTEEN BYTES OR NOTHING. Without `authTagLength` node takes
1073
- * whatever length `setAuthTag` is handed, down to four bytes — measured on
1074
- * Node 24: a tag cut to its first four bytes opened, with nothing but a
1075
- * DEP0182 warning on stderr to say so. A 32-bit tag is one forgery in four
1076
- * billion tries instead of none, on the value that decides whether a frame or
1077
- * a credential is genuine. `seal` has only ever written sixteen, so no deck of
1078
- * any version is refused by this. */
1079
- export function open(key, { iv, tag, body }, aad) {
1080
- try {
1081
- const d = createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "base64"), { authTagLength: 16 });
1082
- d.setAAD(Buffer.from(aad, "utf8"));
1083
- d.setAuthTag(Buffer.from(tag, "base64"));
1084
- return Buffer.concat([d.update(Buffer.from(body, "base64")), d.final()]).toString("utf8");
1085
- } catch {
1086
- return null;
1087
- }
1088
- }
1089
-
1090
- // ── the frames after the handshake ──────────────────────────────────────────
1091
- //
1092
- // SEALED, EVERY ONE, AND UNTIL #810 NONE OF THEM WAS. The handshake proves both
1093
- // keys and derives one for the connection, and that key sealed exactly two
1094
- // things: the credential inside `have`, and the version card. Everything else
1095
- // was a JSON line on a TCP socket. Captured through a relay on loopback between
1096
- // two engines of the version before this, the round that healed one account
1097
- // and added another read, after `ok`:
1098
- //
1099
- // {"t":"manifest","accounts":[{"key":"claude1@sapec.md@@org-1","email":"claude1@sapec.md","alive":true},…],"current":{"key":"claude1@sapec.md@@org-1"}}
1100
- // {"t":"want","key":"claude2@sapec.md@@org-2","nonce":"…","proof":"…"}
1101
- // {"t":"have","key":"claude2@sapec.md@@org-2","sealed":{…}}
1102
- //
1103
- // So anybody on the same network segment could read every address a deck
1104
- // shares, which of its copies are broken, which one the machine is on, and
1105
- // which one is being repaired. No credential left that way and nobody could
1106
- // pose as a deck; what leaked was which accounts are on which machines, which
1107
- // is the one thing the share list promises to tell nobody else. And it was not
1108
- // only readable: nothing after `ok` carried a MAC, so a manifest could be
1109
- // rewritten in flight and the panel would draw whatever it was handed.
1110
- //
1111
- // ONE KEY PER DIRECTION, AND A COUNTER FOR A NONCE. Both ends start from the
1112
- // same session key, so a single frame key would have both of them sealing frame
1113
- // 0, frame 1, … under one key — and AES-GCM under a repeated nonce hands over
1114
- // the XOR of the two plaintexts and the means to forge. So each direction gets
1115
- // its own key and its own IV out of HKDF, the way sessionKey gets its own, and
1116
- // the labels name the direction by ROLE rather than by fingerprint: two decks
1117
- // holding one key (a copied ~/.claude) have one fingerprint between them, and a
1118
- // label built from it would be the same label twice. The nonce is the IV XORed
1119
- // with the frame's number, which is TLS 1.3's per-record nonce (RFC 8446 §5.3)
1120
- // over its per-direction write key and write IV (§7.3).
1121
- //
1122
- // THE NUMBER IS NEVER SENT. Each end counts what it has sealed and what it has
1123
- // opened, and opens the next frame under the number it expects next. A frame
1124
- // played twice, two frames swapped, one dropped from the middle, one lifted
1125
- // from another connection, or one sent back to the deck that sealed it — each
1126
- // is opened under the wrong number or the wrong key, its tag does not match,
1127
- // and the connection ends. There is no second try: a channel that has refused
1128
- // one frame refuses every one after it, and the socket goes with it.
1129
- //
1130
- // THE ANNOUNCEMENT RIDES INSIDE THE CHALLENGE, and that is the whole defence
1131
- // against being talked back down to plain text. An older deck has to keep
1132
- // working, so a peer that says nothing about sealing is answered in the clear —
1133
- // which makes "says nothing" exactly what a middleman would try to arrange. A
1134
- // flag of its own in `hello` or `challenge` is a flag nobody signs: the proofs
1135
- // cover the two fingerprints and the two challenges and nothing else, and an
1136
- // older deck will never cover more. Delete the flag both ways and each end sees
1137
- // a peer older than it is. Fold the flag into a new kind of proof and each end
1138
- // falls back to the old kind for the peer it now believes is old — the same
1139
- // round, readable, with nothing on either side to say so.
1140
- //
1141
- // The challenge is the one field every deck already binds. It goes verbatim
1142
- // into the transcript sessionKey derives from, and into both proofs. So a deck
1143
- // of this version marks its own (`<32 hex>.seal1`), an older deck carries that
1144
- // as the opaque string it always took, and a middleman who takes the mark off
1145
- // either challenge leaves the two ends deriving different keys: the caller's
1146
- // proof fails at the listener and the handshake ends before a frame is sent.
1147
- // TLS 1.3 puts its downgrade sentinel in ServerHello.random for the same reason
1148
- // (RFC 8446 §4.1.3) — it is the field the older handshake already covers.
1149
- //
1150
- // WHICH IS WHY A `|` IN A CHALLENGE IS REFUSED. The transcript joins its four
1151
- // fields with `|` and no lengths, so a middleman who could put one inside a
1152
- // challenge could move characters from one challenge into the other and leave
1153
- // both ends agreeing on the transcript, and so on the key, while each read a
1154
- // different challenge from its peer than the one that peer sent. Every deck
1155
- // sends hex, so a deck of this version takes letters, digits, `.`, `_` and `-`
1156
- // and refuses anything else before it derives a key from it.
1157
- //
1158
- // WHAT IT DOES NOT HIDE: how long each frame is and when it was sent — a
1159
- // manifest of ten accounts is longer than one of two. And it is exactly as
1160
- // strong as the session key it starts from; see sessionKey for what that is.
1161
-
1162
- /** What a deck of this version appends to its challenge to say it seals every
1163
- * frame after the handshake. A word with a number rather than a bit, so a
1164
- * later frame format is a new word an older deck simply does not recognise. */
1165
- export const SEALS = "seal1";
1166
-
1167
- /**
1168
- * What a deck of this version ends the random part of its challenge with, to
1169
- * say it mixes a key pair made for the connection into the connection's key
1170
- * (#1120; see sessionKey): the four bytes of "eph1" in ASCII, which in the hex
1171
- * a challenge is written in is `65706831`.
1172
- *
1173
- * IN THE CHALLENGE FOR THE REASON `.seal1` IS, and stripped it fails the same
1174
- * way. Mixing needs a new field — each end's ephemeral public key, `epk` — and
1175
- * an older deck binds nothing but the fingerprints and the two challenges. So
1176
- * a deck that took "no `epk`" to mean "an older peer" would be taken back to a
1177
- * key with no forward secrecy by anybody who deleted the field both ways, with
1178
- * both proofs still good. The mark goes where both proofs already reach: each
1179
- * end decides from the two challenges, each end's own challenge is the one it
1180
- * binds, and a middleman who takes the mark off either leaves the two ends on
1181
- * different transcripts — and on different schedules — so the dialler's proof
1182
- * fails at the listener before a frame is sent. And once both challenges say
1183
- * so, a missing or unusable `epk` is refused, never read as an older deck.
1184
- *
1185
- * IN THE RANDOM PART RATHER THAN AS A WORD, which is TLS 1.3's own answer to
1186
- * the same problem: its downgrade sentinel is a fixed value in the last bytes
1187
- * of ServerHello.random (RFC 8446 §4.1.3), a field the older handshake already
1188
- * covers and reads as nothing but random. Here that keeps a challenge the
1189
- * shape #810 gave it — 32 hex characters and `.seal1` — which #810's decks
1190
- * send too and its suite pins. It costs 32 of the 128 random bits, and 96 is
1191
- * still no challenge anybody sees twice. And a deck from #810 whose sixteen
1192
- * random bytes happen to end in these four, one handshake in 2^32, reads as a
1193
- * deck that mixes and sent no key: that round is refused rather than
1194
- * downgraded, and the next one has a new challenge.
1195
- */
1196
- export const EPHEMERAL = "eph1";
1197
- const EPHEMERAL_HEX = Buffer.from(EPHEMERAL, "ascii").toString("hex");
1198
- const MIXES = new RegExp(`^[0-9a-f]{24}${EPHEMERAL_HEX}\\.`);
1199
-
1200
- /** Letters, digits, `.`, `_` and `-`, bounded. Ours are 38 characters and an
1201
- * older deck's are 32; the bound is only there so a challenge is never the
1202
- * largest thing a stranger can make this hash. */
1203
- const CHALLENGE = /^[0-9A-Za-z._-]{1,128}$/;
1204
-
1205
- /** This deck's challenge for one connection: twelve fresh random bytes and the
1206
- * four that say it mixes, then the mark that says it seals. A deck speaking as
1207
- * one of #810's version sends sixteen random bytes and the mark, and one from
1208
- * before #810 sixteen random bytes and nothing, which is how the suite plays
1209
- * each. Mixing rides on sealing: no deck was ever released that did one
1210
- * without the other, so `seals: false` says neither. */
1211
- export function challengeFor({ seals = true, ephemeral = seals } = {}) {
1212
- if (!seals) return randomBytes(16).toString("hex");
1213
- const nonce = ephemeral
1214
- ? `${randomBytes(12).toString("hex")}${EPHEMERAL_HEX}`
1215
- : randomBytes(16).toString("hex");
1216
- return `${nonce}.${SEALS}`;
1217
- }
1218
-
1219
- /** A challenge a peer sent, or null for one no deck sends — see "WHICH IS WHY
1220
- * A `|` IN A CHALLENGE IS REFUSED" above. */
1221
- export function readChallenge(raw) {
1222
- return typeof raw === "string" && CHALLENGE.test(raw) ? raw : null;
1223
- }
1224
-
1225
- /** Whether the deck that made this challenge said it seals. */
1226
- export function sealsFrames(challenge) {
1227
- return typeof challenge === "string" && challenge.endsWith(`.${SEALS}`);
1228
- }
1229
-
1230
- /** Whether the deck that made this challenge said it mixes a key pair of its
1231
- * own into the connection's key — see EPHEMERAL. Only in a challenge that
1232
- * says it seals as well, so a deck from before #810, whose challenge is
1233
- * sixteen random bytes and nothing else, can never be taken for one. */
1234
- export function mixesEphemeral(challenge) {
1235
- return sealsFrames(challenge) && MIXES.test(challenge);
1236
- }
1237
-
1238
- /** Which way a frame is going, named by who dialled — never by fingerprint,
1239
- * for the reason above. */
1240
- const WAY = Object.freeze({ caller: "caller->listener", listener: "listener->caller" });
1241
-
1242
- /**
1243
- * The key and IV each end seals its own frames with, from one connection's
1244
- * session key.
1245
- *
1246
- * HKDF like sessionKey, one label per direction and per purpose: `key` and `iv`
1247
- * are separate expansions rather than one long one cut in two, the way RFC 8446
1248
- * §7.3 takes a write key and a write IV from one traffic secret.
1249
- */
1250
- export function frameKeys(key) {
1251
- const derive = (way, what, length) => Buffer.from(hkdfSync("sha256", key, Buffer.alloc(0),
1252
- Buffer.from(`ccdeck-lan-v${PROTOCOL}|frames|${way}|${what}`, "utf8"), length));
1253
- const one = way => ({ way, key: derive(way, "key", 32), iv: derive(way, "iv", 12) });
1254
- return { caller: one(WAY.caller), listener: one(WAY.listener) };
1255
- }
1256
-
1257
- /** Frame n's nonce: its direction's IV, XORed with n as a 64-bit big-endian
1258
- * number in the last eight bytes (RFC 8446 §5.3). */
1259
- function frameNonce(iv, n) {
1260
- const out = Buffer.alloc(12);
1261
- out.writeBigUInt64BE(BigInt(n), 4);
1262
- for (let i = 0; i < out.length; i++) out[i] ^= iv[i];
1263
- return out;
1264
- }
1265
-
1266
- /**
1267
- * One end of a sealed connection: wrap what this end sends, unwrap what the
1268
- * other end sent — in order, once each.
1269
- *
1270
- * `role` is which end this is: "caller" for the deck that dialled, "listener"
1271
- * for the deck that answered. Each end seals under its own direction's key and
1272
- * opens under the other's, so a frame only ever opens at the deck it was sealed
1273
- * for.
1274
- *
1275
- * On the wire a frame is `{ sealed, tag }` and nothing else — not the verb, not
1276
- * the number, not the nonce. The number is the one each end already expects,
1277
- * and a frame that is not the one expected, or that carries anything beside
1278
- * those two fields, does not open.
1279
- */
1280
- export function frameChannel(key, role) {
1281
- if (role !== "caller" && role !== "listener") throw new Error(`no such end: ${role}`);
1282
- const keys = frameKeys(key);
1283
- const out = keys[role];
1284
- const inn = keys[role === "caller" ? "listener" : "caller"];
1285
- let sent = 0;
1286
- let opened = 0;
1287
- let broken = false;
1288
- // The number goes in the additional data as well as the nonce. The nonce
1289
- // alone binds it; this says so in the one place a reader looks for what a
1290
- // tag covers, and it survives anybody changing how the nonce is built.
1291
- const aad = (way, n) => `ccdeck-lan-v${PROTOCOL}|frame|${way}|${n}`;
1292
- return {
1293
- wrap(obj) {
1294
- // 2^53 frames is not a connection anybody holds open — a round is a
1295
- // handful — but a counter that wrapped would be a nonce used twice, and
1296
- // refusing is cheaper than arguing about it.
1297
- if (!Number.isSafeInteger(sent + 1)) throw new Error("this connection has sealed all it may");
1298
- const n = sent++;
1299
- const { tag, body } = seal(out.key, JSON.stringify(obj), aad(out.way, n), frameNonce(out.iv, n));
1300
- return { sealed: body, tag };
1301
- },
1302
- unwrap(frame) {
1303
- if (broken) return null;
1304
- const shaped = !!frame && typeof frame === "object" && !Array.isArray(frame)
1305
- && Object.keys(frame).length === 2 && typeof frame.sealed === "string" && typeof frame.tag === "string";
1306
- const text = shaped
1307
- ? open(inn.key, { iv: frameNonce(inn.iv, opened).toString("base64"), tag: frame.tag, body: frame.sealed },
1308
- aad(inn.way, opened))
1309
- : null;
1310
- let msg = null;
1311
- if (text != null) { try { msg = JSON.parse(text); } catch { msg = null; } }
1312
- // A record, which is what frameReader asks of a plain frame: a sealed
1313
- // array is no more a frame than an unsealed one.
1314
- if (!msg || typeof msg !== "object" || Array.isArray(msg)) { broken = true; return null; }
1315
- opened++;
1316
- return msg;
1317
- },
1318
- };
1319
- }
1320
-
1321
- // ── which copy wins ─────────────────────────────────────────────────────────
1322
-
1323
- /**
1324
- * The identity of one account, which is NOT its slot number.
1325
- *
1326
- * claude-swap keys on `(email, organizationUuid)` — same email under two orgs
1327
- * is two accounts on purpose — and assigns slots as max+1 per store, so the
1328
- * account that is 4 here is 2 there. Anything keyed on the number would swap
1329
- * the wrong pair the first time two stores had grown in a different order.
1330
- */
1331
- export function accountKey(email, orgUuid) {
1332
- return `${String(email ?? "").trim().toLowerCase()}@@${String(orgUuid ?? "")}`;
1333
- }
1334
-
1335
- // ── why a login did not move ────────────────────────────────────────────────
1336
-
1337
- /**
1338
- * The reason a sending deck gives for a login it holds and cannot read: on a
1339
- * Mac, claude-swap could not open the Keychain from the session this deck runs
1340
- * in. A fixed code, never the CLI's words — export's stdout IS the credential,
1341
- * and nothing it printed is any business of the deck asking.
1342
- */
1343
- export const SENDER_UNREADABLE = "keychain_unavailable";
1344
-
1345
- /** Every reason `serve` answers a `want` with. */
1346
- const WIRE_REFUSALS = new Set(["proof", "not shared", "not mine to give", "export failed", SENDER_UNREADABLE, "error"]);
1347
-
1348
- /**
1349
- * A peer's refusal, as this deck records it. The panel prints these, so a peer
1350
- * must not be able to put words in its mouth — least of all one of the HERE
1351
- * codes, which are sentences about this machine. Anything outside the closed
1352
- * set is "refused", which is all it ever proved.
1353
- */
1354
- export function peerWhy(why) {
1355
- return typeof why === "string" && WIRE_REFUSALS.has(why) ? why : "refused";
1356
- }
1357
-
1358
- /**
1359
- * What this deck found wrong with a login it received. Produced by the local
1360
- * adapters only and never read off a frame — see peerWhy — so a code here is
1361
- * always about THIS machine, and on an arrived row it is a warning rather than
1362
- * a failure: the credential landed and something about this machine stops it
1363
- * being used.
1364
- */
1365
- export const HERE = Object.freeze({
1366
- // claude-swap cannot open this Mac's Keychain from the deck's session.
1367
- unreadable: "unreadable_here",
1368
- // It landed, and claude-swap still holds no login for it.
1369
- noLogin: "no_credentials_here",
1370
- // It landed, and the login it carried was rejected.
1371
- expired: "relogin_required_here",
1372
- // It landed, and claude-swap could not be asked whether it is readable.
1373
- unverified: "unverified_here",
1374
- });
1375
-
1376
- /**
1377
- * What to do about one account, given what I have and what a peer has.
1378
- *
1379
- * TWO OUTCOMES, AND NEITHER OVERWRITES SOMETHING THAT WORKS.
1380
- *
1381
- * "add" I do not have this account at all.
1382
- * "heal" I have it and claude-swap has quarantined it — refresh token dead,
1383
- * verified by an invalid_grant from Anthropic rather than guessed
1384
- * from an expiry field — and the peer's copy is alive.
1385
- * null Anything else, which is most of the time.
1386
- *
1387
- * A third outcome was designed and dropped, and the reason is worth keeping
1388
- * because it looks like a feature being given up. It was "replace": take a
1389
- * peer's copy when it is strictly newer than mine, so the freshest copy wins
1390
- * everywhere. Measuring "newer" needs the OAuth payload's `expiresAt`, which
1391
- * means the deck opening a credential — something it does not do, claude-swap
1392
- * owns that — and on macOS that credential is in the Keychain, which a
1393
- * background process cannot reliably read at all.
1394
- *
1395
- * What settled it is not the obstacle. It is that a working credential replaced
1396
- * by a newer working credential changes nothing today. It would only matter if
1397
- * mine were about to die — and a login dies from not being used, so if I am not
1398
- * using it I do not care, and if I am using it the refresh keeps it alive. The
1399
- * whole value is in the account that is already dead.
1400
- *
1401
- * So this never returns an outcome that needs `cswap import --force`, which
1402
- * makes claude-swap's own rule the entire safety property: a plain import skips
1403
- * an account that is present and healthy, and replaces exactly one that is
1404
- * quarantined. A peer cannot overwrite a credential of mine that works, because
1405
- * nothing here ever asks for that.
1406
- */
1407
- export function syncAction(mine, theirs) {
1408
- if (!theirs || !theirs.alive || theirs.shareable === false) return null;
1409
- if (!mine) return "add";
1410
- return mine.alive ? null : "heal";
1411
- }
1412
-
1413
- /**
1414
- * Everything to do this round, over one peer's manifest.
1415
- *
1416
- * Sorted by account key rather than left in manifest order, so two decks
1417
- * reconciling the same pair of stores do the same work in the same sequence —
1418
- * which is what makes a failure halfway through repeatable rather than a
1419
- * different half each time.
1420
- */
1421
- export function plan(local, remote) {
1422
- // Through onePerKey, not array order: a Map built straight from the rows
1423
- // keeps the LAST one for a key, so an expired second slot hid a live first
1424
- // one and every round "healed" a login that works here.
1425
- const mine = new Map(onePerKey(local).map(a => [a.key, a]));
1426
- const out = [];
1427
- for (const theirs of remote) {
1428
- const action = syncAction(mine.get(theirs.key), theirs);
1429
- if (action) out.push({ key: theirs.key, email: theirs.email, action });
1430
- }
1431
- return out.sort((a, b) => a.key.localeCompare(b.key));
1432
- }
1433
-
1434
- /**
1435
- * One row per identity, and THE rule for which one when claude-swap holds two
1436
- * slots for the same login: a live copy beats an expired one, then one this
1437
- * process can read beats one it cannot, then the earlier slot.
1438
- *
1439
- * ONE RULE, FOUR PLACES. The manifest this deck sends, the peer manifest it
1440
- * reads, the export it answers a `want` with, and plan's view of this deck's
1441
- * own copy all pick through this, so the slot a peer was told about is the
1442
- * slot it is then handed, and the copy this deck advertises as working is the
1443
- * one it plans from. Each used to take array order on its own and agreed only
1444
- * by coincidence — and two slots for one identity became two imports of it in
1445
- * a single round.
1446
- */
1447
- export function onePerKey(rows) {
1448
- const rank = a => (a.alive ? 2 : 0) + (a.readable === false ? 0 : 1);
1449
- const best = new Map();
1450
- for (const a of rows) {
1451
- const had = best.get(a.key);
1452
- if (!had || rank(a) > rank(had)) best.set(a.key, a);
1453
- }
1454
- return [...best.values()];
1455
- }
1456
-
1457
- /** This deck's own slot for one identity, by onePerKey's rule, or null. */
1458
- export function slotFor(accounts, key) {
1459
- return onePerKey(accounts.filter(a => a.key === key))[0] ?? null;
1460
- }
1461
-
1462
- /**
1463
- * What this deck publishes about its own accounts — to the group, and only to
1464
- * the group.
1465
- *
1466
- * `alive` says whether the stored copy is still valid. `shareable: false` is
1467
- * the separate, temporary condition where this process cannot read that valid
1468
- * copy (for example a locked macOS Keychain). Keeping those facts separate is
1469
- * what stops another deck treating a healthy-but-inaccessible copy as expired
1470
- * and repeatedly trying to heal it.
1471
- *
1472
- * Emails are in it, and the manifest is sealed on its way. This said "in the
1473
- * clear inside the encrypted channel" until #810, and there was no such
1474
- * channel: the handshake derived a key and sealed a credential and a version
1475
- * card with it, and nothing else — so every email in this list crossed the
1476
- * network readable by anybody on it. Between two decks that both seal, every
1477
- * frame after the handshake now is (see frameChannel). Toward a deck from
1478
- * before that it still travels plain, because that deck cannot open anything
1479
- * else, until it updates.
1480
- *
1481
- * Plain emails INSIDE the seal are a deliberate line: a manifest only ever
1482
- * reaches a deck that proved the key somebody here accepted, and the panel has
1483
- * to name the account it is offering to heal. Hashing the email would buy
1484
- * nothing against that reader and would cost the one thing the row needs to say.
1485
- *
1486
- * `shared` is the user's list. An account absent from it is absent from the
1487
- * manifest entirely — not listed as withheld, which would tell the group that
1488
- * an account exists and is being kept back, and that is itself the fact being
1489
- * kept back.
1490
- */
1491
- export function manifestFor(accounts, shared) {
1492
- const want = new Set(shared);
1493
- return onePerKey(accounts.filter(a => want.has(a.key)))
1494
- .map(a => ({
1495
- key: a.key,
1496
- email: a.email,
1497
- alive: !!a.alive,
1498
- ...(a.readable === false ? { shareable: false } : {}),
1499
- }))
1500
- .sort((a, b) => a.key.localeCompare(b.key));
1501
- }
1502
-
1503
- /**
1504
- * Which account this deck is on, as a frame field for the decks it is paired
1505
- * with — or nothing.
1506
- *
1507
- * ONLY ONE IT SHARES IS EVER NAMED. The account a deck works on is named only
1508
- * when it is also in the list the owner ticked, so the promise the share list
1509
- * makes — an unticked account is never told to anybody — holds for this too.
1510
- * On one it does not share, it says `other` and nothing more: a paired deck
1511
- * reads "on another account", which says this machine is working without
1512
- * saying on what.
1513
- *
1514
- * `hidden` is the owner's switch turned off, and that IS said, on purpose: a
1515
- * paired deck then reads "current account hidden" rather than nothing, so the
1516
- * person over there knows this machine is working and chose not to say where.
1517
- * A deck with no account at all says nothing.
1518
- */
1519
- export function currentFor(accounts, shared, shareActive) {
1520
- if (shareActive === false) return { current: { hidden: true } };
1521
- const on = accounts.find(a => a.active);
1522
- if (!on) return {};
1523
- return new Set(shared).has(on.key) ? { current: { key: on.key } } : { current: { other: true } };
1524
- }