diff --git a/bundled-addons/aegis/addon.json b/bundled-addons/aegis/addon.json index a226cb78..ce5f6b30 100644 --- a/bundled-addons/aegis/addon.json +++ b/bundled-addons/aegis/addon.json @@ -1,7 +1,7 @@ { "id": "aegis", "name": "Aegis Wallet", - "version": "0.30.0", + "version": "0.31.0", "category": "plugin", "description": "Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB) derived from your Theseus vault. Dapps get window.bitcoincash and window.wizardconnect on any site; window.tronWeb / window.tronLink / window.ethereum / window.solana too. Every call needs your approval.", "author": "Silent Mode", diff --git a/bundled-addons/aegis/lib/bcmr.js b/bundled-addons/aegis/lib/bcmr.js index f597ee0f..8c7b7574 100644 --- a/bundled-addons/aegis/lib/bcmr.js +++ b/bundled-addons/aegis/lib/bcmr.js @@ -51,7 +51,9 @@ module.exports = function makeBcmr({ storage, log = () => {} }) { return DEFAULT_REGISTRIES.slice(); } function setRegistries(list) { - const clean = Array.isArray(list) ? list.filter((r) => r && typeof r.url === "string" && /^https?:\/\//i.test(r.url)) : []; + // https only: a registry decides what a token is called on the approval + // path, and plain http lets anyone on the network rewrite that. + const clean = Array.isArray(list) ? list.filter((r) => r && typeof r.url === "string" && /^https:\/\//i.test(r.url)) : []; storage.set("bcmr/registries", clean); } @@ -162,6 +164,19 @@ module.exports = function makeBcmr({ storage, log = () => {} }) { // // A local name wins over the registry: the user typed it for this exact // category, which is better evidence than a third-party document. + // Control, bidi-override, zero-width and BOM characters. Built from code + // points so no invisible character has to live in this source file. + const rng = (a, b) => String.fromCharCode(a) + "-" + String.fromCharCode(b); + const INVISIBLE = new RegExp("[" + rng(0x00, 0x1f) + rng(0x7f, 0x9f) + rng(0x200b, 0x200f) + rng(0x202a, 0x202e) + rng(0x2060, 0x2069) + rng(0xfeff, 0xfeff) + "]", "g"); + function cleanText(v, max) { + if (typeof v !== "string") return null; + const s = v.replace(INVISIBLE, "").trim().slice(0, max); + return s || null; + } + function cleanIcon(v) { + if (typeof v !== "string" || v.length > 512) return null; + return /^(https:\/\/|ipfs:\/\/)[^\s"'<>]+$/i.test(v) ? v : null; + } function metadataOf(entry, categoryHex) { const local = categoryHex ? localName(categoryHex) : null; if (!entry || !entry.snapshot) { @@ -173,14 +188,20 @@ module.exports = function makeBcmr({ storage, log = () => {} }) { } const s = entry.snapshot; const t = s.token || {}; + // Registry content is third-party and unauthenticated (no authchain + // check), so everything is bounded before it reaches the panel: text is + // stripped of control / bidi / zero-width characters and capped, decimals + // must be a whole number BCMR allows, and an icon is only ever an https + // or ipfs URL — never data:, javascript: or a plain-http tracker. + const regDecimals = Number(t.decimals); return { - name: local?.name || s.name || t.name || null, - symbol: local?.symbol || s.token?.symbol || s.symbol || null, - description: s.description || null, + name: local?.name || cleanText(s.name || t.name, 40), + symbol: local?.symbol || cleanText(s.token?.symbol || s.symbol, 12), + description: cleanText(s.description, 280), decimals: Number.isFinite(local?.decimals) ? local.decimals - : (Number.isFinite(Number(t.decimals)) ? Number(t.decimals) : 0), + : (Number.isInteger(regDecimals) && regDecimals >= 0 && regDecimals <= 18 ? regDecimals : 0), // Icon URIs live under s.uris.icon per schema; older files use s.icon. - iconUri: s.uris?.icon || s.icon || null, + iconUri: cleanIcon(s.uris?.icon || s.icon), source: local ? "local" : (entry.source || null), local: !!local, }; diff --git a/bundled-addons/aegis/lib/chain-bch-imported.js b/bundled-addons/aegis/lib/chain-bch-imported.js index 421b4d06..a48cedc6 100644 --- a/bundled-addons/aegis/lib/chain-bch-imported.js +++ b/bundled-addons/aegis/lib/chain-bch-imported.js @@ -185,6 +185,17 @@ module.exports = function makeImportedBchAdapter({ txid: u.tx_hash, vout: u.tx_pos, value: Number(u.value), height: Number(u.height || 0), token: u.token_data || null, })); + // get_balance counts the sats parked under token UTXOs, which this + // wallet never spends (see plan()). Report what is actually + // spendable so Max and the balance agree with what a send can move. + if (this._state.utxos.some((u) => u.token)) { + let confirmed = 0, unconfirmed = 0; + for (const u of this._state.utxos) { + if (u.token) continue; + if (u.height > 0) confirmed += u.value; else unconfirmed += u.value; + } + this._state.balance = { confirmed, unconfirmed }; + } // CashTokens. This adapter never looked for them, so a WIF-imported // wallet holding tokens reported none and the panel hid its Assets // card — a chipnet test wallet with 46 categories showed nothing. @@ -265,7 +276,12 @@ module.exports = function makeImportedBchAdapter({ // Imported wallets have exactly one address, so change goes back to // itself — no need to derive a fresh change entry from an HD tree. const changeScript = this._script; - const spendable = this._state.utxos.slice().sort((a, b) => (b.height > 0) - (a.height > 0)); + // Token-bearing UTXOs never enter coin selection — same rule as the HD + // wallet. tx.js builds plain P2PKH inputs with no token prefix, so a + // selected token UTXO fails at broadcast today, and would BURN the + // token the day the sighash learns about prefixes. + const spendable = this._state.utxos.filter((u) => !u.token).sort((a, b) => (b.height > 0) - (a.height > 0)); + if (!spendable.length) throw new Error("every unspent output in this wallet carries a token; there is no plain BCH to spend"); const sel = tx.select(spendable, outs, rate, changeScript, { sendMax: !!spec?.sendMax }); const nonData = sel.outputs.filter((o) => !o.data); const total = sel.outputs.reduce((a, o) => a + o.value, 0); diff --git a/bundled-addons/aegis/lib/chain-eth.js b/bundled-addons/aegis/lib/chain-eth.js index a1676303..60cc3ea4 100644 --- a/bundled-addons/aegis/lib/chain-eth.js +++ b/bundled-addons/aegis/lib/chain-eth.js @@ -306,6 +306,8 @@ module.exports = function makeEthAdapter({ HDKey, secp256k1, keccak_256 }) { } schedulePoll(ms = 20_000) { clearTimeout(this._pollTimer); + // dispose() during an in-flight refresh must not re-arm the poll. + if (this._disposed) return; this._pollTimer = setTimeout(() => this.refresh().finally(() => this.schedulePoll(ms)), ms); } @@ -426,6 +428,7 @@ module.exports = function makeEthAdapter({ HDKey, secp256k1, keccak_256 }) { } dispose() { + this._disposed = true; clearTimeout(this._pollTimer); try { this._priv && this._priv.fill(0); } catch {} try { this._root && this._root.fill(0); } catch {} diff --git a/bundled-addons/aegis/lib/chain-sol.js b/bundled-addons/aegis/lib/chain-sol.js index 44cf84d7..786cdb50 100644 --- a/bundled-addons/aegis/lib/chain-sol.js +++ b/bundled-addons/aegis/lib/chain-sol.js @@ -293,6 +293,8 @@ module.exports = function makeSolAdapter({ ed25519, base58, sha256 }) { } schedulePoll(ms = 20_000) { clearTimeout(this._pollTimer); + // dispose() during an in-flight refresh must not re-arm the poll. + if (this._disposed) return; this._pollTimer = setTimeout(() => this.refresh().finally(() => this.schedulePoll(ms)), ms); } @@ -476,6 +478,7 @@ module.exports = function makeSolAdapter({ ed25519, base58, sha256 }) { } dispose() { + this._disposed = true; clearTimeout(this._pollTimer); try { this._priv && this._priv.fill(0); } catch {} try { this._root && this._root.fill(0); } catch {} diff --git a/bundled-addons/aegis/lib/chain-tron.js b/bundled-addons/aegis/lib/chain-tron.js index eebbf7b2..c2b36650 100644 --- a/bundled-addons/aegis/lib/chain-tron.js +++ b/bundled-addons/aegis/lib/chain-tron.js @@ -44,6 +44,27 @@ module.exports = function makeTronAdapter({ HDKey, secp256k1, sha256, keccak_256 if (!HDKey || !secp256k1 || !sha256 || !keccak_256 || !base58check) { throw new Error("chain-tron: missing dep"); } + const tronDecode = require("./tron-decode.js")({ sha256, base58check }); + // TronGrid builds the transfer for us (/wallet/createtransaction). What + // comes back is checked against what was asked for before it is shown or + // signed: one TransferContract, from this wallet, to that address, for + // that amount, with the txID being the hash of exactly those bytes. A + // compromised or spoofed node used to be able to substitute any + // transaction — the approval rows came from the local request while the + // signature covered whatever bytes the server returned. + function assertDraftMatches(draft, { owner, to, amount }) { + let d; + try { d = tronDecode.decodeRawData(draft.raw_data_hex); } + catch (e) { throw new Error("node returned an unreadable transaction: " + (e?.message || e)); } + if (d.contracts.length !== 1) throw new Error(`node returned ${d.contracts.length} contracts for a single transfer`); + const c = d.contracts[0]; + if (c.type !== 1) throw new Error(`node returned a ${c.typeName} instead of a transfer`); + if (c.owner !== owner) throw new Error("node returned a transaction from another account"); + if (c.to !== to) throw new Error(`node changed the recipient to ${c.to}`); + if (BigInt(c.amount) !== BigInt(amount)) throw new Error(`node changed the amount to ${c.amount} sun`); + if (draft.txID && String(draft.txID).toLowerCase() !== d.txid) throw new Error("node returned a txID that does not match the transaction"); + return d.txid; + } const bytesToHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); const hexToBytes = (h) => { const s = String(h).replace(/^0x/i, ""); @@ -180,6 +201,8 @@ module.exports = function makeTronAdapter({ HDKey, secp256k1, sha256, keccak_256 schedulePoll(ms = 20_000) { clearTimeout(this._pollTimer); + // dispose() during an in-flight refresh must not re-arm the poll. + if (this._disposed) return; this._pollTimer = setTimeout(() => this.refresh(false).finally(() => this.schedulePoll(ms)), ms); } @@ -263,7 +286,10 @@ module.exports = function makeTronAdapter({ HDKey, secp256k1, sha256, keccak_256 if (draft?.Error || !draft?.raw_data_hex) { throw new Error("createtransaction: " + (draft?.Error || "empty response")); } + const toB58 = base58check.encodeCheck(dest); + const txid = assertDraftMatches(draft, { owner: this.address, to: toB58, amount: value }); const plan = { + _expect: { owner: this.address, to: toB58, amount: value, txid }, recipients: [{ to: base58check.encodeCheck(dest), value }], fee: FEE_EST, feeRate: 1, @@ -277,7 +303,11 @@ module.exports = function makeTronAdapter({ HDKey, secp256k1, sha256, keccak_256 // Sign the raw_data_hex bytes with the wallet key and POST the signed tx. async signAndBroadcast(plan) { const draft = plan && plan._draft; - if (!draft || !draft.raw_data_hex) throw new Error("bad plan"); + if (!draft || !draft.raw_data_hex || !plan._expect) throw new Error("bad plan"); + // Re-checked at the moment of signing: the bytes about to be signed + // must still be the transfer the user approved. + const txid = assertDraftMatches(draft, plan._expect); + if (txid !== plan._expect.txid) throw new Error("transaction changed after it was approved"); const rawBytes = hexToBytes(draft.raw_data_hex); const digest = sha256(rawBytes); const sig = secp256k1.sign(digest, this._priv, { prehash: false, lowS: false, format: "recovered" }); @@ -289,7 +319,7 @@ module.exports = function makeTronAdapter({ HDKey, secp256k1, sha256, keccak_256 const body = { raw_data: draft.raw_data, raw_data_hex: draft.raw_data_hex, - txID: draft.txID, + txID: txid, visible: true, signature: [bytesToHex(trxSig)], }; @@ -299,8 +329,8 @@ module.exports = function makeTronAdapter({ HDKey, secp256k1, sha256, keccak_256 throw new Error("broadcast: " + msg); } // Refresh soon so history/balance catch up. - setTimeout(() => this.refresh(false), 2500); - return { txid: draft.txID }; + setTimeout(() => { if (!this._disposed) this.refresh(false); }, 2500); + return { txid }; } // BIP137-style signing isn't standard on Tron; dapps use signMessageV2 @@ -343,6 +373,7 @@ module.exports = function makeTronAdapter({ HDKey, secp256k1, sha256, keccak_256 } dispose() { + this._disposed = true; clearTimeout(this._pollTimer); try { this._priv && this._priv.fill(0); } catch {} try { this._root && this._root.fill(0); } catch {} diff --git a/bundled-addons/aegis/lib/import-derive.js b/bundled-addons/aegis/lib/import-derive.js index f5bef35d..2462cd47 100644 --- a/bundled-addons/aegis/lib/import-derive.js +++ b/bundled-addons/aegis/lib/import-derive.js @@ -192,17 +192,14 @@ module.exports = function makeImportDerive({ function slip0010DeriveEd25519(seed, path) { const HMAC_KEY = new TextEncoder().encode("ed25519 seed"); const parts = String(path).split("/").slice(1); - // Compute master - const enc = new (require("crypto")).createHmac ? require("crypto") : null; - // Not using node crypto — the deps hand in @noble/hashes hmac via sha512. - // We rely on secp256k1's helpers? No — use ed25519 utils. - // Simplified: compute HMAC-SHA512(HMAC_KEY, seed) → I=I_L||I_R, sk=I_L, cc=I_R. - // Then each step: HMAC-SHA512(cc, 0x00 || sk || idx). - // Implementation via @noble/hashes/hmac imported as `hmacSha512`. We - // require it lazily so unavailable deps error out here rather than at - // load time. - const { hmac } = require("@noble/hashes/hmac"); - const { sha512 } = require("@noble/hashes/sha2"); + // HMAC-SHA512(HMAC_KEY, seed) → I = I_L || I_R, sk = I_L, cc = I_R; each + // step is HMAC-SHA512(cc, 0x00 || sk || idx). Node's own HMAC, same as + // chain-sol.js: the add-on runs in Electron main, and the previous + // `new require("crypto").createHmac` plus a bare require of an ESM-only + // @noble/hashes subpath threw on every Solana seed import. + const nodeCrypto = require("node:crypto"); + const hmac = (_h, key, data) => new Uint8Array(nodeCrypto.createHmac("sha512", Buffer.from(key)).update(Buffer.from(data)).digest()); + const sha512 = null; let I = hmac(sha512, HMAC_KEY, seed); let sk = I.slice(0, 32); let cc = I.slice(32); for (const seg of parts) { diff --git a/bundled-addons/aegis/lib/wc-sign.js b/bundled-addons/aegis/lib/wc-sign.js index 33d76999..34d5ab4c 100644 --- a/bundled-addons/aegis/lib/wc-sign.js +++ b/bundled-addons/aegis/lib/wc-sign.js @@ -31,6 +31,26 @@ function ensureTransaction(txOrHex, libauth) { return txOrHex; } +// libauth Output.token: { amount: bigint, category: Uint8Array, +// nft?: { capability, commitment: Uint8Array } }. Over the wire the byte +// fields may arrive as hex and the amount as a string or number. +function normalizeToken(t) { + if (!t || typeof t !== "object") return null; + const bytes = (v) => (v instanceof Uint8Array ? v : fromHex(String(v || ""))); + const out = { + amount: typeof t.amount === "bigint" ? t.amount : BigInt(t.amount ?? 0), + category: bytes(t.category), + }; + if (out.category.length !== 32) throw new Error("wc-sign: token category must be 32 bytes"); + if (t.nft) { + out.nft = { + capability: String(t.nft.capability || "none"), + commitment: bytes(t.nft.commitment), + }; + } + return out; +} + async function signTx({ request, account, branches, libauth, secp256k1 }) { const { generateSigningSerializationBCH, @@ -55,10 +75,17 @@ async function signTx({ request, account, branches, libauth, secp256k1 }) { const tx = ensureTransaction(inner.transaction, libauth); const sourceOutputs = (inner.sourceOutputs || []).map((o, i) => { if (o.contract) throw new Error(`wc-sign: input ${i} spends a contract — unsupported`); - return { + const out = { lockingBytecode: o.lockingBytecode instanceof Uint8Array ? o.lockingBytecode : fromHex(o.lockingBytecode), valueSatoshis: typeof o.valueSatoshis === "bigint" ? o.valueSatoshis : BigInt(o.valueSatoshis), }; + // The token rides along. SIGHASH_UTXOS commits every input's signature + // to ALL source outputs including their token prefix, so dropping it + // (as this did) made every signature of a token-spending transaction + // invalid. + const tok = normalizeToken(o.token); + if (tok) out.token = tok; + return out; }); if (sourceOutputs.length !== tx.inputs.length) { throw new Error(`wc-sign: sourceOutputs (${sourceOutputs.length}) ≠ inputs (${tx.inputs.length})`);