/** * The admin login file: format, password hashing, and atomic read/write. * * Plain Node on purpose (no "server-only", no path aliases, no TS-only syntax) so that * scripts/create-admin.mts can run it directly with `node` to create the file outside the * app. The app uses it through admin-auth.ts. */ import { randomBytes, scrypt as scryptCb, timingSafeEqual, type ScryptOptions } from "node:crypto"; import { link, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; import { adminFile } from "./paths.ts"; // Runtime data, not a build input: the ignore comment keeps it out of Next's output file // tracing (see camera-registry.ts). /** ADMIN_AUTH_FILE if set, otherwise admin.json in the data folder (see lib/paths.ts). */ export function adminFilePath(env: Record = process.env): string { return adminFile(env); } export const usernameSchema = z .string() .regex(/^[A-Za-z0-9._@-]{1,64}$/, "1–64 letters, digits, or . _ @ -"); export const passwordSchema = z .string() .min(12, "At least 12 characters") .max(256, "At most 256 characters"); // scrypt cost: N=2^16, r=8, p=1 needs 64 MiB and ~100–200 ms per hash, which makes guessing // against a stolen file expensive while keeping a login quick. const COST = { N: 2 ** 16, r: 8, p: 1 }; const KEY_LENGTH = 64; const SALT_LENGTH = 16; // Refuse absurd parameters from a tampered file rather than exhausting memory. const MAX_N = 2 ** 20; function scrypt( password: string, salt: Buffer, keylen: number, { N, r, p }: { N: number; r: number; p: number }, ) { // scrypt needs 128·N·r bytes; allow twice that so Node's default 32 MiB cap doesn't refuse. const options: ScryptOptions = { N, r, p, maxmem: 256 * N * r }; return new Promise((resolve, reject) => scryptCb(password, salt, keylen, options, (err, key) => (err ? reject(err) : resolve(key))), ); } /** `scrypt$N$r$p$salt$hash`, with salt and hash in base64. */ export async function hashPassword(password: string): Promise { const salt = randomBytes(SALT_LENGTH); const key = await scrypt(password, salt, KEY_LENGTH, COST); const { N, r, p } = COST; return ["scrypt", N, r, p, salt.toString("base64"), key.toString("base64")].join("$"); } const HASH_PATTERN = /^scrypt\$(\d+)\$(\d+)\$(\d+)\$([A-Za-z0-9+/=]+)\$([A-Za-z0-9+/=]+)$/; function parseHash(encoded: string) { const m = HASH_PATTERN.exec(encoded); if (!m) return null; const [N, r, p] = [m[1], m[2], m[3]].map(Number); const salt = Buffer.from(m[4], "base64"); const key = Buffer.from(m[5], "base64"); const powerOfTwo = N > 1 && (N & (N - 1)) === 0; if (!powerOfTwo || N > MAX_N || r < 1 || r > 32 || p < 1 || p > 16) return null; if (salt.length < 8 || key.length < 32) return null; return { N, r, p, salt, key }; } export function isValidPasswordHash(encoded: string): boolean { return parseHash(encoded) !== null; } /** Recomputes the hash with its stored parameters and compares in constant time. */ export async function verifyPassword(password: string, encoded: string): Promise { const parsed = parseHash(encoded); if (!parsed) return false; const { N, r, p, salt, key } = parsed; const candidate = await scrypt(password, salt, key.length, { N, r, p }); return timingSafeEqual(candidate, key); } export const adminFileSchema = z.object({ version: z.literal(1), username: usernameSchema, passwordHash: z.string().refine(isValidPasswordHash, "Not a scrypt$N$r$p$salt$hash string"), }); export type AdminFile = z.infer; export class AdminFileError extends Error { name = "AdminFileError"; } /** * The admin file, or null if it doesn't exist. A file that exists but is unreadable or * malformed throws: a typo must never silently turn authentication off. */ export async function readAdminFile(file: string): Promise { let text: string; try { text = await readFile(file, "utf8"); } catch (err) { if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; throw new AdminFileError(`Can't read admin file ${file}: ${(err as Error).message}`); } let json: unknown; try { json = JSON.parse(text); } catch { throw new AdminFileError(`Admin file ${file} is not valid JSON`); } const parsed = adminFileSchema.safeParse(json); if (!parsed.success) { const problems = parsed.error.issues.map((i) => `${i.path.join(".") || "file"}: ${i.message}`); throw new AdminFileError(`Admin file ${file} is malformed: ${problems.join("; ")}`); } return parsed.data; } /** * Writes the file owner-only (0600) via a temp file, so a crash never leaves it half * written. Without `overwrite`, fails with EEXIST if an admin already exists, atomically. */ export async function writeAdminFile( file: string, admin: AdminFile, { overwrite = false }: { overwrite?: boolean } = {}, ): Promise { const contents = `${JSON.stringify(adminFileSchema.parse(admin), null, 2)}\n`; await mkdir(path.dirname(file), { recursive: true, mode: 0o700 }); const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`; await writeFile(tmp, contents, { mode: 0o600 }); try { // link() refuses to replace an existing file; rename() replaces it. await (overwrite ? rename(tmp, file) : link(tmp, file)); } finally { if (!overwrite) await unlink(tmp).catch(() => {}); } } /** Builds a validated admin record, hashing the password. */ export async function createAdminRecord(username: string, password: string): Promise { return { version: 1, username: usernameSchema.parse(username), passwordHash: await hashPassword(passwordSchema.parse(password)), }; }