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",
|
"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