cameras/src/lib/admin-file.test.ts
Michael Mainguy 462141aa35 Add admin login foundations: scrypt admin file, CLI, sessions
Groundwork for securing the web front end (vrek gol-wqf95dq). The app
does not enforce login yet.

- src/lib/admin-file.ts: the admin file at ADMIN_AUTH_FILE (default
  .data/admin.json). scrypt hashing (N=2^16, random salt, bounded
  parameters, constant-time compare), zod-validated reads where a
  malformed file is an error, and atomic 0600 writes that won't
  replace an existing admin without overwrite. Plain Node, so the
  CLI can share it (iss-mffqscg).
- src/lib/admin-auth.ts: server-only app layer; failed logins always
  cost one hash.
- scripts/create-admin.mts + `npm run admin:create`: create or reset
  the admin outside the app, interactive (hidden, confirmed) or piped
  (iss-7xmka20). The README documents it, a no-npm Node one-liner,
  the file format, and password reset.
- src/lib/session-token.ts and session.ts: stateless HMAC-signed
  session cookie, keyed from the password hash so a password change
  ends every session, with a 12 h sliding window (iss-e27nb70,
  dec-f0xar8r).

281 tests, 99.8% line coverage. Refreshes the vrek export.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 09:46:40 -05:00

173 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import {
AdminFileError,
adminFilePath,
createAdminRecord,
DEFAULT_ADMIN_FILE,
hashPassword,
isValidPasswordHash,
passwordSchema,
readAdminFile,
usernameSchema,
verifyPassword,
writeAdminFile,
type AdminFile,
} from "./admin-file";
const PASSWORD = "correct horse battery";
// A well-formed hash string (valid shape, meaningless key) for the malformed-hash table,
// which is built before beforeAll runs.
const SHAPE = `scrypt$65536$8$1$${"A".repeat(24)}$${"A".repeat(88)}`;
let hash: string;
let admin: AdminFile;
beforeAll(async () => {
hash = await hashPassword(PASSWORD);
admin = { version: 1, username: "admin", passwordHash: hash };
});
let dir: string;
let file: string;
beforeEach(async () => {
dir = await mkdtemp(path.join(os.tmpdir(), "admin-file-"));
file = path.join(dir, "nested", "admin.json");
});
afterEach(() => rm(dir, { recursive: true, force: true }));
describe("adminFilePath", () => {
it("uses ADMIN_AUTH_FILE when set", () => {
expect(adminFilePath({ ADMIN_AUTH_FILE: "/etc/cams/admin.json" })).toBe("/etc/cams/admin.json");
});
it.each([[{}], [{ ADMIN_AUTH_FILE: "" }]])("defaults to ./.data/admin.json for %j", (env) => {
expect(adminFilePath(env)).toBe(DEFAULT_ADMIN_FILE);
expect(DEFAULT_ADMIN_FILE).toBe(path.join(process.cwd(), ".data", "admin.json"));
});
});
describe("password hashing", () => {
it("produces a salted scrypt string that never contains the password", () => {
expect(hash).toMatch(/^scrypt\$65536\$8\$1\$[A-Za-z0-9+/=]{24}\$[A-Za-z0-9+/=]{88}$/);
expect(hash).not.toContain(PASSWORD);
expect(isValidPasswordHash(hash)).toBe(true);
});
it("salts every hash differently", async () => {
expect(await hashPassword(PASSWORD)).not.toBe(hash);
});
it("verifies the right password and rejects others", async () => {
expect(await verifyPassword(PASSWORD, hash)).toBe(true);
expect(await verifyPassword("correct horse battery!", hash)).toBe(false);
expect(await verifyPassword("", hash)).toBe(false);
});
it("verifies hashes made with other scrypt parameters", async () => {
// e.g. one produced by a future, costlier default, or by hand with the documented one-liner.
const { scryptSync, randomBytes } = await import("node:crypto");
const salt = randomBytes(16);
const key = scryptSync(PASSWORD, salt, 32, { N: 1024, r: 4, p: 2 });
const encoded = `scrypt$1024$4$2$${salt.toString("base64")}$${key.toString("base64")}`;
expect(await verifyPassword(PASSWORD, encoded)).toBe(true);
});
it("accepts the table's base shape", () => expect(isValidPasswordHash(SHAPE)).toBe(true));
it.each([
["not a hash", "hunter2"],
["wrong algorithm", SHAPE.replace(/^scrypt/, "sha512")],
["N not a power of two", SHAPE.replace("$65536$", "$65535$")],
["N too large", SHAPE.replace("$65536$", "$2097152$")],
["r out of range", SHAPE.replace("$8$1$", "$0$1$")],
["p out of range", SHAPE.replace("$8$1$", "$8$99$")],
["salt too short", "scrypt$1024$8$1$AAAA$" + "A".repeat(88)],
["key too short", `scrypt$1024$8$1$${"A".repeat(24)}$AAAA`],
])("rejects %s without hashing", async (_label, encoded) => {
expect(isValidPasswordHash(encoded)).toBe(false);
expect(await verifyPassword(PASSWORD, encoded)).toBe(false);
});
});
describe("validation", () => {
it.each(["admin", "mike.m", "ops_team-1", "me@example.com", "a".repeat(64)])(
"accepts username %s",
(u) => expect(usernameSchema.safeParse(u).success).toBe(true),
);
it.each(["", "has space", "semi;colon", "a".repeat(65), "ünïcode"])("rejects username %j", (u) =>
expect(usernameSchema.safeParse(u).success).toBe(false),
);
it("requires 12–256 character passwords", () => {
expect(passwordSchema.safeParse("x".repeat(11)).success).toBe(false);
expect(passwordSchema.safeParse("x".repeat(12)).success).toBe(true);
expect(passwordSchema.safeParse("x".repeat(256)).success).toBe(true);
expect(passwordSchema.safeParse("x".repeat(257)).success).toBe(false);
});
it("createAdminRecord validates, then hashes", async () => {
await expect(createAdminRecord("bad name", PASSWORD)).rejects.toThrow();
await expect(createAdminRecord("admin", "short")).rejects.toThrow();
const record = await createAdminRecord("admin", PASSWORD);
expect(record).toMatchObject({ version: 1, username: "admin" });
expect(await verifyPassword(PASSWORD, record.passwordHash)).toBe(true);
});
});
describe("reading and writing the file", () => {
it("returns null when the file doesn't exist", async () => {
expect(await readAdminFile(file)).toBeNull();
});
it("writes owner-only in an owner-only folder, and reads it back", async () => {
await writeAdminFile(file, admin);
expect(await readAdminFile(file)).toEqual(admin);
expect((await stat(file)).mode & 0o777).toBe(0o600);
expect((await stat(path.dirname(file))).mode & 0o777).toBe(0o700);
expect(await readFile(file, "utf8")).not.toContain(PASSWORD);
});
it("refuses to replace an existing admin unless asked, leaving no temp files", async () => {
await writeAdminFile(file, admin);
const other = { ...admin, username: "intruder" };
await expect(writeAdminFile(file, other)).rejects.toMatchObject({ code: "EEXIST" });
expect((await readAdminFile(file))!.username).toBe("admin");
await writeAdminFile(file, other, { overwrite: true });
expect((await readAdminFile(file))!.username).toBe("intruder");
const { readdir } = await import("node:fs/promises");
expect(await readdir(path.dirname(file))).toEqual(["admin.json"]);
});
it("refuses to write an invalid record", async () => {
await expect(writeAdminFile(file, { ...admin, passwordHash: "plaintext" })).rejects.toThrow();
expect(await readAdminFile(file)).toBeNull();
});
describe("fails loudly instead of disabling auth", () => {
it.each([
["invalid JSON", "{not json", /not valid JSON/],
["a missing field", JSON.stringify({ version: 1, username: "admin" }), /passwordHash/],
["a plaintext password", JSON.stringify({ version: 1, username: "admin", passwordHash: "hunter2" }), /passwordHash/],
["an unknown version", JSON.stringify({ version: 2, username: "admin", passwordHash: "x" }), /version/],
["a non-object", "null", /file/],
])("on %s", async (_label, contents, message) => {
await writeFile(file.replace("nested/", ""), contents);
const err = await readAdminFile(file.replace("nested/", "")).catch((e) => e);
expect(err).toBeInstanceOf(AdminFileError);
expect(err.message).toMatch(message);
});
it("on an unreadable path", async () => {
// A directory where the file should be: exists, but can't be read as a file.
await writeAdminFile(path.join(file, "inner.json"), admin);
await expect(readAdminFile(file)).rejects.toBeInstanceOf(AdminFileError);
});
});
});