v1's sha256(host_id || mac) used the lex-first non-virtual interface's MAC — usually en0 on Wi-Fi Macs, whose MAC Apple's privacy feature re-randomizes across reboots. After a restart the recomputed hash no longer matched the stored one and the daemon entered a launchd respawn loop until manual `claudemesh daemon accept-host`. v2 reads IOPlatformUUID via ioreg on macOS (burned into EFI, stable), rejects locally-administered MACs in the picker, extends the ignored- interface list with anpi/bridge/ap[N], and prepends "v2\0" to the hash so v1 and v2 hashes can never collide on the same inputs. Migration is silent: a stored v1 fingerprint that still matches under the v1 algorithm is transparently rewritten as v2 with no error; v1 stores that fail v1 are reported as genuine mismatches as before; unknown future schema_versions return `unavailable` without overwriting. Drive-by fixes for two pre-existing test-infra papercuts found while validating: turbo's `test` task now depends on `build`, and a new vitest globalSetup rebuilds the CLI on demand with ~/.bun/bin and Homebrew layered into PATH — golden tests (whoami, --version) no longer fail opaquely after a clean checkout. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
312 lines
11 KiB
TypeScript
312 lines
11 KiB
TypeScript
// Accidental-clone detection per spec §2.2. Catches restored backups
|
|
// and copy-pasted homedirs by comparing a stable host fingerprint
|
|
// against the one we wrote at first daemon start.
|
|
//
|
|
// 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 } 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 | 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 {
|
|
result: "first_run" | "match" | "mismatch" | "unavailable";
|
|
current: FingerprintRecord;
|
|
stored?: FingerprintRecord;
|
|
}
|
|
|
|
const FILE_NAME = "host_fingerprint.json";
|
|
const CURRENT_SCHEMA: 2 = 2;
|
|
|
|
function path(): string {
|
|
return join(DAEMON_PATHS.DAEMON_DIR, FILE_NAME);
|
|
}
|
|
|
|
// ── public API ────────────────────────────────────────────────────────
|
|
|
|
/** Compute (without writing) the current host fingerprint under v2 rules. */
|
|
export function computeCurrentFingerprint(): FingerprintRecord {
|
|
const host_id = readHostIdV2() ?? "";
|
|
const stable_mac = pickStableMacFromInterfaces(networkInterfaces()) ?? "";
|
|
return {
|
|
schema_version: CURRENT_SCHEMA,
|
|
fingerprint: fingerprintV2(host_id, stable_mac),
|
|
host_id,
|
|
stable_mac,
|
|
written_at: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
/** Read or write the persisted fingerprint and report the result. */
|
|
export function checkFingerprint(): FingerprintCheck {
|
|
const current = computeCurrentFingerprint();
|
|
if (!existsSync(path())) {
|
|
writeFileSync(path(), JSON.stringify(current, null, 2), { mode: 0o600 });
|
|
return { result: "first_run", current };
|
|
}
|
|
let stored: FingerprintRecord;
|
|
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 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;
|
|
}
|
|
|
|
// ── v2 helpers (exported for tests) ───────────────────────────────────
|
|
|
|
/** 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<NetworkInterfaceInfo[]>,
|
|
): 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 */
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
// v1 on macOS/Windows: empty host_id, MAC-only fingerprint.
|
|
return null;
|
|
}
|
|
|
|
function pickStableMacV1(): string | null {
|
|
const ifs = networkInterfaces();
|
|
const candidates: string[] = [];
|
|
for (const [name, addrs] of Object.entries(ifs)) {
|
|
if (!addrs) 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;
|
|
candidates.push(`${name}::${a.mac}`);
|
|
break;
|
|
}
|
|
}
|
|
if (candidates.length === 0) return null;
|
|
candidates.sort();
|
|
const first = candidates[0]!;
|
|
const idx = first.indexOf("::");
|
|
return idx >= 0 ? first.slice(idx + 2) : first;
|
|
}
|
|
|
|
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();
|
|
}
|