import { chmodSync, existsSync, lstatSync, mkdirSync, renameSync, realpathSync, symlinkSync, unlinkSync, utimesSync, writeFileSync, linkSync, } from "node:fs"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, test, vi } from "vitest"; type OwnershipObservation = | "file-lstat" | "file-fstat" | "directory-lstat" | "directory-fstat"; const ownershipOverride = vi.hoisted(() => ({ observation: undefined as OwnershipObservation | undefined, uid: undefined as number | undefined, })); vi.mock("node:fs", async (importOriginal) => { const actual = await importOriginal(); const replaceUid = (value: T, uid: number): T => new Proxy(value, { get(target, property) { if (property === "uid") return uid; const member = Reflect.get(target, property, target); return typeof member === "function" ? member.bind(target) : member; }, }); const maybeReplace = ( value: T, source: "lstat" | "fstat", ): T => { const kind = value.isDirectory() ? "directory" : "file"; return ownershipOverride.observation === `${kind}-${source}` && ownershipOverride.uid !== undefined ? replaceUid(value, ownershipOverride.uid) : value; }; return { ...actual, lstatSync(path: import("node:fs").PathLike) { return maybeReplace(actual.lstatSync(path), "lstat"); }, fstatSync(fd: number) { return maybeReplace(actual.fstatSync(fd), "fstat"); }, }; }); 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"; const adminId = "6ba7b810-9dad-4ed1-80b4-00c04fd430c8"; const userId = "7ba7b810-9dad-4ed1-80b4-00c04fd430c8"; const createdRoots: string[] = []; afterEach(() => { ownershipOverride.observation = undefined; ownershipOverride.uid = undefined; for (const root of createdRoots.splice(0)) { for (const name of ["users.yaml", "users-link.yaml", "users-target.yaml", "replacement.yaml"]) { const path = join(root, name); if (existsSync(path) || lstatMaybe(path)) unlinkSync(path); } } }); function lstatMaybe(path: string): boolean { try { lstatSync(path); return true; } catch { return false; } } function root(): string { const path = mkdtempSync(join(realpathSync(tmpdir()), "thothii-local-registry-")); chmodSync(path, 0o700); if ((lstatSync(path).mode & 0o7777) !== 0o700) throw new Error("test root is not private"); createdRoots.push(path); return path; } function userYaml(options: { id?: string; username?: string; displayName?: string; enabled?: boolean; role?: "user" | "admin"; } = {}): string { return [ ` - id: ${options.id ?? adminId}`, ` username: ${options.username ?? "Admin"}`, ` displayName: ${options.displayName ?? "Admin"}`, ` passwordHash: ${passwordHash}`, ` roles:`, ` - ${options.role ?? "admin"}`, ` enabled: ${options.enabled ?? true}`, ` authRevision: 1`, ].join("\n") + "\n"; } function registryYaml(users: string): string { return `version: 1\nusers:\n${users}`; } function writeRegistry(contents: string, file = "users.yaml"): { root: string; path: string } { const directory = root(); const path = join(directory, file); writeFileSync(path, contents, { encoding: "utf8", mode: 0o600 }); chmodSync(path, 0o600); return { root: directory, path }; } async function expectInvalid(operation: Promise, secrets: string[] = []): Promise { try { await operation; throw new Error("operation unexpectedly succeeded"); } catch (error) { const message = error instanceof Error ? error.message : String(error); expect(message).toBe("local_user_registry_invalid"); for (const secret of secrets) expect(message).not.toContain(secret); } } 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); await expect(registry.findByUsername("aDmIn")).resolves.toMatchObject({ id: adminId, username: "Admin", normalizedUsername: "admin", displayName: "Local administrator", passwordHash, roles: ["admin"], enabled: true, authRevision: 1, }); await expect(registry.findBySubject(adminId)).resolves.toMatchObject({ username: "Admin" }); await expect(registry.verify(await registry.findByUsername("admin"), password)).resolves.toBe(true); await expect(registry.verify(await registry.findByUsername("admin"), `${password}!`)).resolves.toBe(false); }); test("uses a dummy verification path for unknown and disabled users", async () => { const fixture = writeRegistry(registryYaml(userYaml() + userYaml({ id: userId, username: "operator", role: "user", enabled: false, }))); const registry = createLocalUserRegistry(fixture.path); await expect(registry.verify(undefined, password)).resolves.toBe(false); await expect(registry.verify(await registry.findByUsername("operator"), password)).resolves.toBe(false); }); test("reports whether a structurally valid registry has an enabled administrator", async () => { const admin = createLocalUserRegistry(writeRegistry(registryYaml(userYaml())).path); const usersOnly = createLocalUserRegistry(writeRegistry(registryYaml(userYaml({ role: "user" }))).path); const disabledAdmin = createLocalUserRegistry(writeRegistry(registryYaml(userYaml({ enabled: false }))).path); await expect(admin.hasEnabledAdmin()).resolves.toBe(true); await expect(usersOnly.hasEnabledAdmin()).resolves.toBe(false); await expect(disabledAdmin.hasEnabledAdmin()).resolves.toBe(false); await expectInvalid(usersOnly.findByUsername("admin")); await expectInvalid(disabledAdmin.findBySubject(adminId)); }); test("rejects a valid registry under a non-private authentication directory", async () => { const fixture = writeRegistry(registryYaml(userYaml())); chmodSync(fixture.root, 0o750); expect(lstatSync(fixture.root).mode & 0o7777).toBe(0o750); await expectInvalid(createLocalUserRegistry(fixture.path).findByUsername("admin"), ["admin", passwordHash, fixture.path]); }); test("rejects a valid registry under a symlinked authentication directory", async () => { const outer = root(); const realDirectory = join(outer, "real-auth"); const linkedDirectory = join(outer, "linked-auth"); mkdirSync(realDirectory, { mode: 0o700 }); chmodSync(realDirectory, 0o700); const path = join(realDirectory, "users.yaml"); writeFileSync(path, registryYaml(userYaml()), { encoding: "utf8", mode: 0o600 }); chmodSync(path, 0o600); symlinkSync(realDirectory, linkedDirectory); await expectInvalid(createLocalUserRegistry(join(linkedDirectory, "users.yaml")).findByUsername("admin"), ["admin", passwordHash]); }); test.each([ ["duplicate normalized usernames", registryYaml(userYaml() + userYaml({ id: userId, username: "admin" }))], ["duplicate IDs", registryYaml(userYaml() + userYaml({ username: "operator" }))], ["unknown YAML fields", `${registryYaml(userYaml())}unexpected: true\n`], ["duplicate roles", registryYaml(userYaml().replace(" - admin", " - admin\n - admin"))], ["invalid password hash", registryYaml(userYaml().replace(passwordHash, "not-a-password-hash"))], ["control character in display name", registryYaml(userYaml().replace("displayName: Admin", 'displayName: "Admin\\tUser"'))], ])("rejects %s", async (_name, contents) => { const fixture = writeRegistry(contents); await expectInvalid(createLocalUserRegistry(fixture.path).findByUsername("admin"), ["admin", passwordHash, fixture.path]); }); test.each(["symlink", "hard link", "mode wider than 0600", "file larger than 1 MiB"])( "rejects unsafe %s registry metadata", async (kind) => { const fixture = writeRegistry(registryYaml(userYaml())); if (kind === "symlink") { const target = join(fixture.root, "users-target.yaml"); renameSync(fixture.path, target); symlinkSync(target, fixture.path); } else if (kind === "hard link") { linkSync(fixture.path, join(fixture.root, "users-link.yaml")); } else if (kind === "mode wider than 0600") { chmodSync(fixture.path, 0o640); } else { writeFileSync(fixture.path, "#".repeat((1 << 20) + 1), { encoding: "utf8", mode: 0o600 }); } await expectInvalid(createLocalUserRegistry(fixture.path).findByUsername("admin"), ["admin", passwordHash, fixture.path]); }, ); test.runIf(process.platform !== "win32").each([ "file-lstat", "file-fstat", "directory-lstat", "directory-fstat", ] as const)("rejects foreign ownership at the %s boundary", async (observation) => { const fixture = writeRegistry(registryYaml(userYaml())); const owner = process.geteuid(); ownershipOverride.observation = observation; ownershipOverride.uid = owner === 0 ? 1 : owner - 1; await expectInvalid( createLocalUserRegistry(fixture.path).findByUsername("admin"), ["admin", passwordHash, fixture.path], ); }); test.runIf(process.platform !== "win32")("fails closed when the effective UID is invalid", async () => { const fixture = writeRegistry(registryYaml(userYaml())); const getuid = vi.spyOn(process, "geteuid").mockReturnValue(-1); try { await expectInvalid( createLocalUserRegistry(fixture.path).findByUsername("admin"), ["admin", passwordHash, fixture.path], ); } finally { getuid.mockRestore(); } }); test("reloads a same-size atomic replacement with changed metadata", async () => { const fixture = writeRegistry(registryYaml(userYaml({ displayName: "Admin" }))); const registry = createLocalUserRegistry(fixture.path); await expect(registry.findByUsername("admin")).resolves.toMatchObject({ displayName: "Admin" }); const replacement = join(fixture.root, "replacement.yaml"); writeFileSync(replacement, registryYaml(userYaml({ displayName: "Owner" })), { encoding: "utf8", mode: 0o600 }); chmodSync(replacement, 0o600); utimesSync(replacement, new Date("2035-01-01T00:00:00Z"), new Date("2035-01-01T00:00:00Z")); expect(lstatSync(replacement).size).toBe(lstatSync(fixture.path).size); renameSync(replacement, fixture.path); await expect(registry.findByUsername("admin")).resolves.toMatchObject({ displayName: "Owner" }); }); test("routes native Windows users.yaml loading only through the bounded auth-storage bridge", async () => { const usersPath = "C:\\ProgramData\\ThothII\\auth\\users.yaml"; const readLocalUsers = vi.fn(async (path: string) => { expect(path).toBe(usersPath); return Buffer.from(registryYaml(userYaml({ displayName: "Bridge administrator" })), "utf8"); }); const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); if (!originalPlatform) throw new Error("platform descriptor unavailable"); Object.defineProperty(process, "platform", { configurable: true, value: "win32" }); try { const registry = createLocalUserRegistry(usersPath, { windowsStorageBridge: { readLocalUsers } } as never); await expect(registry.findByUsername("ADMIN")).resolves.toMatchObject({ id: adminId, displayName: "Bridge administrator", }); await expect(registry.hasEnabledAdmin()).resolves.toBe(true); // Windows reloads from the bridge on every registry observation so an atomic host // replacement cannot be missed between authorization checks. expect(readLocalUsers).toHaveBeenCalledTimes(2); } finally { Object.defineProperty(process, "platform", originalPlatform); } }); });