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",
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue