Aegis 0.31.0: chain adapters stop trusting what they should check
Start of the next batch; 0.30.0 is published.
- Tron panel sends signed whatever /wallet/createtransaction returned while
the approval showed the local request. The returned bytes are now decoded
and must be one transfer from this wallet, to that address, for that
amount, with a matching txID - checked at plan time and again at signing.
- Importing a Solana wallet from a seed phrase threw on every attempt (a
mis-parenthesised `new require("crypto").createHmac` plus a bare require
of an ESM-only subpath). SLIP-0010 now uses Node's HMAC, as chain-sol does.
- Imported BCH wallets put token-bearing UTXOs into coin selection. They are
excluded, as in the HD wallet, and the balance counts what can be spent.
- WizardConnect dropped the token from each spent output before signing, so
under SIGHASH_UTXOS every signature of a token transaction was invalid.
- A wallet disposed while a refresh was in flight re-armed its poll timer.
- BCMR registry content is bounded before it reaches the panel: control and
bidi characters stripped, lengths capped, decimals 0-18, icons https/ipfs
only, registries https only.
This commit is contained in:
parent
84a37b26bf
commit
9e7e9d5e8b
8 changed files with 122 additions and 24 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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})`);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue