diff --git a/backend/src/auth/config.ts b/backend/src/auth/config.ts index f3c97aa6..47d77a54 100644 --- a/backend/src/auth/config.ts +++ b/backend/src/auth/config.ts @@ -228,7 +228,7 @@ function canonicalRevision(value: AuthenticationConfig): string { return createHash("sha256").update(JSON.stringify(canonicalize(value))).digest("hex"); } -function parseAuthenticationConfig(source: string): AuthenticationConfig { +export function parseAuthenticationConfigSource(source: string): AuthenticationConfig { try { const document = parseDocument(source, { uniqueKeys: true }); if (document.errors.length > 0 || document.warnings.length > 0) throw invalid(); @@ -254,7 +254,7 @@ function parseAuthenticationConfig(source: string): AuthenticationConfig { function loadAuthenticationConfigWithIdentity(path: string): { loaded: LoadedAuthConfig; identity: StorageIdentity } { const read = readBoundedConfig(path); - const value = parseAuthenticationConfig(read.source); + const value = parseAuthenticationConfigSource(read.source); return { loaded: { value, revision: canonicalRevision(value), sourcePath: path }, identity: read.identity }; } @@ -266,7 +266,7 @@ function loadWindowsAuthenticationConfig( const contents = bridge.readAuthConfig(path); if (!Buffer.isBuffer(contents) || contents.length === 0 || contents.length > MAX_AUTH_CONFIG_BYTES) throw invalid(); const source = new TextDecoder("utf-8", { fatal: true }).decode(contents); - const value = parseAuthenticationConfig(source); + const value = parseAuthenticationConfigSource(source); return { value, revision: canonicalRevision(value), sourcePath: path }; } catch { throw invalid(); diff --git a/backend/src/auth/local-registry.ts b/backend/src/auth/local-registry.ts index 7d70d99d..36e284a2 100644 --- a/backend/src/auth/local-registry.ts +++ b/backend/src/auth/local-registry.ts @@ -12,7 +12,7 @@ import { dirname, isAbsolute, join, normalize } from "node:path"; import { parseDocument } from "yaml"; import { z } from "zod"; import { isValidPasswordHash, verifyPassword, verifyWithDummy } from "./password.js"; -import type { LoadedAuthConfig, Role } from "./types.js"; +import type { LoadedAuthConfig, LocalUserRecord, Role } from "./types.js"; import { createWindowsAuthStorageBridge, type WindowsAuthStorageBridge } from "./windows-auth-storage.js"; const MAX_USERS_YAML_BYTES = 1 << 20; @@ -28,16 +28,7 @@ function runtimeOwner(): number { return owner; } -export interface LocalUserRecord { - id: string; - username: string; - normalizedUsername: string; - displayName?: string; - passwordHash: string; - roles: readonly Role[]; - enabled: boolean; - authRevision: number; -} +export type { LocalUserRecord } from "./types.js"; export interface LocalUserRegistry { /** Safe production diagnostic probe; never returns user records or hashes. */ @@ -193,7 +184,7 @@ function readBounded(path: string, owner: number): { source: string; identity: R } } -function parseRegistry(source: string): LocalUserRecord[] { +export function parseLocalUserRegistrySource(source: string): readonly LocalUserRecord[] { try { const document = parseDocument(source, { uniqueKeys: true }); if (document.errors.length > 0 || document.warnings.length > 0) throw invalid(); @@ -216,24 +207,24 @@ function parseRegistry(source: string): LocalUserRecord[] { authRevision: user.authRevision, }); }); - return records; + return Object.freeze(records); } catch { throw invalid(); } } -function load(path: string, owner: number): { records: LocalUserRecord[]; identity: RegistryIdentity } { +function load(path: string, owner: number): { records: readonly LocalUserRecord[]; identity: RegistryIdentity } { const read = readBounded(path, owner); - return { records: parseRegistry(read.source), identity: read.identity }; + return { records: parseLocalUserRegistrySource(read.source), identity: read.identity }; } export function createLocalUserRegistry(usersPath: string, options: LocalUserRegistryOptions = {}): LocalUserRegistry { - let cached: { records: LocalUserRecord[]; identity: RegistryIdentity } | undefined; + let cached: { records: readonly LocalUserRecord[]; identity: RegistryIdentity } | undefined; const windowsStorage = process.platform === "win32" ? options.windowsStorageBridge ?? createWindowsAuthStorageBridge() : undefined; - function currentPosix(): LocalUserRecord[] { + function currentPosix(): readonly LocalUserRecord[] { try { const owner = runtimeOwner(); const before = registryIdentity(usersPath, owner); @@ -251,19 +242,19 @@ export function createLocalUserRegistry(usersPath: string, options: LocalUserReg throw invalid(); } - async function current(): Promise { + async function current(): Promise { if (process.platform !== "win32") return currentPosix(); try { if (!windowsStorage) throw invalid(); const contents = await windowsStorage.readLocalUsers(usersPath); if (!Buffer.isBuffer(contents) || contents.length === 0 || contents.length > MAX_USERS_YAML_BYTES) throw invalid(); - return parseRegistry(new TextDecoder("utf-8", { fatal: true }).decode(contents)); + return parseLocalUserRegistrySource(new TextDecoder("utf-8", { fatal: true }).decode(contents)); } catch { throw invalid(); } } - async function operationalRecords(): Promise { + async function operationalRecords(): Promise { const records = await current(); if (!records.some((user) => user.enabled && user.roles.includes("admin"))) throw invalid(); return records; @@ -291,15 +282,48 @@ export function createLocalUserRegistry(usersPath: string, options: LocalUserReg } export function createCurrentLocalUserRegistryResolver(options: LocalUserRegistryOptions = {}): CurrentLocalUserRegistryResolver { - let current: { usersPath: string; registry: LocalUserRegistry } | undefined; + let current: { key: object | string; registry: LocalUserRegistry } | undefined; return { resolve(loaded: LoadedAuthConfig): LocalUserRegistry | undefined { if (loaded.value.mode !== "local") return undefined; + const runtimeProjection = loaded.runtimeProjection; + const projectedUsers = runtimeProjection?.localUsers; + if (projectedUsers && runtimeProjection) { + if (current && current.key === runtimeProjection) return current.registry; + const registry = createInMemoryLocalUserRegistry(projectedUsers); + current = { key: runtimeProjection, registry }; + return registry; + } const usersPath = join(dirname(loaded.sourcePath), loaded.value.local.usersFile); - if (current?.usersPath === usersPath) return current.registry; + if (current && current.key === usersPath) return current.registry; const registry = createLocalUserRegistry(usersPath, options); - current = { usersPath, registry }; + current = { key: usersPath, registry }; return registry; }, }; } + +function createInMemoryLocalUserRegistry(records: readonly LocalUserRecord[]): LocalUserRegistry { + async function operationalRecords(): Promise { + if (!records.some((user) => user.enabled && user.roles.includes("admin"))) throw invalid(); + return records; + } + return { + async hasEnabledAdmin(): Promise { + return records.some((user) => user.enabled && user.roles.includes("admin")); + }, + async findByUsername(username: string): Promise { + return (await operationalRecords()).find((user) => user.normalizedUsername === normalizeUsername(username)); + }, + async findBySubject(id: string): Promise { + return (await operationalRecords()).find((user) => user.id === id); + }, + async verify(user: LocalUserRecord | undefined, password: string): Promise { + if (!user || !user.enabled) { + await verifyWithDummy(password); + return false; + } + return await verifyPassword(password, user.passwordHash); + }, + }; +} diff --git a/backend/src/auth/routes.ts b/backend/src/auth/routes.ts index 71e7ad97..90cabfe4 100644 --- a/backend/src/auth/routes.ts +++ b/backend/src/auth/routes.ts @@ -3,12 +3,13 @@ import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import type { AuthenticationConfigProvider, LoadedAuthConfig, + LocalUserRecord, OidcAuthenticationConfig, OidcStateRecord, OidcTransactionTransport, Role, } from "./types.js"; -import type { LocalUserRecord, LocalUserRegistry } from "./local-registry.js"; +import type { LocalUserRegistry } from "./local-registry.js"; import { OidcStateCapacityError, type AuthSessionStore } from "./session-store.js"; import { rolesToPermissions } from "./config.js"; import { captureAuthConfigSnapshot, getPrincipal, requireExactOrigin, sessionCookieName } from "./auth.js"; diff --git a/backend/src/auth/runtime-projection.ts b/backend/src/auth/runtime-projection.ts new file mode 100644 index 00000000..782d9a32 --- /dev/null +++ b/backend/src/auth/runtime-projection.ts @@ -0,0 +1,534 @@ +import { createHash } from "node:crypto"; +import { + closeSync, + constants, + fstatSync, + lstatSync, + openSync, + readSync, + readdirSync, +} 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 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 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.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.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 + ) + 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 names = readdirSync(path); + 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; +} +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); + const afterCurrent = regular( + lstatSync(currentPath) as Stats, + uid, + MAX_SELECTOR_BYTES, + ); + if (!same(selectedCurrent.identity, afterCurrent)) { + if (replaced(selectedCurrent.identity, afterCurrent)) + throw new CurrentReplaced(); + throw invalid(); + } + stableDirectory(root, openedRoot, uid); + return { + value: selectedGeneration.value, + revision: `sha256:${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(); + }, + }; +} diff --git a/backend/src/auth/types.ts b/backend/src/auth/types.ts index 88292315..3cc8955b 100644 --- a/backend/src/auth/types.ts +++ b/backend/src/auth/types.ts @@ -45,10 +45,28 @@ export interface OidcAuthenticationConfig { export type AuthenticationConfig = LocalAuthenticationConfig | OidcAuthenticationConfig; +export interface LocalUserRecord { + id: string; + username: string; + normalizedUsername: string; + displayName?: string; + passwordHash: string; + roles: readonly Role[]; + enabled: boolean; + authRevision: number; +} + +export interface RuntimeProjectionSnapshot { + generation: string; + canonicalRevision: string; + localUsers?: readonly LocalUserRecord[]; +} + export interface LoadedAuthConfig { value: AuthenticationConfig; revision: string; sourcePath: string; + runtimeProjection?: RuntimeProjectionSnapshot; } export interface AuthenticationConfigProvider { diff --git a/backend/test/auth-runtime-projection.test.ts b/backend/test/auth-runtime-projection.test.ts new file mode 100644 index 00000000..0fa6e38b --- /dev/null +++ b/backend/test/auth-runtime-projection.test.ts @@ -0,0 +1,620 @@ +import { createHash } from "node:crypto"; +import { + chmodSync, + chownSync, + existsSync, + linkSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { stringify } from "yaml"; +import { afterEach, expect, test, vi } from "vitest"; +import { createProjectedAuthenticationConfigProvider } from "../src/auth/runtime-projection.js"; +import { createCurrentLocalUserRegistryResolver } from "../src/auth/local-registry.js"; + +const lstatHook = vi.hoisted(() => ({ + path: undefined as string | undefined, + callback: undefined as (() => void) | undefined, +})); + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + lstatSync(path: import("node:fs").PathLike) { + const result = actual.lstatSync(path); + if (lstatHook.path === String(path)) lstatHook.callback?.(); + return result; + }, + }; +}); + +const sentinel = "$argon2id$synthetic-sentinel"; +const password = "correct horse battery staple"; +const passwordHash = + "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4"; +const userId = "6ba7b810-9dad-4ed1-80b4-00c04fd430c8"; +const roots: string[] = []; + +afterEach(() => { + lstatHook.path = undefined; + lstatHook.callback = undefined; + for (const root of roots.splice(0)) + rmSync(root, { recursive: true, force: true }); +}); + +interface ProjectionFixture { + username: string; + hash?: string; + publicUrl?: string; +} + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function generationFor(auth: string, users: string): string { + return sha256( + `thothii-auth-projection-v1\nmode=local\nauth=${sha256(auth)}\nusers=${sha256(users)}\n`, + ); +} + +function localProjectionFixture( + username: string, + hash = passwordHash, +): ProjectionFixture { + return { username, hash }; +} + +function projectionRoot(): string { + const root = mkdtempSync(join(tmpdir(), "tht-auth-projection-")); + chmodSync(root, 0o700); + roots.push(root); + return root; +} + +function currentDocument( + generation: string, + previousGenerations: readonly string[] = [], +): string { + return `${JSON.stringify({ + version: 1, + state: "ready", + transaction: "a".repeat(32), + generation, + ...(previousGenerations.length === 0 ? {} : { previousGenerations }), + })}\n`; +} + +function projectionSources(fixture: ProjectionFixture): { + auth: string; + users: string; +} { + return { + auth: stringify({ + version: 1, + mode: "local", + publicUrl: fixture.publicUrl ?? "http://127.0.0.1:8080", + local: { usersFile: "users.yaml" }, + }), + users: stringify({ + version: 1, + users: [ + { + id: userId, + username: fixture.username, + passwordHash: fixture.hash ?? passwordHash, + roles: ["admin"], + enabled: true, + authRevision: 1, + }, + ], + }), + }; +} + +function writeGeneration(root: string, fixture: ProjectionFixture): string { + const { auth, users } = projectionSources(fixture); + const generation = generationFor(auth, users); + const directory = join(root, "generations", generation); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + chmodSync(directory, 0o700); + const manifest = `${JSON.stringify({ + version: 1, + generation, + mode: "local", + canonicalRevision: `sha256:${generation}`, + files: [ + { + name: "auth.yaml", + size: Buffer.byteLength(auth), + sha256: sha256(auth), + }, + { + name: "users.yaml", + size: Buffer.byteLength(users), + sha256: sha256(users), + }, + ], + })}\n`; + for (const [name, source] of [ + ["manifest.json", manifest], + ["auth.yaml", auth], + ["users.yaml", users], + ] as const) { + writeFileSync(join(directory, name), source, { + encoding: "utf8", + mode: 0o600, + }); + chmodSync(join(directory, name), 0o600); + } + return generation; +} + +function writeReadyProjection( + root: string, + fixture: ProjectionFixture, +): string { + mkdirSync(join(root, "generations"), { mode: 0o700 }); + chmodSync(join(root, "generations"), 0o700); + const generation = writeGeneration(root, fixture); + writeFileSync(join(root, "CURRENT"), currentDocument(generation), { + encoding: "utf8", + mode: 0o600, + }); + chmodSync(join(root, "CURRENT"), 0o600); + return generation; +} + +function expectDenied(operation: () => unknown): void { + try { + operation(); + throw new Error("operation unexpectedly succeeded"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + expect(message).toBe("authentication runtime projection is invalid"); + expect(message).not.toContain(sentinel); + expect(message).not.toContain(passwordHash); + } +} + +test("loads ready projection as one immutable auth and local-users snapshot", () => { + const root = projectionRoot(); + const fixture = localProjectionFixture("synthetic-user", passwordHash); + const generation = writeReadyProjection(root, fixture); + const loaded = createProjectedAuthenticationConfigProvider(root).current(); + + expect(loaded.revision).toBe(`sha256:${generation}`); + expect(loaded.sourcePath).toBe( + join(root, "generations", generation, "auth.yaml"), + ); + expect(loaded.runtimeProjection?.generation).toBe(generation); + expect(loaded.runtimeProjection?.canonicalRevision).toBe( + `sha256:${generation}`, + ); + expect(Object.isFrozen(loaded.runtimeProjection)).toBe(true); + expect(Object.isFrozen(loaded.runtimeProjection?.localUsers)).toBe(true); + expect(Object.isFrozen(loaded.runtimeProjection?.localUsers?.[0])).toBe(true); + expect( + Object.isFrozen(loaded.runtimeProjection?.localUsers?.[0]?.roles), + ).toBe(true); +}); + +test.each([ + ["missing", undefined], + [ + "blocked", + `${JSON.stringify({ version: 1, state: "blocked", transaction: "a".repeat(32) })}\n`, + ], + ["malformed", "{not-json}\n"], + [ + "duplicate-field", + `{"version":1,"version":1,"state":"ready","transaction":"${"a".repeat(32)}","generation":"${"b".repeat(64)}"}\n`, + ], + [ + "unknown-version", + `${JSON.stringify({ version: 2, state: "ready", transaction: "a".repeat(32), generation: "b".repeat(64) })}\n`, + ], +])("rejects %s CURRENT without secret disclosure", (_label, contents) => { + const root = projectionRoot(); + writeReadyProjection( + root, + localProjectionFixture("synthetic-user", sentinel), + ); + if (contents === undefined) unlinkSync(join(root, "CURRENT")); + else + writeFileSync(join(root, "CURRENT"), contents, { + encoding: "utf8", + mode: 0o600, + }); + expectDenied(() => + createProjectedAuthenticationConfigProvider(root).current(), + ); +}); + +test.each([ + "root traversal", + "CURRENT symlink", + "CURRENT hardlink", + "permissive mode", + "unexpected root entry", +])("rejects %s without secret disclosure", (kind) => { + const root = projectionRoot(); + writeReadyProjection( + root, + localProjectionFixture("synthetic-user", sentinel), + ); + const current = join(root, "CURRENT"); + if (kind === "root traversal") { + expectDenied(() => + createProjectedAuthenticationConfigProvider( + `${root}/../${root.split("/").at(-1)!}`, + ).current(), + ); + return; + } + if (kind === "CURRENT symlink") { + renameSync(current, join(root, "current-target")); + symlinkSync(join(root, "current-target"), current); + } else if (kind === "CURRENT hardlink") { + linkSync(current, join(root, "current-link")); + } else if (kind === "permissive mode") { + chmodSync(current, 0o640); + } else { + writeFileSync(join(root, "unexpected"), "x", { + encoding: "utf8", + mode: 0o600, + }); + } + expectDenied(() => + createProjectedAuthenticationConfigProvider(root).current(), + ); +}); + +test.runIf(process.geteuid?.() === 0)( + "rejects wrong owner at every projection boundary without secret disclosure", + () => { + const root = projectionRoot(); + writeReadyProjection( + root, + localProjectionFixture("synthetic-user", sentinel), + ); + // Root may safely construct a synthetic foreign-owned fixture; no real account is touched. + chownSync(join(root, "CURRENT"), 1, 1); + expectDenied(() => + createProjectedAuthenticationConfigProvider(root).current(), + ); + }, +); + +test("rejects a symlinked runtime root", () => { + const root = projectionRoot(); + writeReadyProjection( + root, + localProjectionFixture("synthetic-user", passwordHash), + ); + const linkedRoot = `${root}-link`; + symlinkSync(root, linkedRoot, "dir"); + roots.push(linkedRoot); + expectDenied(() => + createProjectedAuthenticationConfigProvider(linkedRoot).current(), + ); +}); + +test.each([ + "root", + "generations", + "selected generation", + "manifest", + "auth", + "users", +])("rejects unsafe mode on %s", (boundary) => { + const root = projectionRoot(); + const generation = writeReadyProjection( + root, + localProjectionFixture("synthetic-user", passwordHash), + ); + const selected = join(root, "generations", generation); + const path = + boundary === "root" + ? root + : boundary === "generations" + ? join(root, "generations") + : boundary === "selected generation" + ? selected + : join( + selected, + boundary === "manifest" ? "manifest.json" : `${boundary}.yaml`, + ); + chmodSync( + path, + boundary === "root" || + boundary === "generations" || + boundary === "selected generation" + ? 0o750 + : 0o640, + ); + expectDenied(() => + createProjectedAuthenticationConfigProvider(root).current(), + ); +}); + +test.each(["manifest", "generation", "size", "digest"])( + "rejects changed %s integrity data without secret disclosure", + (kind) => { + const root = projectionRoot(); + const generation = writeReadyProjection( + root, + localProjectionFixture("synthetic-user", sentinel), + ); + const manifestPath = join(root, "generations", generation, "manifest.json"); + const manifest = JSON.parse(String(readFileSync(manifestPath))) as Record< + string, + any + >; + if (kind === "manifest") manifest.unexpected = true; + if (kind === "generation") manifest.generation = "b".repeat(64); + if (kind === "size") manifest.files[0].size += 1; + if (kind === "digest") manifest.files[1].sha256 = "b".repeat(64); + writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + chmodSync(manifestPath, 0o600); + expectDenied(() => + createProjectedAuthenticationConfigProvider(root).current(), + ); + }, +); + +test("switches atomically to a later complete generation", () => { + const root = projectionRoot(); + const first = writeReadyProjection( + root, + localProjectionFixture("first", passwordHash), + ); + const provider = createProjectedAuthenticationConfigProvider(root); + expect(provider.current().runtimeProjection?.generation).toBe(first); + const second = writeGeneration( + root, + localProjectionFixture("second", passwordHash), + ); + const temporary = join(root, ".current-switch.tmp"); + writeFileSync(temporary, currentDocument(second, [first]), { + encoding: "utf8", + mode: 0o600, + }); + chmodSync(temporary, 0o600); + renameSync(temporary, join(root, "CURRENT")); + expect(provider.current().runtimeProjection?.generation).toBe(second); +}); + +test("retries once when CURRENT is atomically replaced between lstat and open", () => { + const root = projectionRoot(); + const first = writeReadyProjection( + root, + localProjectionFixture("first", passwordHash), + ); + const second = writeGeneration( + root, + localProjectionFixture("second", passwordHash), + ); + const temporary = join(root, ".current-replacement.tmp"); + lstatHook.path = join(root, "CURRENT"); + lstatHook.callback = () => { + lstatHook.callback = undefined; + writeFileSync(temporary, currentDocument(second, [first]), { + encoding: "utf8", + mode: 0o600, + }); + chmodSync(temporary, 0o600); + renameSync(temporary, join(root, "CURRENT")); + }; + expect( + createProjectedAuthenticationConfigProvider(root).current() + .runtimeProjection?.generation, + ).toBe(second); +}); + +test("rejects a second CURRENT replacement after the one permitted retry", () => { + const root = projectionRoot(); + const first = writeReadyProjection( + root, + localProjectionFixture("first", passwordHash), + ); + const second = writeGeneration( + root, + localProjectionFixture("second", passwordHash), + ); + const third = writeGeneration( + root, + localProjectionFixture("third", passwordHash), + ); + const replacements = [ + { generation: second, previous: [first] }, + { generation: third, previous: [second, first] }, + ]; + lstatHook.path = join(root, "CURRENT"); + lstatHook.callback = () => { + const replacement = replacements.shift(); + if (!replacement) return; + const temporary = join( + root, + `.current-replacement-${replacement.generation}.tmp`, + ); + writeFileSync( + temporary, + currentDocument(replacement.generation, replacement.previous), + { encoding: "utf8", mode: 0o600 }, + ); + chmodSync(temporary, 0o600); + renameSync(temporary, join(root, "CURRENT")); + }; + expectDenied(() => + createProjectedAuthenticationConfigProvider(root).current(), + ); +}); + +test("fails deterministically when generations is replaced during a load", () => { + const root = projectionRoot(); + const generation = writeReadyProjection( + root, + localProjectionFixture("synthetic-user", passwordHash), + ); + const replacement = mkdtempSync( + join(tmpdir(), "tht-auth-projection-replacement-"), + ); + roots.push(replacement); + chmodSync(replacement, 0o700); + mkdirSync(join(replacement, generation), { recursive: true, mode: 0o700 }); + chmodSync(join(replacement, generation), 0o700); + for (const name of ["manifest.json", "auth.yaml", "users.yaml"]) { + const source = join(root, "generations", generation, name); + writeFileSync(join(replacement, generation, name), readFileSync(source), { + mode: 0o600, + }); + chmodSync(join(replacement, generation, name), 0o600); + } + lstatHook.path = join(root, "generations"); + lstatHook.callback = () => { + lstatHook.callback = undefined; + renameSync(join(root, "generations"), join(root, "generations-retired")); + renameSync(replacement, join(root, "generations")); + }; + expectDenied(() => + createProjectedAuthenticationConfigProvider(root).current(), + ); +}); + +test("fails deterministically when the selected generation directory is replaced during a load", () => { + const root = projectionRoot(); + const generation = writeReadyProjection( + root, + localProjectionFixture("synthetic-user", passwordHash), + ); + const replacement = mkdtempSync( + join(tmpdir(), "tht-auth-projection-generation-replacement-"), + ); + roots.push(replacement); + chmodSync(replacement, 0o700); + for (const name of ["manifest.json", "auth.yaml", "users.yaml"]) { + const source = join(root, "generations", generation, name); + writeFileSync(join(replacement, name), readFileSync(source), { + mode: 0o600, + }); + chmodSync(join(replacement, name), 0o600); + } + lstatHook.path = join(root, "generations", generation); + lstatHook.callback = () => { + lstatHook.callback = undefined; + renameSync( + join(root, "generations", generation), + join(root, "generation-retired"), + ); + renameSync(replacement, join(root, "generations", generation)); + }; + expectDenied(() => + createProjectedAuthenticationConfigProvider(root).current(), + ); +}); + +test.each(["corrupt", "symlink"])( + "rejects a %s retained predecessor generation", + (kind) => { + const root = projectionRoot(); + const first = writeReadyProjection( + root, + localProjectionFixture("first", passwordHash), + ); + const second = writeGeneration( + root, + localProjectionFixture("second", passwordHash), + ); + writeFileSync(join(root, "CURRENT"), currentDocument(second, [first]), { + encoding: "utf8", + mode: 0o600, + }); + chmodSync(join(root, "CURRENT"), 0o600); + const predecessor = join(root, "generations", first); + if (kind === "corrupt") { + writeFileSync(join(predecessor, "auth.yaml"), "tampered", { + encoding: "utf8", + mode: 0o600, + }); + chmodSync(join(predecessor, "auth.yaml"), 0o600); + } else { + const replacement = mkdtempSync( + join(tmpdir(), "tht-auth-projection-history-"), + ); + roots.push(replacement); + chmodSync(replacement, 0o700); + rmSync(predecessor, { recursive: true, force: true }); + symlinkSync(replacement, predecessor, "dir"); + } + expectDenied(() => + createProjectedAuthenticationConfigProvider(root).current(), + ); + }, +); + +test("has no direct-file fallback when CURRENT is absent", () => { + const root = projectionRoot(); + const generation = writeReadyProjection( + root, + localProjectionFixture("synthetic-user", sentinel), + ); + unlinkSync(join(root, "CURRENT")); + writeFileSync( + join(root, "auth.yaml"), + projectionSources(localProjectionFixture("synthetic-user", sentinel)).auth, + { mode: 0o600 }, + ); + expect(existsSync(join(root, "generations", generation, "auth.yaml"))).toBe( + true, + ); + expectDenied(() => + createProjectedAuthenticationConfigProvider(root).current(), + ); +}); + +test("in-flight snapshot authenticates A after selection B and deletion A, while a new load sees B", async () => { + const root = projectionRoot(); + const first = writeReadyProjection( + root, + localProjectionFixture("first", passwordHash), + ); + const provider = createProjectedAuthenticationConfigProvider(root); + const loadedA = provider.current(); + const second = writeGeneration( + root, + localProjectionFixture("second", passwordHash), + ); + const temporary = join(root, ".current-switch.tmp"); + writeFileSync(temporary, currentDocument(second), { + encoding: "utf8", + mode: 0o600, + }); + chmodSync(temporary, 0o600); + renameSync(temporary, join(root, "CURRENT")); + rmSync(join(root, "generations", first), { recursive: true, force: true }); + + const resolver = createCurrentLocalUserRegistryResolver(); + const registryA = resolver.resolve(loadedA); + const userA = await registryA?.findByUsername("FIRST"); + await expect(registryA?.verify(userA, password)).resolves.toBe(true); + const loadedB = provider.current(); + const registryB = resolver.resolve(loadedB); + await expect(registryB?.findByUsername("second")).resolves.toMatchObject({ + username: "second", + }); + await expect(registryB?.findByUsername("first")).resolves.toBeUndefined(); +}); diff --git a/backend/test/local-registry.test.ts b/backend/test/local-registry.test.ts index 6a7718b5..c80f70a8 100644 --- a/backend/test/local-registry.test.ts +++ b/backend/test/local-registry.test.ts @@ -56,7 +56,7 @@ vi.mock("node:fs", async (importOriginal) => { }; }); -import { createLocalUserRegistry } from "../src/auth/local-registry.js"; +import { createCurrentLocalUserRegistryResolver, createLocalUserRegistry } from "../src/auth/local-registry.js"; const password = "correct horse battery staple"; const passwordHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4"; @@ -136,6 +136,44 @@ async function expectInvalid(operation: Promise, secrets: string[] = [] } describe("local user registry", () => { + test("resolves a projected local registry from its frozen in-memory users", async () => { + const resolver = createCurrentLocalUserRegistryResolver(); + const users = Object.freeze([Object.freeze({ + id: adminId, + username: "ProjectedAdmin", + normalizedUsername: "projectedadmin", + passwordHash, + roles: Object.freeze(["admin"] as const), + enabled: true, + authRevision: 1, + })]); + const loaded = { + value: { + version: 1 as const, + mode: "local" as const, + publicUrl: "http://127.0.0.1:8080", + session: { + regularTtlSeconds: 1, + regularIdleSeconds: 1, + rememberTtlSeconds: 1, + rememberIdleSeconds: 1, + oidcTtlSeconds: 1, + }, + local: { usersFile: "users.yaml" }, + }, + revision: "sha256:synthetic", + sourcePath: "/definitely/not/opened/auth.yaml", + runtimeProjection: Object.freeze({ + generation: "a".repeat(64), + canonicalRevision: `sha256:${"a".repeat(64)}`, + localUsers: users, + }), + }; + const registry = resolver.resolve(loaded); + await expect(registry?.findByUsername("PROJECTEDADMIN")).resolves.toMatchObject({ id: adminId }); + await expect(registry?.verify(await registry?.findByUsername("projectedadmin"), password)).resolves.toBe(true); + }); + test("reads known fields, performs case-insensitive lookup, and verifies passwords", async () => { const fixture = writeRegistry(registryYaml(userYaml({ displayName: "Local administrator" }))); const registry = createLocalUserRegistry(fixture.path);