/** * The daemon HTTP surface's bearer token (Phase 4, chokepoints). * * The daemon's loopback port used to be deliberately unauthenticated: the * comment above its `Bun.serve` called the surface loopback-only by design and * left `POST /wake` and `PUT /runs/:issue/*` open. On a shared host that is a * mutating surface any local process — any browser page that can reach * 127.0.0.1, any other tenant's job — can drive: stop a live worker, raise a * turn ceiling, wake dispatch in a loop. This module is the credential that * closes it. `GET /healthz` stays open, because `requireDaemonControl` probes * it to find a live unit-owned daemon *before* it has any reason to hold a * token, and a health probe mutates nothing. * * ## Why not the dashboard's token * * The discipline here is copied verbatim from `dashboard/server.ts`'s * `ensureDashboardToken` — 0700 state dir, 0600 temp file, atomic rename, * explicit chmod, hash-then-compare — but the *token* is a separate file on * purpose: * * - Different surface. The dashboard token guards a read-only HTTP server in * its own process; this one guards the daemon's mutating run control. * - Different lifetime. The dashboard mints on dashboard start; this one is * minted by the daemon at start. Either can be rotated by deleting its file * and restarting only that process. * - Different blast radius. This token is handed to every CLI caller * (`extend`, `worker`, `board`, `wake`) and to the dashboard process. * Handing those the dashboard's token would mean any of them could also * drive the dashboard's whole `/api/*` surface, and a leak from a CLI * invocation would compromise both. One credential per surface keeps the * failure contained. * * ## Minting is the daemon's job, never a client's * * {@link ensureHttpToken} is called by the daemon at start. Clients call * {@link readHttpToken} and refuse when it is absent: an absent token means the * daemon has never run under this `$OMP_CONDUCTOR_HOME`, so a client that * minted one would be inventing a credential the daemon does not know and * turning a clear "the daemon is not running" into a 401 nobody can explain. */ import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { stateDir } from "./config.ts"; /** The token's filename under `$OMP_CONDUCTOR_HOME`, beside `dashboard-token`. */ export const HTTP_TOKEN_FILE = "http-token"; /** Where the daemon's bearer token lives, next to config.json. */ export function httpTokenPath(): string { return join(stateDir(), HTTP_TOKEN_FILE); } /** The stored token, or undefined when the daemon has never started here. */ export function readHttpToken(): string | undefined { try { const raw = readFileSync(httpTokenPath(), "utf8").trim(); return raw.length > 0 ? raw : undefined; } catch { return undefined; } } function hashToken(value: string): Buffer { return createHash("sha256").update(value).digest(); } /** * Mints `/http-token` on first daemon start; later starts reuse the * existing token so every already-configured client keeps working across a * restart. Write discipline is `writeConfigRaw`'s: the state dir is created * 0700 (chmod only what this call created, so an operator-chosen mode * survives), and the token is written 0600 through a same-directory temp file * plus an atomic rename, so a crash mid-write leaves the previous token intact * rather than a truncated one that authenticates nobody. */ export function ensureHttpToken(): string { const existing = readHttpToken(); if (existing !== undefined) return existing; const token = randomBytes(32).toString("base64url"); const dir = stateDir(); const created = mkdirSync(dir, { recursive: true, mode: 0o700 }); if (created !== undefined) chmodSync(dir, 0o700); const target = httpTokenPath(); const tmp = join(dir, `.${HTTP_TOKEN_FILE}.${process.pid.toString(36)}.${Date.now().toString(36)}.tmp`); try { writeFileSync(tmp, `${token}\n`, { mode: 0o600 }); renameSync(tmp, target); } catch (err) { rmSync(tmp, { force: true }); throw err; } // The rename preserves the temp's 0600; state it anyway so the promise never // leans on umask moods. chmodSync(target, 0o600); return token; } /** * Constant-time `Authorization: Bearer ` check. * * Takes the raw header (or null, which is what `Headers.get` returns when it is * absent) so every route checks the same way and no caller has to remember to * strip the scheme. Both sides are hashed to a fixed 32 bytes before the * compare, so a wrong-length candidate cannot leak the stored token's length * through `timingSafeEqual`'s length check — the same reason * `verifyDashboardToken` hashes first. */ export function verifyHttpToken(header: string | null, token: string): boolean { if (header === null || token.length === 0) return false; const match = /^Bearer[ \t]+(\S+)$/.exec(header.trim()); if (match === null) return false; return timingSafeEqual(hashToken(match[1]!), hashToken(token)); } /** * The refusal every client prints when the token file is absent, worded once so * `extend`, `worker`, `board`, `wake` and the dashboard proxy cannot drift. * Names the file and the cause: the daemon mints it at start, so no file means * no daemon has run here. */ export function missingHttpTokenMessage(): string { return ( `no daemon HTTP token at ${httpTokenPath()} — the daemon mints it at start, ` + "so this means no daemon has run under this OMP_CONDUCTOR_HOME. " + "Start it with `omp-conductor start` and retry." ); } /** * The `Authorization` header every mutating client must send, or `undefined` * when there is no token to send. Callers turn `undefined` into * {@link missingHttpTokenMessage} — never into an unauthenticated request that * would come back as a bare 401. */ export function httpAuthHeader(): { authorization: string } | undefined { const token = readHttpToken(); return token === undefined ? undefined : { authorization: `Bearer ${token}` }; }