feat(auth): verify local ThothII users in the backend

This commit is contained in:
2026-08-16 19:49:02 +02:00
parent 856ac05edc
commit 5eed4f9449
4 changed files with 508 additions and 0 deletions
+195
View File
@@ -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<LocalUserRecord | undefined>;
findBySubject(id: string): Promise<LocalUserRecord | undefined>;
verify(user: LocalUserRecord | undefined, password: string): Promise<boolean>;
}
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<string>();
const usernames = new Set<string>();
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<LocalUserRecord | undefined> {
const normalized = normalizeUsername(username);
return current().find((user) => user.normalizedUsername === normalized);
},
async findBySubject(id: string): Promise<LocalUserRecord | undefined> {
return current().find((user) => user.id === id);
},
async verify(user: LocalUserRecord | undefined, password: string): Promise<boolean> {
if (!user || !user.enabled) {
verifyWithDummy(password);
return false;
}
return verifyPassword(password, user.passwordHash);
},
};
}
+97
View File
@@ -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);
}