// Admin dashboard auth. Mirrors Recap's server/admin-auth.js shape so // the patterns are familiar. Single-user auth (operator only) — verify // scrypt(password, salt) against the stored hash, mint a signed // session cookie on success. Cookie is HMAC-signed with a per-install // session secret so attackers can't forge sessions even with read // access to the relay's /admin endpoints. import { scryptSync, timingSafeEqual, createHmac } from "crypto"; import express from "express"; import { getConfigSnapshot } from "./config.js"; const SCRYPT_KEYLEN = 64; const SESSION_COOKIE = "recap-relay-admin"; const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24h // Path prefix that requires admin auth. Public /relay/* paths are // authenticated per-call by the route handlers, not the cookie. const ADMIN_PREFIX = "/admin"; function constantTimeEqual(a, b) { if (typeof a !== "string" || typeof b !== "string") return false; if (a.length !== b.length) return false; try { return timingSafeEqual(Buffer.from(a), Buffer.from(b)); } catch { return false; } } function signSessionToken(payload, secret) { const body = JSON.stringify(payload); const sig = createHmac("sha256", secret).update(body).digest("hex"); return `${Buffer.from(body).toString("base64url")}.${sig}`; } function verifySessionToken(token, secret) { if (!token) return null; const parts = token.split("."); if (parts.length !== 2) return null; const [b64, sig] = parts; let body; try { body = Buffer.from(b64, "base64url").toString("utf8"); } catch { return null; } const expectedSig = createHmac("sha256", secret).update(body).digest("hex"); if (!constantTimeEqual(sig, expectedSig)) return null; let payload; try { payload = JSON.parse(body); } catch { return null; } if (typeof payload?.exp !== "number" || Date.now() > payload.exp) return null; return payload; } export function setupAdminAuthMiddleware(app) { app.use(async (req, res, next) => { if (!req.path.startsWith(ADMIN_PREFIX)) return next(); // /admin/login is reachable without auth. if (req.path === "/admin/login" || req.path === "/admin/status") return next(); // /admin/btcpay/callback is hit via a POST-redirect from BTCPay // after the operator clicks "Approve" in their authorize page. // The cookie may not flow on cross-site POST (SameSite=Lax), so // we exempt this path and validate via a state token instead — // /admin/btcpay/start stashes a random token in setup-context, // and the callback rejects requests without a matching one. if (req.path === "/admin/btcpay/callback") return next(); const cfg = await getConfigSnapshot(); if (!cfg.relay_admin_password_hash) { // No password set — admin endpoints are disabled entirely. return res.status(401).json({ error: "admin_disabled" }); } const token = req.cookies?.[SESSION_COOKIE]; const payload = verifySessionToken(token, cfg.relay_admin_session_secret); if (!payload) return res.status(401).json({ error: "unauthorized" }); req.adminUser = payload.user; next(); }); } export function setupAdminAuthRoutes(app) { app.get("/admin/status", async (_req, res) => { const cfg = await getConfigSnapshot(); res.json({ enabled: !!cfg.relay_admin_password_hash, username: cfg.relay_admin_username || "admin", }); }); app.post("/admin/login", express.json(), async (req, res) => { const cfg = await getConfigSnapshot(); if (!cfg.relay_admin_password_hash) { return res.status(400).json({ error: "admin_disabled" }); } const { username, password } = req.body || {}; if ( !username || !password || typeof username !== "string" || typeof password !== "string" ) { return res.status(400).json({ error: "missing_credentials" }); } if (username.trim() !== (cfg.relay_admin_username || "admin")) { return res.status(401).json({ error: "invalid_credentials" }); } const hash = scryptSync(password, cfg.relay_admin_password_salt, SCRYPT_KEYLEN).toString("hex"); if (!constantTimeEqual(hash, cfg.relay_admin_password_hash)) { return res.status(401).json({ error: "invalid_credentials" }); } const token = signSessionToken( { user: username, exp: Date.now() + SESSION_TTL_MS }, cfg.relay_admin_session_secret ); res.cookie(SESSION_COOKIE, token, { httpOnly: true, sameSite: "lax", // secure: true would be ideal but the relay runs behind // StartTunnel which terminates TLS — the cookie travels over // plain HTTP inside the tunnel. Leave secure false so the // cookie sticks; the tunnel itself provides the encryption. secure: false, maxAge: SESSION_TTL_MS, }); res.json({ ok: true, username }); }); app.post("/admin/logout", (_req, res) => { res.clearCookie(SESSION_COOKIE); res.json({ ok: true }); }); }