import { createHmac, randomBytes, scrypt as scryptCallback, timingSafeEqual } from "node:crypto"; import { promisify } from "node:util"; import { readAuth, writeAuth } from "./store"; const scrypt = promisify(scryptCallback) as (password: string, salt: Buffer, keylen: number, options: object) => Promise; const SCRYPT = { N: 1 << 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }; const SESSION_COOKIE = "hfo_admin"; const SESSION_SECONDS = 30 * 24 * 60 * 60; export const MIN_PASSWORD_LENGTH = 6; // ---------- network policy ---------- function ipv4ToInt(ip: string) { const parts = ip.split(".").map(Number); if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null; return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0; } function inCidr(ip: string, cidr: string) { const [base, bitsText] = cidr.trim().split("/"); const address = ipv4ToInt(ip); const network = ipv4ToInt(base); const bits = Number(bitsText ?? 32); if (address === null || network === null || !(bits >= 0 && bits <= 32)) return false; const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0; return (address & mask) === (network & mask); } /** * Admin is reachable only from a direct LAN connection. The container's front proxy * stamps x-hfo-client-ip with the TCP peer and x-hfo-forwarded when the request came * through another proxy (e.g. a future Traefik route), which is always refused. */ export function isAdminNetworkAllowed(headers: Headers) { if (headers.get("x-hfo-forwarded")) return false; const ip = headers.get("x-hfo-client-ip") || ""; const allowed = (process.env.ADMIN_ALLOWED_CIDRS || "192.168.86.0/24,127.0.0.1/32").split(","); return allowed.some((cidr) => inCidr(ip, cidr)); } export function clientIp(headers: Headers) { return headers.get("x-hfo-client-ip") || "unknown"; } /** Mutating requests must come from this same origin (CSRF guard on top of SameSite=Strict). */ export function sameOrigin(request: Request) { const origin = request.headers.get("origin"); const host = request.headers.get("host"); if (!origin || !host) return false; try { return new URL(origin).host === host; } catch { return false; } } // ---------- passwords ---------- export async function hashPassword(password: string) { const salt = randomBytes(16); const key = await scrypt(password, salt, 32, SCRYPT); return `scrypt$${SCRYPT.N}$${SCRYPT.r}$${SCRYPT.p}$${salt.toString("base64url")}$${key.toString("base64url")}`; } export async function verifyPassword(password: string, stored: string) { const [scheme, n, r, p, saltText, keyText] = stored.split("$"); if (scheme !== "scrypt" || !saltText || !keyText) return false; const expected = Buffer.from(keyText, "base64url"); const actual = await scrypt(password, Buffer.from(saltText, "base64url"), expected.length, { N: Number(n), r: Number(r), p: Number(p), maxmem: 64 * 1024 * 1024, }); return actual.length === expected.length && timingSafeEqual(actual, expected); } // ---------- login throttling (in memory, per client IP) ---------- const failures = new Map(); export function loginLockedFor(ip: string) { const entry = failures.get(ip); return entry && entry.lockedUntil > Date.now() ? Math.ceil((entry.lockedUntil - Date.now()) / 1000) : 0; } export function recordLoginFailure(ip: string) { const entry = failures.get(ip) ?? { count: 0, lockedUntil: 0 }; entry.count += 1; if (entry.count >= 5) entry.lockedUntil = Date.now() + Math.min(15 * 60, 30 * 2 ** (entry.count - 5)) * 1000; failures.set(ip, entry); } export function clearLoginFailures(ip: string) { failures.delete(ip); } // ---------- sessions ---------- function sign(payload: string, secret: string) { return createHmac("sha256", secret).update(payload).digest("base64url"); } export function sessionCookie(secret: string) { const payload = Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + SESSION_SECONDS })).toString("base64url"); const value = `${payload}.${sign(payload, secret)}`; return `${SESSION_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${SESSION_SECONDS}`; } export function clearedSessionCookie() { return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`; } function readCookie(headers: Headers, name: string) { for (const part of (headers.get("cookie") || "").split(";")) { const [key, ...rest] = part.trim().split("="); if (key === name) return rest.join("="); } return ""; } export async function hasValidSession(headers: Headers) { const value = readCookie(headers, SESSION_COOKIE); const [payload, signature] = value.split("."); if (!payload || !signature) return false; const auth = await readAuth(); if (!auth.passwordHash) return false; const expected = Buffer.from(sign(payload, auth.sessionSecret)); const actual = Buffer.from(signature); if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) return false; try { const { exp } = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { exp: number }; return typeof exp === "number" && exp > Date.now() / 1000; } catch { return false; } } /** Sets a new password and rotates the session secret, signing out every other session. */ export async function setPassword(password: string) { const sessionSecret = randomBytes(32).toString("base64url"); await writeAuth({ passwordHash: await hashPassword(password), sessionSecret }); return sessionCookie(sessionSecret); } // ---------- route helpers ---------- export function json(body: unknown, status = 200, extraHeaders: Record = {}) { return Response.json(body, { status, headers: { "Cache-Control": "no-store", ...extraHeaders } }); } /** Returns an error Response when the caller may not use admin APIs, otherwise null. */ export async function requireAdmin(request: Request, { mutating = false } = {}) { if (!isAdminNetworkAllowed(request.headers)) return json({ error: "Admin is only available on the home network." }, 403); if (mutating && !sameOrigin(request)) return json({ error: "Cross-origin request refused." }, 403); if (!(await hasValidSession(request.headers))) return json({ error: "Sign in required." }, 401); return null; } export async function readJsonBody(request: Request): Promise { if (!(request.headers.get("content-type") || "").includes("application/json")) return null; try { return (await request.json()) as T; } catch { return null; } }