import { createHash } from "node:crypto"; import { closeSync, constants, fstatSync, lstatSync, opendirSync, openSync, readSync, } from "node:fs"; import type { Stats } from "node:fs"; import { isAbsolute, join, normalize } from "node:path"; import { parseAuthenticationConfigSource } from "./config.js"; import { parseLocalUserRegistrySource } from "./local-registry.js"; import type { AuthenticationConfigProvider, LoadedAuthConfig, LocalUserRecord, RuntimeProjectionSnapshot, } from "./types.js"; const MAX_AUTH_BYTES = 1 << 20; const MAX_USERS_BYTES = 1 << 20; const MAX_SELECTOR_BYTES = 4096; const MAX_MANIFEST_BYTES = 4096; const MAX_DIRECTORY_ENTRIES = 16; const DIR_MODE = 0o700; const FILE_MODE = 0o600; const GENERATION = /^[0-9a-f]{64}$/; const TRANSACTION = /^[0-9a-f]{32}$/; const invalid = (): Error => new Error("authentication runtime projection is invalid"); interface Identity { dev: number; ino: number; uid: number; gid: number; mode: number; nlink: number; size: number; mtimeMs: number; ctimeMs: number; } interface Selector { version: 1; state: "ready" | "blocked"; transaction: string; generation?: string; previousGenerations?: readonly string[]; } interface ManifestFile { name: "auth.yaml" | "users.yaml"; size: number; sha256: string; } interface Manifest { version: 1; generation: string; mode: "local" | "oidc"; canonicalRevision: string; files: readonly ManifestFile[]; } class CurrentReplaced extends Error {} function runtimeOwner(): number { if (process.platform === "win32" || typeof process.geteuid !== "function") throw invalid(); const uid = process.geteuid(); if (!Number.isSafeInteger(uid) || uid < 0) throw invalid(); return uid; } function runtimeGroup(): number { if (process.platform === "win32" || typeof process.getegid !== "function") throw invalid(); const gid = process.getegid(); if (!Number.isSafeInteger(gid) || gid < 0) throw invalid(); return gid; } function meta(info: Stats): Identity { return { dev: info.dev, ino: info.ino, uid: info.uid, gid: info.gid, mode: info.mode & 0o7777, nlink: info.nlink, size: info.size, mtimeMs: info.mtimeMs, ctimeMs: info.ctimeMs, }; } function same(a: Identity, b: Identity): boolean { return ( a.dev === b.dev && a.ino === b.ino && a.uid === b.uid && a.gid === b.gid && a.mode === b.mode && a.nlink === b.nlink && a.size === b.size && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs ); } function directory(info: Stats, uid: number): Identity { const value = meta(info); if ( !info.isDirectory() || value.uid !== uid || value.gid !== runtimeGroup() || value.mode !== DIR_MODE ) throw invalid(); return value; } function regular(info: Stats, uid: number, maximum: number): Identity { const value = meta(info); if ( !info.isFile() || value.uid !== uid || value.gid !== runtimeGroup() || value.mode !== FILE_MODE || value.nlink !== 1 || value.size < 0 || value.size > maximum ) throw invalid(); return value; } function checkRoot(root: string): void { if ( typeof root !== "string" || root.length === 0 || root.includes("\0") || !isAbsolute(root) || normalize(root) !== root || (root.length > 1 && root.endsWith("/")) ) throw invalid(); } function openDirectory( path: string, uid: number, ): { fd: number; identity: Identity } { let fd: number | undefined; try { const before = directory(lstatSync(path) as Stats, uid); fd = openSync( path, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0) | constants.O_NOFOLLOW | constants.O_NONBLOCK, ); const opened = directory(fstatSync(fd) as Stats, uid); if (!same(before, opened)) throw invalid(); return { fd, identity: opened }; } catch { if (fd !== undefined) try { closeSync(fd); } catch {} throw invalid(); } } function stableDirectory( path: string, opened: { fd: number; identity: Identity }, uid: number, ): void { if ( !same(opened.identity, directory(fstatSync(opened.fd) as Stats, uid)) || !same(opened.identity, directory(lstatSync(path) as Stats, uid)) ) throw invalid(); } function entries(path: string, uid: number, expected: readonly string[]): void { const opened = openDirectory(path, uid); try { const directory = opendirSync(`/proc/self/fd/${opened.fd}`, { bufferSize: 1, }); const names: string[] = []; try { for (;;) { const entry = directory.readSync(); if (entry === null) break; if (names.length === MAX_DIRECTORY_ENTRIES) throw invalid(); names.push(entry.name); } } finally { try { directory.closeSync(); } catch {} } if ( names.length !== expected.length || new Set(names).size !== names.length || names.some((name) => !expected.includes(name)) ) throw invalid(); stableDirectory(path, opened, uid); } finally { try { closeSync(opened.fd); } catch {} } } function replaced(before: Identity, after: Identity): boolean { return before.dev !== after.dev || before.ino !== after.ino; } interface RootObservation { descriptor: Identity; path: Identity; } function sameObject(left: Identity, right: Identity): boolean { return left.dev === right.dev && left.ino === right.ino; } function observeRootAtPathAndDescriptor( root: string, openedRoot: { fd: number; identity: Identity }, uid: number, ): RootObservation { const openedAfter = directory(fstatSync(openedRoot.fd) as Stats, uid); const pathAfter = directory(lstatSync(root) as Stats, uid); if (!sameObject(openedAfter, pathAfter)) throw invalid(); return { descriptor: openedAfter, path: pathAfter }; } function validateRootAndCurrentAfterLoad( root: string, openedRoot: { fd: number; identity: Identity }, currentPath: string, selectedCurrent: Identity, uid: number, ): void { const rootBeforeCurrent = observeRootAtPathAndDescriptor( root, openedRoot, uid, ); const currentAfter = regular( lstatSync(currentPath) as Stats, uid, MAX_SELECTOR_BYTES, ); const rootAfterCurrent = observeRootAtPathAndDescriptor( root, openedRoot, uid, ); if ( !same(openedRoot.identity, rootBeforeCurrent.descriptor) || !same(rootBeforeCurrent.descriptor, rootBeforeCurrent.path) || !same(rootBeforeCurrent.path, rootAfterCurrent.descriptor) || !same(rootAfterCurrent.descriptor, rootAfterCurrent.path) ) { const latestCurrent = regular( lstatSync(currentPath) as Stats, uid, MAX_SELECTOR_BYTES, ); if (replaced(selectedCurrent, latestCurrent)) throw new CurrentReplaced(); throw invalid(); } if (!same(selectedCurrent, currentAfter)) { if (replaced(selectedCurrent, currentAfter)) throw new CurrentReplaced(); throw invalid(); } } function readRegular( path: string, parentPath: string, uid: number, maximum: number, retryOnReplacement = false, ): { bytes: Buffer; identity: Identity } { let fd: number | undefined; let parent: { fd: number; identity: Identity } | undefined; try { parent = openDirectory(parentPath, uid); const before = regular(lstatSync(path) as Stats, uid, maximum); fd = openSync( path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK, ); const opened = regular(fstatSync(fd) as Stats, uid, maximum); if (!same(before, opened)) { if (retryOnReplacement && replaced(before, opened)) throw new CurrentReplaced(); throw invalid(); } const buffer = Buffer.allocUnsafe(maximum + 1); let offset = 0; while (offset < buffer.length) { const count = readSync(fd, buffer, offset, buffer.length - offset, null); if (count === 0) break; offset += count; } if (offset > maximum) throw invalid(); const after = regular(fstatSync(fd) as Stats, uid, maximum); const atPath = regular(lstatSync(path) as Stats, uid, maximum); if (!same(opened, after) || !same(after, atPath)) { if (retryOnReplacement && replaced(after, atPath)) throw new CurrentReplaced(); throw invalid(); } stableDirectory(parentPath, parent, uid); return { bytes: buffer.subarray(0, offset), identity: after }; } catch (error) { if (error instanceof CurrentReplaced) throw error; throw invalid(); } finally { if (fd !== undefined) try { closeSync(fd); } catch {} if (parent) try { closeSync(parent.fd); } catch {} } } function text(bytes: Buffer): string { try { return new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { throw invalid(); } } function object(value: unknown): Record { if (!value || typeof value !== "object" || Array.isArray(value)) throw invalid(); return value as Record; } function safeGeneration(value: unknown): string { if (typeof value !== "string" || !GENERATION.test(value)) throw invalid(); return value; } function strictJson( contents: string, normalizeValue: (raw: unknown) => T, ): T { try { const value = normalizeValue(JSON.parse(contents)); if (`${JSON.stringify(value)}\n` !== contents) throw invalid(); return value; } catch { throw invalid(); } } function selector(contents: string): Selector { return strictJson(contents, (raw) => { const value = object(raw); if ( value.version !== 1 || typeof value.transaction !== "string" || !TRANSACTION.test(value.transaction) ) throw invalid(); if (value.state === "blocked" && Object.keys(value).length === 3) return { version: 1, state: "blocked", transaction: value.transaction }; if ( value.state !== "ready" || (Object.keys(value).length !== 4 && Object.keys(value).length !== 5) ) throw invalid(); const generation = safeGeneration(value.generation); const previousGenerations = value.previousGenerations === undefined ? [] : Array.isArray(value.previousGenerations) ? value.previousGenerations.map(safeGeneration) : (() => { throw invalid(); })(); if ( previousGenerations.length > 2 || (previousGenerations.length === 0 && Object.keys(value).length !== 4) || (previousGenerations.length > 0 && Object.keys(value).length !== 5) ) throw invalid(); if ( new Set([generation, ...previousGenerations]).size !== previousGenerations.length + 1 ) throw invalid(); return { version: 1, state: "ready", transaction: value.transaction, generation, ...(previousGenerations.length > 0 ? { previousGenerations } : {}), }; }); } function manifest(contents: string): Manifest { return strictJson(contents, (raw) => { const value = object(raw); if ( Object.keys(value).length !== 5 || value.version !== 1 || (value.mode !== "local" && value.mode !== "oidc") ) throw invalid(); const mode = value.mode; const generation = safeGeneration(value.generation); if ( value.canonicalRevision !== `sha256:${generation}` || !Array.isArray(value.files) ) throw invalid(); const wanted: readonly ("auth.yaml" | "users.yaml")[] = mode === "local" ? ["auth.yaml", "users.yaml"] : ["auth.yaml"]; if (value.files.length !== wanted.length) throw invalid(); const files: ManifestFile[] = value.files.map((candidate, index) => { const item = object(candidate); const name = wanted[index]!; const maximum = name === "auth.yaml" ? MAX_AUTH_BYTES : MAX_USERS_BYTES; if ( Object.keys(item).length !== 3 || item.name !== name || !Number.isSafeInteger(item.size) || (item.size as number) < 0 || (item.size as number) > maximum || typeof item.sha256 !== "string" || !GENERATION.test(item.sha256) ) throw invalid(); return { name, size: item.size as number, sha256: item.sha256 }; }); return { version: 1, generation, mode, canonicalRevision: value.canonicalRevision as string, files, }; }); } function digest(bytes: Buffer): string { return createHash("sha256").update(bytes).digest("hex"); } function generationFor( mode: "local" | "oidc", auth: Buffer, users?: Buffer, ): string { return digest( Buffer.from( `thothii-auth-projection-v1\nmode=${mode}\nauth=${digest(auth)}\nusers=${mode === "local" && users ? digest(users) : "-"}\n`, "utf8", ), ); } function snapshot( generation: string, users?: readonly LocalUserRecord[], ): RuntimeProjectionSnapshot { const localUsers = users === undefined ? undefined : Object.freeze( users.map((user) => Object.freeze({ ...user, roles: Object.freeze([...user.roles]) }), ), ); return Object.freeze({ generation, canonicalRevision: `sha256:${generation}`, ...(localUsers ? { localUsers } : {}), }); } interface ValidGeneration { value: ReturnType; users?: readonly LocalUserRecord[]; } function validateGeneration( generationsPath: string, generation: string, uid: number, ): ValidGeneration { const selectedPath = join(generationsPath, generation); const openedGeneration = openDirectory(selectedPath, uid); try { const readManifest = readRegular( join(selectedPath, "manifest.json"), selectedPath, uid, MAX_MANIFEST_BYTES, ); const loadedManifest = manifest(text(readManifest.bytes)); if (loadedManifest.generation !== generation) throw invalid(); entries( selectedPath, uid, [ ...loadedManifest.files.map((item) => item.name), "manifest.json", ].sort(), ); const auth = readRegular( join(selectedPath, "auth.yaml"), selectedPath, uid, MAX_AUTH_BYTES, ); if ( loadedManifest.files[0]?.size !== auth.bytes.length || loadedManifest.files[0]?.sha256 !== digest(auth.bytes) ) throw invalid(); const value = parseAuthenticationConfigSource(text(auth.bytes)); if (value.mode !== loadedManifest.mode) throw invalid(); let users: readonly LocalUserRecord[] | undefined; let userBytes: Buffer | undefined; if (loadedManifest.mode === "local") { const readUsers = readRegular( join(selectedPath, "users.yaml"), selectedPath, uid, MAX_USERS_BYTES, ); if ( loadedManifest.files[1]?.size !== readUsers.bytes.length || loadedManifest.files[1]?.sha256 !== digest(readUsers.bytes) ) throw invalid(); userBytes = readUsers.bytes; users = parseLocalUserRegistrySource(text(userBytes)); } if ( generationFor(loadedManifest.mode, auth.bytes, userBytes) !== generation ) throw invalid(); stableDirectory(selectedPath, openedGeneration, uid); return { value, users }; } finally { try { closeSync(openedGeneration.fd); } catch {} } } function load(root: string): LoadedAuthConfig { const uid = runtimeOwner(); checkRoot(root); const openedRoot = openDirectory(root, uid); try { entries(root, uid, ["CURRENT", "generations"]); const currentPath = join(root, "CURRENT"); const selectedCurrent = readRegular( currentPath, root, uid, MAX_SELECTOR_BYTES, true, ); const selected = selector(text(selectedCurrent.bytes)); if (selected.state !== "ready" || !selected.generation) throw invalid(); const generationsPath = join(root, "generations"); const openedGenerations = openDirectory(generationsPath, uid); try { entries(generationsPath, uid, [ selected.generation, ...(selected.previousGenerations ?? []), ]); const selectedGeneration = validateGeneration( generationsPath, selected.generation, uid, ); for (const predecessor of selected.previousGenerations ?? []) validateGeneration(generationsPath, predecessor, uid); stableDirectory(generationsPath, openedGenerations, uid); validateRootAndCurrentAfterLoad( root, openedRoot, currentPath, selectedCurrent.identity, uid, ); return { value: selectedGeneration.value, revision: selected.generation, sourcePath: join(generationsPath, selected.generation, "auth.yaml"), runtimeProjection: snapshot( selected.generation, selectedGeneration.users, ), }; } finally { try { closeSync(openedGenerations.fd); } catch {} } } finally { try { closeSync(openedRoot.fd); } catch {} } } export function createProjectedAuthenticationConfigProvider( root: string, ): AuthenticationConfigProvider { return { current(): LoadedAuthConfig { for (let attempt = 0; attempt < 2; attempt += 1) { try { return load(root); } catch (error) { if (error instanceof CurrentReplaced && attempt === 0) continue; throw invalid(); } } throw invalid(); }, }; }