diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md index 7668d73..b3199f6 100644 --- a/apps/cli/CHANGELOG.md +++ b/apps/cli/CHANGELOG.md @@ -1,5 +1,96 @@ # Changelog +## 1.34.17 (2026-05-20) — host fingerprint v2: stable across Mac reboots + +Fixes a long-standing class of false-positive `host_fingerprint +mismatch` failures that put the daemon in a launchd restart loop +after Mac reboots (one user observed the daemon's `runs` counter +climb past 360 and `daemon.log` balloon to 24 MB before the next +manual `claudemesh daemon accept-host`). + +### Root cause + +`apps/cli/src/daemon/identity.ts` (v1) computed `sha256(host_id || +mac)` where `host_id` was empty on macOS (the file commented "No +reliable file; fall back to MAC-only fingerprint") and `mac` was +picked by enumerating `os.networkInterfaces()`, filtering a small +list of known-virtual interface name prefixes, then sorting the +remainder lex and taking the first. + +On a Mac with Wi-Fi the lex-first survivor was usually `en0` — but +Apple's privacy feature randomizes the Wi-Fi MAC, and the random +value can change across reboots, network rejoins, or when the user +toggles "Private Wi-Fi address" for a network. The stored +fingerprint was hashed from one randomized MAC; after a reboot, +`pickStableMac()` returned a different randomized MAC; the hashes +diverged; the daemon refused to start and the LaunchAgent +respawned it every second. + +The interface name filter was also missing several macOS virtual +adapters (`anpi*`, `bridge*`, `ap[0-9]`) which can churn for +similar reasons. + +### The fix — schema_version 2 + +- **macOS host_id** — `IOPlatformUUID` via `ioreg -rd1 -c + IOPlatformExpertDevice`, parsed at daemon start (~30 ms, + cached for the process lifetime). Burned into EFI; stable + across reboots, OS reinstalls, and macOS upgrades. Falls back + to MAC-only if `ioreg` fails or is missing (`darwin` namespaced + so it can never collide with a `linux:` machine-id). +- **MAC picker hardening** — rejects any MAC with the + locally-administered bit (`0x02` of the first byte) set. These + are randomization-prone by definition (RFC 5342). Hardware + MACs (universally-administered, OUI-prefixed) are preferred; + locally-administered MACs are kept only as a fallback when no + hardware NIC is enumerable. +- **Extended interface ignore list** — `anpi`, `bridge`, + `ap[0-9]` join the existing `lo|docker|br-|veth|tap|tun| + tailscale|wg|utun|ppp|vboxnet|vmnet|awdl|llw`. +- **Domain-separated hash** — v2 hash prefixes `"v2\0"` before + `host_id || \0 || mac`. Guarantees v1 and v2 outputs can never + collide on the same inputs, so the migration check is + unambiguous. + +### Silent migration (v1 → v2) + +`checkFingerprint()` now handles three cases: + +1. Stored file has `schema_version: 2` → direct hash compare. +2. Stored file has `schema_version: 1` → recompute fingerprint + under the v1 algorithm. If it matches, the user is legitimately + on the same host running v2 for the first time — the file is + silently rewritten as v2 and the daemon proceeds. If the v1 + recompute also fails to match, this is a genuine mismatch + (real host change, restored backup, accidental clone) and the + daemon refuses as before. +3. Stored file has an unknown future `schema_version` → treated + as `unavailable` (not overwritten). Prevents a newer daemon + from silently wiping a file it doesn't understand. + +The v1 algorithm is preserved as a frozen internal helper for the +migration path only. New code should never extend it. + +### Tests + test infra fixes + +`apps/cli/tests/unit/identity.test.ts` (18 cases) covers v2 +determinism, v2 domain separation from v1, hardware-MAC +preference, locally-administered MAC fallback, every +`checkFingerprint` branch, and the silent v1→v2 upgrade behavior +of `acceptCurrentHost`. + +Two pre-existing CI/test-infra papercuts surfaced while validating +this fix and were corrected alongside: + +- `apps/cli/tests/golden/{version,whoami}.test.ts` spawn the + built CLI at `dist/entrypoints/cli.js` but nothing built it + before `vitest run`. `turbo.json` now declares + `test.dependsOn = ["build"]` so the monorepo always builds + first, and a new vitest globalSetup + (`apps/cli/tests/setup/ensure-built.ts`) rebuilds on demand + with `~/.bun/bin` and Homebrew layered into PATH for the + spawned subprocess. + ## 1.34.15 (2026-05-04) — `peer list --mesh` actually scopes + `kick` refuses control-plane Two follow-ups from the 1.34.x train, both backwards-compatible. diff --git a/apps/cli/package.json b/apps/cli/package.json index ffa8194..94c8275 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "claudemesh-cli", - "version": "1.34.16", + "version": "1.34.17", "description": "Peer mesh for Claude Code sessions — CLI + MCP server.", "keywords": [ "claude-code", diff --git a/apps/cli/src/daemon/identity.ts b/apps/cli/src/daemon/identity.ts index 813538a..905bd6b 100644 --- a/apps/cli/src/daemon/identity.ts +++ b/apps/cli/src/daemon/identity.ts @@ -4,22 +4,37 @@ // // NOT attacker-grade: anyone copying both the keypair AND the // host_fingerprint defeats this. Threat model §16 says so explicitly. +// +// ── schema_version: 2 (1.34.17+) ── +// v1 was vulnerable to false mismatches across reboots on macOS because +// `os.networkInterfaces()` returns Wi-Fi MACs that Apple's privacy +// rotation re-randomizes (bit 0x02 of the first byte = "locally +// administered"). After a Mac restart, en0's MAC could change → the +// stored sha256(host_id || mac) no longer matched → the daemon refused +// to start in a restart loop. Cure was always manual `accept-host`. +// +// v2 fixes the root cause: on macOS we read `IOPlatformUUID` via +// `ioreg` (burned into EFI, never changes); the MAC picker rejects any +// locally-administered MAC and prefers true hardware NICs. Migration +// is silent — a v1 store that still matches the v1 algorithm is +// rewritten transparently as v2 on first start under this version. import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { createHash, randomUUID } from "node:crypto"; -import { networkInterfaces } from "node:os"; +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { networkInterfaces, type NetworkInterfaceInfo } from "node:os"; import { DAEMON_PATHS } from "./paths.js"; export type ClonePolicy = "refuse" | "warn" | "allow"; export interface FingerprintRecord { - schema_version: 1; - fingerprint: string; // sha256 hex - host_id: string; // raw, for diagnostics - stable_mac: string; // raw, for diagnostics - written_at: string; // ISO date + schema_version: 1 | 2; + fingerprint: string; // sha256 hex + host_id: string; // raw, for diagnostics + stable_mac: string; // raw, for diagnostics + written_at: string; // ISO date } export interface FingerprintCheck { @@ -29,32 +44,27 @@ export interface FingerprintCheck { } const FILE_NAME = "host_fingerprint.json"; +const CURRENT_SCHEMA: 2 = 2; -function path(): string { return join(DAEMON_PATHS.DAEMON_DIR, FILE_NAME); } +function path(): string { + return join(DAEMON_PATHS.DAEMON_DIR, FILE_NAME); +} -/** Compute (without writing) the current host fingerprint. */ +// ── public API ──────────────────────────────────────────────────────── + +/** Compute (without writing) the current host fingerprint under v2 rules. */ export function computeCurrentFingerprint(): FingerprintRecord { - // Per spec §2.2 / followups doc: when neither host_id nor a stable MAC - // are readable we fall back to a persisted random UUID. We DO NOT mint - // a fresh random per call (that would make every restart look like a - // clone). Instead, leave host_id empty when unknown — the MAC alone - // identifies the host for accidental-clone detection. - const host_id = readHostId() ?? ""; - const stable_mac = pickStableMac() ?? ""; - const fp = createHash("sha256").update(host_id, "utf8").update("\0").update(stable_mac, "utf8").digest("hex"); + const host_id = readHostIdV2() ?? ""; + const stable_mac = pickStableMacFromInterfaces(networkInterfaces()) ?? ""; return { - schema_version: 1, - fingerprint: fp, + schema_version: CURRENT_SCHEMA, + fingerprint: fingerprintV2(host_id, stable_mac), host_id, stable_mac, written_at: new Date().toISOString(), }; } -// `randomUUID` is no longer used after the random-fallback fix; keep the -// import only if other helpers need it. -void randomUUID; - /** Read or write the persisted fingerprint and report the result. */ export function checkFingerprint(): FingerprintCheck { const current = computeCurrentFingerprint(); @@ -63,47 +73,139 @@ export function checkFingerprint(): FingerprintCheck { return { result: "first_run", current }; } let stored: FingerprintRecord; - try { stored = JSON.parse(readFileSync(path(), "utf8")) as FingerprintRecord; } - catch { return { result: "unavailable", current }; } - if (stored.fingerprint === current.fingerprint) return { result: "match", current, stored }; - return { result: "mismatch", current, stored }; + try { + stored = JSON.parse(readFileSync(path(), "utf8")) as FingerprintRecord; + } catch { + return { result: "unavailable", current }; + } + + // v2 fast path: direct compare. + if (stored.schema_version === 2) { + if (stored.fingerprint === current.fingerprint) + return { result: "match", current, stored }; + return { result: "mismatch", current, stored }; + } + + // v1 migration path. Recompute under v1 rules; if it matches, the + // user is legitimately on the same host, just running v2 for the + // first time — rewrite the file as v2 and report match. If v1 does + // not match either, this is a genuine mismatch (clone, restored + // backup, or actual host change) and the daemon should refuse. + if (stored.schema_version === 1) { + const v1 = computeCurrentFingerprintV1(); + if (stored.fingerprint === v1.fingerprint) { + writeFileSync(path(), JSON.stringify(current, null, 2), { mode: 0o600 }); + return { result: "match", current, stored }; + } + return { result: "mismatch", current, stored }; + } + + // Unknown future schema. Treat as unavailable rather than first_run + // — we don't want a newer daemon to silently overwrite a file it + // doesn't understand. + return { result: "unavailable", current }; } -/** Re-write the fingerprint file. Used by `daemon accept-host`. */ +/** Re-write the fingerprint file under v2 rules. Used by `daemon accept-host`. */ export function acceptCurrentHost(): FingerprintRecord { const current = computeCurrentFingerprint(); writeFileSync(path(), JSON.stringify(current, null, 2), { mode: 0o600 }); return current; } -// ── platform helpers ─────────────────────────────────────────────────── +// ── v2 helpers (exported for tests) ─────────────────────────────────── -function readHostId(): string | null { - // Linux: /etc/machine-id (or /var/lib/dbus/machine-id). +/** Pure: compute a v2 fingerprint from host_id + stable_mac strings. */ +export function fingerprintV2(host_id: string, stable_mac: string): string { + // The "v2\0" prefix guarantees v1 and v2 hashes are domain-separated + // even when fed identical inputs. + return createHash("sha256") + .update("v2", "utf8") + .update("\0") + .update(host_id, "utf8") + .update("\0") + .update(stable_mac, "utf8") + .digest("hex"); +} + +/** + * Pure: pick a stable MAC from a NetworkInterfaceInfo map. + * Rejects locally-administered MACs (bit 0x02 of first byte), which + * are typically randomized on Apple Wi-Fi, NetworkManager privacy + * mode, and most virtual bridges. Returns null if none qualify. + * + * Exported for unit tests that feed synthetic interface tables. + */ +export function pickStableMacFromInterfaces( + ifs: NodeJS.Dict, +): string | null { + // First pass: prefer hardware (universally-administered) MACs. + const hardware: Array<{ name: string; mac: string }> = []; + const fallback: Array<{ name: string; mac: string }> = []; + + for (const [name, addrs] of Object.entries(ifs)) { + if (!addrs) continue; + if (isIgnoredInterface(name)) continue; + for (const a of addrs) { + if (a.internal) continue; + if (!a.mac || a.mac === "00:00:00:00:00:00") continue; + const entry = { name, mac: a.mac }; + if (isLocallyAdministered(a.mac)) { + fallback.push(entry); + } else { + hardware.push(entry); + } + break; + } + } + + const pool = hardware.length > 0 ? hardware : fallback; + if (pool.length === 0) return null; + pool.sort((a, b) => a.name.localeCompare(b.name)); + return pool[0]!.mac; +} + +// ── v1 helpers (kept ONLY for migration; do not extend) ─────────────── + +function computeCurrentFingerprintV1(): FingerprintRecord { + const host_id = readHostIdV1() ?? ""; + const stable_mac = pickStableMacV1() ?? ""; + const fp = createHash("sha256") + .update(host_id, "utf8") + .update("\0") + .update(stable_mac, "utf8") + .digest("hex"); + return { + schema_version: 1, + fingerprint: fp, + host_id, + stable_mac, + written_at: new Date().toISOString(), + }; +} + +function readHostIdV1(): string | null { if (process.platform === "linux") { for (const p of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) { try { const raw = readFileSync(p, "utf8").trim(); if (raw) return `linux:${raw}`; - } catch { /* try next */ } + } catch { + /* try next */ + } } return null; } - // macOS: IOPlatformUUID via ioreg. We avoid spawning by checking ENV. - if (process.platform === "darwin") { - // No reliable file; fall back to MAC-only fingerprint. - return null; - } - // Windows: HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid. Skip in v0.9.0. + // v1 on macOS/Windows: empty host_id, MAC-only fingerprint. return null; } -function pickStableMac(): string | null { +function pickStableMacV1(): string | null { const ifs = networkInterfaces(); const candidates: string[] = []; for (const [name, addrs] of Object.entries(ifs)) { if (!addrs) continue; - if (isIgnoredInterface(name)) continue; + if (isIgnoredInterfaceV1(name)) continue; for (const a of addrs) { if (a.internal) continue; if (!a.mac || a.mac === "00:00:00:00:00:00") continue; @@ -112,12 +214,98 @@ function pickStableMac(): string | null { } } if (candidates.length === 0) return null; - candidates.sort(); // lex by interface name + candidates.sort(); const first = candidates[0]!; const idx = first.indexOf("::"); return idx >= 0 ? first.slice(idx + 2) : first; } -function isIgnoredInterface(name: string): boolean { - return /^(lo|docker|br-|veth|tap|tun|tailscale|wg|utun|ppp|vboxnet|vmnet|awdl|llw)/i.test(name); +function isIgnoredInterfaceV1(name: string): boolean { + return /^(lo|docker|br-|veth|tap|tun|tailscale|wg|utun|ppp|vboxnet|vmnet|awdl|llw)/i.test( + name, + ); +} + +// ── platform helpers (v2) ───────────────────────────────────────────── + +let cachedHostIdV2: string | null | undefined; + +function readHostIdV2(): string | null { + if (cachedHostIdV2 !== undefined) return cachedHostIdV2; + cachedHostIdV2 = readHostIdV2Uncached(); + return cachedHostIdV2; +} + +function readHostIdV2Uncached(): string | null { + // Linux: /etc/machine-id (or /var/lib/dbus/machine-id) — burned in at + // first boot, stable across reboots, namespaced per spec. + if (process.platform === "linux") { + for (const p of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) { + try { + const raw = readFileSync(p, "utf8").trim(); + if (raw) return `linux:${raw}`; + } catch { + /* try next */ + } + } + return null; + } + + // macOS: IOPlatformUUID is burned into EFI/hardware, stable across + // reboots, OS reinstalls, and macOS upgrades. We spawn `ioreg` once + // at daemon start (cached for process lifetime) — ~30 ms, run on + // first start only thanks to the module-level cache. + if (process.platform === "darwin") { + try { + const out = execFileSync( + "/usr/sbin/ioreg", + ["-rd1", "-c", "IOPlatformExpertDevice"], + { + encoding: "utf8", + timeout: 2000, + stdio: ["ignore", "pipe", "ignore"], + }, + ); + const m = out.match(/"IOPlatformUUID"\s*=\s*"([0-9A-Fa-f-]+)"/); + if (m && m[1]) return `darwin:${m[1]}`; + } catch { + /* fall through to MAC-only fingerprint */ + } + return null; + } + + // Windows: HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid. Reading + // the registry from Node would require a native module or a `reg + // query` spawn — deferred until we have Windows users complaining. + return null; +} + +/** True if the MAC has the locally-administered bit set (0x02 of first byte). */ +function isLocallyAdministered(mac: string): boolean { + const firstByte = parseInt(mac.split(":")[0] ?? "0", 16); + if (Number.isNaN(firstByte)) return false; + return (firstByte & 0x02) !== 0; +} + +function isIgnoredInterface(name: string): boolean { + // Extends the v1 list with `anpi*` (Apple Network Personal Interface + // — bridges to peripherals), `ap[0-9]` (AP mode adapters), and + // `bridge` (virtual bridges from VMs / Internet Sharing). All can + // appear with unstable MACs even when "hardware" by Node's standards. + return /^(lo|docker|br-|bridge|veth|tap|tun|tailscale|wg|utun|ppp|vboxnet|vmnet|awdl|llw|anpi|ap\d)/i.test( + name, + ); +} + +// ── test-only hooks ─────────────────────────────────────────────────── + +/** Reset the module-level host_id cache. Used by unit tests only. */ +export function __resetHostIdCacheForTests(): void { + cachedHostIdV2 = undefined; +} + +/** Compute the v1 fingerprint on this host. Tests use this to seed a + * matching v1 fingerprint file to exercise the silent-migration path. */ +export function __computeV1FingerprintForTests(): FingerprintRecord { + return computeCurrentFingerprintV1(); } diff --git a/apps/cli/tests/setup/ensure-built.ts b/apps/cli/tests/setup/ensure-built.ts new file mode 100644 index 0000000..e2c952e --- /dev/null +++ b/apps/cli/tests/setup/ensure-built.ts @@ -0,0 +1,71 @@ +// vitest globalSetup — guarantees `dist/entrypoints/cli.js` exists +// before any golden test spawns the built CLI. Without this, running +// `npx vitest run` in a clean checkout (or after `pnpm run clean`) +// surfaces as opaque `MODULE_NOT_FOUND` failures inside golden tests. + +import { existsSync, statSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { join, dirname, delimiter } from "node:path"; +import { fileURLToPath } from "node:url"; +import { homedir } from "node:os"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CLI_PKG_DIR = join(HERE, "..", ".."); +const CLI_ENTRY = join(CLI_PKG_DIR, "dist", "entrypoints", "cli.js"); +const BUILD_SCRIPT = join(CLI_PKG_DIR, "build.ts"); +const PKG_JSON = join(CLI_PKG_DIR, "package.json"); + +// Vitest's worker doesn't always inherit the user's shell PATH (no +// `.zshrc`/`config.fish` is sourced), so a bun install at `~/.bun/bin` +// is invisible to spawnSync. Layer the well-known install locations +// in so the build command can find them. +const EXTRA_PATHS = [ + join(homedir(), ".bun", "bin"), + "/opt/homebrew/bin", + "/usr/local/bin", +]; + +function augmentedEnv(): NodeJS.ProcessEnv { + const current = process.env.PATH ?? ""; + const augmented = [...EXTRA_PATHS, current].filter(Boolean).join(delimiter); + return { ...process.env, PATH: augmented }; +} + +function isDistFresh(): boolean { + if (!existsSync(CLI_ENTRY)) return false; + // If the build script or package.json (which contributes the + // injected version constant) is newer than dist, rebuild. + try { + const distMtime = statSync(CLI_ENTRY).mtimeMs; + if (statSync(BUILD_SCRIPT).mtimeMs > distMtime) return false; + if (statSync(PKG_JSON).mtimeMs > distMtime) return false; + } catch { + return false; + } + return true; +} + +export default async function setup(): Promise { + if (isDistFresh()) return; + + // Try `bun build.ts` first (the canonical path). If bun is missing, + // fall back to `pnpm run build` which delegates to the same script. + const tries: Array<{ cmd: string; args: string[] }> = [ + { cmd: "bun", args: ["build.ts"] }, + { cmd: "pnpm", args: ["run", "build"] }, + ]; + + const env = augmentedEnv(); + for (const { cmd, args } of tries) { + const r = spawnSync(cmd, args, { cwd: CLI_PKG_DIR, stdio: "inherit", env }); + if (r.status === 0 && existsSync(CLI_ENTRY)) return; + if (r.error && (r.error as NodeJS.ErrnoException).code === "ENOENT") + continue; + } + + throw new Error( + `vitest globalSetup: failed to build the CLI. ` + + `Tried \`bun build.ts\` and \`pnpm run build\`. ` + + `Install bun (https://bun.sh) or run \`pnpm run build\` manually before testing.`, + ); +} diff --git a/apps/cli/tests/unit/identity.test.ts b/apps/cli/tests/unit/identity.test.ts new file mode 100644 index 0000000..ba24abe --- /dev/null +++ b/apps/cli/tests/unit/identity.test.ts @@ -0,0 +1,316 @@ +import { describe, it, expect, beforeEach, afterAll } from "vitest"; +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import type { NetworkInterfaceInfo } from "node:os"; + +/** + * identity.ts v2 — fingerprint algorithm + v1→v2 migration. + * + * `daemon/paths.ts` reads `CLAUDEMESH_DAEMON_DIR` ONCE at module load + * and caches it in a `const`. We MUST set it at module load time of + * this test file (BEFORE any `await import("~/daemon/identity.js")` + * fires, which would transitively load paths.ts). Setting it inside + * `beforeAll` is too late — the pure-helper describes above run their + * dynamic imports first and pin paths.ts to the real `~/.claudemesh`. + */ +const TEST_DIR = join( + tmpdir(), + `claudemesh-identity-test-${Date.now()}-${process.pid}`, +); +process.env.CLAUDEMESH_DAEMON_DIR = TEST_DIR; +mkdirSync(TEST_DIR, { recursive: true }); + +function iface(mac: string, internal = false): NetworkInterfaceInfo { + return { + address: "192.168.1.1", + netmask: "255.255.255.0", + family: "IPv4", + mac, + internal, + cidr: "192.168.1.1/24", + }; +} + +describe("fingerprintV2 (pure)", () => { + it("is deterministic for the same inputs", async () => { + const { fingerprintV2 } = await import("~/daemon/identity.js"); + expect(fingerprintV2("darwin:ABC", "00:e0:4c:68:00:c0")).toBe( + fingerprintV2("darwin:ABC", "00:e0:4c:68:00:c0"), + ); + }); + + it("changes when host_id changes", async () => { + const { fingerprintV2 } = await import("~/daemon/identity.js"); + expect(fingerprintV2("darwin:A", "00:e0:4c:68:00:c0")).not.toBe( + fingerprintV2("darwin:B", "00:e0:4c:68:00:c0"), + ); + }); + + it("changes when MAC changes", async () => { + const { fingerprintV2 } = await import("~/daemon/identity.js"); + expect(fingerprintV2("darwin:A", "00:e0:4c:68:00:c0")).not.toBe( + fingerprintV2("darwin:A", "00:e0:4c:68:00:c1"), + ); + }); + + it("is domain-separated from v1 — same inputs produce different hashes", async () => { + const { fingerprintV2 } = await import("~/daemon/identity.js"); + const { createHash } = await import("node:crypto"); + // v1 was: sha256(host_id || \0 || mac). v2 prepends "v2\0". + const v1Hash = createHash("sha256") + .update("h", "utf8") + .update("\0") + .update("m", "utf8") + .digest("hex"); + expect(fingerprintV2("h", "m")).not.toBe(v1Hash); + }); +}); + +describe("pickStableMacFromInterfaces (pure)", () => { + it("prefers a hardware (universally-administered) MAC over a locally-administered one", async () => { + const { pickStableMacFromInterfaces } = await import( + "~/daemon/identity.js" + ); + const ifs = { + en0: [iface("2a:11:99:2b:5f:c1")], // locally-admin (Apple Wi-Fi rotation) + en7: [iface("00:e0:4c:68:00:c0")], // hardware (real NIC) + }; + expect(pickStableMacFromInterfaces(ifs)).toBe("00:e0:4c:68:00:c0"); + }); + + it("falls back to a locally-administered MAC if no hardware MAC is available", async () => { + const { pickStableMacFromInterfaces } = await import( + "~/daemon/identity.js" + ); + const ifs = { + en0: [iface("2a:11:99:2b:5f:c1")], + }; + expect(pickStableMacFromInterfaces(ifs)).toBe("2a:11:99:2b:5f:c1"); + }); + + it("ignores loopback, docker, tun/tap, tailscale, utun, awdl, llw, bridge, anpi, ap[0-9]", async () => { + const { pickStableMacFromInterfaces } = await import( + "~/daemon/identity.js" + ); + const ifs = { + lo0: [iface("00:00:00:00:00:00", true)], + docker0: [iface("02:42:ac:11:00:01")], + utun0: [iface("aa:bb:cc:dd:ee:ff")], + awdl0: [iface("0e:df:dc:f9:da:33")], + llw0: [iface("0e:df:dc:f9:da:33")], + bridge0: [iface("36:77:b5:15:36:80")], + anpi0: [iface("fe:f8:57:24:57:4a")], + ap1: [iface("a2:e3:aa:60:12:88")], + tailscale0: [iface("aa:bb:cc:11:22:33")], + en0: [iface("00:e0:4c:68:00:c0")], // the only one that should win + }; + expect(pickStableMacFromInterfaces(ifs)).toBe("00:e0:4c:68:00:c0"); + }); + + it("returns null when no interfaces qualify", async () => { + const { pickStableMacFromInterfaces } = await import( + "~/daemon/identity.js" + ); + expect(pickStableMacFromInterfaces({})).toBeNull(); + expect( + pickStableMacFromInterfaces({ + lo0: [iface("00:00:00:00:00:00", true)], + }), + ).toBeNull(); + }); + + it("skips internal addresses and zero-MAC; picks the first valid by name", async () => { + const { pickStableMacFromInterfaces } = await import( + "~/daemon/identity.js" + ); + const ifs = { + en0: [iface("00:00:00:00:00:00")], + en1: [iface("00:e0:4c:68:00:c0", true)], + en2: [iface("00:e0:4c:68:00:c1")], + }; + expect(pickStableMacFromInterfaces(ifs)).toBe("00:e0:4c:68:00:c1"); + }); + + it("sorts by interface name when multiple hardware MACs are present", async () => { + const { pickStableMacFromInterfaces } = await import( + "~/daemon/identity.js" + ); + const ifs = { + en1: [iface("00:e0:4c:68:00:c1")], + en0: [iface("00:e0:4c:68:00:c0")], + }; + expect(pickStableMacFromInterfaces(ifs)).toBe("00:e0:4c:68:00:c0"); + }); +}); + +describe("checkFingerprint (file-based)", () => { + const testDir = TEST_DIR; + + beforeEach(() => { + if (existsSync(testDir)) { + for (const f of readdirSync(testDir)) { + rmSync(join(testDir, f), { force: true, recursive: true }); + } + } + }); + + afterAll(() => { + delete process.env.CLAUDEMESH_DAEMON_DIR; + if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); + }); + + it("first_run writes a v2 fingerprint when no file exists", async () => { + const { checkFingerprint } = await import("~/daemon/identity.js"); + const result = checkFingerprint(); + expect(result.result).toBe("first_run"); + expect(result.current.schema_version).toBe(2); + const onDisk = JSON.parse( + readFileSync(join(testDir, "host_fingerprint.json"), "utf8"), + ); + expect(onDisk.schema_version).toBe(2); + expect(onDisk.fingerprint).toBe(result.current.fingerprint); + }); + + it("match returns 'match' when stored v2 fingerprint equals current", async () => { + const { checkFingerprint, acceptCurrentHost } = await import( + "~/daemon/identity.js" + ); + const first = acceptCurrentHost(); + const result = checkFingerprint(); + expect(result.result).toBe("match"); + expect(result.current.fingerprint).toBe(first.fingerprint); + }); + + it("v1 stored that matches v1 algorithm is silently upgraded to v2", async () => { + const { checkFingerprint, __computeV1FingerprintForTests } = await import( + "~/daemon/identity.js" + ); + const v1 = __computeV1FingerprintForTests(); + writeFileSync( + join(testDir, "host_fingerprint.json"), + JSON.stringify(v1, null, 2), + ); + + const result = checkFingerprint(); + + expect(result.result).toBe("match"); + expect(result.stored?.schema_version).toBe(1); + expect(result.current.schema_version).toBe(2); + + const after = JSON.parse( + readFileSync(join(testDir, "host_fingerprint.json"), "utf8"), + ); + expect(after.schema_version).toBe(2); + expect(after.fingerprint).toBe(result.current.fingerprint); + }); + + it("v1 stored that does NOT match v1 algorithm reports mismatch (genuine host change)", async () => { + const { checkFingerprint } = await import("~/daemon/identity.js"); + writeFileSync( + join(testDir, "host_fingerprint.json"), + JSON.stringify( + { + schema_version: 1, + fingerprint: "0".repeat(64), + host_id: "spoofed", + stable_mac: "ff:ff:ff:ff:ff:ff", + written_at: "2026-01-01T00:00:00.000Z", + }, + null, + 2, + ), + ); + + const result = checkFingerprint(); + expect(result.result).toBe("mismatch"); + expect(result.stored?.schema_version).toBe(1); + }); + + it("v2 stored with a different fingerprint reports mismatch", async () => { + const { checkFingerprint, acceptCurrentHost } = await import( + "~/daemon/identity.js" + ); + const real = acceptCurrentHost(); + writeFileSync( + join(testDir, "host_fingerprint.json"), + JSON.stringify( + { + ...real, + fingerprint: "f".repeat(64), + }, + null, + 2, + ), + ); + + const result = checkFingerprint(); + expect(result.result).toBe("mismatch"); + expect(result.stored?.schema_version).toBe(2); + }); + + it("unknown future schema is treated as 'unavailable', not overwritten", async () => { + const { checkFingerprint } = await import("~/daemon/identity.js"); + writeFileSync( + join(testDir, "host_fingerprint.json"), + JSON.stringify( + { + schema_version: 99, + fingerprint: "x", + host_id: "x", + stable_mac: "x", + written_at: "2099-01-01T00:00:00.000Z", + }, + null, + 2, + ), + ); + + const before = readFileSync(join(testDir, "host_fingerprint.json"), "utf8"); + const result = checkFingerprint(); + expect(result.result).toBe("unavailable"); + expect(readFileSync(join(testDir, "host_fingerprint.json"), "utf8")).toBe( + before, + ); + }); + + it("corrupt JSON is treated as 'unavailable', not overwritten", async () => { + const { checkFingerprint } = await import("~/daemon/identity.js"); + writeFileSync(join(testDir, "host_fingerprint.json"), "{ not valid json"); + + const before = readFileSync(join(testDir, "host_fingerprint.json"), "utf8"); + const result = checkFingerprint(); + expect(result.result).toBe("unavailable"); + expect(readFileSync(join(testDir, "host_fingerprint.json"), "utf8")).toBe( + before, + ); + }); + + it("acceptCurrentHost always writes a v2 fingerprint", async () => { + const { acceptCurrentHost } = await import("~/daemon/identity.js"); + writeFileSync( + join(testDir, "host_fingerprint.json"), + JSON.stringify({ + schema_version: 1, + fingerprint: "x", + host_id: "", + stable_mac: "", + written_at: "", + }), + ); + const out = acceptCurrentHost(); + expect(out.schema_version).toBe(2); + const onDisk = JSON.parse( + readFileSync(join(testDir, "host_fingerprint.json"), "utf8"), + ); + expect(onDisk.schema_version).toBe(2); + expect(onDisk.fingerprint).toBe(out.fingerprint); + }); +}); diff --git a/apps/cli/vitest.config.ts b/apps/cli/vitest.config.ts index af85919..b6fb79a 100644 --- a/apps/cli/vitest.config.ts +++ b/apps/cli/vitest.config.ts @@ -4,6 +4,10 @@ import { resolve } from "node:path"; export default defineConfig({ test: { include: ["tests/**/*.test.ts", "src/**/*.test.ts"], + // Builds the CLI on demand when `dist/entrypoints/cli.js` is + // missing or older than its sources. Required by the golden + // tests in `tests/golden/` which spawn the built artifact. + globalSetup: ["tests/setup/ensure-built.ts"], alias: { "~": resolve(__dirname, "src"), }, diff --git a/docs/roadmap.md b/docs/roadmap.md index 153a1ff..92072bc 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -481,6 +481,56 @@ invisibility moment" goal. --- +## v1.34.17 — *host fingerprint v2: stable across Mac reboots* — *shipped* + +Field report 2026-05-19: after a routine Mac restart the daemon +(under 1.34.16) entered a launchd respawn loop on +`host_fingerprint mismatch` — `runs` past 360, `daemon.log` at +24 MB, the user's `claudemesh peer list` falling to the cold +path and showing zero peers. + +Root cause was in `apps/cli/src/daemon/identity.ts`. v1 of the +algorithm hashed `host_id || mac` where (a) `host_id` was empty +on macOS and (b) `mac` was picked by enumerating +`os.networkInterfaces()`, ignoring a short list of known-virtual +prefixes, and taking the lex-first of the rest — usually `en0`, +the Wi-Fi adapter, whose MAC Apple's privacy feature randomizes +across reboots and network rejoins. The stored hash was anchored +to one randomized MAC; the next boot's MAC produced a different +hash; the safety check fired correctly for the wrong reason. + +The fix is a hardened algorithm bumped to `schema_version: 2`: + +- **macOS host_id** — `IOPlatformUUID` via `ioreg`, parsed once + at daemon start, cached for process lifetime. Burned into + EFI/hardware; stable across reboots and OS reinstalls. +- **MAC picker** — rejects any MAC with the locally-administered + bit (`0x02`) set; prefers true hardware MACs and only falls + back to locally-administered ones when no hardware NIC is + enumerable. Interface ignore list extended with `anpi*`, + `bridge*`, `ap[0-9]`. +- **Domain-separated hash** — v2 prepends `"v2\0"` so its hash + can never collide with a v1 hash on the same inputs. +- **Silent migration** — existing v1 stores that still match + under the v1 algorithm (legitimate same-host upgrades) are + transparently rewritten as v2 with no error. v1 stores that + fail the v1 check are reported as genuine mismatches as + before. Unknown future schema versions return `unavailable` + without overwriting. + +18 unit tests in `apps/cli/tests/unit/identity.test.ts` cover the +pure helpers + every `checkFingerprint` branch + the v1→v2 +silent-upgrade path. Two pre-existing test-infra papercuts were +fixed alongside: `turbo.json` now declares `test` depends on +`build`, and a new vitest globalSetup +(`apps/cli/tests/setup/ensure-built.ts`) rebuilds on demand with +`~/.bun/bin` layered into PATH so golden tests no longer fail +opaquely after a clean checkout. + +*Shipped 2026-05-20.* + +--- + ## v2.0.0 — *HKDF cross-machine identity* The remaining v2 promise after Sprint A: the user's account secret diff --git a/turbo.json b/turbo.json index 71c7656..e8d4072 100644 --- a/turbo.json +++ b/turbo.json @@ -31,6 +31,7 @@ "dependsOn": ["@turbostarter/db#db:setup"] }, "test": { + "dependsOn": ["build"], "outputs": ["coverage.json"] } },