cameras/src/lib/admin-file.ts
Michael Mainguy 0556b3913e Prepare to publish: onvif-dashboard under MIT, paths in one module
- Rename the package to onvif-dashboard, drop private, add the MIT
  LICENSE and the metadata npm needs; a files list keeps the tarball to
  473 kB instead of sweeping in the MediaMTX binary.
- src/lib/paths.ts now decides where everything lives, so the app can run
  from any directory. CAMERAS_DATA_DIR moves the data folder;
  CAMERAS_BIN_DIR moves the helper binaries; every existing per-file
  override still wins, and the defaults are exactly what running from the
  repo meant before.

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

153 lines
5.7 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.

/**
* 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<string, string | undefined> = 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<Buffer>((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<string> {
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<boolean> {
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<typeof adminFileSchema>;
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<AdminFile | null> {
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<void> {
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<AdminFile> {
return {
version: 1,
username: usernameSchema.parse(username),
passwordHash: await hashPassword(passwordSchema.parse(password)),
};
}