ccdeck 3.22.15 → 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.
- package/README.md +53 -10
- package/THIRD_PARTY_NOTICES.md +123 -15
- package/bin/deck.js +264 -21
- package/dist/web/assets/BrowserWatchModal-DD6srgsk.js +1 -0
- package/dist/web/assets/UsageHistoryModal-zExZpRWU.js +1 -0
- package/dist/web/assets/index-Bf-qcZ6x.css +1 -0
- package/dist/web/assets/index-Dsca2TwS.js +296 -0
- package/dist/web/index.html +9 -5
- package/hook/hook.js +631 -99
- package/package.json +1 -1
- package/release-notes.json +62 -1
- package/src/server/activity.mjs +42 -4
- package/src/server/args.mjs +30 -4
- package/src/server/browser-history.mjs +164 -38
- package/src/server/browser-presence.mjs +60 -0
- package/src/server/browser-react.mjs +160 -7
- package/src/server/browser-watch-store.mjs +319 -17
- package/src/server/browser-watch.mjs +304 -60
- package/src/server/ccusage.mjs +20 -4
- package/src/server/claude-accounts.mjs +79 -34
- package/src/server/claude-identity.mjs +121 -0
- package/src/server/cswap-admin.mjs +160 -133
- package/src/server/cswap-auto.mjs +86 -7
- package/src/server/cswap-install.mjs +280 -43
- package/src/server/deck-home.mjs +1 -1
- package/src/server/deck-prefs.mjs +244 -36
- package/src/server/deck-probe.mjs +1 -1
- package/src/server/exec.mjs +84 -3
- package/src/server/global-install.mjs +13 -0
- package/src/server/index.mjs +1454 -184
- package/src/server/installer.mjs +136 -8
- package/src/server/lan-about.mjs +8 -3
- package/src/server/lan-engine.mjs +335 -47
- package/src/server/lan-socket.mjs +384 -57
- package/src/server/lan-sync.mjs +472 -22
- package/src/server/log-writer.mjs +575 -2
- package/src/server/login-service.mjs +31 -4
- package/src/server/open-url.mjs +22 -2
- package/src/server/output-watch.mjs +59 -8
- package/src/server/purge-key.mjs +135 -0
- package/src/server/quota.mjs +112 -7
- package/src/server/running-deck.mjs +15 -1
- package/src/server/self-update.mjs +98 -4
- package/src/server/session-recap.mjs +89 -0
- package/src/server/static-cache.mjs +67 -0
- package/src/server/stop-deck.mjs +2 -2
- package/src/server/store-lock.mjs +51 -0
- package/src/server/supervisor.mjs +1 -1
- package/src/server/system-metrics.mjs +143 -17
- package/dist/web/assets/index-BYqkKWPC.js +0 -274
- 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,
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
446
|
-
|
|
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
|
-
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
}[
|
|
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 ||
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|