From be05fe67d4f35c646ad83d3c7886d39b69ed3a14 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Fri, 2 Oct 2026 20:46:36 +0200 Subject: [PATCH] feat(aegis): choose when Aegis asks for the PIN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported: after a restart Aegis opens without asking for a PIN, then demands one as soon as you click something. That came from the only control being requirePinForSending — safeStorage remembers the master password, so the wallet reopens unlocked, and the PIN prompt then ambushes the first action. Asking at the door is coherent. Asking nothing is coherent. Asking once the user is already inside is not. "Ask for PIN" is now four independent triggers, because wanting one at startup and again per transaction is a normal combination: - On each browser restart (compares a per-process boot id; a timestamp cannot tell a restart from a long idle) - On each wallet launch (hide/show — one panel load is one launch) - Every 6 hours - Each transaction With none ticked, an open wallet is never interrupted again. The decision is made host-side: the panel reloads on every hide/show and must not be the thing that remembers a gate was cleared. A clearance is only recorded after the panel has actually decrypted the PIN blob, which is proof rather than a claim, and ticking a trigger does not fire it retroactively. restart defaults ON for a wallet that otherwise reopens fully unlocked, and transaction inherits the old requirePinForSending so nobody loses a gate they had chosen. Removing the PIN clears every trigger and the clearance record. --- bundled-addons/aegis/index.js | 90 ++++++++++++++++++++++++ bundled-addons/aegis/panel.html | 28 ++++++-- bundled-addons/aegis/panel.js | 119 ++++++++++++++++++++++++-------- 3 files changed, 201 insertions(+), 36 deletions(-) diff --git a/bundled-addons/aegis/index.js b/bundled-addons/aegis/index.js index f696f5b0..43de247a 100644 --- a/bundled-addons/aegis/index.js +++ b/bundled-addons/aegis/index.js @@ -18,6 +18,14 @@ const path = require("node:path"); const fs = require("node:fs"); const LEGACY_BCH_PURPOSE = "bchwallet/mainnet/0"; + +// New each time the add-on's main process starts, i.e. once per Theseus +// launch. Comparing it against the id stored the last time a PIN was +// accepted is how "ask again after a browser restart" is detected — a +// timestamp cannot tell a restart from a long idle, and the panel cannot be +// trusted to report its own restarts. +const BOOT_ID = require("node:crypto").randomBytes(8).toString("hex"); +const PIN_INTERVAL_MS = 6 * 60 * 60 * 1000; const LEGACY_BCH_WALLET_ID = "bch-default"; let ctx = null; @@ -2200,6 +2208,9 @@ function registerPanelMessages(api) { fromPanel(m); api.storage.set("aegis/pin/v1", null); api.storage.set("aegis/pin/failCount", 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); return true; }); // Track failed PIN attempts in the addon so a panel reload cannot bypass @@ -2226,11 +2237,74 @@ function registerPanelMessages(api) { }; }); + // When to ask for the PIN. These are independent triggers, not a single + // mode: wanting one at startup and one per transaction is a normal + // combination. Previously the only control was requirePinForSending, which + // produced the behaviour the user reported — Aegis opens unlocked after a + // restart (safeStorage remembered the password) and then demands a PIN the + // moment you touch something. Asking at the door or not at all is + // coherent; asking only once you are inside is not. + // + // `restart` defaults ON for a wallet that otherwise reopens fully unlocked. + // `transaction` inherits the old requirePinForSending so nobody silently + // loses a gate they had chosen. + function pinPolicy() { + const cfg = api.storage.get("aegis/security/v1", {}) || {}; + const on = (cfg.pinOn && typeof cfg.pinOn === "object") ? cfg.pinOn : null; + return { + restart: on ? !!on.restart : true, + launch: on ? !!on.launch : false, + interval: on ? !!on.interval : false, + transaction: on ? !!on.transaction : !!cfg.requirePinForSending, + }; + } + const pinGate = () => { + const g = api.storage.get("aegis/pin/gate", null); + return (g && typeof g === "object") ? g : {}; + }; + + // Does the user have to prove the PIN right now? Decided host-side: the + // panel reloads freely and must not be the thing that remembers whether a + // gate was already satisfied. + function pinNeeded(event) { + if (!api.storage.get("aegis/pin/v1", null)) return { needPin: false, reason: "no-pin" }; + const on = pinPolicy(); + const g = pinGate(); + if (on.interval) { + // Never satisfied, or satisfied too long ago. Checked for every event + // so a six-hour expiry also lands on the next transaction. + if (!g.lastOkAt || (Date.now() - Number(g.lastOkAt)) > PIN_INTERVAL_MS) { + return { needPin: true, reason: "interval" }; + } + } + if (event === "transaction" && on.transaction) return { needPin: true, reason: "transaction" }; + if (event === "panel-load") { + if (on.launch) return { needPin: true, reason: "launch" }; + if (on.restart && g.bootId !== BOOT_ID) return { needPin: true, reason: "restart" }; + } + return { needPin: false, reason: "satisfied" }; + } + + api.onMessage("pinGateStatus", (p, m) => { + fromPanel(m); + const event = String(p && p.event || "panel-load"); + return { ...pinNeeded(event), event, policy: pinPolicy() }; + }); + // Called only after the panel has actually decrypted the PIN blob, which + // is proof of the PIN and not merely a claim about it. + api.onMessage("pinGateSatisfied", (_p, m) => { + fromPanel(m); + api.storage.set("aegis/pin/gate", { lastOkAt: Date.now(), bootId: BOOT_ID }); + return true; + }); + api.onMessage("securityGet", (_p, m) => { fromPanel(m); const cfg = api.storage.get("aegis/security/v1", {}) || {}; return { hasPin: !!api.storage.get("aegis/pin/v1", null), + pinOn: pinPolicy(), + pinIntervalHours: PIN_INTERVAL_MS / 3600000, requirePinForSending: !!cfg.requirePinForSending, // Defaults ON (note the !== false), unlike the send flag: a send is // already fronted by an approval overlay, whereas revealing a key is @@ -2246,9 +2320,25 @@ function registerPanelMessages(api) { const next = { ...cur }; if (p && typeof p.requirePinForSending === "boolean") next.requirePinForSending = p.requirePinForSending; if (p && typeof p.requirePinForReveal === "boolean") next.requirePinForReveal = p.requirePinForReveal; + if (p && p.pinOn && typeof p.pinOn === "object") { + // Write the whole set from the current policy plus the keys given, so + // the first edit materialises the migrated defaults instead of leaving + // three triggers undefined and one set. + const cur = pinPolicy(); + const merged = { ...cur }; + for (const k of ["restart", "launch", "interval", "transaction"]) { + if (typeof p.pinOn[k] === "boolean") merged[k] = p.pinOn[k]; + } + next.pinOn = merged; + // The legacy flag now lives in pinOn.transaction; keep them in step so + // an older build reading this store still gates sends the same way. + next.requirePinForSending = merged.transaction; + } api.storage.set("aegis/security/v1", next); return { hasPin: !!api.storage.get("aegis/pin/v1", null), + pinOn: pinPolicy(), + pinIntervalHours: PIN_INTERVAL_MS / 3600000, requirePinForSending: !!next.requirePinForSending, requirePinForReveal: next.requirePinForReveal !== false, }; diff --git a/bundled-addons/aegis/panel.html b/bundled-addons/aegis/panel.html index ae997e6a..594e5478 100644 --- a/bundled-addons/aegis/panel.html +++ b/bundled-addons/aegis/panel.html @@ -121,6 +121,13 @@ /* Network selector — indicator and switch in one control, directly under the balance and present whenever a wallet is. The active chip IS the "you are here" marker the status row used to carry. */ + /* The "Ask for PIN" trigger list. Stacked under its own label rather than + one switch per row: four rows of switches read as four unrelated + settings, when they are one question with four answers. */ + .gsec .gline .pinon { display: flex; flex-direction: column; gap: 5px; margin-top: 7px; } + .gsec .gline .pinon label { display: flex; align-items: center; gap: 7px; font-size: 12px; + color: var(--ink); cursor: pointer; font-weight: 400; } + .gsec .gline .pinon input { margin: 0; } .netsel { display: flex; gap: 6px; margin-top: 12px; padding: 0 2px; flex-wrap: wrap; } .netsel[hidden] { display: none; } .netselchip { background: transparent; border: 1px solid var(--line); color: var(--mut); @@ -990,13 +997,22 @@ -