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
@@ -0,0 +1,620 @@
1
+ // What two decks say to each other once one has dialled the other: the key
2
+ // each deck is known by and the fingerprint it goes by, the key two decks agree
3
+ // for one connection, the proofs made over it, and the seal on a login, on a
4
+ // card and on every frame after the handshake. And PROTOCOL, the wire's
5
+ // version, which every key, proof and seal here is derived under. Moved out of
6
+ // lan-sync.mjs, which re-exports all of it; the beacon there takes PROTOCOL
7
+ // from here, because this file cannot import from the one that re-exports it.
8
+ // Like everything there it is pure — no sockets, no timers, no filesystem —
9
+ // and why it is built on node:crypto alone is in that file's header.
10
+ import {
11
+ createCipheriv, createDecipheriv, createHash, createHmac, createPrivateKey,
12
+ createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes,
13
+ timingSafeEqual,
14
+ } from "node:crypto";
15
+
16
+ /** The wire format. Bumped when a field changes meaning, never when one is
17
+ * added — a reader that does not know a field ignores it, which is what makes
18
+ * a deck a version behind still discoverable. */
19
+ export const PROTOCOL = 2;
20
+
21
+ // ── identity ────────────────────────────────────────────────────────────────
22
+
23
+ /**
24
+ * What a deck calls itself on the wire, given its long-term public key.
25
+ *
26
+ * A hash of the key rather than the key: it is short enough to read out over a
27
+ * phone, it is stable across restarts, and — the part that matters — it is the
28
+ * only identity claim in a beacon that cannot be forged, because a deck that
29
+ * cannot do the handshake cannot use somebody else's fingerprint for anything.
30
+ *
31
+ * Twelve hex characters, in threes. 48 bits is far past what an accident
32
+ * reaches on one network, and a person can compare four groups at a glance
33
+ * where they cannot compare sixty-four characters. Syncthing and LocalSend both
34
+ * make the same trade at a similar length.
35
+ */
36
+ export function fingerprint(publicKeyRaw) {
37
+ const hex = createHash("sha256").update(publicKeyRaw).digest("hex").slice(0, 12);
38
+ return hex.replace(/(.{3})(?=.)/g, "$1-");
39
+ }
40
+
41
+ // ── this deck's own key ─────────────────────────────────────────────────────
42
+
43
+ /**
44
+ * A deck's own long-term identity.
45
+ *
46
+ * X25519, because the only thing it is ever used for is agreeing a session key
47
+ * with a peer — never a signature — and node has it built in. The private half
48
+ * lives in prefs.json, which is written 0600 for exactly this reason; the
49
+ * public half is what a peer pins, and its fingerprint is what a person
50
+ * compares.
51
+ */
52
+ function newKeypair() {
53
+ const { publicKey, privateKey } = generateKeyPairSync("x25519");
54
+ return {
55
+ secret: privateKey.export({ type: "pkcs8", format: "der" }).toString("base64"),
56
+ pub: publicKey.export({ type: "spki", format: "der" }).toString("base64"),
57
+ };
58
+ }
59
+
60
+ /**
61
+ * The keypair on disk, or a fresh one when there is none or it is unusable.
62
+ *
63
+ * A corrupt secret is not an error anybody can act on mid-session, and refusing
64
+ * to start would take the whole feature away over one bad string — so it is
65
+ * replaced. The cost of replacing it is stated rather than hidden: this deck
66
+ * gets a new fingerprint, so every peer that had pinned the old one sees a
67
+ * stranger and asks its owner to accept again.
68
+ */
69
+ export function identityFrom(secret) {
70
+ if (typeof secret === "string" && secret !== "") {
71
+ try {
72
+ const priv = createPrivateKey({ key: Buffer.from(secret, "base64"), format: "der", type: "pkcs8" });
73
+ const pub = createPublicKey(priv).export({ type: "spki", format: "der" });
74
+ return { secret, pub: pub.toString("base64"), fp: fingerprint(pub), fresh: false };
75
+ } catch { /* unusable; fall through and make one */ }
76
+ }
77
+ const made = newKeypair();
78
+ return { ...made, fp: fingerprint(Buffer.from(made.pub, "base64")), fresh: true };
79
+ }
80
+
81
+ /**
82
+ * The key two decks use for one connection, and for nothing else.
83
+ *
84
+ * X25519 to a shared secret, then HKDF over the transcript — both fingerprints
85
+ * and both challenges, in a fixed order — so the key is bound to THIS exchange.
86
+ * A recording of an old one derives a different key and proves nothing.
87
+ *
88
+ * PER CONNECTION, never stored. The old design sealed credentials under one
89
+ * long-lived group key, which meant a single recorded transfer stayed readable
90
+ * to anybody who ever learned the passphrase.
91
+ *
92
+ * AND FORWARD-SECRET BETWEEN TWO DECKS THAT BOTH SAY SO, which until #1120 no
93
+ * connection was. Without `ephemeral` this is the key every deck derived
94
+ * before: X25519 between the two decks' LONG-TERM keys, over a transcript
95
+ * whose every field crossed the network in the clear. So whoever recorded a
96
+ * connection and later took either deck's private key — a 0600 file, and also
97
+ * a file that rides along in backups and in a `~/.claude` somebody copied —
98
+ * could derive that connection's key and open all of it: every frame after the
99
+ * handshake, the version card, and each credential a `have` carried, logins
100
+ * since rotated or removed included, and the other deck's half of the
101
+ * conversation with them.
102
+ *
103
+ * With `ephemeral`, each end also brings a key pair made for this connection
104
+ * alone (ephemeralPair), and the key is HKDF over four X25519 results rather
105
+ * than one. Which four is Noise's KK pattern (noiseprotocol.org/noise.html
106
+ * §7.5): both static keys known in advance, `-> e, es, ss` and `<- e, ee, se`.
107
+ * How they are combined is Signal's X3DH (§2.2, §3.3): concatenated in a fixed
108
+ * order, each a fixed 32 bytes so no two orderings read alike, into one HKDF —
109
+ * without X3DH's run of 0xFF bytes in front, which it needs only because its
110
+ * keys also sign, and a deck's never do. HKDF's info is the transcript, the
111
+ * way TLS 1.3 derives every secret over the transcript of the handshake that
112
+ * made it (RFC 8446 §7.1).
113
+ *
114
+ * ss static × static — the one term there was, and still what makes the key
115
+ * need a long-term key at all: whoever held both ephemeral halves and
116
+ * neither static key would have ee, es and se, and not this.
117
+ * ee ephemeral × ephemeral — the forward secrecy, which is #1120. Neither
118
+ * deck keeps its half past this call (see lan-socket.mjs), so once a
119
+ * connection is over, no key anybody still holds — both long-term keys
120
+ * included — recomputes this one.
121
+ * es the dialler's ephemeral × the answerer's static key, and
122
+ * se the dialler's static key × the answerer's ephemeral. Neither adds
123
+ * forward secrecy; each stops a stolen key being worn as SOMEBODY ELSE,
124
+ * which ss cannot. Whoever holds B's private key computes DH(b, A) just
125
+ * as A does, so with ss and ee alone they could dial B as A — or as any
126
+ * deck B has paired — and B would prove itself to them and hand over
127
+ * every login it shares. With se they need A's key or B's ephemeral as
128
+ * well, and hold neither. es is the same guard the other way round, for
129
+ * a deck whose own key is stolen and which dials out. Noise calls it
130
+ * resistance to key-compromise impersonation (§7.7, source property 2).
131
+ *
132
+ * `role` says which end this is — "caller" for the deck that dialled — because
133
+ * each end computes es and se from different halves and both must land in the
134
+ * same place. Named by who dialled and never by fingerprint, for frameKeys'
135
+ * reason: a copied ~/.claude gives both ends one fingerprint.
136
+ *
137
+ * Its own label in the info string, so a key from this schedule and one from
138
+ * the other are never the same key, whatever two transcripts look like. The
139
+ * four results are zeroed as soon as HKDF has read them; the ephemeral private
140
+ * half never leaves the KeyObject it was made in, and nothing writes it
141
+ * anywhere. That is all the forgetting a JavaScript process can promise, and
142
+ * it is what forward secrecy asks of it: nothing of the ephemeral side left to
143
+ * find once the connection is gone.
144
+ *
145
+ * THROWS on a key X25519 cannot use: a type other than X25519, which readPub
146
+ * now refuses before this, or one of the few low-order points, whose shared
147
+ * secret is all zeros (RFC 7748 §6.1) and which OpenSSL refuses rather than
148
+ * returns. The socket layer refuses the handshake when it does.
149
+ */
150
+ export function sessionKey(secret, peerPub, transcript, ephemeral = null) {
151
+ const priv = createPrivateKey({ key: Buffer.from(secret, "base64"), format: "der", type: "pkcs8" });
152
+ const theirs = createPublicKey({ key: Buffer.from(peerPub, "base64"), format: "der", type: "spki" });
153
+ if (!ephemeral) {
154
+ const shared = diffieHellman({ privateKey: priv, publicKey: theirs });
155
+ return Buffer.from(hkdfSync("sha256", shared, Buffer.alloc(0),
156
+ Buffer.from(`ccdeck-lan-v${PROTOCOL}|${transcript}`, "utf8"), 32));
157
+ }
158
+ const { role, priv: mine, peer } = ephemeral;
159
+ if (role !== "caller" && role !== "listener") throw new Error(`no such end: ${role}`);
160
+ const theirsNow = createPublicKey({ key: Buffer.from(peer, "base64"), format: "der", type: "spki" });
161
+ const made = [];
162
+ const dh = (privateKey, publicKey) => {
163
+ const out = diffieHellman({ privateKey, publicKey });
164
+ made.push(out);
165
+ return out;
166
+ };
167
+ try {
168
+ const ss = dh(priv, theirs);
169
+ const ee = dh(mine, theirsNow);
170
+ const mineWithTheirStatic = dh(mine, theirs);
171
+ const staticWithTheirs = dh(priv, theirsNow);
172
+ const [es, se] = role === "caller"
173
+ ? [mineWithTheirStatic, staticWithTheirs]
174
+ : [staticWithTheirs, mineWithTheirStatic];
175
+ const ikm = Buffer.concat([ss, ee, es, se]);
176
+ made.push(ikm);
177
+ return Buffer.from(hkdfSync("sha256", ikm, Buffer.alloc(0),
178
+ Buffer.from(`ccdeck-lan-v${PROTOCOL}|${EPHEMERAL}|${transcript}`, "utf8"), 32));
179
+ } finally {
180
+ for (const b of made) b.fill(0);
181
+ }
182
+ }
183
+
184
+ /**
185
+ * The string both sides bind their session key to.
186
+ *
187
+ * One definition, used by the caller and the listener, because a transcript the
188
+ * two build differently is a handshake that never agrees and a bug that only
189
+ * appears between two machines. Caller first, always, so the order does not
190
+ * depend on which end is asking.
191
+ *
192
+ * BOTH EPHEMERAL KEYS TOO, when the two decks mix them (#1120), as they
193
+ * arrived. Not only inside ee: this string is HKDF's info in sessionKey, and
194
+ * each proof is an HMAC under the key that comes out, so the proofs the static
195
+ * keys make cover these two strings by name. A middleman who swaps either key
196
+ * for one of their own has changed what one end proves over, and that end's
197
+ * proof fails at the other whatever the arithmetic would have done. Noise
198
+ * mixes every public key it sends into its handshake hash for the same reason
199
+ * (§5.3). Base64 has no `|`, and readEphemeral takes a key only in the one
200
+ * spelling its bytes encode to, so no field can spill into the next.
201
+ *
202
+ * Given at all, both are written. One missing prints as `undefined` and
203
+ * matches nothing at the other end; it does not fall back to the four-field
204
+ * string, because a transcript that quietly dropped a key is the downgrade this
205
+ * is here to refuse.
206
+ */
207
+ export function handshakeTranscript(callerFp, listenerFp, callerChallenge, listenerChallenge,
208
+ callerEphemeral, listenerEphemeral) {
209
+ const fixed = `${callerFp}|${listenerFp}|${callerChallenge}|${listenerChallenge}`;
210
+ return callerEphemeral === undefined && listenerEphemeral === undefined
211
+ ? fixed
212
+ : `${fixed}|${callerEphemeral}|${listenerEphemeral}`;
213
+ }
214
+
215
+ /**
216
+ * A public key somebody sent, or null.
217
+ *
218
+ * It arrives from the network before anything has been agreed, so it is checked
219
+ * for being an X25519 public key at all rather than trusted to be one — a
220
+ * string that is not gets a refusal here instead of a throw three frames later.
221
+ *
222
+ * AN X25519 KEY, NOT MERELY A KEY, and until #1120 it only checked the second.
223
+ * Any SPKI parsed, so an Ed25519 or a P-256 key came through as a deck's
224
+ * public key, and the throw this promised to prevent happened one line later,
225
+ * in sessionKey: `Incompatible key types for Diffie-Hellman`, inside the
226
+ * listener's `data` handler, before anybody was trusted. Nothing catches a
227
+ * throw there. Measured on Node 24 with a listener in a process of its own:
228
+ * one `hello` carrying an Ed25519 key, and the process exited with code 1.
229
+ * No deck has ever sent anything but X25519 (newKeypair), so no deck is
230
+ * refused by this.
231
+ */
232
+ export function readPub(raw) {
233
+ const der = x25519(raw);
234
+ return der ? { pub: raw, fp: fingerprint(der) } : null;
235
+ }
236
+
237
+ /**
238
+ * An ephemeral public key somebody sent, or null: checked exactly as readPub
239
+ * checks a static one, and then held to one spelling.
240
+ *
241
+ * The spelling matters here and not for a static key, which only ever reaches
242
+ * the transcript as its fingerprint. This string goes in as it arrived, among
243
+ * fields joined by `|` with no lengths, and base64 decoding skips what it does
244
+ * not understand, `|` included — so a key that decoded to the right bytes
245
+ * could still carry a separator. Only the exact text those bytes encode to is
246
+ * taken.
247
+ */
248
+ export function readEphemeral(raw) {
249
+ const der = x25519(raw);
250
+ return der && der.toString("base64") === raw ? raw : null;
251
+ }
252
+
253
+ /** The DER of an X25519 public key, from the base64 a deck sends, or null. */
254
+ function x25519(raw) {
255
+ if (typeof raw !== "string" || raw.length < 40 || raw.length > 128) return null;
256
+ try {
257
+ const der = Buffer.from(raw, "base64");
258
+ return createPublicKey({ key: der, format: "der", type: "spki" }).asymmetricKeyType === "x25519" ? der : null;
259
+ } catch { return null; }
260
+ }
261
+
262
+ /**
263
+ * A key pair for one connection and no other (#1120).
264
+ *
265
+ * The private half stays the KeyObject it was made as — never exported, never
266
+ * a string, which JavaScript could not wipe — and the end that made it lets go
267
+ * of it the moment sessionKey has used it. The public half is what travels, as
268
+ * the same base64 SPKI a static key travels as.
269
+ */
270
+ export function ephemeralPair() {
271
+ const { publicKey, privateKey } = generateKeyPairSync("x25519");
272
+ return { priv: privateKey, pub: publicKey.export({ type: "spki", format: "der" }).toString("base64") };
273
+ }
274
+
275
+ // ── the handshake ───────────────────────────────────────────────────────────
276
+
277
+ /**
278
+ * The proof a deck offers to show it holds the private half of the key it
279
+ * announced — the key a paired deck pinned for it.
280
+ *
281
+ * Challenge-response over the derived key rather than sending anything derived
282
+ * from that private key directly, so a recording of one exchange is worth
283
+ * nothing for the next: the challenge is fresh random from the side being
284
+ * convinced.
285
+ *
286
+ * BOTH SIDES PROVE. The obvious version has the caller prove itself to the
287
+ * listener, which stops a stranger reading a manifest and stops nothing else —
288
+ * a stranger can still stand up a listener, wait for a real deck to connect,
289
+ * and be handed one. So the response covers a challenge from each side and the
290
+ * initiator checks the answer with the same care.
291
+ *
292
+ * The transcript is in the MAC, not just the challenges. Without it the same
293
+ * proof is valid on a different port, for a different peer, in a different
294
+ * direction — three distinct ways for a recording to be replayed somewhere it
295
+ * was not made.
296
+ */
297
+ export function proof(key, { challenge, peerChallenge, fromFp, toFp, direction }) {
298
+ return createHmac("sha256", key)
299
+ .update(`ccdeck-lan-v${PROTOCOL}|${direction}|${fromFp}|${toFp}|${challenge}|${peerChallenge}`)
300
+ .digest("hex");
301
+ }
302
+
303
+ /** Whether a proof is the one expected, compared in constant time so how much
304
+ * of a guess matched is not something a caller can time. Length-checked first
305
+ * because timingSafeEqual throws on a mismatch, and a throw here is a crash
306
+ * rather than a refusal. */
307
+ export function proofOk(expected, offered) {
308
+ if (typeof offered !== "string" || offered.length !== expected.length) return false;
309
+ return timingSafeEqual(Buffer.from(expected, "utf8"), Buffer.from(offered, "utf8"));
310
+ }
311
+
312
+ /**
313
+ * A fresh challenge for the sensitive operation, separate from the session.
314
+ *
315
+ * LocalSend's lesson, and the reason this exists rather than the transfer
316
+ * riding on the handshake: a session proves who connected, at the moment they
317
+ * connected. A credential leaving the machine is a different question, asked
318
+ * later, sometimes much later — a long-lived connection that was authenticated
319
+ * an hour ago is not a statement about now. So the transfer carries its own
320
+ * round trip, over the same key, naming the account being asked for.
321
+ */
322
+ export function transferChallenge(key, { nonce, accountKey, fromFp, toFp }) {
323
+ return createHmac("sha256", key)
324
+ .update(`ccdeck-lan-transfer-v${PROTOCOL}|${fromFp}|${toFp}|${accountKey}|${nonce}`)
325
+ .digest("hex");
326
+ }
327
+
328
+ // ── the payload ─────────────────────────────────────────────────────────────
329
+
330
+ /**
331
+ * Seal something so only the deck on the other end of one connection can read
332
+ * it — a login, a card, and through frameChannel every frame.
333
+ *
334
+ * AES-256-GCM under a key that connection derived, with a random 12-byte
335
+ * nonce. It sealed under one long-lived group key once, and sessionKey's note
336
+ * says why that stopped.
337
+ *
338
+ * The additional data binds the ciphertext to the two decks and the account, so
339
+ * a blob captured on one exchange cannot be replayed into another as if it were
340
+ * about something else.
341
+ *
342
+ * `iv` IS RANDOM UNLESS THE CALLER OWNS A COUNTER, and exactly one does. A
343
+ * random 96-bit nonce is safe for the handful of seals a connection makes this
344
+ * way; frameChannel seals every frame of a connection and derives each nonce
345
+ * from the frame's number instead, so that a repeat is impossible rather than
346
+ * unlikely and a frame's place in the conversation is part of what its tag
347
+ * proves. Nothing else passes one.
348
+ */
349
+ export function seal(key, plaintext, aad, iv = randomBytes(12)) {
350
+ const c = createCipheriv("aes-256-gcm", key, iv);
351
+ c.setAAD(Buffer.from(aad, "utf8"));
352
+ const body = Buffer.concat([c.update(plaintext, "utf8"), c.final()]);
353
+ return { iv: iv.toString("base64"), tag: c.getAuthTag().toString("base64"), body: body.toString("base64") };
354
+ }
355
+
356
+ /** The other half. Returns null rather than throwing on every failure — a
357
+ * wrong tag, a wrong key, a truncated body and a hand-built packet all mean
358
+ * the same thing to the caller, which is "do not use this".
359
+ *
360
+ * THE TAG IS ALL SIXTEEN BYTES OR NOTHING. Without `authTagLength` node takes
361
+ * whatever length `setAuthTag` is handed, down to four bytes — measured on
362
+ * Node 24: a tag cut to its first four bytes opened, with nothing but a
363
+ * DEP0182 warning on stderr to say so. A 32-bit tag is one forgery in four
364
+ * billion tries instead of none, on the value that decides whether a frame or
365
+ * a credential is genuine. `seal` has only ever written sixteen, so no deck of
366
+ * any version is refused by this. */
367
+ export function open(key, { iv, tag, body }, aad) {
368
+ try {
369
+ const d = createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "base64"), { authTagLength: 16 });
370
+ d.setAAD(Buffer.from(aad, "utf8"));
371
+ d.setAuthTag(Buffer.from(tag, "base64"));
372
+ return Buffer.concat([d.update(Buffer.from(body, "base64")), d.final()]).toString("utf8");
373
+ } catch {
374
+ return null;
375
+ }
376
+ }
377
+
378
+ /**
379
+ * The additional data a credential is sealed under: which deck it leaves,
380
+ * which deck it is for, and which account it is — the binding `seal` above
381
+ * describes, as the one string both ends must build alike. The deck answering
382
+ * a `want` seals under it and the deck that asked opens under it, so a
383
+ * difference between the two is not an error anywhere: it is a login that
384
+ * never opens, reported as "could not open" about a peer that did nothing
385
+ * wrong.
386
+ */
387
+ export function credentialAad(fromFp, toFp, key) {
388
+ return `${fromFp}->${toFp}|${key}`;
389
+ }
390
+
391
+ // ── the frames after the handshake ──────────────────────────────────────────
392
+ //
393
+ // SEALED, EVERY ONE, AND UNTIL #810 NONE OF THEM WAS. The handshake proves both
394
+ // keys and derives one for the connection, and that key sealed exactly two
395
+ // things: the credential inside `have`, and the version card. Everything else
396
+ // was a JSON line on a TCP socket. Captured through a relay on loopback between
397
+ // two engines of the version before this, the round that healed one account
398
+ // and added another read, after `ok`:
399
+ //
400
+ // {"t":"manifest","accounts":[{"key":"claude1@sapec.md@@org-1","email":"claude1@sapec.md","alive":true},…],"current":{"key":"claude1@sapec.md@@org-1"}}
401
+ // {"t":"want","key":"claude2@sapec.md@@org-2","nonce":"…","proof":"…"}
402
+ // {"t":"have","key":"claude2@sapec.md@@org-2","sealed":{…}}
403
+ //
404
+ // So anybody on the same network segment could read every address a deck
405
+ // shares, which of its copies are broken, which one the machine is on, and
406
+ // which one is being repaired. No credential left that way and nobody could
407
+ // pose as a deck; what leaked was which accounts are on which machines, which
408
+ // is the one thing the share list promises to tell nobody else. And it was not
409
+ // only readable: nothing after `ok` carried a MAC, so a manifest could be
410
+ // rewritten in flight and the panel would draw whatever it was handed.
411
+ //
412
+ // ONE KEY PER DIRECTION, AND A COUNTER FOR A NONCE. Both ends start from the
413
+ // same session key, so a single frame key would have both of them sealing frame
414
+ // 0, frame 1, … under one key — and AES-GCM under a repeated nonce hands over
415
+ // the XOR of the two plaintexts and the means to forge. So each direction gets
416
+ // its own key and its own IV out of HKDF, the way sessionKey gets its own, and
417
+ // the labels name the direction by ROLE rather than by fingerprint: two decks
418
+ // holding one key (a copied ~/.claude) have one fingerprint between them, and a
419
+ // label built from it would be the same label twice. The nonce is the IV XORed
420
+ // with the frame's number, which is TLS 1.3's per-record nonce (RFC 8446 §5.3)
421
+ // over its per-direction write key and write IV (§7.3).
422
+ //
423
+ // THE NUMBER IS NEVER SENT. Each end counts what it has sealed and what it has
424
+ // opened, and opens the next frame under the number it expects next. A frame
425
+ // played twice, two frames swapped, one dropped from the middle, one lifted
426
+ // from another connection, or one sent back to the deck that sealed it — each
427
+ // is opened under the wrong number or the wrong key, its tag does not match,
428
+ // and the connection ends. There is no second try: a channel that has refused
429
+ // one frame refuses every one after it, and the socket goes with it.
430
+ //
431
+ // THE ANNOUNCEMENT RIDES INSIDE THE CHALLENGE, and that is the whole defence
432
+ // against being talked back down to plain text. An older deck has to keep
433
+ // working, so a peer that says nothing about sealing is answered in the clear —
434
+ // which makes "says nothing" exactly what a middleman would try to arrange. A
435
+ // flag of its own in `hello` or `challenge` is a flag nobody signs: the proofs
436
+ // cover the two fingerprints and the two challenges and nothing else, and an
437
+ // older deck will never cover more. Delete the flag both ways and each end sees
438
+ // a peer older than it is. Fold the flag into a new kind of proof and each end
439
+ // falls back to the old kind for the peer it now believes is old — the same
440
+ // round, readable, with nothing on either side to say so.
441
+ //
442
+ // The challenge is the one field every deck already binds. It goes verbatim
443
+ // into the transcript sessionKey derives from, and into both proofs. So a deck
444
+ // of this version marks its own (`<32 hex>.seal1`), an older deck carries that
445
+ // as the opaque string it always took, and a middleman who takes the mark off
446
+ // either challenge leaves the two ends deriving different keys: the caller's
447
+ // proof fails at the listener and the handshake ends before a frame is sent.
448
+ // TLS 1.3 puts its downgrade sentinel in ServerHello.random for the same reason
449
+ // (RFC 8446 §4.1.3) — it is the field the older handshake already covers.
450
+ //
451
+ // WHICH IS WHY A `|` IN A CHALLENGE IS REFUSED. The transcript joins its four
452
+ // fields with `|` and no lengths, so a middleman who could put one inside a
453
+ // challenge could move characters from one challenge into the other and leave
454
+ // both ends agreeing on the transcript, and so on the key, while each read a
455
+ // different challenge from its peer than the one that peer sent. Every deck
456
+ // sends hex, so a deck of this version takes letters, digits, `.`, `_` and `-`
457
+ // and refuses anything else before it derives a key from it.
458
+ //
459
+ // WHAT IT DOES NOT HIDE: how long each frame is and when it was sent — a
460
+ // manifest of ten accounts is longer than one of two. And it is exactly as
461
+ // strong as the session key it starts from; see sessionKey for what that is.
462
+
463
+ /** What a deck of this version appends to its challenge to say it seals every
464
+ * frame after the handshake. A word with a number rather than a bit, so a
465
+ * later frame format is a new word an older deck simply does not recognise. */
466
+ export const SEALS = "seal1";
467
+
468
+ /**
469
+ * What a deck of this version ends the random part of its challenge with, to
470
+ * say it mixes a key pair made for the connection into the connection's key
471
+ * (#1120; see sessionKey): the four bytes of "eph1" in ASCII, which in the hex
472
+ * a challenge is written in is `65706831`.
473
+ *
474
+ * IN THE CHALLENGE FOR THE REASON `.seal1` IS, and stripped it fails the same
475
+ * way. Mixing needs a new field — each end's ephemeral public key, `epk` — and
476
+ * an older deck binds nothing but the fingerprints and the two challenges. So
477
+ * a deck that took "no `epk`" to mean "an older peer" would be taken back to a
478
+ * key with no forward secrecy by anybody who deleted the field both ways, with
479
+ * both proofs still good. The mark goes where both proofs already reach: each
480
+ * end decides from the two challenges, each end's own challenge is the one it
481
+ * binds, and a middleman who takes the mark off either leaves the two ends on
482
+ * different transcripts — and on different schedules — so the dialler's proof
483
+ * fails at the listener before a frame is sent. And once both challenges say
484
+ * so, a missing or unusable `epk` is refused, never read as an older deck.
485
+ *
486
+ * IN THE RANDOM PART RATHER THAN AS A WORD, which is TLS 1.3's own answer to
487
+ * the same problem: its downgrade sentinel is a fixed value in the last bytes
488
+ * of ServerHello.random (RFC 8446 §4.1.3), a field the older handshake already
489
+ * covers and reads as nothing but random. Here that keeps a challenge the
490
+ * shape #810 gave it — 32 hex characters and `.seal1` — which #810's decks
491
+ * send too and its suite pins. It costs 32 of the 128 random bits, and 96 is
492
+ * still no challenge anybody sees twice. And a deck from #810 whose sixteen
493
+ * random bytes happen to end in these four, one handshake in 2^32, reads as a
494
+ * deck that mixes and sent no key: that round is refused rather than
495
+ * downgraded, and the next one has a new challenge.
496
+ */
497
+ export const EPHEMERAL = "eph1";
498
+ const EPHEMERAL_HEX = Buffer.from(EPHEMERAL, "ascii").toString("hex");
499
+ const MIXES = new RegExp(`^[0-9a-f]{24}${EPHEMERAL_HEX}\\.`);
500
+
501
+ /** Letters, digits, `.`, `_` and `-`, bounded. Ours are 38 characters and an
502
+ * older deck's are 32; the bound is only there so a challenge is never the
503
+ * largest thing a stranger can make this hash. */
504
+ const CHALLENGE = /^[0-9A-Za-z._-]{1,128}$/;
505
+
506
+ /** This deck's challenge for one connection: twelve fresh random bytes and the
507
+ * four that say it mixes, then the mark that says it seals. A deck speaking as
508
+ * one of #810's version sends sixteen random bytes and the mark, and one from
509
+ * before #810 sixteen random bytes and nothing, which is how the suite plays
510
+ * each. Mixing rides on sealing: no deck was ever released that did one
511
+ * without the other, so `seals: false` says neither. */
512
+ export function challengeFor({ seals = true, ephemeral = seals } = {}) {
513
+ if (!seals) return randomBytes(16).toString("hex");
514
+ const nonce = ephemeral
515
+ ? `${randomBytes(12).toString("hex")}${EPHEMERAL_HEX}`
516
+ : randomBytes(16).toString("hex");
517
+ return `${nonce}.${SEALS}`;
518
+ }
519
+
520
+ /** A challenge a peer sent, or null for one no deck sends — see "WHICH IS WHY
521
+ * A `|` IN A CHALLENGE IS REFUSED" above. */
522
+ export function readChallenge(raw) {
523
+ return typeof raw === "string" && CHALLENGE.test(raw) ? raw : null;
524
+ }
525
+
526
+ /** Whether the deck that made this challenge said it seals. */
527
+ export function sealsFrames(challenge) {
528
+ return typeof challenge === "string" && challenge.endsWith(`.${SEALS}`);
529
+ }
530
+
531
+ /** Whether the deck that made this challenge said it mixes a key pair of its
532
+ * own into the connection's key — see EPHEMERAL. Only in a challenge that
533
+ * says it seals as well, so a deck from before #810, whose challenge is
534
+ * sixteen random bytes and nothing else, can never be taken for one. */
535
+ export function mixesEphemeral(challenge) {
536
+ return sealsFrames(challenge) && MIXES.test(challenge);
537
+ }
538
+
539
+ /** Which way a frame is going, named by who dialled — never by fingerprint,
540
+ * for the reason above. */
541
+ const WAY = Object.freeze({ caller: "caller->listener", listener: "listener->caller" });
542
+
543
+ /**
544
+ * The key and IV each end seals its own frames with, from one connection's
545
+ * session key.
546
+ *
547
+ * HKDF like sessionKey, one label per direction and per purpose: `key` and `iv`
548
+ * are separate expansions rather than one long one cut in two, the way RFC 8446
549
+ * §7.3 takes a write key and a write IV from one traffic secret.
550
+ */
551
+ export function frameKeys(key) {
552
+ const derive = (way, what, length) => Buffer.from(hkdfSync("sha256", key, Buffer.alloc(0),
553
+ Buffer.from(`ccdeck-lan-v${PROTOCOL}|frames|${way}|${what}`, "utf8"), length));
554
+ const one = way => ({ way, key: derive(way, "key", 32), iv: derive(way, "iv", 12) });
555
+ return { caller: one(WAY.caller), listener: one(WAY.listener) };
556
+ }
557
+
558
+ /** Frame n's nonce: its direction's IV, XORed with n as a 64-bit big-endian
559
+ * number in the last eight bytes (RFC 8446 §5.3). */
560
+ function frameNonce(iv, n) {
561
+ const out = Buffer.alloc(12);
562
+ out.writeBigUInt64BE(BigInt(n), 4);
563
+ for (let i = 0; i < out.length; i++) out[i] ^= iv[i];
564
+ return out;
565
+ }
566
+
567
+ /**
568
+ * One end of a sealed connection: wrap what this end sends, unwrap what the
569
+ * other end sent — in order, once each.
570
+ *
571
+ * `role` is which end this is: "caller" for the deck that dialled, "listener"
572
+ * for the deck that answered. Each end seals under its own direction's key and
573
+ * opens under the other's, so a frame only ever opens at the deck it was sealed
574
+ * for.
575
+ *
576
+ * On the wire a frame is `{ sealed, tag }` and nothing else — not the verb, not
577
+ * the number, not the nonce. The number is the one each end already expects,
578
+ * and a frame that is not the one expected, or that carries anything beside
579
+ * those two fields, does not open.
580
+ */
581
+ export function frameChannel(key, role) {
582
+ if (role !== "caller" && role !== "listener") throw new Error(`no such end: ${role}`);
583
+ const keys = frameKeys(key);
584
+ const out = keys[role];
585
+ const inn = keys[role === "caller" ? "listener" : "caller"];
586
+ let sent = 0;
587
+ let opened = 0;
588
+ let broken = false;
589
+ // The number goes in the additional data as well as the nonce. The nonce
590
+ // alone binds it; this says so in the one place a reader looks for what a
591
+ // tag covers, and it survives anybody changing how the nonce is built.
592
+ const aad = (way, n) => `ccdeck-lan-v${PROTOCOL}|frame|${way}|${n}`;
593
+ return {
594
+ wrap(obj) {
595
+ // 2^53 frames is not a connection anybody holds open — a round is a
596
+ // handful — but a counter that wrapped would be a nonce used twice, and
597
+ // refusing is cheaper than arguing about it.
598
+ if (!Number.isSafeInteger(sent + 1)) throw new Error("this connection has sealed all it may");
599
+ const n = sent++;
600
+ const { tag, body } = seal(out.key, JSON.stringify(obj), aad(out.way, n), frameNonce(out.iv, n));
601
+ return { sealed: body, tag };
602
+ },
603
+ unwrap(frame) {
604
+ if (broken) return null;
605
+ const shaped = !!frame && typeof frame === "object" && !Array.isArray(frame)
606
+ && Object.keys(frame).length === 2 && typeof frame.sealed === "string" && typeof frame.tag === "string";
607
+ const text = shaped
608
+ ? open(inn.key, { iv: frameNonce(inn.iv, opened).toString("base64"), tag: frame.tag, body: frame.sealed },
609
+ aad(inn.way, opened))
610
+ : null;
611
+ let msg = null;
612
+ if (text != null) { try { msg = JSON.parse(text); } catch { msg = null; } }
613
+ // A record, which is what frameReader asks of a plain frame: a sealed
614
+ // array is no more a frame than an unsealed one.
615
+ if (!msg || typeof msg !== "object" || Array.isArray(msg)) { broken = true; return null; }
616
+ opened++;
617
+ return msg;
618
+ },
619
+ };
620
+ }