From 5eed4f94497ba97b0fe85c471ca16cf8169a0505 Mon Sep 17 00:00:00 2001 From: mptyl Date: Sun, 16 Aug 2026 19:49:02 +0200 Subject: [PATCH] feat(auth): verify local ThothII users in the backend --- backend/src/auth/local-registry.ts | 195 ++++++++++++++++++++++++++++ backend/src/auth/password.ts | 97 ++++++++++++++ backend/test/auth-password.test.ts | 47 +++++++ backend/test/local-registry.test.ts | 169 ++++++++++++++++++++++++ 4 files changed, 508 insertions(+) create mode 100644 backend/src/auth/local-registry.ts create mode 100644 backend/src/auth/password.ts create mode 100644 backend/test/auth-password.test.ts create mode 100644 backend/test/local-registry.test.ts diff --git a/backend/src/auth/local-registry.ts b/backend/src/auth/local-registry.ts new file mode 100644 index 00000000..bda47d34 --- /dev/null +++ b/backend/src/auth/local-registry.ts @@ -0,0 +1,195 @@ +import { + closeSync, + constants, + fstatSync, + lstatSync, + openSync, + readSync, + realpathSync, +} from "node:fs"; +import type { Stats } from "node:fs"; +import { dirname, isAbsolute, normalize } from "node:path"; +import { parseDocument } from "yaml"; +import { z } from "zod"; +import { isValidPasswordHash, verifyPassword, verifyWithDummy } from "./password.js"; +import type { Role } from "./types.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"); + +export interface LocalUserRecord { + id: string; + username: string; + normalizedUsername: string; + displayName?: string; + passwordHash: string; + roles: readonly Role[]; + enabled: boolean; + authRevision: number; +} + +export interface LocalUserRegistry { + findByUsername(username: string): Promise; + findBySubject(id: string): Promise; + verify(user: LocalUserRecord | undefined, password: string): Promise; +} + +interface FileIdentity { + dev: number; + ino: number; + size: number; + mtimeMs: number; +} + +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 sameIdentity(left: FileIdentity, right: FileIdentity): boolean { + return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeMs === right.mtimeMs; +} + +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 validateMetadata(info: { isFile(): boolean; nlink: number; mode: number }): void { + if (!info.isFile() || info.nlink !== 1 || (info.mode & 0o7777) !== 0o600) throw invalid(); +} + +function metadata(info: Stats): FileIdentity { + validateMetadata(info); + if (info.size < 0 || info.size > MAX_USERS_YAML_BYTES) throw invalid(); + return { dev: info.dev, ino: info.ino, size: info.size, mtimeMs: info.mtimeMs }; +} + +function fileIdentity(path: string): FileIdentity { + validateCanonicalPath(path); + const info = lstatSync(path); + return metadata(info as Stats); +} + +function readBounded(path: string): { source: string; identity: FileIdentity } { + validateCanonicalPath(path); + const beforePath = lstatSync(path); + const before = metadata(beforePath); + let descriptor: number | undefined; + try { + descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + const opened = metadata(fstatSync(descriptor) as Stats); + if (!sameIdentity(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 = metadata(fstatSync(descriptor) as Stats); + const afterPath = metadata(lstatSync(path) as Stats); + if (!sameIdentity(opened, after) || !sameIdentity(after, afterPath)) throw invalid(); + const source = new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, offset)); + return { source, identity: after }; + } catch { + throw invalid(); + } finally { + if (descriptor !== undefined) { + try { closeSync(descriptor); } 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(); + let enabledAdmin = false; + 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); + if (user.enabled && user.roles.includes("admin")) enabledAdmin = true; + 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, + }); + }); + if (!enabledAdmin) throw invalid(); + return records; + } catch { + throw invalid(); + } +} + +function load(path: string): { records: LocalUserRecord[]; identity: FileIdentity } { + const read = readBounded(path); + return { records: parseRegistry(read.source), identity: read.identity }; +} + +export function createLocalUserRegistry(usersPath: string): LocalUserRegistry { + let cached: { records: LocalUserRecord[]; identity: FileIdentity } | undefined; + + function current(): LocalUserRecord[] { + try { + const before = fileIdentity(usersPath); + if (cached && sameIdentity(cached.identity, before)) return cached.records; + for (let attempt = 0; attempt < 2; attempt += 1) { + const loaded = load(usersPath); + if (sameIdentity(loaded.identity, fileIdentity(usersPath))) { + cached = loaded; + return loaded.records; + } + } + } catch { + throw invalid(); + } + throw invalid(); + } + + return { + async findByUsername(username: string): Promise { + const normalized = normalizeUsername(username); + return current().find((user) => user.normalizedUsername === normalized); + }, + async findBySubject(id: string): Promise { + return current().find((user) => user.id === id); + }, + async verify(user: LocalUserRecord | undefined, password: string): Promise { + if (!user || !user.enabled) { + verifyWithDummy(password); + return false; + } + return verifyPassword(password, user.passwordHash); + }, + }; +} diff --git a/backend/src/auth/password.ts b/backend/src/auth/password.ts new file mode 100644 index 00000000..69db9af4 --- /dev/null +++ b/backend/src/auth/password.ts @@ -0,0 +1,97 @@ +import { argon2Sync, randomBytes, timingSafeEqual } from "node:crypto"; + +const MAXIMUM_PHC_BYTES = 256; +const MAXIMUM_MEMORY_KIB = 256 * 1024; +const MAXIMUM_PASSES = 10; +const MAXIMUM_PARALLELISM = 4; +const MINIMUM_SALT_BYTES = 16; +const MAXIMUM_SALT_BYTES = 64; +const MINIMUM_KEY_BYTES = 16; +const MAXIMUM_KEY_BYTES = 64; +const MINIMUM_PASSWORD_BYTES = 12; +const MAXIMUM_PASSWORD_BYTES = 1024; + +interface Argon2Parameters { + memory: number; + passes: number; + parallelism: number; + salt: Buffer; + digest: Buffer; +} + +function parseDecimal(value: string, maximum: number): number | undefined { + if (!/^\d+$/.test(value) || (value.length > 1 && value[0] === "0") || value.length > 10) return undefined; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed <= maximum ? parsed : undefined; +} + +function decodeRawBase64(value: string, minimum: number, maximum: number): Buffer | undefined { + if (!/^[A-Za-z0-9+/]+$/.test(value)) return undefined; + const decoded = Buffer.from(value, "base64"); + if (decoded.length < minimum || decoded.length > maximum) return undefined; + if (decoded.toString("base64").replace(/=+$/, "") !== value) return undefined; + return decoded; +} + +function parsePHC(encoded: string): Argon2Parameters | undefined { + if (typeof encoded !== "string" || encoded.length === 0 || Buffer.byteLength(encoded, "utf8") > MAXIMUM_PHC_BYTES) return undefined; + const parts = encoded.split("$"); + if (parts.length !== 6 || parts[0] !== "" || parts[1] !== "argon2id" || parts[2] !== "v=19") return undefined; + + const parameterParts = parts[3].split(","); + if (parameterParts.length !== 3 || !parameterParts[0].startsWith("m=") || !parameterParts[1].startsWith("t=") || !parameterParts[2].startsWith("p=")) return undefined; + const memory = parseDecimal(parameterParts[0].slice(2), MAXIMUM_MEMORY_KIB); + const passes = parseDecimal(parameterParts[1].slice(2), MAXIMUM_PASSES); + const parallelism = parseDecimal(parameterParts[2].slice(2), MAXIMUM_PARALLELISM); + if (memory === undefined || memory < 8 || passes === undefined || passes === 0 || parallelism === undefined || parallelism === 0 || memory < 8 * parallelism) return undefined; + + const salt = decodeRawBase64(parts[4], MINIMUM_SALT_BYTES, MAXIMUM_SALT_BYTES); + const digest = decodeRawBase64(parts[5], MINIMUM_KEY_BYTES, MAXIMUM_KEY_BYTES); + if (!salt || !digest) return undefined; + return { memory, passes, parallelism, salt, digest }; +} + +function passwordBytes(password: string): Buffer | undefined { + if (typeof password !== "string") return undefined; + const bytes = Buffer.from(password, "utf8"); + return bytes.length >= MINIMUM_PASSWORD_BYTES && bytes.length <= MAXIMUM_PASSWORD_BYTES ? bytes : undefined; +} + +export function isValidPasswordHash(encoded: string): boolean { + return parsePHC(encoded) !== undefined; +} + +export function verifyPassword(password: string, encoded: string): boolean { + const parameters = parsePHC(encoded); + const message = passwordBytes(password); + if (!message || !parameters) return false; + try { + const derived = argon2Sync("argon2id", { + message, + nonce: parameters.salt, + memory: parameters.memory, + passes: parameters.passes, + parallelism: parameters.parallelism, + tagLength: parameters.digest.length, + }); + return derived.length === parameters.digest.length && timingSafeEqual(derived, parameters.digest); + } catch { + return false; + } +} + +const DUMMY_PASSWORD = "thothii-process-local-dummy-password"; +const DUMMY_SALT = randomBytes(MINIMUM_SALT_BYTES); +const DUMMY_DIGEST = argon2Sync("argon2id", { + message: Buffer.from(DUMMY_PASSWORD, "utf8"), + nonce: DUMMY_SALT, + memory: 65536, + passes: 3, + parallelism: 1, + tagLength: 32, +}); +const DUMMY_HASH = `$argon2id$v=19$m=65536,t=3,p=1$${DUMMY_SALT.toString("base64").replace(/=+$/, "")}$${DUMMY_DIGEST.toString("base64").replace(/=+$/, "")}`; + +export function verifyWithDummy(password: string): void { + verifyPassword(passwordBytes(password) ? password : DUMMY_PASSWORD, DUMMY_HASH); +} diff --git a/backend/test/auth-password.test.ts b/backend/test/auth-password.test.ts new file mode 100644 index 00000000..d99f0f44 --- /dev/null +++ b/backend/test/auth-password.test.ts @@ -0,0 +1,47 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, test } from "vitest"; +import { verifyPassword } from "../src/auth/password.js"; + +interface Argon2Vector { + password: string; + phc: string; +} + +const vectors = JSON.parse(readFileSync( + resolve(import.meta.dirname, "fixtures/argon2id-vectors.json"), + "utf8", +)) as Argon2Vector[]; + +describe("local Argon2id password verification", () => { + test("accepts every committed Go-generated vector", () => { + expect(vectors.length).toBeGreaterThan(0); + for (const vector of vectors) { + expect(verifyPassword(vector.password, vector.phc)).toBe(true); + } + }); + + test("rejects a one-byte password change", () => { + for (const vector of vectors) { + expect(verifyPassword(`${vector.password}!`, vector.phc)).toBe(false); + } + }); + + test("rejects malformed and oversized PHC parameters before Argon2 allocation", () => { + const password = vectors[0].password; + const digest = vectors[0].phc.split("$")[5]; + const salt = vectors[0].phc.split("$")[4]; + const cases = [ + `$argon2id$v=19$m=262145,t=1,p=1$${salt}$${digest}`, + `$argon2id$v=19$m=65536,t=11,p=1$${salt}$${digest}`, + `$argon2id$v=19$m=65536,t=3,p=5$${salt}$${digest}`, + `$argon2id$v=19$m=65536,t=3,p=1$${salt}$${"A".repeat(88)}`, + `$argon2id$v=19$m=65536,t=3,p=1$${salt}=$${digest}`, + ]; + + for (const phc of cases) { + expect(() => verifyPassword(password, phc)).not.toThrow(); + expect(verifyPassword(password, phc)).toBe(false); + } + }); +}); diff --git a/backend/test/local-registry.test.ts b/backend/test/local-registry.test.ts new file mode 100644 index 00000000..28c909c1 --- /dev/null +++ b/backend/test/local-registry.test.ts @@ -0,0 +1,169 @@ +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 } from "vitest"; +import { 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(() => { + 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); + 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("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.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`], + ])("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("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" }); + }); +});