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:
commit
80146c8c22
6 changed files with 360 additions and 209 deletions
|
|
@ -980,6 +980,13 @@ function buildWcApproval(payload) {
|
|||
inner = typeof dec === "string" ? null : dec;
|
||||
} 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 rows = [{ label: "Wallet", value: walletName }, { label: "Inputs", value: String(inputCount) }];
|
||||
// What the wallet is putting IN. The outputs alone never showed that a
|
||||
|
|
@ -1011,6 +1018,7 @@ function buildWcApproval(payload) {
|
|||
try {
|
||||
const outs = inner?.outputs || [];
|
||||
let total = 0n;
|
||||
for (const o of outs) { try { total += BigInt(o.valueSatoshis ?? 0); } catch {} }
|
||||
outs.slice(0, 8).forEach((o, i) => {
|
||||
const lb = o.lockingBytecode;
|
||||
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)));
|
||||
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);
|
||||
total += v;
|
||||
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 });
|
||||
});
|
||||
|
|
@ -1230,39 +1237,88 @@ function openPinBlob(api) {
|
|||
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
|
||||
// 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.
|
||||
//
|
||||
// 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 DAPP_PIN_WAIT_MS = 120_000;
|
||||
let txClearanceUntil = 0;
|
||||
let pendingPinRequest = null; // { origin, what, at } while a dapp tx waits for the PIN
|
||||
let panelClearanceUntil = 0;
|
||||
const pendingPinRequests = new Map(); // id -> { id, origin, what, at, cleared }
|
||||
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");
|
||||
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) {
|
||||
if (!txPinOwed() || takeTxClearance()) return;
|
||||
pendingPinRequest = { origin: String(origin || ""), what: String(what || "transaction"), at: Date.now() };
|
||||
if (!txPinOwed()) return;
|
||||
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 { 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;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
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.");
|
||||
}
|
||||
|
||||
|
|
@ -1341,6 +1397,7 @@ function registerPanelMessages(api) {
|
|||
fromPanel(m);
|
||||
const pw = String(p && p.masterPassword || "");
|
||||
await api.vault.lifecycle.unlock(pw);
|
||||
noteMasterVerified(api);
|
||||
await mountAllWallets();
|
||||
return fullState();
|
||||
});
|
||||
|
|
@ -1749,6 +1806,10 @@ function registerPanelMessages(api) {
|
|||
api.onMessage("sendToken", async (p, m) => {
|
||||
fromPanel(m);
|
||||
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();
|
||||
if (rt.entry.chain !== "sol") throw new Error("token send is Solana-only");
|
||||
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
|
||||
// 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()) {
|
||||
// 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");
|
||||
}
|
||||
const rt = requireSelected();
|
||||
|
|
@ -1879,7 +1943,7 @@ function registerPanelMessages(api) {
|
|||
// 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) {
|
||||
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);
|
||||
|
|
@ -2122,6 +2186,7 @@ function registerPanelMessages(api) {
|
|||
if (/no vault|not set up/i.test(msg)) throw new Error(msg);
|
||||
throw new Error("wrong master password");
|
||||
}
|
||||
noteMasterVerified(api);
|
||||
|
||||
const id = String((p && p.walletId) || selectedWalletId() || "");
|
||||
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
|
||||
// process boundary except via vaultUnlock. These handlers only shuttle
|
||||
// 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);
|
||||
return openPinBlob(api);
|
||||
});
|
||||
api.onMessage("pinBlobSet", (p, m) => {
|
||||
fromPanel(m);
|
||||
const blob = p && p.blob;
|
||||
if (!blob || typeof blob !== "object") throw new Error("blob required");
|
||||
if (typeof blob.salt !== "string" || typeof blob.iv !== "string" || typeof blob.ct !== "string" || typeof blob.iters !== "number") {
|
||||
throw new Error("blob shape invalid");
|
||||
}
|
||||
api.storage.set(PIN_BLOB_KEY, sealPinBlob(api, { salt: blob.salt, iv: blob.iv, ct: blob.ct, iters: blob.iters }));
|
||||
const pin = String((p && p.pin) || "");
|
||||
const pw = String((p && p.masterPassword) || "");
|
||||
if (!PIN_RE.test(pin)) throw new Error("the PIN must be 6 digits");
|
||||
if (!pw) throw new Error("master password required");
|
||||
try { await api.vault.lifecycle.unlock(pw); }
|
||||
catch { throw new Error("wrong master password"); }
|
||||
noteMasterVerified(api);
|
||||
api.storage.set(PIN_BLOB_KEY, sealPinBlob(api, await pinWrap(pin, pw)));
|
||||
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) => {
|
||||
fromPanel(m);
|
||||
api.storage.set("aegis/pin/v1", null);
|
||||
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
|
||||
// "not yet satisfied" rather than inheriting the old PIN's clearance.
|
||||
api.storage.set("aegis/pin/gate", null);
|
||||
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
|
||||
// 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");
|
||||
try { await api.vault.lifecycle.unlock(pw); }
|
||||
catch { throw new Error("PIN proof rejected"); }
|
||||
noteMasterVerified(api);
|
||||
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;
|
||||
});
|
||||
// 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;
|
||||
// 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;
|
||||
// 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", {}) || {};
|
||||
return {
|
||||
hasPin: !!api.storage.get("aegis/pin/v1", null),
|
||||
pinRequireMaster: pinFails(api).requireMaster,
|
||||
pinOn: pinPolicy(),
|
||||
pinIntervalHours: PIN_INTERVAL_MS / 3600000,
|
||||
requirePinForSending: !!cfg.requirePinForSending,
|
||||
|
|
@ -2712,6 +2798,7 @@ function registerPanelMessages(api) {
|
|||
api.storage.set("aegis/security/v1", next);
|
||||
return {
|
||||
hasPin: !!api.storage.get("aegis/pin/v1", null),
|
||||
pinRequireMaster: pinFails(api).requireMaster,
|
||||
pinOn: pinPolicy(),
|
||||
pinIntervalHours: PIN_INTERVAL_MS / 3600000,
|
||||
requirePinForSending: !!next.requirePinForSending,
|
||||
|
|
@ -3111,13 +3198,50 @@ const ETH_RPC_NEVER = /^(eth_sign|eth_signTransaction|eth_sendTransaction|eth_ac
|
|||
// 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.
|
||||
// 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) {
|
||||
const primary = String(td?.primaryType || "");
|
||||
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
|
||||
// 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 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 b = big(v);
|
||||
if (b === null) return String(v);
|
||||
|
|
@ -3126,23 +3250,34 @@ function describePermit(td) {
|
|||
};
|
||||
const rows = [];
|
||||
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 });
|
||||
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] : [])));
|
||||
// The variant is chosen by the declared type, never by what the message
|
||||
// happens to carry.
|
||||
if (has("holder", "spender", "nonce", "expiry", "allowed")) {
|
||||
// DAI-style: a bool, encoded by truthiness — so is this.
|
||||
items.push({ token: td.domain?.verifyingContract, amount: msg.allowed ? UNLIMITED : 0n });
|
||||
rows.push({ label: "Valid until", value: when(msg.expiry) });
|
||||
} 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 });
|
||||
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) => {
|
||||
const b = big(it.amount);
|
||||
if (b !== null && b >= UNLIMITED) risky = true;
|
||||
if (b === null || b >= HUGE) 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)}` : ""}`,
|
||||
|
|
@ -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) { 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 };
|
||||
}
|
||||
|
||||
|
|
@ -3223,15 +3358,31 @@ function registerPageMessages(api) {
|
|||
const perms = permissions(api);
|
||||
const budget = perms[origin] && perms[origin].sendTx;
|
||||
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) {
|
||||
// An allowance skips the overlay, not the PIN the user asked for on
|
||||
// every transaction.
|
||||
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);
|
||||
budget.usedSats = (budget.usedSats | 0) + d.total;
|
||||
api.storage.set("permissions", perms);
|
||||
patchSendTx((t) => (t ? { ...t, usedSats: (t.usedSats | 0) + d.total } : t));
|
||||
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 };
|
||||
}
|
||||
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");
|
||||
const cap = flags.find((f) => f.startsWith("cap="));
|
||||
const capSats = cap ? Number(cap.slice(4)) : 0;
|
||||
if (BCH_ALLOWANCES.includes(capSats)) {
|
||||
perms[origin] = { ...(perms[origin] || {}), sendTx: { capSats, usedSats: 0, grantedAt: Date.now() } };
|
||||
api.storage.set("permissions", perms);
|
||||
} else if (budget) {
|
||||
delete perms[origin].sendTx;
|
||||
api.storage.set("permissions", perms);
|
||||
}
|
||||
if (BCH_ALLOWANCES.includes(capSats)) patchSendTx(() => ({ capSats, usedSats: 0, grantedAt: Date.now() }));
|
||||
else if (budget) patchSendTx(() => null);
|
||||
emitState();
|
||||
const r = await rt.adapter.signAndBroadcast(plan);
|
||||
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 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 }];
|
||||
if (dom.verifyingContract) rows.push({ label: "Contract", value: String(dom.verifyingContract), mono: true });
|
||||
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.
|
||||
const permit = describePermit(td);
|
||||
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 });
|
||||
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 });
|
||||
const risky = !!(permit && permit.risky);
|
||||
const risky = !!(permit && permit.risky) || movesAssets;
|
||||
return withOriginLock(origin, async () => {
|
||||
const pick = await api.approvalModal({
|
||||
title: permit ? "Sign a token spending permit?" : "Sign typed data (EIP-712)?",
|
||||
|
|
@ -3676,7 +3829,9 @@ function registerPageMessages(api) {
|
|||
? (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."
|
||||
: "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,
|
||||
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
|
||||
// every WizardConnect signing request was refused, and the HTML
|
||||
// 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({
|
||||
title: "Sign a Bitcoin Cash transaction (WizardConnect)?",
|
||||
origin: dappName,
|
||||
|
|
|
|||
|
|
@ -46,8 +46,11 @@ const DEFAULT_REGISTRIES = [
|
|||
module.exports = function makeBcmr({ storage, log = () => {} }) {
|
||||
|
||||
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);
|
||||
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();
|
||||
}
|
||||
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
|
||||
// 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.
|
||||
const rng = (a, b) => String.fromCharCode(a) + "-" + String.fromCharCode(b);
|
||||
const INVISIBLE = new RegExp("[" + rng(0x00, 0x1f) + rng(0x7f, 0x9f) + rng(0x200b, 0x200f) + rng(0x202a, 0x202e) + rng(0x2060, 0x2069) + rng(0xfeff, 0xfeff) + "]", "g");
|
||||
const rng = (a, b) => String.fromCodePoint(a) + "-" + String.fromCodePoint(b);
|
||||
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) {
|
||||
if (typeof v !== "string") return null;
|
||||
const s = v.replace(INVISIBLE, "").trim().slice(0, max);
|
||||
|
|
|
|||
|
|
@ -87,8 +87,11 @@ module.exports = function makeEip712({ keccak_256 }) {
|
|||
return keccak_256(b);
|
||||
}
|
||||
if (type === "address") {
|
||||
const h = hex2bytes(String(value || "0x0").replace(/^0x/, ""));
|
||||
if (h.length !== 20) throw new Error("address must be 20 bytes");
|
||||
// Validate before decoding: the hex decoder turns non-hex characters
|
||||
// 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);
|
||||
out.set(h, 12);
|
||||
return out;
|
||||
|
|
|
|||
|
|
@ -81,9 +81,20 @@ module.exports = function makeTronDecode({ sha256, base58check }) {
|
|||
}
|
||||
return out;
|
||||
}
|
||||
const one = (fields, n) => fields.find((f) => f.field === n);
|
||||
const bytesOf = (fields, n) => { const f = one(fields, n); return f && f.wire === 2 ? f.value : null; };
|
||||
const numOf = (fields, n) => { const f = one(fields, n); return f && f.wire === 0 ? f.value : null; };
|
||||
// A singular field that appears twice is legal protobuf: the parser keeps
|
||||
// the LAST copy (and merges repeated sub-messages). java-tron does that, so
|
||||
// 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
|
||||
// returned as hex so the overlay never hides a malformed field.
|
||||
|
|
|
|||
|
|
@ -66,12 +66,13 @@ async function signTx({ request, account, branches, libauth, secp256k1 }) {
|
|||
// the outputs) meant `tx.inputs` was undefined and signing threw on the
|
||||
// first real request. index.js already read the nested
|
||||
// request.transaction.userPrompt for the approval dialog, so only this
|
||||
// module had it wrong. The flat shape is still accepted so a caller
|
||||
// that hands us an already-unwrapped payload keeps working.
|
||||
const inner = (request.transaction && (request.transaction.transaction !== undefined
|
||||
|| request.transaction.sourceOutputs !== undefined))
|
||||
? request.transaction
|
||||
: request;
|
||||
// module had it wrong. Only the nested shape is accepted: the approval
|
||||
// overlay reads that shape, and a flat request used to be signed after an
|
||||
// overlay that could show neither its outputs nor what it spends.
|
||||
const inner = request.transaction;
|
||||
if (!inner || typeof inner !== "object" || inner.transaction === undefined || !Array.isArray(inner.sourceOutputs)) {
|
||||
throw new Error("wc-sign: request is not a WizardConnect sign request (request.transaction.{transaction,sourceOutputs})");
|
||||
}
|
||||
const tx = ensureTransaction(inner.transaction, libauth);
|
||||
const sourceOutputs = (inner.sourceOutputs || []).map((o, i) => {
|
||||
if (o.contract) throw new Error(`wc-sign: input ${i} spends a contract — unsupported`);
|
||||
|
|
|
|||
|
|
@ -300,48 +300,20 @@ function fiatSkeleton() {
|
|||
return state?.prices?.enabled ? "≈ $—" : null;
|
||||
}
|
||||
|
||||
// ---- security: PIN encryption + verification (WebCrypto) -------------------
|
||||
// The PIN blob wraps the master password: PBKDF2-SHA256(pin, salt, iters)
|
||||
// derives an AES-GCM key; the master password is encrypted with a fresh
|
||||
// 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
|
||||
// limiter is stored addon-side so reloading the panel cannot reset it.
|
||||
const PIN_ITERS = 600000; // new blobs only; an existing blob carries its own count
|
||||
const PIN_MAX_FAILS = 5;
|
||||
const PIN_LOCKOUT_MS = 15 * 60 * 1000;
|
||||
const b2h = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
|
||||
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; }
|
||||
// ---- security: PIN ----------------------------------------------------------
|
||||
// The pads below only collect six digits. The host (index.js pinUnwrap)
|
||||
// holds the PIN blob, counts every guess before trying it, and after too
|
||||
// many wrong ones switches the PIN off until the master password is entered.
|
||||
// The panel never sees the blob, so it cannot be searched from here, and it
|
||||
// cannot reset the counter.
|
||||
// pinTry(pin) -> { ok: true, masterPassword } | { ok: false, remaining, requireMaster }
|
||||
const pinTry = (pin) => S.invoke("pinUnwrap", { pin: String(pin) });
|
||||
async function pinNeedsMaster() {
|
||||
try { return !!(await S.invoke("pinStatus")).requireMaster; } catch { return false; }
|
||||
}
|
||||
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() {
|
||||
try {
|
||||
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.`,
|
||||
});
|
||||
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 {
|
||||
const r = await S.invoke("promoteToHd", { walletId: w.id, label: pv.suggestedLabel });
|
||||
close();
|
||||
|
|
@ -3374,7 +3349,7 @@ function renderLockScreen(phase) {
|
|||
const forcePw = body.dataset.forcePw === "1";
|
||||
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.";
|
||||
if (hasPin && !forcePw) {
|
||||
if (hasPin && !forcePw && !securityState?.pinRequireMaster) {
|
||||
// render() runs on every state push — balance polls fire it every couple
|
||||
// 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.
|
||||
|
|
@ -3399,25 +3374,20 @@ function renderLockScreen(phase) {
|
|||
keys: body.querySelector("#lockPinKeys"),
|
||||
err: body.querySelector("#lockPinErr"),
|
||||
onComplete: async (pin) => {
|
||||
const remain = await pinLockoutRemainingMs();
|
||||
if (remain > 0) {
|
||||
$("lockPinErr").textContent = `Too many failed attempts. Try again in ${Math.ceil(remain / 60000)} min or use the master password.`;
|
||||
let r;
|
||||
try { r = await pinTry(pin); }
|
||||
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";
|
||||
}
|
||||
try {
|
||||
const blob = await S.invoke("pinBlobGet");
|
||||
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");
|
||||
state = await S.invoke("vaultUnlock", { masterPassword: r.masterPassword });
|
||||
render();
|
||||
return "ok";
|
||||
} catch (e) {
|
||||
const fs = await S.invoke("pinFailInc").catch(() => ({ count: 0 }));
|
||||
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.`;
|
||||
$("lockPinErr").textContent = cleanErr(e);
|
||||
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
|
||||
// attempt — and five such amounts locked the PIN for 15 minutes.
|
||||
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;
|
||||
if (tgt && (tgt.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(tgt.tagName || ""))) return;
|
||||
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,
|
||||
// and Number() accepted "1e3", "0x10" and "-0.5".
|
||||
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 };
|
||||
// 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)) {
|
||||
raw = raw.replace(/,/g, ""); // 1,234.5 · 1,234,567
|
||||
} else if (/^\d{1,3},\d{3}$/.test(raw)) {
|
||||
|
|
@ -4635,7 +4612,7 @@ $("sendBtn").addEventListener("click", async () => {
|
|||
try {
|
||||
const isToken = !!(req.mint && lastPlan._token);
|
||||
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 });
|
||||
// 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.
|
||||
|
|
@ -5004,9 +4981,7 @@ async function handlePinSet(replacing) {
|
|||
});
|
||||
if (!pin) return;
|
||||
try {
|
||||
const blob = await pinEncryptMaster(pin, masterPw);
|
||||
await S.invoke("pinBlobSet", { blob });
|
||||
await S.invoke("pinFailReset").catch(() => {});
|
||||
await S.invoke("pinSet", { pin, masterPassword: masterPw });
|
||||
await refreshSecurityState();
|
||||
renderGeneralSecurity();
|
||||
} catch (e) {
|
||||
|
|
@ -5169,19 +5144,29 @@ async function pinGate(event, subtitle) {
|
|||
// 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 }); }
|
||||
// `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; }
|
||||
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.
|
||||
// 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) {
|
||||
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 */ }
|
||||
if (!req || !req.id || pinGateBlocked || pinRequestBusy) return;
|
||||
pinRequestBusy = true;
|
||||
try {
|
||||
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) { 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 = {
|
||||
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
|
||||
// asks for the master password instead. Lower than PIN_MAX_FAILS on purpose:
|
||||
// someone fumbling their own PIN gets a way through that does not cost them a
|
||||
// 15-minute lockout, and someone guessing is pushed onto the credential that
|
||||
// is actually hard to guess. The global counter is NOT reset on the way
|
||||
// across, so guesses still accumulate toward the lockout.
|
||||
// asks for the master password instead. Lower than the host's limit (5) on
|
||||
// purpose: someone fumbling their own PIN gets a way through before the PIN
|
||||
// is switched off, and someone guessing is pushed onto the credential that
|
||||
// is actually hard to guess. The host counter is NOT reset on the way
|
||||
// across, so guesses still accumulate toward that limit.
|
||||
const REVEAL_PIN_MAX_FAILS = 3;
|
||||
|
||||
// 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.
|
||||
return promptMasterPassword({ title: "Confirm master password", subtitle });
|
||||
}
|
||||
const remain = await pinLockoutRemainingMs();
|
||||
if (remain > 0) {
|
||||
// Locked out of the PIN, but the password is a separate credential and
|
||||
// the lockout exists to stop PIN guessing, not to lock the owner out.
|
||||
return promptMasterPassword({
|
||||
title: "Confirm master password",
|
||||
subtitle: `PIN entry is locked for ${Math.ceil(remain / 60000)} min. ${subtitle || ""}`.trim(),
|
||||
});
|
||||
if (await pinNeedsMaster()) {
|
||||
// The PIN is switched off after too many wrong guesses, but the password
|
||||
// is a separate credential: the strikes stop PIN guessing, they do not
|
||||
// lock the owner out.
|
||||
return promptMasterPassword({ title: "Confirm master password", subtitle: `${PIN_MASTER_COPY} ${subtitle || ""}`.trim() });
|
||||
}
|
||||
const pin = await capturePinForSecret(subtitle);
|
||||
if (pin === null) return null; // cancelled
|
||||
|
|
@ -5329,23 +5311,16 @@ function capturePinForSecret(subtitle) {
|
|||
setupPinPad({
|
||||
dots: wrap.querySelector("#rsDots"), keys: wrap.querySelector("#rsKeys"), err: wrap.querySelector("#rsErr"),
|
||||
onComplete: async (pin) => {
|
||||
try {
|
||||
const blob = await S.invoke("pinBlobGet");
|
||||
if (!blob) throw new Error("no PIN configured");
|
||||
const master = await pinDecryptMaster(pin, blob);
|
||||
await S.invoke("pinFailReset").catch(() => {});
|
||||
done(master);
|
||||
return "ok";
|
||||
} catch (e) {
|
||||
tries++;
|
||||
// Keep feeding the shared counter: these are real PIN guesses and
|
||||
// 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";
|
||||
}
|
||||
let r;
|
||||
try { r = await pinTry(pin); }
|
||||
catch (e) { $("rsErr").textContent = cleanErr(e); return "reset"; }
|
||||
if (r.ok) { done(r.masterPassword); return "ok"; }
|
||||
// Every guess here also counts toward the host's limit.
|
||||
tries++;
|
||||
if (r.requireMaster || tries >= REVEAL_PIN_MAX_FAILS) { done("__fallback__"); return "ok"; }
|
||||
const left = Math.min(REVEAL_PIN_MAX_FAILS - tries, r.remaining);
|
||||
$("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) {
|
||||
const remain = await pinLockoutRemainingMs();
|
||||
if (remain > 0) {
|
||||
aegisAlert(`PIN entry is locked for ${Math.ceil(remain / 60000)} min. Use "Remove" in Settings or wait it out.`);
|
||||
return false;
|
||||
// The PIN is off after too many wrong guesses; the master password is the
|
||||
// same proof (it is what the PIN unwraps), and the host checks it.
|
||||
if (await pinNeedsMaster()) {
|
||||
const pw = await promptMasterPassword({ title: "Confirm master password", subtitle: `${PIN_MASTER_COPY} ${subtitle || ""}`.trim() });
|
||||
return pw || false;
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const wrap = document.createElement("div");
|
||||
|
|
@ -5398,24 +5374,22 @@ async function verifyPinInteractivelyOnce(subtitle) {
|
|||
setupPinPad({
|
||||
dots: wrap.querySelector("#vpDots"), keys: wrap.querySelector("#vpKeys"), err: wrap.querySelector("#vpErr"),
|
||||
onComplete: async (pin) => {
|
||||
try {
|
||||
const blob = await S.invoke("pinBlobGet");
|
||||
if (!blob) throw new Error("no PIN configured");
|
||||
const master = await pinDecryptMaster(pin, blob);
|
||||
await S.invoke("pinFailReset").catch(() => {});
|
||||
// Truthy for every existing caller; the gate hands it to the host
|
||||
// as proof (see pinGate).
|
||||
done(master || true);
|
||||
let r;
|
||||
try { r = await pinTry(pin); }
|
||||
catch (e) { $("vpErr").textContent = cleanErr(e); return "reset"; }
|
||||
// The unwrapped master password is truthy for every existing caller;
|
||||
// the gate hands it to the host as proof (see pinGate).
|
||||
if (r.ok) { done(r.masterPassword || true); return "ok"; }
|
||||
$("vpErr").textContent = r.requireMaster ? PIN_MASTER_COPY : wrongPinCopy(r.remaining);
|
||||
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";
|
||||
} 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";
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue