From 72f7d419021d4ee7d085399d385440c137888a90 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Thu, 8 Oct 2026 01:48:12 +0200 Subject: [PATCH] =?UTF-8?q?prometheus:=20signed=20listings=20=E2=80=94=20B?= =?UTF-8?q?IP-137=20publisher=20sign,=20SQLite=20index?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/) — 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// are read-only endpoints the browse milestone will render from. MOCK_BNS_OWNER= 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=, then run the script. Negative paths (fake scan report, bad category, duplicate slug) also verified by hand. --- package-lock.json | 12 +- package.json | 5 +- scripts/test-listing-roundtrip.mjs | 150 ++++++++++++ service/bns.js | 85 +++++++ service/db.js | 131 +++++++++++ service/lib/sign-message.js | 151 ++++++++++++ service/listings.js | 361 +++++++++++++++++++++++++++++ service/server.js | 11 + 8 files changed, 903 insertions(+), 3 deletions(-) create mode 100644 scripts/test-listing-roundtrip.mjs create mode 100644 service/bns.js create mode 100644 service/db.js create mode 100644 service/lib/sign-message.js create mode 100644 service/listings.js diff --git a/package-lock.json b/package-lock.json index c72434a..49eea30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,20 @@ "version": "0.1.0", "license": "MPL-2.0", "dependencies": { + "@bitauth/libauth": "^3.1.0-next.8", "busboy": "^1.6.0" }, "engines": { - "node": ">=20" + "node": ">=22" + } + }, + "node_modules/@bitauth/libauth": { + "version": "3.1.0-next.8", + "resolved": "https://registry.npmjs.org/@bitauth/libauth/-/libauth-3.1.0-next.8.tgz", + "integrity": "sha512-Pm+Ju+YP3JeBLLTiVrBnia2wwE4G17r4XqpvPRMcklElJTe8J6x3JgKRg1by0Xm3ZY6UFxACkEAoSA+x419/zA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, "node_modules/busboy": { diff --git a/package.json b/package.json index 4dd9151..69319d0 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,13 @@ "description": "The service behind prometheus.x", "license": "MPL-2.0", "engines": { - "node": ">=20" + "node": ">=22" }, "scripts": { "start": "node service/server.js" }, "dependencies": { - "busboy": "^1.6.0" + "busboy": "^1.6.0", + "@bitauth/libauth": "^3.1.0-next.8" } } diff --git a/scripts/test-listing-roundtrip.mjs b/scripts/test-listing-roundtrip.mjs new file mode 100644 index 0000000..35f6108 --- /dev/null +++ b/scripts/test-listing-roundtrip.mjs @@ -0,0 +1,150 @@ +#!/usr/bin/env node +/** + * End-to-end roundtrip for the listing endpoint. + * + * Not a CI test — a manual sanity check you can run against a locally running + * service to confirm every link in the chain is wired. It: + * + * 1. generates a test key, derives the cashaddress, + * 2. uploads a small file to /api/upload (requires the server with + * MOCK_SCAN=clean), + * 3. builds a listing body around the scan report, signs the canonical + * message with the test key (BIP-137), + * 4. POSTs it to /api/listing (requires MOCK_BNS_OWNER set to the test + * address so the on-chain-owner check passes without a real chipnet + * lookup), and + * 5. reads /api/listings and prints the row. + * + * Run the service like this: + * MOCK_SCAN=clean MOCK_BNS_OWNER= \ + * node service/server.js + * + * Then from another terminal: + * node scripts/test-listing-roundtrip.mjs + */ + +import { signMessageBip137, addressFromPrivateKey } from '../service/lib/sign-message.js'; + +const BASE = process.env.BASE || 'http://127.0.0.1:3700'; +const PUBLISHER = process.env.PUBLISHER || 'testpub.x'; +const SLUG = process.env.SLUG || `hello-world-${Date.now()}`; +const CATEGORY = process.env.CATEGORY || 'utilities'; + +// A hard-coded deterministic test key — this is NEVER a real wallet key, just +// a predictable 32-byte scalar so the roundtrip script can print the matching +// cashaddress BEFORE anyone sets MOCK_BNS_OWNER, instead of asking you to +// chase your own tail. Override with TEST_PRIVKEY=<64 hex> if a different key +// is useful (e.g. testing two publishers at once). +const DEFAULT_TEST_PRIVKEY_HEX = + '0101010101010101010101010101010101010101010101010101010101010101'; +const PRIVKEY_HEX = process.env.TEST_PRIVKEY || DEFAULT_TEST_PRIVKEY_HEX; +if (!/^[0-9a-f]{64}$/i.test(PRIVKEY_HEX)) { + console.error('TEST_PRIVKEY must be 64 hex chars'); + process.exit(2); +} +const privateKey = Buffer.from(PRIVKEY_HEX, 'hex'); + +const log = (...a) => console.log('[test]', ...a); +const fail = (msg, extra) => { + console.error('[test] FAIL:', msg); + if (extra !== undefined) console.error(extra); + process.exit(1); +}; + +async function main() { + // 1. derive the test address from the deterministic key + const address = addressFromPrivateKey(privateKey); + log(`test address: ${address}`); + log(`→ server must have: MOCK_SCAN=clean MOCK_BNS_OWNER=${address}`); + + // 2. upload + log('uploading a 20-byte sample file …'); + const sample = Buffer.from(`prometheus listing test ${Date.now()}`); + const form = new FormData(); + form.append('file', new Blob([sample], { type: 'text/plain' }), 'sample.txt'); + const upResp = await fetch(`${BASE}/api/upload`, { method: 'POST', body: form }); + const upJson = await upResp.json(); + if (!upResp.ok || !upJson.ok) fail('upload failed', upJson); + log(`upload hash: ${upJson.upload.hash} size: ${upJson.upload.size} verdict: ${upJson.scan.verdict}`); + + // 3. build the listing + const listing = { + publisher: PUBLISHER, + slug: SLUG, + name: 'Hello, world', + description: 'An unremarkable sample listing to prove the publisher-sign path works end-to-end.', + category: CATEGORY, + version: '0.1.0', + homepage: 'https://example.com', + icon: 'https://example.com/icon.png', + download: { + url: 'https://example.com/sample.txt', + sha256: upJson.upload.hash, + size: upJson.upload.size, + }, + scan_report: upJson.report, + }; + + // 4. canonical signing message, same shape the service builds + const message = buildSigningMessage(listing); + const signature = signMessageBip137(message, privateKey); + log(`signed ${message.length} bytes of canonical message`); + + const body = { ...listing, signature }; + + // 5. POST /api/listing + log(`POST ${BASE}/api/listing …`); + const resp = await fetch(`${BASE}/api/listing`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + const json = await resp.json(); + if (!resp.ok || !json.ok) fail(`POST returned ${resp.status}`, json); + log(`✓ listing created: ${json.id}`); + + // 6. read it back via /api/listings/ and /api/listings?category=… + const detail = await (await fetch(`${BASE}/api/listings/${json.id}`)).json(); + if (!detail.ok) fail('GET detail failed', detail); + log(`✓ GET /api/listings/${json.id} returned ${detail.listing.name} v${detail.listing.version}`); + + const index = await (await fetch(`${BASE}/api/listings?category=${CATEGORY}`)).json(); + if (!index.ok) fail('GET index failed', index); + const found = index.listings.find((l) => l.id === json.id); + if (!found) fail(`listing ${json.id} missing from category index`, index); + log(`✓ listing appears in /api/listings?category=${CATEGORY} (${index.count} total in category)`); + + log('all four checks passed.'); +} + +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, + }); + 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(',') + '}'; +} + +main().catch((err) => fail(err?.message || String(err), err?.stack)); diff --git a/service/bns.js b/service/bns.js new file mode 100644 index 0000000..bdf8a86 --- /dev/null +++ b/service/bns.js @@ -0,0 +1,85 @@ +/** + * Looking up a BNS name's current on-chain owner via the Silent Mode gateway. + * + * The listing endpoint uses this to answer "did this publisher really sign + * this?" — recover the signer address from the BIP-137 signature (that's just + * cryptography) and compare it against what the chain says owns the name + * (that's the trust claim). + * + * Memory [[recovery-is-not-verification]] is what makes this a two-step check + * rather than one — recovery always succeeds against *some* key, so we must + * compare the result against a claimed destination. + * + * MOCK_BNS_OWNER= short-circuits the network call for local tests + * where we don't have a chipnet gateway reachable or don't want to depend on + * one for a unit test. + */ + +const DEFAULT_GATEWAY = + (process.env.BNS_NETWORK || 'chipnet').toLowerCase() === 'mainnet' + ? 'https://silentmode.st' + : 'https://navigate.st'; + +const BNS_GATEWAY = process.env.BNS_GATEWAY || DEFAULT_GATEWAY; + +/** + * Fetch a name's record from the gateway. Returns: + * {ok: true, owner, records, registered: true} + * {ok: false, reason} + * + * Caches successful lookups for 60 seconds — ownership rarely changes faster + * than that, and a listing POST should not thrash the gateway on retries. + */ +const cache = new Map(); // name -> {at, result} +const CACHE_TTL_MS = 60_000; + +export async function lookupName(name) { + const mock = process.env.MOCK_BNS_OWNER; + if (mock) { + return { ok: true, owner: mock, records: {}, registered: true, source: 'mock' }; + } + + const key = String(name).toLowerCase(); + const now = Date.now(); + const cached = cache.get(key); + if (cached && now - cached.at < CACHE_TTL_MS) { + return cached.result; + } + + let resp; + try { + resp = await fetch(`${BNS_GATEWAY}/api/name/${encodeURIComponent(key)}`, { + headers: { accept: 'application/json' }, + signal: AbortSignal.timeout(5000), + }); + } catch (err) { + return { ok: false, reason: `gateway unreachable: ${err?.message || err}` }; + } + + if (!resp.ok) { + return { ok: false, reason: `gateway returned HTTP ${resp.status}` }; + } + + let body; + try { + body = await resp.json(); + } catch (err) { + return { ok: false, reason: 'gateway returned non-JSON' }; + } + + if (!body.registered) { + const result = { ok: false, reason: 'name is not registered', body }; + cache.set(key, { at: now, result }); + return result; + } + + const result = { + ok: true, + owner: body.owner, + records: body.records || {}, + registered: true, + source: 'gateway', + }; + cache.set(key, { at: now, result }); + return result; +} diff --git a/service/db.js b/service/db.js new file mode 100644 index 0000000..cb9ddc4 --- /dev/null +++ b/service/db.js @@ -0,0 +1,131 @@ +/** + * The listings database — a SQLite file next to the service. + * + * The service is small enough that one file + prepared statements beats a + * server-side DB, and node:sqlite (Node 22+) keeps that free from a native + * build step. Everything synchronous — fine for the volume we expect at + * launch, and the schema migrates easily to Postgres if volume forces it. + * + * The schema deliberately duplicates the publisher BNS name into the row: + * a listing is identified by `/`, and that composite is + * what browse URLs point at. Changing a publisher name later would mean a + * new row, not an UPDATE — which matches reality (ownership transfers of a + * BNS name do not transfer the apps published under it). + */ + +import { DatabaseSync } from 'node:sqlite'; +import { mkdirSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const DB_PATH = process.env.PROMETHEUS_DB + || resolve(dirname(fileURLToPath(import.meta.url)), '..', 'db.sqlite'); + +mkdirSync(dirname(DB_PATH), { recursive: true }); + +export const db = new DatabaseSync(DB_PATH); + +db.exec(` + PRAGMA journal_mode = WAL; + PRAGMA foreign_keys = ON; + + CREATE TABLE IF NOT EXISTS listings ( + id TEXT PRIMARY KEY, + publisher TEXT NOT NULL, + slug TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL, + category TEXT NOT NULL, + version TEXT NOT NULL, + homepage TEXT, + icon TEXT, + download_url TEXT NOT NULL, + download_sha256 TEXT NOT NULL, + download_size INTEGER NOT NULL, + scan_report TEXT NOT NULL, -- the base64url signed envelope from /api/upload + signer_address TEXT NOT NULL, + signature TEXT NOT NULL, -- the publisher's BIP-137 signature + signed_message TEXT NOT NULL, -- exact bytes the publisher signed + created_at TEXT NOT NULL, + withdrawn_at TEXT, + UNIQUE (publisher, slug) + ); + + CREATE INDEX IF NOT EXISTS idx_listings_category + ON listings(category) WHERE withdrawn_at IS NULL; + CREATE INDEX IF NOT EXISTS idx_listings_publisher + ON listings(publisher); +`); + +/* ----------------------- prepared statements ----------------------- */ + +const stmtInsert = db.prepare(` + INSERT INTO listings ( + id, publisher, slug, name, description, category, version, + homepage, icon, download_url, download_sha256, download_size, + scan_report, signer_address, signature, signed_message, created_at + ) VALUES ( + @id, @publisher, @slug, @name, @description, @category, @version, + @homepage, @icon, @download_url, @download_sha256, @download_size, + @scan_report, @signer_address, @signature, @signed_message, @created_at + ) +`); + +const stmtGet = db.prepare(`SELECT * FROM listings WHERE id = ?`); + +const stmtAllActive = db.prepare(` + SELECT * FROM listings WHERE withdrawn_at IS NULL ORDER BY created_at DESC +`); + +const stmtByCategory = db.prepare(` + SELECT * FROM listings + WHERE category = ? AND withdrawn_at IS NULL + ORDER BY created_at DESC +`); + +const stmtByPublisher = db.prepare(` + SELECT * FROM listings WHERE publisher = ? ORDER BY created_at DESC +`); + +const stmtCountActive = db.prepare(` + SELECT COUNT(*) AS n FROM listings WHERE withdrawn_at IS NULL +`); + +const stmtCountByCategory = db.prepare(` + SELECT category, COUNT(*) AS n FROM listings + WHERE withdrawn_at IS NULL + GROUP BY category +`); + +/* ----------------------- public helpers ----------------------- */ + +export function insertListing(row) { + stmtInsert.run(row); +} + +export function getListing(id) { + return stmtGet.get(id) || null; +} + +export function allActiveListings() { + return stmtAllActive.all(); +} + +export function listingsByCategory(category) { + return stmtByCategory.all(category); +} + +export function listingsByPublisher(publisher) { + return stmtByPublisher.all(publisher); +} + +export function countActive() { + return stmtCountActive.get().n; +} + +export function countByCategory() { + const rows = stmtCountByCategory.all(); + const out = {}; + for (const row of rows) out[row.category] = row.n; + return out; +} diff --git a/service/lib/sign-message.js b/service/lib/sign-message.js new file mode 100644 index 0000000..2257904 --- /dev/null +++ b/service/lib/sign-message.js @@ -0,0 +1,151 @@ +/** + * BIP-137 "Bitcoin Signed Message" verification, adapted from + * Argus/src/lib/sign-message.js so Prometheus does not pull the Argus + * resolver's whole dependency tree in just to verify a signature. + * + * The behaviour is the one every BCH wallet implements (Electron Cash, + * Deviant, Cashonize, Electrum). We verify what real wallets actually + * produce rather than inventing a scheme only we can sign. + * + * The address prefix defaults to "bchtest" (chipnet). Override with + * BNS_NETWORK=mainnet or by passing an explicit `prefix` argument. + */ + +import { + sha256, + hash160, + secp256k1, + utf8ToBin, + binToHex, + base64ToBin, + lockingBytecodeToCashAddress, + cashAddressToLockingBytecode, +} from '@bitauth/libauth'; + +const MAGIC = 'Bitcoin Signed Message:\n'; + +/** The default address prefix for this process. */ +export const DEFAULT_PREFIX = + (process.env.BNS_NETWORK || 'chipnet').toLowerCase() === 'mainnet' + ? 'bitcoincash' + : 'bchtest'; + +/** Bitcoin's length-prefixed string encoding. */ +function varStr(bytes) { + const n = bytes.length; + let prefix; + if (n < 0xfd) prefix = Uint8Array.of(n); + else if (n <= 0xffff) prefix = Uint8Array.of(0xfd, n & 0xff, (n >> 8) & 0xff); + else prefix = Uint8Array.of(0xfe, n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >> 24) & 0xff); + return Uint8Array.from([...prefix, ...bytes]); +} + +/** The digest a wallet actually signs: double-SHA256 over magic || message. */ +export function messageMagicHash(message) { + const payload = Uint8Array.from([ + ...varStr(utf8ToBin(MAGIC)), + ...varStr(utf8ToBin(String(message))), + ]); + return sha256.hash(sha256.hash(payload)); +} + +/** + * Verify a BIP-137 signature and return the address that produced it. + * + * Signatures are 65 bytes base64: [header][r|s]; header encodes recovery id + * and whether the public key was compressed. + * + * @returns {{address: string, publicKey: Uint8Array, compressed: boolean} | null} + */ +export function recoverSigner(message, signatureBase64, prefix = DEFAULT_PREFIX) { + let sig; + try { + sig = base64ToBin(String(signatureBase64).trim()); + } catch { + return null; + } + if (!(sig instanceof Uint8Array) || sig.length !== 65) return null; + + const header = sig[0]; + // 27..30 uncompressed, 31..34 compressed. 39..42 is segwit, not BCH. + if (header < 27 || header > 34) return null; + const compressed = header >= 31; + const recoveryId = (header - 27) & 0b11; + const compact = sig.slice(1); + + const digest = messageMagicHash(message); + const recover = compressed + ? secp256k1.recoverPublicKeyCompressed + : secp256k1.recoverPublicKeyUncompressed; + const publicKey = recover(compact, recoveryId, digest); + if (typeof publicKey === 'string') return null; // libauth returns strings on error + + const r = lockingBytecodeToCashAddress({ + bytecode: Uint8Array.from([0x76, 0xa9, 0x14, ...hash160(publicKey), 0x88, 0xac]), + prefix, + tokenSupport: false, + }); + if (typeof r === 'string') return null; + return { address: r.address, publicKey, compressed }; +} + +/** + * Did the holder of `expectedAddress` sign this message? + * + * Compares locking bytecode rather than address strings, so a token-aware `z…` + * address and its `q…` form are treated as the same key. + * + * This is the function that enforces the rule in + * [[recovery-is-not-verification]]: recovery always succeeds against *some* + * key, so we must compare the result against a claimed destination rather + * than trust the recovered one on its own. + */ +export function verifyControl({ message, signature, expectedAddress, prefix = DEFAULT_PREFIX }) { + const signer = recoverSigner(message, signature, prefix); + if (!signer) return { ok: false, reason: 'signature is malformed or not a Bitcoin message signature' }; + + let expected, actual; + try { + expected = cashAddressToLockingBytecode(expectedAddress); + actual = cashAddressToLockingBytecode(signer.address); + } catch { + return { ok: false, reason: 'address could not be decoded' }; + } + if (typeof expected === 'string' || typeof actual === 'string') { + return { ok: false, reason: 'address could not be decoded' }; + } + if (binToHex(expected.bytecode) !== binToHex(actual.bytecode)) { + return { + ok: false, + reason: 'signed by a different key than the destination address', + signedBy: signer.address, + }; + } + return { ok: true, signedBy: signer.address, publicKey: signer.publicKey }; +} + +/** Sign a message locally — mirror of what a BCH wallet does. */ +export function signMessageBip137(message, privateKey, compressed = true) { + const digest = messageMagicHash(message); + const result = secp256k1.signMessageHashRecoverableCompact(privateKey, digest); + if (typeof result === 'string') throw new Error(`signing failed: ${result}`); + const header = 27 + result.recoveryId + (compressed ? 4 : 0); + const sig = Uint8Array.from([header, ...result.signature]); + return Buffer.from(sig).toString('base64'); +} + +/** + * Derive the compressed-pubkey P2PKH cashaddress from a private key. + * Used by tests and for computing a publisher's address from a seeded key. + */ +export function addressFromPrivateKey(privateKey, prefix = DEFAULT_PREFIX) { + const pub = secp256k1.derivePublicKeyCompressed(privateKey); + if (typeof pub === 'string') throw new Error(`derive failed: ${pub}`); + const r = lockingBytecodeToCashAddress({ + bytecode: Uint8Array.from([0x76, 0xa9, 0x14, ...hash160(pub), 0x88, 0xac]), + prefix, + tokenSupport: false, + }); + if (typeof r === 'string') throw new Error(`encode failed: ${r}`); + return r.address; +} diff --git a/service/listings.js b/service/listings.js new file mode 100644 index 0000000..9e3b767 --- /dev/null +++ b/service/listings.js @@ -0,0 +1,361 @@ +/** + * 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); +} diff --git a/service/server.js b/service/server.js index 7648d0c..4fd1b32 100644 --- a/service/server.js +++ b/service/server.js @@ -21,6 +21,11 @@ import * as pages from './pages.js'; import { handleUpload } from './uploads.js'; import { clamdPing } from './clamd.js'; import { publicKeyBase64, loadServiceKey } from './signing.js'; +import { + handleListingCreate, + handleListingIndex, + handleListingGet, +} from './listings.js'; const PORT = Number(process.env.PORT) || 3700; const BIND = process.env.BIND || '127.0.0.1'; @@ -73,6 +78,12 @@ const server = createServer(async (req, res) => { await serveStatic(url.slice('/static/'.length), res); } else if (url === '/api/upload') { await handleUpload(req, res); + } else if (url === '/api/listing') { + await handleListingCreate(req, res); + } else if (url === '/api/listings') { + handleListingIndex(req, res, req.url); + } else if (url.startsWith('/api/listings/')) { + handleListingGet(req, res, req.url); } else if (url === '/healthz') { await serveHealth(res); } else if (url === '/.well-known/prometheus-service-key.json') {