- 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>
153 lines
5.7 KiB
TypeScript
153 lines
5.7 KiB
TypeScript
/**
|
||
* 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)),
|
||
};
|
||
}
|