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",
"name": "Aegis Wallet",
"version": "0.31.0",
"version": "0.31.1",
"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.",
"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
// 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).
// PINs offline. Now the blob stays in this process and every guess is
// counted before it is tried. PIN_MAX_FAILS wrong guesses lock the PIN for
// 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
// appended to the ciphertext, as WebCrypto wrote it.
const PIN_ITERS = 600_000;
const PIN_MAX_FAILS = 5;
const PIN_LOCKOUT_MS = 15 * 60 * 1000;
const PIN_RE = /^\d{6}$/;
const nodeCrypto = require("node:crypto");
const pinKey = (pin, saltHex, iters) => new Promise((resolve, reject) =>
@ -1267,13 +1270,15 @@ async function pinUnwrapBlob(pin, blob) {
}
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 };
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
// thing that does, apart from a correct PIN while the PIN is still allowed.
// A master password the vault accepted. Clears the PIN strikes, as a
// correct PIN does.
function noteMasterVerified(api) {
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
@ -2627,23 +2632,24 @@ function registerPanelMessages(api) {
});
// 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 }
// { ok: true, masterPassword } | { ok: false, remaining, lockedMs }
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);
const st = pinFails(api);
if (st.lockedMs > 0) return { ok: false, remaining: 0, lockedMs: st.lockedMs };
api.storage.set("aegis/pin/failCount", st.fails + 1);
api.storage.set("aegis/pin/failLast", Date.now());
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 };
const now = pinFails(api);
return { ok: false, remaining: Math.max(0, PIN_MAX_FAILS - now.fails), lockedMs: now.lockedMs };
}
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)
// is re-made now, under a fresh salt, while the PIN is at hand.
if ((Number(blob.iters) || 0) < PIN_ITERS) {
@ -2654,13 +2660,13 @@ function registerPanelMessages(api) {
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 };
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) => {
fromPanel(m);
api.storage.set("aegis/pin/v1", null);
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
// "not yet satisfied" rather than inheriting the old PIN's clearance.
api.storage.set("aegis/pin/gate", null);
@ -2763,7 +2769,6 @@ 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,
@ -2798,7 +2803,6 @@ 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

@ -302,18 +302,15 @@ function fiatSkeleton() {
// ---- 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 }
// holds the PIN blob, counts every guess before trying it, and enforces the
// 15-minute lockout after PIN_MAX_FAILS wrong ones. 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, lockedMs }
const PIN_MAX_FAILS = 5;
const pinTry = (pin) => S.invoke("pinUnwrap", { pin: String(pin) });
async function pinNeedsMaster() {
try { return !!(await S.invoke("pinStatus")).requireMaster; } catch { return false; }
async function pinLockoutRemainingMs() {
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() {
try {
securityState = await S.invoke("securityGet");
@ -3349,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 && !securityState?.pinRequireMaster) {
if (hasPin && !forcePw) {
// 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.
@ -3374,15 +3371,20 @@ function renderLockScreen(phase) {
keys: body.querySelector("#lockPinKeys"),
err: body.querySelector("#lockPinErr"),
onComplete: async (pin) => {
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); }
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.`;
return "reset";
}
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 });
render();
return "ok";
@ -5238,11 +5240,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 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.
// 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.
const REVEAL_PIN_MAX_FAILS = 3;
// 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.
return promptMasterPassword({ title: "Confirm master password", subtitle });
}
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 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(),
});
}
const pin = await capturePinForSecret(subtitle);
if (pin === null) return null; // cancelled
@ -5315,10 +5320,10 @@ function capturePinForSecret(subtitle) {
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.
// Every guess here also counts toward the 15 min lockout.
tries++;
if (r.requireMaster || tries >= REVEAL_PIN_MAX_FAILS) { done("__fallback__"); return "ok"; }
const left = Math.min(REVEAL_PIN_MAX_FAILS - tries, r.remaining);
if (r.lockedMs > 0 || 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";
},
@ -5341,11 +5346,10 @@ function verifyPinInteractively(subtitle) {
}
async function verifyPinInteractivelyOnce(subtitle) {
// 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;
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;
}
return new Promise((resolve) => {
const wrap = document.createElement("div");
@ -5380,15 +5384,11 @@ async function verifyPinInteractivelyOnce(subtitle) {
// 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";
}
const left = Math.max(0, r.remaining);
$("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";
},
});