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:
parent
c4566b4ea3
commit
72f7d41902
8 changed files with 903 additions and 3 deletions
12
package-lock.json
generated
12
package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
150
scripts/test-listing-roundtrip.mjs
Normal file
150
scripts/test-listing-roundtrip.mjs
Normal 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
85
service/bns.js
Normal 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
131
service/db.js
Normal 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
151
service/lib/sign-message.js
Normal 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
361
service/listings.js
Normal 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);
|
||||
}
|
||||
|
|
@ -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') {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue