From 206f54edd24597c79ff47987407ec34b508b2628 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sat, 3 Oct 2026 22:53:58 +0200 Subject: [PATCH] Aegis: Tron signing overlay comes from the bytes being signed The overlay for tronWeb-built transactions was built from the dapp's raw_data JSON, which need not match raw_data_hex: a site could show "1 TRX to X" and get a signature over anything. lib/tron-decode.js decodes Transaction.raw from raw_data_hex (contract type, owner, to, amount, TRC-20 transfer/approve calldata, fee limit, memo); the txID must match the bytes, every contract's owner must be this wallet, and unlimited approvals or permission/resource delegation get a danger action. sendRawTransaction relayed any signed transaction a site handed it; it now broadcasts only txids Aegis itself signed. --- bundled-addons/aegis/index.js | 86 +++++++++---- bundled-addons/aegis/lib/tron-decode.js | 163 ++++++++++++++++++++++++ 2 files changed, 226 insertions(+), 23 deletions(-) create mode 100644 bundled-addons/aegis/lib/tron-decode.js diff --git a/bundled-addons/aegis/index.js b/bundled-addons/aegis/index.js index 112da42b..222dbe6f 100644 --- a/bundled-addons/aegis/index.js +++ b/bundled-addons/aegis/index.js @@ -59,6 +59,9 @@ async function loadDeps(api) { const siaAdapter = require("./lib/chain-sia.js")({ ed25519, blake2b }); const ethAdapter = require("./lib/chain-eth.js")({ HDKey, secp256k1, keccak_256 }); const eip712 = require("./lib/eip712.js")({ keccak_256 }); + // Tron raw_data_hex decoder — the approval overlay for dapp-built Tron + // transactions is built from these bytes, never from the dapp's JSON. + const tronDecode = require("./lib/tron-decode.js")({ sha256, base58check }); // Solana uses the raw base58 alphabet (no checksum), which lives inside // base58check as encodeBase58 / decodeBase58 — expose them under a // `{encode, decode}` shape the SOL adapter reads from. @@ -144,7 +147,7 @@ async function loadDeps(api) { bchAdapter, tronAdapter, siaAdapter, dgbAdapter, ethAdapter, solAdapter, btcAdapter, importedBchAdapter, utxoImportedAdapter, genericImportedAdapter, derive, bip39, - dgbCore, dgbPsbt, bitcoinjs, ecc, eip712, + dgbCore, dgbPsbt, bitcoinjs, ecc, eip712, tronDecode, wcCore, wcWallet, libauth }; } @@ -2670,6 +2673,14 @@ function grantsForPanel(api) { return out; } +// Tron: only transactions Aegis itself signed may be broadcast through the +// bridge — a dapp can't use the wallet as a relay for foreign signatures. +const signedTronTxids = new Set(); +function rememberSignedTron(txid) { + signedTronTxids.add(txid); + if (signedTronTxids.size > 200) signedTronTxids.delete(signedTronTxids.values().next().value); +} + // Dapp message bytes: personal_sign carries hex-encoded bytes (ethers, viem, // wagmi); a plain string is UTF-8 (older dapps, our own panel). function bytesFromDappMessage(raw) { @@ -3037,38 +3048,64 @@ function registerPageMessages(api) { return { address: snap.address, network: snap.network }; }); // Sign an arbitrary raw_data_hex the dapp built (with its own tronWeb). - // The wallet never guesses the intent — the approval overlay shows the - // decoded contract type and destination when it can, and always the txID. + // Everything the overlay shows is decoded from raw_data_hex — the bytes + // that get signed. The overlay used to read the dapp's raw_data JSON, + // which can say "1 TRX to X" over bytes that do something else entirely. api.onMessage("trx.signTransaction", async (p, m) => { const origin = fromPage(m); const rt = activeTronRuntime(); if (!grantFor(api, origin, "trx")) throw new Error("not connected — call tron_requestAccounts first"); const tx = p && p.transaction; - if (!tx || typeof tx !== "object" || !tx.raw_data_hex || !tx.raw_data) throw new Error("bad transaction"); + if (!tx || typeof tx !== "object" || !tx.raw_data_hex) throw new Error("bad transaction"); + // The dapp's raw_data JSON and txID are cross-checked, never trusted. + let decoded; + try { decoded = ctx.d.tronDecode.decodeRawData(tx.raw_data_hex); } + catch (e) { throw new Error("cannot decode raw_data_hex: " + (e?.message || e)); } + if (tx.txID && String(tx.txID).toLowerCase() !== decoded.txid) throw new Error("txID does not match raw_data_hex"); + const me = rt.adapter.address; + for (const c of decoded.contracts) { + if (!c.owner) throw new Error(`cannot read the owner of ${c.typeName}; refusing to sign`); + if (c.owner !== me) throw new Error(`transaction owner ${c.owner} is not this wallet`); + } return withOriginLock(origin, async () => { - const contract = (tx.raw_data.contract || [])[0]; - const type = contract?.type || "Contract"; - const rows = [{ label: "Type", value: type }, { label: "Tx ID", value: tx.txID || "(unset)", mono: true }]; - if (type === "TransferContract") { - const v = contract.parameter?.value || {}; - try { - const to = v.to_address ? ctx.d.tronAdapter.hexToAddress(v.to_address) : (v.to_address || ""); - const amount = Number(v.amount || 0); - rows.splice(1, 0, { label: "To", value: to, mono: true }, { label: "Amount", value: `${fmtTrx(amount)} TRX`, strong: true }); - } catch {} - } + const rows = []; + decoded.contracts.forEach((c, i) => { + const n = decoded.contracts.length > 1 ? ` #${i + 1}` : ""; + rows.push({ label: "Type" + n, value: c.typeName + (c.dangerous ? " — grants control to another account" : "") }); + if (c.type === 1) { + rows.push({ label: "To", value: c.to || "(none)", mono: true }); + rows.push({ label: "Amount", value: `${fmtTrx(Number(c.amount))} TRX`, strong: true }); + } else if (c.type === 2) { + rows.push({ label: "To", value: c.to || "(none)", mono: true }); + rows.push({ label: "Amount", value: `${String(c.amount)} units of asset ${c.assetName || "?"}`, strong: true }); + } else if (c.type === 31) { + rows.push({ label: "Contract", value: c.contract || "(none)", mono: true }); + const call = c.call; + if (call && call.name === "transfer") rows.push({ label: "Call", value: `transfer ${call.unlimited ? "UNLIMITED" : String(call.amount)} token units to ${call.to}`, strong: true }); + else if (call && (call.name === "approve" || call.name === "increaseAllowance")) rows.push({ label: "Call", value: `${call.name}: let ${call.spender} spend ${call.unlimited ? "UNLIMITED (∞)" : String(call.amount)} token units`, strong: true }); + else if (call && call.name === "transferFrom") rows.push({ label: "Call", value: `transferFrom ${call.from} → ${call.to}: ${call.unlimited ? "UNLIMITED" : String(call.amount)} units`, strong: true }); + else rows.push({ label: "Call", value: call && call.selector ? `unknown method 0x${call.selector} (${c.data.length / 2} bytes, not decoded)` : "(no call data)", mono: true }); + if (c.callValue) rows.push({ label: "TRX attached", value: `${fmtTrx(Number(c.callValue))} TRX` }); + } + }); + if (decoded.feeLimit != null) rows.push({ label: "Fee limit", value: `${fmtTrx(Number(decoded.feeLimit))} TRX` }); + if (decoded.memo) rows.push({ label: "Memo", value: decoded.memo.slice(0, 200), mono: true }); + rows.push({ label: "Tx ID", value: decoded.txid, mono: true }); rows.push({ label: "Wallet", value: `${rt.entry.label} — Tron · ${rt.adapter.snapshot().network === "nile" ? "Nile testnet" : "Mainnet"}` }); + const risky = decoded.contracts.some((c) => c.dangerous || (c.call && c.call.unlimited)); const pick = await api.approvalModal({ title: "Sign a Tron transaction?", origin, - body: "The site built this transaction. Check the type, amount, and destination before signing.", + body: risky + ? "WARNING: this transaction hands another account unlimited or permanent control over funds. Only sign if you fully trust this site." + : "Decoded from the bytes that will be signed. Check the type, amount and destination.", rows, - actions: [{ id: "sign", label: "Sign", primary: true }], + actions: [{ id: "sign", label: risky ? "Sign anyway" : "Sign", primary: !risky, danger: risky }], }); if (pick !== "sign") throw new Error("user rejected"); const sig = rt.adapter.signRawData(tx.raw_data_hex); - const signed = { ...tx, signature: [sig] }; - return signed; + rememberSignedTron(decoded.txid); + return { ...tx, txID: decoded.txid, signature: [sig] }; }); }); api.onMessage("trx.sendRawTransaction", async (p, m) => { @@ -3077,10 +3114,13 @@ function registerPageMessages(api) { if (!grantFor(api, origin, "trx")) throw new Error("not connected — call tron_requestAccounts first"); const signedTx = p && p.transaction; if (!signedTx || !signedTx.raw_data_hex || !Array.isArray(signedTx.signature)) throw new Error("bad signed tx"); - // No approval here — broadcasting a *signed* tx does not add any risk - // the sign step didn't already carry. Sites that don't want an extra - // network round-trip pass {broadcast:true} to sign; we support both. - return rt.adapter.broadcastSignedTx(signedTx); + // No approval here — the sign step carried it. But only what Aegis + // signed goes out: the bridge is not a relay for foreign signatures. + let txid; + try { txid = ctx.d.tronDecode.decodeRawData(signedTx.raw_data_hex).txid; } + catch (e) { throw new Error("cannot decode raw_data_hex: " + (e?.message || e)); } + if (!signedTronTxids.has(txid)) throw new Error("refusing to broadcast a transaction this wallet did not sign"); + return rt.adapter.broadcastSignedTx({ ...signedTx, txID: txid }); }); api.onMessage("trx.signMessageV2", async (p, m) => { const origin = fromPage(m); diff --git a/bundled-addons/aegis/lib/tron-decode.js b/bundled-addons/aegis/lib/tron-decode.js new file mode 100644 index 00000000..8ecc60ae --- /dev/null +++ b/bundled-addons/aegis/lib/tron-decode.js @@ -0,0 +1,163 @@ +// Minimal protobuf wire decoder for Tron's `Transaction.raw` — what a dapp +// hands us as `raw_data_hex`. The approval overlay MUST be built from these +// bytes (the thing that actually gets signed), never from the dapp's +// `raw_data` JSON, which can say anything. +// +// Field numbers from protocol/core/Tron.proto: +// Transaction.raw: 1 ref_block_bytes, 3 ref_block_num, 4 ref_block_hash, +// 8 expiration, 9 auths, 10 data, 11 contract (repeated), 12 scripts, +// 14 timestamp, 18 fee_limit +// Transaction.Contract: 1 type (enum), 2 parameter (google.protobuf.Any), +// 3 provider, 4 ContractName, 5 Permission_id +// Any: 1 type_url (string), 2 value (bytes) +// TransferContract: 1 owner_address, 2 to_address, 3 amount +// TransferAssetContract: 1 asset_name, 2 owner_address, 3 to_address, 4 amount +// TriggerSmartContract: 1 owner_address, 2 contract_address, 3 call_value, +// 4 data, 5 call_token_value, 6 token_id +// Every other contract type puts owner_address in field 1. + +const CONTRACT_TYPES = { + 0: "AccountCreateContract", 1: "TransferContract", 2: "TransferAssetContract", + 3: "VoteAssetContract", 4: "VoteWitnessContract", 5: "WitnessCreateContract", + 6: "AssetIssueContract", 8: "WitnessUpdateContract", 9: "ParticipateAssetIssueContract", + 10: "AccountUpdateContract", 11: "FreezeBalanceContract", 12: "UnfreezeBalanceContract", + 13: "WithdrawBalanceContract", 14: "UnfreezeAssetContract", 15: "UpdateAssetContract", + 16: "ProposalCreateContract", 17: "ProposalApproveContract", 18: "ProposalDeleteContract", + 19: "SetAccountIdContract", 20: "CustomContract", 30: "CreateSmartContract", + 31: "TriggerSmartContract", 33: "UpdateSettingContract", 41: "ExchangeCreateContract", + 42: "ExchangeInjectContract", 43: "ExchangeWithdrawContract", 44: "ExchangeTransactionContract", + 45: "UpdateEnergyLimitContract", 46: "AccountPermissionUpdateContract", 48: "ClearABIContract", + 49: "UpdateBrokerageContract", 51: "ShieldedTransferContract", 52: "MarketSellAssetContract", + 53: "MarketCancelOrderContract", 54: "FreezeBalanceV2Contract", 55: "UnfreezeBalanceV2Contract", + 56: "WithdrawExpireUnfreezeContract", 57: "DelegateResourceContract", + 58: "UnDelegateResourceContract", 59: "CancelAllUnfreezeV2Contract", +}; +// Contract types that hand control of the account or its resources to a +// third party — the overlay stresses these. +const DANGEROUS_TYPES = new Set([46, 57]); + +const TRC20_SELECTORS = { + a9059cbb: { name: "transfer", args: ["to", "amount"] }, + "095ea7b3": { name: "approve", args: ["spender", "amount"] }, + "23b872dd": { name: "transferFrom", args: ["from", "to", "amount"] }, + "39509351": { name: "increaseAllowance", args: ["spender", "amount"] }, +}; +const UINT256_MAX = (1n << 256n) - 1n; + +module.exports = function makeTronDecode({ sha256, base58check }) { + const hexToBytes = (h) => { + const s = String(h || "").replace(/^0x/i, ""); + if (!/^[0-9a-f]*$/i.test(s) || s.length % 2) throw new Error("raw_data_hex is not hex"); + const out = new Uint8Array(s.length / 2); + for (let i = 0; i < out.length; i++) out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16); + return out; + }; + const bytesToHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); + + // Split a message into { field, wire, value } records. Varints come back + // as BigInt, length-delimited fields as Uint8Array views. + function readFields(bytes) { + const out = []; + let off = 0; + const varint = () => { + let v = 0n, shift = 0n; + for (;;) { + if (off >= bytes.length) throw new Error("truncated varint"); + const b = bytes[off++]; + v |= BigInt(b & 0x7f) << shift; + if ((b & 0x80) === 0) return v; + shift += 7n; + if (shift > 70n) throw new Error("varint too long"); + } + }; + while (off < bytes.length) { + const key = varint(); + const field = Number(key >> 3n), wire = Number(key & 7n); + if (wire === 0) out.push({ field, wire, value: varint() }); + else if (wire === 1) { if (off + 8 > bytes.length) throw new Error("truncated fixed64"); out.push({ field, wire, value: bytes.subarray(off, off + 8) }); off += 8; } + else if (wire === 2) { const n = Number(varint()); if (off + n > bytes.length) throw new Error("truncated bytes"); out.push({ field, wire, value: bytes.subarray(off, off + n) }); off += n; } + else if (wire === 5) { if (off + 4 > bytes.length) throw new Error("truncated fixed32"); out.push({ field, wire, value: bytes.subarray(off, off + 4) }); off += 4; } + else throw new Error("unsupported wire type " + wire); + } + return out; + } + const one = (fields, n) => fields.find((f) => f.field === n); + const bytesOf = (fields, n) => { const f = one(fields, n); return f && f.wire === 2 ? f.value : null; }; + const numOf = (fields, n) => { const f = one(fields, n); return f && f.wire === 0 ? f.value : null; }; + + // 21-byte Tron address (0x41 || h20) → "T…" base58check. Anything else is + // returned as hex so the overlay never hides a malformed field. + function addr(b) { + if (!b) return null; + if (b.length === 21 && b[0] === 0x41) { try { return base58check.encodeCheck(b); } catch {} } + return "0x" + bytesToHex(b); + } + // EVM-style 32-byte word → Tron address (last 20 bytes, 0x41 prefix). + function wordToAddr(word) { + const out = new Uint8Array(21); out[0] = 0x41; out.set(word.subarray(12, 32), 1); + return addr(out); + } + const wordToBig = (word) => { let v = 0n; for (const x of word) v = (v << 8n) | BigInt(x); return v; }; + + function decodeTrc20(data) { + if (!data || data.length < 4) return null; + const sel = bytesToHex(data.subarray(0, 4)); + const spec = TRC20_SELECTORS[sel]; + if (!spec) return { selector: sel, name: null }; + const words = []; + for (let i = 4; i + 32 <= data.length; i += 32) words.push(data.subarray(i, i + 32)); + if (words.length < spec.args.length) return { selector: sel, name: spec.name, malformed: true }; + const out = { selector: sel, name: spec.name }; + spec.args.forEach((a, i) => { out[a] = a === "amount" ? wordToBig(words[i]) : wordToAddr(words[i]); }); + if (out.amount != null) out.unlimited = out.amount >= (1n << 255n) || out.amount === UINT256_MAX; + return out; + } + + function decodeContract(fields) { + const typeNum = Number(numOf(fields, 1) ?? 0n); + const typeName = CONTRACT_TYPES[typeNum] || `Contract#${typeNum}`; + const any = bytesOf(fields, 2); + const c = { type: typeNum, typeName, dangerous: DANGEROUS_TYPES.has(typeNum), owner: null, permissionId: Number(numOf(fields, 5) ?? 0n) }; + if (!any) return c; + const anyFields = readFields(any); + const value = bytesOf(anyFields, 2); + if (!value) return c; + const vf = readFields(value); + if (typeNum === 1) { // TransferContract + c.owner = addr(bytesOf(vf, 1)); c.to = addr(bytesOf(vf, 2)); c.amount = numOf(vf, 3) ?? 0n; + } else if (typeNum === 2) { // TransferAssetContract + const name = bytesOf(vf, 1); + c.assetName = name ? Buffer.from(name).toString("utf8") : null; + c.owner = addr(bytesOf(vf, 2)); c.to = addr(bytesOf(vf, 3)); c.amount = numOf(vf, 4) ?? 0n; + } else if (typeNum === 31) { // TriggerSmartContract + c.owner = addr(bytesOf(vf, 1)); c.contract = addr(bytesOf(vf, 2)); + c.callValue = numOf(vf, 3) ?? 0n; + const data = bytesOf(vf, 4); + c.data = data ? bytesToHex(data) : ""; + c.call = decodeTrc20(data); + } else { + c.owner = addr(bytesOf(vf, 1)); + } + return c; + } + + // Full decode. Throws on malformed input so the caller refuses to sign. + function decodeRawData(rawDataHex) { + const bytes = hexToBytes(rawDataHex); + if (!bytes.length) throw new Error("raw_data_hex is empty"); + const fields = readFields(bytes); + const contracts = fields.filter((f) => f.field === 11 && f.wire === 2).map((f) => decodeContract(readFields(f.value))); + if (!contracts.length) throw new Error("raw_data_hex carries no contract"); + const memo = bytesOf(fields, 10); + return { + txid: bytesToHex(sha256(bytes)), + contracts, + expiration: Number(numOf(fields, 8) ?? 0n), + timestamp: Number(numOf(fields, 14) ?? 0n), + feeLimit: numOf(fields, 18) ?? null, + memo: memo ? Buffer.from(memo).toString("utf8") : null, + }; + } + + return { decodeRawData, decodeTrc20, readFields, CONTRACT_TYPES }; +};