ccdeck 3.22.15 → 3.23.1

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-B00oF8_I.js +1 -0
  5. package/dist/web/assets/UsageHistoryModal-IkFiv0Vg.js +1 -0
  6. package/dist/web/assets/index-DgrvXP9K.css +1 -0
  7. package/dist/web/assets/index-Q2E4ePHr.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 +244 -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 -184
  31. package/src/server/installer.mjs +136 -8
  32. package/src/server/lan-about.mjs +8 -3
  33. package/src/server/lan-engine.mjs +335 -47
  34. package/src/server/lan-socket.mjs +384 -57
  35. package/src/server/lan-sync.mjs +472 -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-BYqkKWPC.js +0 -274
  51. package/dist/web/assets/index-s_dF8YYA.css +0 -1
@@ -25,6 +25,20 @@ import dgram from "node:dgram";
25
25
  import { networkInterfaces } from "node:os";
26
26
 
27
27
  /** An IPv4 dotted quad as four numbers, or null for anything that is not one. */
28
+ /**
29
+ * What each refusal reason means, in a sentence the LAN panel can print.
30
+ *
31
+ * At module scope so it is one object rather than one per frame, and named so
32
+ * the `Object.hasOwn` guard below reads as the rule it is rather than as
33
+ * punctuation. The keys are the protocol's, not a user's.
34
+ */
35
+ const REFUSALS = Object.freeze({
36
+ pending: "waiting for the other deck to accept this one",
37
+ declined: "that deck said no",
38
+ impostor: "that deck has this one pinned under a different key",
39
+ "bad proof": "the other deck refused this one's proof",
40
+ });
41
+
28
42
  function quad(text) {
29
43
  const parts = String(text ?? "").split(".");
30
44
  if (parts.length !== 4) return null;
@@ -76,8 +90,9 @@ export function broadcastTargets(ifaces) {
76
90
  import net from "node:net";
77
91
  import { randomBytes } from "node:crypto";
78
92
  import {
79
- beaconPayload, beaconVerdict, handshakeTranscript, hostId, notePeer, proof, proofOk,
80
- inviteProof, readBeacon, readPub, sessionKey, trustedPeer,
93
+ beaconPayload, beaconVerdict, challengeFor, cleanName, ephemeralPair, frameChannel, handshakeTranscript,
94
+ hostId, mixesEphemeral, notePeer, proof, proofOk, inviteProof, inviteProofBack, readBeacon, readChallenge,
95
+ readEphemeral, readPub, sealsFrames, sessionKey, trustedPeer,
81
96
  ANNOUNCE_MS, MAX_BEACON_BYTES, MAX_MANIFEST_BYTES,
82
97
  } from "./lan-sync.mjs";
83
98
 
@@ -107,10 +122,56 @@ export const MAX_FRAME_BYTES = MAX_MANIFEST_BYTES;
107
122
  * in a peer or somebody holding sockets open to see what happens. */
108
123
  export const MAX_SOCKETS = 16;
109
124
 
125
+ /**
126
+ * And the most any ONE host may take of that, because a budget nobody
127
+ * apportions belongs to whoever grabs it first.
128
+ *
129
+ * The cap above is deliberate and documented and it was not shared out, so a
130
+ * host that did nothing at all could hold the whole of it. Measured against a
131
+ * real listener with a paired caller:
132
+ *
133
+ * baseline handshake: OK
134
+ * with 16 silent sockets held: REFUSED: peer closed the connection
135
+ * with 16 idle authed sockets held: REFUSED, and still refused 11s later
136
+ *
137
+ * No key, no trust, not one byte sent — sixteen TCP connections and silence.
138
+ * Every further connection is destroyed on arrival, a paired deck's included,
139
+ * and re-dialling on a cycle shorter than HANDSHAKE_MS holds it there. Outbound
140
+ * still works, so the shape of it is "this deck becomes unreachable" rather
141
+ * than "sync stops" — but a deck nobody can reach is a deck that has stopped
142
+ * healing logins for everyone pointed at it.
143
+ *
144
+ * FOUR RATHER THAN THE OBVIOUS TWO. One round plus one dial-back is two, and
145
+ * two decks on ONE machine is a supported setup — it is how this gets tested,
146
+ * and `reuseAddr` exists for it — so the honest ceiling from one address is
147
+ * twice that. It still means no single host can take more than a quarter of the
148
+ * table, which is the property that matters.
149
+ */
150
+ export const MAX_SOCKETS_PER_HOST = 4;
151
+
152
+ /**
153
+ * How long a socket may say nothing before it is dropped, at any point.
154
+ *
155
+ * HANDSHAKE_MS covers the part before `authed` and stops there: it is cleared
156
+ * the moment a peer authenticates, and after that a socket had no deadline of
157
+ * any kind — `live` shrank only on close or error. So a paired deck that
158
+ * completed four handshakes and then went quiet held its whole share of the
159
+ * table for as long as its process lived, which makes the budget a permanent
160
+ * lease rather than a rate limit.
161
+ *
162
+ * A round is a request and a response. The caller's own per-frame bell is
163
+ * ROUND_MS (10s) in lan-engine, and a `want` puts a claude-swap subprocess on
164
+ * the far side between the two, so a legitimate gap can approach that. Thirty
165
+ * seconds is three times the longest honest silence and half the gap between
166
+ * rounds, and a round gets a fresh socket anyway — lan-engine destroys it in
167
+ * its own `finally`.
168
+ */
169
+ export const IDLE_MS = 30_000;
170
+
110
171
  /** The shortest gap between two "I am here too" replies to a stranger. Long
111
172
  * enough that a burst of decks starting together cannot make a storm, short
112
173
  * enough that starting two decks by hand feels instant. */
113
- export const REPLY_COOLDOWN_MS = 2_000;
174
+ const REPLY_COOLDOWN_MS = 2_000;
114
175
 
115
176
  /**
116
177
  * The shouting half.
@@ -312,7 +373,15 @@ export function frameReader(onFrame, onRefuse, max = MAX_FRAME_BYTES) {
312
373
  if (!msg || typeof msg !== "object" || Array.isArray(msg)) {
313
374
  dead = true; buf = ""; onRefuse("not an object"); return;
314
375
  }
315
- onFrame(msg);
376
+ // A HANDLER THAT THROWS ENDS ITS CONNECTION, NOT THE PROCESS. This runs
377
+ // inside the socket's `data` event, where nothing above catches, so a
378
+ // throw from a frame handler took the whole deck down — and every frame
379
+ // read here has come off the network before anybody is trusted. The
380
+ // handlers are meant never to throw; this is what holds when one does.
381
+ // The error goes on with the refusal, so the next one that hides here is
382
+ // logged with its stack rather than as two words.
383
+ try { onFrame(msg); }
384
+ catch (err) { dead = true; buf = ""; onRefuse("bad frame", err); return; }
316
385
  if (dead) return;
317
386
  }
318
387
  };
@@ -335,7 +404,9 @@ export function sendFrame(sock, obj) {
335
404
  * lesson has.
336
405
  *
337
406
  * `handlers` is called only with authenticated frames, and it never sees the
338
- * handshake at all.
407
+ * handshake at all. On a sealed connection it is handed each frame already
408
+ * opened, and `ctx.send` seals whatever it is given — see frameChannel.
409
+ * `ctx.sealed` says which kind of connection it is on.
339
410
  */
340
411
  export function createSyncServer({
341
412
  fp, pub, name, secret, handlers, onError, host = "0.0.0.0", prefer = 0,
@@ -358,15 +429,60 @@ export function createSyncServer({
358
429
  /** One was used. The caller stores the pairing and retires the invite: a
359
430
  * token that pairs twice is a token worth stealing twice. */
360
431
  onInviteUsed,
432
+ /** How long a socket may say nothing before it is dropped — see IDLE_MS,
433
+ * which is what the deck runs on. A parameter only so the suite can drive
434
+ * the reclaim in a few hundred milliseconds rather than half a minute; a
435
+ * case that slept for the real value would be thirty seconds of CI per run
436
+ * and would still only be checking a timer. */
437
+ idleMs = IDLE_MS,
438
+ /** Whether this deck seals every frame after the handshake with a peer that
439
+ * says it does too — see frameChannel. On in every real deck. False makes
440
+ * this listener announce nothing and seal nothing, which is the wire every
441
+ * deck before #810 speaks, and is how the suite plays one. */
442
+ sealFrames = true,
443
+ /** Whether this deck mixes a key pair made for each connection into its key,
444
+ * with a peer that says it does too — see sessionKey. On in every real
445
+ * deck. False makes this listener's challenge say only that it seals, which
446
+ * is the wire of a deck of #810's version, and is how the suite plays one.
447
+ * It rides on `sealFrames`, as it does on the wire. */
448
+ ephemeral = true,
361
449
  } = {}) {
362
450
  let server = null;
363
451
  const live = new Set();
364
452
 
453
+ /** Which host a socket came from, in one spelling. Node reports an IPv4 peer
454
+ * on a dual-stack listener as `::ffff:127.0.0.1`, and two spellings of one
455
+ * address would be two budgets. Empty for a socket already going away, which
456
+ * is why the count below only ever matches non-empty ones. */
457
+ const from = sock => sock.remoteAddress?.replace(/^::ffff:/, "") ?? "";
458
+
365
459
  const onConnection = sock => {
366
460
  if (!secret || live.size >= MAX_SOCKETS) { sock.destroy(); return; }
461
+ // AND NOT ALL OF IT TO ONE CALLER. The table is small on purpose; what was
462
+ // missing is that it was not shared out, so the cheapest thing on the
463
+ // network — connect, say nothing — took the whole of it. See
464
+ // MAX_SOCKETS_PER_HOST.
465
+ //
466
+ // An address we cannot read is counted by the total alone rather than
467
+ // lumped together with every other unreadable one: an accepted TCP socket
468
+ // always has a peer, so an empty answer here is a socket already on its way
469
+ // out, and treating those as one host would let a closing connection refuse
470
+ // a real one.
471
+ const here = from(sock);
472
+ if (here) {
473
+ let mine = 0;
474
+ for (const s of live) if (from(s) === here) mine++;
475
+ if (mine >= MAX_SOCKETS_PER_HOST) { sock.destroy(); return; }
476
+ }
367
477
  live.add(sock);
368
478
  sock.setEncoding("utf8");
369
479
  sock.setNoDelay(true);
480
+ // ARMED HERE RATHER THAN AT `authed`, and that is the point of it: the
481
+ // handshake deadline below is cleared the moment a peer authenticates, and
482
+ // this is the one that is not. It is longer than HANDSHAKE_MS, so it never
483
+ // decides the outcome of a handshake — it is what ends a socket that has
484
+ // gone quiet afterwards, which nothing used to.
485
+ sock.setTimeout(idleMs, () => sock.destroy());
370
486
 
371
487
  let authed = false;
372
488
  let peerFp = null;
@@ -374,8 +490,30 @@ export function createSyncServer({
374
490
  let peerName = "";
375
491
  let peerPort = null;
376
492
  let key = null;
377
- const myChallenge = randomBytes(16).toString("hex");
493
+ // Carrying this deck's marks — that it seals, and that it mixes a key pair
494
+ // of its own into the key — inside the one field the handshake already
495
+ // binds. See "THE ANNOUNCEMENT RIDES INSIDE THE CHALLENGE" and EPHEMERAL
496
+ // in lan-sync.mjs.
497
+ const myChallenge = challengeFor({ seals: sealFrames, ephemeral });
378
498
  let theirChallenge = null;
499
+ /** What the key was derived over, kept so the invite proofs below are made
500
+ * over the same string rather than rebuilt from its parts — which, once
501
+ * the ephemeral keys are in it, only the `hello` branch holds all of. */
502
+ let transcript = null;
503
+ /** The sealed channel, once the handshake is done and both challenges said
504
+ * so; null for a peer that did not, which is answered in the clear exactly
505
+ * as before. Set once and never changed. */
506
+ let chan = null;
507
+ /** Set by `refuse`, and read first by every frame after it. A refusal is the
508
+ * end of this socket, and nothing the peer sent in the same write may be
509
+ * read against whatever state the refusal left half-set. */
510
+ let refused = false;
511
+ /** Called at the moment `authed` turns true, so the first frame after `ok`
512
+ * in either direction is already the sealed kind. Both challenges are
513
+ * known by then, and the caller's proof has just said it saw the same two
514
+ * this end did. */
515
+ const sealedChannel = () =>
516
+ (sealsFrames(myChallenge) && sealsFrames(theirChallenge) ? frameChannel(key, "listener") : null);
379
517
 
380
518
  // Armed before the first byte is read, and cleared only by a completed
381
519
  // handshake. A socket that connects and says nothing is the cheapest
@@ -407,15 +545,28 @@ export function createSyncServer({
407
545
  * that is the whole point. The timer is the backstop for a peer whose
408
546
  * receive window is full and whose callback therefore never comes.
409
547
  */
410
- const refuse = why => {
411
- onError?.("frame", new Error(why));
548
+ const refuse = (why, cause) => {
549
+ refused = true;
550
+ // The thrown error itself when frameReader caught one, so the terminal
551
+ // shows what threw and where rather than only "bad frame".
552
+ onError?.("frame", cause ?? new Error(why));
412
553
  const bye = () => { try { sock.destroy(); } catch { /* already gone */ } };
554
+ // SEALED, A REFUSAL IS A CLOSE. Once both ends seal nothing leaves this
555
+ // socket in the clear, not even a word about why — and a peer whose frame
556
+ // would not open has nothing it could do with the word anyway.
557
+ if (chan) { bye(); return; }
413
558
  try { sock.write(`${JSON.stringify({ t: "no", why })}\n`, bye); }
414
559
  catch { bye(); return; }
415
560
  setTimeout(bye, 250).unref?.();
416
561
  };
417
562
 
418
563
  sock.on("data", frameReader(msg => {
564
+ // NOTHING AFTER A REFUSAL. The reader hands over every frame in a chunk,
565
+ // and `refuse` destroys the socket without stopping it. So a `hello`
566
+ // refused after its challenge was stored and before a key existed could
567
+ // be followed, in the same write, by an `auth` that reached `proof` with
568
+ // no key: a throw in this handler, and the end of the process.
569
+ if (refused) return;
419
570
  if (!authed) {
420
571
  // FOUR MESSAGES, and the order is chosen so that a stranger who merely
421
572
  // connects receives nothing derived from a key.
@@ -430,8 +581,18 @@ export function createSyncServer({
430
581
  // value. What the exchange establishes is that whoever is on the other
431
582
  // end holds the private half of the key they claimed — which is the
432
583
  // only thing a pin can later be checked against.
584
+ //
585
+ // AND, FROM #1120, ONE KEY PAIR MORE AT EACH END, made for this
586
+ // connection alone: the caller's public half rides in `hello` as
587
+ // `epk`, ours in `challenge`. Public too, and in the clear too. What
588
+ // they buy is a connection key that is gone once both ends have let go
589
+ // of the private halves, whoever takes either long-term key later —
590
+ // see sessionKey.
433
591
  if (msg.t === "hello") {
434
- if (theirChallenge || typeof msg.challenge !== "string") return refuse("bad hello");
592
+ // A string of letters, digits, `.`, `_` and `-` — a `|` inside it
593
+ // would let the transcript come out the same on both ends while each
594
+ // read a different challenge. See readChallenge.
595
+ if (theirChallenge || !readChallenge(msg.challenge)) return refuse("bad hello");
435
596
  const them = readPub(msg.pub);
436
597
  // The fingerprint is a hash of the key, so a hello whose two halves
437
598
  // disagree is not a deck with a stale field, it is somebody trying to
@@ -440,13 +601,60 @@ export function createSyncServer({
440
601
  theirChallenge = msg.challenge;
441
602
  peerFp = them.fp;
442
603
  peerPub = them.pub;
443
- peerName = typeof msg.name === "string" ? msg.name : "";
604
+ // THROUGH cleanName, like the beacon and the invite. This path — the
605
+ // handshake — was the one that skipped it, and it is the one that
606
+ // feeds the pairing prompt and cfg.trusted, which index.mjs writes to
607
+ // prefs.json. So the only bound on the name an operator reads before
608
+ // pressing Accept was the 128 KB frame cap.
609
+ //
610
+ // cleanName caps at MAX_NAME (40) and collapses \s+, which includes
611
+ // U+00A0 — and 200 non-breaking spaces neither collapse in HTML nor
612
+ // offer a break opportunity, so a name of "Alice's laptop at
613
+ // 192.168.1.10 wants to pair" + padding pushed the REAL address out
614
+ // of a fixed 288px column and left a complete, plausible sentence.
615
+ peerName = cleanName(msg.name, "");
444
616
  peerPort = Number.isInteger(msg.port) && msg.port > 0 && msg.port < 65_536 ? msg.port : null;
445
- key = sessionKey(secret, peerPub, handshakeTranscript(peerFp, fp, theirChallenge, myChallenge));
446
- sendFrame(sock, { t: "challenge", fp, pub, name, challenge: myChallenge });
617
+ // MIXED WHEN BOTH CHALLENGES SAY SO, and only then: the two strings
618
+ // the proofs bind decide it, never whether a field turned up. Once
619
+ // both say so, a hello with no usable key is refused rather than
620
+ // answered the old way, because answering the old way is the
621
+ // downgrade. See EPHEMERAL in lan-sync.mjs.
622
+ //
623
+ // A KEY OFFERED IS A KEY ANSWERED, whatever the challenges say, so
624
+ // what this end sends depends only on what it was sent. When the two
625
+ // do not mix, ours goes out and is never used. That happens only
626
+ // between two decks of this version whose challenges somebody edited
627
+ // on the way, and there it takes the dialler on to a proof this end
628
+ // refuses by name — `bad proof`, as for every other edit to a
629
+ // challenge — rather than stopping it one message short. Made here
630
+ // and not when the socket opened, so a connection that never says
631
+ // hello costs what it always did.
632
+ const theirEphemeral = readEphemeral(msg.epk);
633
+ const mixing = mixesEphemeral(myChallenge) && mixesEphemeral(theirChallenge);
634
+ if (mixing && !theirEphemeral) return refuse("bad hello");
635
+ let mine = theirEphemeral && mixesEphemeral(myChallenge) ? ephemeralPair() : null;
636
+ const epk = mine?.pub;
637
+ transcript = mixing
638
+ ? handshakeTranscript(peerFp, fp, theirChallenge, myChallenge, theirEphemeral, epk)
639
+ : handshakeTranscript(peerFp, fp, theirChallenge, myChallenge);
640
+ // A key X25519 cannot use throws in here, and nothing above this
641
+ // handler catches it — see readPub for what that throw used to do.
642
+ try {
643
+ key = sessionKey(secret, peerPub, transcript,
644
+ mixing ? { role: "listener", priv: mine.priv, peer: theirEphemeral } : null);
645
+ } catch {
646
+ return refuse("bad hello");
647
+ } finally {
648
+ // The private half, let go before a byte of the reply is written.
649
+ mine = null;
650
+ }
651
+ sendFrame(sock, { t: "challenge", fp, pub, name, challenge: myChallenge, ...(epk ? { epk } : {}) });
447
652
  return;
448
653
  }
449
- if (msg.t !== "auth" || !theirChallenge) return refuse("expected auth");
654
+ // A key, too: a challenge on record says a `hello` arrived, not that one
655
+ // was accepted. Unreachable after the guard at the top of this handler,
656
+ // and stated anyway, because it is the rule `proof` below depends on.
657
+ if (msg.t !== "auth" || !theirChallenge || !key) return refuse("expected auth");
450
658
  const want = proof(key, {
451
659
  challenge: theirChallenge, peerChallenge: myChallenge,
452
660
  fromFp: peerFp, toFp: fp, direction: "hello",
@@ -465,32 +673,51 @@ export function createSyncServer({
465
673
  // name — refused loudly rather than quietly re-pinned.
466
674
  return refuse("impostor");
467
675
  }
676
+ // AN INVITE THIS DECK HANDED OUT, PRESENTED BACK. Whoever is calling
677
+ // holds a token the owner of this machine copied and sent, which is the
678
+ // same decision the accept button is — made earlier, and made once.
679
+ //
680
+ // The proof is over the transcript, so it is worth nothing to somebody
681
+ // who recorded an earlier exchange, and the code itself never travels.
682
+ //
683
+ // ASKED BEFORE "DO I KNOW THIS DECK", because the two questions are
684
+ // independent and the answer to this one is owed to the caller either
685
+ // way. A caller that sent a code is waiting to be shown one back, and a
686
+ // deck it has ALREADY paired with is not exempt from that — somebody
687
+ // pasting a token into a deck that happens to be paired already would
688
+ // otherwise be told the minter does not hold its own invite.
689
+ // `offer` rather than `live`, which is what this used to be called: the
690
+ // socket table one scope out is also `live`, and widening this block
691
+ // widened the shadow with it.
692
+ const offer = invite();
693
+ // Over the transcript the key came from — the ephemeral keys in it
694
+ // when the two mix — which is the string the dialler proved over too.
695
+ const heldInvite = !!offer && typeof msg.invite === "string"
696
+ && proofOk(inviteProof(offer.code, transcript), msg.invite);
697
+ // AND THE CODE, BACK. The session proof says "I hold the private half
698
+ // of the key I just showed you", which anything with a socket can say.
699
+ // This says "I am the deck whose owner minted that token", which only
700
+ // the minter can — and a caller joining on an invite has no pin to
701
+ // check against, so it is the only thing standing between an invite
702
+ // address and whoever else is reachable there.
703
+ const back = heldInvite ? { inviteProof: inviteProofBack(offer.code, transcript) } : {};
468
704
  if (!known) {
469
- // AN INVITE THIS DECK HANDED OUT, PRESENTED BACK. Whoever is calling
470
- // holds a token the owner of this machine copied and sent, which is
471
- // the same decision the accept button is — made earlier, and made
472
- // once. So there is nothing to press: the deck is pinned here.
473
- //
474
- // The proof is over the transcript, so it is worth nothing to
475
- // somebody who recorded an earlier exchange, and the code itself
476
- // never travels.
477
- const live = invite();
478
- if (live && typeof msg.invite === "string") {
479
- const want = inviteProof(live.code, handshakeTranscript(peerFp, fp, theirChallenge, myChallenge));
480
- if (proofOk(want, msg.invite)) {
481
- onInviteUsed?.({ fp: peerFp, pub: peerPub, name: peerName, port: peerPort,
482
- addr: sock.remoteAddress?.replace(/^::ffff:/, "") ?? "" });
483
- authed = true;
484
- clearTimeout(deadline);
485
- sendFrame(sock, {
486
- t: "ok", fp, name,
487
- proof: proof(key, {
488
- challenge: myChallenge, peerChallenge: theirChallenge,
489
- fromFp: fp, toFp: peerFp, direction: "reply",
490
- }),
491
- });
492
- return;
493
- }
705
+ if (heldInvite) {
706
+ // So there is nothing to press: the deck is pinned here.
707
+ onInviteUsed?.({ fp: peerFp, pub: peerPub, name: peerName, port: peerPort,
708
+ addr: sock.remoteAddress?.replace(/^::ffff:/, "") ?? "" });
709
+ authed = true;
710
+ chan = sealedChannel();
711
+ clearTimeout(deadline);
712
+ sendFrame(sock, {
713
+ t: "ok", fp, name,
714
+ proof: proof(key, {
715
+ challenge: myChallenge, peerChallenge: theirChallenge,
716
+ fromFp: fp, toFp: peerFp, direction: "reply",
717
+ }),
718
+ ...back,
719
+ });
720
+ return;
494
721
  }
495
722
  // A DECK THIS ONE'S OWNER ALREADY ANSWERED, and the answer was no.
496
723
  // It is not asked again here, and — the half a held refusal cannot
@@ -513,19 +740,34 @@ export function createSyncServer({
513
740
  }
514
741
 
515
742
  authed = true;
743
+ chan = sealedChannel();
516
744
  clearTimeout(deadline);
517
745
  // And ours, so the caller knows it reached the deck it pinned rather
518
- // than something standing in the way of one.
746
+ // than something standing in the way of one. Plus the invite, when one
747
+ // was presented and held: a deck already on this list is not a reason
748
+ // to leave a caller's question unanswered. Nothing is retired on this
749
+ // path — nobody was paired, because they already were.
519
750
  sendFrame(sock, {
520
751
  t: "ok", fp, name,
521
752
  proof: proof(key, {
522
753
  challenge: myChallenge, peerChallenge: theirChallenge,
523
754
  fromFp: fp, toFp: peerFp, direction: "reply",
524
755
  }),
756
+ ...back,
525
757
  });
526
758
  return;
527
759
  }
528
- handlers?.(msg, { sock, peerFp, key, send: obj => sendFrame(sock, obj) });
760
+ // SEALED FROM HERE WHEN BOTH ENDS SAID SO, AND ONLY SEALED. A frame that
761
+ // does not open is refused and the socket goes with it — see
762
+ // frameChannel for what "does not open" covers: altered, replayed,
763
+ // reordered, dropped, sent back, or simply plain. There is no reading it
764
+ // as it stands instead; that fallback would be the downgrade.
765
+ const frame = chan ? chan.unwrap(msg) : msg;
766
+ if (!frame) return refuse("a sealed frame did not open");
767
+ handlers?.(frame, {
768
+ sock, peerFp, key, sealed: !!chan,
769
+ send: obj => sendFrame(sock, chan ? chan.wrap(obj) : obj),
770
+ });
529
771
  }, refuse));
530
772
  };
531
773
 
@@ -589,15 +831,34 @@ export function connectToPeer({
589
831
  /** The public key we pinned for this deck the first time, or null for a deck
590
832
  * we are meeting — an address somebody typed. */
591
833
  expectPub = null,
834
+ /** Whether the deck that minted `code` said it will prove it holds one too.
835
+ * From the token's own `pb`, which is the only way to tell a deck that will
836
+ * not from a deck that cannot — see mintInvite. False leaves this path
837
+ * exactly as it shipped, for a token minted by an older deck. */
838
+ inviteProvesBack = false,
839
+ /** Whether this deck seals every frame after the handshake with a deck that
840
+ * says it does too — see frameChannel. False announces nothing and seals
841
+ * nothing, exactly as every deck before #810 dials; only the suite asks. */
842
+ sealFrames = true,
843
+ /** Whether this deck mixes a key pair made for the connection into its key,
844
+ * with a deck that says it does too — see sessionKey. False dials exactly
845
+ * as a deck of #810's version does; only the suite asks. */
846
+ ephemeral = true,
592
847
  }) {
593
848
  return new Promise((resolve, reject) => {
594
- const myChallenge = randomBytes(16).toString("hex");
849
+ const myChallenge = challengeFor({ seals: sealFrames, ephemeral });
850
+ // Made before the other deck is heard from, because the public half goes
851
+ // in the hello. Let go of once the key is derived — unused, when the deck
852
+ // that answers turns out not to mix — and on every way out below.
853
+ let mine = mixesEphemeral(myChallenge) ? ephemeralPair() : null;
854
+ const myEpk = mine?.pub;
595
855
  const sock = net.createConnection({ host, port });
596
856
  sock.setEncoding("utf8");
597
857
  let settled = false;
598
858
  const fail = err => {
599
859
  if (settled) return;
600
860
  settled = true;
861
+ mine = null;
601
862
  sock.destroy();
602
863
  reject(err instanceof Error ? err : new Error(String(err)));
603
864
  };
@@ -614,28 +875,41 @@ export function connectToPeer({
614
875
  // — that one is ephemeral and useless to dial. Without it a deck can
615
876
  // accept an incoming request and still have no way to reach back, so the
616
877
  // pairing is mutual on paper and one-way in fact.
617
- sendFrame(sock, { t: "hello", fp, pub, name, port: myPort, challenge: myChallenge });
878
+ // `epk` is this connection's key pair, offered: a deck from before #1120
879
+ // reads past a field it does not know, and one of this version answers.
880
+ sendFrame(sock, {
881
+ t: "hello", fp, pub, name, port: myPort, challenge: myChallenge, ...(myEpk ? { epk: myEpk } : {}),
882
+ });
618
883
  });
619
884
 
620
885
  let theirChallenge = null;
621
886
  let theirFp = null;
622
887
  let theirPub = null;
623
888
  let key = null;
889
+ /** What the key was derived over; the invite proofs are made over it too. */
890
+ let transcript = null;
624
891
  sock.on("data", frameReader(msg => {
625
892
  if (settled) return;
626
893
  // A deck that heard us and said no. Each reason is a different problem
627
894
  // with a different fix, and until this frame existed they were all one
628
- // silent close that read as a firewall.
895
+ // silent close that read as a firewall. See REFUSALS.
629
896
  if (msg.t === "no") {
630
- return fail(new Error({
631
- pending: "waiting for the other deck to accept this one",
632
- declined: "that deck said no",
633
- impostor: "that deck has this one pinned under a different key",
634
- "bad proof": "the other deck refused this one's proof",
635
- }[msg.why] ?? "the other deck refused this handshake"));
897
+ // `Object.hasOwn`, and here more than anywhere: `msg.why` is a field in
898
+ // a frame written by the OTHER MACHINE, which is the case admin-failure
899
+ // states the rule for (#474). Every member of Object.prototype answers
900
+ // a plain bracket read with an inherited value that is neither nullish
901
+ // nor falsy, so `?? "the other deck refused this handshake"` never
902
+ // fired for one — `{...}["constructor"]` is the Object function itself,
903
+ // and `new Error(Object).message` is the string "function Object() {
904
+ // [native code] }". lan-engine files that as `lastRound.error` and the
905
+ // LAN panel prints it verbatim, so a peer chose what appeared in the
906
+ // user's interface. Asking whether the map has a ROW is the question
907
+ // this read was always trying to ask; no real reason moves.
908
+ return fail(new Error(Object.hasOwn(REFUSALS, msg.why) ? REFUSALS[msg.why]
909
+ : "the other deck refused this handshake"));
636
910
  }
637
911
  if (msg.t === "challenge") {
638
- if (theirFp || typeof msg.challenge !== "string") return fail(new Error("bad challenge"));
912
+ if (theirFp || !readChallenge(msg.challenge)) return fail(new Error("bad challenge"));
639
913
  const them = readPub(msg.pub);
640
914
  if (!them || them.fp !== msg.fp) return fail(new Error("bad challenge"));
641
915
  // THE PIN, CHECKED BEFORE ANYTHING ELSE. A deck we have paired with is
@@ -647,7 +921,25 @@ export function connectToPeer({
647
921
  theirChallenge = msg.challenge;
648
922
  theirFp = them.fp;
649
923
  theirPub = them.pub;
650
- key = sessionKey(secret, theirPub, handshakeTranscript(fp, theirFp, myChallenge, theirChallenge));
924
+ // Mixed when both challenges say so, as at the listener, and then the
925
+ // listener's key is required: a deck of this version that says it
926
+ // mixes and sends no key is not an older deck, it is a deck whose key
927
+ // was taken out on the way. Refused, and never read the old way.
928
+ const mixing = mixesEphemeral(myChallenge) && mixesEphemeral(theirChallenge);
929
+ const theirEphemeral = mixing ? readEphemeral(msg.epk) : null;
930
+ if (mixing && !theirEphemeral) return fail(new Error("bad challenge"));
931
+ transcript = mixing
932
+ ? handshakeTranscript(fp, theirFp, myChallenge, theirChallenge, myEpk, theirEphemeral)
933
+ : handshakeTranscript(fp, theirFp, myChallenge, theirChallenge);
934
+ try {
935
+ key = sessionKey(secret, theirPub, transcript,
936
+ mixing ? { role: "caller", priv: mine.priv, peer: theirEphemeral } : null);
937
+ } catch {
938
+ return fail(new Error("bad challenge"));
939
+ } finally {
940
+ // Used or not, the private half goes now.
941
+ mine = null;
942
+ }
651
943
  sendFrame(sock, {
652
944
  t: "auth",
653
945
  proof: proof(key, {
@@ -657,26 +949,61 @@ export function connectToPeer({
657
949
  // Only when joining on an invite. Sent in the same frame as the
658
950
  // session proof so a deck that holds a token is paired in one round
659
951
  // trip rather than being queued behind somebody else's press.
660
- ...(code ? { invite: inviteProof(code, handshakeTranscript(fp, theirFp, myChallenge, theirChallenge)) } : {}),
952
+ ...(code ? { invite: inviteProof(code, transcript) } : {}),
661
953
  });
662
954
  return;
663
955
  }
664
956
  // The same deck that gave us the challenge, or nothing: a reply naming a
665
957
  // different fingerprint is a second party in the middle of this.
666
- if (msg.t !== "ok" || msg.fp !== theirFp) return fail(new Error("expected ok"));
958
+ //
959
+ // AND A CHALLENGE FIRST. `theirFp` is null until one is accepted, so an
960
+ // `ok` sent as the very first frame with `fp: null` matched it — null is
961
+ // null — and walked into `proof` with no key, which throws. Before #1146
962
+ // that throw ended the deck, and this end dials whatever address a beacon
963
+ // announces; since #1146 the reader catches it as "bad frame". Neither is
964
+ // the answer: an `ok` before a challenge is out of order, and says so.
965
+ if (msg.t !== "ok" || !theirFp || !key || msg.fp !== theirFp) return fail(new Error("expected ok"));
667
966
  const want = proof(key, {
668
967
  challenge: theirChallenge, peerChallenge: myChallenge,
669
968
  fromFp: theirFp, toFp: fp, direction: "reply",
670
969
  });
671
970
  if (!proofOk(want, msg.proof)) return fail(new Error("that deck could not prove its own key"));
971
+ // AND THAT IT IS THE DECK THE INVITE NAMED. The proof above is about a
972
+ // key the responder chose a moment ago; this one is about a code it had
973
+ // to have been given. `join` has no pin to pass, so `expectPub` above is
974
+ // null on this path and this is the only check that distinguishes the
975
+ // deck whose owner minted the token from whatever else is reachable at
976
+ // one of the ten addresses the token happens to carry.
977
+ //
978
+ // The caller giving up here is not the end of the attempt: `join` walks
979
+ // the rest of the list, so a deck answering at a stale or borrowed
980
+ // address costs one failed address instead of winning the whole token.
981
+ if (code && inviteProvesBack) {
982
+ const back = inviteProofBack(code, transcript);
983
+ if (!proofOk(back, msg.inviteProof)) {
984
+ return fail(new Error("that deck does not hold the invite"));
985
+ }
986
+ }
987
+ // Sealed from here when both challenges said so. The listener decided
988
+ // the same at the same moment from the same two strings, and the proof
989
+ // just checked is what says it saw the same two. See frameChannel.
990
+ const chan = sealsFrames(myChallenge) && sealsFrames(theirChallenge) ? frameChannel(key, "caller") : null;
672
991
  settled = true;
673
992
  clearTimeout(timer);
674
993
  sock.removeAllListeners("close");
675
994
  resolve({
676
- sock, key,
677
- peerFp: theirFp, peerPub: theirPub, peerName: msg.name,
678
- send: obj => sendFrame(sock, obj),
995
+ sock, key, sealed: !!chan,
996
+ peerFp: theirFp, peerPub: theirPub, peerName: cleanName(msg.name, ""),
997
+ send: obj => sendFrame(sock, chan ? chan.wrap(obj) : obj),
998
+ /** What a frame from the other end says — or null on a sealed
999
+ * connection when it does not open, which ends the connection: every
1000
+ * frame after it would fail too. On a connection to a deck from before
1001
+ * #810, a plain frame passes through as it arrived. */
1002
+ read: frame => (chan ? chan.unwrap(frame) : frame),
679
1003
  });
680
- }, fail));
1004
+ // A refusal from the reader, with the thrown error as its cause when there
1005
+ // was one: the message is the one it always was, and whatever logs the
1006
+ // rejection can reach what actually threw.
1007
+ }, (why, cause) => fail(cause ? new Error(why, { cause }) : new Error(why))));
681
1008
  });
682
1009
  }