feat(cli,broker): stable session identity — fix ghost peers + lost DMs (1.35.0)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

Session identity is now anchored on Claude Code's session UUID instead of a
fresh random keypair per launch. The ed25519 session keypair is generated
once per (mesh, session UUID) and persisted under
~/.claudemesh/sessions/<mesh>/<uuid>.json, so relaunching or --resume-ing the
same session reuses the same sessionPubkey.

Why: a DM is sealed (crypto_box) to the recipient's sessionPubkey. With
ephemeral per-launch keys, the pubkey rotated on every relaunch, so queued
messages became undecryptable AND the old presence lingered as a same-name
ghost that won queued-DM claim races. Reconnecting could not recover the
peer because it minted yet another key. On --resume the CLI also registered
a throwaway random id unrelated to the resumed session, so the broker never
recognized the returning peer.

CLI (launch.ts):
- resolve the stable UUID for all paths: fresh mints + forces via
  --session-id; --resume V registers V; --continue resolves the most-recent
  session UUID from ~/.claude/projects/<cwd>.
- use loadOrCreateSessionKeypair(mesh, uuid) instead of generateKeypair().

CLI (daemon/run.ts):
- onRegister closes any prior SessionBrokerClient holding the same pubkey
  under a different token (the leaked-WS ghost).

Broker (handleSessionHello):
- reattach by sessionPubkey regardless of lease state (online or grace),
  closing the stale socket — enforces one live presence per session pubkey,
  killing the duplicate and draining queued DMs on return.

Trade-off: session secret keys now persist on disk (the member key already
does); SPEC.md updated to reflect the stable-identity model. Older CLIs
remain compatible (they keep using ephemeral keys).

New: keypair-store.ts + 7 unit tests. Full CLI suite: 114/114 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-06-02 12:59:36 +01:00
parent 589d050f81
commit 2b88784005
7 changed files with 337 additions and 8 deletions

View File

@@ -0,0 +1,147 @@
/**
* Persistent per-session ed25519 keypairs, keyed by Claude Code's
* session UUID.
*
* Background. Until this module landed, `claudemesh launch` minted a
* FRESH ephemeral session keypair on every invocation (see
* SPEC.md §"Session identity"). That made a peer's routing/crypto
* identity unstable across relaunch and `--resume`: a DM is sealed to
* the recipient's `sessionPubkey` (crypto_box; see services/crypto/box.ts),
* so when the key rotated, any message queued for the old pubkey became
* undecryptable AND the old presence lingered as a ghost on the broker.
*
* The fix anchors session identity on the stable thing Claude Code
* itself uses for resume: the session UUID (scoped to the project/cwd).
* The keypair for a given (mesh, sessionUuid) is generated once and
* persisted, so:
* - relaunching / `--resume`-ing the same session reuses the SAME
* pubkey → the broker reattaches the existing presence and queued
* DMs both route AND decrypt;
* - a genuinely new session (fresh UUID) gets a fresh keypair → it is
* correctly a distinct peer.
*
* Storage. `~/.claudemesh/sessions/<meshSlug>/<sessionUuid>.json`, the
* file mode 0o600 inside a 0o700 dir — same secret-hygiene as the IPC
* token store. The secret key lives on disk (like the member key
* already does in the mesh config); the threat-model delta over the old
* ephemeral scheme is small and was an accepted trade for reliable
* delivery. `CLAUDEMESH_SESSIONS_DIR` overrides the root for tests.
*/
import { randomBytes } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { generateKeypair, type Ed25519Keypair } from "~/services/crypto/facade.js";
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const SLUG_RE = /^[a-z0-9._-]+$/i;
interface StoredKeypair {
version: 1;
meshSlug: string;
sessionId: string;
publicKey: string;
secretKey: string;
createdAt: string;
}
/** Root dir for persisted session keypairs. Stable per-machine; does
* NOT honor the per-launch `CLAUDEMESH_CONFIG_DIR` tmpdir (those are
* ephemeral and would defeat persistence). */
export function sessionsDir(): string {
return (
process.env.CLAUDEMESH_SESSIONS_DIR ||
join(homedir(), ".claudemesh", "sessions")
);
}
function keyFilePath(meshSlug: string, sessionId: string): string {
return join(sessionsDir(), meshSlug, `${sessionId}.json`);
}
/** Read a persisted keypair, returning null (never throwing) when the
* file is missing, unreadable, malformed, or carries an invalid key. */
function readValidKeypair(file: string): Ed25519Keypair | null {
try {
if (!existsSync(file)) return null;
const parsed = JSON.parse(readFileSync(file, "utf8")) as Partial<StoredKeypair>;
if (
parsed &&
typeof parsed.publicKey === "string" &&
/^[0-9a-f]{64}$/.test(parsed.publicKey) &&
typeof parsed.secretKey === "string" &&
/^[0-9a-f]{128}$/.test(parsed.secretKey)
) {
return { publicKey: parsed.publicKey, secretKey: parsed.secretKey };
}
} catch {
// Unreadable / corrupt — caller treats as absent and rewrites.
}
return null;
}
/**
* Return the persisted keypair for (meshSlug, sessionId), creating and
* writing one on first use. Re-reads from disk every call so concurrent
* launches of the same session converge on one identity rather than
* racing to mint divergent keys.
*
* Falls back to an in-memory ephemeral keypair (the legacy behaviour)
* when the identifiers are unusable or disk I/O fails — a launch must
* never be blocked by a keystore problem.
*/
export async function loadOrCreateSessionKeypair(
meshSlug: string,
sessionId: string,
): Promise<Ed25519Keypair> {
// Defensive validation: these compose into a filesystem path, so a
// malformed slug/uuid must never escape the sessions dir.
if (!SLUG_RE.test(meshSlug) || !UUID_RE.test(sessionId)) {
return generateKeypair();
}
const file = keyFilePath(meshSlug, sessionId);
const existing = readValidKeypair(file);
if (existing) return existing;
const kp = await generateKeypair();
try {
mkdirSync(join(sessionsDir(), meshSlug), { recursive: true, mode: 0o700 });
const stored: StoredKeypair = {
version: 1,
meshSlug,
sessionId,
publicKey: kp.publicKey,
secretKey: kp.secretKey,
createdAt: new Date().toISOString(),
};
// Write to a temp sibling then rename for atomicity, so a concurrent
// reader never sees a half-written file.
const tmp = `${file}.${randomBytes(6).toString("hex")}.tmp`;
writeFileSync(tmp, JSON.stringify(stored), { mode: 0o600 });
try {
// Re-check: another launch may have won the race and created the
// canonical file with a VALID keypair while we were generating —
// prefer it. A corrupt/invalid existing file is not a winner; fall
// through and overwrite it via the atomic rename below.
if (existsSync(file)) {
const won = readValidKeypair(file);
if (won) {
try { rmSync(tmp, { force: true }); } catch { /* ignore */ }
return won;
}
}
// renameSync is atomic on the same filesystem.
renameSync(tmp, file);
} catch {
// rename failed — best effort, the in-memory keypair is still valid
// for this launch.
}
} catch {
// mkdir/write failed — return the freshly generated keypair anyway so
// the launch proceeds (degrades to ephemeral, same as legacy).
}
return kp;
}