/** * Admin sign-in via BIP-137, plus the cookie-based session the admin pages * verify on every request. * * Flow: * 1. GET /admin/login → the login page renders a fresh challenge string * (random 24 bytes base64url). The challenge is stored in memory with a * 10-minute TTL and the admin's BNS name baked in — tying it to one name * stops a challenge from being used to log in as somebody else later. * 2. The admin signs the challenge with the key for the address that owns * the admin BNS name, pastes the signature, and POSTs. * 3. The server recovers the signer, looks up the current on-chain owner of * the admin name, and compares (per [[recovery-is-not-verification]]). On * match, we mint a signed session cookie — payload has {name, address, * exp}, signed with the service's ed25519 key. * 4. Every /admin/* page reads the cookie, verifies it, and gates on the * expiration + the name matching the configured admin. * * No cross-request state apart from the challenge map, which is tiny and * single-process — fine for the operator-only admin. */ import { randomBytes, sign as nodeSign, verify as nodeVerify, createPublicKey, createPrivateKey } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { verifyControl, DEFAULT_PREFIX } from './lib/sign-message.js'; import { lookupName } from './bns.js'; const CHALLENGE_TTL_MS = 10 * 60 * 1000; // 10 min const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 h const COOKIE_NAME = 'prom_admin'; // The one admin, by BNS name. The on-chain owner of this name is who can log in. const ADMIN_PUBLISHER = (process.env.ADMIN_PUBLISHER || '').toLowerCase(); /* ------------------------------- key ------------------------------- */ const KEY_DIR = process.env.PROMETHEUS_KEY_DIR || resolve(dirname(fileURLToPath(import.meta.url)), '..', '.keys'); const KEY_PATH = resolve(KEY_DIR, 'service.key'); let cachedPrivate = null; let cachedPublic = null; function key() { if (cachedPrivate) return { priv: cachedPrivate, pub: cachedPublic }; const pem = readFileSync(KEY_PATH, 'utf8'); cachedPrivate = createPrivateKey(pem); cachedPublic = createPublicKey(cachedPrivate); return { priv: cachedPrivate, pub: cachedPublic }; } /* ------------------------------- challenges ------------------------------- */ /** `{challenge: {created_at, name}}` — one-shot, 10 min TTL. */ const challenges = new Map(); /** Return a fresh challenge for `name`. Pulls old entries while we're here. */ export function newChallenge(name) { const challenge = randomBytes(24).toString('base64url'); challenges.set(challenge, { created_at: Date.now(), name }); pruneChallenges(); return challenge; } function pruneChallenges() { const cutoff = Date.now() - CHALLENGE_TTL_MS; for (const [k, v] of challenges) { if (v.created_at < cutoff) challenges.delete(k); } } /* ------------------------------- login verify ------------------------------- */ /** * Verify a signed login challenge and, on success, return the session cookie * string to set. * * @returns {{ok: true, cookie: string, expiresAt: number, admin: {name, address}} * | {ok: false, reason: string}} */ export async function verifyLogin({ challenge, signature }) { if (!ADMIN_PUBLISHER) { return { ok: false, reason: 'admin is not configured (set ADMIN_PUBLISHER)' }; } const slot = challenges.get(challenge); if (!slot) return { ok: false, reason: 'challenge expired or unknown' }; challenges.delete(challenge); // one-shot if (Date.now() - slot.created_at > CHALLENGE_TTL_MS) { return { ok: false, reason: 'challenge expired' }; } if (slot.name !== ADMIN_PUBLISHER) { return { ok: false, reason: 'challenge was minted for a different name' }; } const nameInfo = await lookupName(ADMIN_PUBLISHER); if (!nameInfo.ok) { return { ok: false, reason: `could not resolve admin name: ${nameInfo.reason}` }; } const message = loginMessage(challenge, ADMIN_PUBLISHER); const verify = verifyControl({ message, signature, expectedAddress: nameInfo.owner, prefix: DEFAULT_PREFIX, }); if (!verify.ok) { return { ok: false, reason: verify.reason }; } const expiresAt = Date.now() + SESSION_TTL_MS; const cookie = mintCookie({ name: ADMIN_PUBLISHER, address: nameInfo.owner, exp: expiresAt, }); return { ok: true, cookie, expiresAt, admin: { name: ADMIN_PUBLISHER, address: nameInfo.owner } }; } /** * The exact bytes the admin signs. Includes the admin name and the challenge * so a signature valid in one admin's context never transfers to another. */ export function loginMessage(challenge, name) { return `PROMETHEUS-ADMIN-LOGIN\nname=${name}\nchallenge=${challenge}`; } /* ------------------------------- cookie mint + verify ------------------------------- */ function mintCookie(payload) { const canonical = canonicalJson(payload); const sig = nodeSign(null, Buffer.from(canonical, 'utf8'), key().priv); const token = Buffer.from(canonical, 'utf8').toString('base64url') + '.' + sig.toString('base64url'); const maxAge = Math.max(1, Math.floor((payload.exp - Date.now()) / 1000)); return `${COOKIE_NAME}=${token}; Path=/admin; Max-Age=${maxAge}; HttpOnly; SameSite=Strict`; } export function clearCookie() { return `${COOKIE_NAME}=; Path=/admin; Max-Age=0; HttpOnly; SameSite=Strict`; } /** * Verify the admin cookie on a request. Returns the admin payload or null. */ export function readSession(req) { const header = req.headers['cookie']; if (!header) return null; const token = pickCookie(header, COOKIE_NAME); if (!token) return null; const dot = token.indexOf('.'); if (dot < 0) return null; const payloadB64 = token.slice(0, dot); const sigB64 = token.slice(dot + 1); let canonical; try { canonical = Buffer.from(payloadB64, 'base64url').toString('utf8'); } catch { return null; } let sig; try { sig = Buffer.from(sigB64, 'base64url'); } catch { return null; } const ok = nodeVerify(null, Buffer.from(canonical, 'utf8'), key().pub, sig); if (!ok) return null; let payload; try { payload = JSON.parse(canonical); } catch { return null; } if (!payload || typeof payload !== 'object') return null; if (!payload.exp || Date.now() > Number(payload.exp)) return null; if (payload.name !== ADMIN_PUBLISHER) return null; // reconfig invalidates sessions return payload; } export function adminPublisher() { return ADMIN_PUBLISHER; } /* ------------------------------- helpers ------------------------------- */ function pickCookie(header, name) { const parts = String(header).split(/;\s*/); for (const p of parts) { const eq = p.indexOf('='); if (eq < 0) continue; if (p.slice(0, eq) === name) return p.slice(eq + 1); } return null; } function canonicalJson(value) { if (value === null || typeof value !== 'object') return JSON.stringify(value); if (Array.isArray(value)) return '[' + value.map(canonicalJson).join(',') + ']'; const keys = Object.keys(value).sort(); return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(value[k])).join(',') + '}'; }