Aegis: the PIN is checked by the host, and guessing ends at five
The panel fetched the PIN blob and decrypted it itself, then reported its own failures. The lockout therefore counted only what a well-behaved panel chose to report, a 15-minute timer handed out five more guesses forever, and anything able to run in the panel could take the blob and search the million PINs offline in minutes. The blob now never leaves index.js: pinSet builds it after checking the master password against the vault, pinUnwrap counts each guess before trying it, and five wrong guesses switch the PIN off until the master password is entered. The panel keeps its PIN pads and only sends digits. A blob from an older build (200k iterations) is re-made at 600k under a fresh salt on the next correct PIN. Only the topmost PIN pad listens to typed digits, so two stacked pads cannot both take one entry.
This commit is contained in:
parent
c906e7b8ed
commit
b85605416e
2 changed files with 154 additions and 142 deletions
|
|
@ -1230,6 +1230,45 @@ 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,
|
||||||
|
|
@ -1341,6 +1380,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();
|
||||||
});
|
});
|
||||||
|
|
@ -2122,6 +2162,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 +2587,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,6 +2708,7 @@ 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;
|
txClearanceUntil = Date.now() + TX_CLEARANCE_MS;
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -2678,6 +2729,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 +2764,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,
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
@ -3374,7 +3346,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 +3371,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 +3479,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 = "";
|
||||||
|
|
@ -5004,9 +4976,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) {
|
||||||
|
|
@ -5253,11 +5223,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 +5244,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 +5296,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);
|
|
||||||
return "ok";
|
|
||||||
} catch (e) {
|
|
||||||
tries++;
|
tries++;
|
||||||
// Keep feeding the shared counter: these are real PIN guesses and
|
if (r.requireMaster || tries >= REVEAL_PIN_MAX_FAILS) { done("__fallback__"); return "ok"; }
|
||||||
// they should still count toward the 15 min lockout.
|
const left = Math.min(REVEAL_PIN_MAX_FAILS - tries, r.remaining);
|
||||||
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.`;
|
$("rsErr").textContent = `Wrong PIN. ${left} attempt${left === 1 ? "" : "s"} left, then Aegis will ask for your master password.`;
|
||||||
return "reset";
|
return "reset";
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -5366,10 +5326,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 +5359,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