Merge aegis-031-fixes: the 0.31.0 audit findings

PIN checked by the host with a hard five-guess limit, permits described
from what is signed, unreadable WizardConnect requests refused, PIN
clearances bound to their request, permissions re-read after the PIN
wait, and the Tron duplicate-field fix (also shipped alone as 0.30.1).
This commit is contained in:
Local Dev 2026-10-04 02:29:32 +02:00
commit 80146c8c22
6 changed files with 360 additions and 209 deletions

View file

@ -980,6 +980,13 @@ function buildWcApproval(payload) {
inner = typeof dec === "string" ? null : dec; inner = typeof dec === "string" ? null : dec;
} catch { inner = null; } } catch { inner = null; }
} }
// Refuse what cannot be shown. The signer only takes this shape (see
// wc-sign.js), and an overlay without outputs or spent inputs is no
// approval at all.
if (!inner || !Array.isArray(inner.inputs) || !Array.isArray(inner.outputs) || !inner.outputs.length
|| !Array.isArray(tx.sourceOutputs) || tx.sourceOutputs.length !== inner.inputs.length) {
return { unreadable: true, dappName };
}
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 // What the wallet is putting IN. The outputs alone never showed that a
@ -1011,6 +1018,7 @@ function buildWcApproval(payload) {
try { try {
const outs = inner?.outputs || []; const outs = inner?.outputs || [];
let total = 0n; let total = 0n;
for (const o of outs) { try { total += BigInt(o.valueSatoshis ?? 0); } catch {} }
outs.slice(0, 8).forEach((o, i) => { outs.slice(0, 8).forEach((o, i) => {
const lb = o.lockingBytecode; const lb = o.lockingBytecode;
const hexScript = typeof lb === "string" ? lb.toLowerCase() : Buffer.from(lb || []).toString("hex"); const hexScript = typeof lb === "string" ? lb.toLowerCase() : Buffer.from(lb || []).toString("hex");
@ -1018,7 +1026,6 @@ function buildWcApproval(payload) {
if (/^76a914[0-9a-f]{40}88ac$/.test(hexScript)) addr = ctx.d.cashaddr.encode(prefix, 0, ctx.d.tx.fromHex(hexScript.slice(6, 46))); if (/^76a914[0-9a-f]{40}88ac$/.test(hexScript)) addr = ctx.d.cashaddr.encode(prefix, 0, ctx.d.tx.fromHex(hexScript.slice(6, 46)));
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;
const token = tokenText(o.token); 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 });
}); });
@ -1230,39 +1237,88 @@ function openPinBlob(api) {
return plain; return plain;
} }
// The PIN is checked here, never in the panel. The panel used to fetch the
// blob and decrypt it itself, then report its own failures — so the lockout
// counted only the guesses a well-behaved panel chose to report, and anything
// that could run in the panel could take the blob and search all million
// PINs offline. Now the blob stays in this process, every guess is counted
// before it is tried, and PIN_MAX_FAILS wrong guesses switch the PIN off
// until the master password is entered (no timer that hands out more tries).
// The blob format is unchanged: PBKDF2-SHA256(pin) -> AES-256-GCM, hex, tag
// appended to the ciphertext, as WebCrypto wrote it.
const PIN_ITERS = 600_000;
const PIN_MAX_FAILS = 5;
const PIN_RE = /^\d{6}$/;
const nodeCrypto = require("node:crypto");
const pinKey = (pin, saltHex, iters) => new Promise((resolve, reject) =>
nodeCrypto.pbkdf2(String(pin), Buffer.from(saltHex, "hex"), iters, 32, "sha256", (e, k) => (e ? reject(e) : resolve(k))));
async function pinWrap(pin, masterPassword) {
const salt = nodeCrypto.randomBytes(16).toString("hex");
const iv = nodeCrypto.randomBytes(12);
const c = nodeCrypto.createCipheriv("aes-256-gcm", await pinKey(pin, salt, PIN_ITERS), iv);
const ct = Buffer.concat([c.update(String(masterPassword), "utf8"), c.final(), c.getAuthTag()]);
return { salt, iv: iv.toString("hex"), ct: ct.toString("hex"), iters: PIN_ITERS };
}
async function pinUnwrapBlob(pin, blob) {
const ct = Buffer.from(String(blob.ct), "hex");
const d = nodeCrypto.createDecipheriv("aes-256-gcm", await pinKey(pin, blob.salt, Number(blob.iters) || PIN_ITERS), Buffer.from(String(blob.iv), "hex"));
d.setAuthTag(ct.subarray(ct.length - 16));
return Buffer.concat([d.update(ct.subarray(0, ct.length - 16)), d.final()]).toString("utf8");
}
function pinFails(api) {
const n = Number(api.storage.get("aegis/pin/failCount", 0)) || 0;
return { fails: n, requireMaster: !!api.storage.get("aegis/pin/requireMaster", false) || n >= PIN_MAX_FAILS };
}
// A master password the vault accepted. Clears the PIN strikes — the only
// thing that does, apart from a correct PIN while the PIN is still allowed.
function noteMasterVerified(api) {
api.storage.set("aegis/pin/failCount", 0);
api.storage.set("aegis/pin/requireMaster", false);
}
// "Ask for PIN on every transaction" used to be decided by the host and // "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 // 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, // 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 // single-use fact recorded only after the host itself has verified the
// master password the PIN unwraps (pinGateSatisfied). Panel sends consume // master password the PIN unwraps (pinGateSatisfied). Panel sends consume
// one; dapp transactions ask the open panel for one and wait. // one; dapp transactions ask the open panel for one and wait.
//
// A clearance belongs to the thing the PIN was entered for. It used to be
// one global window: any PIN proof (opening the wallet, a settings toggle)
// let the next dapp transaction from any site through, a waiting dapp could
// take the clearance the user had just made for their own send, and with
// two sites waiting the second one's request was lost. Now:
// - each waiting dapp transaction has its own id, and only a proof that
// names that id releases it;
// - a proof given for "transaction" in the panel clears the panel's next
// send only;
// - any other proof clears nothing.
const TX_CLEARANCE_MS = 90_000; const TX_CLEARANCE_MS = 90_000;
const DAPP_PIN_WAIT_MS = 120_000; const DAPP_PIN_WAIT_MS = 120_000;
let txClearanceUntil = 0; let panelClearanceUntil = 0;
let pendingPinRequest = null; // { origin, what, at } while a dapp tx waits for the PIN const pendingPinRequests = new Map(); // id -> { id, origin, what, at, cleared }
function txPinOwed() { function txPinOwed() {
try { return !!(ctx && ctx.pinNeeded && ctx.pinNeeded("transaction").needPin); } try { return !!(ctx && ctx.pinNeeded && ctx.pinNeeded("transaction").needPin); }
catch { return true; } // the safe direction for a lock is closed 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() { function requirePanelTxPin() {
if (txPinOwed() && !takeTxClearance()) throw new Error("PIN required — confirm your PIN to continue"); if (!txPinOwed()) return;
if (Date.now() < panelClearanceUntil) { panelClearanceUntil = 0; return; }
throw new Error("PIN required — confirm your PIN to continue");
} }
async function requireDappTxPin(origin, what) { async function requireDappTxPin(origin, what) {
if (!txPinOwed() || takeTxClearance()) return; if (!txPinOwed()) return;
pendingPinRequest = { origin: String(origin || ""), what: String(what || "transaction"), at: Date.now() }; const req = { id: nodeCrypto.randomBytes(8).toString("hex"), origin: String(origin || ""), what: String(what || "transaction"), at: Date.now(), cleared: false };
pendingPinRequests.set(req.id, req);
try { try {
try { ctx.api.emit("pinRequest", pendingPinRequest); } catch {} try { ctx.api.emit("pinRequest", { id: req.id, origin: req.origin, what: req.what, at: req.at }); } catch {}
const deadline = Date.now() + DAPP_PIN_WAIT_MS; const deadline = Date.now() + DAPP_PIN_WAIT_MS;
while (Date.now() < deadline) { while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 250)); await new Promise((r) => setTimeout(r, 250));
if (!ctx) break; if (!ctx) break;
if (takeTxClearance()) return; if (req.cleared) return;
} }
} finally { pendingPinRequest = null; } } finally { pendingPinRequests.delete(req.id); }
throw new Error("Aegis asks for your PIN on every transaction. Open the Aegis panel, confirm your PIN there, then try again."); throw new Error("Aegis asks for your PIN on every transaction. Open the Aegis panel, confirm your PIN there, then try again.");
} }
@ -1341,6 +1397,7 @@ function registerPanelMessages(api) {
fromPanel(m); fromPanel(m);
const pw = String(p && p.masterPassword || ""); const pw = String(p && p.masterPassword || "");
await api.vault.lifecycle.unlock(pw); await api.vault.lifecycle.unlock(pw);
noteMasterVerified(api);
await mountAllWallets(); await mountAllWallets();
return fullState(); return fullState();
}); });
@ -1749,6 +1806,10 @@ function registerPanelMessages(api) {
api.onMessage("sendToken", async (p, m) => { api.onMessage("sendToken", async (p, m) => {
fromPanel(m); fromPanel(m);
requirePanelTxPin(); requirePanelTxPin();
// Same rule as send: the token send names the wallet it was reviewed for.
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();
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 || {});
@ -1777,7 +1838,10 @@ function registerPanelMessages(api) {
// The panel names the wallet its form was built for. If the selection // The panel names the wallet its form was built for. If the selection
// moved since (another surface, a late state push), refuse rather than // moved since (another surface, a late state push), refuse rather than
// send this amount from a different wallet. // send this amount from a different wallet.
if (p && p.walletId && String(p.walletId) !== selectedWalletId()) { // Required, not optional: a send that does not say which wallet it was
// reviewed for cannot be checked against the selection.
if (!p || !p.walletId) throw new Error("send names no wallet — review the send and try again");
if (String(p.walletId) !== selectedWalletId()) {
throw new Error("the selected wallet changed — review the send and try again"); throw new Error("the selected wallet changed — review the send and try again");
} }
const rt = requireSelected(); const rt = requireSelected();
@ -1879,7 +1943,7 @@ function registerPanelMessages(api) {
// The destination is the wallet the PREVIEW was drawn for, not whatever // 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 // 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. // followed by a switch to B used to sweep everything into B.
if (p && p.destinationWalletId && String(p.destinationWalletId) !== destId) { if (!p || !p.destinationWalletId || String(p.destinationWalletId) !== destId) {
throw new Error("the selected wallet changed since this preview — reopen Consolidate"); 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);
@ -2122,6 +2186,7 @@ function registerPanelMessages(api) {
if (/no vault|not set up/i.test(msg)) throw new Error(msg); if (/no vault|not set up/i.test(msg)) throw new Error(msg);
throw new Error("wrong master password"); throw new Error("wrong master password");
} }
noteMasterVerified(api);
const id = String((p && p.walletId) || selectedWalletId() || ""); const id = String((p && p.walletId) || selectedWalletId() || "");
const entry = walletEntries().find((w) => w.id === id); const entry = walletEntries().find((w) => w.id === id);
@ -2546,52 +2611,61 @@ function registerPanelMessages(api) {
// decryption inside its iframe — the master password never crosses the // decryption inside its iframe — the master password never crosses the
// process boundary except via vaultUnlock. These handlers only shuttle // process boundary except via vaultUnlock. These handlers only shuttle
// 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) => { // Set or replace the PIN. The master password is checked against the vault
// first, and the blob is built here, so the panel never holds one.
api.onMessage("pinSet", async (p, m) => {
fromPanel(m); fromPanel(m);
return openPinBlob(api); const pin = String((p && p.pin) || "");
}); const pw = String((p && p.masterPassword) || "");
api.onMessage("pinBlobSet", (p, m) => { if (!PIN_RE.test(pin)) throw new Error("the PIN must be 6 digits");
fromPanel(m); if (!pw) throw new Error("master password required");
const blob = p && p.blob; try { await api.vault.lifecycle.unlock(pw); }
if (!blob || typeof blob !== "object") throw new Error("blob required"); catch { throw new Error("wrong master password"); }
if (typeof blob.salt !== "string" || typeof blob.iv !== "string" || typeof blob.ct !== "string" || typeof blob.iters !== "number") { noteMasterVerified(api);
throw new Error("blob shape invalid"); api.storage.set(PIN_BLOB_KEY, sealPinBlob(api, await pinWrap(pin, pw)));
}
api.storage.set(PIN_BLOB_KEY, sealPinBlob(api, { salt: blob.salt, iv: blob.iv, ct: blob.ct, iters: blob.iters }));
return true; return true;
}); });
// Try a PIN. Counts the guess before trying it, so a crash or a closed
// panel mid-check still costs an attempt. Answers
// { ok: true, masterPassword } | { ok: false, remaining, requireMaster }
api.onMessage("pinUnwrap", async (p, m) => {
fromPanel(m);
const pin = String((p && p.pin) || "");
const blob = openPinBlob(api);
if (!blob) throw new Error("no PIN is set");
if (pinFails(api).requireMaster) return { ok: false, remaining: 0, requireMaster: true };
const before = pinFails(api).fails;
api.storage.set("aegis/pin/failCount", before + 1);
let pw = null;
if (PIN_RE.test(pin)) { try { pw = await pinUnwrapBlob(pin, blob); } catch { pw = null; } }
if (pw == null) {
const fails = before + 1;
if (fails >= PIN_MAX_FAILS) api.storage.set("aegis/pin/requireMaster", true);
return { ok: false, remaining: Math.max(0, PIN_MAX_FAILS - fails), requireMaster: fails >= PIN_MAX_FAILS };
}
api.storage.set("aegis/pin/failCount", 0);
// A blob from an older build (200k iterations, or from before sealing)
// is re-made now, under a fresh salt, while the PIN is at hand.
if ((Number(blob.iters) || 0) < PIN_ITERS) {
try { api.storage.set(PIN_BLOB_KEY, sealPinBlob(api, await pinWrap(pin, pw))); } catch (e) { api.log("PIN re-wrap:", e?.message || e); }
}
return { ok: true, masterPassword: pw };
});
api.onMessage("pinStatus", (_p, m) => {
fromPanel(m);
const f = pinFails(api);
return { hasPin: !!api.storage.get(PIN_BLOB_KEY, null), fails: f.fails, maxFails: PIN_MAX_FAILS, requireMaster: f.requireMaster };
});
api.onMessage("pinBlobClear", (_p, m) => { api.onMessage("pinBlobClear", (_p, m) => {
fromPanel(m); fromPanel(m);
api.storage.set("aegis/pin/v1", null); api.storage.set("aegis/pin/v1", null);
api.storage.set("aegis/pin/failCount", 0); api.storage.set("aegis/pin/failCount", 0);
api.storage.set("aegis/pin/requireMaster", false);
// Drop the gate record as well, so enrolling a new PIN later starts from // Drop the gate record as well, so enrolling a new PIN later starts from
// "not yet satisfied" rather than inheriting the old PIN's clearance. // "not yet satisfied" rather than inheriting the old PIN's clearance.
api.storage.set("aegis/pin/gate", null); api.storage.set("aegis/pin/gate", null);
return true; return true;
}); });
// Track failed PIN attempts in the addon so a panel reload cannot bypass
// rate-limiting by dropping panel-side counters.
api.onMessage("pinFailInc", (_p, m) => {
fromPanel(m);
const cur = Number(api.storage.get("aegis/pin/failCount", 0)) || 0;
const next = cur + 1;
api.storage.set("aegis/pin/failCount", next);
api.storage.set("aegis/pin/failLast", Date.now());
return { count: next, at: Date.now() };
});
api.onMessage("pinFailReset", (_p, m) => {
fromPanel(m);
api.storage.set("aegis/pin/failCount", 0);
api.storage.set("aegis/pin/failLast", 0);
return true;
});
api.onMessage("pinFailStatus", (_p, m) => {
fromPanel(m);
return {
count: Number(api.storage.get("aegis/pin/failCount", 0)) || 0,
last: Number(api.storage.get("aegis/pin/failLast", 0)) || 0,
};
});
// When to ask for the PIN. These are independent triggers, not a single // When to ask for the PIN. These are independent triggers, not a single
// mode: wanting one at startup and one per transaction is a normal // mode: wanting one at startup and one per transaction is a normal
@ -2658,15 +2732,26 @@ function registerPanelMessages(api) {
if (!api.vault?.lifecycle || typeof api.vault.lifecycle.unlock !== "function") throw new Error("this build cannot verify a PIN"); 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); } try { await api.vault.lifecycle.unlock(pw); }
catch { throw new Error("PIN proof rejected"); } catch { throw new Error("PIN proof rejected"); }
noteMasterVerified(api);
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; // What this proof clears (see requireDappTxPin).
const forRequest = String((p && p.requestId) || "");
if (forRequest) {
const req = pendingPinRequests.get(forRequest);
if (!req) throw new Error("that request is no longer waiting");
req.cleared = true;
} else if (String((p && p.event) || "") === "transaction") {
panelClearanceUntil = Date.now() + TX_CLEARANCE_MS;
}
return true; return true;
}); });
// A panel opened while a dapp transaction is waiting for the PIN picks the // 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. // request up here; one already open gets the "pinRequest" event instead.
api.onMessage("pinRequestPending", (_p, m) => { api.onMessage("pinRequestPending", (_p, m) => {
fromPanel(m); fromPanel(m);
return pendingPinRequest ? { ...pendingPinRequest } : null; // The oldest waiting request that is not answered yet.
for (const r of pendingPinRequests.values()) if (!r.cleared) return { id: r.id, origin: r.origin, what: r.what, at: r.at };
return null;
}); });
ctx.pinNeeded = pinNeeded; ctx.pinNeeded = pinNeeded;
// Seal a plain blob left by an older build now, not when the panel next // Seal a plain blob left by an older build now, not when the panel next
@ -2678,6 +2763,7 @@ function registerPanelMessages(api) {
const cfg = api.storage.get("aegis/security/v1", {}) || {}; const cfg = api.storage.get("aegis/security/v1", {}) || {};
return { return {
hasPin: !!api.storage.get("aegis/pin/v1", null), hasPin: !!api.storage.get("aegis/pin/v1", null),
pinRequireMaster: pinFails(api).requireMaster,
pinOn: pinPolicy(), pinOn: pinPolicy(),
pinIntervalHours: PIN_INTERVAL_MS / 3600000, pinIntervalHours: PIN_INTERVAL_MS / 3600000,
requirePinForSending: !!cfg.requirePinForSending, requirePinForSending: !!cfg.requirePinForSending,
@ -2712,6 +2798,7 @@ function registerPanelMessages(api) {
api.storage.set("aegis/security/v1", next); api.storage.set("aegis/security/v1", next);
return { return {
hasPin: !!api.storage.get("aegis/pin/v1", null), hasPin: !!api.storage.get("aegis/pin/v1", null),
pinRequireMaster: pinFails(api).requireMaster,
pinOn: pinPolicy(), pinOn: pinPolicy(),
pinIntervalHours: PIN_INTERVAL_MS / 3600000, pinIntervalHours: PIN_INTERVAL_MS / 3600000,
requirePinForSending: !!next.requirePinForSending, requirePinForSending: !!next.requirePinForSending,
@ -3111,13 +3198,50 @@ const ETH_RPC_NEVER = /^(eth_sign|eth_signTransaction|eth_sendTransaction|eth_ac
// PermitBatch / PermitTransferFrom / PermitBatchTransferFrom). Returns the // PermitBatch / PermitTransferFrom / PermitBatchTransferFrom). Returns the
// overlay rows that say who may spend what until when, or null when the // overlay rows that say who may spend what until when, or null when the
// typed data is not a spending approval. // typed data is not a spending approval.
// What the signature actually covers. The EIP-712 encoder reads only the
// fields each type declares; anything else in `message` is ignored by the
// digest but was read by the overlay, so a dapp could add a decoy
// `allowed: false` or `details: {amount: "1"}` beside an unlimited permit and
// have the overlay show the decoy. Everything shown is built from this view.
function signedView(td) {
const types = (td && typeof td.types === "object" && td.types) || {};
let dropped = 0;
const walk = (type, v) => {
const arr = /\[\d*\]$/.exec(type);
if (arr) {
const inner = type.slice(0, arr.index);
return Array.isArray(v) ? v.map((x) => walk(inner, x)) : v;
}
const fields = types[type];
if (!Array.isArray(fields)) return v; // atomic
const src = (v && typeof v === "object") ? v : {};
for (const k of Object.keys(src)) if (!fields.some((f) => f && f.name === k)) dropped++;
const out = {};
for (const f of fields) if (f && typeof f.name === "string") out[f.name] = walk(String(f.type), src[f.name]);
return out;
};
return { message: walk(String(td?.primaryType || ""), td?.message), dropped };
}
function describePermit(td) { function describePermit(td) {
const primary = String(td?.primaryType || ""); const primary = String(td?.primaryType || "");
if (!/^Permit/.test(primary)) return null; if (!/^Permit/.test(primary)) return null;
const msg = (td && typeof td.message === "object" && td.message) || {}; const msg = signedView(td).message || {};
const declared = (t) => (Array.isArray(td?.types?.[t]) ? td.types[t].map((f) => f && f.name) : []);
const pf = declared(primary);
const has = (...names) => names.every((n) => pf.includes(n));
const UNLIMITED = 1n << 159n; // ≥ half of uint160 covers Permit2's max and every uint256 max const UNLIMITED = 1n << 159n; // ≥ half of uint160 covers Permit2's max and every uint256 max
// No token has a supply anywhere near this, so an amount this large is an
// unlimited approval in all but name (2^96 ≈ 7.9e28 base units: 79 billion
// tokens at 18 decimals).
const HUGE = 1n << 96n;
const big = (v) => { try { return BigInt(v); } catch { return null; } }; 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 amountText = (v) => {
const b = big(v);
if (b === null) return String(v);
if (b >= UNLIMITED) return "UNLIMITED (∞)";
return b.toString() + " units" + (b >= HUGE ? " (effectively unlimited)" : "");
};
const when = (v) => { const when = (v) => {
const b = big(v); const b = big(v);
if (b === null) return String(v); if (b === null) return String(v);
@ -3126,23 +3250,34 @@ function describePermit(td) {
}; };
const rows = []; const rows = [];
let risky = false; let risky = false;
const spender = msg.spender; const spender = has("spender") ? msg.spender : null;
rows.push({ label: "Spender", value: spender ? String(spender) : "(none named — anyone holding this signature)", mono: true, strong: true }); rows.push({ label: "Spender", value: spender ? String(spender) : "(none named — anyone holding this signature)", mono: true, strong: true });
if (!spender) risky = true; if (!spender) risky = true;
const items = []; const items = [];
if (primary === "Permit") { // The variant is chosen by the declared type, never by what the message
// EIP-2612 has `value`; DAI has `allowed: true` (always unlimited). // happens to carry.
if ("allowed" in msg) items.push({ token: td.domain?.verifyingContract, amount: msg.allowed ? UNLIMITED : 0n }); if (has("holder", "spender", "nonce", "expiry", "allowed")) {
else items.push({ token: td.domain?.verifyingContract, amount: msg.value }); // DAI-style: a bool, encoded by truthiness — so is this.
if (msg.deadline != null || msg.expiry != null) rows.push({ label: "Valid until", value: when(msg.deadline ?? msg.expiry) }); items.push({ token: td.domain?.verifyingContract, amount: msg.allowed ? UNLIMITED : 0n });
} else { rows.push({ label: "Valid until", value: when(msg.expiry) });
const list = Array.isArray(msg.details) ? msg.details : (msg.details ? [msg.details] : (Array.isArray(msg.permitted) ? msg.permitted : (msg.permitted ? [msg.permitted] : []))); } else if (has("owner", "spender", "value", "deadline")) {
// EIP-2612.
items.push({ token: td.domain?.verifyingContract, amount: msg.value });
rows.push({ label: "Valid until", value: when(msg.deadline) });
} else if (has("details", "spender", "sigDeadline")) {
// Permit2 PermitSingle / PermitBatch.
const list = Array.isArray(msg.details) ? msg.details : [msg.details];
for (const d of list) items.push({ token: d?.token, amount: d?.amount, expiration: d?.expiration }); 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) }); rows.push({ label: "Signature valid until", value: when(msg.sigDeadline) });
} else if (has("permitted", "spender", "deadline")) {
// Permit2 PermitTransferFrom / PermitBatchTransferFrom (and *Witness*).
const list = Array.isArray(msg.permitted) ? msg.permitted : [msg.permitted];
for (const d of list) items.push({ token: d?.token, amount: d?.amount });
rows.push({ label: "Signature valid until", value: when(msg.deadline) });
} }
items.slice(0, 10).forEach((it, i) => { items.slice(0, 10).forEach((it, i) => {
const b = big(it.amount); const b = big(it.amount);
if (b !== null && b >= UNLIMITED) risky = true; if (b === null || b >= HUGE) risky = true;
rows.push({ rows.push({
label: items.length > 1 ? `Token #${i + 1}` : "Token", label: items.length > 1 ? `Token #${i + 1}` : "Token",
value: `${it.token || "?"} — ${amountText(it.amount)}${it.expiration != null ? ` · allowance ${when(it.expiration)}` : ""}`, value: `${it.token || "?"} — ${amountText(it.amount)}${it.expiration != null ? ` · allowance ${when(it.expiration)}` : ""}`,
@ -3150,7 +3285,7 @@ function describePermit(td) {
}); });
}); });
if (items.length > 10) { rows.push({ label: "Tokens", value: `… and ${items.length - 10} more` }); risky = 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; } if (!items.length) { rows.push({ label: "Token", value: "(not a permit shape Aegis can read — treat as unlimited)" }); risky = true; }
return { rows, risky }; return { rows, risky };
} }
@ -3223,15 +3358,31 @@ function registerPageMessages(api) {
const perms = permissions(api); const perms = permissions(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;
// The PIN wait below can last two minutes, during which the user may
// revoke the site or other permissions may change. Everything after it
// re-reads permissions and changes only this origin's sendTx; writing
// back the snapshot taken here used to restore a revoked allowance and
// wipe any other change made meanwhile.
const patchSendTx = (fn) => {
const now = permissions(api);
const cur = { ...(now[origin] || {}) };
const next = fn(cur.sendTx);
if (!next && !now[origin]) return; // revoked meanwhile: nothing to change
if (next) cur.sendTx = next; else delete cur.sendTx;
now[origin] = cur;
api.storage.set("permissions", now);
};
if (budget && d.total <= remaining) { if (budget && d.total <= remaining) {
// An allowance skips the overlay, not the PIN the user asked for on // An allowance skips the overlay, not the PIN the user asked for on
// every transaction. // every transaction.
await requireDappTxPin(origin, "Bitcoin Cash payment"); await requireDappTxPin(origin, "Bitcoin Cash payment");
const fresh = (permissions(api)[origin] || {}).sendTx;
const left = fresh ? Math.max(0, (fresh.capSats | 0) - (fresh.usedSats | 0)) : 0;
if (!fresh || fresh.grantedAt !== budget.grantedAt || d.total > left) throw new Error("this site's allowance changed while waiting for the PIN; ask again");
const r = await rt.adapter.signAndBroadcast(plan); const r = await rt.adapter.signAndBroadcast(plan);
budget.usedSats = (budget.usedSats | 0) + d.total; patchSendTx((t) => (t ? { ...t, usedSats: (t.usedSats | 0) + d.total } : t));
api.storage.set("permissions", perms);
emitState(); emitState();
api.log(`silent send ${d.total} sat for ${origin}, ${remaining - d.total} sat of allowance left`); api.log(`silent send ${d.total} sat for ${origin}, ${left - d.total} sat of allowance left`);
return { txid: r.txid }; return { txid: r.txid };
} }
const rows = d.recipients.map((r, i) => ({ label: d.recipients.length > 1 ? `To #${i + 1}` : "To", value: r.to, mono: true })); const rows = d.recipients.map((r, i) => ({ label: d.recipients.length > 1 ? `To #${i + 1}` : "To", value: r.to, mono: true }));
@ -3256,13 +3407,8 @@ function registerPageMessages(api) {
await requireDappTxPin(origin, "Bitcoin Cash payment"); 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)) patchSendTx(() => ({ capSats, usedSats: 0, grantedAt: Date.now() }));
perms[origin] = { ...(perms[origin] || {}), sendTx: { capSats, usedSats: 0, grantedAt: Date.now() } }; else if (budget) patchSendTx(() => null);
api.storage.set("permissions", perms);
} else if (budget) {
delete perms[origin].sendTx;
api.storage.set("permissions", perms);
}
emitState(); emitState();
const r = await rt.adapter.signAndBroadcast(plan); const r = await rt.adapter.signAndBroadcast(plan);
return { txid: r.txid }; return { txid: r.txid };
@ -3655,7 +3801,9 @@ function registerPageMessages(api) {
} }
} }
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, (_k, v) => (typeof v === "bigint" ? v.toString() : v), 2) || ""; // Preview only what the signature covers (see signedView).
const view = signedView(td);
const messagePreview = JSON.stringify(view.message, (_k, v) => (typeof v === "bigint" ? v.toString() : v), 2) || "";
const rows = [{ label: "Domain", value: domainSummary }]; const rows = [{ label: "Domain", value: domainSummary }];
if (dom.verifyingContract) rows.push({ label: "Contract", value: String(dom.verifyingContract), mono: true }); if (dom.verifyingContract) rows.push({ label: "Contract", value: String(dom.verifyingContract), mono: true });
rows.push({ label: "Primary type", value: String(td.primaryType || "") }); rows.push({ label: "Primary type", value: String(td.primaryType || "") });
@ -3665,9 +3813,14 @@ function registerPageMessages(api) {
// and the deadline out of the message and say what they mean. // and the deadline out of the message and say what they mean.
const permit = describePermit(td); const permit = describePermit(td);
if (permit) for (const r of permit.rows) rows.push(r); if (permit) for (const r of permit.rows) rows.push(r);
// Marketplace orders and multisig transactions are not permits but move
// NFTs, tokens or a whole Safe just as surely once signed.
const MOVES_ASSETS = /^(OrderComponents|BulkOrder|Order|MakerOrder|TakerOrder|SafeTx|SafeMessage|MetaTransaction|ForwardRequest)$/;
const movesAssets = !permit && MOVES_ASSETS.test(String(td.primaryType || ""));
rows.push({ label: "Message", value: previewText(messagePreview, 3000), mono: true }); rows.push({ label: "Message", value: previewText(messagePreview, 3000), mono: true });
if (view.dropped) rows.push({ label: "Not signed", value: `${view.dropped} field${view.dropped === 1 ? "" : "s"} the site sent are not covered by this signature and are not shown` });
rows.push({ label: "Address", value: snapTd.address, mono: true }); rows.push({ label: "Address", value: snapTd.address, mono: true });
const risky = !!(permit && permit.risky); const risky = !!(permit && permit.risky) || movesAssets;
return withOriginLock(origin, async () => { return withOriginLock(origin, async () => {
const pick = await api.approvalModal({ const pick = await api.approvalModal({
title: permit ? "Sign a token spending permit?" : "Sign typed data (EIP-712)?", title: permit ? "Sign a token spending permit?" : "Sign typed data (EIP-712)?",
@ -3676,7 +3829,9 @@ function registerPageMessages(api) {
? (risky ? (risky
? "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." ? "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."
: "Signing this lets the spender below move the stated amount of your tokens without a transaction from you.") : "Signing this lets the spender below move the stated amount of your tokens without a transaction from you.")
: "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.", : movesAssets
? `WARNING: a signed ${String(td.primaryType)} can move your NFTs, tokens or Safe without another prompt. Only sign if you fully trust this site and the details below are what you expect.`
: "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.",
rows, rows,
actions: [{ id: "sign", label: risky ? "Sign anyway" : "Sign", primary: !risky, danger: risky }], actions: [{ id: "sign", label: risky ? "Sign anyway" : "Sign", primary: !risky, danger: risky }],
}); });
@ -4040,7 +4195,8 @@ module.exports = {
// keys fell back to a lone "OK" button whose id never matched, so // keys fell back to a lone "OK" button whose id never matched, so
// every WizardConnect signing request was refused, and the HTML // every WizardConnect signing request was refused, and the HTML
// body was shown as literal markup. // body was shown as literal markup.
const { body, rows, dappName } = buildWcApproval(payload); const { body, rows, dappName, unreadable } = buildWcApproval(payload);
if (unreadable) { api.log(`wc sign: refused an unreadable request from ${dappName}`); return { approved: false }; }
const pick = await api.approvalModal({ const pick = await api.approvalModal({
title: "Sign a Bitcoin Cash transaction (WizardConnect)?", title: "Sign a Bitcoin Cash transaction (WizardConnect)?",
origin: dappName, origin: dappName,

View file

@ -46,8 +46,11 @@ const DEFAULT_REGISTRIES = [
module.exports = function makeBcmr({ storage, log = () => {} }) { module.exports = function makeBcmr({ storage, log = () => {} }) {
function registryList() { function registryList() {
// Filtered on read as well: a list saved before setRegistries enforced
// https still carries its http entries.
const custom = storage.get("bcmr/registries", null); const custom = storage.get("bcmr/registries", null);
if (Array.isArray(custom) && custom.length) return custom; const clean = Array.isArray(custom) ? custom.filter((r) => r && typeof r.url === "string" && /^https:\/\//i.test(r.url)) : [];
if (clean.length) return clean;
return DEFAULT_REGISTRIES.slice(); return DEFAULT_REGISTRIES.slice();
} }
function setRegistries(list) { function setRegistries(list) {
@ -164,10 +167,13 @@ 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 // Control, bidi-override, zero-width and BOM characters, plus the soft
// hyphen, the Arabic letter mark, the Mongolian vowel separator, the
// line/paragraph separators and the Unicode tag block. Built from code
// points so no invisible character has to live in this source file. // points so no invisible character has to live in this source file.
const rng = (a, b) => String.fromCharCode(a) + "-" + String.fromCharCode(b); const rng = (a, b) => String.fromCodePoint(a) + "-" + String.fromCodePoint(b);
const INVISIBLE = new RegExp("[" + rng(0x00, 0x1f) + rng(0x7f, 0x9f) + rng(0x200b, 0x200f) + rng(0x202a, 0x202e) + rng(0x2060, 0x2069) + rng(0xfeff, 0xfeff) + "]", "g"); const INVISIBLE = new RegExp("[" + rng(0x00, 0x1f) + rng(0x7f, 0x9f) + rng(0xad, 0xad) + rng(0x61c, 0x61c) + rng(0x180e, 0x180e)
+ rng(0x200b, 0x200f) + rng(0x2028, 0x202e) + rng(0x2060, 0x2069) + rng(0xfeff, 0xfeff) + rng(0xe0000, 0xe007f) + "]", "gu");
function cleanText(v, max) { function cleanText(v, max) {
if (typeof v !== "string") return null; if (typeof v !== "string") return null;
const s = v.replace(INVISIBLE, "").trim().slice(0, max); const s = v.replace(INVISIBLE, "").trim().slice(0, max);

View file

@ -87,8 +87,11 @@ module.exports = function makeEip712({ keccak_256 }) {
return keccak_256(b); return keccak_256(b);
} }
if (type === "address") { if (type === "address") {
const h = hex2bytes(String(value || "0x0").replace(/^0x/, "")); // Validate before decoding: the hex decoder turns non-hex characters
if (h.length !== 20) throw new Error("address must be 20 bytes"); // into zero bytes, so a malformed address would sign as a different one.
const s = String(value ?? "").replace(/^0x/i, "");
if (!/^[0-9a-fA-F]{40}$/.test(s)) throw new Error("address must be 20 bytes of hex");
const h = hex2bytes(s);
const out = new Uint8Array(32); const out = new Uint8Array(32);
out.set(h, 12); out.set(h, 12);
return out; return out;

View file

@ -81,9 +81,20 @@ module.exports = function makeTronDecode({ sha256, base58check }) {
} }
return out; return out;
} }
const one = (fields, n) => fields.find((f) => f.field === n); // A singular field that appears twice is legal protobuf: the parser keeps
const bytesOf = (fields, n) => { const f = one(fields, n); return f && f.wire === 2 ? f.value : null; }; // the LAST copy (and merges repeated sub-messages). java-tron does that, so
const numOf = (fields, n) => { const f = one(fields, n); return f && f.wire === 0 ? f.value : null; }; // reading the first copy would show the user one recipient and amount while
// the chain executes another — and Any.value is opaque bytes, so the signed
// hash is the same either way. Refuse instead of guessing, and refuse a
// known field sent with the wrong wire type for the same reason.
const one = (fields, n, wire) => {
const hits = fields.filter((f) => f.field === n);
if (hits.length > 1) throw new Error(`field ${n} appears ${hits.length} times; refusing an ambiguous transaction`);
if (hits[0] && hits[0].wire !== wire) throw new Error(`field ${n} has wire type ${hits[0].wire}, expected ${wire}`);
return hits[0];
};
const bytesOf = (fields, n) => { const f = one(fields, n, 2); return f ? f.value : null; };
const numOf = (fields, n) => { const f = one(fields, n, 0); return f ? f.value : null; };
// 21-byte Tron address (0x41 || h20) → "T…" base58check. Anything else is // 21-byte Tron address (0x41 || h20) → "T…" base58check. Anything else is
// returned as hex so the overlay never hides a malformed field. // returned as hex so the overlay never hides a malformed field.

View file

@ -66,12 +66,13 @@ async function signTx({ request, account, branches, libauth, secp256k1 }) {
// the outputs) meant `tx.inputs` was undefined and signing threw on the // the outputs) meant `tx.inputs` was undefined and signing threw on the
// first real request. index.js already read the nested // first real request. index.js already read the nested
// request.transaction.userPrompt for the approval dialog, so only this // request.transaction.userPrompt for the approval dialog, so only this
// module had it wrong. The flat shape is still accepted so a caller // module had it wrong. Only the nested shape is accepted: the approval
// that hands us an already-unwrapped payload keeps working. // overlay reads that shape, and a flat request used to be signed after an
const inner = (request.transaction && (request.transaction.transaction !== undefined // overlay that could show neither its outputs nor what it spends.
|| request.transaction.sourceOutputs !== undefined)) const inner = request.transaction;
? request.transaction if (!inner || typeof inner !== "object" || inner.transaction === undefined || !Array.isArray(inner.sourceOutputs)) {
: request; throw new Error("wc-sign: request is not a WizardConnect sign request (request.transaction.{transaction,sourceOutputs})");
}
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`);

View file

@ -300,48 +300,20 @@ function fiatSkeleton() {
return state?.prices?.enabled ? "≈ $—" : null; return state?.prices?.enabled ? "≈ $—" : null;
} }
// ---- security: PIN encryption + verification (WebCrypto) ------------------- // ---- security: PIN ----------------------------------------------------------
// The PIN blob wraps the master password: PBKDF2-SHA256(pin, salt, iters) // The pads below only collect six digits. The host (index.js pinUnwrap)
// derives an AES-GCM key; the master password is encrypted with a fresh // holds the PIN blob, counts every guess before trying it, and after too
// per-blob IV. The addon (main process) only handles the opaque blob; the // many wrong ones switches the PIN off until the master password is entered.
// panel never sends the raw PIN or the master password to it. The rate // The panel never sees the blob, so it cannot be searched from here, and it
// limiter is stored addon-side so reloading the panel cannot reset it. // cannot reset the counter.
const PIN_ITERS = 600000; // new blobs only; an existing blob carries its own count // pinTry(pin) -> { ok: true, masterPassword } | { ok: false, remaining, requireMaster }
const PIN_MAX_FAILS = 5; const pinTry = (pin) => S.invoke("pinUnwrap", { pin: String(pin) });
const PIN_LOCKOUT_MS = 15 * 60 * 1000; async function pinNeedsMaster() {
const b2h = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); try { return !!(await S.invoke("pinStatus")).requireMaster; } catch { return false; }
const h2b = (h) => { const b = new Uint8Array(h.length / 2); for (let i = 0; i < b.length; i++) b[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16); return b; };
async function pinDeriveKey(pin, saltBytes, iters) {
const enc = new TextEncoder();
const material = await crypto.subtle.importKey("raw", enc.encode(pin), "PBKDF2", false, ["deriveKey"]);
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt: saltBytes, iterations: iters, hash: "SHA-256" },
material,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
}
async function pinEncryptMaster(pin, masterPassword) {
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await pinDeriveKey(pin, salt, PIN_ITERS);
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(masterPassword)));
return { salt: b2h(salt), iv: b2h(iv), ct: b2h(ct), iters: PIN_ITERS };
}
async function pinDecryptMaster(pin, blob) {
const key = await pinDeriveKey(pin, h2b(blob.salt), blob.iters || PIN_ITERS);
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: h2b(blob.iv) }, key, h2b(blob.ct));
return new TextDecoder().decode(pt);
}
async function pinLockoutRemainingMs() {
try {
const s = await S.invoke("pinFailStatus");
if (!s || !s.count || s.count < PIN_MAX_FAILS) return 0;
const since = Date.now() - (s.last || 0);
return since >= PIN_LOCKOUT_MS ? 0 : (PIN_LOCKOUT_MS - since);
} catch { return 0; }
} }
const PIN_MASTER_COPY = "Too many wrong PINs. Enter your master password; the PIN works again after that.";
const wrongPinCopy = (remaining) =>
`Wrong PIN. ${remaining} attempt${remaining === 1 ? "" : "s"} left, then Aegis asks for your master password.`;
async function refreshSecurityState() { async function refreshSecurityState() {
try { try {
securityState = await S.invoke("securityGet"); securityState = await S.invoke("securityGet");
@ -1224,6 +1196,9 @@ function openWalletManageModal(w) {
+ `<br><br>The imported key is kept rather than deleted: the sweep still has to confirm, and anyone holding the old address can still pay into it. Remove it yourself once its balance reads zero.`, + `<br><br>The imported key is kept rather than deleted: the sweep still has to confirm, and anyone holding the old address can still pay into it. Remove it yourself once its balance reads zero.`,
}); });
if (!ok) return; if (!ok) return;
// The sweep is a spend: with "PIN on every transaction" the host refuses
// it without a proof given for a transaction.
if (!await pinGate("transaction", "Confirm the sweep with your PIN.")) return;
try { try {
const r = await S.invoke("promoteToHd", { walletId: w.id, label: pv.suggestedLabel }); const r = await S.invoke("promoteToHd", { walletId: w.id, label: pv.suggestedLabel });
close(); close();
@ -3374,7 +3349,7 @@ function renderLockScreen(phase) {
const forcePw = body.dataset.forcePw === "1"; const forcePw = body.dataset.forcePw === "1";
title.textContent = "Unlock Aegis"; title.textContent = "Unlock Aegis";
sub.textContent = "Aegis derives its keys from your Theseus vault. There's nothing separate to unlock — the vault is your wallet."; sub.textContent = "Aegis derives its keys from your Theseus vault. There's nothing separate to unlock — the vault is your wallet.";
if (hasPin && !forcePw) { if (hasPin && !forcePw && !securityState?.pinRequireMaster) {
// render() runs on every state push — balance polls fire it every couple // render() runs on every state push — balance polls fire it every couple
// of seconds — and this used to rebuild body.innerHTML each time, wiping // of seconds — and this used to rebuild body.innerHTML each time, wiping
// the pad DOM and its digit buffer out from under someone mid-entry. // the pad DOM and its digit buffer out from under someone mid-entry.
@ -3399,25 +3374,20 @@ function renderLockScreen(phase) {
keys: body.querySelector("#lockPinKeys"), keys: body.querySelector("#lockPinKeys"),
err: body.querySelector("#lockPinErr"), err: body.querySelector("#lockPinErr"),
onComplete: async (pin) => { onComplete: async (pin) => {
const remain = await pinLockoutRemainingMs(); let r;
if (remain > 0) { try { r = await pinTry(pin); }
$("lockPinErr").textContent = `Too many failed attempts. Try again in ${Math.ceil(remain / 60000)} min or use the master password.`; catch (e) { $("lockPinErr").textContent = cleanErr(e); return "reset"; }
if (!r.ok) {
$("lockPinErr").textContent = r.requireMaster ? PIN_MASTER_COPY : wrongPinCopy(r.remaining);
if (r.requireMaster) { body.dataset.forcePw = "1"; setTimeout(() => renderLockScreen("locked"), 1200); }
return "reset"; return "reset";
} }
try { try {
const blob = await S.invoke("pinBlobGet"); state = await S.invoke("vaultUnlock", { masterPassword: r.masterPassword });
if (!blob) throw new Error("PIN not set");
const pw = await pinDecryptMaster(pin, blob);
state = await S.invoke("vaultUnlock", { masterPassword: pw });
await S.invoke("pinFailReset");
render(); render();
return "ok"; return "ok";
} catch (e) { } catch (e) {
const fs = await S.invoke("pinFailInc").catch(() => ({ count: 0 })); $("lockPinErr").textContent = cleanErr(e);
const left = Math.max(0, PIN_MAX_FAILS - (fs?.count || 0));
$("lockPinErr").textContent = left > 0
? `Wrong PIN. ${left} attempt${left === 1 ? "" : "s"} left before a 15 min lockout.`
: `Locked for 15 min — use the master password instead.`;
return "reset"; return "reset";
} }
}, },
@ -3512,6 +3482,11 @@ function setupPinPad({ dots, keys, err, onComplete }) {
// unlock, so six digits typed into the amount box counted as a PIN // unlock, so six digits typed into the amount box counted as a PIN
// attempt — and five such amounts locked the PIN for 15 minutes. // attempt — and five such amounts locked the PIN for 15 minutes.
if (dots.offsetParent === null) return; if (dots.offsetParent === null) return;
// Two pads can be visible at once (a dapp's PIN request over a send's
// pad): only the topmost one listens, or six digits would complete both
// and a wrong PIN would cost two attempts.
const topModal = [...document.querySelectorAll(".pinmodal")].pop();
if (topModal && !topModal.contains(dots)) return;
const tgt = e.target; const tgt = e.target;
if (tgt && (tgt.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(tgt.tagName || ""))) return; if (tgt && (tgt.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(tgt.tagName || ""))) return;
if (err) err.textContent = ""; if (err) err.textContent = "";
@ -4160,8 +4135,10 @@ function amountDecimals() { return sendAsset ? Number(sendAsset.decimals) || 0 :
// guess: the old `.replace(/,/g, "")` turned a decimal comma ("0,5") into 5, // guess: the old `.replace(/,/g, "")` turned a decimal comma ("0,5") into 5,
// and Number() accepted "1e3", "0x10" and "-0.5". // and Number() accepted "1e3", "0x10" and "-0.5".
function parseAmountText(text) { function parseAmountText(text) {
let raw = String(text == null ? "" : text).trim().replace(/\s/g, ""); let raw = String(text == null ? "" : text).trim();
if (!raw) return { empty: true }; if (!raw) return { empty: true };
// Space inside the number is refused, not deleted: "1 0" used to become 10.
if (/\s/.test(raw)) return { error: "Remove the space from the amount" };
if (/^\d{1,3}(,\d{3})+\.\d*$/.test(raw) || /^\d{1,3}(,\d{3}){2,}$/.test(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 raw = raw.replace(/,/g, ""); // 1,234.5 · 1,234,567
} else if (/^\d{1,3},\d{3}$/.test(raw)) { } else if (/^\d{1,3},\d{3}$/.test(raw)) {
@ -4635,7 +4612,7 @@ $("sendBtn").addEventListener("click", async () => {
try { try {
const isToken = !!(req.mint && lastPlan._token); const isToken = !!(req.mint && lastPlan._token);
const r = isToken const r = isToken
? await S.invoke("sendToken", { mint: req.mint, to: req.to, amount: req.amount }) ? await S.invoke("sendToken", { walletId: req.walletId, mint: req.mint, to: req.to, amount: req.amount })
: await S.invoke("send", { walletId: req.walletId, to: req.to, amount: req.amount, feeRate: req.feeRate, sendMax: req.sendMax, memo: req.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 // 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. // it as a failure and leave a filled form inviting a second send.
@ -5004,9 +4981,7 @@ async function handlePinSet(replacing) {
}); });
if (!pin) return; if (!pin) return;
try { try {
const blob = await pinEncryptMaster(pin, masterPw); await S.invoke("pinSet", { pin, masterPassword: masterPw });
await S.invoke("pinBlobSet", { blob });
await S.invoke("pinFailReset").catch(() => {});
await refreshSecurityState(); await refreshSecurityState();
renderGeneralSecurity(); renderGeneralSecurity();
} catch (e) { } catch (e) {
@ -5169,19 +5144,29 @@ async function pinGate(event, subtitle) {
// does not count, so a failure here is a failed gate. // 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."); const proof = await verifyPinInteractively(subtitle || PIN_GATE_COPY[st.reason] || "Confirm with your PIN.");
if (!proof) return false; if (!proof) return false;
try { await S.invoke("pinGateSatisfied", { masterPassword: proof }); } // `event` says what the proof is for: "transaction" clears the panel's
// next send, anything else only records the gate.
try { await S.invoke("pinGateSatisfied", { masterPassword: proof, event }); }
catch { return false; } catch { return false; }
return true; return true;
} }
// A dapp transaction is waiting on the PIN ("ask on every transaction"). // 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 // The host cannot draw a PIN pad, so it asks the panel: prove the PIN here
// and the pending transaction goes through. // and the pending transaction goes through.
// Requests are answered one at a time, each with its own PIN entry, and the
// proof names the request it was entered for — so a PIN typed for one site
// never releases another site's transaction.
let pinRequestBusy = false;
async function answerPinRequest(req) { async function answerPinRequest(req) {
if (!req || pinGateBlocked) return; if (!req || !req.id || pinGateBlocked || pinRequestBusy) return;
const where = String(req.origin || "A site").slice(0, 80); pinRequestBusy = true;
const proof = await verifyPinInteractively(`${where} is waiting — confirm the ${String(req.what || "transaction").slice(0, 60)} with your PIN.`); try {
if (!proof) return; const where = String(req.origin || "A site").slice(0, 80);
try { await S.invoke("pinGateSatisfied", { masterPassword: proof }); } catch { /* host keeps waiting, then refuses */ } const proof = await verifyPinInteractively(`${where} is waiting — confirm the ${String(req.what || "transaction").slice(0, 60)} with your PIN.`);
if (proof) { try { await S.invoke("pinGateSatisfied", { masterPassword: proof, requestId: req.id }); } catch { /* host keeps waiting, then refuses */ } }
} finally { pinRequestBusy = false; }
// Another site may be waiting too.
try { const next = await S.invoke("pinRequestPending"); if (next && next.id !== req.id) answerPinRequest(next); } catch {}
} }
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.",
@ -5253,11 +5238,11 @@ function paintPinDoor(reason) {
} }
// How many wrong PINs before a sensitive reveal stops asking for the PIN and // How many wrong PINs before a sensitive reveal stops asking for the PIN and
// asks for the master password instead. Lower than PIN_MAX_FAILS on purpose: // asks for the master password instead. Lower than the host's limit (5) on
// someone fumbling their own PIN gets a way through that does not cost them a // purpose: someone fumbling their own PIN gets a way through before the PIN
// 15-minute lockout, and someone guessing is pushed onto the credential that // is switched off, and someone guessing is pushed onto the credential that
// is actually hard to guess. The global counter is NOT reset on the way // is actually hard to guess. The host counter is NOT reset on the way
// across, so guesses still accumulate toward the lockout. // across, so guesses still accumulate toward that limit.
const REVEAL_PIN_MAX_FAILS = 3; const REVEAL_PIN_MAX_FAILS = 3;
// Prove entitlement to see a secret, and hand back the master password — // Prove entitlement to see a secret, and hand back the master password —
@ -5274,14 +5259,11 @@ async function authorizeForSecret(subtitle) {
// the master password is the gate — never nothing. // the master password is the gate — never nothing.
return promptMasterPassword({ title: "Confirm master password", subtitle }); return promptMasterPassword({ title: "Confirm master password", subtitle });
} }
const remain = await pinLockoutRemainingMs(); if (await pinNeedsMaster()) {
if (remain > 0) { // The PIN is switched off after too many wrong guesses, but the password
// Locked out of the PIN, but the password is a separate credential and // is a separate credential: the strikes stop PIN guessing, they do not
// the lockout exists to stop PIN guessing, not to lock the owner out. // lock the owner out.
return promptMasterPassword({ return promptMasterPassword({ title: "Confirm master password", subtitle: `${PIN_MASTER_COPY} ${subtitle || ""}`.trim() });
title: "Confirm master password",
subtitle: `PIN entry is locked for ${Math.ceil(remain / 60000)} min. ${subtitle || ""}`.trim(),
});
} }
const pin = await capturePinForSecret(subtitle); const pin = await capturePinForSecret(subtitle);
if (pin === null) return null; // cancelled if (pin === null) return null; // cancelled
@ -5329,23 +5311,16 @@ function capturePinForSecret(subtitle) {
setupPinPad({ setupPinPad({
dots: wrap.querySelector("#rsDots"), keys: wrap.querySelector("#rsKeys"), err: wrap.querySelector("#rsErr"), dots: wrap.querySelector("#rsDots"), keys: wrap.querySelector("#rsKeys"), err: wrap.querySelector("#rsErr"),
onComplete: async (pin) => { onComplete: async (pin) => {
try { let r;
const blob = await S.invoke("pinBlobGet"); try { r = await pinTry(pin); }
if (!blob) throw new Error("no PIN configured"); catch (e) { $("rsErr").textContent = cleanErr(e); return "reset"; }
const master = await pinDecryptMaster(pin, blob); if (r.ok) { done(r.masterPassword); return "ok"; }
await S.invoke("pinFailReset").catch(() => {}); // Every guess here also counts toward the host's limit.
done(master); tries++;
return "ok"; if (r.requireMaster || tries >= REVEAL_PIN_MAX_FAILS) { done("__fallback__"); return "ok"; }
} catch (e) { const left = Math.min(REVEAL_PIN_MAX_FAILS - tries, r.remaining);
tries++; $("rsErr").textContent = `Wrong PIN. ${left} attempt${left === 1 ? "" : "s"} left, then Aegis will ask for your master password.`;
// Keep feeding the shared counter: these are real PIN guesses and return "reset";
// they should still count toward the 15 min lockout.
await S.invoke("pinFailInc").catch(() => ({ count: 0 }));
if (tries >= REVEAL_PIN_MAX_FAILS) { done("__fallback__"); return "ok"; }
const left = REVEAL_PIN_MAX_FAILS - tries;
$("rsErr").textContent = `Wrong PIN. ${left} attempt${left === 1 ? "" : "s"} left, then Aegis will ask for your master password.`;
return "reset";
}
}, },
}); });
}); });
@ -5366,10 +5341,11 @@ function verifyPinInteractively(subtitle) {
} }
async function verifyPinInteractivelyOnce(subtitle) { async function verifyPinInteractivelyOnce(subtitle) {
const remain = await pinLockoutRemainingMs(); // The PIN is off after too many wrong guesses; the master password is the
if (remain > 0) { // same proof (it is what the PIN unwraps), and the host checks it.
aegisAlert(`PIN entry is locked for ${Math.ceil(remain / 60000)} min. Use "Remove" in Settings or wait it out.`); if (await pinNeedsMaster()) {
return false; const pw = await promptMasterPassword({ title: "Confirm master password", subtitle: `${PIN_MASTER_COPY} ${subtitle || ""}`.trim() });
return pw || false;
} }
return new Promise((resolve) => { return new Promise((resolve) => {
const wrap = document.createElement("div"); const wrap = document.createElement("div");
@ -5398,24 +5374,22 @@ async function verifyPinInteractivelyOnce(subtitle) {
setupPinPad({ setupPinPad({
dots: wrap.querySelector("#vpDots"), keys: wrap.querySelector("#vpKeys"), err: wrap.querySelector("#vpErr"), dots: wrap.querySelector("#vpDots"), keys: wrap.querySelector("#vpKeys"), err: wrap.querySelector("#vpErr"),
onComplete: async (pin) => { onComplete: async (pin) => {
try { let r;
const blob = await S.invoke("pinBlobGet"); try { r = await pinTry(pin); }
if (!blob) throw new Error("no PIN configured"); catch (e) { $("vpErr").textContent = cleanErr(e); return "reset"; }
const master = await pinDecryptMaster(pin, blob); // The unwrapped master password is truthy for every existing caller;
await S.invoke("pinFailReset").catch(() => {}); // the gate hands it to the host as proof (see pinGate).
// Truthy for every existing caller; the gate hands it to the host if (r.ok) { done(r.masterPassword || true); return "ok"; }
// as proof (see pinGate). $("vpErr").textContent = r.requireMaster ? PIN_MASTER_COPY : wrongPinCopy(r.remaining);
done(master || true); if (r.requireMaster) {
setTimeout(async () => {
try { wrap.remove(); } catch {}
const pw = await promptMasterPassword({ title: "Confirm master password", subtitle: PIN_MASTER_COPY });
resolve(pw || false);
}, 1200);
return "ok"; return "ok";
} catch (e) {
const fs = await S.invoke("pinFailInc").catch(() => ({ count: 0 }));
const left = Math.max(0, PIN_MAX_FAILS - (fs?.count || 0));
$("vpErr").textContent = left > 0
? `Wrong PIN. ${left} attempt${left === 1 ? "" : "s"} left before a 15 min lockout.`
: `Locked for 15 min.`;
if (left === 0) { done(false); return "ok"; }
return "reset";
} }
return "reset";
}, },
}); });
}); });