prometheus/service/listings.js
Local Dev 72f7d41902 prometheus: signed listings — BIP-137 publisher sign, SQLite index
POST /api/listing accepts a JSON body with the manifest fields, a
reference to a signed scan report from /api/upload, and a BIP-137
signature. Before the row lands in SQLite the service verifies three
things:

  1. the attached scan_report was signed by this service, is for the
     exact (hash, size) the listing claims, and the verdict is clean —
     a publisher cannot swap in a different file's clean report;

  2. the publisher's signature recovers to a valid cashaddress, and
     that address matches the on-chain owner of the publisher's BNS
     name (gateway /api/name/<name>) — recovery alone is never enough
     per [[recovery-is-not-verification]];

  3. the usual shape checks: category in the allow-list, sha256 is 64
     hex, download URL is http(s), slug matches [a-z0-9-]{1..64}, body
     under 1 MiB.

GET /api/listings and GET /api/listings/<publisher>/<slug> are read-only
endpoints the browse milestone will render from.

MOCK_BNS_OWNER=<cashaddr> short-circuits the gateway call for local
testing without a chipnet gateway reachable — same pattern as MOCK_SCAN.

Dependencies:
- @bitauth/libauth, for the secp256k1 primitives behind BIP-137 recovery
  and the cashaddress encoder. Same primitive Argus's registrar uses, so
  signatures from Argus-compatible wallets verify unchanged.
- node:sqlite (ships with Node 22+) for the listings DB — one file next
  to the service, WAL journaling, no native build step. engines bumped
  to ">=22".

Lifted service/lib/sign-message.js from Argus so Prometheus does not
have to pull the resolver's whole dependency tree in for one verifier.
Removed its NETWORK import; the default address prefix comes from
BNS_NETWORK=chipnet|mainnet instead.

INTERIM: no APS1 record on chain yet. The signed row IS the listing;
when APS1 is defined cross-session the service will add an on-chain
publish step that points at the same signed manifest. The browse and
admin milestones can proceed against this DB in the meantime.

scripts/test-listing-roundtrip.mjs drives the whole flow end-to-end
with a deterministic test key, so a sanity check is a two-command
sequence: start the server with MOCK_SCAN=clean + MOCK_BNS_OWNER=<the
test address>, then run the script. Negative paths (fake scan report,
bad category, duplicate slug) also verified by hand.
2026-10-08 01:48:12 +02:00

361 lines
13 KiB
JavaScript

/**
* POST /api/listing — accept a signed listing from a publisher.
* GET /api/listings — the public index, optionally filtered by category.
* GET /api/listings/<publisher>/<slug> — 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/<publisher>/<slug>
====================================================================== */
export function handleListingGet(req, res, url) {
const u = new URL(url, 'http://localhost');
const parts = u.pathname.split('/').filter(Boolean);
// ['api', 'listings', '<publisher>', '<slug>']
if (parts.length !== 4) {
return json(res, 400, { ok: false, reason: 'expected /api/listings/<publisher>/<slug>' });
}
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);
}