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

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

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

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

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

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

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

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

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

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

151 lines
5.4 KiB
JavaScript

/**
* 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;
}