agent-dag 3.15.0 → 3.17.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/dist/web/assets/{index-CqAgutJT.css → index-BwFExw1D.css} +1 -1
- package/dist/web/assets/index-CSpWSOAU.js +260 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/src/server/deck-prefs.mjs +33 -14
- package/src/server/index.mjs +162 -26
- package/src/server/lan-engine.mjs +455 -67
- package/src/server/lan-reach.mjs +256 -0
- package/src/server/lan-socket.mjs +257 -63
- package/src/server/lan-sync.mjs +334 -86
- package/dist/web/assets/index-CbYzt04Y.js +0 -248
package/src/server/lan-sync.mjs
CHANGED
|
@@ -65,11 +65,27 @@
|
|
|
65
65
|
// notice it. A manual address covers the routed and VPN cases that a scan
|
|
66
66
|
// cannot reach anyway, and it costs one text field.
|
|
67
67
|
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
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.
|
|
73
89
|
//
|
|
74
90
|
// NO DEPENDENCY. node:crypto has X25519, HKDF, scrypt, AES-256-GCM and
|
|
75
91
|
// timingSafeEqual, all of which this needs and none of which it should be
|
|
@@ -78,8 +94,9 @@
|
|
|
78
94
|
// cryptography to protect live credentials is worse than the plain construction
|
|
79
95
|
// below.
|
|
80
96
|
import {
|
|
81
|
-
createCipheriv, createDecipheriv, createHash, createHmac,
|
|
82
|
-
|
|
97
|
+
createCipheriv, createDecipheriv, createHash, createHmac, createPrivateKey,
|
|
98
|
+
createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes,
|
|
99
|
+
timingSafeEqual,
|
|
83
100
|
} from "node:crypto";
|
|
84
101
|
|
|
85
102
|
/** Marks a packet as ours before anything reads a field of it. Four bytes of
|
|
@@ -90,7 +107,7 @@ export const MAGIC = "CCDK";
|
|
|
90
107
|
/** The wire format. Bumped when a field changes meaning, never when one is
|
|
91
108
|
* added — a reader that does not know a field ignores it, which is what makes
|
|
92
109
|
* a deck a version behind still discoverable. */
|
|
93
|
-
export const PROTOCOL =
|
|
110
|
+
export const PROTOCOL = 2;
|
|
94
111
|
|
|
95
112
|
/** The most a beacon may be. A beacon is a name, a port and two hashes; the
|
|
96
113
|
* worst realistic case is under 300 bytes. Refusing at 512 before parsing is
|
|
@@ -161,7 +178,7 @@ export function cleanName(raw, fallback = "unnamed deck") {
|
|
|
161
178
|
return stripped ? [...stripped].slice(0, MAX_NAME).join("") : fallback;
|
|
162
179
|
}
|
|
163
180
|
|
|
164
|
-
// ──
|
|
181
|
+
// ── this deck's own key ─────────────────────────────────────────────────────
|
|
165
182
|
|
|
166
183
|
/**
|
|
167
184
|
* Turn a passphrase into the key everything else hangs off.
|
|
@@ -179,56 +196,207 @@ export function cleanName(raw, fallback = "unnamed deck") {
|
|
|
179
196
|
* being generated at 128 bits by default; a table against a random 128-bit
|
|
180
197
|
* secret is not a thing that exists.
|
|
181
198
|
*/
|
|
182
|
-
|
|
183
|
-
|
|
199
|
+
/**
|
|
200
|
+
* A deck's own long-term identity.
|
|
201
|
+
*
|
|
202
|
+
* X25519, because the only thing it is ever used for is agreeing a session key
|
|
203
|
+
* with a peer — never a signature — and node has it built in. The private half
|
|
204
|
+
* lives in prefs.json, which is written 0600 for exactly this reason; the
|
|
205
|
+
* public half is what a peer pins, and its fingerprint is what a person
|
|
206
|
+
* compares.
|
|
207
|
+
*/
|
|
208
|
+
export function newKeypair() {
|
|
209
|
+
const { publicKey, privateKey } = generateKeyPairSync("x25519");
|
|
210
|
+
return {
|
|
211
|
+
secret: privateKey.export({ type: "pkcs8", format: "der" }).toString("base64"),
|
|
212
|
+
pub: publicKey.export({ type: "spki", format: "der" }).toString("base64"),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
184
215
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
216
|
+
/**
|
|
217
|
+
* The keypair on disk, or a fresh one when there is none or it is unusable.
|
|
218
|
+
*
|
|
219
|
+
* A corrupt secret is not an error anybody can act on mid-session, and refusing
|
|
220
|
+
* to start would take the whole feature away over one bad string — so it is
|
|
221
|
+
* replaced. The cost of replacing it is stated rather than hidden: this deck
|
|
222
|
+
* gets a new fingerprint, so every peer that had pinned the old one sees a
|
|
223
|
+
* stranger and asks its owner to accept again.
|
|
224
|
+
*/
|
|
225
|
+
export function identityFrom(secret) {
|
|
226
|
+
if (typeof secret === "string" && secret !== "") {
|
|
227
|
+
try {
|
|
228
|
+
const priv = createPrivateKey({ key: Buffer.from(secret, "base64"), format: "der", type: "pkcs8" });
|
|
229
|
+
const pub = createPublicKey(priv).export({ type: "spki", format: "der" });
|
|
230
|
+
return { secret, pub: pub.toString("base64"), fp: fingerprint(pub), fresh: false };
|
|
231
|
+
} catch { /* unusable; fall through and make one */ }
|
|
232
|
+
}
|
|
233
|
+
const made = newKeypair();
|
|
234
|
+
return { ...made, fp: fingerprint(Buffer.from(made.pub, "base64")), fresh: true };
|
|
188
235
|
}
|
|
189
236
|
|
|
190
237
|
/**
|
|
191
|
-
*
|
|
238
|
+
* The key two decks use for one connection, and for nothing else.
|
|
192
239
|
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
* is thin against an offline grind — so the number is six, for 66 bits, which
|
|
197
|
-
* at scrypt's cost above is beyond reach.
|
|
240
|
+
* X25519 to a shared secret, then HKDF over the transcript — both fingerprints
|
|
241
|
+
* and both challenges, in a fixed order — so the key is bound to THIS exchange.
|
|
242
|
+
* A recording of an old one derives a different key and proves nothing.
|
|
198
243
|
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
244
|
+
* PER CONNECTION, never stored. The old design sealed credentials under one
|
|
245
|
+
* long-lived group key, which meant a single recorded transfer stayed readable
|
|
246
|
+
* to anybody who ever learned the passphrase. This gives forward secrecy for
|
|
247
|
+
* free: the ephemeral halves are gone when the socket is.
|
|
201
248
|
*/
|
|
202
|
-
export
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
export function
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
249
|
+
export function sessionKey(secret, peerPub, transcript) {
|
|
250
|
+
const priv = createPrivateKey({ key: Buffer.from(secret, "base64"), format: "der", type: "pkcs8" });
|
|
251
|
+
const theirs = createPublicKey({ key: Buffer.from(peerPub, "base64"), format: "der", type: "spki" });
|
|
252
|
+
const shared = diffieHellman({ privateKey: priv, publicKey: theirs });
|
|
253
|
+
return Buffer.from(hkdfSync("sha256", shared, Buffer.alloc(0),
|
|
254
|
+
Buffer.from(`ccdeck-lan-v${PROTOCOL}|${transcript}`, "utf8"), 32));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The string both sides bind their session key to.
|
|
259
|
+
*
|
|
260
|
+
* One definition, used by the caller and the listener, because a transcript the
|
|
261
|
+
* two build differently is a handshake that never agrees and a bug that only
|
|
262
|
+
* appears between two machines. Caller first, always, so the order does not
|
|
263
|
+
* depend on which end is asking.
|
|
264
|
+
*/
|
|
265
|
+
export function handshakeTranscript(callerFp, listenerFp, callerChallenge, listenerChallenge) {
|
|
266
|
+
return `${callerFp}|${listenerFp}|${callerChallenge}|${listenerChallenge}`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* A public key somebody sent, or null.
|
|
271
|
+
*
|
|
272
|
+
* It arrives from the network before anything has been agreed, so it is checked
|
|
273
|
+
* for being an X25519 public key at all rather than trusted to be one — a
|
|
274
|
+
* string that is not gets a refusal here instead of a throw three frames later.
|
|
275
|
+
*/
|
|
276
|
+
export function readPub(raw) {
|
|
277
|
+
if (typeof raw !== "string" || raw.length < 40 || raw.length > 128) return null;
|
|
278
|
+
try {
|
|
279
|
+
const der = Buffer.from(raw, "base64");
|
|
280
|
+
createPublicKey({ key: der, format: "der", type: "spki" });
|
|
281
|
+
return { pub: raw, fp: fingerprint(der) };
|
|
282
|
+
} catch { return null; }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ── the invite ──────────────────────────────────────────────────────────────
|
|
286
|
+
//
|
|
287
|
+
// ONE PIECE OF TEXT THAT CARRIES EVERYTHING, and it exists because of a real
|
|
288
|
+
// afternoon. Two people, two machines, a typed address, and `handshake timed
|
|
289
|
+
// out` — because the address on screen was the LAN one and the only route
|
|
290
|
+
// between them was a VPN. Neither of them could have known which of the two to
|
|
291
|
+
// use; the deck could not know either, and printing both made it their problem.
|
|
292
|
+
//
|
|
293
|
+
// So the invite carries EVERY address this deck has, and the deck that receives
|
|
294
|
+
// it tries them in turn. Nobody has to know which one is routable, because the
|
|
295
|
+
// only machine that can find out is the one doing the reaching.
|
|
296
|
+
//
|
|
297
|
+
// AND IT CARRIES A CODE, which is what turns two presses into one. A deck that
|
|
298
|
+
// proves it holds the invite is not a stranger asking to be let in — it is
|
|
299
|
+
// somebody the owner of this machine handed a token to. So it is paired on
|
|
300
|
+
// arrival and nobody presses accept. That also closes the gap trust-on-first-use
|
|
301
|
+
// left open: the first contact is verified rather than believed.
|
|
302
|
+
//
|
|
303
|
+
// THE TOKEN IS THE SECRET, said plainly rather than implied. Whoever holds it
|
|
304
|
+
// can pair with this deck until it expires. It is a door key with a timer, not
|
|
305
|
+
// an identifier — which is why it is short-lived, single-purpose, and replaced
|
|
306
|
+
// the moment it is used.
|
|
307
|
+
|
|
308
|
+
/** How long an invite is good for. Long enough to paste into a chat and have
|
|
309
|
+
* somebody read it, short enough that one left in a channel is not a way in
|
|
310
|
+
* tomorrow. */
|
|
311
|
+
export const INVITE_MS = 10 * 60 * 1000;
|
|
312
|
+
|
|
313
|
+
/** The most addresses an invite may carry. A machine with a VPN, a second card
|
|
314
|
+
* and a container bridge has three or four; ten is far past honest and keeps a
|
|
315
|
+
* hand-built token from being a way to make this deck dial a list. */
|
|
316
|
+
export const MAX_INVITE_ADDRS = 10;
|
|
317
|
+
|
|
318
|
+
/** What the token starts with, so a reader can tell at a glance what they have
|
|
319
|
+
* been sent and a wrong paste is refused before it is parsed. */
|
|
320
|
+
export const INVITE_PREFIX = "ccdeck1.";
|
|
321
|
+
|
|
322
|
+
const b64url = buf => buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
323
|
+
const unb64url = str => Buffer.from(str.replace(/-/g, "+").replace(/_/g, "/"), "base64");
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* A code somebody could read out loud if they had to.
|
|
327
|
+
*
|
|
328
|
+
* Six digits, from rejection sampling rather than modulo — the same argument
|
|
329
|
+
* the word list made and for the same reason: a bias here is a bias in the one
|
|
330
|
+
* number that decides whether a stranger can pair.
|
|
331
|
+
*/
|
|
332
|
+
export function inviteCode(rand = randomBytes) {
|
|
333
|
+
let out = "";
|
|
334
|
+
while (out.length < 6) {
|
|
335
|
+
for (const b of rand(12)) {
|
|
336
|
+
if (b >= 250) continue; // 250 = 25 * 10, the largest clean multiple
|
|
337
|
+
out += String(b % 10);
|
|
338
|
+
if (out.length === 6) break;
|
|
229
339
|
}
|
|
230
340
|
}
|
|
231
|
-
return out
|
|
341
|
+
return out;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Mint one. `addrs` are already `host:port` strings, because which addresses
|
|
345
|
+
* this machine has is not a question this file can answer. */
|
|
346
|
+
export function mintInvite({ addrs, name, now = Date.now(), code = inviteCode() } = {}) {
|
|
347
|
+
const list = (Array.isArray(addrs) ? addrs : []).filter(a => typeof a === "string" && a).slice(0, MAX_INVITE_ADDRS);
|
|
348
|
+
if (!list.length) return null;
|
|
349
|
+
const expiresAt = now + INVITE_MS;
|
|
350
|
+
const body = { v: PROTOCOL, a: list, n: cleanName(name), c: code, x: expiresAt };
|
|
351
|
+
return { token: INVITE_PREFIX + b64url(Buffer.from(JSON.stringify(body), "utf8")), code, expiresAt };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Read one somebody pasted, or null.
|
|
356
|
+
*
|
|
357
|
+
* REFUSES BEFORE IT UNDERSTANDS, in the order that costs least: the prefix,
|
|
358
|
+
* then the length, then the decode, then the shape, then the clock. A token is
|
|
359
|
+
* text a person pasted out of a chat window, so it is as untrusted as anything
|
|
360
|
+
* that arrives on a socket — and every failure here is the same answer to the
|
|
361
|
+
* reader, which is "that is not an invite".
|
|
362
|
+
*/
|
|
363
|
+
export function readInvite(raw, now = Date.now()) {
|
|
364
|
+
if (typeof raw !== "string") return null;
|
|
365
|
+
const text = raw.trim();
|
|
366
|
+
if (!text.startsWith(INVITE_PREFIX)) return null;
|
|
367
|
+
if (text.length > 2048) return null;
|
|
368
|
+
let body = null;
|
|
369
|
+
try { body = JSON.parse(unb64url(text.slice(INVITE_PREFIX.length)).toString("utf8")); }
|
|
370
|
+
catch { return null; }
|
|
371
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return null;
|
|
372
|
+
if (body.v !== PROTOCOL) return null;
|
|
373
|
+
if (typeof body.c !== "string" || !/^[0-9]{6}$/.test(body.c)) return null;
|
|
374
|
+
if (typeof body.x !== "number" || !Number.isFinite(body.x)) return null;
|
|
375
|
+
// Expired is its own answer and the caller says so — "that invite has run
|
|
376
|
+
// out, ask for a new one" is a different instruction from "that is not an
|
|
377
|
+
// invite", and a reader who cannot tell them apart retypes the same thing.
|
|
378
|
+
const expired = body.x <= now;
|
|
379
|
+
const addrs = (Array.isArray(body.a) ? body.a : [])
|
|
380
|
+
.filter(a => typeof a === "string")
|
|
381
|
+
.slice(0, MAX_INVITE_ADDRS)
|
|
382
|
+
.map(a => ({ raw: a, at: a.lastIndexOf(":") }))
|
|
383
|
+
.filter(p => p.at > 0)
|
|
384
|
+
.map(p => ({ addr: p.raw.slice(0, p.at), port: Number(p.raw.slice(p.at + 1)) }))
|
|
385
|
+
.filter(p => p.addr && Number.isInteger(p.port) && p.port > 0 && p.port < 65_536);
|
|
386
|
+
if (!addrs.length) return null;
|
|
387
|
+
return { addrs, name: cleanName(body.n), code: body.c, expiresAt: body.x, expired };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* The proof that a caller is holding the invite, over the same transcript the
|
|
392
|
+
* session key is bound to.
|
|
393
|
+
*
|
|
394
|
+
* Not the code itself on the wire. A code that travelled in the clear would be
|
|
395
|
+
* replayable by anybody who watched one exchange, and this is the value that
|
|
396
|
+
* decides whether a deck is paired without anybody pressing anything.
|
|
397
|
+
*/
|
|
398
|
+
export function inviteProof(code, transcript) {
|
|
399
|
+
return createHmac("sha256", `ccdeck-invite-v${PROTOCOL}`).update(`${code}|${transcript}`).digest("hex");
|
|
232
400
|
}
|
|
233
401
|
|
|
234
402
|
// ── the beacon ──────────────────────────────────────────────────────────────
|
|
@@ -238,26 +406,26 @@ export function suggestPassphrase(words = 6, rand = randomBytes) {
|
|
|
238
406
|
*
|
|
239
407
|
* NO ACCOUNTS AND NO SECRET. This is the packet a stranger on the same wifi
|
|
240
408
|
* receives, so what is in it is what a stranger learns: that a ccdeck is here,
|
|
241
|
-
* what it calls itself, and
|
|
242
|
-
* the machine, not how many,
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
409
|
+
* what it calls itself, and a hash of its public key. Not which Anthropic
|
|
410
|
+
* accounts exist on the machine, not how many, and nothing that could be ground
|
|
411
|
+
* at offline, because there is nothing in it derived from a secret.
|
|
412
|
+
*
|
|
413
|
+
* IT NO LONGER CARRIES A GROUP TAG, and that is the shape of the whole feature
|
|
414
|
+
* changing rather than a field going away. The tag let a deck drop a packet
|
|
415
|
+
* from outside its group before any handshake existed to attack, which is a
|
|
416
|
+
* real property and was worth having — but it only worked when both people
|
|
417
|
+
* held the same passphrase, and a passphrase neither of them can see is exactly
|
|
418
|
+
* what nobody could get right. What replaces it is later and cheaper: a beacon
|
|
419
|
+
* from a deck this one does not trust becomes a row somebody can accept, and
|
|
420
|
+
* nothing at all happens until they do.
|
|
252
421
|
*/
|
|
253
|
-
export function beaconPayload({ name, fp, port,
|
|
422
|
+
export function beaconPayload({ name, fp, port, instance }) {
|
|
254
423
|
return {
|
|
255
424
|
m: MAGIC,
|
|
256
425
|
v: PROTOCOL,
|
|
257
426
|
n: cleanName(name),
|
|
258
427
|
f: fp,
|
|
259
428
|
p: port,
|
|
260
|
-
g: group,
|
|
261
429
|
// Randomised at start. Two beacons from one fingerprint with different
|
|
262
430
|
// instance ids mean the deck restarted between them, which is the signal to
|
|
263
431
|
// drop whatever session state was held for it rather than trying to resume
|
|
@@ -266,11 +434,6 @@ export function beaconPayload({ name, fp, port, group, instance }) {
|
|
|
266
434
|
};
|
|
267
435
|
}
|
|
268
436
|
|
|
269
|
-
/** The tag that says "same group as me", without saying what the group is. */
|
|
270
|
-
export function groupTag(key) {
|
|
271
|
-
return createHmac("sha256", key).update("beacon-group").digest("hex").slice(0, 16);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
437
|
/**
|
|
275
438
|
* Read a packet off the wire, refusing before understanding.
|
|
276
439
|
*
|
|
@@ -300,9 +463,8 @@ export function readBeacon(buf, { maxBytes = MAX_BEACON_BYTES } = {}) {
|
|
|
300
463
|
if (typeof raw.p !== "number" || !Number.isInteger(raw.p) || raw.p < 1 || raw.p > 65_535) return null;
|
|
301
464
|
const port = raw.p;
|
|
302
465
|
if (typeof raw.f !== "string" || !/^[0-9a-f]{3}(-[0-9a-f]{3}){3}$/.test(raw.f)) return null;
|
|
303
|
-
if (typeof raw.g !== "string" || !/^[0-9a-f]{16}$/.test(raw.g)) return null;
|
|
304
466
|
if (typeof raw.i !== "string" || !/^[0-9a-f]{8,32}$/.test(raw.i)) return null;
|
|
305
|
-
return { name: cleanName(raw.n, raw.f), fp: raw.f, port,
|
|
467
|
+
return { name: cleanName(raw.n, raw.f), fp: raw.f, port, instance: raw.i };
|
|
306
468
|
}
|
|
307
469
|
|
|
308
470
|
/**
|
|
@@ -318,30 +480,116 @@ export function readBeacon(buf, { maxBytes = MAX_BEACON_BYTES } = {}) {
|
|
|
318
480
|
* broadcast on every interface it owns, and filtering by address would need a
|
|
319
481
|
* list of them that changes when a VPN comes up.
|
|
320
482
|
*/
|
|
321
|
-
export function beaconVerdict(beacon, { selfFp,
|
|
483
|
+
export function beaconVerdict(beacon, { selfFp, selfInstance, trusted } = {}) {
|
|
322
484
|
if (!beacon) return "unreadable";
|
|
323
485
|
if (beacon.fp === selfFp) {
|
|
324
|
-
// OUR OWN NAME, FROM SOMEBODY ELSE'S PROCESS.
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
// to say why.
|
|
486
|
+
// OUR OWN NAME, FROM SOMEBODY ELSE'S PROCESS. Two decks sharing a config
|
|
487
|
+
// directory hold the same key — and so does the second machine when
|
|
488
|
+
// somebody copies their ~/.claude across, which people do. Both then file
|
|
489
|
+
// every one of the other's beacons as "that is me" and the two are
|
|
490
|
+
// permanently invisible to each other, with nothing on screen to say why.
|
|
330
491
|
//
|
|
331
492
|
// `instance` is what separates the cases: it is fresh per process, so our
|
|
332
493
|
// own packet carries the instance we are running and another deck's cannot.
|
|
333
494
|
// Told apart here rather than healed here — this function decides, and
|
|
334
|
-
//
|
|
495
|
+
// taking a new key is the engine's to do.
|
|
335
496
|
return selfInstance && beacon.instance !== selfInstance ? "id-clash" : "self";
|
|
336
497
|
}
|
|
337
|
-
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
498
|
+
// A DECK WE HAVE BEEN TOLD TO TRUST, or one somebody may choose to. There is
|
|
499
|
+
// no third answer any more: the group tag used to sort strangers from peers
|
|
500
|
+
// before a handshake existed, and now every stranger is a row with a name and
|
|
501
|
+
// an address that a person can accept or ignore. Nothing is asked of an
|
|
502
|
+
// unknown deck and nothing is offered to it.
|
|
503
|
+
return Array.isArray(trusted) && trusted.some(t => t.fp === beacon.fp) ? "peer" : "stranger";
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** Whether this fingerprint is one somebody has already accepted, and what was
|
|
507
|
+
* recorded about it — the pinned public key above all, which is what makes a
|
|
508
|
+
* second connection from the same fingerprint checkable rather than merely
|
|
509
|
+
* claimed. */
|
|
510
|
+
export function trustedPeer(trusted, fp) {
|
|
511
|
+
return (Array.isArray(trusted) ? trusted : []).find(t => t && t.fp === fp) ?? null;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Add a deck to the trusted list, or update what is known about one.
|
|
516
|
+
*
|
|
517
|
+
* THE PINNED KEY NEVER CHANGES UNDER US. A second entry claiming a fingerprint
|
|
518
|
+
* we already hold with a different public key is not an update, it is a
|
|
519
|
+
* different deck wearing the name — and 48 bits of fingerprint is far past
|
|
520
|
+
* accident, so it is somebody trying. The old entry stands and the caller is
|
|
521
|
+
* told nothing changed.
|
|
522
|
+
*/
|
|
523
|
+
export function addTrusted(trusted, entry) {
|
|
524
|
+
const list = Array.isArray(trusted) ? trusted : [];
|
|
525
|
+
if (!entry || typeof entry.fp !== "string" || typeof entry.pub !== "string") return { list, added: false };
|
|
526
|
+
const had = trustedPeer(list, entry.fp);
|
|
527
|
+
if (had) {
|
|
528
|
+
if (had.pub !== entry.pub) return { list, added: false };
|
|
529
|
+
return {
|
|
530
|
+
list: list.map(t => (t.fp === entry.fp ? { ...t, name: entry.name ?? t.name } : t)),
|
|
531
|
+
added: false,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
return { list: [...list, { fp: entry.fp, pub: entry.pub, name: entry.name ?? "" }], added: true };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Take one back out. Unpairing stops what has not happened yet and takes back
|
|
538
|
+
* nothing that has — the same sentence the panel says about a shared login,
|
|
539
|
+
* and true here for the same reason. */
|
|
540
|
+
export function dropTrusted(trusted, fp) {
|
|
541
|
+
return (Array.isArray(trusted) ? trusted : []).filter(t => t && t.fp !== fp);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* The decks worth offering to pair with, out of everything that has ever been
|
|
546
|
+
* heard.
|
|
547
|
+
*
|
|
548
|
+
* THIS LIST WAS A WALL. Every deck ever heard stayed in it forever, and a deck
|
|
549
|
+
* that restarts takes a NEW key — so a machine started and stopped seven times
|
|
550
|
+
* was seven rows, all with the same name, all at the same address, none of them
|
|
551
|
+
* reachable any more. The dialog it filled was unreadable, which is exactly
|
|
552
|
+
* what somebody reported.
|
|
553
|
+
*
|
|
554
|
+
* Three rules, in order:
|
|
555
|
+
*
|
|
556
|
+
* PRESENT ONLY. A deck that has not been heard for a couple of announce
|
|
557
|
+
* intervals is not somewhere you can pair right now, so it is not offered. This
|
|
558
|
+
* is the same window the peer table uses, for the same reason.
|
|
559
|
+
*
|
|
560
|
+
* ONE ROW PER MACHINE. A person reading this sees a name and an address, and
|
|
561
|
+
* two rows carrying the same pair are the same machine to them whatever the
|
|
562
|
+
* fingerprints say. The freshest wins, because it is the one still running.
|
|
563
|
+
*
|
|
564
|
+
* NEWEST FIRST, AND CAPPED. The deck somebody just started is the one they are
|
|
565
|
+
* looking for; a list longer than a screen is a list nobody reads.
|
|
566
|
+
*/
|
|
567
|
+
export function pairable(strangers, now, { presentMs = PRESENT_MS, limit = 8, mine = [] } = {}) {
|
|
568
|
+
// NOT THIS MACHINE. A deck's own beacon is filtered by fingerprint, which is
|
|
569
|
+
// right and is not enough: a second deck on the same computer is a different
|
|
570
|
+
// process with a different key, so it passes that check honestly and then
|
|
571
|
+
// shows up in the list under this machine's own hostname, at this machine's
|
|
572
|
+
// own address, offering to pair with itself.
|
|
573
|
+
//
|
|
574
|
+
// It was reported from a screenshot — "why myself appear here in list" — and
|
|
575
|
+
// the answer is that the address is the one thing that cannot lie: a beacon
|
|
576
|
+
// arriving FROM an address this machine holds came from this machine. Pairing
|
|
577
|
+
// with it would also buy nothing, because both decks read one claude-swap
|
|
578
|
+
// store and there is nothing for either to heal.
|
|
579
|
+
const own = new Set(Array.isArray(mine) ? mine : []);
|
|
580
|
+
const live = [...(strangers ?? [])]
|
|
581
|
+
.filter(p => p && typeof p.at === "number" && now - p.at <= presentMs)
|
|
582
|
+
.filter(p => !own.has(p.addr))
|
|
583
|
+
.sort((a, b) => b.at - a.at);
|
|
584
|
+
const seen = new Set();
|
|
585
|
+
const out = [];
|
|
586
|
+
for (const p of live) {
|
|
587
|
+
const key = `${p.name}@${p.addr}`;
|
|
588
|
+
if (seen.has(key)) continue;
|
|
589
|
+
seen.add(key);
|
|
590
|
+
out.push(p);
|
|
591
|
+
}
|
|
592
|
+
return { shown: out.slice(0, limit), more: Math.max(0, out.length - limit) };
|
|
345
593
|
}
|
|
346
594
|
|
|
347
595
|
// ── the peer table ──────────────────────────────────────────────────────────
|