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:
Local Dev 2026-10-04 01:38:50 +02:00
parent 84a37b26bf
commit 9e7e9d5e8b
8 changed files with 122 additions and 24 deletions

View file

@ -1,7 +1,7 @@
{ {
"id": "aegis", "id": "aegis",
"name": "Aegis Wallet", "name": "Aegis Wallet",
"version": "0.30.0", "version": "0.31.0",
"category": "plugin", "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.", "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", "author": "Silent Mode",

View file

@ -51,7 +51,9 @@ module.exports = function makeBcmr({ storage, log = () => {} }) {
return DEFAULT_REGISTRIES.slice(); return DEFAULT_REGISTRIES.slice();
} }
function setRegistries(list) { 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); 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 // A local name wins over the registry: the user typed it for this exact
// category, which is better evidence than a third-party document. // 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) { function metadataOf(entry, categoryHex) {
const local = categoryHex ? localName(categoryHex) : null; const local = categoryHex ? localName(categoryHex) : null;
if (!entry || !entry.snapshot) { if (!entry || !entry.snapshot) {
@ -173,14 +188,20 @@ module.exports = function makeBcmr({ storage, log = () => {} }) {
} }
const s = entry.snapshot; const s = entry.snapshot;
const t = s.token || {}; 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 { return {
name: local?.name || s.name || t.name || null, name: local?.name || cleanText(s.name || t.name, 40),
symbol: local?.symbol || s.token?.symbol || s.symbol || null, symbol: local?.symbol || cleanText(s.token?.symbol || s.symbol, 12),
description: s.description || null, description: cleanText(s.description, 280),
decimals: Number.isFinite(local?.decimals) ? local.decimals 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. // 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), source: local ? "local" : (entry.source || null),
local: !!local, local: !!local,
}; };

View file

@ -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), txid: u.tx_hash, vout: u.tx_pos, value: Number(u.value), height: Number(u.height || 0),
token: u.token_data || null, 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 // CashTokens. This adapter never looked for them, so a WIF-imported
// wallet holding tokens reported none and the panel hid its Assets // wallet holding tokens reported none and the panel hid its Assets
// card — a chipnet test wallet with 46 categories showed nothing. // 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 // Imported wallets have exactly one address, so change goes back to
// itself — no need to derive a fresh change entry from an HD tree. // itself — no need to derive a fresh change entry from an HD tree.
const changeScript = this._script; 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 sel = tx.select(spendable, outs, rate, changeScript, { sendMax: !!spec?.sendMax });
const nonData = sel.outputs.filter((o) => !o.data); const nonData = sel.outputs.filter((o) => !o.data);
const total = sel.outputs.reduce((a, o) => a + o.value, 0); const total = sel.outputs.reduce((a, o) => a + o.value, 0);

View file

@ -306,6 +306,8 @@ module.exports = function makeEthAdapter({ HDKey, secp256k1, keccak_256 }) {
} }
schedulePoll(ms = 20_000) { schedulePoll(ms = 20_000) {
clearTimeout(this._pollTimer); 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); this._pollTimer = setTimeout(() => this.refresh().finally(() => this.schedulePoll(ms)), ms);
} }
@ -426,6 +428,7 @@ module.exports = function makeEthAdapter({ HDKey, secp256k1, keccak_256 }) {
} }
dispose() { dispose() {
this._disposed = true;
clearTimeout(this._pollTimer); clearTimeout(this._pollTimer);
try { this._priv && this._priv.fill(0); } catch {} try { this._priv && this._priv.fill(0); } catch {}
try { this._root && this._root.fill(0); } catch {} try { this._root && this._root.fill(0); } catch {}

View file

@ -293,6 +293,8 @@ module.exports = function makeSolAdapter({ ed25519, base58, sha256 }) {
} }
schedulePoll(ms = 20_000) { schedulePoll(ms = 20_000) {
clearTimeout(this._pollTimer); 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); this._pollTimer = setTimeout(() => this.refresh().finally(() => this.schedulePoll(ms)), ms);
} }
@ -476,6 +478,7 @@ module.exports = function makeSolAdapter({ ed25519, base58, sha256 }) {
} }
dispose() { dispose() {
this._disposed = true;
clearTimeout(this._pollTimer); clearTimeout(this._pollTimer);
try { this._priv && this._priv.fill(0); } catch {} try { this._priv && this._priv.fill(0); } catch {}
try { this._root && this._root.fill(0); } catch {} try { this._root && this._root.fill(0); } catch {}

View file

@ -44,6 +44,27 @@ module.exports = function makeTronAdapter({ HDKey, secp256k1, sha256, keccak_256
if (!HDKey || !secp256k1 || !sha256 || !keccak_256 || !base58check) { if (!HDKey || !secp256k1 || !sha256 || !keccak_256 || !base58check) {
throw new Error("chain-tron: missing dep"); 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 bytesToHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
const hexToBytes = (h) => { const hexToBytes = (h) => {
const s = String(h).replace(/^0x/i, ""); const s = String(h).replace(/^0x/i, "");
@ -180,6 +201,8 @@ module.exports = function makeTronAdapter({ HDKey, secp256k1, sha256, keccak_256
schedulePoll(ms = 20_000) { schedulePoll(ms = 20_000) {
clearTimeout(this._pollTimer); 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); 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) { if (draft?.Error || !draft?.raw_data_hex) {
throw new Error("createtransaction: " + (draft?.Error || "empty response")); 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 = { const plan = {
_expect: { owner: this.address, to: toB58, amount: value, txid },
recipients: [{ to: base58check.encodeCheck(dest), value }], recipients: [{ to: base58check.encodeCheck(dest), value }],
fee: FEE_EST, fee: FEE_EST,
feeRate: 1, 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. // Sign the raw_data_hex bytes with the wallet key and POST the signed tx.
async signAndBroadcast(plan) { async signAndBroadcast(plan) {
const draft = plan && plan._draft; 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 rawBytes = hexToBytes(draft.raw_data_hex);
const digest = sha256(rawBytes); const digest = sha256(rawBytes);
const sig = secp256k1.sign(digest, this._priv, { prehash: false, lowS: false, format: "recovered" }); 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 = { const body = {
raw_data: draft.raw_data, raw_data: draft.raw_data,
raw_data_hex: draft.raw_data_hex, raw_data_hex: draft.raw_data_hex,
txID: draft.txID, txID: txid,
visible: true, visible: true,
signature: [bytesToHex(trxSig)], signature: [bytesToHex(trxSig)],
}; };
@ -299,8 +329,8 @@ module.exports = function makeTronAdapter({ HDKey, secp256k1, sha256, keccak_256
throw new Error("broadcast: " + msg); throw new Error("broadcast: " + msg);
} }
// Refresh soon so history/balance catch up. // Refresh soon so history/balance catch up.
setTimeout(() => this.refresh(false), 2500); setTimeout(() => { if (!this._disposed) this.refresh(false); }, 2500);
return { txid: draft.txID }; return { txid };
} }
// BIP137-style signing isn't standard on Tron; dapps use signMessageV2 // 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() { dispose() {
this._disposed = true;
clearTimeout(this._pollTimer); clearTimeout(this._pollTimer);
try { this._priv && this._priv.fill(0); } catch {} try { this._priv && this._priv.fill(0); } catch {}
try { this._root && this._root.fill(0); } catch {} try { this._root && this._root.fill(0); } catch {}

View file

@ -192,17 +192,14 @@ module.exports = function makeImportDerive({
function slip0010DeriveEd25519(seed, path) { function slip0010DeriveEd25519(seed, path) {
const HMAC_KEY = new TextEncoder().encode("ed25519 seed"); const HMAC_KEY = new TextEncoder().encode("ed25519 seed");
const parts = String(path).split("/").slice(1); const parts = String(path).split("/").slice(1);
// Compute master // HMAC-SHA512(HMAC_KEY, seed) → I = I_L || I_R, sk = I_L, cc = I_R; each
const enc = new (require("crypto")).createHmac ? require("crypto") : null; // step is HMAC-SHA512(cc, 0x00 || sk || idx). Node's own HMAC, same as
// Not using node crypto — the deps hand in @noble/hashes hmac via sha512. // chain-sol.js: the add-on runs in Electron main, and the previous
// We rely on secp256k1's helpers? No — use ed25519 utils. // `new require("crypto").createHmac` plus a bare require of an ESM-only
// Simplified: compute HMAC-SHA512(HMAC_KEY, seed) → I=I_L||I_R, sk=I_L, cc=I_R. // @noble/hashes subpath threw on every Solana seed import.
// Then each step: HMAC-SHA512(cc, 0x00 || sk || idx). const nodeCrypto = require("node:crypto");
// Implementation via @noble/hashes/hmac imported as `hmacSha512`. We const hmac = (_h, key, data) => new Uint8Array(nodeCrypto.createHmac("sha512", Buffer.from(key)).update(Buffer.from(data)).digest());
// require it lazily so unavailable deps error out here rather than at const sha512 = null;
// load time.
const { hmac } = require("@noble/hashes/hmac");
const { sha512 } = require("@noble/hashes/sha2");
let I = hmac(sha512, HMAC_KEY, seed); let I = hmac(sha512, HMAC_KEY, seed);
let sk = I.slice(0, 32); let cc = I.slice(32); let sk = I.slice(0, 32); let cc = I.slice(32);
for (const seg of parts) { for (const seg of parts) {

View file

@ -31,6 +31,26 @@ function ensureTransaction(txOrHex, libauth) {
return txOrHex; 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 }) { async function signTx({ request, account, branches, libauth, secp256k1 }) {
const { const {
generateSigningSerializationBCH, generateSigningSerializationBCH,
@ -55,10 +75,17 @@ async function signTx({ request, account, branches, libauth, secp256k1 }) {
const tx = ensureTransaction(inner.transaction, libauth); const tx = ensureTransaction(inner.transaction, libauth);
const sourceOutputs = (inner.sourceOutputs || []).map((o, i) => { const sourceOutputs = (inner.sourceOutputs || []).map((o, i) => {
if (o.contract) throw new Error(`wc-sign: input ${i} spends a contract — unsupported`); 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), lockingBytecode: o.lockingBytecode instanceof Uint8Array ? o.lockingBytecode : fromHex(o.lockingBytecode),
valueSatoshis: typeof o.valueSatoshis === "bigint" ? o.valueSatoshis : BigInt(o.valueSatoshis), 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) { if (sourceOutputs.length !== tx.inputs.length) {
throw new Error(`wc-sign: sourceOutputs (${sourceOutputs.length}) ≠ inputs (${tx.inputs.length})`); throw new Error(`wc-sign: sourceOutputs (${sourceOutputs.length}) ≠ inputs (${tx.inputs.length})`);