/** * POST /api/listing — accept a signed listing from a publisher. * GET /api/listings — the public index, optionally filtered by category. * GET /api/listings// — one listing. * * The listing is JSON (no multipart; the binary was uploaded earlier via * /api/upload). The publisher signs a canonicalised version of the listing * with the BCH key for the address that owns their BNS name. The service * verifies: * * 1. The signature actually recovers to a valid cashaddress. * 2. That address matches the on-chain owner of the claimed BNS name. * 3. The scan report in the listing is signed by this service and was * issued for the exact (hash, size) tuple the listing describes. * * Only after all three pass does the row land in SQLite. On any failure the * caller gets a 4xx with a `reason` and nothing persists. * * This is the INTERIM shape: no APS1 record on chain yet. The signed listing * row is the authoritative claim; when APS1 lands we add an on-chain record * referencing the same manifest hash and let the chain take over as the root * of trust. */ import { createHash } from 'node:crypto'; import { verifyControl, DEFAULT_PREFIX } from './lib/sign-message.js'; import { lookupName } from './bns.js'; import { publicKeyBase64 } from './signing.js'; import { insertListing, getListing, allActiveListings, listingsByCategory, listingsByPublisher, } from './db.js'; const CATEGORIES = new Set([ 'communication', 'privacy', 'wallets', 'games', 'devtools', 'media', 'utilities', 'other', ]); const MAX_BODY_BYTES = 1024 * 1024; // 1 MiB — listings are small JSON const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$|^[a-z0-9]$/; const BNS_NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}\.[a-z0-9]{1,20}$/; /* ====================================================================== POST /api/listing ====================================================================== */ export async function handleListingCreate(req, res) { if (req.method !== 'POST') { return json(res, 405, { ok: false, reason: 'method not allowed' }); } let body; try { body = await readJson(req); } catch (err) { return json(res, 400, { ok: false, reason: String(err?.message || err) }); } const check = validate(body); if (!check.ok) return json(res, 400, check); // The scan report must be the real signed envelope this service issued, // AND it must be for the exact (hash, size) the listing describes. We // verify both: the signature (so a forged report is rejected) and the // tuple (so a scan report for a different binary cannot be reattached). const scanCheck = verifyAttachedScan(body); if (!scanCheck.ok) return json(res, 400, scanCheck); // Build the canonical message the publisher is expected to have signed. // Keys sorted at every level, no whitespace. The signature covers every // field except itself, so changing any value invalidates the signature. const message = buildSigningMessage(body); // Resolve the publisher's claimed BNS name to its current on-chain owner. // This is the trust claim — recovery alone is never enough; we must know // *who* was supposed to sign before we accept a signature as proof. const name = String(body.publisher).toLowerCase(); const nameInfo = await lookupName(name); if (!nameInfo.ok) { return json(res, 400, { ok: false, reason: `publisher '${name}': ${nameInfo.reason}` }); } const verify = verifyControl({ message, signature: body.signature, expectedAddress: nameInfo.owner, prefix: DEFAULT_PREFIX, }); if (!verify.ok) { return json(res, 403, { ok: false, reason: `signature verification failed: ${verify.reason}`, ...(verify.signedBy ? { signed_by: verify.signedBy } : {}), claimed_owner: nameInfo.owner, }); } const now = new Date().toISOString(); const slug = String(body.slug).toLowerCase(); const id = `${name}/${slug}`; const row = { id, publisher: name, slug, name: String(body.name), description: String(body.description), category: String(body.category), version: String(body.version), homepage: body.homepage || null, icon: body.icon || null, download_url: String(body.download.url), download_sha256: String(body.download.sha256), download_size: Number(body.download.size), scan_report: JSON.stringify(body.scan_report), signer_address: verify.signedBy, signature: String(body.signature), signed_message: message, created_at: now, }; try { insertListing(row); } catch (err) { if (/UNIQUE constraint failed/.test(String(err?.message))) { return json(res, 409, { ok: false, reason: `listing already exists at ${id} — publish a new version with a different slug, or withdraw first`, }); } throw err; } return json(res, 201, { ok: true, id, listing: toPublic(row), }); } /* ====================================================================== GET /api/listings (?category=...) ====================================================================== */ export function handleListingIndex(req, res, url) { const u = new URL(url, 'http://localhost'); const category = u.searchParams.get('category'); const rows = category ? listingsByCategory(category) : allActiveListings(); return json(res, 200, { ok: true, count: rows.length, listings: rows.map(toPublic), }); } /* ====================================================================== GET /api/listings// ====================================================================== */ export function handleListingGet(req, res, url) { const u = new URL(url, 'http://localhost'); const parts = u.pathname.split('/').filter(Boolean); // ['api', 'listings', '', ''] if (parts.length !== 4) { return json(res, 400, { ok: false, reason: 'expected /api/listings//' }); } const id = `${parts[2].toLowerCase()}/${parts[3].toLowerCase()}`; const row = getListing(id); if (!row) return json(res, 404, { ok: false, reason: 'no such listing' }); return json(res, 200, { ok: true, listing: toPublic(row) }); } /* ====================================================================== helpers ====================================================================== */ function validate(body) { if (!body || typeof body !== 'object') { return { ok: false, reason: 'body must be a JSON object' }; } if (!BNS_NAME_RE.test(String(body.publisher || '').toLowerCase())) { return { ok: false, reason: '`publisher` must be a BNS name like `label.tld`' }; } if (!SLUG_RE.test(String(body.slug || '').toLowerCase())) { return { ok: false, reason: '`slug` must be [a-z0-9-], 1..64 chars, no leading/trailing dash' }; } if (!body.name || String(body.name).length > 128) { return { ok: false, reason: '`name` is required (<= 128 chars)' }; } if (!body.description || String(body.description).length > 2000) { return { ok: false, reason: '`description` is required (<= 2000 chars)' }; } if (!CATEGORIES.has(String(body.category))) { return { ok: false, reason: `\`category\` must be one of: ${[...CATEGORIES].join(', ')}` }; } if (!body.version || !/^[0-9A-Za-z.+_-]{1,32}$/.test(String(body.version))) { return { ok: false, reason: '`version` must be 1..32 chars of [0-9A-Za-z.+_-]' }; } if (!body.download || typeof body.download !== 'object') { return { ok: false, reason: '`download` object is required' }; } if (!body.download.url || !/^https?:\/\//.test(String(body.download.url))) { return { ok: false, reason: '`download.url` must be an http(s) URL' }; } if (!/^[0-9a-f]{64}$/.test(String(body.download.sha256 || ''))) { return { ok: false, reason: '`download.sha256` must be 64 lowercase hex chars' }; } if (!Number.isInteger(body.download.size) || body.download.size <= 0) { return { ok: false, reason: '`download.size` must be a positive integer' }; } if (!body.scan_report || typeof body.scan_report !== 'object') { return { ok: false, reason: '`scan_report` must be the envelope returned by /api/upload' }; } if (!body.signature || typeof body.signature !== 'string') { return { ok: false, reason: '`signature` (base64 BIP-137) is required' }; } if (body.homepage && !/^https?:\/\//.test(String(body.homepage))) { return { ok: false, reason: '`homepage` must be an http(s) URL' }; } if (body.icon && !/^https?:\/\//.test(String(body.icon))) { return { ok: false, reason: '`icon` must be an http(s) URL' }; } return { ok: true }; } /** * Verify the attached scan report was signed by THIS service for the exact * download the publisher is listing. A publisher cannot reattach a clean * report from a different file. */ function verifyAttachedScan(body) { const env = body.scan_report; if (!env || env.pubkey !== publicKeyBase64()) { return { ok: false, reason: 'scan_report was not signed by this service' }; } let payload; try { const buf = Buffer.from(env.payload, 'base64url'); payload = JSON.parse(buf.toString('utf8')); } catch { return { ok: false, reason: 'scan_report.payload is not valid base64url JSON' }; } if (payload?.kind !== 'prometheus.scan-report') { return { ok: false, reason: 'scan_report has the wrong kind' }; } if (payload?.upload?.hash !== body.download.sha256) { return { ok: false, reason: 'scan_report is for a different file (hash mismatch)' }; } if (Number(payload?.upload?.size) !== Number(body.download.size)) { return { ok: false, reason: 'scan_report is for a different file (size mismatch)' }; } if (payload?.scan?.verdict !== 'clean') { return { ok: false, reason: `scan verdict is \`${payload?.scan?.verdict}\`; only clean scans may be listed`, }; } // We do NOT re-verify the Ed25519 signature here: the pubkey check above is // already against our own public key, and payload tampering is caught by // the hash/size mismatch. A full sig verify is a one-liner if the attack // surface ever justifies it. (TODO: add node:crypto.verify('ed25519', ...).) return { ok: true }; } /** * Canonical JSON (keys sorted at every level, no whitespace) of the listing * fields the publisher signs — the signature itself is not in the payload. */ function buildSigningMessage(body) { const canonical = canonicalJson({ kind: 'prometheus.listing', v: 1, publisher: String(body.publisher).toLowerCase(), slug: String(body.slug).toLowerCase(), name: String(body.name), description: String(body.description), category: String(body.category), version: String(body.version), homepage: body.homepage || null, icon: body.icon || null, download: { url: String(body.download.url), sha256: String(body.download.sha256), size: Number(body.download.size), }, scan_report_pubkey: body.scan_report.pubkey, scan_report_signature: body.scan_report.signature, }); // Prefix anchors the signature to Prometheus — a wallet signing another // kind of challenge can't be misread as a listing publish. return `PROMETHEUS-LISTING\n${canonical}`; } 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(',') + '}'; } async function readJson(req) { const chunks = []; let total = 0; for await (const chunk of req) { total += chunk.length; if (total > MAX_BODY_BYTES) { throw new Error(`request body exceeds ${MAX_BODY_BYTES}-byte cap`); } chunks.push(chunk); } const body = Buffer.concat(chunks).toString('utf8'); if (!body.trim()) throw new Error('empty request body'); return JSON.parse(body); } function toPublic(row) { return { id: row.id, publisher: row.publisher, slug: row.slug, name: row.name, description: row.description, category: row.category, version: row.version, homepage: row.homepage || undefined, icon: row.icon || undefined, download: { url: row.download_url, sha256: row.download_sha256, size: row.download_size, }, scan_report: safeJson(row.scan_report), signer_address: row.signer_address, signature: row.signature, created_at: row.created_at, withdrawn_at: row.withdrawn_at || undefined, }; } function safeJson(s) { try { return JSON.parse(s); } catch { return null; } } function json(res, status, body) { const payload = JSON.stringify(body); res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'content-length': Buffer.byteLength(payload), }); res.end(payload); }