/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.
209 lines
7.1 KiB
JavaScript
209 lines
7.1 KiB
JavaScript
/**
|
|
* 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(',') + '}';
|
|
}
|