/** * The authenticated local transport the mutation verbs ride on (#126). * * **What the permission layout does and does not buy, stated plainly.** Every * session is a child process running as the daemon's own uid. So the modes here * keep *other local accounts* out; they do not restrain a session, because a * session matches the owner class and owner bits are `rwx`. A session that * wanted to list the socket directory, unlink a sibling's socket and bind an * impostor in its place could do so, and nothing in this file would stop it. * * That is not a hole this file is hiding — it is the consequence of running * sessions as the daemon. What restrains a session is the mechanical worktree * gate on its structured tools plus the fact that publication only happens * through the daemon, and what makes a bad push *visible* is the verb ledger. * * So the layout is: * * - the parent directory is daemon-owned and mode **`0711`** — searchable so a * caller reaches a socket it can already name, not listable and not writable * by any account other than the daemon's; * - each socket is `0600`, so only the daemon's uid can connect at all; * - the per-socket suffix is random, so the path is not guessable by a foreign * account that cannot list the directory. * * Two further rules make that hold rather than merely describe it: * {@link validateSocketPath} refuses to bind under a path anyone else could have * tampered with, and {@link peerVerdict} compares the connecting uid the kernel * reports against the daemon's own. * * Every decision in this file is a pure function of `lstat` results, so the * adversarial cases are testable without root and without a second uid. */ import { dlopen, FFIType, ptr, suffix } from "bun:ffi"; import { randomBytes } from "node:crypto"; import { chmodSync, chownSync, lstatSync, mkdirSync, rmSync } from "node:fs"; import type { Stats } from "node:fs"; import { dirname, isAbsolute, join, resolve } from "node:path"; /** Searchable, but not listable or writable by any account but the daemon's. */ export const VERB_DIR_MODE = 0o711; /** Only the daemon's uid may connect. The parent's `0711` keeps that reachable. */ export const VERB_SOCKET_MODE = 0o600; /** * How long a socket path may be. * * `sun_path` is 104 bytes on darwin and 108 on Linux, *including* the * terminator, and a bind past it fails with a truncation nobody reads as a * path-length problem. Refusing at 100 leaves headroom on both and turns a * confusing `ENAMETOOLONG` into a named dispatch refusal. */ export const SOCKET_PATH_MAX = 100; /** The daemon-owned parent every verb socket lives directly inside. */ export function verbSocketDir(root: string): string { return join(root, "verbs"); } /** * Create (or repair the mode of) the socket parent. * * `chmod` on every call rather than only on create: a directory left `0700` by * an older release, or widened by hand, would otherwise stay wrong until * somebody deleted it — and the failure is silent in the safe direction for * `0700` (nothing can connect) and silent in the unsafe direction for `0777`. */ export function ensureVerbSocketDir(root: string): string { const dir = verbSocketDir(root); mkdirSync(dir, { recursive: true, mode: VERB_DIR_MODE }); chmodSync(dir, VERB_DIR_MODE); return dir; } /** * A per-run socket path with an unguessable suffix. * * Unguessable because the parent is unlistable: a run that cannot enumerate the * directory and cannot guess a sibling's filename cannot even name the socket * it is not allowed to open. The suffix is the second lock on a door whose * first lock is `0600` ownership. */ export function verbSocketPath(dir: string, label: string): string { const slug = label.replace(/[^A-Za-z0-9_-]+/g, "-").slice(0, 24); return join(dir, `${slug}-${randomBytes(8).toString("hex")}.sock`); } export type SocketPathFault = | "not-absolute" | "too-long" | "missing" | "symlink" | "not-directory" | "foreign-owner" | "group-writable" | "other-writable" | "not-searchable"; export interface SocketPathProblem { /** The offending path component, named so the refusal is actionable. */ component: string; fault: SocketPathFault; message: string; } /** Every ancestor of `path`, root first, excluding `path` itself. */ function ancestors(path: string): string[] { const out: string[] = []; let cursor = dirname(resolve(path)); for (;;) { out.unshift(cursor); const parent = dirname(cursor); if (parent === cursor) break; cursor = parent; } return out; } /** * Verify nobody but the daemon could have tampered with where this socket is * about to be bound. A failure **refuses dispatch** — it never degrades to * binding anyway, because a socket under a directory a third party can write is * a socket a third party can replace. * * Ownership is `daemonUid` **or root**. Requiring the daemon to own `/` and * `/Users` is not a check, it is a condition that can never hold, and a check * that always fails gets deleted by the next person who reads it. Root-owned * ancestors are the operating system's, and a host whose `/usr` is hostile has * already lost in ways no socket mode addresses. * * `lstat` rather than `stat`, deliberately: `stat` follows the very symlink * this is looking for. */ export function validateSocketPath( path: string, daemonUid: number, lstat: (p: string) => Stats = lstatSync, ): SocketPathProblem | undefined { if (!isAbsolute(path)) { return { component: path, fault: "not-absolute", message: `verb socket path ${path} is not absolute`, }; } if (Buffer.byteLength(path) > SOCKET_PATH_MAX) { return { component: path, fault: "too-long", message: `verb socket path ${path} is ${Buffer.byteLength(path)} bytes, over the ${SOCKET_PATH_MAX}-byte limit a unix socket address allows`, }; } for (const component of ancestors(path)) { let stat: Stats; try { stat = lstat(component); } catch { return { component, fault: "missing", message: `verb socket parent ${component} does not exist or cannot be read`, }; } if (stat.isSymbolicLink()) { return { component, fault: "symlink", message: `verb socket parent ${component} is a symlink; a component that can be re-pointed is a socket that can be moved under the daemon`, }; } if (!stat.isDirectory()) { return { component, fault: "not-directory", message: `verb socket parent ${component} is not a directory`, }; } if (stat.uid !== daemonUid && stat.uid !== 0) { return { component, fault: "foreign-owner", message: `verb socket parent ${component} is owned by uid ${stat.uid}, neither the daemon (${daemonUid}) nor root`, }; } if ((stat.mode & 0o020) !== 0) { return { component, fault: "group-writable", message: `verb socket parent ${component} is group-writable (mode ${(stat.mode & 0o7777).toString(8)}); anyone in that group could replace the socket`, }; } if ((stat.mode & 0o002) !== 0) { return { component, fault: "other-writable", message: `verb socket parent ${component} is world-writable (mode ${(stat.mode & 0o7777).toString(8)}); anyone on this host could replace the socket`, }; } } return undefined; } /** * Remove a stale socket at `path`, and only ever one the daemon itself placed. * * Called after {@link validateSocketPath} has passed, so the parent is known * non-writable by anyone else — which is what makes "unlink and rebind" safe * here and unsafe in `/tmp`. Nothing outside the daemon ever unlinks these. */ export function unlinkStaleSocket(path: string): void { rmSync(path, { force: true }); } /** * Restrict a bound socket to the daemon's own uid, or to the worker identity * that owns the channel when `owner` is given. * * Returns the guarantee in force, because #126 asks the daemon to *state* its * mechanism at startup rather than guess: the socket is `0600` under a `0711` * daemon-owned parent, so it is one-user, and the unguessable suffix plus that * parent are the whole story. Under the worker identity the one user is the * worker account — the socket is chowned to it, because the child that must * connect to it runs as that uid and nothing else does. Saying so is the * difference between an audited boundary and a hopeful one. */ export type SocketOwnership = "daemon-user" | "worker-user"; export interface SecureBoundSocketOptions { /** The chmod call; injected so tests pin the mode without a real socket. */ chmod?: (p: string, mode: number) => void; /** * The uid/gid the socket belongs to (the worker identity for a run * channel). Absent, the socket stays daemon-owned and only the daemon's * uid can connect. */ owner?: { uid: number; gid: number }; } export function secureBoundSocket( path: string, options: SecureBoundSocketOptions = {}, ): SocketOwnership { const chmod = options.chmod ?? chmodSync; chmod(path, VERB_SOCKET_MODE); if (options.owner !== undefined) { chownSync(path, options.owner.uid, options.owner.gid); return "worker-user"; } return "daemon-user"; } export interface PeerCredentials { uid: number; gid: number; /** * The connecting process, as the kernel reports it. * * The uid alone stopped meaning anything the moment sessions began sharing the * daemon's account: every session satisfies it. The pid is what distinguishes * them, because the daemon spawned each one and knows which is which. * * `undefined` only when the platform call failed. It is not optional in the * sense of "nice to have" — {@link peerVerdict} refuses a channel whose pid it * expected and could not read. */ pid?: number; } /** * Read the connecting process's credentials off a connected socket fd, or * `undefined` where the platform (or this runtime's socket object) does not * expose them. * * `SO_PEERCRED` on Linux, `getpeereid` plus `LOCAL_PEERPID` on darwin — all asked * of the kernel, which is the point: the answer cannot be forged by the peer, * unlike anything the peer could put in a payload. */ export type PeerReader = (fd: number) => PeerCredentials | undefined; interface LibcSymbols { getpeereid?: (fd: number, uid: unknown, gid: unknown) => number; getsockopt?: (fd: number, level: number, name: number, value: unknown, len: unknown) => number; } /** `SOL_SOCKET` / `SO_PEERCRED` on Linux. Both are ABI constants, not guesses. */ const SOL_SOCKET_LINUX = 1; const SO_PEERCRED_LINUX = 17; /** `SOL_LOCAL` / `LOCAL_PEERPID` from darwin's `sys/un.h`. Also ABI constants. */ const SOL_LOCAL_DARWIN = 0; const LOCAL_PEERPID_DARWIN = 0x002; /** * The peer reader for this host, or `undefined` when there is none. * * Resolved once at daemon start so the mechanism can be logged before the * first socket exists, rather than discovered on the first connection. The * `dlopen` is inside the try because it is the part that fails on a host * without a loadable libc — the symbols this asks for are POSIX, but a * hardened or unusual host is entitled to refuse, and "we could not ask" must * never read as "the uid matched". */ export function peerCredentialReader(): PeerReader | undefined { const declarations: Record = process.platform === "darwin" ? { getpeereid: { args: [FFIType.i32, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 }, // For LOCAL_PEERPID: `getpeereid` answers uid and gid, and nothing else. getsockopt: { args: [FFIType.i32, FFIType.i32, FFIType.i32, FFIType.ptr, FFIType.ptr], returns: FFIType.i32, }, } : { getsockopt: { args: [FFIType.i32, FFIType.i32, FFIType.i32, FFIType.ptr, FFIType.ptr], returns: FFIType.i32, }, }; // `libc.${suffix}` is `libc.so` on Linux, and that is a GNU **linker script**, // not a shared object — `dlopen` refuses it, the reader silently resolves to // undefined, and peer-credential verification is quietly absent on the one // platform the fleet actually runs on. Measured on ubuntu-24.04. So the real // sonames are tried first, and only then the generic name for musl and macOS. const candidates = process.platform === "linux" ? ["libc.so.6", "libc.musl-x86_64.so.1", "libc.musl-aarch64.so.1", `libc.${suffix}`] : [`libc.${suffix}`]; let symbols: LibcSymbols | undefined; for (const lib of candidates) { try { symbols = dlopen(lib, declarations).symbols as LibcSymbols; break; } catch { // Next candidate. Exhausting them all is a real answer — see the docstring: // "we could not ask" must never read as "the uid matched". } } if (symbols === undefined) return undefined; if (process.platform === "darwin") { const getpeereid = symbols.getpeereid; const getsockoptDarwin = symbols.getsockopt; if (getpeereid === undefined) return undefined; return (fd) => { const uid = new Uint32Array(1); const gid = new Uint32Array(1); if (getpeereid(fd, ptr(uid), ptr(gid)) !== 0) return undefined; let pid: number | undefined; if (getsockoptDarwin !== undefined) { const out = new Uint32Array(1); const len = new Uint32Array([out.byteLength]); if (getsockoptDarwin(fd, SOL_LOCAL_DARWIN, LOCAL_PEERPID_DARWIN, ptr(out), ptr(len)) === 0) { pid = out[0]; } } return { uid: uid[0] ?? -1, gid: gid[0] ?? -1, ...(pid === undefined ? {} : { pid }) }; }; } const getsockopt = symbols.getsockopt; if (getsockopt === undefined) return undefined; return (fd) => { // struct ucred { pid_t pid; uid_t uid; gid_t gid; } — three 32-bit fields. const cred = new Uint32Array(3); const len = new Uint32Array([cred.byteLength]); if (getsockopt(fd, SOL_SOCKET_LINUX, SO_PEERCRED_LINUX, ptr(cred), ptr(len)) !== 0) { return undefined; } // struct ucred's first field is the pid, and it has been read all along. return { uid: cred[1] ?? -1, gid: cred[2] ?? -1, ...(cred[0] === undefined ? {} : { pid: cred[0] }) }; }; } /** * The file descriptor behind a `node:net` socket, or `undefined`. * * Reached through the handle rather than a public accessor because neither * Node nor Bun exposes one, and narrowed here so exactly one place in this * package knows that. An absent fd is not a failure — it means peer credentials * cannot be read, which {@link peerVerdict} accounts for explicitly. */ export function socketFd(socket: unknown): number | undefined { const handle = (socket as { _handle?: { fd?: unknown } } | null)?._handle; const fd = handle?.fd; return typeof fd === "number" && fd >= 0 ? fd : undefined; } export type PeerVerdict = | { ok: true; basis: "peer-pid"; uid: number; pid: number } | { ok: true; basis: "peer-uid"; uid: number } | { ok: true; basis: "socket-ownership"; why: string } | { ok: false; detail: string; peerUid: number; expectedUid: number }; /** * Compare the kernel-reported peer against the session the daemon launched. * * The three `ok` bases are different guarantees and are deliberately not * collapsed: * * - `peer-pid` — the kernel says this connection comes from the exact process the * daemon spawned for this channel. This is the only basis that distinguishes one * session from another, because they all share the daemon's uid. * - `peer-uid` — the uid matched but no pid was expected for this channel. True of * a channel nothing was bound to, which in production is a bug and in a test is * the case under test. * - `socket-ownership` — nobody could vouch at all, and the whole guarantee is the * `0600` socket under an unlistable, daemon-owned parent. Real against other * accounts, and no help between sessions. * * A caller that printed "verified" for all three would be claiming something it * never checked, which is exactly how the channel came to be described as * authentication when it was only ever context. * * **Fail closed on doubt.** If a pid was expected and the kernel would not answer, * that is a refusal: on a host where the check normally works, "could not ask" * arriving on the one connection that matters is not a reason to allow it. */ export function peerVerdict( expected: { uid: number; pid?: number } | undefined, peer: PeerCredentials | undefined, ): PeerVerdict { if (expected === undefined) { return { ok: true, basis: "socket-ownership", why: "no uid was named for this socket, so the 0600 mode under the daemon-owned 0711 parent is all that keeps other local accounts out", }; } if (peer === undefined) { // A channel bound to a process and no way to identify the caller is the one // combination that must never pass. Under one uid the mode says nothing about // *which* session is connecting, so allowing here would hand any session the // authority of whichever channel it reached — on exactly the hosts where the // check silently could not run. This ordering was the bug: the previous // release returned `socket-ownership` here before ever looking at the // expected pid, so a host whose libc would not load failed OPEN while the // startup banner claimed it refused everything. if (expected.pid !== undefined) { return { ok: false, peerUid: -1, expectedUid: expected.uid, detail: `this channel belongs to pid ${expected.pid} and this host reports no peer credentials at all, ` + `so the caller cannot be identified — every session here shares one uid, and the socket mode ` + `cannot tell them apart`, }; } return { ok: true, basis: "socket-ownership", why: "this host exposes no peer-credential call and no pid was expected, so the 0600 socket under the daemon-owned 0711 parent is all that keeps other local accounts out", }; } if (peer.uid !== expected.uid) { return { ok: false, peerUid: peer.uid, expectedUid: expected.uid, detail: `uid ${peer.uid} connected to a socket allocated to uid ${expected.uid}`, }; } if (expected.pid !== undefined) { if (peer.pid === undefined) { return { ok: false, peerUid: peer.uid, expectedUid: expected.uid, detail: `this channel belongs to pid ${expected.pid} and the kernel would not report the caller's pid, ` + `so the session could not be identified — every session shares this uid`, }; } if (peer.pid !== expected.pid) { return { ok: false, peerUid: peer.uid, expectedUid: expected.uid, detail: `pid ${peer.pid} connected to a channel belonging to pid ${expected.pid} — same uid, ` + `different session, so this is another session assuming this one's authority`, }; } return { ok: true, basis: "peer-pid", uid: peer.uid, pid: peer.pid }; } return { ok: true, basis: "peer-uid", uid: peer.uid }; } /** One line naming what the transport can actually enforce on this host. */ export function transportBanner(dir: string, peerReader: PeerReader | undefined): string { const peers = peerReader === undefined ? `no peer-credential call on ${process.platform} — every bound channel REFUSES every connection` : process.platform === "darwin" ? "caller pid+uid asserted with getpeereid and LOCAL_PEERPID; each channel bound to the session the daemon launched" : "caller pid+uid asserted with SO_PEERCRED; each channel bound to the session the daemon launched"; return ( `verb sockets in ${dir} (mode ${VERB_DIR_MODE.toString(8)}); ` + `each socket 0600 under the daemon's own uid; ${peers}` ); }