Aegis 0.31.1: the PIN screens look and behave as they did before 0.31
0.31 moved the PIN check into index.js and, with it, replaced the 15-minute lockout after five wrong PINs by "PIN off until the master password", with new wording on every PIN screen. The user wants the screens as they were. The wording, the lockout and the switch to the master password are back; the check stays in index.js, so the lockout is now enforced by the host and the panel still never sees the PIN blob. After the lockout every further wrong PIN locks it again.
This commit is contained in:
parent
7d080c3383
commit
4117a010c4
3 changed files with 67 additions and 63 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue