// HD key tree for the wallet. Root = 32 bytes from api.vault.derive treated as // a BIP32 master seed; account = m/44'/145'/0' (BCH, SLIP-44). Branch 0 is // receive, branch 1 is change. Private keys never leave this module except // through sign() / signRecoverable() for a specific entry. module.exports = function makeKeys({ HDKey, secp256k1, sha256, ripemd160, cashaddr }) { const hash160 = (b) => ripemd160(sha256(b)); const p2pkhScript = (h160) => Uint8Array.from([0x76, 0xa9, 0x14, ...h160, 0x88, 0xac]); const p2shScript = (h160) => Uint8Array.from([0xa9, 0x14, ...h160, 0x87]); const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); // electrum scripthash: sha256(script), byte-reversed, hex. const scripthash = (script) => toHex(sha256(script).slice().reverse()); class WalletKeys { constructor(root32, accountPath, prefix) { this.prefix = prefix; this.accountPath = accountPath; this._account = HDKey.fromMasterSeed(root32).derive(accountPath); this._branch = [this._account.deriveChild(0), this._account.deriveChild(1)]; this._cache = new Map(); // "branch/index" -> entry } get xpub() { return this._account.publicExtendedKey; } // Revealed only on explicit user action in Settings (show recovery info). get xprv() { return this._account.privateExtendedKey; } entry(branch, index) { const k = branch + "/" + index; let e = this._cache.get(k); if (!e) { const node = this._branch[branch].deriveChild(index); const h160 = hash160(node.publicKey); const script = p2pkhScript(h160); e = { branch, index, path: this.accountPath + "/" + branch + "/" + index, publicKey: node.publicKey, h160, script, scriptHex: toHex(script), scripthash: scripthash(script), address: cashaddr.encode(this.prefix, 0, h160), _node: node, }; this._cache.set(k, e); } return e; } findByScriptHex(scriptHex) { for (const e of this._cache.values()) if (e.scriptHex === scriptHex) return e; return null; } // ECDSA over a 32-byte digest, DER-encoded, low-S (BCH consensus rule). sign(entry, digest32) { return secp256k1.sign(digest32, entry._node.privateKey, { prehash: false, lowS: true, format: "der" }); } // 65-byte BIP-137 signature: [27 + recid + 4 (compressed)] || r || s. signRecoverable(entry, digest32) { const sig = secp256k1.sign(digest32, entry._node.privateKey, { prehash: false, lowS: true, format: "recovered" }); const out = new Uint8Array(65); out[0] = 27 + sig[0] + 4; out.set(sig.subarray(1), 1); return out; } wipe() { for (const e of this._cache.values()) { try { e._node.wipePrivateData(); } catch {} } this._cache.clear(); for (const b of this._branch) { try { b.wipePrivateData(); } catch {} } try { this._account.wipePrivateData(); } catch {} } } // BIP-137 verification. Given a message, a 65-byte recoverable signature // (base64, produced by signRecoverable above or Electron Cash / any other // BCH tool), and a CashAddr, recover the signer's pubkey, hash it to the // address's h160, and compare. Returns { valid, address, recoveredHash }. // Deliberately pure (no wallet state) so a panel can verify a sig pasted // from anywhere without touching the vault. function verifyMessage(message, base64Signature, address, { cashaddr: caLib, secp256k1: sec }) { const dec = (b64) => { const bin = typeof atob === "function" ? atob(b64) : Buffer.from(b64, "base64").toString("binary"); const u = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i); return u; }; const sig = dec(String(base64Signature || "").trim()); if (sig.length !== 65) throw new Error(`signature must be 65 bytes (got ${sig.length})`); const header = sig[0]; // BIP-137 header layout: 27 + recid + 4 (compressed). 0..3 → uncompressed, // 4..7 → uncompressed P2SH-P2WPKH, 8..11 → uncompressed native-segwit, // 12..15 → compressed. Every P2PKH BCH signer we care about uses the // 31..34 range (27 + recid + 4). Anything outside 27..34 is rejected. if (header < 27 || header > 34) throw new Error(`bad signature header ${header}`); const recid = (header - 27) & 3; const compressed = header >= 31; const enc = new TextEncoder(); const varstr = (s) => { const b = enc.encode(s); if (b.length >= 0xfd) throw new Error("message too long"); return Uint8Array.from([b.length, ...b]); }; const MAGIC = "Bitcoin Signed Message:\n"; const payload = Uint8Array.from([...varstr(MAGIC), ...varstr(String(message))]); const digest = sha256(sha256(payload)); // Reconstruct the raw signature (1-byte recid || r || s) for // secp256k1.recoverPublicKey. @noble/curves takes the recovered format // whether we pass compressed or uncompressed, we ask for compressed // (matches every BCH wallet's derived pubkey). const recovered = new Uint8Array(65); recovered[0] = recid; recovered.set(sig.subarray(1), 1); const pub = sec.getPublicKey ? sec.recoverPublicKey(digest, recovered, { prehash: false, format: compressed ? "compressed" : "uncompressed" }) : sec.Signature.fromCompact(sig.subarray(1)).addRecoveryBit(recid).recoverPublicKey(digest).toRawBytes(compressed); const recoveredHash = hash160(pub); // Decode the expected address to its h160 payload; accept both mainnet // and testnet prefixes. Rejects non-P2PKH addresses (type != 0) since // this signing scheme has no notion of a P2SH signer. const raw = String(address || ""); const full = raw.includes(":") ? raw : "bitcoincash:" + raw; const { type, hash } = caLib.decode(full); if (type !== 0) throw new Error(`address must be P2PKH (got type ${type})`); const valid = recoveredHash.length === hash.length && recoveredHash.every((b, i) => b === hash[i]); return { valid, address: raw, recoveredHash: toHex(recoveredHash) }; } return { WalletKeys, hash160, p2pkhScript, p2shScript, scripthash, toHex, verifyMessage }; };