prometheus/service/admin-session.js

210 lines
7.1 KiB
JavaScript
Raw Normal View History

prometheus: admin panel — login, listings, 3-mode toggle, audit log /admin is wallet-gated. The configured ADMIN_PUBLISHER is a BNS name; only the on-chain owner of that name can sign in. The sign-in flow is BIP-137 (same primitive publishers use): the login page shows a fresh one-shot challenge, the admin signs it in their wallet, pastes the signature, and the service verifies that it recovers to the current on-chain owner. Session is a 24-hour ed25519-signed cookie minted by the service key. Pages: /admin dashboard — totals, mode state, recent listings + audit /admin/login BIP-137 challenge sign-in /admin/listings all listings, with a Withdraw button per row /admin/modes global + per-category publish-mode toggle /admin/audit the hash-chained audit log The three publish modes exist as toggles: `append` — any valid listing goes in (launch default) `scan-first` — infected uploads are rejected before they land `review` — clean listings wait for human approval Global mode is the fallback; a per-category override takes precedence when set. The toggle persists; the review-queue enforcement lands in a later milestone (the enforcement sketch is in modes.js's header). Audit log: Every admin action (login, set_mode, clear_mode, withdraw_listing) is an append-only row with a sha256 chain over the previous row's hash. The chain does not prevent edits; it makes retroactive ones loud — row N's `prev_hash` disagreeing with row N-1's `this_hash` is the tamper signal. Withdrawal: Soft — `withdrawn_at` on the listing row. Browse hides withdrawn listings (already the case) and the detail page returns 404 for them. Nothing in the chain is undone; this is Prometheus's own view only. Session design: Cookie is `prom_admin=<payload b64url>.<signature b64url>`, signed with the ed25519 service key, HttpOnly, SameSite=Strict, Path=/admin, 24-hour expiry. Reconfiguring ADMIN_PUBLISHER invalidates in-flight sessions by name mismatch. Challenges live in a 10-min TTL map and are tied to a specific name so one admin's challenge cannot authenticate another. Added: service/admin.js route handlers + dispatch service/admin-pages.js HTML templates for the four pages service/admin-session.js challenge + cookie mint/verify + readSession service/audit.js hash-chained append helper service/modes.js publish-mode read/write with override semantics schema: settings + audit tables scripts/test-admin-roundtrip.mjs drives the whole flow (unauth redirect → challenge scrape → sign → login → dashboard → set mode → audit inspection) with the same deterministic test key the listing-roundtrip script uses. Withdraw-flow spot-check: create a listing, log in as admin, withdraw it, confirm browse hides it and the detail page returns 404. All seven checks pass.
2026-10-08 21:35:48 +02:00
/**
* 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(',') + '}';
}