theseus/bundled-addons/aegis/lib/keys.js
Local Dev cb7ca53b01 chore(aegis): 0.8.2 — sectioned Settings tab
Settings grew tall enough that the user had to scroll past a dozen
cards to reach Prices, Sites or About. Split it into six named
sections (Security, Session, Wallet, Prices, Sites, About) with a
chip nav row at the top; only one section is visible at a time and
the choice persists across restarts.

Adds an About card that names the wallet, the aegis.x front-door
site and the silentmode.st umbrella, so support triage has a
one-click way to reach either from within the panel.

Includes the accumulated 0.7.x-0.8.1 wallet work that was already
shipping on OTA (CashTokens/BCMR, imported-wallet spend, siascan
integration, consolidate, currency picker, footer update chip).
2026-09-22 20:37:28 +02:00

118 lines
6.1 KiB
JavaScript

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