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;
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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`);
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue