agent-dag 3.22.1 → 3.22.4
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 +6 -477
- package/package.json +14 -48
- package/shim.js +107 -0
- package/LICENSE +0 -661
- package/LICENSING.md +0 -82
- package/THIRD_PARTY_NOTICES.md +0 -395
- package/bin/agent-dag.js +0 -626
- package/bin/deck.js +0 -1805
- package/dist/web/assets/index-CJYsv0lr.css +0 -1
- package/dist/web/assets/index-Ifm23DDC.js +0 -270
- package/dist/web/index.html +0 -49
- package/hook/hook.js +0 -542
- package/release-notes.json +0 -398
- package/src/server/activity.mjs +0 -52
- package/src/server/agent-activity.mjs +0 -522
- package/src/server/args.mjs +0 -183
- package/src/server/auto-update.mjs +0 -79
- package/src/server/block-notify.mjs +0 -173
- package/src/server/boot-deadline.mjs +0 -127
- package/src/server/brand.mjs +0 -16
- package/src/server/browser-history.mjs +0 -497
- package/src/server/browser-presence.mjs +0 -211
- package/src/server/browser-profiles.mjs +0 -279
- package/src/server/browser-react.mjs +0 -284
- package/src/server/browser-watch-store.mjs +0 -350
- package/src/server/browser-watch.mjs +0 -905
- package/src/server/ccusage.mjs +0 -1168
- package/src/server/claude-accounts.mjs +0 -951
- package/src/server/claude-dir.mjs +0 -213
- package/src/server/codex-auth.mjs +0 -388
- package/src/server/codex-dir.mjs +0 -171
- package/src/server/codex-quota.mjs +0 -449
- package/src/server/codex-usage.mjs +0 -512
- package/src/server/cswap-admin.mjs +0 -1562
- package/src/server/cswap-auto.mjs +0 -658
- package/src/server/cswap-install.mjs +0 -641
- package/src/server/deck-home.mjs +0 -243
- package/src/server/deck-prefs.mjs +0 -301
- package/src/server/deck-probe.mjs +0 -111
- package/src/server/detach.mjs +0 -244
- package/src/server/exec.mjs +0 -996
- package/src/server/global-install.mjs +0 -67
- package/src/server/hwmonitor.mjs +0 -56
- package/src/server/index.mjs +0 -6043
- package/src/server/installer.mjs +0 -912
- package/src/server/invoked-as.mjs +0 -144
- package/src/server/lan-about.mjs +0 -119
- package/src/server/lan-engine.mjs +0 -952
- package/src/server/lan-reach.mjs +0 -256
- package/src/server/lan-socket.mjs +0 -682
- package/src/server/lan-sync.mjs +0 -941
- package/src/server/lhm-parse.mjs +0 -91
- package/src/server/log-tail.mjs +0 -139
- package/src/server/log-writer.mjs +0 -322
- package/src/server/login-service.mjs +0 -473
- package/src/server/macmon.mjs +0 -310
- package/src/server/npx.mjs +0 -264
- package/src/server/open-url.mjs +0 -242
- package/src/server/presence.mjs +0 -40
- package/src/server/quota.mjs +0 -792
- package/src/server/relay-guard.mjs +0 -507
- package/src/server/reset-label.mjs +0 -78
- package/src/server/retire-sound-hook.mjs +0 -349
- package/src/server/running-deck.mjs +0 -234
- package/src/server/self-update.mjs +0 -1380
- package/src/server/stop-deck.mjs +0 -171
- package/src/server/supervisor.mjs +0 -392
- package/src/server/system-metrics.mjs +0 -1825
- package/src/server/term.mjs +0 -686
- package/src/server/uv-bootstrap.mjs +0 -337
package/src/server/lan-sync.mjs
DELETED
|
@@ -1,941 +0,0 @@
|
|
|
1
|
-
// The rules for finding other decks on this network and agreeing who is in the
|
|
2
|
-
// group. No sockets, no timers, no filesystem — those live in lan-socket.mjs,
|
|
3
|
-
// and everything here is a pure function so the suite can run all of it on one
|
|
4
|
-
// machine, which is the only machine there is.
|
|
5
|
-
//
|
|
6
|
-
// WHAT THIS IS FOR. An account's login dies on a machine that has not used it
|
|
7
|
-
// for a while, while the same account stays alive on a machine that has. Today
|
|
8
|
-
// the fix is a blob copied out of one deck and pasted into another. This is
|
|
9
|
-
// that, without the copying: decks on one network find each other, agree they
|
|
10
|
-
// belong to the same group, say which accounts they hold and how fresh each
|
|
11
|
-
// one is, and hand over the fresher copy.
|
|
12
|
-
//
|
|
13
|
-
// WHAT IT IS NOT. Not a way to keep one account working on two machines at
|
|
14
|
-
// once — a quota is the account's, not the machine's, so a second machine
|
|
15
|
-
// buys no capacity. Not a backup. Not a way to reach a deck across the
|
|
16
|
-
// internet: everything here is link-local by construction.
|
|
17
|
-
//
|
|
18
|
-
// ── the shape of it, and where each piece came from ──────────────────────────
|
|
19
|
-
//
|
|
20
|
-
// Three shipped systems solve nearly this problem and were read before any of
|
|
21
|
-
// it was written, because the failure modes are all in the parts that look
|
|
22
|
-
// simple.
|
|
23
|
-
//
|
|
24
|
-
// SYNCTHING (docs.syncthing.net/specs/localdisco-v4.html) announces on UDP
|
|
25
|
-
// with NO authentication in the announcement at all, and puts the trust
|
|
26
|
-
// somewhere else entirely: the announcement's only identity claim is a hash of
|
|
27
|
-
// the device's long-term key. That is exactly right and it is what this does.
|
|
28
|
-
// An announcement is a shout in a room; anything it asserts is a claim by
|
|
29
|
-
// whoever shouted.
|
|
30
|
-
//
|
|
31
|
-
// It also broadcasts on IPv4 rather than multicasting — 255.255.255.255, port
|
|
32
|
-
// 21027 — and multicasts only on IPv6. That looked like an oddity and is not:
|
|
33
|
-
// consumer switches and access points forward broadcast where they drop
|
|
34
|
-
// unregistered multicast groups, so the "modern" choice is the one that fails
|
|
35
|
-
// on more real networks. Copied.
|
|
36
|
-
//
|
|
37
|
-
// And it re-announces IMMEDIATELY on start rather than waiting for the next
|
|
38
|
-
// interval, so a deck that just came up appears at once instead of up to a
|
|
39
|
-
// minute later. Copied.
|
|
40
|
-
//
|
|
41
|
-
// LOCALSEND (github.com/localsend/protocol) is the closest thing to this that
|
|
42
|
-
// people actually run. Its fingerprint is a hash of a long-term key, used both
|
|
43
|
-
// to recognise a peer across restarts and to ignore its own announcements —
|
|
44
|
-
// the second of which is not obvious until you watch a deck discover itself.
|
|
45
|
-
// Copied.
|
|
46
|
-
//
|
|
47
|
-
// Its other lesson is about WHERE a check goes. The group secret authenticates
|
|
48
|
-
// the channel; LocalSend still puts a separate PIN on the transfer endpoint
|
|
49
|
-
// itself, so the sensitive operation carries its own proof rather than
|
|
50
|
-
// inheriting one from a session that may be old. See `transferChallenge`.
|
|
51
|
-
//
|
|
52
|
-
// KDE CONNECT had CVE-2020-26164: several issues in the daemon that listens on
|
|
53
|
-
// the LAN, in a design whose protocol was fine. The lesson is not "do TLS
|
|
54
|
-
// better", it is that a process listening on a hostile network must do as
|
|
55
|
-
// close to nothing as possible before it knows who it is talking to. So
|
|
56
|
-
// `readBeacon` refuses on length before it parses, refuses on magic before it
|
|
57
|
-
// reads a field, and never allocates anything sized by the packet.
|
|
58
|
-
//
|
|
59
|
-
// ── what is deliberately NOT here ───────────────────────────────────────────
|
|
60
|
-
//
|
|
61
|
-
// NO SUBNET SCAN. LocalSend falls back to POSTing to every address on the
|
|
62
|
-
// subnet when multicast finds nobody, and it is a defensible choice for
|
|
63
|
-
// consumer software. It is a port scan, which on a corporate network is the
|
|
64
|
-
// thing that gets a machine quarantined by the very systems that exist to
|
|
65
|
-
// notice it. A manual address covers the routed and VPN cases that a scan
|
|
66
|
-
// cannot reach anyway, and it costs one text field.
|
|
67
|
-
//
|
|
68
|
-
// PER-PEER PAIRING, AND THIS REVERSES AN EARLIER DECISION. The first version
|
|
69
|
-
// used one group passphrase, on the argument that a fleet of n decks should not
|
|
70
|
-
// cost n² pairings. That argument is sound and it was answering the wrong
|
|
71
|
-
// question. What two people actually hit on real hardware was this: a
|
|
72
|
-
// passphrase that differs by one character produces a closed socket and no
|
|
73
|
-
// other symptom, on both machines, with the panel unable to tell them apart
|
|
74
|
-
// from a firewall — because a secret is the one value the panel must never
|
|
75
|
-
// print, so neither person can check theirs against the other's.
|
|
76
|
-
//
|
|
77
|
-
// A deck is now identified by its own long-term key, and a peer is somebody
|
|
78
|
-
// this deck has been told to trust: an address typed into the panel, or an
|
|
79
|
-
// incoming connection somebody pressed accept on. Nothing is shared before that
|
|
80
|
-
// press, and the press is a decision about a named machine at a named address
|
|
81
|
-
// rather than about a string neither person can see.
|
|
82
|
-
//
|
|
83
|
-
// TRUST ON FIRST USE, and said plainly rather than implied. The first
|
|
84
|
-
// connection to a fingerprint is taken on faith and pinned; every one after it
|
|
85
|
-
// is checked against the pin. That stops a stranger replacing a paired deck and
|
|
86
|
-
// does not stop somebody standing in the middle of the very first exchange. The
|
|
87
|
-
// answer to that is comparing fingerprints out of band, which the panel shows
|
|
88
|
-
// and which is the next thing to build.
|
|
89
|
-
//
|
|
90
|
-
// NO DEPENDENCY. node:crypto has X25519, HKDF, scrypt, AES-256-GCM and
|
|
91
|
-
// timingSafeEqual, all of which this needs and none of which it should be
|
|
92
|
-
// implementing. The one PAKE package on npm was last published in 2022, has 37
|
|
93
|
-
// downloads a week and four dependencies of its own; running unmaintained
|
|
94
|
-
// cryptography to protect live credentials is worse than the plain construction
|
|
95
|
-
// below.
|
|
96
|
-
import {
|
|
97
|
-
createCipheriv, createDecipheriv, createHash, createHmac, createPrivateKey,
|
|
98
|
-
createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes,
|
|
99
|
-
timingSafeEqual,
|
|
100
|
-
} from "node:crypto";
|
|
101
|
-
import os from "node:os";
|
|
102
|
-
|
|
103
|
-
/** Marks a packet as ours before anything reads a field of it. Four bytes of
|
|
104
|
-
* ASCII rather than a number, so a stray packet on the port is recognisable
|
|
105
|
-
* in a capture as "something else's" rather than as a corrupt one of ours. */
|
|
106
|
-
export const MAGIC = "CCDK";
|
|
107
|
-
|
|
108
|
-
/** The wire format. Bumped when a field changes meaning, never when one is
|
|
109
|
-
* added — a reader that does not know a field ignores it, which is what makes
|
|
110
|
-
* a deck a version behind still discoverable. */
|
|
111
|
-
export const PROTOCOL = 2;
|
|
112
|
-
|
|
113
|
-
/** The most a beacon may be. A beacon is a name, a port and two hashes; the
|
|
114
|
-
* worst realistic case is under 300 bytes. Refusing at 512 before parsing is
|
|
115
|
-
* the cheapest possible answer to a packet built to be expensive to read. */
|
|
116
|
-
export const MAX_BEACON_BYTES = 512;
|
|
117
|
-
|
|
118
|
-
/** How often a deck announces itself, and how long a peer is remembered as
|
|
119
|
-
* present after its last one.
|
|
120
|
-
*
|
|
121
|
-
* 30 seconds is Syncthing's floor and it is chosen for the same reason: a
|
|
122
|
-
* quiet network should not carry more of this than it has to, and a deck that
|
|
123
|
-
* went away is not urgent news. `PRESENT_MS` is three intervals, so two lost
|
|
124
|
-
* packets do not make a live deck flicker out of the list. */
|
|
125
|
-
export const ANNOUNCE_MS = 30_000;
|
|
126
|
-
export const PRESENT_MS = ANNOUNCE_MS * 3 + 5_000;
|
|
127
|
-
|
|
128
|
-
/** Longest a display name may be, in characters rather than bytes so the limit
|
|
129
|
-
* means the same thing in every script. A name is chosen by whoever is
|
|
130
|
-
* shouting, so it is untrusted text: capped here, and never rendered as
|
|
131
|
-
* anything but text at the other end. */
|
|
132
|
-
export const MAX_NAME = 40;
|
|
133
|
-
|
|
134
|
-
/** Longest a manifest may be. Fifty accounts, each an email, an org id and a
|
|
135
|
-
* few numbers, comes to roughly 12 KB; this is an order of magnitude over
|
|
136
|
-
* that and still nothing to allocate by accident. */
|
|
137
|
-
export const MAX_MANIFEST_BYTES = 128 * 1024;
|
|
138
|
-
|
|
139
|
-
// ── identity ────────────────────────────────────────────────────────────────
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* What a deck calls itself on the wire, given its long-term public key.
|
|
143
|
-
*
|
|
144
|
-
* A hash of the key rather than the key: it is short enough to read out over a
|
|
145
|
-
* phone, it is stable across restarts, and — the part that matters — it is the
|
|
146
|
-
* only identity claim in a beacon that cannot be forged, because a deck that
|
|
147
|
-
* cannot do the handshake cannot use somebody else's fingerprint for anything.
|
|
148
|
-
*
|
|
149
|
-
* Twelve hex characters, in threes. 48 bits is far past what an accident
|
|
150
|
-
* reaches on one network, and a person can compare four groups at a glance
|
|
151
|
-
* where they cannot compare sixty-four characters. Syncthing and LocalSend both
|
|
152
|
-
* make the same trade at a similar length.
|
|
153
|
-
*/
|
|
154
|
-
export function fingerprint(publicKeyRaw) {
|
|
155
|
-
const hex = createHash("sha256").update(publicKeyRaw).digest("hex").slice(0, 12);
|
|
156
|
-
return hex.replace(/(.{3})(?=.)/g, "$1-");
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* The name a deck shows, cleaned.
|
|
161
|
-
*
|
|
162
|
-
* Control characters go, because this string is written into a log and read in
|
|
163
|
-
* a terminal as well as rendered in a page, and a name carrying an escape
|
|
164
|
-
* sequence is a name that can move a cursor. Whitespace collapses so a peer
|
|
165
|
-
* cannot pad itself into looking like two entries. Empty falls back to the
|
|
166
|
-
* fingerprint, which is never empty and is the honest answer for a deck that
|
|
167
|
-
* did not say who it was.
|
|
168
|
-
*/
|
|
169
|
-
export function cleanName(raw, fallback = "unnamed deck") {
|
|
170
|
-
if (typeof raw !== "string") return fallback;
|
|
171
|
-
// Escape sequences and NUL, spelled by code point rather than by literal:
|
|
172
|
-
// this string reaches a terminal log as well as a page, and a name carrying
|
|
173
|
-
// an ANSI escape can move a cursor or repaint a line. A literal control
|
|
174
|
-
// character in the source would be invisible to whoever reads this next.
|
|
175
|
-
const stripped = raw
|
|
176
|
-
.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
|
|
177
|
-
.replace(/\s+/g, " ")
|
|
178
|
-
.trim();
|
|
179
|
-
return stripped ? [...stripped].slice(0, MAX_NAME).join("") : fallback;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// ── this deck's own key ─────────────────────────────────────────────────────
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Turn a passphrase into the key everything else hangs off.
|
|
186
|
-
*
|
|
187
|
-
* scrypt rather than a plain hash, and the parameters are the point: this is a
|
|
188
|
-
* passphrase a person typed, on a network where anybody can capture the
|
|
189
|
-
* handshake and grind at it offline. N=2^15 costs about 100ms and 32 MB per
|
|
190
|
-
* guess here, which is nothing once per deck start and is the difference
|
|
191
|
-
* between a weak passphrase falling in seconds and falling in weeks.
|
|
192
|
-
*
|
|
193
|
-
* The salt is FIXED and that is deliberate, not an oversight. A per-deck salt
|
|
194
|
-
* would mean two decks with the same passphrase deriving different keys, which
|
|
195
|
-
* is the one thing this must never do. What a salt buys — that one rainbow
|
|
196
|
-
* table cannot cover every deployment — is bought instead by the passphrase
|
|
197
|
-
* being generated at 128 bits by default; a table against a random 128-bit
|
|
198
|
-
* secret is not a thing that exists.
|
|
199
|
-
*/
|
|
200
|
-
/**
|
|
201
|
-
* A deck's own long-term identity.
|
|
202
|
-
*
|
|
203
|
-
* X25519, because the only thing it is ever used for is agreeing a session key
|
|
204
|
-
* with a peer — never a signature — and node has it built in. The private half
|
|
205
|
-
* lives in prefs.json, which is written 0600 for exactly this reason; the
|
|
206
|
-
* public half is what a peer pins, and its fingerprint is what a person
|
|
207
|
-
* compares.
|
|
208
|
-
*/
|
|
209
|
-
export function newKeypair() {
|
|
210
|
-
const { publicKey, privateKey } = generateKeyPairSync("x25519");
|
|
211
|
-
return {
|
|
212
|
-
secret: privateKey.export({ type: "pkcs8", format: "der" }).toString("base64"),
|
|
213
|
-
pub: publicKey.export({ type: "spki", format: "der" }).toString("base64"),
|
|
214
|
-
};
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/**
|
|
218
|
-
* The keypair on disk, or a fresh one when there is none or it is unusable.
|
|
219
|
-
*
|
|
220
|
-
* A corrupt secret is not an error anybody can act on mid-session, and refusing
|
|
221
|
-
* to start would take the whole feature away over one bad string — so it is
|
|
222
|
-
* replaced. The cost of replacing it is stated rather than hidden: this deck
|
|
223
|
-
* gets a new fingerprint, so every peer that had pinned the old one sees a
|
|
224
|
-
* stranger and asks its owner to accept again.
|
|
225
|
-
*/
|
|
226
|
-
export function identityFrom(secret) {
|
|
227
|
-
if (typeof secret === "string" && secret !== "") {
|
|
228
|
-
try {
|
|
229
|
-
const priv = createPrivateKey({ key: Buffer.from(secret, "base64"), format: "der", type: "pkcs8" });
|
|
230
|
-
const pub = createPublicKey(priv).export({ type: "spki", format: "der" });
|
|
231
|
-
return { secret, pub: pub.toString("base64"), fp: fingerprint(pub), fresh: false };
|
|
232
|
-
} catch { /* unusable; fall through and make one */ }
|
|
233
|
-
}
|
|
234
|
-
const made = newKeypair();
|
|
235
|
-
return { ...made, fp: fingerprint(Buffer.from(made.pub, "base64")), fresh: true };
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* The key two decks use for one connection, and for nothing else.
|
|
240
|
-
*
|
|
241
|
-
* X25519 to a shared secret, then HKDF over the transcript — both fingerprints
|
|
242
|
-
* and both challenges, in a fixed order — so the key is bound to THIS exchange.
|
|
243
|
-
* A recording of an old one derives a different key and proves nothing.
|
|
244
|
-
*
|
|
245
|
-
* PER CONNECTION, never stored. The old design sealed credentials under one
|
|
246
|
-
* 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.
|
|
249
|
-
*/
|
|
250
|
-
export function sessionKey(secret, peerPub, transcript) {
|
|
251
|
-
const priv = createPrivateKey({ key: Buffer.from(secret, "base64"), format: "der", type: "pkcs8" });
|
|
252
|
-
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));
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* The string both sides bind their session key to.
|
|
260
|
-
*
|
|
261
|
-
* One definition, used by the caller and the listener, because a transcript the
|
|
262
|
-
* two build differently is a handshake that never agrees and a bug that only
|
|
263
|
-
* appears between two machines. Caller first, always, so the order does not
|
|
264
|
-
* depend on which end is asking.
|
|
265
|
-
*/
|
|
266
|
-
export function handshakeTranscript(callerFp, listenerFp, callerChallenge, listenerChallenge) {
|
|
267
|
-
return `${callerFp}|${listenerFp}|${callerChallenge}|${listenerChallenge}`;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/**
|
|
271
|
-
* A public key somebody sent, or null.
|
|
272
|
-
*
|
|
273
|
-
* It arrives from the network before anything has been agreed, so it is checked
|
|
274
|
-
* for being an X25519 public key at all rather than trusted to be one — a
|
|
275
|
-
* string that is not gets a refusal here instead of a throw three frames later.
|
|
276
|
-
*/
|
|
277
|
-
export function readPub(raw) {
|
|
278
|
-
if (typeof raw !== "string" || raw.length < 40 || raw.length > 128) return null;
|
|
279
|
-
try {
|
|
280
|
-
const der = Buffer.from(raw, "base64");
|
|
281
|
-
createPublicKey({ key: der, format: "der", type: "spki" });
|
|
282
|
-
return { pub: raw, fp: fingerprint(der) };
|
|
283
|
-
} catch { return null; }
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// ── the invite ──────────────────────────────────────────────────────────────
|
|
287
|
-
//
|
|
288
|
-
// ONE PIECE OF TEXT THAT CARRIES EVERYTHING, and it exists because of a real
|
|
289
|
-
// afternoon. Two people, two machines, a typed address, and `handshake timed
|
|
290
|
-
// out` — because the address on screen was the LAN one and the only route
|
|
291
|
-
// between them was a VPN. Neither of them could have known which of the two to
|
|
292
|
-
// use; the deck could not know either, and printing both made it their problem.
|
|
293
|
-
//
|
|
294
|
-
// So the invite carries EVERY address this deck has, and the deck that receives
|
|
295
|
-
// it tries them in turn. Nobody has to know which one is routable, because the
|
|
296
|
-
// only machine that can find out is the one doing the reaching.
|
|
297
|
-
//
|
|
298
|
-
// AND IT CARRIES A CODE, which is what turns two presses into one. A deck that
|
|
299
|
-
// proves it holds the invite is not a stranger asking to be let in — it is
|
|
300
|
-
// somebody the owner of this machine handed a token to. So it is paired on
|
|
301
|
-
// arrival and nobody presses accept. That also closes the gap trust-on-first-use
|
|
302
|
-
// left open: the first contact is verified rather than believed.
|
|
303
|
-
//
|
|
304
|
-
// THE TOKEN IS THE SECRET, said plainly rather than implied. Whoever holds it
|
|
305
|
-
// can pair with this deck until it expires. It is a door key with a timer, not
|
|
306
|
-
// an identifier — which is why it is short-lived, single-purpose, and replaced
|
|
307
|
-
// the moment it is used.
|
|
308
|
-
|
|
309
|
-
/** How long an invite is good for. Long enough to paste into a chat and have
|
|
310
|
-
* somebody read it, short enough that one left in a channel is not a way in
|
|
311
|
-
* tomorrow. */
|
|
312
|
-
export const INVITE_MS = 10 * 60 * 1000;
|
|
313
|
-
|
|
314
|
-
/** The most addresses an invite may carry. A machine with a VPN, a second card
|
|
315
|
-
* and a container bridge has three or four; ten is far past honest and keeps a
|
|
316
|
-
* hand-built token from being a way to make this deck dial a list. */
|
|
317
|
-
export const MAX_INVITE_ADDRS = 10;
|
|
318
|
-
|
|
319
|
-
/** What the token starts with, so a reader can tell at a glance what they have
|
|
320
|
-
* been sent and a wrong paste is refused before it is parsed. */
|
|
321
|
-
export const INVITE_PREFIX = "ccdeck1.";
|
|
322
|
-
|
|
323
|
-
const b64url = buf => buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
324
|
-
const unb64url = str => Buffer.from(str.replace(/-/g, "+").replace(/_/g, "/"), "base64");
|
|
325
|
-
|
|
326
|
-
/**
|
|
327
|
-
* A code somebody could read out loud if they had to.
|
|
328
|
-
*
|
|
329
|
-
* Six digits, from rejection sampling rather than modulo — the same argument
|
|
330
|
-
* the word list made and for the same reason: a bias here is a bias in the one
|
|
331
|
-
* number that decides whether a stranger can pair.
|
|
332
|
-
*/
|
|
333
|
-
export function inviteCode(rand = randomBytes) {
|
|
334
|
-
let out = "";
|
|
335
|
-
while (out.length < 6) {
|
|
336
|
-
for (const b of rand(12)) {
|
|
337
|
-
if (b >= 250) continue; // 250 = 25 * 10, the largest clean multiple
|
|
338
|
-
out += String(b % 10);
|
|
339
|
-
if (out.length === 6) break;
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
return out;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
/** Mint one. `addrs` are already `host:port` strings, because which addresses
|
|
346
|
-
* this machine has is not a question this file can answer. */
|
|
347
|
-
export function mintInvite({ addrs, name, now = Date.now(), code = inviteCode() } = {}) {
|
|
348
|
-
const list = (Array.isArray(addrs) ? addrs : []).filter(a => typeof a === "string" && a).slice(0, MAX_INVITE_ADDRS);
|
|
349
|
-
if (!list.length) return null;
|
|
350
|
-
const expiresAt = now + INVITE_MS;
|
|
351
|
-
const body = { v: PROTOCOL, a: list, n: cleanName(name), c: code, x: expiresAt };
|
|
352
|
-
return { token: INVITE_PREFIX + b64url(Buffer.from(JSON.stringify(body), "utf8")), code, expiresAt };
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
/**
|
|
356
|
-
* Read one somebody pasted, or null.
|
|
357
|
-
*
|
|
358
|
-
* REFUSES BEFORE IT UNDERSTANDS, in the order that costs least: the prefix,
|
|
359
|
-
* then the length, then the decode, then the shape, then the clock. A token is
|
|
360
|
-
* text a person pasted out of a chat window, so it is as untrusted as anything
|
|
361
|
-
* that arrives on a socket — and every failure here is the same answer to the
|
|
362
|
-
* reader, which is "that is not an invite".
|
|
363
|
-
*/
|
|
364
|
-
export function readInvite(raw, now = Date.now()) {
|
|
365
|
-
if (typeof raw !== "string") return null;
|
|
366
|
-
const text = raw.trim();
|
|
367
|
-
if (!text.startsWith(INVITE_PREFIX)) return null;
|
|
368
|
-
if (text.length > 2048) return null;
|
|
369
|
-
let body = null;
|
|
370
|
-
try { body = JSON.parse(unb64url(text.slice(INVITE_PREFIX.length)).toString("utf8")); }
|
|
371
|
-
catch { return null; }
|
|
372
|
-
if (!body || typeof body !== "object" || Array.isArray(body)) return null;
|
|
373
|
-
if (body.v !== PROTOCOL) return null;
|
|
374
|
-
if (typeof body.c !== "string" || !/^[0-9]{6}$/.test(body.c)) return null;
|
|
375
|
-
if (typeof body.x !== "number" || !Number.isFinite(body.x)) return null;
|
|
376
|
-
// Expired is its own answer and the caller says so — "that invite has run
|
|
377
|
-
// out, ask for a new one" is a different instruction from "that is not an
|
|
378
|
-
// invite", and a reader who cannot tell them apart retypes the same thing.
|
|
379
|
-
const expired = body.x <= now;
|
|
380
|
-
const addrs = (Array.isArray(body.a) ? body.a : [])
|
|
381
|
-
.filter(a => typeof a === "string")
|
|
382
|
-
.slice(0, MAX_INVITE_ADDRS)
|
|
383
|
-
.map(a => ({ raw: a, at: a.lastIndexOf(":") }))
|
|
384
|
-
.filter(p => p.at > 0)
|
|
385
|
-
.map(p => ({ addr: p.raw.slice(0, p.at), port: Number(p.raw.slice(p.at + 1)) }))
|
|
386
|
-
.filter(p => p.addr && Number.isInteger(p.port) && p.port > 0 && p.port < 65_536);
|
|
387
|
-
if (!addrs.length) return null;
|
|
388
|
-
return { addrs, name: cleanName(body.n), code: body.c, expiresAt: body.x, expired };
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
/**
|
|
392
|
-
* The proof that a caller is holding the invite, over the same transcript the
|
|
393
|
-
* session key is bound to.
|
|
394
|
-
*
|
|
395
|
-
* Not the code itself on the wire. A code that travelled in the clear would be
|
|
396
|
-
* replayable by anybody who watched one exchange, and this is the value that
|
|
397
|
-
* decides whether a deck is paired without anybody pressing anything.
|
|
398
|
-
*/
|
|
399
|
-
export function inviteProof(code, transcript) {
|
|
400
|
-
return createHmac("sha256", `ccdeck-invite-v${PROTOCOL}`).update(`${code}|${transcript}`).digest("hex");
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
// ── the beacon ──────────────────────────────────────────────────────────────
|
|
404
|
-
|
|
405
|
-
/**
|
|
406
|
-
* What a deck shouts, which anybody on the network can hear.
|
|
407
|
-
*
|
|
408
|
-
* NO ACCOUNTS AND NO SECRET. This is the packet a stranger on the same wifi
|
|
409
|
-
* receives, so what is in it is what a stranger learns: that a ccdeck is here,
|
|
410
|
-
* what it calls itself, and a hash of its public key. Not which Anthropic
|
|
411
|
-
* accounts exist on the machine, not how many, and nothing that could be ground
|
|
412
|
-
* at offline, because there is nothing in it derived from a secret.
|
|
413
|
-
*
|
|
414
|
-
* IT NO LONGER CARRIES A GROUP TAG, and that is the shape of the whole feature
|
|
415
|
-
* changing rather than a field going away. The tag let a deck drop a packet
|
|
416
|
-
* from outside its group before any handshake existed to attack, which is a
|
|
417
|
-
* real property and was worth having — but it only worked when both people
|
|
418
|
-
* held the same passphrase, and a passphrase neither of them can see is exactly
|
|
419
|
-
* what nobody could get right. What replaces it is later and cheaper: a beacon
|
|
420
|
-
* from a deck this one does not trust becomes a row somebody can accept, and
|
|
421
|
-
* nothing at all happens until they do.
|
|
422
|
-
*/
|
|
423
|
-
/**
|
|
424
|
-
* WHICH MACHINE THIS IS, as opposed to which PROCESS or which KEY.
|
|
425
|
-
*
|
|
426
|
-
* Reported by somebody looking at a colleague's screen: "I appear three or four
|
|
427
|
-
* times". Every one of those rows was honest — a deck's identity is its key, a
|
|
428
|
-
* second deck sharing a config directory is told to take a fresh one
|
|
429
|
-
* (`id-clash`), and a deck heard yesterday stays listed for a day — so a
|
|
430
|
-
* machine that has run the deck a few times becomes a column of itself on
|
|
431
|
-
* everybody else's panel, under one hostname, offering to pair with each.
|
|
432
|
-
*
|
|
433
|
-
* A key cannot answer "same machine?" and was never meant to. This can: it is
|
|
434
|
-
* derived rather than stored, so two processes on one computer agree without
|
|
435
|
-
* coordinating and a first run needs nothing written; and it is HASHED, because
|
|
436
|
-
* a hostname and a home directory carry a person's name and this goes out in
|
|
437
|
-
* the clear to everyone on the network, thirty seconds apart, forever.
|
|
438
|
-
*
|
|
439
|
-
* The home directory is in it so that a copied `~/.claude` — which is how two
|
|
440
|
-
* real machines end up holding one key, and the reason `id-clash` exists —
|
|
441
|
-
* still reads as two machines when the hostnames differ, which they do.
|
|
442
|
-
*/
|
|
443
|
-
export function hostId({ hostname = os.hostname(), home = os.homedir() } = {}) {
|
|
444
|
-
return createHash("sha256").update(`${hostname}\u0000${home}`).digest("hex").slice(0, 12);
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
export function beaconPayload({ name, fp, port, instance, host }) {
|
|
448
|
-
return {
|
|
449
|
-
m: MAGIC,
|
|
450
|
-
v: PROTOCOL,
|
|
451
|
-
n: cleanName(name),
|
|
452
|
-
f: fp,
|
|
453
|
-
p: port,
|
|
454
|
-
// Randomised at start. Two beacons from one fingerprint with different
|
|
455
|
-
// instance ids mean the deck restarted between them, which is the signal to
|
|
456
|
-
// drop whatever session state was held for it rather than trying to resume
|
|
457
|
-
// into a process that no longer exists. Syncthing's field, same job.
|
|
458
|
-
i: instance,
|
|
459
|
-
// Which MACHINE, so several processes on one computer are one row rather
|
|
460
|
-
// than one row each. Optional on the wire: a deck older than this sends no
|
|
461
|
-
// `h`, and a reader that requires one would stop seeing every deck already
|
|
462
|
-
// installed.
|
|
463
|
-
...(host ? { h: host } : {}),
|
|
464
|
-
};
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
/**
|
|
468
|
-
* Read a packet off the wire, refusing before understanding.
|
|
469
|
-
*
|
|
470
|
-
* The order is the whole point and it is the CVE-2020-26164 lesson: length
|
|
471
|
-
* first, because that costs nothing; then magic, because that is one string
|
|
472
|
-
* compare; then a parse; then the fields. Nothing here allocates anything
|
|
473
|
-
* sized by the packet, and a packet that fails at any step produces `null`
|
|
474
|
-
* rather than a reason — a listener that explained itself to whoever is
|
|
475
|
-
* probing it would be a listener helping them.
|
|
476
|
-
*/
|
|
477
|
-
export function readBeacon(buf, { maxBytes = MAX_BEACON_BYTES } = {}) {
|
|
478
|
-
if (!buf || buf.length === 0 || buf.length > maxBytes) return null;
|
|
479
|
-
// One byte before a parse. The payload is JSON, so anything not starting with
|
|
480
|
-
// `{` is somebody else's traffic on the port and costs nothing to refuse —
|
|
481
|
-
// which on a busy network is most of what arrives.
|
|
482
|
-
if (buf[0] !== 0x7b) return null;
|
|
483
|
-
let raw;
|
|
484
|
-
try { raw = JSON.parse(buf.toString("utf8")); }
|
|
485
|
-
catch { return null; }
|
|
486
|
-
if (!raw || typeof raw !== "object") return null;
|
|
487
|
-
if (raw.m !== MAGIC) return null;
|
|
488
|
-
if (raw.v !== PROTOCOL) return null;
|
|
489
|
-
// The TYPE as well as the value. `Number("4319")` is a valid port and this
|
|
490
|
-
// used to take it, which is leniency of exactly the kind this whole reader
|
|
491
|
-
// exists to refuse: a field arriving as a type nobody sends is a field built
|
|
492
|
-
// by hand, and the only safe answer to a hand-built packet is no.
|
|
493
|
-
if (typeof raw.p !== "number" || !Number.isInteger(raw.p) || raw.p < 1 || raw.p > 65_535) return null;
|
|
494
|
-
const port = raw.p;
|
|
495
|
-
if (typeof raw.f !== "string" || !/^[0-9a-f]{3}(-[0-9a-f]{3}){3}$/.test(raw.f)) return null;
|
|
496
|
-
if (typeof raw.i !== "string" || !/^[0-9a-f]{8,32}$/.test(raw.i)) return null;
|
|
497
|
-
// Optional, and refused rather than tolerated when it is malformed: a field
|
|
498
|
-
// that decides which rows collapse into one is a field worth being strict
|
|
499
|
-
// about. Absent is fine and means "a deck older than this".
|
|
500
|
-
if (raw.h !== undefined && (typeof raw.h !== "string" || !/^[0-9a-f]{6,32}$/.test(raw.h))) return null;
|
|
501
|
-
return { name: cleanName(raw.n, raw.f), fp: raw.f, port, instance: raw.i, host: raw.h };
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
/**
|
|
505
|
-
* Whether a beacon is one this deck should act on.
|
|
506
|
-
*
|
|
507
|
-
* Three separate refusals and they mean different things, which is why this
|
|
508
|
-
* returns a reason rather than a boolean — the panel says "found a deck that is
|
|
509
|
-
* not in your group" where it would otherwise say nothing at all, and "not in
|
|
510
|
-
* your group" is the single most useful sentence when somebody has mistyped the
|
|
511
|
-
* passphrase on one machine.
|
|
512
|
-
*
|
|
513
|
-
* Self-recognition is by fingerprint, not by address: a deck hears its own
|
|
514
|
-
* broadcast on every interface it owns, and filtering by address would need a
|
|
515
|
-
* list of them that changes when a VPN comes up.
|
|
516
|
-
*/
|
|
517
|
-
export function beaconVerdict(beacon, { selfFp, selfInstance, selfHost, trusted } = {}) {
|
|
518
|
-
if (!beacon) return "unreadable";
|
|
519
|
-
// ANOTHER DECK ON THIS COMPUTER. Not this process — a different key, honestly
|
|
520
|
-
// its own — and still nothing to pair with: both read one claude-swap store,
|
|
521
|
-
// so neither holds a login the other could heal. It used to be filtered by
|
|
522
|
-
// the address the packet came from, which is true and needs the list of this
|
|
523
|
-
// machine's addresses to be current; the machine's own id needs nothing and
|
|
524
|
-
// is the same answer.
|
|
525
|
-
if (selfHost && beacon.host && beacon.host === selfHost && beacon.fp !== selfFp) return "self";
|
|
526
|
-
if (beacon.fp === selfFp) {
|
|
527
|
-
// OUR OWN NAME, FROM SOMEBODY ELSE'S PROCESS. Two decks sharing a config
|
|
528
|
-
// directory hold the same key — and so does the second machine when
|
|
529
|
-
// somebody copies their ~/.claude across, which people do. Both then file
|
|
530
|
-
// every one of the other's beacons as "that is me" and the two are
|
|
531
|
-
// permanently invisible to each other, with nothing on screen to say why.
|
|
532
|
-
//
|
|
533
|
-
// `instance` is what separates the cases: it is fresh per process, so our
|
|
534
|
-
// own packet carries the instance we are running and another deck's cannot.
|
|
535
|
-
// Told apart here rather than healed here — this function decides, and
|
|
536
|
-
// taking a new key is the engine's to do.
|
|
537
|
-
//
|
|
538
|
-
// AND ONE MACHINE'S OWN TWIN IS NOT A CLASH, which is the whole of a defect
|
|
539
|
-
// reported as "Fiodor pressed yes ten times and it keeps asking". Several
|
|
540
|
-
// decks on one computer share a config directory, so they start with one
|
|
541
|
-
// key; each read the others' beacons as somebody wearing its name, each
|
|
542
|
-
// took a fresh key, each wrote that key to the file the others read — and
|
|
543
|
-
// the loop never settles. Every new key is a new deck to everybody else on
|
|
544
|
-
// the network, so one machine produced a fresh pairing request every few
|
|
545
|
-
// seconds, forever, and accepting one accomplished nothing because the deck
|
|
546
|
-
// that asked no longer existed by the time the answer arrived.
|
|
547
|
-
//
|
|
548
|
-
// Two processes on one computer sharing one key is not a problem to heal.
|
|
549
|
-
// They read one claude-swap store; there is nothing for either to send the
|
|
550
|
-
// other, they never dial each other, and to the rest of the network they
|
|
551
|
-
// are one deck — which is exactly what they are. The clash worth healing is
|
|
552
|
-
// the OTHER one: a `~/.claude` copied to a second machine, where two real
|
|
553
|
-
// decks would otherwise be permanently invisible to each other.
|
|
554
|
-
if (selfInstance && beacon.instance !== selfInstance) {
|
|
555
|
-
return selfHost && beacon.host === selfHost ? "self" : "id-clash";
|
|
556
|
-
}
|
|
557
|
-
return "self";
|
|
558
|
-
}
|
|
559
|
-
// A DECK WE HAVE BEEN TOLD TO TRUST, or one somebody may choose to. There is
|
|
560
|
-
// no third answer any more: the group tag used to sort strangers from peers
|
|
561
|
-
// before a handshake existed, and now every stranger is a row with a name and
|
|
562
|
-
// an address that a person can accept or ignore. Nothing is asked of an
|
|
563
|
-
// unknown deck and nothing is offered to it.
|
|
564
|
-
return Array.isArray(trusted) && trusted.some(t => t.fp === beacon.fp) ? "peer" : "stranger";
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
/** Whether this fingerprint is one somebody has already accepted, and what was
|
|
568
|
-
* recorded about it — the pinned public key above all, which is what makes a
|
|
569
|
-
* second connection from the same fingerprint checkable rather than merely
|
|
570
|
-
* claimed. */
|
|
571
|
-
export function trustedPeer(trusted, fp) {
|
|
572
|
-
return (Array.isArray(trusted) ? trusted : []).find(t => t && t.fp === fp) ?? null;
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
/**
|
|
576
|
-
* Add a deck to the trusted list, or update what is known about one.
|
|
577
|
-
*
|
|
578
|
-
* THE PINNED KEY NEVER CHANGES UNDER US. A second entry claiming a fingerprint
|
|
579
|
-
* we already hold with a different public key is not an update, it is a
|
|
580
|
-
* different deck wearing the name — and 48 bits of fingerprint is far past
|
|
581
|
-
* accident, so it is somebody trying. The old entry stands and the caller is
|
|
582
|
-
* told nothing changed.
|
|
583
|
-
*/
|
|
584
|
-
export function addTrusted(trusted, entry) {
|
|
585
|
-
const list = Array.isArray(trusted) ? trusted : [];
|
|
586
|
-
if (!entry || typeof entry.fp !== "string" || typeof entry.pub !== "string") return { list, added: false };
|
|
587
|
-
const had = trustedPeer(list, entry.fp);
|
|
588
|
-
if (had) {
|
|
589
|
-
if (had.pub !== entry.pub) return { list, added: false };
|
|
590
|
-
return {
|
|
591
|
-
list: list.map(t => (t.fp === entry.fp ? { ...t, name: entry.name ?? t.name } : t)),
|
|
592
|
-
added: false,
|
|
593
|
-
};
|
|
594
|
-
}
|
|
595
|
-
// WHEN, for the panel's "paired since". Only a new pin gets one: a deck that
|
|
596
|
-
// was already trusted keeps whatever date it had, and one pinned before this
|
|
597
|
-
// field existed keeps having none rather than being given today's.
|
|
598
|
-
const at = Number.isFinite(entry.at) && entry.at > 0 ? { at: entry.at } : {};
|
|
599
|
-
return { list: [...list, { fp: entry.fp, pub: entry.pub, name: entry.name ?? "", ...at }], added: true };
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
/** Take one back out. Unpairing stops what has not happened yet and takes back
|
|
603
|
-
* nothing that has — the same sentence the panel says about a shared login,
|
|
604
|
-
* and true here for the same reason. */
|
|
605
|
-
export function dropTrusted(trusted, fp) {
|
|
606
|
-
return (Array.isArray(trusted) ? trusted : []).filter(t => t && t.fp !== fp);
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
/**
|
|
610
|
-
* The decks worth offering to pair with, out of everything that has ever been
|
|
611
|
-
* heard.
|
|
612
|
-
*
|
|
613
|
-
* THIS LIST WAS A WALL. Every deck ever heard stayed in it forever, and a deck
|
|
614
|
-
* that restarts takes a NEW key — so a machine started and stopped seven times
|
|
615
|
-
* was seven rows, all with the same name, all at the same address, none of them
|
|
616
|
-
* reachable any more. The dialog it filled was unreadable, which is exactly
|
|
617
|
-
* what somebody reported.
|
|
618
|
-
*
|
|
619
|
-
* Three rules, in order:
|
|
620
|
-
*
|
|
621
|
-
* PRESENT ONLY. A deck that has not been heard for a couple of announce
|
|
622
|
-
* intervals is not somewhere you can pair right now, so it is not offered. This
|
|
623
|
-
* is the same window the peer table uses, for the same reason.
|
|
624
|
-
*
|
|
625
|
-
* ONE ROW PER MACHINE. A person reading this sees a name and an address, and
|
|
626
|
-
* two rows carrying the same pair are the same machine to them whatever the
|
|
627
|
-
* fingerprints say. The freshest wins, because it is the one still running.
|
|
628
|
-
*
|
|
629
|
-
* NEWEST FIRST, AND CAPPED. The deck somebody just started is the one they are
|
|
630
|
-
* looking for; a list longer than a screen is a list nobody reads.
|
|
631
|
-
*/
|
|
632
|
-
export function pairable(strangers, now, { presentMs = PRESENT_MS, limit = 8, mine = [] } = {}) {
|
|
633
|
-
// NOT THIS MACHINE. A deck's own beacon is filtered by fingerprint, which is
|
|
634
|
-
// right and is not enough: a second deck on the same computer is a different
|
|
635
|
-
// process with a different key, so it passes that check honestly and then
|
|
636
|
-
// shows up in the list under this machine's own hostname, at this machine's
|
|
637
|
-
// own address, offering to pair with itself.
|
|
638
|
-
//
|
|
639
|
-
// It was reported from a screenshot — "why myself appear here in list" — and
|
|
640
|
-
// the answer is that the address is the one thing that cannot lie: a beacon
|
|
641
|
-
// arriving FROM an address this machine holds came from this machine. Pairing
|
|
642
|
-
// with it would also buy nothing, because both decks read one claude-swap
|
|
643
|
-
// store and there is nothing for either to heal.
|
|
644
|
-
const own = new Set(Array.isArray(mine) ? mine : []);
|
|
645
|
-
const live = [...(strangers ?? [])]
|
|
646
|
-
.filter(p => p && typeof p.at === "number" && now - p.at <= presentMs)
|
|
647
|
-
.filter(p => !own.has(p.addr))
|
|
648
|
-
.sort((a, b) => b.at - a.at);
|
|
649
|
-
// ONE ROW PER MACHINE. `live` is newest first, so the row that survives is the
|
|
650
|
-
// one that spoke most recently — which is the process somebody is actually
|
|
651
|
-
// running, rather than a key its computer took and abandoned an hour ago.
|
|
652
|
-
//
|
|
653
|
-
// The machine's own id when the far deck sends one, and the old name@address
|
|
654
|
-
// pair when it does not: a deck older than the `h` field is still collapsed
|
|
655
|
-
// as well as it can be rather than not at all.
|
|
656
|
-
const seen = new Set();
|
|
657
|
-
const out = [];
|
|
658
|
-
for (const p of live) {
|
|
659
|
-
const key = p.host ? `host:${p.host}` : `${p.name}@${p.addr}`;
|
|
660
|
-
if (seen.has(key)) continue;
|
|
661
|
-
seen.add(key);
|
|
662
|
-
out.push(p);
|
|
663
|
-
}
|
|
664
|
-
return { shown: out.slice(0, limit), more: Math.max(0, out.length - limit) };
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
// ── the peer table ──────────────────────────────────────────────────────────
|
|
668
|
-
|
|
669
|
-
/**
|
|
670
|
-
* Fold one heard beacon into what is known, and say whether anything changed.
|
|
671
|
-
*
|
|
672
|
-
* PEERS ARE REMEMBERED, NOT MERELY SEEN. A deck that is switched off should
|
|
673
|
-
* stay in the list with "last seen yesterday" rather than vanishing, because
|
|
674
|
-
* the question the list answers is "who is in my group" and the answer to that
|
|
675
|
-
* does not change when a laptop closes. Presence is a field, not membership.
|
|
676
|
-
*
|
|
677
|
-
* `changed` is returned rather than inferred by the caller so a beacon that
|
|
678
|
-
* says nothing new — which is most of them, one every thirty seconds per peer
|
|
679
|
-
* forever — costs no render and no write.
|
|
680
|
-
*/
|
|
681
|
-
export function notePeer(peers, beacon, addr, now) {
|
|
682
|
-
const prev = peers.get(beacon.fp);
|
|
683
|
-
const next = {
|
|
684
|
-
fp: beacon.fp,
|
|
685
|
-
name: beacon.name,
|
|
686
|
-
addr,
|
|
687
|
-
port: beacon.port,
|
|
688
|
-
instance: beacon.instance,
|
|
689
|
-
host: beacon.host,
|
|
690
|
-
firstSeen: prev?.firstSeen ?? now,
|
|
691
|
-
lastSeen: now,
|
|
692
|
-
// Kept across a name change so the panel can say "was Laptop-birou", which
|
|
693
|
-
// is what stops a renamed deck reading as a new one that appeared.
|
|
694
|
-
prevName: prev && prev.name !== beacon.name ? prev.name : prev?.prevName,
|
|
695
|
-
// A manual peer stays manual once found by beacon, so removing it from the
|
|
696
|
-
// list means removing the address the user typed rather than waiting for a
|
|
697
|
-
// packet that will re-add it.
|
|
698
|
-
manual: prev?.manual ?? false,
|
|
699
|
-
};
|
|
700
|
-
const changed = !prev
|
|
701
|
-
|| prev.name !== next.name
|
|
702
|
-
|| prev.addr !== next.addr
|
|
703
|
-
|| prev.port !== next.port
|
|
704
|
-
|| prev.instance !== next.instance;
|
|
705
|
-
peers.set(beacon.fp, next);
|
|
706
|
-
return { peer: next, changed, restarted: !!prev && prev.instance !== next.instance };
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
/** Whether a peer counts as here right now. Separate from being in the list at
|
|
710
|
-
* all — see notePeer. */
|
|
711
|
-
export function isPresent(peer, now) {
|
|
712
|
-
return peer.lastSeen != null && now - peer.lastSeen < PRESENT_MS;
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
/** How long a deck stays in the list after its last beacon. See stillListed. */
|
|
716
|
-
export const FORGET_MS = 24 * 60 * 60_000;
|
|
717
|
-
|
|
718
|
-
/**
|
|
719
|
-
* Whether a peer still belongs in the list at all.
|
|
720
|
-
*
|
|
721
|
-
* The list answers "who is in my group", and that does not change when a laptop
|
|
722
|
-
* closes — so a deck heard this morning is still there tonight with the time
|
|
723
|
-
* beside it. A day later it is not news that it once existed.
|
|
724
|
-
*
|
|
725
|
-
* WITHOUT THIS the list is a graveyard, and that was measured rather than
|
|
726
|
-
* imagined: on a machine where decks had been restarted a few times, every
|
|
727
|
-
* peer's list held a row per restart, each reporting ECONNREFUSED once a minute
|
|
728
|
-
* against a port nothing had listened on for an hour. A stable deck id stopped
|
|
729
|
-
* new rows appearing; this is what clears the ones that are genuinely gone.
|
|
730
|
-
*
|
|
731
|
-
* A TYPED ADDRESS IS EXEMPT, and that is the whole reason this is a rule rather
|
|
732
|
-
* than a comparison inlined at the call site: an address somebody typed is a
|
|
733
|
-
* decision they made, and a deck that has been off for a week is exactly the
|
|
734
|
-
* case they typed it for. Only the user takes those off.
|
|
735
|
-
*/
|
|
736
|
-
export function stillListed(peer, now, forgetMs = FORGET_MS) {
|
|
737
|
-
if (peer?.manual) return true;
|
|
738
|
-
return peer?.lastSeen != null && now - peer.lastSeen < forgetMs;
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
/**
|
|
742
|
-
* The list as the panel shows it: present decks first, then by name.
|
|
743
|
-
*
|
|
744
|
-
* Not by last-seen within each group, which was the obvious ordering and is
|
|
745
|
-
* wrong: a list that reorders itself every thirty seconds as beacons land in
|
|
746
|
-
* whatever order the network delivers them is a list nobody can point at.
|
|
747
|
-
* Name is stable, and the presence split is the only thing that should move a
|
|
748
|
-
* row.
|
|
749
|
-
*/
|
|
750
|
-
export function peerRows(peers, now) {
|
|
751
|
-
return [...peers.values()]
|
|
752
|
-
.map(p => ({ ...p, present: isPresent(p, now) }))
|
|
753
|
-
.sort((a, b) => (Number(b.present) - Number(a.present))
|
|
754
|
-
|| a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
|
|
755
|
-
|| a.fp.localeCompare(b.fp));
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
// ── the handshake ───────────────────────────────────────────────────────────
|
|
759
|
-
|
|
760
|
-
/**
|
|
761
|
-
* The proof a deck offers to show it holds the group passphrase.
|
|
762
|
-
*
|
|
763
|
-
* Challenge-response over the derived key rather than sending anything derived
|
|
764
|
-
* from the passphrase directly, so a recording of one exchange is worth nothing
|
|
765
|
-
* for the next: the challenge is fresh random from the side being convinced.
|
|
766
|
-
*
|
|
767
|
-
* BOTH SIDES PROVE. The obvious version has the caller prove itself to the
|
|
768
|
-
* listener, which stops a stranger reading a manifest and stops nothing else —
|
|
769
|
-
* a stranger can still stand up a listener, wait for a real deck to connect,
|
|
770
|
-
* and be handed one. So the response covers a challenge from each side and the
|
|
771
|
-
* initiator checks the answer with the same care.
|
|
772
|
-
*
|
|
773
|
-
* The transcript is in the MAC, not just the challenges. Without it the same
|
|
774
|
-
* proof is valid on a different port, for a different peer, in a different
|
|
775
|
-
* direction — three distinct ways for a recording to be replayed somewhere it
|
|
776
|
-
* was not made.
|
|
777
|
-
*/
|
|
778
|
-
export function proof(key, { challenge, peerChallenge, fromFp, toFp, direction }) {
|
|
779
|
-
return createHmac("sha256", key)
|
|
780
|
-
.update(`ccdeck-lan-v${PROTOCOL}|${direction}|${fromFp}|${toFp}|${challenge}|${peerChallenge}`)
|
|
781
|
-
.digest("hex");
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
/** Whether a proof is the one expected, compared in constant time for the
|
|
785
|
-
* reason `beaconVerdict` gives. Length-checked first because timingSafeEqual
|
|
786
|
-
* throws on a mismatch, and a throw here is a crash rather than a refusal. */
|
|
787
|
-
export function proofOk(expected, offered) {
|
|
788
|
-
if (typeof offered !== "string" || offered.length !== expected.length) return false;
|
|
789
|
-
return timingSafeEqual(Buffer.from(expected, "utf8"), Buffer.from(offered, "utf8"));
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
/**
|
|
793
|
-
* A fresh challenge for the sensitive operation, separate from the session.
|
|
794
|
-
*
|
|
795
|
-
* LocalSend's lesson, and the reason this exists rather than the transfer
|
|
796
|
-
* riding on the handshake: a session proves who connected, at the moment they
|
|
797
|
-
* connected. A credential leaving the machine is a different question, asked
|
|
798
|
-
* later, sometimes much later — a long-lived connection that was authenticated
|
|
799
|
-
* an hour ago is not a statement about now. So the transfer carries its own
|
|
800
|
-
* round trip, over the same key, naming the account being asked for.
|
|
801
|
-
*/
|
|
802
|
-
export function transferChallenge(key, { nonce, accountKey, fromFp, toFp }) {
|
|
803
|
-
return createHmac("sha256", key)
|
|
804
|
-
.update(`ccdeck-lan-transfer-v${PROTOCOL}|${fromFp}|${toFp}|${accountKey}|${nonce}`)
|
|
805
|
-
.digest("hex");
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
// ── the payload ─────────────────────────────────────────────────────────────
|
|
809
|
-
|
|
810
|
-
/**
|
|
811
|
-
* Wrap a share blob so only the group can read it.
|
|
812
|
-
*
|
|
813
|
-
* AES-256-GCM over the group key with a random 12-byte nonce. The alternative
|
|
814
|
-
* — per-peer keys from the X25519 exchange — is better cryptography and worse
|
|
815
|
-
* for this: it would make the encryption depend on a session that the transfer
|
|
816
|
-
* check above deliberately does not trust, and the thing being protected is
|
|
817
|
-
* already group-wide by definition. Anybody who can decrypt this is somebody
|
|
818
|
-
* the passphrase already admits.
|
|
819
|
-
*
|
|
820
|
-
* The additional data binds the ciphertext to the two decks and the account, so
|
|
821
|
-
* a blob captured on one exchange cannot be replayed into another as if it were
|
|
822
|
-
* about something else.
|
|
823
|
-
*/
|
|
824
|
-
export function seal(key, plaintext, aad) {
|
|
825
|
-
const iv = randomBytes(12);
|
|
826
|
-
const c = createCipheriv("aes-256-gcm", key, iv);
|
|
827
|
-
c.setAAD(Buffer.from(aad, "utf8"));
|
|
828
|
-
const body = Buffer.concat([c.update(plaintext, "utf8"), c.final()]);
|
|
829
|
-
return { iv: iv.toString("base64"), tag: c.getAuthTag().toString("base64"), body: body.toString("base64") };
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
/** The other half. Returns null rather than throwing on every failure — a
|
|
833
|
-
* wrong tag, a wrong key, a truncated body and a hand-built packet all mean
|
|
834
|
-
* the same thing to the caller, which is "do not use this". */
|
|
835
|
-
export function open(key, { iv, tag, body }, aad) {
|
|
836
|
-
try {
|
|
837
|
-
const d = createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "base64"));
|
|
838
|
-
d.setAAD(Buffer.from(aad, "utf8"));
|
|
839
|
-
d.setAuthTag(Buffer.from(tag, "base64"));
|
|
840
|
-
return Buffer.concat([d.update(Buffer.from(body, "base64")), d.final()]).toString("utf8");
|
|
841
|
-
} catch {
|
|
842
|
-
return null;
|
|
843
|
-
}
|
|
844
|
-
}
|
|
845
|
-
|
|
846
|
-
// ── which copy wins ─────────────────────────────────────────────────────────
|
|
847
|
-
|
|
848
|
-
/**
|
|
849
|
-
* The identity of one account, which is NOT its slot number.
|
|
850
|
-
*
|
|
851
|
-
* claude-swap keys on `(email, organizationUuid)` — same email under two orgs
|
|
852
|
-
* is two accounts on purpose — and assigns slots as max+1 per store, so the
|
|
853
|
-
* account that is 4 here is 2 there. Anything keyed on the number would swap
|
|
854
|
-
* the wrong pair the first time two stores had grown in a different order.
|
|
855
|
-
*/
|
|
856
|
-
export function accountKey(email, orgUuid) {
|
|
857
|
-
return `${String(email ?? "").trim().toLowerCase()}@@${String(orgUuid ?? "")}`;
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
/**
|
|
861
|
-
* What to do about one account, given what I have and what a peer has.
|
|
862
|
-
*
|
|
863
|
-
* TWO OUTCOMES, AND NEITHER OVERWRITES SOMETHING THAT WORKS.
|
|
864
|
-
*
|
|
865
|
-
* "add" I do not have this account at all.
|
|
866
|
-
* "heal" I have it and claude-swap has quarantined it — refresh token dead,
|
|
867
|
-
* verified by an invalid_grant from Anthropic rather than guessed
|
|
868
|
-
* from an expiry field — and the peer's copy is alive.
|
|
869
|
-
* null Anything else, which is most of the time.
|
|
870
|
-
*
|
|
871
|
-
* A third outcome was designed and dropped, and the reason is worth keeping
|
|
872
|
-
* because it looks like a feature being given up. It was "replace": take a
|
|
873
|
-
* peer's copy when it is strictly newer than mine, so the freshest copy wins
|
|
874
|
-
* everywhere. Measuring "newer" needs the OAuth payload's `expiresAt`, which
|
|
875
|
-
* means the deck opening a credential — something it does not do, claude-swap
|
|
876
|
-
* owns that — and on macOS that credential is in the Keychain, which a
|
|
877
|
-
* background process cannot reliably read at all.
|
|
878
|
-
*
|
|
879
|
-
* What settled it is not the obstacle. It is that a working credential replaced
|
|
880
|
-
* by a newer working credential changes nothing today. It would only matter if
|
|
881
|
-
* mine were about to die — and a login dies from not being used, so if I am not
|
|
882
|
-
* using it I do not care, and if I am using it the refresh keeps it alive. The
|
|
883
|
-
* whole value is in the account that is already dead.
|
|
884
|
-
*
|
|
885
|
-
* So this never returns an outcome that needs `cswap import --force`, which
|
|
886
|
-
* makes claude-swap's own rule the entire safety property: a plain import skips
|
|
887
|
-
* an account that is present and healthy, and replaces exactly one that is
|
|
888
|
-
* quarantined. A peer cannot overwrite a credential of mine that works, because
|
|
889
|
-
* nothing here ever asks for that.
|
|
890
|
-
*/
|
|
891
|
-
export function syncAction(mine, theirs) {
|
|
892
|
-
if (!theirs || !theirs.alive) return null;
|
|
893
|
-
if (!mine) return "add";
|
|
894
|
-
return mine.alive ? null : "heal";
|
|
895
|
-
}
|
|
896
|
-
|
|
897
|
-
/**
|
|
898
|
-
* Everything to do this round, over one peer's manifest.
|
|
899
|
-
*
|
|
900
|
-
* Sorted by account key rather than left in manifest order, so two decks
|
|
901
|
-
* reconciling the same pair of stores do the same work in the same sequence —
|
|
902
|
-
* which is what makes a failure halfway through repeatable rather than a
|
|
903
|
-
* different half each time.
|
|
904
|
-
*/
|
|
905
|
-
export function plan(local, remote) {
|
|
906
|
-
const mine = new Map(local.map(a => [a.key, a]));
|
|
907
|
-
const out = [];
|
|
908
|
-
for (const theirs of remote) {
|
|
909
|
-
const action = syncAction(mine.get(theirs.key), theirs);
|
|
910
|
-
if (action) out.push({ key: theirs.key, email: theirs.email, action });
|
|
911
|
-
}
|
|
912
|
-
return out.sort((a, b) => a.key.localeCompare(b.key));
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
/**
|
|
916
|
-
* What this deck publishes about its own accounts — to the group, and only to
|
|
917
|
-
* the group.
|
|
918
|
-
*
|
|
919
|
-
* Three fields, and `alive` is the only one that is a judgement: it is
|
|
920
|
-
* claude-swap's own verdict on this machine's copy, not a guess. An account
|
|
921
|
-
* this deck cannot use is worth nothing to a peer, so saying so plainly is what
|
|
922
|
-
* stops a peer asking for it.
|
|
923
|
-
*
|
|
924
|
-
* Emails are in it, in the clear inside the encrypted channel. That is a
|
|
925
|
-
* deliberate line: a manifest only ever reaches a deck that has already proved
|
|
926
|
-
* it holds the passphrase, and the panel has to name the account it is offering
|
|
927
|
-
* to heal. Hashing the email would buy nothing against that reader and would
|
|
928
|
-
* cost the one thing the row needs to say.
|
|
929
|
-
*
|
|
930
|
-
* `shared` is the user's list. An account absent from it is absent from the
|
|
931
|
-
* manifest entirely — not listed as withheld, which would tell the group that
|
|
932
|
-
* an account exists and is being kept back, and that is itself the fact being
|
|
933
|
-
* kept back.
|
|
934
|
-
*/
|
|
935
|
-
export function manifestFor(accounts, shared) {
|
|
936
|
-
const want = new Set(shared);
|
|
937
|
-
return accounts
|
|
938
|
-
.filter(a => want.has(a.key))
|
|
939
|
-
.map(a => ({ key: a.key, email: a.email, alive: !!a.alive }))
|
|
940
|
-
.sort((a, b) => a.key.localeCompare(b.key));
|
|
941
|
-
}
|