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 @@ -