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>
173 lines
7.0 KiB
TypeScript
173 lines
7.0 KiB
TypeScript
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);
|
||
});
|
||
});
|
||
});
|