Merge aegis-pin-view: Aegis 0.31.1 restores the earlier PIN screens
This commit is contained in:
commit
0514d2d4e3
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