Merge aegis-pin-view: Aegis 0.31.1 restores the earlier PIN screens

This commit is contained in:
Local Dev 2026-10-04 03:40:18 +02:00
commit 0514d2d4e3
3 changed files with 67 additions and 63 deletions

View file

@ -1,7 +1,7 @@
{ {
"id": "aegis", "id": "aegis",
"name": "Aegis Wallet", "name": "Aegis Wallet",
"version": "0.31.0", "version": "0.31.1",
"category": "plugin", "category": "plugin",
"description": "Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB) derived from your Theseus vault. Dapps get window.bitcoincash and window.wizardconnect on any site; window.tronWeb / window.tronLink / window.ethereum / window.solana too. Every call needs your approval.", "description": "Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB) derived from your Theseus vault. Dapps get window.bitcoincash and window.wizardconnect on any site; window.tronWeb / window.tronLink / window.ethereum / window.solana too. Every call needs your approval.",
"author": "Silent Mode", "author": "Silent Mode",

View file

@ -1241,13 +1241,16 @@ function openPinBlob(api) {
// blob and decrypt it itself, then report its own failures — so the lockout // 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 // 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 // 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 // PINs offline. Now the blob stays in this process and every guess is
// before it is tried, and PIN_MAX_FAILS wrong guesses switch the PIN off // counted before it is tried. PIN_MAX_FAILS wrong guesses lock the PIN for
// until the master password is entered (no timer that hands out more tries). // PIN_LOCKOUT_MS (the panel shows the same 15-minute lockout as before);
// every further wrong guess locks it again. A correct PIN or a master
// password the vault accepts clears the count.
// The blob format is unchanged: PBKDF2-SHA256(pin) -> AES-256-GCM, hex, tag // The blob format is unchanged: PBKDF2-SHA256(pin) -> AES-256-GCM, hex, tag
// appended to the ciphertext, as WebCrypto wrote it. // appended to the ciphertext, as WebCrypto wrote it.
const PIN_ITERS = 600_000; const PIN_ITERS = 600_000;
const PIN_MAX_FAILS = 5; const PIN_MAX_FAILS = 5;
const PIN_LOCKOUT_MS = 15 * 60 * 1000;
const PIN_RE = /^\d{6}$/; const PIN_RE = /^\d{6}$/;
const nodeCrypto = require("node:crypto"); const nodeCrypto = require("node:crypto");
const pinKey = (pin, saltHex, iters) => new Promise((resolve, reject) => const pinKey = (pin, saltHex, iters) => new Promise((resolve, reject) =>
@ -1267,13 +1270,15 @@ async function pinUnwrapBlob(pin, blob) {
} }
function pinFails(api) { function pinFails(api) {
const n = Number(api.storage.get("aegis/pin/failCount", 0)) || 0; 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 }; const last = Number(api.storage.get("aegis/pin/failLast", 0)) || 0;
const lockedMs = n >= PIN_MAX_FAILS ? Math.max(0, PIN_LOCKOUT_MS - (Date.now() - last)) : 0;
return { fails: n, last, lockedMs };
} }
// A master password the vault accepted. Clears the PIN strikes — the only // A master password the vault accepted. Clears the PIN strikes, as a
// thing that does, apart from a correct PIN while the PIN is still allowed. // correct PIN does.
function noteMasterVerified(api) { function noteMasterVerified(api) {
api.storage.set("aegis/pin/failCount", 0); api.storage.set("aegis/pin/failCount", 0);
api.storage.set("aegis/pin/requireMaster", false); api.storage.set("aegis/pin/failLast", 0);
} }
// "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
@ -2627,23 +2632,24 @@ function registerPanelMessages(api) {
}); });
// Try a PIN. Counts the guess before trying it, so a crash or a closed // Try a PIN. Counts the guess before trying it, so a crash or a closed
// panel mid-check still costs an attempt. Answers // panel mid-check still costs an attempt. Answers
// { ok: true, masterPassword } | { ok: false, remaining, requireMaster } // { ok: true, masterPassword } | { ok: false, remaining, lockedMs }
api.onMessage("pinUnwrap", async (p, m) => { api.onMessage("pinUnwrap", async (p, m) => {
fromPanel(m); fromPanel(m);
const pin = String((p && p.pin) || ""); const pin = String((p && p.pin) || "");
const blob = openPinBlob(api); const blob = openPinBlob(api);
if (!blob) throw new Error("no PIN is set"); if (!blob) throw new Error("no PIN is set");
if (pinFails(api).requireMaster) return { ok: false, remaining: 0, requireMaster: true }; const st = pinFails(api);
const before = pinFails(api).fails; if (st.lockedMs > 0) return { ok: false, remaining: 0, lockedMs: st.lockedMs };
api.storage.set("aegis/pin/failCount", before + 1); api.storage.set("aegis/pin/failCount", st.fails + 1);
api.storage.set("aegis/pin/failLast", Date.now());
let pw = null; let pw = null;
if (PIN_RE.test(pin)) { try { pw = await pinUnwrapBlob(pin, blob); } catch { pw = null; } } if (PIN_RE.test(pin)) { try { pw = await pinUnwrapBlob(pin, blob); } catch { pw = null; } }
if (pw == null) { if (pw == null) {
const fails = before + 1; const now = pinFails(api);
if (fails >= PIN_MAX_FAILS) api.storage.set("aegis/pin/requireMaster", true); return { ok: false, remaining: Math.max(0, PIN_MAX_FAILS - now.fails), lockedMs: now.lockedMs };
return { ok: false, remaining: Math.max(0, PIN_MAX_FAILS - fails), requireMaster: fails >= PIN_MAX_FAILS };
} }
api.storage.set("aegis/pin/failCount", 0); api.storage.set("aegis/pin/failCount", 0);
api.storage.set("aegis/pin/failLast", 0);
// A blob from an older build (200k iterations, or from before sealing) // 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. // is re-made now, under a fresh salt, while the PIN is at hand.
if ((Number(blob.iters) || 0) < PIN_ITERS) { if ((Number(blob.iters) || 0) < PIN_ITERS) {
@ -2654,13 +2660,13 @@ function registerPanelMessages(api) {
api.onMessage("pinStatus", (_p, m) => { api.onMessage("pinStatus", (_p, m) => {
fromPanel(m); fromPanel(m);
const f = pinFails(api); const f = pinFails(api);
return { hasPin: !!api.storage.get(PIN_BLOB_KEY, null), fails: f.fails, maxFails: PIN_MAX_FAILS, requireMaster: f.requireMaster }; return { hasPin: !!api.storage.get(PIN_BLOB_KEY, null), fails: f.fails, last: f.last, maxFails: PIN_MAX_FAILS, lockedMs: f.lockedMs };
}); });
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); api.storage.set("aegis/pin/failLast", 0);
// 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);
@ -2763,7 +2769,6 @@ 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,
@ -2798,7 +2803,6 @@ 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,

View file

@ -302,18 +302,15 @@ function fiatSkeleton() {
// ---- security: PIN ---------------------------------------------------------- // ---- security: PIN ----------------------------------------------------------
// The pads below only collect six digits. The host (index.js pinUnwrap) // 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 // holds the PIN blob, counts every guess before trying it, and enforces the
// many wrong ones switches the PIN off until the master password is entered. // 15-minute lockout after PIN_MAX_FAILS wrong ones. The panel never sees the
// The panel never sees the blob, so it cannot be searched from here, and it // blob, so it cannot be searched from here, and it cannot reset the counter.
// cannot reset the counter. // pinTry(pin) -> { ok: true, masterPassword } | { ok: false, remaining, lockedMs }
// pinTry(pin) -> { ok: true, masterPassword } | { ok: false, remaining, requireMaster } const PIN_MAX_FAILS = 5;
const pinTry = (pin) => S.invoke("pinUnwrap", { pin: String(pin) }); const pinTry = (pin) => S.invoke("pinUnwrap", { pin: String(pin) });
async function pinNeedsMaster() { async function pinLockoutRemainingMs() {
try { return !!(await S.invoke("pinStatus")).requireMaster; } catch { return false; } try { return Number((await S.invoke("pinStatus")).lockedMs) || 0; } 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");
@ -3349,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 && !securityState?.pinRequireMaster) { if (hasPin && !forcePw) {
// 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.
@ -3374,15 +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) => {
let r; const remain = await pinLockoutRemainingMs();
try { r = await pinTry(pin); } if (remain > 0) {
catch (e) { $("lockPinErr").textContent = cleanErr(e); return "reset"; } $("lockPinErr").textContent = `Too many failed attempts. Try again in ${Math.ceil(remain / 60000)} min or use the master password.`;
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 r = await pinTry(pin);
if (!r.ok) {
const left = Math.max(0, r.remaining);
$("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";
}
state = await S.invoke("vaultUnlock", { masterPassword: r.masterPassword }); state = await S.invoke("vaultUnlock", { masterPassword: r.masterPassword });
render(); render();
return "ok"; return "ok";
@ -5238,11 +5240,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 the host's limit (5) on // asks for the master password instead. Lower than PIN_MAX_FAILS on purpose:
// purpose: someone fumbling their own PIN gets a way through before the PIN // someone fumbling their own PIN gets a way through that does not cost them a
// is switched off, and someone guessing is pushed onto the credential that // 15-minute lockout, and someone guessing is pushed onto the credential that
// is actually hard to guess. The host counter is NOT reset on the way // is actually hard to guess. The global counter is NOT reset on the way
// across, so guesses still accumulate toward that limit. // across, so guesses still accumulate toward the lockout.
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 —
@ -5259,11 +5261,14 @@ 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 });
} }
if (await pinNeedsMaster()) { const remain = await pinLockoutRemainingMs();
// The PIN is switched off after too many wrong guesses, but the password if (remain > 0) {
// is a separate credential: the strikes stop PIN guessing, they do not // Locked out of the PIN, but the password is a separate credential and
// lock the owner out. // the lockout exists to stop PIN guessing, not to lock the owner out.
return promptMasterPassword({ title: "Confirm master password", subtitle: `${PIN_MASTER_COPY} ${subtitle || ""}`.trim() }); return promptMasterPassword({
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
@ -5315,10 +5320,10 @@ function capturePinForSecret(subtitle) {
try { r = await pinTry(pin); } try { r = await pinTry(pin); }
catch (e) { $("rsErr").textContent = cleanErr(e); return "reset"; } catch (e) { $("rsErr").textContent = cleanErr(e); return "reset"; }
if (r.ok) { done(r.masterPassword); return "ok"; } if (r.ok) { done(r.masterPassword); return "ok"; }
// Every guess here also counts toward the host's limit. // Every guess here also counts toward the 15 min lockout.
tries++; tries++;
if (r.requireMaster || tries >= REVEAL_PIN_MAX_FAILS) { done("__fallback__"); return "ok"; } if (r.lockedMs > 0 || tries >= REVEAL_PIN_MAX_FAILS) { done("__fallback__"); return "ok"; }
const left = Math.min(REVEAL_PIN_MAX_FAILS - tries, r.remaining); 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";
}, },
@ -5341,11 +5346,10 @@ function verifyPinInteractively(subtitle) {
} }
async function verifyPinInteractivelyOnce(subtitle) { async function verifyPinInteractivelyOnce(subtitle) {
// The PIN is off after too many wrong guesses; the master password is the const remain = await pinLockoutRemainingMs();
// same proof (it is what the PIN unwraps), and the host checks it. if (remain > 0) {
if (await pinNeedsMaster()) { aegisAlert(`PIN entry is locked for ${Math.ceil(remain / 60000)} min. Use "Remove" in Settings or wait it out.`);
const pw = await promptMasterPassword({ title: "Confirm master password", subtitle: `${PIN_MASTER_COPY} ${subtitle || ""}`.trim() }); return false;
return pw || false;
} }
return new Promise((resolve) => { return new Promise((resolve) => {
const wrap = document.createElement("div"); const wrap = document.createElement("div");
@ -5380,15 +5384,11 @@ async function verifyPinInteractivelyOnce(subtitle) {
// The unwrapped master password is truthy for every existing caller; // The unwrapped master password is truthy for every existing caller;
// the gate hands it to the host as proof (see pinGate). // the gate hands it to the host as proof (see pinGate).
if (r.ok) { done(r.masterPassword || true); return "ok"; } if (r.ok) { done(r.masterPassword || true); return "ok"; }
$("vpErr").textContent = r.requireMaster ? PIN_MASTER_COPY : wrongPinCopy(r.remaining); const left = Math.max(0, r.remaining);
if (r.requireMaster) { $("vpErr").textContent = left > 0
setTimeout(async () => { ? `Wrong PIN. ${left} attempt${left === 1 ? "" : "s"} left before a 15 min lockout.`
try { wrap.remove(); } catch {} : `Locked for 15 min.`;
const pw = await promptMasterPassword({ title: "Confirm master password", subtitle: PIN_MASTER_COPY }); if (left === 0) { done(false); return "ok"; }
resolve(pw || false);
}, 1200);
return "ok";
}
return "reset"; return "reset";
}, },
}); });