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:
Local Dev 2026-10-04 02:17:26 +02:00
parent c906e7b8ed
commit b85605416e
2 changed files with 154 additions and 142 deletions

View file

@ -1230,6 +1230,45 @@ function openPinBlob(api) {
return plain;
}
// The PIN is checked here, never in the panel. The panel used to fetch the
// blob and decrypt it itself, then report its own failures — so the lockout
// counted only the guesses a well-behaved panel chose to report, and anything
// that could run in the panel could take the blob and search all million
// PINs offline. Now the blob stays in this process, every guess is counted
// before it is tried, and PIN_MAX_FAILS wrong guesses switch the PIN off
// until the master password is entered (no timer that hands out more tries).
// The blob format is unchanged: PBKDF2-SHA256(pin) -> AES-256-GCM, hex, tag
// appended to the ciphertext, as WebCrypto wrote it.
const PIN_ITERS = 600_000;
const PIN_MAX_FAILS = 5;
const PIN_RE = /^\d{6}$/;
const nodeCrypto = require("node:crypto");
const pinKey = (pin, saltHex, iters) => new Promise((resolve, reject) =>
nodeCrypto.pbkdf2(String(pin), Buffer.from(saltHex, "hex"), iters, 32, "sha256", (e, k) => (e ? reject(e) : resolve(k))));
async function pinWrap(pin, masterPassword) {
const salt = nodeCrypto.randomBytes(16).toString("hex");
const iv = nodeCrypto.randomBytes(12);
const c = nodeCrypto.createCipheriv("aes-256-gcm", await pinKey(pin, salt, PIN_ITERS), iv);
const ct = Buffer.concat([c.update(String(masterPassword), "utf8"), c.final(), c.getAuthTag()]);
return { salt, iv: iv.toString("hex"), ct: ct.toString("hex"), iters: PIN_ITERS };
}
async function pinUnwrapBlob(pin, blob) {
const ct = Buffer.from(String(blob.ct), "hex");
const d = nodeCrypto.createDecipheriv("aes-256-gcm", await pinKey(pin, blob.salt, Number(blob.iters) || PIN_ITERS), Buffer.from(String(blob.iv), "hex"));
d.setAuthTag(ct.subarray(ct.length - 16));
return Buffer.concat([d.update(ct.subarray(0, ct.length - 16)), d.final()]).toString("utf8");
}
function pinFails(api) {
const n = Number(api.storage.get("aegis/pin/failCount", 0)) || 0;
return { fails: n, requireMaster: !!api.storage.get("aegis/pin/requireMaster", false) || n >= PIN_MAX_FAILS };
}
// A master password the vault accepted. Clears the PIN strikes — the only
// thing that does, apart from a correct PIN while the PIN is still allowed.
function noteMasterVerified(api) {
api.storage.set("aegis/pin/failCount", 0);
api.storage.set("aegis/pin/requireMaster", false);
}
// "Ask for PIN on every transaction" used to be decided by the host and
// enforced by nobody: `send` never checked it, and a dapp-initiated
// transaction had no PIN step at all. A clearance is now a short-lived,
@ -1341,6 +1380,7 @@ function registerPanelMessages(api) {
fromPanel(m);
const pw = String(p && p.masterPassword || "");
await api.vault.lifecycle.unlock(pw);
noteMasterVerified(api);
await mountAllWallets();
return fullState();
});
@ -2122,6 +2162,7 @@ function registerPanelMessages(api) {
if (/no vault|not set up/i.test(msg)) throw new Error(msg);
throw new Error("wrong master password");
}
noteMasterVerified(api);
const id = String((p && p.walletId) || selectedWalletId() || "");
const entry = walletEntries().find((w) => w.id === id);
@ -2546,52 +2587,61 @@ function registerPanelMessages(api) {
// decryption inside its iframe — the master password never crosses the
// process boundary except via vaultUnlock. These handlers only shuttle
// the opaque blob + a small policy object in and out of api.storage.
api.onMessage("pinBlobGet", (_p, m) => {
// Set or replace the PIN. The master password is checked against the vault
// first, and the blob is built here, so the panel never holds one.
api.onMessage("pinSet", async (p, m) => {
fromPanel(m);
return openPinBlob(api);
});
api.onMessage("pinBlobSet", (p, m) => {
fromPanel(m);
const blob = p && p.blob;
if (!blob || typeof blob !== "object") throw new Error("blob required");
if (typeof blob.salt !== "string" || typeof blob.iv !== "string" || typeof blob.ct !== "string" || typeof blob.iters !== "number") {
throw new Error("blob shape invalid");
}
api.storage.set(PIN_BLOB_KEY, sealPinBlob(api, { salt: blob.salt, iv: blob.iv, ct: blob.ct, iters: blob.iters }));
const pin = String((p && p.pin) || "");
const pw = String((p && p.masterPassword) || "");
if (!PIN_RE.test(pin)) throw new Error("the PIN must be 6 digits");
if (!pw) throw new Error("master password required");
try { await api.vault.lifecycle.unlock(pw); }
catch { throw new Error("wrong master password"); }
noteMasterVerified(api);
api.storage.set(PIN_BLOB_KEY, sealPinBlob(api, await pinWrap(pin, pw)));
return true;
});
// Try a PIN. Counts the guess before trying it, so a crash or a closed
// panel mid-check still costs an attempt. Answers
// { ok: true, masterPassword } | { ok: false, remaining, requireMaster }
api.onMessage("pinUnwrap", async (p, m) => {
fromPanel(m);
const pin = String((p && p.pin) || "");
const blob = openPinBlob(api);
if (!blob) throw new Error("no PIN is set");
if (pinFails(api).requireMaster) return { ok: false, remaining: 0, requireMaster: true };
const before = pinFails(api).fails;
api.storage.set("aegis/pin/failCount", before + 1);
let pw = null;
if (PIN_RE.test(pin)) { try { pw = await pinUnwrapBlob(pin, blob); } catch { pw = null; } }
if (pw == null) {
const fails = before + 1;
if (fails >= PIN_MAX_FAILS) api.storage.set("aegis/pin/requireMaster", true);
return { ok: false, remaining: Math.max(0, PIN_MAX_FAILS - fails), requireMaster: fails >= PIN_MAX_FAILS };
}
api.storage.set("aegis/pin/failCount", 0);
// A blob from an older build (200k iterations, or from before sealing)
// is re-made now, under a fresh salt, while the PIN is at hand.
if ((Number(blob.iters) || 0) < PIN_ITERS) {
try { api.storage.set(PIN_BLOB_KEY, sealPinBlob(api, await pinWrap(pin, pw))); } catch (e) { api.log("PIN re-wrap:", e?.message || e); }
}
return { ok: true, masterPassword: pw };
});
api.onMessage("pinStatus", (_p, m) => {
fromPanel(m);
const f = pinFails(api);
return { hasPin: !!api.storage.get(PIN_BLOB_KEY, null), fails: f.fails, maxFails: PIN_MAX_FAILS, requireMaster: f.requireMaster };
});
api.onMessage("pinBlobClear", (_p, m) => {
fromPanel(m);
api.storage.set("aegis/pin/v1", null);
api.storage.set("aegis/pin/failCount", 0);
api.storage.set("aegis/pin/requireMaster", false);
// Drop the gate record as well, so enrolling a new PIN later starts from
// "not yet satisfied" rather than inheriting the old PIN's clearance.
api.storage.set("aegis/pin/gate", null);
return true;
});
// Track failed PIN attempts in the addon so a panel reload cannot bypass
// rate-limiting by dropping panel-side counters.
api.onMessage("pinFailInc", (_p, m) => {
fromPanel(m);
const cur = Number(api.storage.get("aegis/pin/failCount", 0)) || 0;
const next = cur + 1;
api.storage.set("aegis/pin/failCount", next);
api.storage.set("aegis/pin/failLast", Date.now());
return { count: next, at: Date.now() };
});
api.onMessage("pinFailReset", (_p, m) => {
fromPanel(m);
api.storage.set("aegis/pin/failCount", 0);
api.storage.set("aegis/pin/failLast", 0);
return true;
});
api.onMessage("pinFailStatus", (_p, m) => {
fromPanel(m);
return {
count: Number(api.storage.get("aegis/pin/failCount", 0)) || 0,
last: Number(api.storage.get("aegis/pin/failLast", 0)) || 0,
};
});
// When to ask for the PIN. These are independent triggers, not a single
// mode: wanting one at startup and one per transaction is a normal
@ -2658,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");
try { await api.vault.lifecycle.unlock(pw); }
catch { throw new Error("PIN proof rejected"); }
noteMasterVerified(api);
api.storage.set("aegis/pin/gate", { lastOkAt: Date.now(), bootId: BOOT_ID });
txClearanceUntil = Date.now() + TX_CLEARANCE_MS;
return true;
@ -2678,6 +2729,7 @@ function registerPanelMessages(api) {
const cfg = api.storage.get("aegis/security/v1", {}) || {};
return {
hasPin: !!api.storage.get("aegis/pin/v1", null),
pinRequireMaster: pinFails(api).requireMaster,
pinOn: pinPolicy(),
pinIntervalHours: PIN_INTERVAL_MS / 3600000,
requirePinForSending: !!cfg.requirePinForSending,
@ -2712,6 +2764,7 @@ function registerPanelMessages(api) {
api.storage.set("aegis/security/v1", next);
return {
hasPin: !!api.storage.get("aegis/pin/v1", null),
pinRequireMaster: pinFails(api).requireMaster,
pinOn: pinPolicy(),
pinIntervalHours: PIN_INTERVAL_MS / 3600000,
requirePinForSending: !!next.requirePinForSending,

View file

@ -300,48 +300,20 @@ function fiatSkeleton() {
return state?.prices?.enabled ? "≈ $—" : null;
}
// ---- security: PIN encryption + verification (WebCrypto) -------------------
// The PIN blob wraps the master password: PBKDF2-SHA256(pin, salt, iters)
// derives an AES-GCM key; the master password is encrypted with a fresh
// per-blob IV. The addon (main process) only handles the opaque blob; the
// panel never sends the raw PIN or the master password to it. The rate
// limiter is stored addon-side so reloading the panel cannot reset it.
const PIN_ITERS = 600000; // new blobs only; an existing blob carries its own count
const PIN_MAX_FAILS = 5;
const PIN_LOCKOUT_MS = 15 * 60 * 1000;
const b2h = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
const h2b = (h) => { const b = new Uint8Array(h.length / 2); for (let i = 0; i < b.length; i++) b[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16); return b; };
async function pinDeriveKey(pin, saltBytes, iters) {
const enc = new TextEncoder();
const material = await crypto.subtle.importKey("raw", enc.encode(pin), "PBKDF2", false, ["deriveKey"]);
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt: saltBytes, iterations: iters, hash: "SHA-256" },
material,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
}
async function pinEncryptMaster(pin, masterPassword) {
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await pinDeriveKey(pin, salt, PIN_ITERS);
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(masterPassword)));
return { salt: b2h(salt), iv: b2h(iv), ct: b2h(ct), iters: PIN_ITERS };
}
async function pinDecryptMaster(pin, blob) {
const key = await pinDeriveKey(pin, h2b(blob.salt), blob.iters || PIN_ITERS);
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: h2b(blob.iv) }, key, h2b(blob.ct));
return new TextDecoder().decode(pt);
}
async function pinLockoutRemainingMs() {
try {
const s = await S.invoke("pinFailStatus");
if (!s || !s.count || s.count < PIN_MAX_FAILS) return 0;
const since = Date.now() - (s.last || 0);
return since >= PIN_LOCKOUT_MS ? 0 : (PIN_LOCKOUT_MS - since);
} catch { return 0; }
// ---- security: PIN ----------------------------------------------------------
// The pads below only collect six digits. The host (index.js pinUnwrap)
// holds the PIN blob, counts every guess before trying it, and after too
// many wrong ones switches the PIN off until the master password is entered.
// The panel never sees the blob, so it cannot be searched from here, and it
// cannot reset the counter.
// pinTry(pin) -> { ok: true, masterPassword } | { ok: false, remaining, requireMaster }
const pinTry = (pin) => S.invoke("pinUnwrap", { pin: String(pin) });
async function pinNeedsMaster() {
try { return !!(await S.invoke("pinStatus")).requireMaster; } catch { return false; }
}
const PIN_MASTER_COPY = "Too many wrong PINs. Enter your master password; the PIN works again after that.";
const wrongPinCopy = (remaining) =>
`Wrong PIN. ${remaining} attempt${remaining === 1 ? "" : "s"} left, then Aegis asks for your master password.`;
async function refreshSecurityState() {
try {
securityState = await S.invoke("securityGet");
@ -3374,7 +3346,7 @@ function renderLockScreen(phase) {
const forcePw = body.dataset.forcePw === "1";
title.textContent = "Unlock Aegis";
sub.textContent = "Aegis derives its keys from your Theseus vault. There's nothing separate to unlock — the vault is your wallet.";
if (hasPin && !forcePw) {
if (hasPin && !forcePw && !securityState?.pinRequireMaster) {
// render() runs on every state push — balance polls fire it every couple
// of seconds — and this used to rebuild body.innerHTML each time, wiping
// the pad DOM and its digit buffer out from under someone mid-entry.
@ -3399,25 +3371,20 @@ function renderLockScreen(phase) {
keys: body.querySelector("#lockPinKeys"),
err: body.querySelector("#lockPinErr"),
onComplete: async (pin) => {
const remain = await pinLockoutRemainingMs();
if (remain > 0) {
$("lockPinErr").textContent = `Too many failed attempts. Try again in ${Math.ceil(remain / 60000)} min or use the master password.`;
let r;
try { r = await pinTry(pin); }
catch (e) { $("lockPinErr").textContent = cleanErr(e); return "reset"; }
if (!r.ok) {
$("lockPinErr").textContent = r.requireMaster ? PIN_MASTER_COPY : wrongPinCopy(r.remaining);
if (r.requireMaster) { body.dataset.forcePw = "1"; setTimeout(() => renderLockScreen("locked"), 1200); }
return "reset";
}
try {
const blob = await S.invoke("pinBlobGet");
if (!blob) throw new Error("PIN not set");
const pw = await pinDecryptMaster(pin, blob);
state = await S.invoke("vaultUnlock", { masterPassword: pw });
await S.invoke("pinFailReset");
state = await S.invoke("vaultUnlock", { masterPassword: r.masterPassword });
render();
return "ok";
} catch (e) {
const fs = await S.invoke("pinFailInc").catch(() => ({ count: 0 }));
const left = Math.max(0, PIN_MAX_FAILS - (fs?.count || 0));
$("lockPinErr").textContent = left > 0
? `Wrong PIN. ${left} attempt${left === 1 ? "" : "s"} left before a 15 min lockout.`
: `Locked for 15 min — use the master password instead.`;
$("lockPinErr").textContent = cleanErr(e);
return "reset";
}
},
@ -3512,6 +3479,11 @@ function setupPinPad({ dots, keys, err, onComplete }) {
// unlock, so six digits typed into the amount box counted as a PIN
// attempt — and five such amounts locked the PIN for 15 minutes.
if (dots.offsetParent === null) return;
// Two pads can be visible at once (a dapp's PIN request over a send's
// pad): only the topmost one listens, or six digits would complete both
// and a wrong PIN would cost two attempts.
const topModal = [...document.querySelectorAll(".pinmodal")].pop();
if (topModal && !topModal.contains(dots)) return;
const tgt = e.target;
if (tgt && (tgt.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(tgt.tagName || ""))) return;
if (err) err.textContent = "";
@ -5004,9 +4976,7 @@ async function handlePinSet(replacing) {
});
if (!pin) return;
try {
const blob = await pinEncryptMaster(pin, masterPw);
await S.invoke("pinBlobSet", { blob });
await S.invoke("pinFailReset").catch(() => {});
await S.invoke("pinSet", { pin, masterPassword: masterPw });
await refreshSecurityState();
renderGeneralSecurity();
} catch (e) {
@ -5253,11 +5223,11 @@ function paintPinDoor(reason) {
}
// How many wrong PINs before a sensitive reveal stops asking for the PIN and
// asks for the master password instead. Lower than PIN_MAX_FAILS on purpose:
// someone fumbling their own PIN gets a way through that does not cost them a
// 15-minute lockout, and someone guessing is pushed onto the credential that
// is actually hard to guess. The global counter is NOT reset on the way
// across, so guesses still accumulate toward the lockout.
// asks for the master password instead. Lower than the host's limit (5) on
// purpose: someone fumbling their own PIN gets a way through before the PIN
// is switched off, and someone guessing is pushed onto the credential that
// is actually hard to guess. The host counter is NOT reset on the way
// across, so guesses still accumulate toward that limit.
const REVEAL_PIN_MAX_FAILS = 3;
// Prove entitlement to see a secret, and hand back the master password —
@ -5274,14 +5244,11 @@ async function authorizeForSecret(subtitle) {
// the master password is the gate — never nothing.
return promptMasterPassword({ title: "Confirm master password", subtitle });
}
const remain = await pinLockoutRemainingMs();
if (remain > 0) {
// Locked out of the PIN, but the password is a separate credential and
// the lockout exists to stop PIN guessing, not to lock the owner out.
return promptMasterPassword({
title: "Confirm master password",
subtitle: `PIN entry is locked for ${Math.ceil(remain / 60000)} min. ${subtitle || ""}`.trim(),
});
if (await pinNeedsMaster()) {
// The PIN is switched off after too many wrong guesses, but the password
// is a separate credential: the strikes stop PIN guessing, they do not
// lock the owner out.
return promptMasterPassword({ title: "Confirm master password", subtitle: `${PIN_MASTER_COPY} ${subtitle || ""}`.trim() });
}
const pin = await capturePinForSecret(subtitle);
if (pin === null) return null; // cancelled
@ -5329,23 +5296,16 @@ function capturePinForSecret(subtitle) {
setupPinPad({
dots: wrap.querySelector("#rsDots"), keys: wrap.querySelector("#rsKeys"), err: wrap.querySelector("#rsErr"),
onComplete: async (pin) => {
try {
const blob = await S.invoke("pinBlobGet");
if (!blob) throw new Error("no PIN configured");
const master = await pinDecryptMaster(pin, blob);
await S.invoke("pinFailReset").catch(() => {});
done(master);
return "ok";
} catch (e) {
tries++;
// Keep feeding the shared counter: these are real PIN guesses and
// they should still count toward the 15 min lockout.
await S.invoke("pinFailInc").catch(() => ({ count: 0 }));
if (tries >= REVEAL_PIN_MAX_FAILS) { done("__fallback__"); return "ok"; }
const left = REVEAL_PIN_MAX_FAILS - tries;
$("rsErr").textContent = `Wrong PIN. ${left} attempt${left === 1 ? "" : "s"} left, then Aegis will ask for your master password.`;
return "reset";
}
let r;
try { r = await pinTry(pin); }
catch (e) { $("rsErr").textContent = cleanErr(e); return "reset"; }
if (r.ok) { done(r.masterPassword); return "ok"; }
// Every guess here also counts toward the host's limit.
tries++;
if (r.requireMaster || tries >= REVEAL_PIN_MAX_FAILS) { done("__fallback__"); return "ok"; }
const left = Math.min(REVEAL_PIN_MAX_FAILS - tries, r.remaining);
$("rsErr").textContent = `Wrong PIN. ${left} attempt${left === 1 ? "" : "s"} left, then Aegis will ask for your master password.`;
return "reset";
},
});
});
@ -5366,10 +5326,11 @@ function verifyPinInteractively(subtitle) {
}
async function verifyPinInteractivelyOnce(subtitle) {
const remain = await pinLockoutRemainingMs();
if (remain > 0) {
aegisAlert(`PIN entry is locked for ${Math.ceil(remain / 60000)} min. Use "Remove" in Settings or wait it out.`);
return false;
// The PIN is off after too many wrong guesses; the master password is the
// same proof (it is what the PIN unwraps), and the host checks it.
if (await pinNeedsMaster()) {
const pw = await promptMasterPassword({ title: "Confirm master password", subtitle: `${PIN_MASTER_COPY} ${subtitle || ""}`.trim() });
return pw || false;
}
return new Promise((resolve) => {
const wrap = document.createElement("div");
@ -5398,24 +5359,22 @@ async function verifyPinInteractivelyOnce(subtitle) {
setupPinPad({
dots: wrap.querySelector("#vpDots"), keys: wrap.querySelector("#vpKeys"), err: wrap.querySelector("#vpErr"),
onComplete: async (pin) => {
try {
const blob = await S.invoke("pinBlobGet");
if (!blob) throw new Error("no PIN configured");
const master = await pinDecryptMaster(pin, blob);
await S.invoke("pinFailReset").catch(() => {});
// Truthy for every existing caller; the gate hands it to the host
// as proof (see pinGate).
done(master || true);
let r;
try { r = await pinTry(pin); }
catch (e) { $("vpErr").textContent = cleanErr(e); return "reset"; }
// The unwrapped master password is truthy for every existing caller;
// the gate hands it to the host as proof (see pinGate).
if (r.ok) { done(r.masterPassword || true); return "ok"; }
$("vpErr").textContent = r.requireMaster ? PIN_MASTER_COPY : wrongPinCopy(r.remaining);
if (r.requireMaster) {
setTimeout(async () => {
try { wrap.remove(); } catch {}
const pw = await promptMasterPassword({ title: "Confirm master password", subtitle: PIN_MASTER_COPY });
resolve(pw || false);
}, 1200);
return "ok";
} catch (e) {
const fs = await S.invoke("pinFailInc").catch(() => ({ count: 0 }));
const left = Math.max(0, PIN_MAX_FAILS - (fs?.count || 0));
$("vpErr").textContent = left > 0
? `Wrong PIN. ${left} attempt${left === 1 ? "" : "s"} left before a 15 min lockout.`
: `Locked for 15 min.`;
if (left === 0) { done(false); return "ok"; }
return "reset";
}
return "reset";
},
});
});