// Authentication for the admin panel — no external crypto deps, all from node:crypto. // // • Passwords are stored as scrypt hashes ("scrypt$salt$hash", both base64url) in // the ADMIN_PASS_HASH env var. Never a plaintext password anywhere. // • Sessions are stateless, signed cookies: base64url(payload).base64url(hmac). // Tamper-proof (HMAC-SHA256 with SESSION_SECRET) and self-expiring. No server // session store to leak or lose on restart. // • Every compare is constant-time (timingSafeEqual) to avoid timing oracles. import crypto from "node:crypto"; const SCRYPT = { N: 16384, r: 8, p: 1, keylen: 32 }; /** Hash a plaintext password → "scrypt$$" (for storing in .env). */ export function hashPassword(password) { const salt = crypto.randomBytes(16); const hash = crypto.scryptSync(password, salt, SCRYPT.keylen, SCRYPT); return `scrypt$${salt.toString("base64url")}$${hash.toString("base64url")}`; } /** Verify a plaintext password against a stored "scrypt$salt$hash" string. */ export function verifyPassword(password, stored) { if (typeof stored !== "string") return false; const parts = stored.split("$"); if (parts.length !== 3 || parts[0] !== "scrypt") return false; let salt, expected; try { salt = Buffer.from(parts[1], "base64url"); expected = Buffer.from(parts[2], "base64url"); } catch { return false; } if (expected.length !== SCRYPT.keylen) return false; const got = crypto.scryptSync(password, salt, SCRYPT.keylen, SCRYPT); return crypto.timingSafeEqual(got, expected); } /** Constant-time string compare (for the username). */ export function safeEqual(a, b) { const ba = Buffer.from(String(a)); const bb = Buffer.from(String(b)); if (ba.length !== bb.length) return false; return crypto.timingSafeEqual(ba, bb); } // ---- signed session cookies ------------------------------------------------- const COOKIE_NAME = "exd_admin"; function sign(data, secret) { return crypto.createHmac("sha256", secret).update(data).digest("base64url"); } /** Build a signed session token that expires after `ttlMs`. */ export function issueToken(username, secret, ttlMs) { const payload = JSON.stringify({ u: username, exp: Date.now() + ttlMs }); const body = Buffer.from(payload).toString("base64url"); return `${body}.${sign(body, secret)}`; } /** Verify a session token → { u } if valid & unexpired, else null. */ export function verifyToken(token, secret) { if (typeof token !== "string" || !token.includes(".")) return null; const [body, mac] = token.split("."); if (!body || !mac) return null; const expected = sign(body, secret); const a = Buffer.from(mac); const b = Buffer.from(expected); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null; let payload; try { payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8")); } catch { return null; } if (!payload || typeof payload.exp !== "number" || Date.now() > payload.exp) return null; return { u: payload.u }; } /** Parse the admin session cookie out of a raw Cookie header. */ export function readSessionCookie(req) { const raw = req.headers.cookie || ""; for (const part of raw.split(";")) { const i = part.indexOf("="); if (i === -1) continue; if (part.slice(0, i).trim() === COOKIE_NAME) { return decodeURIComponent(part.slice(i + 1).trim()); } } return null; } export function setSessionCookie(res, token, { secure, ttlMs }) { const attrs = [ `${COOKIE_NAME}=${encodeURIComponent(token)}`, "HttpOnly", "SameSite=Strict", "Path=/", `Max-Age=${Math.floor(ttlMs / 1000)}`, ]; if (secure) attrs.push("Secure"); res.append("Set-Cookie", attrs.join("; ")); } export function clearSessionCookie(res, { secure }) { const attrs = [`${COOKIE_NAME}=`, "HttpOnly", "SameSite=Strict", "Path=/", "Max-Age=0"]; if (secure) attrs.push("Secure"); res.append("Set-Cookie", attrs.join("; ")); } /** Express middleware: allow through only requests with a valid session. */ export function requireAuth(secret) { return (req, res, next) => { const tok = readSessionCookie(req); const sess = tok && verifyToken(tok, secret); if (!sess) { // req.path is stripped of the mount prefix, so match on originalUrl const url = req.originalUrl || req.url || ""; if (url.startsWith("/admin/api") || url.endsWith(".csv")) { return res.status(401).json({ error: "unauthorized" }); } return res.redirect("/admin/login"); } req.admin = sess; next(); }; } export { COOKIE_NAME };