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