// Optional admin PIN protection for mutating API routes. Off until a PIN is set. const crypto = require("crypto"); const fs = require("fs"); const path = require("path"); const COOKIE_NAME = "rc_auth"; const TOKEN_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; const MIN_PIN_LENGTH = 4; const LOGIN_WINDOW_MS = 5 * 60 * 1000; const LOGIN_MAX_FAILURES = 10; function hashPin(pin, salt) { return crypto.scryptSync(String(pin), salt, 32).toString("hex"); } function parseCookies(header) { const cookies = {}; String(header || "").split(";").forEach((part) => { const index = part.indexOf("="); if (index > 0) cookies[part.slice(0, index).trim()] = decodeURIComponent(part.slice(index + 1).trim()); }); return cookies; } function normalizeIp(ip) { const value = String(ip || "").trim(); return value.startsWith("::ffff:") ? value.slice(7) : value; } function ipv4ToInt(ip) { 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) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]; } // Comma-separated IPv4 CIDRs or exact addresses (IPv4 or IPv6). Empty = allow everyone. function parseAllowedRanges(value) { return String(value || "").split(",").map((entry) => entry.trim()).filter(Boolean).map((entry) => { const [base, bits] = entry.split("/"); const baseInt = ipv4ToInt(base); if (baseInt === null || bits === undefined) return { exact: normalizeIp(entry) }; const prefix = Math.max(0, Math.min(32, Number(bits))); const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0; return { network: (baseInt & mask) >>> 0, mask }; }); } function isIpAllowed(ip, ranges) { if (!ranges.length) return true; const normalized = normalizeIp(ip); const asInt = ipv4ToInt(normalized); return ranges.some((range) => (range.exact !== undefined ? range.exact === normalized : asInt !== null && ((asInt & range.mask) >>> 0) === range.network)); } function createAuth({ dataDir, allowedCidrs = "", now = () => Date.now() }) { // Kept out of the app_state blob on purpose, so a PIN hash can never leak through // GET /api/state, a JSON export or a backup restore. const configPath = path.join(dataDir, "auth.json"); const secretPath = path.join(dataDir, "auth_secret"); const ranges = parseAllowedRanges(allowedCidrs); const loginFailures = new Map(); function readConfig() { try { return JSON.parse(fs.readFileSync(configPath, "utf8")); } catch { return null; } } function writeConfig(config) { fs.mkdirSync(dataDir, { recursive: true }); if (config) fs.writeFileSync(configPath, JSON.stringify(config), { mode: 0o600 }); else fs.rmSync(configPath, { force: true }); } function getSecret() { try { return fs.readFileSync(secretPath, "utf8").trim(); } catch {} const secret = crypto.randomBytes(32).toString("hex"); fs.mkdirSync(dataDir, { recursive: true }); fs.writeFileSync(secretPath, secret, { mode: 0o600 }); return secret; } function sign(payload) { return crypto.createHmac("sha256", getSecret()).update(payload).digest("hex"); } function isEnabled() { return Boolean(readConfig()?.pinHash); } function verifyPin(pin) { const config = readConfig(); if (!config?.pinHash) return false; const expected = Buffer.from(config.pinHash, "hex"); const actual = Buffer.from(hashPin(pin, config.salt), "hex"); return expected.length === actual.length && crypto.timingSafeEqual(expected, actual); } function setPin(newPin) { const pin = String(newPin || ""); if (pin.length < MIN_PIN_LENGTH) throw new Error(`PIN must be at least ${MIN_PIN_LENGTH} characters`); const salt = crypto.randomBytes(16).toString("hex"); // A fresh pinVersion invalidates every token issued under the previous PIN. writeConfig({ salt, pinHash: hashPin(pin, salt), pinVersion: crypto.randomBytes(8).toString("hex"), updatedAt: new Date(now()).toISOString() }); } function disable() { writeConfig(null); } function issueToken() { const issuedAt = String(now()); return `${issuedAt}.${sign(`${issuedAt}.${readConfig()?.pinVersion || ""}`)}`; } function verifyToken(token) { const config = readConfig(); const [issuedAt, signature] = String(token || "").split("."); // Strict format check: Buffer.from(hex) silently drops a trailing odd character. if (!config?.pinVersion || !/^\d+$/.test(issuedAt || "") || !/^[0-9a-f]{64}$/.test(signature || "")) return false; if (now() - Number(issuedAt) > TOKEN_MAX_AGE_MS) return false; const expected = Buffer.from(sign(`${issuedAt}.${config.pinVersion}`), "hex"); const actual = Buffer.from(signature, "hex"); return expected.length === actual.length && crypto.timingSafeEqual(expected, actual); } function isAuthenticated(req) { return verifyToken(parseCookies(req.headers?.cookie)[COOKIE_NAME]); } // PINs are short, so failed logins are throttled per client address. function recentFailures(ip) { const key = normalizeIp(ip); const list = (loginFailures.get(key) || []).filter((at) => now() - at < LOGIN_WINDOW_MS); loginFailures.set(key, list); return list; } function registerLoginFailure(ip) { recentFailures(ip).push(now()); } function isLoginBlocked(ip) { return recentFailures(ip).length >= LOGIN_MAX_FAILURES; } // Writes plus the AMMC config read (it exposes the executable path) are protected; // other reads stay open because the public overlay hydrates through GET /api/state. function isProtectedRoute(req) { const route = String(req.path || ""); if (!route.startsWith("/api/") || route === "/api/auth/login" || route === "/api/auth/status") return false; if (!["GET", "HEAD", "OPTIONS"].includes(req.method)) return true; return route === "/api/ammc/config"; } function middleware(req, res, next) { if (!isProtectedRoute(req)) return next(); if (!isIpAllowed(req.ip, ranges)) return res.status(403).json({ error: "ip_not_allowed" }); if (isEnabled() && !isAuthenticated(req)) return res.status(401).json({ error: "auth_required" }); return next(); } return { isEnabled, verifyPin, setPin, disable, issueToken, verifyToken, isAuthenticated, registerLoginFailure, isLoginBlocked, isProtectedRoute, middleware, isIpAllowed: (ip) => isIpAllowed(ip, ranges) }; } module.exports = { createAuth, parseCookies, isIpAllowed, parseAllowedRanges, COOKIE_NAME, TOKEN_MAX_AGE_MS, MIN_PIN_LENGTH };