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.
151 lines
5.4 KiB
JavaScript
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;
|
|
}
|