import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync, } from "node:fs"; import type { Stats } from "node:fs"; 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 { createWindowsAuthStorageBridge, type WindowsAuthStorageBridge } from "./windows-auth-storage.js"; const MAX_USERS_YAML_BYTES = 1 << 20; const USERNAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._@-]{2,63}$/; const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; const ROLES = ["user", "admin"] as const; const invalid = (): Error => new Error("local_user_registry_invalid"); function runtimeOwner(): number { if (process.platform === "win32" || typeof process.geteuid !== "function") throw invalid(); const owner = process.geteuid(); if (!Number.isSafeInteger(owner) || owner < 0) throw invalid(); return owner; } export interface LocalUserRecord { id: string; username: string; normalizedUsername: string; displayName?: string; passwordHash: string; roles: readonly Role[]; enabled: boolean; authRevision: number; } export interface LocalUserRegistry { /** Safe production diagnostic probe; never returns user records or hashes. */ hasEnabledAdmin(): Promise; findByUsername(username: string): Promise; findBySubject(id: string): Promise; verify(user: LocalUserRecord | undefined, password: string): Promise; } /** Keeps only the registry named by the current coherent authentication-config snapshot. */ export interface CurrentLocalUserRegistryResolver { resolve(loaded: LoadedAuthConfig): LocalUserRegistry | undefined; } /** Native Windows obtains protected registry bytes only from the hidden tht bridge. */ export interface LocalUserRegistryOptions { windowsStorageBridge?: Pick; } interface FileIdentity { dev: number; ino: number; uid: number; size: number; mtimeMs: number; } interface DirectoryIdentity { dev: number; ino: number; uid: number; mode: number; } interface RegistryIdentity { file: FileIdentity; directory: DirectoryIdentity; } const roleSchema = z.enum(ROLES); const userSchema = z.strictObject({ id: z.string().regex(UUID_V4_PATTERN), username: z.string().regex(USERNAME_PATTERN), displayName: z.string().optional().refine((value) => value === undefined || !/\p{Cc}/u.test(value)), passwordHash: z.string().refine(isValidPasswordHash), roles: z.array(roleSchema).min(1).superRefine((roles, context) => { if (new Set(roles).size !== roles.length) context.addIssue({ code: "custom", message: "duplicate role" }); }), enabled: z.boolean(), authRevision: z.number().int().positive().safe(), }); const registrySchema = z.strictObject({ version: z.literal(1), users: z.array(userSchema).min(1) }); function normalizeUsername(username: string): string { return username.replace(/[A-Z]/g, (character) => character.toLowerCase()); } function sameFileIdentity(left: FileIdentity, right: FileIdentity): boolean { return left.dev === right.dev && left.ino === right.ino && left.uid === right.uid && left.size === right.size && left.mtimeMs === right.mtimeMs; } function sameDirectoryIdentity(left: DirectoryIdentity, right: DirectoryIdentity): boolean { return left.dev === right.dev && left.ino === right.ino && left.uid === right.uid && left.mode === right.mode; } function sameIdentity(left: RegistryIdentity, right: RegistryIdentity): boolean { return sameFileIdentity(left.file, right.file) && sameDirectoryIdentity(left.directory, right.directory); } function validateCanonicalPath(path: string): void { if (typeof path !== "string" || path.length === 0 || path.includes("\0") || !isAbsolute(path) || normalize(path) !== path) throw invalid(); const parent = dirname(path); if (realpathSync(parent) !== parent) throw invalid(); } function fileMetadata(info: Stats, owner: number): FileIdentity { if (!info.isFile() || info.uid !== owner || info.nlink !== 1 || (info.mode & 0o7777) !== 0o600) throw invalid(); if (info.size < 0 || info.size > MAX_USERS_YAML_BYTES) throw invalid(); return { dev: info.dev, ino: info.ino, uid: info.uid, size: info.size, mtimeMs: info.mtimeMs }; } function directoryMetadata(info: Stats, owner: number): DirectoryIdentity { if (!info.isDirectory() || info.uid !== owner || (info.mode & 0o7777) !== 0o700) throw invalid(); return { dev: info.dev, ino: info.ino, uid: info.uid, mode: info.mode & 0o7777 }; } function directoryIdentity(path: string, owner: number): DirectoryIdentity { const parent = dirname(path); if (realpathSync(parent) !== parent) throw invalid(); return directoryMetadata(lstatSync(parent) as Stats, owner); } function registryIdentity(path: string, owner: number): RegistryIdentity { validateCanonicalPath(path); const info = lstatSync(path); return { file: fileMetadata(info as Stats, owner), directory: directoryIdentity(path, owner) }; } function openDirectoryDescriptor(path: string): number | undefined { if (process.platform === "win32") return undefined; const flags = constants.O_RDONLY | (constants.O_DIRECTORY ?? 0) | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0); return openSync(path, flags); } function readBounded(path: string, owner: number): { source: string; identity: RegistryIdentity } { validateCanonicalPath(path); const beforeDirectory = directoryIdentity(path, owner); const beforePath = lstatSync(path); const before = fileMetadata(beforePath as Stats, owner); let directoryDescriptor: number | undefined; let descriptor: number | undefined; try { directoryDescriptor = openDirectoryDescriptor(dirname(path)); const openedDirectory = directoryDescriptor === undefined ? beforeDirectory : directoryMetadata(fstatSync(directoryDescriptor) as Stats, owner); if (!sameDirectoryIdentity(beforeDirectory, openedDirectory)) throw invalid(); descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); const opened = fileMetadata(fstatSync(descriptor) as Stats, owner); if (!sameFileIdentity(before, opened)) throw invalid(); const buffer = Buffer.allocUnsafe(MAX_USERS_YAML_BYTES + 1); let offset = 0; while (offset < buffer.length) { const bytesRead = readSync(descriptor, buffer, offset, buffer.length - offset, null); if (bytesRead === 0) break; offset += bytesRead; } if (offset > MAX_USERS_YAML_BYTES) throw invalid(); const after = fileMetadata(fstatSync(descriptor) as Stats, owner); const afterPath = fileMetadata(lstatSync(path) as Stats, owner); const afterDirectory = directoryMetadata(lstatSync(dirname(path)) as Stats, owner); const afterOpenedDirectory = directoryDescriptor === undefined ? afterDirectory : directoryMetadata(fstatSync(directoryDescriptor) as Stats, owner); if (!sameFileIdentity(opened, after) || !sameFileIdentity(after, afterPath) || !sameDirectoryIdentity(beforeDirectory, afterDirectory) || !sameDirectoryIdentity(openedDirectory, afterOpenedDirectory)) throw invalid(); const source = new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, offset)); return { source, identity: { file: after, directory: afterDirectory } }; } catch { throw invalid(); } finally { if (descriptor !== undefined) { try { closeSync(descriptor); } catch { /* sanitized by design */ } } if (directoryDescriptor !== undefined) { try { closeSync(directoryDescriptor); } catch { /* sanitized by design */ } } } } function parseRegistry(source: string): LocalUserRecord[] { try { const document = parseDocument(source, { uniqueKeys: true }); if (document.errors.length > 0 || document.warnings.length > 0) throw invalid(); const parsed = registrySchema.parse(document.toJSON()); const ids = new Set(); const usernames = new Set(); const records = parsed.users.map((user) => { const normalizedUsername = normalizeUsername(user.username); if (ids.has(user.id) || usernames.has(normalizedUsername)) throw invalid(); ids.add(user.id); usernames.add(normalizedUsername); return Object.freeze({ id: user.id, username: user.username, normalizedUsername, ...(user.displayName === undefined ? {} : { displayName: user.displayName }), passwordHash: user.passwordHash, roles: Object.freeze([...user.roles]) as readonly Role[], enabled: user.enabled, authRevision: user.authRevision, }); }); return records; } catch { throw invalid(); } } function load(path: string, owner: number): { records: LocalUserRecord[]; identity: RegistryIdentity } { const read = readBounded(path, owner); return { records: parseRegistry(read.source), identity: read.identity }; } export function createLocalUserRegistry(usersPath: string, options: LocalUserRegistryOptions = {}): LocalUserRegistry { let cached: { records: LocalUserRecord[]; identity: RegistryIdentity } | undefined; const windowsStorage = process.platform === "win32" ? options.windowsStorageBridge ?? createWindowsAuthStorageBridge() : undefined; function currentPosix(): LocalUserRecord[] { try { const owner = runtimeOwner(); const before = registryIdentity(usersPath, owner); if (cached && sameIdentity(cached.identity, before)) return cached.records; for (let attempt = 0; attempt < 2; attempt += 1) { const loaded = load(usersPath, owner); if (sameIdentity(loaded.identity, registryIdentity(usersPath, owner))) { cached = loaded; return loaded.records; } } } catch { throw invalid(); } throw invalid(); } 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)); } catch { throw invalid(); } } async function operationalRecords(): Promise { const records = await current(); if (!records.some((user) => user.enabled && user.roles.includes("admin"))) throw invalid(); return records; } return { async hasEnabledAdmin(): Promise { return (await current()).some((user) => user.enabled && user.roles.includes("admin")); }, async findByUsername(username: string): Promise { const normalized = normalizeUsername(username); return (await operationalRecords()).find((user) => user.normalizedUsername === normalized); }, 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); }, }; } export function createCurrentLocalUserRegistryResolver(options: LocalUserRegistryOptions = {}): CurrentLocalUserRegistryResolver { let current: { usersPath: string; registry: LocalUserRegistry } | undefined; return { resolve(loaded: LoadedAuthConfig): LocalUserRegistry | undefined { if (loaded.value.mode !== "local") return undefined; const usersPath = join(dirname(loaded.sourcePath), loaded.value.local.usersFile); if (current?.usersPath === usersPath) return current.registry; const registry = createLocalUserRegistry(usersPath, options); current = { usersPath, registry }; return registry; }, }; }