Aegis: the PIN is enforced by the host, and every spend is confirmed there

PIN
- The PIN blob wraps the vault master password under six digits and sat in
  plain add-on storage, so a copy of the profile reduced the master password
  to a million offline PBKDF2 guesses. It is sealed with the OS keystore
  before it is stored; a plain blob from an older build is sealed on first
  read. New blobs use 600k iterations.
- "Ask for PIN on every transaction" was decided by the host and enforced
  by nobody: `send` never checked it and dapp transactions had no PIN step.
  A gate is now cleared only by the master password the PIN unwraps,
  verified against the vault, which also opens a single-use transaction
  clearance. Panel sends consume one; dapp transactions ask the open panel
  and wait.

Spending and signing
- Consolidate emptied wallets on the panel's confirmation alone, defaulted
  to every sibling when no list was sent, and swept into whatever was
  selected at click time. It now needs explicit sources and the previewed
  destination, and shows the whole batch on the host overlay.
- Typed data for a chain other than the connected one is refused. Permit
  and Permit2 signatures name the spender, tokens, amounts and expiry, and
  an unlimited one gets the danger action. Previews are no longer cut at
  600/400 characters without saying so.
- eth.rpc relayed any eth_* call for sites that never connected.
- The WizardConnect overlay lists the tokens being spent and received.

Send form
- "0,5" was read as 5: the parser deleted commas. Amounts are parsed
  exactly; a decimal comma is a decimal, ambiguous or non-numeric input is
  refused, extra decimals are an error instead of being dropped.
- Send submits the request the summary was computed for, never a fresh read
  of the form, and stays off while a plan is pending or stale.
- Switching wallet resets the form instead of leaving a live button on the
  previous wallet's plan.
- The unlock field kept the master password after unlocking; the PIN pad
  kept its digits and kept counting keystrokes typed elsewhere as PIN
  attempts; an idle lock left a revealed key or seed form on screen.
- Enter confirmed a dialog even with focus on Cancel.
This commit is contained in:
Local Dev 2026-10-04 01:38:53 +02:00
parent 9e7e9d5e8b
commit f27f3d27c9
2 changed files with 525 additions and 82 deletions

View file

@ -975,6 +975,32 @@ function buildWcApproval(payload) {
} }
const inputCount = Array.isArray(req.inputPaths) ? req.inputPaths.length : (Array.isArray(inner?.inputs) ? inner.inputs.length : "?"); const inputCount = Array.isArray(req.inputPaths) ? req.inputPaths.length : (Array.isArray(inner?.inputs) ? inner.inputs.length : "?");
const rows = [{ label: "Wallet", value: walletName }, { label: "Inputs", value: String(inputCount) }]; const rows = [{ label: "Wallet", value: walletName }, { label: "Inputs", value: String(inputCount) }];
// What the wallet is putting IN. The outputs alone never showed that a
// transaction spends the user's tokens — and with the token prefix now
// signed correctly (wc-sign.js) such a transaction is valid, so the
// tokens leaving must be on the overlay.
const hexOf = (v) => (typeof v === "string" ? v.toLowerCase() : Buffer.from(v || []).toString("hex"));
const tokenText = (t) => {
if (!t) return "";
const cat = hexOf(t.category);
let amt = "0";
try { amt = BigInt(t.amount ?? 0).toString(); } catch {}
const nft = t.nft ? ` + NFT (${String(t.nft.capability || "none")})` : "";
return ` + token ${cat.slice(0, 8)}…${cat.slice(-4)}: ${amt} units${nft}`;
};
try {
const srcs = Array.isArray(tx.sourceOutputs) ? tx.sourceOutputs : [];
if (srcs.length) {
let inTotal = 0n;
const tokenLines = [];
srcs.forEach((o, i) => {
try { inTotal += BigInt(o.valueSatoshis ?? 0); } catch {}
if (o.token) tokenLines.push(`input #${i + 1}${tokenText(o.token)}`);
});
rows.push({ label: "Spending", value: `${fmtBch(Number(inTotal))} BCH from ${srcs.length} input${srcs.length === 1 ? "" : "s"}` });
if (tokenLines.length) rows.push({ label: "Tokens spent", value: tokenLines.slice(0, 8).join("\n") + (tokenLines.length > 8 ? `\n… and ${tokenLines.length - 8} more` : ""), mono: true, strong: true });
}
} catch {}
try { try {
const outs = inner?.outputs || []; const outs = inner?.outputs || [];
let total = 0n; let total = 0n;
@ -986,7 +1012,7 @@ function buildWcApproval(payload) {
else if (/^a914[0-9a-f]{40}87$/.test(hexScript)) addr = ctx.d.cashaddr.encode(prefix, 1, ctx.d.tx.fromHex(hexScript.slice(4, 44))); else if (/^a914[0-9a-f]{40}87$/.test(hexScript)) addr = ctx.d.cashaddr.encode(prefix, 1, ctx.d.tx.fromHex(hexScript.slice(4, 44)));
const v = BigInt(o.valueSatoshis ?? 0); const v = BigInt(o.valueSatoshis ?? 0);
total += v; total += v;
const token = o.token ? " + CashTokens" : ""; const token = tokenText(o.token);
rows.push({ label: `Output #${i + 1}`, value: `${fmtBch(Number(v))} BCH${token} → ${addr || (hexScript.startsWith("6a") ? "OP_RETURN data" : "script " + hexScript.slice(0, 24) + "…")}`, mono: true }); rows.push({ label: `Output #${i + 1}`, value: `${fmtBch(Number(v))} BCH${token} → ${addr || (hexScript.startsWith("6a") ? "OP_RETURN data" : "script " + hexScript.slice(0, 24) + "…")}`, mono: true });
}); });
if (outs.length > 8) rows.push({ label: "Outputs", value: `… and ${outs.length - 8} more` }); if (outs.length > 8) rows.push({ label: "Outputs", value: `… and ${outs.length - 8} more` });
@ -1160,6 +1186,86 @@ function requireWallet(id) {
return rt; return rt;
} }
function requireSelected() { return requireWallet(selectedWalletId()); } function requireSelected() { return requireWallet(selectedWalletId()); }
// ---- PIN: sealed blob + host-verified transaction clearance ---------------
// The PIN blob is the vault master password wrapped under a 6-digit PIN. In
// plain add-on storage that made the master password exactly as strong as a
// million PBKDF2 guesses to anyone holding a copy of the profile (a backup,
// another machine, a disk image) — the panel's lockout counter never sees an
// offline guess. It is therefore sealed with the OS keystore (DPAPI /
// Keychain / libsecret) before it touches disk, the same as Theseus's own
// vault PIN: a copied file is useless without this OS account. A plain blob
// from an older build is sealed the first time it is read.
const PIN_BLOB_KEY = "aegis/pin/v1";
function sealPinBlob(api, blob) {
const ss = safeStorageOr(api);
try {
if (ss && ss.isEncryptionAvailable()) {
return { v: 2, sealed: ss.encryptString(JSON.stringify(blob)).toString("base64") };
}
} catch { /* fall through: unsealed is still better than no PIN at all */ }
return blob;
}
function openPinBlob(api) {
const b = api.storage.get(PIN_BLOB_KEY, null);
if (!b || typeof b !== "object") return null;
if (b.sealed) {
const ss = safeStorageOr(api);
if (!ss) return null;
try {
const plain = JSON.parse(ss.decryptString(Buffer.from(String(b.sealed), "base64")));
return (plain && typeof plain === "object") ? plain : null;
} catch { return null; } // sealed under another OS account: the PIN is simply gone
}
const plain = { salt: b.salt, iv: b.iv, ct: b.ct, iters: b.iters };
const sealed = sealPinBlob(api, plain);
if (sealed.sealed) api.storage.set(PIN_BLOB_KEY, sealed);
return plain;
}
// "Ask for PIN on every transaction" used to be decided by the host and
// enforced by nobody: `send` never checked it, and a dapp-initiated
// transaction had no PIN step at all. A clearance is now a short-lived,
// single-use fact recorded only after the host itself has verified the
// master password the PIN unwraps (pinGateSatisfied). Panel sends consume
// one; dapp transactions ask the open panel for one and wait.
const TX_CLEARANCE_MS = 90_000;
const DAPP_PIN_WAIT_MS = 120_000;
let txClearanceUntil = 0;
let pendingPinRequest = null; // { origin, what, at } while a dapp tx waits for the PIN
function txPinOwed() {
try { return !!(ctx && ctx.pinNeeded && ctx.pinNeeded("transaction").needPin); }
catch { return true; } // the safe direction for a lock is closed
}
function takeTxClearance() {
if (Date.now() < txClearanceUntil) { txClearanceUntil = 0; return true; }
return false;
}
function requirePanelTxPin() {
if (txPinOwed() && !takeTxClearance()) throw new Error("PIN required — confirm your PIN to continue");
}
async function requireDappTxPin(origin, what) {
if (!txPinOwed() || takeTxClearance()) return;
pendingPinRequest = { origin: String(origin || ""), what: String(what || "transaction"), at: Date.now() };
try {
try { ctx.api.emit("pinRequest", pendingPinRequest); } catch {}
const deadline = Date.now() + DAPP_PIN_WAIT_MS;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 250));
if (!ctx) break;
if (takeTxClearance()) return;
}
} finally { pendingPinRequest = null; }
throw new Error("Aegis asks for your PIN on every transaction. Open the Aegis panel, confirm your PIN there, then try again.");
}
// Signed text shown on an approval overlay. Long messages are cut for the
// overlay's sake, but never silently: the cut says how much is missing, so a
// benign-looking opening cannot hide what the rest commits the user to.
function previewText(text, max = 1500) {
const s = String(text);
return s.length > max ? `${s.slice(0, max)}\n… [${s.length - max} more characters are NOT shown but will be signed]` : s;
}
function fromPanel(m) { if (!m || m.from !== "panel") throw new Error("panel-only message"); } function fromPanel(m) { if (!m || m.from !== "panel") throw new Error("panel-only message"); }
function fromPage(m) { function fromPage(m) {
if (!m || m.from !== "page" || !m.origin) throw new Error("page-only message"); if (!m || m.from !== "page" || !m.origin) throw new Error("page-only message");
@ -1635,6 +1741,7 @@ function registerPanelMessages(api) {
}); });
api.onMessage("sendToken", async (p, m) => { api.onMessage("sendToken", async (p, m) => {
fromPanel(m); fromPanel(m);
requirePanelTxPin();
const rt = requireSelected(); const rt = requireSelected();
if (rt.entry.chain !== "sol") throw new Error("token send is Solana-only"); if (rt.entry.chain !== "sol") throw new Error("token send is Solana-only");
const plan = await rt.adapter.planTokenTransfer(p || {}); const plan = await rt.adapter.planTokenTransfer(p || {});
@ -1659,6 +1766,13 @@ function registerPanelMessages(api) {
// Execute a send with approval overlay. // Execute a send with approval overlay.
api.onMessage("send", async (p, m) => { api.onMessage("send", async (p, m) => {
fromPanel(m); fromPanel(m);
requirePanelTxPin();
// The panel names the wallet its form was built for. If the selection
// moved since (another surface, a late state push), refuse rather than
// send this amount from a different wallet.
if (p && p.walletId && String(p.walletId) !== selectedWalletId()) {
throw new Error("the selected wallet changed — review the send and try again");
}
const rt = requireSelected(); const rt = requireSelected();
const plan = await Promise.resolve(rt.adapter.plan(p || {})); const plan = await Promise.resolve(rt.adapter.plan(p || {}));
const d = describePlan(plan, rt.entry.chain, rt.entry.network); const d = describePlan(plan, rt.entry.chain, rt.entry.network);
@ -1752,26 +1866,39 @@ function registerPanelMessages(api) {
api.onMessage("consolidateIntoSelected", async (p, m) => { api.onMessage("consolidateIntoSelected", async (p, m) => {
fromPanel(m); fromPanel(m);
requirePanelTxPin();
const destId = selectedWalletId(); const destId = selectedWalletId();
if (!destId) throw new Error("no wallet selected"); if (!destId) throw new Error("no wallet selected");
// The destination is the wallet the PREVIEW was drawn for, not whatever
// is selected by the time the button is pressed: a preview painted for A
// followed by a switch to B used to sweep everything into B.
if (p && p.destinationWalletId && String(p.destinationWalletId) !== destId) {
throw new Error("the selected wallet changed since this preview — reopen Consolidate");
}
const destEntry = walletEntries().find((w) => w.id === destId); const destEntry = walletEntries().find((w) => w.id === destId);
if (!destEntry) throw new Error("selected wallet not found"); if (!destEntry) throw new Error("selected wallet not found");
const destRt = ctx.runtimes.get(destId); const destRt = ctx.runtimes.get(destId);
if (!destRt?.adapter) throw new Error("destination wallet not ready"); if (!destRt?.adapter) throw new Error("destination wallet not ready");
const destAddr = destRt.adapter.snapshot().address; const destAddr = destRt.adapter.snapshot().address;
if (!destAddr) throw new Error("destination wallet has no receive address"); if (!destAddr) throw new Error("destination wallet has no receive address");
// sourceIds are the wallets the user CHECKED in the preview. Defaults // sourceIds are the wallets the user CHECKED. They are required: an
// to every eligible sibling if the panel omits the field (safety net, // omitted list used to mean "every sibling wallet".
// shouldn't happen in normal flow). if (!Array.isArray(p?.sourceIds) || !p.sourceIds.length) throw new Error("choose at least one wallet to consolidate");
const requested = Array.isArray(p?.sourceIds) && p.sourceIds.length const requested = new Set(p.sourceIds.map(String));
? new Set(p.sourceIds.map(String))
: null;
const sources = walletEntries().filter((w) => const sources = walletEntries().filter((w) =>
w.chain === destEntry.chain && w.chain === destEntry.chain &&
w.network === destEntry.network && w.network === destEntry.network &&
w.id !== destId && w.id !== destId &&
(!requested || requested.has(w.id)), requested.has(w.id),
); );
if (!sources.length) throw new Error("none of the chosen wallets can be consolidated into this one");
// Plan every sweep first, then put the WHOLE batch on the host overlay.
// This emptied wallets on the panel's say-so alone; every other spend
// in Aegis is confirmed on a surface the panel cannot draw.
const meta = chainMeta(destEntry.chain, destEntry.network);
const dec = meta?.decimals ?? 8;
const planned = [];
const results = []; const results = [];
for (const src of sources) { for (const src of sources) {
const rt = ctx.runtimes.get(src.id); const rt = ctx.runtimes.get(src.id);
@ -1781,6 +1908,38 @@ function registerPanelMessages(api) {
} }
try { try {
const plan = await Promise.resolve(rt.adapter.plan({ to: destAddr, sendMax: true })); const plan = await Promise.resolve(rt.adapter.plan({ to: destAddr, sendMax: true }));
planned.push({ src, rt, plan });
} catch (e) {
results.push({ walletId: src.id, label: src.label, ok: false, error: e?.message || String(e) });
}
}
if (planned.length) {
let totalNet = 0n, totalFee = 0n;
const rows = planned.slice(0, 12).map(({ src, plan }) => {
const net = BigInt(String(plan.recipients?.[0]?.value ?? 0));
const fee = BigInt(String(plan.fee ?? 0));
totalNet += net; totalFee += fee;
return { label: "Empty", value: `${src.label} — ${fmtValue(net.toString(), dec)} ${meta?.ticker || ""}`, mono: true };
});
for (const { plan } of planned.slice(12)) {
totalNet += BigInt(String(plan.recipients?.[0]?.value ?? 0));
totalFee += BigInt(String(plan.fee ?? 0));
}
if (planned.length > 12) rows.push({ label: "…", value: `and ${planned.length - 12} more wallets` });
rows.push({ label: "Into", value: `${destEntry.label} — ${destAddr}`, mono: true });
rows.push({ label: "Arrives", value: `${fmtValue(totalNet.toString(), dec)} ${meta?.ticker || ""}`, strong: true });
rows.push({ label: "Fees", value: `${fmtValue(totalFee.toString(), dec)} ${meta?.ticker || ""} across ${planned.length} transaction${planned.length === 1 ? "" : "s"}` });
const pick = await api.approvalModal({
title: `Consolidate ${planned.length} wallet${planned.length === 1 ? "" : "s"}?`,
origin: "Aegis wallet panel",
body: "Each wallet listed is emptied into the destination in its own transaction. This cannot be undone.",
rows,
actions: [{ id: "send", label: "Consolidate", primary: true }],
});
if (pick !== "send") throw new Error("cancelled");
}
for (const { src, rt, plan } of planned) {
try {
const r = await rt.adapter.signAndBroadcast(plan); const r = await rt.adapter.signAndBroadcast(plan);
results.push({ results.push({
walletId: src.id, label: src.label, ok: true, walletId: src.id, label: src.label, ok: true,
@ -1873,6 +2032,7 @@ function registerPanelMessages(api) {
api.onMessage("promoteToHd", async (p, m) => { api.onMessage("promoteToHd", async (p, m) => {
fromPanel(m); fromPanel(m);
requirePanelTxPin();
const { entry, rt } = promotableImport(p && p.walletId); const { entry, rt } = promotableImport(p && p.walletId);
const dest = await createVaultWallet({ const dest = await createVaultWallet({
chain: entry.chain, network: entry.network, chain: entry.chain, network: entry.network,
@ -2343,8 +2503,7 @@ function registerPanelMessages(api) {
// the opaque blob + a small policy object in and out of api.storage. // the opaque blob + a small policy object in and out of api.storage.
api.onMessage("pinBlobGet", (_p, m) => { api.onMessage("pinBlobGet", (_p, m) => {
fromPanel(m); fromPanel(m);
const b = api.storage.get("aegis/pin/v1", null); return openPinBlob(api);
return (b && typeof b === "object") ? b : null;
}); });
api.onMessage("pinBlobSet", (p, m) => { api.onMessage("pinBlobSet", (p, m) => {
fromPanel(m); fromPanel(m);
@ -2353,7 +2512,7 @@ function registerPanelMessages(api) {
if (typeof blob.salt !== "string" || typeof blob.iv !== "string" || typeof blob.ct !== "string" || typeof blob.iters !== "number") { if (typeof blob.salt !== "string" || typeof blob.iv !== "string" || typeof blob.ct !== "string" || typeof blob.iters !== "number") {
throw new Error("blob shape invalid"); throw new Error("blob shape invalid");
} }
api.storage.set("aegis/pin/v1", { salt: blob.salt, iv: blob.iv, ct: blob.ct, iters: blob.iters }); api.storage.set(PIN_BLOB_KEY, sealPinBlob(api, { salt: blob.salt, iv: blob.iv, ct: blob.ct, iters: blob.iters }));
return true; return true;
}); });
api.onMessage("pinBlobClear", (_p, m) => { api.onMessage("pinBlobClear", (_p, m) => {
@ -2442,13 +2601,32 @@ function registerPanelMessages(api) {
const event = String(p && p.event || "panel-load"); const event = String(p && p.event || "panel-load");
return { ...pinNeeded(event), event, policy: pinPolicy() }; return { ...pinNeeded(event), event, policy: pinPolicy() };
}); });
// Called only after the panel has actually decrypted the PIN blob, which // The panel decrypts the PIN blob and hands over what was inside it. That
// is proof of the PIN and not merely a claim about it. // is the vault master password, and the host checks it against the vault
api.onMessage("pinGateSatisfied", (_p, m) => { // itself — so a gate is cleared by proof the host verified, not by the
// panel saying so. The same proof opens a single-use transaction
// clearance (see requirePanelTxPin / requireDappTxPin).
api.onMessage("pinGateSatisfied", async (p, m) => {
fromPanel(m); fromPanel(m);
const pw = String((p && p.masterPassword) || "");
if (!pw) throw new Error("PIN proof required");
if (!api.vault?.lifecycle || typeof api.vault.lifecycle.unlock !== "function") throw new Error("this build cannot verify a PIN");
try { await api.vault.lifecycle.unlock(pw); }
catch { throw new Error("PIN proof rejected"); }
api.storage.set("aegis/pin/gate", { lastOkAt: Date.now(), bootId: BOOT_ID }); api.storage.set("aegis/pin/gate", { lastOkAt: Date.now(), bootId: BOOT_ID });
txClearanceUntil = Date.now() + TX_CLEARANCE_MS;
return true; return true;
}); });
// A panel opened while a dapp transaction is waiting for the PIN picks the
// request up here; one already open gets the "pinRequest" event instead.
api.onMessage("pinRequestPending", (_p, m) => {
fromPanel(m);
return pendingPinRequest ? { ...pendingPinRequest } : null;
});
ctx.pinNeeded = pinNeeded;
// Seal a plain blob left by an older build now, not when the panel next
// happens to open.
try { openPinBlob(api); } catch {}
api.onMessage("securityGet", (_p, m) => { api.onMessage("securityGet", (_p, m) => {
fromPanel(m); fromPanel(m);
@ -2725,7 +2903,7 @@ function previewBytes(bytes, max = 400) {
let text = null; let text = null;
try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch {} try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch {}
if (text != null && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(text)) { if (text != null && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(text)) {
return text.length > max ? text.slice(0, max) + "…" : text; return previewText(text, Math.max(max, 1500));
} }
return `<${bytes.length} bytes: 0x${Buffer.from(bytes.subarray(0, 30)).toString("hex")}${bytes.length > 30 ? "…" : ""}>`; return `<${bytes.length} bytes: 0x${Buffer.from(bytes.subarray(0, 30)).toString("hex")}${bytes.length > 30 ? "…" : ""}>`;
} }
@ -2852,6 +3030,56 @@ function solInstructionRows(parsed) {
return rows; return rows;
} }
const ETH_RPC_PRECONNECT = new Set(["eth_chainId", "net_version", "eth_blockNumber"]);
const ETH_RPC_NEVER = /^(eth_sign|eth_signTransaction|eth_sendTransaction|eth_accounts|eth_requestAccounts|eth_coinbase|eth_signTypedData|eth_newFilter|eth_newBlockFilter|eth_newPendingTransactionFilter|eth_uninstallFilter|eth_getFilterChanges|eth_getFilterLogs|eth_subscribe|eth_unsubscribe|eth_mining|eth_submit|eth_getWork)/;
// EIP-2612 Permit, DAI-style permit, and Uniswap Permit2 (PermitSingle /
// PermitBatch / PermitTransferFrom / PermitBatchTransferFrom). Returns the
// overlay rows that say who may spend what until when, or null when the
// typed data is not a spending approval.
function describePermit(td) {
const primary = String(td?.primaryType || "");
if (!/^Permit/.test(primary)) return null;
const msg = (td && typeof td.message === "object" && td.message) || {};
const UNLIMITED = 1n << 159n; // ≥ half of uint160 covers Permit2's max and every uint256 max
const big = (v) => { try { return BigInt(v); } catch { return null; } };
const amountText = (v) => { const b = big(v); return b === null ? String(v) : (b >= UNLIMITED ? "UNLIMITED (∞)" : b.toString() + " units"); };
const when = (v) => {
const b = big(v);
if (b === null) return String(v);
if (b >= 4102444800n) return "never expires"; // 2100-01-01 and beyond
try { return new Date(Number(b) * 1000).toISOString().replace("T", " ").slice(0, 16) + " UTC"; } catch { return b.toString(); }
};
const rows = [];
let risky = false;
const spender = msg.spender;
rows.push({ label: "Spender", value: spender ? String(spender) : "(none named — anyone holding this signature)", mono: true, strong: true });
if (!spender) risky = true;
const items = [];
if (primary === "Permit") {
// EIP-2612 has `value`; DAI has `allowed: true` (always unlimited).
if ("allowed" in msg) items.push({ token: td.domain?.verifyingContract, amount: msg.allowed ? UNLIMITED : 0n });
else items.push({ token: td.domain?.verifyingContract, amount: msg.value });
if (msg.deadline != null || msg.expiry != null) rows.push({ label: "Valid until", value: when(msg.deadline ?? msg.expiry) });
} else {
const list = Array.isArray(msg.details) ? msg.details : (msg.details ? [msg.details] : (Array.isArray(msg.permitted) ? msg.permitted : (msg.permitted ? [msg.permitted] : [])));
for (const d of list) items.push({ token: d?.token, amount: d?.amount, expiration: d?.expiration });
if (msg.sigDeadline != null || msg.deadline != null) rows.push({ label: "Signature valid until", value: when(msg.sigDeadline ?? msg.deadline) });
}
items.slice(0, 10).forEach((it, i) => {
const b = big(it.amount);
if (b !== null && b >= UNLIMITED) risky = true;
rows.push({
label: items.length > 1 ? `Token #${i + 1}` : "Token",
value: `${it.token || "?"} — ${amountText(it.amount)}${it.expiration != null ? ` · allowance ${when(it.expiration)}` : ""}`,
mono: true,
});
});
if (items.length > 10) { rows.push({ label: "Tokens", value: `… and ${items.length - 10} more` }); risky = true; }
if (!items.length) { rows.push({ label: "Token", value: "(could not read the approval — treat as unlimited)" }); risky = true; }
return { rows, risky };
}
async function withOriginLock(origin, fn) { async function withOriginLock(origin, fn) {
if (pendingByOrigin.has(origin)) throw new Error("a wallet request from this site is already waiting for approval"); if (pendingByOrigin.has(origin)) throw new Error("a wallet request from this site is already waiting for approval");
pendingByOrigin.add(origin); pendingByOrigin.add(origin);
@ -2922,6 +3150,9 @@ function registerPageMessages(api) {
const budget = perms[origin] && perms[origin].sendTx; const budget = perms[origin] && perms[origin].sendTx;
const remaining = budget ? Math.max(0, (budget.capSats | 0) - (budget.usedSats | 0)) : 0; const remaining = budget ? Math.max(0, (budget.capSats | 0) - (budget.usedSats | 0)) : 0;
if (budget && d.total <= remaining) { if (budget && d.total <= remaining) {
// An allowance skips the overlay, not the PIN the user asked for on
// every transaction.
await requireDappTxPin(origin, "Bitcoin Cash payment");
const r = await rt.adapter.signAndBroadcast(plan); const r = await rt.adapter.signAndBroadcast(plan);
budget.usedSats = (budget.usedSats | 0) + d.total; budget.usedSats = (budget.usedSats | 0) + d.total;
api.storage.set("permissions", perms); api.storage.set("permissions", perms);
@ -2948,6 +3179,7 @@ function registerPageMessages(api) {
}); });
const [action, ...flags] = pick.split("+"); const [action, ...flags] = pick.split("+");
if (action !== "send") throw new Error("user rejected"); if (action !== "send") throw new Error("user rejected");
await requireDappTxPin(origin, "Bitcoin Cash payment");
const cap = flags.find((f) => f.startsWith("cap=")); const cap = flags.find((f) => f.startsWith("cap="));
const capSats = cap ? Number(cap.slice(4)) : 0; const capSats = cap ? Number(cap.slice(4)) : 0;
if (BCH_ALLOWANCES.includes(capSats)) { if (BCH_ALLOWANCES.includes(capSats)) {
@ -2974,7 +3206,7 @@ function registerPageMessages(api) {
origin, origin,
body: "Signing proves you control the address below. It moves no coins.", body: "Signing proves you control the address below. It moves no coins.",
rows: [ rows: [
{ label: "Message", value: message.length > 400 ? message.slice(0, 400) + "…" : message, mono: true }, { label: "Message", value: previewText(message), mono: true },
{ label: "Address", value: rt.adapter.current().address, mono: true }, { label: "Address", value: rt.adapter.current().address, mono: true },
], ],
actions: [{ id: "sign", label: "Sign", primary: true }], actions: [{ id: "sign", label: "Sign", primary: true }],
@ -3135,6 +3367,7 @@ function registerPageMessages(api) {
actions: [{ id: "sign", label: risky ? "Sign anyway" : "Sign", primary: !risky, danger: risky }], actions: [{ id: "sign", label: risky ? "Sign anyway" : "Sign", primary: !risky, danger: risky }],
}); });
if (pick !== "sign") throw new Error("user rejected"); if (pick !== "sign") throw new Error("user rejected");
await requireDappTxPin(origin, "Tron transaction");
const sig = rt.adapter.signRawData(tx.raw_data_hex); const sig = rt.adapter.signRawData(tx.raw_data_hex);
rememberSignedTron(decoded.txid); rememberSignedTron(decoded.txid);
return { ...tx, txID: decoded.txid, signature: [sig] }; return { ...tx, txID: decoded.txid, signature: [sig] };
@ -3167,7 +3400,7 @@ function registerPageMessages(api) {
origin, origin,
body: "Signing proves you control this address. It moves no coins.", body: "Signing proves you control this address. It moves no coins.",
rows: [ rows: [
{ label: "Message", value: message.length > 400 ? message.slice(0, 400) + "…" : message, mono: true }, { label: "Message", value: previewText(message), mono: true },
{ label: "Address", value: snap.address, mono: true }, { label: "Address", value: snap.address, mono: true },
], ],
actions: [{ id: "sign", label: "Sign", primary: true }], actions: [{ id: "sign", label: "Sign", primary: true }],
@ -3313,6 +3546,7 @@ function registerPageMessages(api) {
actions: [{ id: "send", label: risky ? "Send anyway" : "Send", primary: !risky, danger: risky }], actions: [{ id: "send", label: risky ? "Send anyway" : "Send", primary: !risky, danger: risky }],
}); });
if (pick !== "send") throw new Error("user rejected"); if (pick !== "send") throw new Error("user rejected");
await requireDappTxPin(origin, "Ethereum transaction");
const r = await rt.adapter.signAndBroadcast(plan); const r = await rt.adapter.signAndBroadcast(plan);
return { txid: r.txid }; return { txid: r.txid };
}); });
@ -3335,23 +3569,46 @@ function registerPageMessages(api) {
// truncated JSON preview of the message so the user has a fighting // truncated JSON preview of the message so the user has a fighting
// chance to spot phishing. // chance to spot phishing.
const dom = td.domain || {}; const dom = td.domain || {};
const snapTd = rt.adapter.snapshot();
// A signature for another chain is the standard way to get an approval
// the user believes is for a testnet or a sidechain. MetaMask refuses a
// domain whose chainId is not the active chain; so does Aegis.
if (dom.chainId != null && dom.chainId !== "") {
let domChain = null;
try { domChain = BigInt(dom.chainId); } catch {}
if (domChain === null || domChain !== BigInt(snapTd.chainId)) {
throw ethError(`typed data is for chain ${dom.chainId} but this site is connected on chain ${snapTd.chainId}`, 4901);
}
}
const domainSummary = [dom.name, dom.version && `v${dom.version}`, dom.chainId && `chain ${dom.chainId}`].filter(Boolean).join(" · ") || "(no domain)"; const domainSummary = [dom.name, dom.version && `v${dom.version}`, dom.chainId && `chain ${dom.chainId}`].filter(Boolean).join(" · ") || "(no domain)";
const messagePreview = JSON.stringify(td.message, null, 2); const messagePreview = JSON.stringify(td.message, (_k, v) => (typeof v === "bigint" ? v.toString() : v), 2) || "";
const preview = messagePreview.length > 600 ? messagePreview.slice(0, 600) + "…" : messagePreview; const rows = [{ label: "Domain", value: domainSummary }];
if (dom.verifyingContract) rows.push({ label: "Contract", value: String(dom.verifyingContract), mono: true });
rows.push({ label: "Primary type", value: String(td.primaryType || "") });
// Off-chain approvals. A Permit / Permit2 signature moves no gas and
// shows no transaction, yet lets the spender take the tokens later — it
// is how most wallet drains happen now. Pull the spender, the amounts
// and the deadline out of the message and say what they mean.
const permit = describePermit(td);
if (permit) for (const r of permit.rows) rows.push(r);
rows.push({ label: "Message", value: previewText(messagePreview, 3000), mono: true });
rows.push({ label: "Address", value: snapTd.address, mono: true });
const risky = !!(permit && permit.risky);
return withOriginLock(origin, async () => { return withOriginLock(origin, async () => {
const pick = await api.approvalModal({ const pick = await api.approvalModal({
title: "Sign typed data (EIP-712)?", title: permit ? "Sign a token spending permit?" : "Sign typed data (EIP-712)?",
origin, origin,
body: "The site is asking you to sign a structured message. Verify the domain matches the site you're on — a mismatched domain is the classic phishing tell.", body: permit
rows: [ ? (risky
{ label: "Domain", value: domainSummary }, ? "WARNING: signing this lets the spender below take these tokens at any time, without another prompt and without a transaction from you. Only sign if you fully trust this site."
{ label: "Primary type", value: String(td.primaryType || "") }, : "Signing this lets the spender below move the stated amount of your tokens without a transaction from you.")
{ label: "Message", value: preview, mono: true }, : "The site is asking you to sign a structured message. Verify the domain matches the site you're on — a mismatched domain is the classic phishing tell.",
{ label: "Address", value: rt.adapter.snapshot().address, mono: true }, rows,
], actions: [{ id: "sign", label: risky ? "Sign anyway" : "Sign", primary: !risky, danger: risky }],
actions: [{ id: "sign", label: "Sign", primary: true }],
}); });
if (pick !== "sign") throw new Error("user rejected"); if (pick !== "sign") throw new Error("user rejected");
// A permit moves tokens as surely as a transaction does.
if (permit) await requireDappTxPin(origin, "token spending permit");
return rt.adapter.signTypedDataDigest(digest); return rt.adapter.signTypedDataDigest(digest);
}); });
}); });
@ -3472,6 +3729,13 @@ function registerPageMessages(api) {
const method = String(p && p.method || ""); const method = String(p && p.method || "");
const params = (p && p.params) || []; const params = (p && p.params) || [];
if (!/^eth_|^net_|^web3_/.test(method)) throw new Error("Aegis: only eth_/net_/web3_ read methods are passed through"); if (!/^eth_|^net_|^web3_/.test(method)) throw new Error("Aegis: only eth_/net_/web3_ read methods are passed through");
// The user's RPC endpoint is theirs (often a keyed, metered one). A site
// that has not connected gets the three calls every dapp makes before
// asking to connect and nothing else; signing, account and filter-state
// methods are never relayed, and broadcasting needs a connection.
const connected = ethConnectedFor(origin);
if (!connected && !ETH_RPC_PRECONNECT.has(method)) throw ethError("not connected — call eth_requestAccounts first", 4100);
if (ETH_RPC_NEVER.test(method)) throw ethError(`Aegis does not relay ${method}`, 4200);
return rt.adapter._client.call(method, params); return rt.adapter._client.call(method, params);
}); });
@ -3560,6 +3824,7 @@ function registerPageMessages(api) {
actions: [{ id: "sign", label: "Sign", primary: true }], actions: [{ id: "sign", label: "Sign", primary: true }],
}); });
if (pick !== "sign") throw new Error("user rejected"); if (pick !== "sign") throw new Error("user rejected");
await requireDappTxPin(origin, "Solana transaction");
return rt.adapter.signBytes(messageBytes); return rt.adapter.signBytes(messageBytes);
}); });
}); });
@ -3599,6 +3864,7 @@ function registerPageMessages(api) {
actions: [{ id: "send", label: "Sign & send", primary: true }], actions: [{ id: "send", label: "Sign & send", primary: true }],
}); });
if (pick !== "send") throw new Error("user rejected"); if (pick !== "send") throw new Error("user rejected");
await requireDappTxPin(origin, "Solana transaction");
// Sign the exact message bytes and patch our slot. signMessage() ran // Sign the exact message bytes and patch our slot. signMessage() ran
// the bytes through String(), so every signature was over "1,2,3,…" // the bytes through String(), so every signature was over "1,2,3,…"
// and the network rejected it. Partial signatures already in the wire // and the network rejected it. Partial signatures already in the wire
@ -3698,7 +3964,10 @@ module.exports = {
body, rows, body, rows,
actions: [{ id: "approve", label: "Sign", primary: true }], actions: [{ id: "approve", label: "Sign", primary: true }],
}); });
return { approved: pick === "approve" }; if (pick !== "approve") return { approved: false };
try { await requireDappTxPin(dappName, "Bitcoin Cash transaction (WizardConnect)"); }
catch (e) { api.log("wc sign:", e?.message || e); return { approved: false }; }
return { approved: true };
}, },
}); });
c.wc.onStateChange(() => emitState()); c.wc.onStateChange(() => emitState());

View file

@ -306,7 +306,7 @@ function fiatSkeleton() {
// per-blob IV. The addon (main process) only handles the opaque blob; the // per-blob IV. The addon (main process) only handles the opaque blob; the
// panel never sends the raw PIN or the master password to it. The rate // panel never sends the raw PIN or the master password to it. The rate
// limiter is stored addon-side so reloading the panel cannot reset it. // limiter is stored addon-side so reloading the panel cannot reset it.
const PIN_ITERS = 200000; const PIN_ITERS = 600000; // new blobs only; an existing blob carries its own count
const PIN_MAX_FAILS = 5; const PIN_MAX_FAILS = 5;
const PIN_LOCKOUT_MS = 15 * 60 * 1000; const PIN_LOCKOUT_MS = 15 * 60 * 1000;
const b2h = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); const b2h = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
@ -377,8 +377,13 @@ function bindIdleAutoLock() {
if (!s || s.phase !== "ready") return; if (!s || s.phase !== "ready") return;
try { try {
state = await S.invoke("vaultLock"); state = await S.invoke("vaultLock");
stripView = { mode: "coins", groupKey: null }; // Start the panel over rather than re-render it. Whatever was open
render(); // goes with the page: a revealed key, an import form holding a seed
// phrase, a half-filled send, the password remembered for PIN
// enrolment. Painting a lock screen underneath left all of that on
// top of a "locked" wallet.
try { delete window.__aegisLastPw; } catch {}
location.reload();
} catch (e) { /* silent — user activity will retry */ } } catch (e) { /* silent — user activity will retry */ }
}, mins * 60 * 1000); }, mins * 60 * 1000);
}; };
@ -1009,7 +1014,12 @@ function aegisConfirm(opts) {
}; };
const onKey = (e) => { const onKey = (e) => {
if (e.key === "Escape") { e.stopPropagation(); finish(false); } if (e.key === "Escape") { e.stopPropagation(); finish(false); }
else if (e.key === "Enter") { e.stopPropagation(); finish(true); } else if (e.key === "Enter") {
// Enter means "the focused button". It used to mean yes even with
// focus on Cancel, so Tab → Enter removed the wallet.
e.preventDefault(); e.stopPropagation();
finish(document.activeElement?.dataset?.ac !== "no");
}
}; };
document.addEventListener("keydown", onKey, true); document.addEventListener("keydown", onKey, true);
overlay.addEventListener("click", (e) => { if (e.target === overlay) finish(false); }); overlay.addEventListener("click", (e) => { if (e.target === overlay) finish(false); });
@ -1017,8 +1027,10 @@ function aegisConfirm(opts) {
e.stopPropagation(); e.stopPropagation();
finish(b.dataset.ac === "yes"); finish(b.dataset.ac === "yes");
})); }));
// A destructive question opens on Cancel, like the host's own overlay.
const yes = overlay.querySelector('[data-ac="yes"]'); const yes = overlay.querySelector('[data-ac="yes"]');
if (yes) yes.focus(); const no = overlay.querySelector('[data-ac="no"]');
if (o.danger && no) no.focus(); else if (yes) yes.focus();
}); });
} }
@ -2208,7 +2220,7 @@ function openConsolidateModal() {
} }
setBusy(true, "Sending…"); setBusy(true, "Sending…");
try { try {
const res = await S.invoke("consolidateIntoSelected", { sourceIds: checked }); const res = await S.invoke("consolidateIntoSelected", { sourceIds: checked, destinationWalletId: preview?.destinationWalletId });
renderResult(res); renderResult(res);
} catch (e) { } catch (e) {
msg.className = "msg err"; msg.textContent = cleanErr(e); msg.hidden = false; msg.className = "msg err"; msg.textContent = cleanErr(e); msg.hidden = false;
@ -2327,7 +2339,7 @@ async function renderConsolidateInline(hostEl) {
const go = hostEl.querySelector("#inconGo"); const go = hostEl.querySelector("#inconGo");
go.disabled = true; go.textContent = "Sending…"; go.disabled = true; go.textContent = "Sending…";
try { try {
const res = await S.invoke("consolidateIntoSelected", { sourceIds: checked }); const res = await S.invoke("consolidateIntoSelected", { sourceIds: checked, destinationWalletId: preview?.destinationWalletId });
const okCount = res.results.filter((r) => r.ok).length; const okCount = res.results.filter((r) => r.ok).length;
const badCount = res.results.length - okCount; const badCount = res.results.length - okCount;
const explorer = sel()?.explorerTx || ""; const explorer = sel()?.explorerTx || "";
@ -3440,6 +3452,11 @@ function renderLockScreen(phase) {
// as the panel navigates or reloads. // as the panel navigates or reloads.
window.__aegisLastPw = pw; window.__aegisLastPw = pw;
setTimeout(() => { try { delete window.__aegisLastPw; } catch {} }, 60_000); setTimeout(() => { try { delete window.__aegisLastPw; } catch {} }, 60_000);
// Empty the field and let the form be rebuilt next time. It stayed
// filled behind the unlocked wallet, so after an idle lock or Sign out
// the password was sitting in the box for anyone to press Unlock.
try { $("gateUnlockPw").value = ""; } catch {}
body.dataset.mode = "";
body.dataset.forcePw = ""; body.dataset.forcePw = "";
render(); render();
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
@ -3481,13 +3498,22 @@ function setupPinPad({ dots, keys, err, onComplete }) {
try { res = await onComplete(buf); } try { res = await onComplete(buf); }
finally { finally {
keys.querySelectorAll("button").forEach((x) => x.disabled = false); keys.querySelectorAll("button").forEach((x) => x.disabled = false);
if (res !== "ok") { buf = ""; paint(); } // Always forget the digits. A pad that kept its six after "ok" was
// dead on the next lock and held the PIN in memory until then.
buf = ""; paint();
} }
} }
})); }));
// Keyboard fallback — some users prefer typing 6 digits fast. // Keyboard fallback — some users prefer typing 6 digits fast.
const keyHandler = async (e) => { const keyHandler = async (e) => {
if (!dots.isConnected) { document.removeEventListener("keydown", keyHandler); return; } if (!dots.isConnected) { document.removeEventListener("keydown", keyHandler); return; }
// Only while this pad is actually on screen, and never for keys typed
// into a field. The lock-screen pad stays in the DOM (hidden) after
// unlock, so six digits typed into the amount box counted as a PIN
// attempt — and five such amounts locked the PIN for 15 minutes.
if (dots.offsetParent === null) return;
const tgt = e.target;
if (tgt && (tgt.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(tgt.tagName || ""))) return;
if (err) err.textContent = ""; if (err) err.textContent = "";
if (/^[0-9]$/.test(e.key)) { if (/^[0-9]$/.test(e.key)) {
if (buf.length >= 6) return; if (buf.length >= 6) return;
@ -3498,7 +3524,7 @@ function setupPinPad({ dots, keys, err, onComplete }) {
try { res = await onComplete(buf); } try { res = await onComplete(buf); }
finally { finally {
keys.querySelectorAll("button").forEach((x) => x.disabled = false); keys.querySelectorAll("button").forEach((x) => x.disabled = false);
if (res !== "ok") { buf = ""; paint(); } buf = ""; paint();
} }
} }
} else if (e.key === "Backspace") { buf = buf.slice(0, -1); paint(); } } else if (e.key === "Backspace") { buf = buf.slice(0, -1); paint(); }
@ -3509,6 +3535,7 @@ function setupPinPad({ dots, keys, err, onComplete }) {
function render() { function render() {
if (!state) return; if (!state) return;
noteSelectedWallet();
const s = sel(); const s = sel();
const ready = s && s.phase === "ready"; const ready = s && s.phase === "ready";
const phase = s?.phase; const phase = s?.phase;
@ -4120,27 +4147,74 @@ function setUnit(u) {
const s = amountUnits(); const s = amountUnits();
unit = u; unit = u;
applyUnitPicker(); applyUnitPicker();
if (s) $("sendAmt").value = unit === "big" ? fmtBig(s) : String(s); // Exact conversion in the decimals of what is being sent. fmtBig() caps at
// 8 places and always used the chain's decimals, so 1 wei became "0" and a
// 6-decimal token amount shrank 1000× on a round trip.
if (s) $("sendAmt").value = unit === "big" ? unitsToDecimalText(String(s), amountDecimals()) : String(s);
updateSendFiatPreview(); updateSendFiatPreview();
} }
function amountUnits() { function amountDecimals() { return sendAsset ? Number(sendAsset.decimals) || 0 : decimals(); }
const raw = $("sendAmt").value.trim().replace(/,/g, "");
if (!raw) return 0; // <amount-parser> — pure functions, exercised by the sandbox harness.
// For SPL tokens the amount is a raw u64 string in the token's own // What the user typed → { whole, frac } digit strings. A wallet must never
// smallest unit — same BigInt-safe path SC uses. // guess: the old `.replace(/,/g, "")` turned a decimal comma ("0,5") into 5,
const d = sendAsset ? Number(sendAsset.decimals) || 0 : decimals(); // and Number() accepted "1e3", "0x10" and "-0.5".
const bigDecimals = sendAsset != null || d > 15; function parseAmountText(text) {
if (unit === "small") { let raw = String(text == null ? "" : text).trim().replace(/\s/g, "");
if (bigDecimals) return raw.replace(/\D+/g, "") || "0"; if (!raw) return { empty: true };
return Math.round(Number(raw)); if (/^\d{1,3}(,\d{3})+\.\d*$/.test(raw) || /^\d{1,3}(,\d{3}){2,}$/.test(raw)) {
raw = raw.replace(/,/g, ""); // 1,234.5 · 1,234,567
} else if (/^\d{1,3},\d{3}$/.test(raw)) {
return { error: `"${raw}" could mean ${raw.replace(",", "")} or ${raw.replace(",", ".")} — write it without the comma, or with a dot` };
} else if (/^\d*,\d+$/.test(raw)) {
raw = raw.replace(",", "."); // decimal comma: 0,5
} }
if (!/^(\d+\.?\d*|\.\d+)$/.test(raw)) return { error: "Enter a plain number, like 0.5" };
const [w, f = ""] = raw.split("."); const [w, f = ""] = raw.split(".");
const frac = (f + "0".repeat(d)).slice(0, d); return { whole: (w || "0").replace(/^0+(?=\d)/, ""), frac: f };
if (bigDecimals) {
const total = (BigInt(w || "0") * (10n ** BigInt(d))) + BigInt(frac || "0");
return total.toString();
} }
return Number(w || 0) * Math.pow(10, d) + Number(frac || 0); // → smallest units as a decimal string, or { error }. `unit` is "big" (coins)
// or "small" (sats / wei / raw token units).
function amountToUnits(text, decimalsN, unit) {
const p = parseAmountText(text);
if (p.empty) return { units: "0", empty: true };
if (p.error) return { error: p.error };
const d = Math.max(0, Math.floor(Number(decimalsN) || 0));
if (unit === "small") {
if (/[1-9]/.test(p.frac)) return { error: "The smallest unit cannot have decimals" };
return { units: BigInt(p.whole).toString() };
}
if (/[1-9]/.test(p.frac.slice(d))) return { error: `At most ${d} decimal place${d === 1 ? "" : "s"} here` };
const frac = (p.frac + "0".repeat(d)).slice(0, d);
return { units: (BigInt(p.whole) * (10n ** BigInt(d)) + BigInt(frac || "0")).toString() };
}
// Exact inverse for filling the field: units → "1.5" with no grouping.
function unitsToDecimalText(units, decimalsN) {
const d = Math.max(0, Math.floor(Number(decimalsN) || 0));
const s = String(units).replace(/^0+(?=\d)/, "");
if (!/^\d+$/.test(s)) return "";
if (d === 0) return s;
const pad = s.padStart(d + 1, "0");
const frac = pad.slice(pad.length - d).replace(/0+$/, "");
return pad.slice(0, pad.length - d) + (frac ? "." + frac : "");
}
// </amount-parser>
// Why the amount in the field is not usable, or null. Set by amountUnits().
let amountError = null;
function amountUnits() {
amountError = null;
const d = amountDecimals();
const r = amountToUnits($("sendAmt").value, d, unit);
if (r.error) { amountError = r.error; return 0; }
if (r.empty) return 0;
// SPL tokens and 18/24-decimal coins travel as decimal strings; the 8–9
// decimal chains keep their Number contract with the host, bounded so a
// value past 2^53 is refused instead of silently rounded.
const bigDecimals = sendAsset != null || d > 15;
if (bigDecimals) return r.units;
if (BigInt(r.units) > BigInt(Number.MAX_SAFE_INTEGER)) { amountError = "That amount is too large"; return 0; }
return Number(r.units);
} }
// Sum "confirmed + unconfirmed" BigInt-safely (strings for SC, numbers elsewhere). // Sum "confirmed + unconfirmed" BigInt-safely (strings for SC, numbers elsewhere).
function balanceSum(b) { function balanceSum(b) {
@ -4425,50 +4499,119 @@ function updateSendFiatPreview() {
const el = $("sendAmtFiat"); if (!el) return; const el = $("sendAmtFiat"); if (!el) return;
const s = sel(); if (!s || sendAsset) { el.hidden = true; return; } const s = sel(); if (!s || sendAsset) { el.hidden = true; return; }
const units = amountUnits(); const units = amountUnits();
if (!units || !state?.prices?.enabled) { el.hidden = true; return; } if (!units || amountError || !state?.prices?.enabled) { el.hidden = true; return; }
const usd = usdOf(chain(), units, decimals()); const usd = usdOf(chain(), units, decimals());
const txt = fmtFiat(usd); const txt = fmtFiat(usd);
el.textContent = txt ? "≈ " + txt : ""; el.textContent = txt ? "≈ " + txt : "";
el.hidden = !txt; el.hidden = !txt;
} }
function schedulePlan() { clearTimeout(planTimer); planTimer = setTimeout(updatePlan, 250); } function schedulePlan() {
// The summary on screen is stale from the first keystroke: Send stays off
// until a plan for exactly what is in the form has come back.
lastPlan = null; lastPlanReq = null;
const b = $("sendBtn"); if (b) b.disabled = true;
clearTimeout(planTimer); planTimer = setTimeout(updatePlan, 250);
}
// The request a plan was made for. Send submits THIS, never a fresh read of
// the form, so what is signed is what the summary showed.
let lastPlanReq = null;
let planSeq = 0;
function currentSendReq() {
const amount = amountUnits();
const base = { walletId: state?.selectedWalletId || null, to: $("sendTo").value.trim() };
if (sendAsset) return { ...base, mint: sendAsset.mint, amount };
return {
...base, amount,
feeRate: chain() === "bch" ? Number($("feeRate").value) : undefined,
sendMax,
memo: chain() === "bch" ? String($("sendMemo")?.value || "").trim() : "",
};
}
// Selecting another wallet invalidates everything the Send form worked out.
// The fields used to survive the switch: the button stayed live on the old
// wallet's plan, Max swept the new wallet under the old summary, and a
// number typed in sats was re-read as wei.
let sendFormWalletId = null;
function noteSelectedWallet() {
const id = state?.selectedWalletId || null;
if (id === sendFormWalletId) return;
const prev = (state?.wallets || []).find((w) => w.id === sendFormWalletId);
const cur = (state?.wallets || []).find((w) => w.id === id);
const first = sendFormWalletId === null;
sendFormWalletId = id;
if (first) return;
lastPlan = null; lastPlanReq = null; planSeq++;
clearTimeout(planTimer);
sendAsset = null;
if (sendMax) {
sendMax = false;
try { $("sendMax").classList.remove("primary"); $("sendAmt").disabled = false; $("sendAmt").value = ""; } catch {}
}
// Another coin means the address and the number mean something else.
if (!prev || !cur || prev.chain !== cur.chain || prev.network !== cur.network) {
try { $("sendTo").value = ""; $("sendAmt").value = ""; if ($("sendMemo")) $("sendMemo").value = ""; } catch {}
unit = "big";
try { applyUnitPicker(); } catch {}
}
try {
$("sendBtn").disabled = true;
$("sumAmt").textContent = $("sumFee").textContent = $("sumTotal").textContent = "—";
$("sendMsg").hidden = true;
} catch {}
if ($("sendTo")?.value && $("sendAmt")?.value) schedulePlan();
}
async function updatePlan() { async function updatePlan() {
const seq = ++planSeq;
const to = $("sendTo").value.trim(); const to = $("sendTo").value.trim();
const msg = $("sendMsg"); msg.hidden = true; const msg = $("sendMsg"); msg.hidden = true;
lastPlan = null; $("sendBtn").disabled = true; lastPlan = null; lastPlanReq = null; $("sendBtn").disabled = true;
$("sumAmt").textContent = $("sumFee").textContent = $("sumTotal").textContent = "—"; $("sumAmt").textContent = $("sumFee").textContent = $("sumTotal").textContent = "—";
$("sendToHint").textContent = ""; $("sendToHint").textContent = "";
if (!to || (!sendMax && !amountUnits())) return; const req = currentSendReq();
if (amountError && !sendMax) { msg.className = "msg err"; msg.textContent = amountError; msg.hidden = false; return; }
if (!to || (!sendMax && !req.amount)) return;
try { try {
if (sendAsset) { if (sendAsset) {
// SPL token flow — amount is raw units of the token's decimals. // SPL token flow — amount is raw units of the token's decimals.
const p = await S.invoke("planTokenSend", { mint: sendAsset.mint, to, amount: amountUnits() }); const p = await S.invoke("planTokenSend", { mint: req.mint, to, amount: req.amount });
lastPlan = { _token: true, ...p }; if (seq !== planSeq) return; // a newer plan is on its way
lastPlan = { _token: true, ...p }; lastPlanReq = req;
$("sumAmt").textContent = fmtTokenAmount(p.recipients[0].value, sendAsset.decimals) + " " + sendAsset.symbol; $("sumAmt").textContent = fmtTokenAmount(p.recipients[0].value, sendAsset.decimals) + " " + sendAsset.symbol;
$("sumFee").textContent = fmtBig(p.fee, decimals()) + " SOL"; $("sumFee").textContent = fmtBig(p.fee, decimals()) + " SOL";
$("sumTotal").textContent = fmtTokenAmount(p.total, sendAsset.decimals) + " " + sendAsset.symbol; $("sumTotal").textContent = fmtTokenAmount(p.total, sendAsset.decimals) + " " + sendAsset.symbol;
$("sendBtn").disabled = false; $("sendBtn").disabled = false;
return; return;
} }
const feeRate = chain() === "bch" ? Number($("feeRate").value) : undefined; const p = await S.invoke("planSend", { to, amount: req.amount, feeRate: req.feeRate, sendMax: req.sendMax, memo: req.memo });
const memo = chain() === "bch" ? String($("sendMemo")?.value || "").trim() : ""; if (seq !== planSeq) return; // a newer plan is on its way
const p = await S.invoke("planSend", { to, amount: amountUnits(), feeRate, sendMax, memo }); lastPlan = p; lastPlanReq = req;
lastPlan = p;
$("sendToHint").textContent = p.recipients[0].to !== to ? "→ " + p.recipients[0].to : ""; $("sendToHint").textContent = p.recipients[0].to !== to ? "→ " + p.recipients[0].to : "";
$("sumAmt").textContent = fmtBig(p.recipients[0].value) + " " + ticker(); $("sumAmt").textContent = fmtBig(p.recipients[0].value) + " " + ticker();
$("sumFee").textContent = chain() === "bch" $("sumFee").textContent = chain() === "bch"
? fmtSmall(p.fee) + " " + smallUnitLabel() ? fmtSmall(p.fee) + " " + smallUnitLabel()
: fmtBig(p.fee) + " " + ticker(); : fmtBig(p.fee) + " " + ticker();
$("sumTotal").textContent = fmtBig(p.total) + " " + ticker(); $("sumTotal").textContent = fmtBig(p.total) + " " + ticker();
if (sendMax) $("sendAmt").value = unit === "big" ? fmtBig(p.recipients[0].value) : String(p.recipients[0].value); if (sendMax) $("sendAmt").value = unit === "big" ? unitsToDecimalText(String(p.recipients[0].value), amountDecimals()) : String(p.recipients[0].value);
$("sendBtn").disabled = false; $("sendBtn").disabled = false;
} catch (e) { } catch (e) {
if (seq !== planSeq) return;
msg.className = "msg err"; msg.textContent = cleanErr(e); msg.hidden = false; msg.className = "msg err"; msg.textContent = cleanErr(e); msg.hidden = false;
} }
} }
$("sendBtn").addEventListener("click", async () => { $("sendBtn").addEventListener("click", async () => {
if (!lastPlan) return; if (!lastPlan || !lastPlanReq) return;
const msg = $("sendMsg"); msg.hidden = true; const msg = $("sendMsg"); msg.hidden = true;
// The form must still say what the summary was computed for. Max rewrites
// the amount field itself, so it is compared without the amount.
const now = currentSendReq();
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const strip = (r) => (r.sendMax ? { ...r, amount: null } : r);
if (!same(strip(now), strip(lastPlanReq))) {
await updatePlan();
if (lastPlan) { msg.className = "msg err"; msg.textContent = "The form changed — check the updated summary, then press Send again."; msg.hidden = false; }
return;
}
const req = lastPlanReq;
// PIN gate. Whether a transaction needs one is the policy's call, not a // PIN gate. Whether a transaction needs one is the policy's call, not a
// single flag's — and if the user only asked for a PIN at startup, this // single flag's — and if the user only asked for a PIN at startup, this
// check passes without a prompt. // check passes without a prompt.
@ -4477,16 +4620,22 @@ $("sendBtn").addEventListener("click", async () => {
} }
$("sendBtn").disabled = true; $("sendBtn").textContent = "Waiting for approval…"; $("sendBtn").disabled = true; $("sendBtn").textContent = "Waiting for approval…";
try { try {
const isToken = sendAsset && lastPlan._token; const isToken = !!(req.mint && lastPlan._token);
const feeRate = chain() === "bch" ? Number($("feeRate").value) : undefined;
const memo = chain() === "bch" ? String($("sendMemo")?.value || "").trim() : "";
const r = isToken const r = isToken
? await S.invoke("sendToken", { mint: sendAsset.mint, to: $("sendTo").value.trim(), amount: amountUnits() }) ? await S.invoke("sendToken", { mint: req.mint, to: req.to, amount: req.amount })
: await S.invoke("send", { to: $("sendTo").value.trim(), amount: amountUnits(), feeRate, sendMax, memo }); : await S.invoke("send", { walletId: req.walletId, to: req.to, amount: req.amount, feeRate: req.feeRate, sendMax: req.sendMax, memo: req.memo });
// A broadcast that returned no txid is still a broadcast: never report
// it as a failure and leave a filled form inviting a second send.
const txid = r && typeof r.txid === "string" ? r.txid : "";
msg.className = "msg ok"; msg.className = "msg ok";
msg.innerHTML = `Sent. <a class="link" data-tx="${esc(r.txid)}">${esc(r.txid.slice(0, 16))}…</a>`; if (txid) {
msg.querySelector("a").addEventListener("click", () => openUrl(explorerHref(sel().explorerTx, r.txid))); msg.innerHTML = `Sent. <a class="link" data-tx="${esc(txid)}">${esc(txid.slice(0, 16))}…</a>`;
msg.querySelector("a").addEventListener("click", () => openUrl(explorerHref(sel().explorerTx, txid)));
} else {
msg.textContent = "Sent. The network did not return a transaction id — check History before sending again.";
}
msg.hidden = false; msg.hidden = false;
lastPlanReq = null;
$("sendTo").value = ""; $("sendAmt").value = ""; sendMax = false; $("sendTo").value = ""; $("sendAmt").value = ""; sendMax = false;
if ($("sendMemo")) $("sendMemo").value = ""; if ($("sendMemo")) $("sendMemo").value = "";
$("sendMax").classList.remove("primary"); $("sendAmt").disabled = false; $("sendMax").classList.remove("primary"); $("sendAmt").disabled = false;
@ -4741,7 +4890,10 @@ for (const [id, key] of PIN_ON_FIELDS) {
securityState = await S.invoke("securitySet", { pinOn: { [key]: want } }); securityState = await S.invoke("securitySet", { pinOn: { [key]: want } });
// Ticking a trigger should not fire it retroactively: the user is // Ticking a trigger should not fire it retroactively: the user is
// sitting in Settings having just proved whatever got them here. // sitting in Settings having just proved whatever got them here.
if (want) { try { await S.invoke("pinGateSatisfied"); } catch {} } // The host only records a gate against proof it verified, so this
// works when the password is still at hand (just unlocked); otherwise
// the new trigger simply asks once at its next occasion.
if (want && window.__aegisLastPw) { try { await S.invoke("pinGateSatisfied", { masterPassword: window.__aegisLastPw }); } catch {} }
renderGeneralSecurity(); renderGeneralSecurity();
} catch (e) { } catch (e) {
box.checked = !want; box.checked = !want;
@ -4998,9 +5150,25 @@ async function pinGate(event, subtitle) {
try { st = await S.invoke("pinGateStatus", { event }); } try { st = await S.invoke("pinGateStatus", { event }); }
catch { st = { needPin: true, reason: "unknown" }; } catch { st = { needPin: true, reason: "unknown" }; }
if (!st.needPin) return true; if (!st.needPin) return true;
const ok = await verifyPinInteractively(subtitle || PIN_GATE_COPY[st.reason] || "Confirm with your PIN."); // verifyPinInteractively resolves to what the PIN unwrapped — the vault
if (ok) { try { await S.invoke("pinGateSatisfied"); } catch { /* re-asks next time */ } } // master password — and the host checks that itself before it records the
return ok; // gate or lets a transaction through. A PIN the host could not verify
// does not count, so a failure here is a failed gate.
const proof = await verifyPinInteractively(subtitle || PIN_GATE_COPY[st.reason] || "Confirm with your PIN.");
if (!proof) return false;
try { await S.invoke("pinGateSatisfied", { masterPassword: proof }); }
catch { return false; }
return true;
}
// A dapp transaction is waiting on the PIN ("ask on every transaction").
// The host cannot draw a PIN pad, so it asks the panel: prove the PIN here
// and the pending transaction goes through.
async function answerPinRequest(req) {
if (!req || pinGateBlocked) return;
const where = String(req.origin || "A site").slice(0, 80);
const proof = await verifyPinInteractively(`${where} is waiting — confirm the ${String(req.what || "transaction").slice(0, 60)} with your PIN.`);
if (!proof) return;
try { await S.invoke("pinGateSatisfied", { masterPassword: proof }); } catch { /* host keeps waiting, then refuses */ }
} }
const PIN_GATE_COPY = { const PIN_GATE_COPY = {
restart: "Theseus restarted — confirm your PIN to use this wallet.", restart: "Theseus restarted — confirm your PIN to use this wallet.",
@ -5040,9 +5208,10 @@ async function pinGateOnLoad() {
// Keep asking until it is satisfied. Cancelling does not open the wallet; // Keep asking until it is satisfied. Cancelling does not open the wallet;
// it leaves the door shut with a button to try again, which is the only // it leaves the door shut with a button to try again, which is the only
// honest outcome for "a PIN is required here". // honest outcome for "a PIN is required here".
let proof = null;
for (;;) { for (;;) {
const ok = await verifyPinInteractively(PIN_GATE_COPY[st.reason] || "Confirm with your PIN."); proof = await verifyPinInteractively(PIN_GATE_COPY[st.reason] || "Confirm with your PIN.");
if (ok) break; if (proof) break;
const again = await aegisConfirm({ const again = await aegisConfirm({
title: "PIN required", title: "PIN required",
icon: "🔒", icon: "🔒",
@ -5052,7 +5221,7 @@ async function pinGateOnLoad() {
}); });
if (!again) return; // stays blocked; door remains shut if (!again) return; // stays blocked; door remains shut
} }
try { await S.invoke("pinGateSatisfied"); } catch { /* re-asks next load */ } try { await S.invoke("pinGateSatisfied", { masterPassword: proof }); } catch { /* re-asks next load */ }
pinGateBlocked = false; pinGateBlocked = false;
render(); render();
} }
@ -5219,9 +5388,11 @@ async function verifyPinInteractivelyOnce(subtitle) {
try { try {
const blob = await S.invoke("pinBlobGet"); const blob = await S.invoke("pinBlobGet");
if (!blob) throw new Error("no PIN configured"); if (!blob) throw new Error("no PIN configured");
await pinDecryptMaster(pin, blob); const master = await pinDecryptMaster(pin, blob);
await S.invoke("pinFailReset").catch(() => {}); await S.invoke("pinFailReset").catch(() => {});
done(true); // Truthy for every existing caller; the gate hands it to the host
// as proof (see pinGate).
done(master || true);
return "ok"; return "ok";
} catch (e) { } catch (e) {
const fs = await S.invoke("pinFailInc").catch(() => ({ count: 0 })); const fs = await S.invoke("pinFailInc").catch(() => ({ count: 0 }));
@ -5586,6 +5757,7 @@ S.on("state", (s) => {
// tab behind the door either. // tab behind the door either.
if (tab === "settings" && !pinGateBlocked) fillSettings(); if (tab === "settings" && !pinGateBlocked) fillSettings();
}); });
S.on("pinRequest", (req) => { answerPinRequest(req); });
(async () => { (async () => {
// Load security + session state first so the very first render() knows // Load security + session state first so the very first render() knows
// whether to paint the PIN pad on the lock screen and what idle-lock // whether to paint the PIN pad on the lock screen and what idle-lock
@ -5605,6 +5777,8 @@ S.on("state", (s) => {
await pinGateOnLoad(); await pinGateOnLoad();
render(); render();
bindIdleAutoLock(); bindIdleAutoLock();
// Opened because a dapp transaction is waiting for the PIN? Answer it.
try { answerPinRequest(await S.invoke("pinRequestPending")); } catch {}
})(); })();
// Persistent footer: aegis.x brand link + version marker + update check. // Persistent footer: aegis.x brand link + version marker + update check.