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.
This commit is contained in:
Local Dev 2026-10-08 01:48:12 +02:00
parent c4566b4ea3
commit 72f7d41902
8 changed files with 903 additions and 3 deletions

12
package-lock.json generated
View file

@ -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": {

View file

@ -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"
}
}

View file

@ -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=<the address this script prints> \
* 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/<id> 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));

85
service/bns.js Normal file
View file

@ -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=<cashaddr> 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;
}

131
service/db.js Normal file
View file

@ -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 `<publisher>/<slug>`, 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;
}

151
service/lib/sign-message.js Normal file
View file

@ -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;
}

361
service/listings.js Normal file
View file

@ -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/<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);
}

View file

@ -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') {