ccdeck 3.22.14 → 3.23.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 (51) hide show
  1. package/README.md +53 -10
  2. package/THIRD_PARTY_NOTICES.md +123 -15
  3. package/bin/deck.js +264 -21
  4. package/dist/web/assets/BrowserWatchModal-DD6srgsk.js +1 -0
  5. package/dist/web/assets/UsageHistoryModal-zExZpRWU.js +1 -0
  6. package/dist/web/assets/index-Bf-qcZ6x.css +1 -0
  7. package/dist/web/assets/index-Dsca2TwS.js +296 -0
  8. package/dist/web/index.html +9 -5
  9. package/hook/hook.js +631 -99
  10. package/package.json +1 -1
  11. package/release-notes.json +62 -1
  12. package/src/server/activity.mjs +42 -4
  13. package/src/server/args.mjs +30 -4
  14. package/src/server/browser-history.mjs +164 -38
  15. package/src/server/browser-presence.mjs +60 -0
  16. package/src/server/browser-react.mjs +160 -7
  17. package/src/server/browser-watch-store.mjs +319 -17
  18. package/src/server/browser-watch.mjs +304 -60
  19. package/src/server/ccusage.mjs +20 -4
  20. package/src/server/claude-accounts.mjs +79 -34
  21. package/src/server/claude-identity.mjs +121 -0
  22. package/src/server/cswap-admin.mjs +160 -133
  23. package/src/server/cswap-auto.mjs +86 -7
  24. package/src/server/cswap-install.mjs +280 -43
  25. package/src/server/deck-home.mjs +1 -1
  26. package/src/server/deck-prefs.mjs +252 -36
  27. package/src/server/deck-probe.mjs +1 -1
  28. package/src/server/exec.mjs +84 -3
  29. package/src/server/global-install.mjs +13 -0
  30. package/src/server/index.mjs +1454 -182
  31. package/src/server/installer.mjs +136 -8
  32. package/src/server/lan-about.mjs +8 -3
  33. package/src/server/lan-engine.mjs +385 -51
  34. package/src/server/lan-socket.mjs +384 -57
  35. package/src/server/lan-sync.mjs +495 -22
  36. package/src/server/log-writer.mjs +575 -2
  37. package/src/server/login-service.mjs +31 -4
  38. package/src/server/open-url.mjs +22 -2
  39. package/src/server/output-watch.mjs +59 -8
  40. package/src/server/purge-key.mjs +135 -0
  41. package/src/server/quota.mjs +112 -7
  42. package/src/server/running-deck.mjs +15 -1
  43. package/src/server/self-update.mjs +98 -4
  44. package/src/server/session-recap.mjs +89 -0
  45. package/src/server/static-cache.mjs +67 -0
  46. package/src/server/stop-deck.mjs +2 -2
  47. package/src/server/store-lock.mjs +51 -0
  48. package/src/server/supervisor.mjs +1 -1
  49. package/src/server/system-metrics.mjs +143 -17
  50. package/dist/web/assets/index-BdYsAtN8.js +0 -274
  51. package/dist/web/assets/index-CgwmcBHv.css +0 -1
@@ -172,8 +172,18 @@ export function cleanName(raw, fallback = "unnamed deck") {
172
172
  // this string reaches a terminal log as well as a page, and a name carrying
173
173
  // an ANSI escape can move a cursor or repaint a line. A literal control
174
174
  // character in the source would be invisible to whoever reads this next.
175
+ // And \p{Cf}, the FORMAT class, which the control ranges above do not cover:
176
+ // U+202E RIGHT-TO-LEFT OVERRIDE, U+2066-2069 (the directional isolates),
177
+ // U+200B and U+00AD. A name is drawn in one inline formatting context with
178
+ // the peer's address and the words "wants to pair" (LanSyncSection.tsx:1496),
179
+ // and there is no `dir`, no <bdi> and no `unicode-bidi: isolate` anywhere in
180
+ // the sheet — so an override's scope runs to the end of that line and the
181
+ // address the operator is checking renders reversed. That row's own comment
182
+ // calls the fingerprint "the only value that cannot be chosen by whoever is
183
+ // asking"; the strings beside it should at least not be able to rearrange it.
175
184
  const stripped = raw
176
185
  .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
186
+ .replace(/\p{Cf}/gu, "")
177
187
  .replace(/\s+/g, " ")
178
188
  .trim();
179
189
  return stripped ? [...stripped].slice(0, MAX_NAME).join("") : fallback;
@@ -206,7 +216,7 @@ export function cleanName(raw, fallback = "unnamed deck") {
206
216
  * public half is what a peer pins, and its fingerprint is what a person
207
217
  * compares.
208
218
  */
209
- export function newKeypair() {
219
+ function newKeypair() {
210
220
  const { publicKey, privateKey } = generateKeyPairSync("x25519");
211
221
  return {
212
222
  secret: privateKey.export({ type: "pkcs8", format: "der" }).toString("base64"),
@@ -244,15 +254,98 @@ export function identityFrom(secret) {
244
254
  *
245
255
  * PER CONNECTION, never stored. The old design sealed credentials under one
246
256
  * long-lived group key, which meant a single recorded transfer stayed readable
247
- * to anybody who ever learned the passphrase. This gives forward secrecy for
248
- * free: the ephemeral halves are gone when the socket is.
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.
249
316
  */
250
- export function sessionKey(secret, peerPub, transcript) {
317
+ export function sessionKey(secret, peerPub, transcript, ephemeral = null) {
251
318
  const priv = createPrivateKey({ key: Buffer.from(secret, "base64"), format: "der", type: "pkcs8" });
252
319
  const theirs = createPublicKey({ key: Buffer.from(peerPub, "base64"), format: "der", type: "spki" });
253
- const shared = diffieHellman({ privateKey: priv, publicKey: theirs });
254
- return Buffer.from(hkdfSync("sha256", shared, Buffer.alloc(0),
255
- Buffer.from(`ccdeck-lan-v${PROTOCOL}|${transcript}`, "utf8"), 32));
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
+ }
256
349
  }
257
350
 
258
351
  /**
@@ -262,9 +355,28 @@ export function sessionKey(secret, peerPub, transcript) {
262
355
  * two build differently is a handshake that never agrees and a bug that only
263
356
  * appears between two machines. Caller first, always, so the order does not
264
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.
265
373
  */
266
- export function handshakeTranscript(callerFp, listenerFp, callerChallenge, listenerChallenge) {
267
- return `${callerFp}|${listenerFp}|${callerChallenge}|${listenerChallenge}`;
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}`;
268
380
  }
269
381
 
270
382
  /**
@@ -273,16 +385,60 @@ export function handshakeTranscript(callerFp, listenerFp, callerChallenge, liste
273
385
  * It arrives from the network before anything has been agreed, so it is checked
274
386
  * for being an X25519 public key at all rather than trusted to be one — a
275
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.
276
398
  */
277
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) {
278
422
  if (typeof raw !== "string" || raw.length < 40 || raw.length > 128) return null;
279
423
  try {
280
424
  const der = Buffer.from(raw, "base64");
281
- createPublicKey({ key: der, format: "der", type: "spki" });
282
- return { pub: raw, fp: fingerprint(der) };
425
+ return createPublicKey({ key: der, format: "der", type: "spki" }).asymmetricKeyType === "x25519" ? der : null;
283
426
  } catch { return null; }
284
427
  }
285
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
+
286
442
  // ── the invite ──────────────────────────────────────────────────────────────
287
443
  //
288
444
  // ONE PIECE OF TEXT THAT CARRIES EVERYTHING, and it exists because of a real
@@ -348,7 +504,19 @@ export function mintInvite({ addrs, name, now = Date.now(), code = inviteCode()
348
504
  const list = (Array.isArray(addrs) ? addrs : []).filter(a => typeof a === "string" && a).slice(0, MAX_INVITE_ADDRS);
349
505
  if (!list.length) return null;
350
506
  const expiresAt = now + INVITE_MS;
351
- const body = { v: PROTOCOL, a: list, n: cleanName(name), c: code, x: expiresAt };
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 };
352
520
  return { token: INVITE_PREFIX + b64url(Buffer.from(JSON.stringify(body), "utf8")), code, expiresAt };
353
521
  }
354
522
 
@@ -385,7 +553,13 @@ export function readInvite(raw, now = Date.now()) {
385
553
  .map(p => ({ addr: p.raw.slice(0, p.at), port: Number(p.raw.slice(p.at + 1)) }))
386
554
  .filter(p => p.addr && Number.isInteger(p.port) && p.port > 0 && p.port < 65_536);
387
555
  if (!addrs.length) return null;
388
- return { addrs, name: cleanName(body.n), code: body.c, expiresAt: body.x, expired };
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
+ };
389
563
  }
390
564
 
391
565
  /**
@@ -400,6 +574,29 @@ export function inviteProof(code, transcript) {
400
574
  return createHmac("sha256", `ccdeck-invite-v${PROTOCOL}`).update(`${code}|${transcript}`).digest("hex");
401
575
  }
402
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
+
403
600
  // ── the beacon ──────────────────────────────────────────────────────────────
404
601
 
405
602
  /**
@@ -840,9 +1037,15 @@ export function transferChallenge(key, { nonce, accountKey, fromFp, toFp }) {
840
1037
  * The additional data binds the ciphertext to the two decks and the account, so
841
1038
  * a blob captured on one exchange cannot be replayed into another as if it were
842
1039
  * about something else.
1040
+ *
1041
+ * `iv` IS RANDOM UNLESS THE CALLER OWNS A COUNTER, and exactly one does. A
1042
+ * random 96-bit nonce is safe for the handful of seals a connection makes this
1043
+ * way; frameChannel seals every frame of a connection and derives each nonce
1044
+ * from the frame's number instead, so that a repeat is impossible rather than
1045
+ * unlikely and a frame's place in the conversation is part of what its tag
1046
+ * proves. Nothing else passes one.
843
1047
  */
844
- export function seal(key, plaintext, aad) {
845
- const iv = randomBytes(12);
1048
+ export function seal(key, plaintext, aad, iv = randomBytes(12)) {
846
1049
  const c = createCipheriv("aes-256-gcm", key, iv);
847
1050
  c.setAAD(Buffer.from(aad, "utf8"));
848
1051
  const body = Buffer.concat([c.update(plaintext, "utf8"), c.final()]);
@@ -851,10 +1054,18 @@ export function seal(key, plaintext, aad) {
851
1054
 
852
1055
  /** The other half. Returns null rather than throwing on every failure — a
853
1056
  * wrong tag, a wrong key, a truncated body and a hand-built packet all mean
854
- * the same thing to the caller, which is "do not use this". */
1057
+ * the same thing to the caller, which is "do not use this".
1058
+ *
1059
+ * THE TAG IS ALL SIXTEEN BYTES OR NOTHING. Without `authTagLength` node takes
1060
+ * whatever length `setAuthTag` is handed, down to four bytes — measured on
1061
+ * Node 24: a tag cut to its first four bytes opened, with nothing but a
1062
+ * DEP0182 warning on stderr to say so. A 32-bit tag is one forgery in four
1063
+ * billion tries instead of none, on the value that decides whether a frame or
1064
+ * a credential is genuine. `seal` has only ever written sixteen, so no deck of
1065
+ * any version is refused by this. */
855
1066
  export function open(key, { iv, tag, body }, aad) {
856
1067
  try {
857
- const d = createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "base64"));
1068
+ const d = createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "base64"), { authTagLength: 16 });
858
1069
  d.setAAD(Buffer.from(aad, "utf8"));
859
1070
  d.setAuthTag(Buffer.from(tag, "base64"));
860
1071
  return Buffer.concat([d.update(Buffer.from(body, "base64")), d.final()]).toString("utf8");
@@ -863,6 +1074,237 @@ export function open(key, { iv, tag, body }, aad) {
863
1074
  }
864
1075
  }
865
1076
 
1077
+ // ── the frames after the handshake ──────────────────────────────────────────
1078
+ //
1079
+ // SEALED, EVERY ONE, AND UNTIL #810 NONE OF THEM WAS. The handshake proves both
1080
+ // keys and derives one for the connection, and that key sealed exactly two
1081
+ // things: the credential inside `have`, and the version card. Everything else
1082
+ // was a JSON line on a TCP socket. Captured through a relay on loopback between
1083
+ // two engines of the version before this, the round that healed one account
1084
+ // and added another read, after `ok`:
1085
+ //
1086
+ // {"t":"manifest","accounts":[{"key":"claude1@sapec.md@@org-1","email":"claude1@sapec.md","alive":true},…],"current":{"key":"claude1@sapec.md@@org-1"}}
1087
+ // {"t":"want","key":"claude2@sapec.md@@org-2","nonce":"…","proof":"…"}
1088
+ // {"t":"have","key":"claude2@sapec.md@@org-2","sealed":{…}}
1089
+ //
1090
+ // So anybody on the same network segment could read every address a deck
1091
+ // shares, which of its copies are broken, which one the machine is on, and
1092
+ // which one is being repaired. No credential left that way and nobody could
1093
+ // pose as a deck; what leaked was which accounts are on which machines, which
1094
+ // is the one thing the share list promises to tell nobody else. And it was not
1095
+ // only readable: nothing after `ok` carried a MAC, so a manifest could be
1096
+ // rewritten in flight and the panel would draw whatever it was handed.
1097
+ //
1098
+ // ONE KEY PER DIRECTION, AND A COUNTER FOR A NONCE. Both ends start from the
1099
+ // same session key, so a single frame key would have both of them sealing frame
1100
+ // 0, frame 1, … under one key — and AES-GCM under a repeated nonce hands over
1101
+ // the XOR of the two plaintexts and the means to forge. So each direction gets
1102
+ // its own key and its own IV out of HKDF, the way sessionKey gets its own, and
1103
+ // the labels name the direction by ROLE rather than by fingerprint: two decks
1104
+ // holding one key (a copied ~/.claude) have one fingerprint between them, and a
1105
+ // label built from it would be the same label twice. The nonce is the IV XORed
1106
+ // with the frame's number, which is TLS 1.3's per-record nonce (RFC 8446 §5.3)
1107
+ // over its per-direction write key and write IV (§7.3).
1108
+ //
1109
+ // THE NUMBER IS NEVER SENT. Each end counts what it has sealed and what it has
1110
+ // opened, and opens the next frame under the number it expects next. A frame
1111
+ // played twice, two frames swapped, one dropped from the middle, one lifted
1112
+ // from another connection, or one sent back to the deck that sealed it — each
1113
+ // is opened under the wrong number or the wrong key, its tag does not match,
1114
+ // and the connection ends. There is no second try: a channel that has refused
1115
+ // one frame refuses every one after it, and the socket goes with it.
1116
+ //
1117
+ // THE ANNOUNCEMENT RIDES INSIDE THE CHALLENGE, and that is the whole defence
1118
+ // against being talked back down to plain text. An older deck has to keep
1119
+ // working, so a peer that says nothing about sealing is answered in the clear —
1120
+ // which makes "says nothing" exactly what a middleman would try to arrange. A
1121
+ // flag of its own in `hello` or `challenge` is a flag nobody signs: the proofs
1122
+ // cover the two fingerprints and the two challenges and nothing else, and an
1123
+ // older deck will never cover more. Delete the flag both ways and each end sees
1124
+ // a peer older than it is. Fold the flag into a new kind of proof and each end
1125
+ // falls back to the old kind for the peer it now believes is old — the same
1126
+ // round, readable, with nothing on either side to say so.
1127
+ //
1128
+ // The challenge is the one field every deck already binds. It goes verbatim
1129
+ // into the transcript sessionKey derives from, and into both proofs. So a deck
1130
+ // of this version marks its own (`<32 hex>.seal1`), an older deck carries that
1131
+ // as the opaque string it always took, and a middleman who takes the mark off
1132
+ // either challenge leaves the two ends deriving different keys: the caller's
1133
+ // proof fails at the listener and the handshake ends before a frame is sent.
1134
+ // TLS 1.3 puts its downgrade sentinel in ServerHello.random for the same reason
1135
+ // (RFC 8446 §4.1.3) — it is the field the older handshake already covers.
1136
+ //
1137
+ // WHICH IS WHY A `|` IN A CHALLENGE IS REFUSED. The transcript joins its four
1138
+ // fields with `|` and no lengths, so a middleman who could put one inside a
1139
+ // challenge could move characters from one challenge into the other and leave
1140
+ // both ends agreeing on the transcript, and so on the key, while each read a
1141
+ // different challenge from its peer than the one that peer sent. Every deck
1142
+ // sends hex, so a deck of this version takes letters, digits, `.`, `_` and `-`
1143
+ // and refuses anything else before it derives a key from it.
1144
+ //
1145
+ // WHAT IT DOES NOT HIDE: how long each frame is and when it was sent — a
1146
+ // manifest of ten accounts is longer than one of two. And it is exactly as
1147
+ // strong as the session key it starts from; see sessionKey for what that is.
1148
+
1149
+ /** What a deck of this version appends to its challenge to say it seals every
1150
+ * frame after the handshake. A word with a number rather than a bit, so a
1151
+ * later frame format is a new word an older deck simply does not recognise. */
1152
+ export const SEALS = "seal1";
1153
+
1154
+ /**
1155
+ * What a deck of this version ends the random part of its challenge with, to
1156
+ * say it mixes a key pair made for the connection into the connection's key
1157
+ * (#1120; see sessionKey): the four bytes of "eph1" in ASCII, which in the hex
1158
+ * a challenge is written in is `65706831`.
1159
+ *
1160
+ * IN THE CHALLENGE FOR THE REASON `.seal1` IS, and stripped it fails the same
1161
+ * way. Mixing needs a new field — each end's ephemeral public key, `epk` — and
1162
+ * an older deck binds nothing but the fingerprints and the two challenges. So
1163
+ * a deck that took "no `epk`" to mean "an older peer" would be taken back to a
1164
+ * key with no forward secrecy by anybody who deleted the field both ways, with
1165
+ * both proofs still good. The mark goes where both proofs already reach: each
1166
+ * end decides from the two challenges, each end's own challenge is the one it
1167
+ * binds, and a middleman who takes the mark off either leaves the two ends on
1168
+ * different transcripts — and on different schedules — so the dialler's proof
1169
+ * fails at the listener before a frame is sent. And once both challenges say
1170
+ * so, a missing or unusable `epk` is refused, never read as an older deck.
1171
+ *
1172
+ * IN THE RANDOM PART RATHER THAN AS A WORD, which is TLS 1.3's own answer to
1173
+ * the same problem: its downgrade sentinel is a fixed value in the last bytes
1174
+ * of ServerHello.random (RFC 8446 §4.1.3), a field the older handshake already
1175
+ * covers and reads as nothing but random. Here that keeps a challenge the
1176
+ * shape #810 gave it — 32 hex characters and `.seal1` — which #810's decks
1177
+ * send too and its suite pins. It costs 32 of the 128 random bits, and 96 is
1178
+ * still no challenge anybody sees twice. And a deck from #810 whose sixteen
1179
+ * random bytes happen to end in these four, one handshake in 2^32, reads as a
1180
+ * deck that mixes and sent no key: that round is refused rather than
1181
+ * downgraded, and the next one has a new challenge.
1182
+ */
1183
+ export const EPHEMERAL = "eph1";
1184
+ const EPHEMERAL_HEX = Buffer.from(EPHEMERAL, "ascii").toString("hex");
1185
+ const MIXES = new RegExp(`^[0-9a-f]{24}${EPHEMERAL_HEX}\\.`);
1186
+
1187
+ /** Letters, digits, `.`, `_` and `-`, bounded. Ours are 38 characters and an
1188
+ * older deck's are 32; the bound is only there so a challenge is never the
1189
+ * largest thing a stranger can make this hash. */
1190
+ const CHALLENGE = /^[0-9A-Za-z._-]{1,128}$/;
1191
+
1192
+ /** This deck's challenge for one connection: twelve fresh random bytes and the
1193
+ * four that say it mixes, then the mark that says it seals. A deck speaking as
1194
+ * one of #810's version sends sixteen random bytes and the mark, and one from
1195
+ * before #810 sixteen random bytes and nothing, which is how the suite plays
1196
+ * each. Mixing rides on sealing: no deck was ever released that did one
1197
+ * without the other, so `seals: false` says neither. */
1198
+ export function challengeFor({ seals = true, ephemeral = seals } = {}) {
1199
+ if (!seals) return randomBytes(16).toString("hex");
1200
+ const nonce = ephemeral
1201
+ ? `${randomBytes(12).toString("hex")}${EPHEMERAL_HEX}`
1202
+ : randomBytes(16).toString("hex");
1203
+ return `${nonce}.${SEALS}`;
1204
+ }
1205
+
1206
+ /** A challenge a peer sent, or null for one no deck sends — see "WHICH IS WHY
1207
+ * A `|` IN A CHALLENGE IS REFUSED" above. */
1208
+ export function readChallenge(raw) {
1209
+ return typeof raw === "string" && CHALLENGE.test(raw) ? raw : null;
1210
+ }
1211
+
1212
+ /** Whether the deck that made this challenge said it seals. */
1213
+ export function sealsFrames(challenge) {
1214
+ return typeof challenge === "string" && challenge.endsWith(`.${SEALS}`);
1215
+ }
1216
+
1217
+ /** Whether the deck that made this challenge said it mixes a key pair of its
1218
+ * own into the connection's key — see EPHEMERAL. Only in a challenge that
1219
+ * says it seals as well, so a deck from before #810, whose challenge is
1220
+ * sixteen random bytes and nothing else, can never be taken for one. */
1221
+ export function mixesEphemeral(challenge) {
1222
+ return sealsFrames(challenge) && MIXES.test(challenge);
1223
+ }
1224
+
1225
+ /** Which way a frame is going, named by who dialled — never by fingerprint,
1226
+ * for the reason above. */
1227
+ const WAY = Object.freeze({ caller: "caller->listener", listener: "listener->caller" });
1228
+
1229
+ /**
1230
+ * The key and IV each end seals its own frames with, from one connection's
1231
+ * session key.
1232
+ *
1233
+ * HKDF like sessionKey, one label per direction and per purpose: `key` and `iv`
1234
+ * are separate expansions rather than one long one cut in two, the way RFC 8446
1235
+ * §7.3 takes a write key and a write IV from one traffic secret.
1236
+ */
1237
+ export function frameKeys(key) {
1238
+ const derive = (way, what, length) => Buffer.from(hkdfSync("sha256", key, Buffer.alloc(0),
1239
+ Buffer.from(`ccdeck-lan-v${PROTOCOL}|frames|${way}|${what}`, "utf8"), length));
1240
+ const one = way => ({ way, key: derive(way, "key", 32), iv: derive(way, "iv", 12) });
1241
+ return { caller: one(WAY.caller), listener: one(WAY.listener) };
1242
+ }
1243
+
1244
+ /** Frame n's nonce: its direction's IV, XORed with n as a 64-bit big-endian
1245
+ * number in the last eight bytes (RFC 8446 §5.3). */
1246
+ function frameNonce(iv, n) {
1247
+ const out = Buffer.alloc(12);
1248
+ out.writeBigUInt64BE(BigInt(n), 4);
1249
+ for (let i = 0; i < out.length; i++) out[i] ^= iv[i];
1250
+ return out;
1251
+ }
1252
+
1253
+ /**
1254
+ * One end of a sealed connection: wrap what this end sends, unwrap what the
1255
+ * other end sent — in order, once each.
1256
+ *
1257
+ * `role` is which end this is: "caller" for the deck that dialled, "listener"
1258
+ * for the deck that answered. Each end seals under its own direction's key and
1259
+ * opens under the other's, so a frame only ever opens at the deck it was sealed
1260
+ * for.
1261
+ *
1262
+ * On the wire a frame is `{ sealed, tag }` and nothing else — not the verb, not
1263
+ * the number, not the nonce. The number is the one each end already expects,
1264
+ * and a frame that is not the one expected, or that carries anything beside
1265
+ * those two fields, does not open.
1266
+ */
1267
+ export function frameChannel(key, role) {
1268
+ if (role !== "caller" && role !== "listener") throw new Error(`no such end: ${role}`);
1269
+ const keys = frameKeys(key);
1270
+ const out = keys[role];
1271
+ const inn = keys[role === "caller" ? "listener" : "caller"];
1272
+ let sent = 0;
1273
+ let opened = 0;
1274
+ let broken = false;
1275
+ // The number goes in the additional data as well as the nonce. The nonce
1276
+ // alone binds it; this says so in the one place a reader looks for what a
1277
+ // tag covers, and it survives anybody changing how the nonce is built.
1278
+ const aad = (way, n) => `ccdeck-lan-v${PROTOCOL}|frame|${way}|${n}`;
1279
+ return {
1280
+ wrap(obj) {
1281
+ // 2^53 frames is not a connection anybody holds open — a round is a
1282
+ // handful — but a counter that wrapped would be a nonce used twice, and
1283
+ // refusing is cheaper than arguing about it.
1284
+ if (!Number.isSafeInteger(sent + 1)) throw new Error("this connection has sealed all it may");
1285
+ const n = sent++;
1286
+ const { tag, body } = seal(out.key, JSON.stringify(obj), aad(out.way, n), frameNonce(out.iv, n));
1287
+ return { sealed: body, tag };
1288
+ },
1289
+ unwrap(frame) {
1290
+ if (broken) return null;
1291
+ const shaped = !!frame && typeof frame === "object" && !Array.isArray(frame)
1292
+ && Object.keys(frame).length === 2 && typeof frame.sealed === "string" && typeof frame.tag === "string";
1293
+ const text = shaped
1294
+ ? open(inn.key, { iv: frameNonce(inn.iv, opened).toString("base64"), tag: frame.tag, body: frame.sealed },
1295
+ aad(inn.way, opened))
1296
+ : null;
1297
+ let msg = null;
1298
+ if (text != null) { try { msg = JSON.parse(text); } catch { msg = null; } }
1299
+ // A record, which is what frameReader asks of a plain frame: a sealed
1300
+ // array is no more a frame than an unsealed one.
1301
+ if (!msg || typeof msg !== "object" || Array.isArray(msg)) { broken = true; return null; }
1302
+ opened++;
1303
+ return msg;
1304
+ },
1305
+ };
1306
+ }
1307
+
866
1308
  // ── which copy wins ─────────────────────────────────────────────────────────
867
1309
 
868
1310
  /**
@@ -941,11 +1383,19 @@ export function plan(local, remote) {
941
1383
  * this deck cannot use is worth nothing to a peer, so saying so plainly is what
942
1384
  * stops a peer asking for it.
943
1385
  *
944
- * Emails are in it, in the clear inside the encrypted channel. That is a
945
- * deliberate line: a manifest only ever reaches a deck that has already proved
946
- * it holds the passphrase, and the panel has to name the account it is offering
947
- * to heal. Hashing the email would buy nothing against that reader and would
948
- * cost the one thing the row needs to say.
1386
+ * Emails are in it, and the manifest is sealed on its way. This said "in the
1387
+ * clear inside the encrypted channel" until #810, and there was no such
1388
+ * channel: the handshake derived a key and sealed a credential and a version
1389
+ * card with it, and nothing else — so every email in this list crossed the
1390
+ * network readable by anybody on it. Between two decks that both seal, every
1391
+ * frame after the handshake now is (see frameChannel). Toward a deck from
1392
+ * before that it still travels plain, because that deck cannot open anything
1393
+ * else, until it updates.
1394
+ *
1395
+ * Plain emails INSIDE the seal are a deliberate line: a manifest only ever
1396
+ * reaches a deck that proved the key somebody here accepted, and the panel has
1397
+ * to name the account it is offering to heal. Hashing the email would buy
1398
+ * nothing against that reader and would cost the one thing the row needs to say.
949
1399
  *
950
1400
  * `shared` is the user's list. An account absent from it is absent from the
951
1401
  * manifest entirely — not listed as withheld, which would tell the group that
@@ -959,3 +1409,26 @@ export function manifestFor(accounts, shared) {
959
1409
  .map(a => ({ key: a.key, email: a.email, alive: !!a.alive }))
960
1410
  .sort((a, b) => a.key.localeCompare(b.key));
961
1411
  }
1412
+
1413
+ /**
1414
+ * Which account this deck is on, as a frame field for the decks it is paired
1415
+ * with — or nothing.
1416
+ *
1417
+ * ONLY ONE IT SHARES IS EVER NAMED. The account a deck works on is named only
1418
+ * when it is also in the list the owner ticked, so the promise the share list
1419
+ * makes — an unticked account is never told to anybody — holds for this too.
1420
+ * On one it does not share, it says `other` and nothing more: a paired deck
1421
+ * reads "on another account", which says this machine is working without
1422
+ * saying on what.
1423
+ *
1424
+ * `hidden` is the owner's switch turned off, and that IS said, on purpose: a
1425
+ * paired deck then reads "current account hidden" rather than nothing, so the
1426
+ * person over there knows this machine is working and chose not to say where.
1427
+ * A deck with no account at all says nothing.
1428
+ */
1429
+ export function currentFor(accounts, shared, shareActive) {
1430
+ if (shareActive === false) return { current: { hidden: true } };
1431
+ const on = accounts.find(a => a.active);
1432
+ if (!on) return {};
1433
+ return new Set(shared).has(on.key) ? { current: { key: on.key } } : { current: { other: true } };
1434
+ }