From ad6aa7b20d8ab24717cb70f1c1405ca7a40a74c5 Mon Sep 17 00:00:00 2001 From: clawbot Date: Sun, 23 Aug 2026 20:04:00 +0200 Subject: [PATCH] fix: version stored state, validate its shape, and give a corrupt blob a way out (closes #311) Stored state had no version and no structural validation, so a corrupt blob produced a completely blank popup with no message and no recovery control, and made every dApp RPC call from every page answer a generic -32603. There was no reset or wipe control anywhere in the UI. saveState() now stamps a schema version and loadState() validates the shape. A version it does not understand, or a wallets array it cannot parse, lands on a recovery screen that names the problem, offers the stored record verbatim for export, and offers a destructive reset behind a typed confirmation. Unversioned but valid state -- which every existing install has -- migrates in place and keeps working; it is never shown a wipe prompt. A dApp call against unusable state answers -32007, which EIP-1474 leaves unassigned, rather than -32603. networkById() refuses an unknown id loudly instead of returning mainnet, and networkId is validated so a corrupt value cannot be used as an object key. Fields the gate does not refuse are floored by type, container and entries both: a malformed trackedTokens or tokenBalances entry is dropped rather than dereferenced. Verified by an independent sweep of 1152 corrupt blobs producing no blank popup, with the same harness showing 9 blanks against the previous revision. --- README.md | 97 +++- TODO.md | 40 ++ src/background/index.js | 89 ++-- src/background/state.js | 16 + src/popup/index.html | 69 +++ src/popup/index.js | 21 +- src/popup/viewRouter.js | 2 +- src/popup/views/helpers.js | 5 + src/popup/views/stateRecovery.js | 197 +++++++ src/shared/browserApi.js | 18 + src/shared/networks.js | 38 +- src/shared/persistedState.js | 112 +++- src/{popup => shared}/restorableViews.js | 8 +- src/shared/state.js | 34 ++ src/shared/stateSchema.js | 265 ++++++++++ tests/alarms.test.js | 17 +- tests/backgroundApproval.test.js | 16 +- tests/deleteWalletLostPassword.test.js | 2 +- tests/exportPrivkey.test.js | 2 +- tests/showPhrase.test.js | 2 +- tests/state.test.js | 12 +- tests/stateRecovery.test.js | 630 +++++++++++++++++++++++ tests/stateSchema.test.js | 423 +++++++++++++++ tests/stateUnusableRpc.test.js | 203 ++++++++ tests/txStatus.test.js | 2 +- 25 files changed, 2270 insertions(+), 50 deletions(-) create mode 100644 src/popup/views/stateRecovery.js rename src/{popup => shared}/restorableViews.js (79%) create mode 100644 src/shared/stateSchema.js create mode 100644 tests/stateRecovery.test.js create mode 100644 tests/stateSchema.test.js create mode 100644 tests/stateUnusableRpc.test.js diff --git a/README.md b/README.md index cce64ea..3cbf3d7 100644 --- a/README.md +++ b/README.md @@ -985,6 +985,55 @@ tokens with fewer than 1,000 holders" setting governs the transaction history and the send-screen token selector, not this list. Tracked tokens with a zero balance are listed as well while "Show tracked tokens with zero balance" is on. +#### Stored state and its version + +The whole profile lives under a single extension-storage key, `autistmask`, and +carries a `schemaVersion` — `STATE_SCHEMA_VERSION` in +`src/shared/stateSchema.js`, currently `1`. Every write stamps it: the popup's +`saveState()` and the background's `updateState()` both do, so whichever context +wrote last, the record says which build's shape it is in. + +Version 1 is the shape that shipped before versions existed, so a stored record +with no `schemaVersion` is version 1 rather than a defect: it loads normally and +is migrated in place by being stamped on the first write. An upgrade never shows +an existing user a warning about a profile that is perfectly good. The version +is bumped only when the MEANING of a stored field changes — a new field with a +sensible absent value is handled by `normalizePersisted()` and is not a bump, +because bumping for one would send every older install to StateRecovery for +nothing. + +Every read of the record goes through `assertStateUsable()` first, on the raw +bytes, before normalization: `loadState()` for the popup and `getState()` for +the background. It refuses a record that is not an object, a `schemaVersion` +this build does not understand (a newer one included), a `wallets` that is not a +list of wallet records with address records in them, and a `networkId` that is +not a network in `src/shared/networks.js`. Refusing is the whole point — a +record the wallet cannot vouch for is never normalized, never written back, and +never half-loaded. The popup shows StateRecovery; a dApp gets a specific error +(`-32007`, an EIP-1474 server-error code the spec leaves unassigned) saying the +saved data cannot be read and that nothing was signed or sent, rather than the +generic `-32603` every request used to answer. + +Every other field of the record is floored in `normalizePersisted()` rather than +gated. That floor is a type check for the fields something dereferences +structurally — `trackedTokens`, each address's `tokenBalances`, `networkId`, +`networkEndpoints`, `activeAddress`, `viewStack` — and it checks the ENTRIES as +well as the container, because `[1, 2]` is a list and `t.address` is one level +below an `Array.isArray()`. The remaining fields get a `saved.x || default` or a +present-or-default passthrough that takes the stored value verbatim, with no +type check at all; which field is in which category is listed in the header of +`src/shared/stateSchema.js`. A truthy value of the wrong type in a field that IS +dereferenced walks through truthiness and throws on the first read, which is the +blank popup again by a longer route — so adding a field means choosing between +the two by what reads it. + +The `networkId` check is not cosmetic: that value is an object KEY into +`state.networkEndpoints`, so an unvalidated `"__proto__"` would set the map's +prototype instead of an own key and the user's endpoint would silently not be +recorded. Every key test in the gate is an own-property test for that reason, +and `networkById()` throws on an id it does not know rather than quietly +answering mainnet. + #### Navigation The main view shows all addresses grouped by wallet, with ETH balances inline. @@ -1011,10 +1060,12 @@ Three elements sit outside the screens and are present on all of them: the title bar ("AutistMask by @sneak" plus the Settings gear), the flash message line under it, and the red banner at the very top that appears on a debug build, when runtime debug mode is on, or when the active network is a testnet. They are not -repeated in the element lists below. +repeated in the element lists below. StateRecovery is the one screen they are +not all present on: it hides the Settings gear, because it is shown precisely +when there is no profile for the screens behind that gear to render from. Closing and reopening the popup returns to the screen the user was last on only -for the views listed in `RESTORABLE_VIEWS` (`src/popup/restorableViews.js`). +for the views listed in `RESTORABLE_VIEWS` (`src/shared/restorableViews.js`). Every other screen falls back to Home. The screens that display a secret — ExportPrivKey and ShowRecoveryPhrase — are deliberately absent from that list, so the popup can never reopen onto one of them with no password prompt in front @@ -1684,6 +1735,48 @@ view would leave a wallet one click from deletion. - Popup window closed without answering → the request is rejected with EIP-1193 code 4001 +#### StateRecovery (`state-recovery`) + +- **When**: `loadState()` refused the stored profile, so the popup has no + profile at all. It is the only screen reached without one, and the only one + that never appears during ordinary use. +- **Why it exists**: a record the wallet cannot read used to render nothing — no + view, no message, no control — while every dApp call answered a generic + internal error, and no reset or wipe control existed anywhere in the product. + The only escape was clearing extension storage through browser internals + ([#311](https://git.eeqj.de/sneak/AutistMask/issues/311)). +- **Elements**: + - "Saved Data Cannot Be Read" heading, and a statement that nothing has been + changed or erased and nothing can be signed or sent + - The problem, in one sentence naming what is wrong with the record + - "Export Saved Data" button, and the read-only text box it fills + - What erasing does and does not do, in bold: every wallet stored in this + browser is deleted; nothing on chain changes and no money is moved + - A text input asking for `ERASE MY WALLET` to be typed back + - Error line + - "Erase Saved Data" button +- **Transitions**: + - "Export Saved Data" → the raw stored record, verbatim, in the text box on + the screen, and a downloaded `autistmask-saved-data.json` where the + browser allows one. The box is filled first and never depends on the + download: an export that can fail is not an export. + - "Erase Saved Data" (phrase typed) → the stored record is removed and the + popup reloads into **Welcome** + - "Erase Saved Data" (phrase not typed) → "Type ERASE MY WALLET to confirm. + Nothing was erased." on the error line +- **No other control is reachable.** The Settings gear is hidden while this + screen is up, because every screen behind it renders from the profile that + could not be read, and `showView()` is not used to raise it for the same + reason — it reads and writes the state singleton. +- **Both controls are required.** An export with no reset leaves the user + looking at a broken profile with no way to use the wallet again; a reset with + no export destroys the only copy of a record that may hold recoverable key + material. The typed phrase is the same barrier DeleteWalletLostPassword uses, + and for the same reason: there is no password to gate this with, since there + is no profile to check one against. +- Not in `RESTORABLE_VIEWS`: it is never persisted as the current view, because + nothing on this path writes state at all. + ### External Services AutistMask is not a fully self-contained offline tool. It necessarily diff --git a/TODO.md b/TODO.md index 2409815..f985496 100644 --- a/TODO.md +++ b/TODO.md @@ -57,6 +57,46 @@ but the review is broader than any of them. `Token Out` now reads `Unknown (not named in the calldata)` and `Min. received` falls to the base-unit refusal from [#340](https://git.eeqj.de/sneak/AutistMask/issues/340). + +- 2026-08-23: The stored profile carries a schema version, and a record the + wallet cannot read produces a screen instead of a blank popup + ([#311](https://git.eeqj.de/sneak/AutistMask/issues/311)). `saveState()` and + `updateState()` both stamp `STATE_SCHEMA_VERSION` + (`src/shared/stateSchema.js`), and every read goes through + `assertStateUsable()` on the raw bytes before normalization gets a chance to + paper over them. Version 1 is the shape that shipped unversioned, so the + profile every existing install holds loads normally and is migrated in place + by being stamped on the first write — an upgrade shows nobody a wipe prompt + for a wallet that is fine. A record this build cannot vouch for is refused + instead: not normalized, not written back, not half-loaded. The popup shows + the new StateRecovery screen, which names the problem, exports the raw record + verbatim into the page (and downloads it where the browser allows), and offers + an erase behind a typed `ERASE MY WALLET` — both controls, because an export + with no reset leaves the user stuck and a reset with no export destroys the + only copy of possibly recoverable key material. The background refuses the + same record and answers dApps `-32007` — a code EIP-1474 leaves unassigned, + unlike `-32000`..`-32006` — with a message saying the saved data cannot be + read and that nothing was signed or sent, rather than the generic `-32603` + that every request used to get. Fields the gate deliberately does not check + produced the same blank popup on their own: `trackedTokens` and + `activeAddress` were floored on truthiness rather than on type, and + `trackedTokens`' ENTRIES and each address's `tokenBalances` were not floored + at all — `[1, 2]` is a list, and the dereference is `t.address.toLowerCase()` + one level below the container. All of them are type-checked now, entries + included, and the header of `src/shared/stateSchema.js` lists which fields of + the record get a type check and which get a `saved.x || default` or a verbatim + passthrough, rather than asserting a rule the module does not follow. + `networkById()` now throws on an id it does not know instead of quietly + answering mainnet, and the gate's key tests are all own-property tests: + `networkId` is an object key into `networkEndpoints`, so an unvalidated + `"__proto__"` used to set that map's prototype and drop the user's endpoint + silently. The three corrupt blobs from the issue drive the real popup entry + point in `tests/stateRecovery.test.js` and the real worker in + `tests/stateUnusableRpc.test.js`; each rendered nothing at all and answered + `-32603` before this. `src/popup/restorableViews.js` moved to + `src/shared/restorableViews.js`, since `persistedState.js` requires it and + that module is in the background bundle. + - 2026-08-23: The background no longer reads or writes the shared `state` singleton ([#324](https://git.eeqj.de/sneak/AutistMask/issues/324)), which also closes the cold-worker wrong-chain send diff --git a/src/background/index.js b/src/background/index.js index 18af9a2..e893024 100644 --- a/src/background/index.js +++ b/src/background/index.js @@ -16,6 +16,7 @@ const { applyChainSwitchFields } = require("../shared/chainSwitchFields"); // script/lib/forbiddenBundleInputs.js). The ESLint rule of the same name is // the same prohibition reported early, not the guarantee. const { getState, updateState } = require("./state"); +const { StateUnusableError } = require("../shared/stateSchema"); const { refreshBalances, getProvider } = require("../shared/balances"); const { debugFetch, log } = require("../shared/log"); const { @@ -179,6 +180,45 @@ const INTERNAL_ERROR_CODE = -32603; const INTERNAL_ERROR_MESSAGE = "AutistMask could not complete this request because of an internal error."; +// What the page is told when the wallet's own stored profile cannot be read. +// +// This used to be the generic answer above: getActiveAddress() dereferenced +// the stored wallet list on nearly every method, so a corrupt or +// newer-than-this-build record turned EVERY request from EVERY page into +// "internal error", which is also what a failed signing attempt answers. The +// page cannot tell those apart, and the user is told nothing about the one +// thing that is actually wrong or where to fix it +// (https://git.eeqj.de/sneak/AutistMask/issues/311). +// +// Its own code rather than -32603, because the condition is specific, +// diagnosable and has a user action attached — none of which "internal error" +// conveys. +// +// -32007 specifically: EIP-1474 sets aside -32000..-32099 for +// implementation-defined server errors, but it ASSIGNS meanings to -32000 +// through -32006 (Invalid input, Resource not found, Resource unavailable, +// Transaction rejected, Method not supported, Limit exceeded, JSON-RPC version +// not supported). -32007..-32099 are the unassigned ones, and this condition +// is not any of the seven. Nothing above it is free to be overloaded either: +// this wallet already answers EIP-1474's -32002 "Resource unavailable" for a +// pending approval, the conventional way, so a page is entitled to read these +// codes by that table. +const STATE_UNUSABLE_CODE = -32007; +const STATE_UNUSABLE_MESSAGE = + "AutistMask cannot read its saved data, so nothing was signed or sent." + + " Open the AutistMask extension to export or reset it."; + +// The EIP-1193 error for a handler that threw, by cause. Everything that +// consults the profile goes through getState(), so this one mapping covers +// every method rather than each one having to know about the condition. +function failureError(err) { + if (err instanceof StateUnusableError) { + log.errorf("state is unusable:", err.problem); + return { code: STATE_UNUSABLE_CODE, message: STATE_UNUSABLE_MESSAGE }; + } + return { code: INTERNAL_ERROR_CODE, message: INTERNAL_ERROR_MESSAGE }; +} + // The active address of a profile snapshot. Pure, and taking the snapshot as // an argument rather than reading storage itself: a handler that has already // read state must not answer "which account is this" from a SECOND, later read @@ -883,6 +923,11 @@ async function handleRpc(method, params, origin) { const result = await proxyRpc(method, params); return { result }; } catch (e) { + // A node that answered with an error is reported as itself. The + // wallet being unable to read its own profile is not that, and it + // must not be flattened into a message with no code: it goes back + // to the dispatcher, which has the one answer for it. + if (e instanceof StateUnusableError) throw e; return { error: { message: e.message } }; } } @@ -1142,7 +1187,14 @@ async function backgroundRefresh() { // module-level state does not outlive it. Alarms are held by the browser and // wake the worker to deliver them. registerAlarmHandlers({ - [BALANCE_REFRESH_ALARM]: backgroundRefresh, + // Caught here rather than left to the alarm dispatcher, which does not + // await what it calls: a profile this build cannot read makes every + // refresh throw, and an unhandled rejection per alarm tick says less than + // one logged line per tick does. + [BALANCE_REFRESH_ALARM]: () => + backgroundRefresh().catch((e) => { + log.errorf("background balance refresh failed:", e); + }), }); // Everything the background context needs re-established on start. This runs @@ -1241,12 +1293,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => { // "it does not throw today" is not a property anyone is // maintaining. log.errorf("RPC request failed:", msg.method, err); - sendResponse({ - error: { - code: INTERNAL_ERROR_CODE, - message: INTERNAL_ERROR_MESSAGE, - }, - }); + sendResponse({ error: failureError(err) }); }); return true; } @@ -1481,18 +1528,10 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => { // the popup nor the page is ever answered. Settle both, through // the same chokepoint as every other retirement. log.errorf("transaction approval response failed:", e); - settleApproval( - msg.id, - { - error: { - code: INTERNAL_ERROR_CODE, - message: INTERNAL_ERROR_MESSAGE, - }, - }, - { holdsClaim: true }, - ); + const failure = failureError(e); + settleApproval(msg.id, { error: failure }, { holdsClaim: true }); sendResponse({ - error: INTERNAL_ERROR_MESSAGE, + error: failure.message, retryable: false, stage: lastResortStage, }); @@ -1584,18 +1623,10 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => { // Same shape as the transaction path: a throw out of the catch // block above would leave the popup and the page both waiting. log.errorf("sign approval response failed:", e); - settleApproval( - msg.id, - { - error: { - code: INTERNAL_ERROR_CODE, - message: INTERNAL_ERROR_MESSAGE, - }, - }, - { holdsClaim: true }, - ); + const failure = failureError(e); + settleApproval(msg.id, { error: failure }, { holdsClaim: true }); sendResponse({ - error: INTERNAL_ERROR_MESSAGE, + error: failure.message, retryable: false, }); }); diff --git a/src/background/state.js b/src/background/state.js index cd66837..45f9f81 100644 --- a/src/background/state.js +++ b/src/background/state.js @@ -28,14 +28,26 @@ const { storageGet, storageSet } = require("../shared/browserApi"); const { normalizePersisted } = require("../shared/persistedState"); +const { + STATE_SCHEMA_VERSION, + assertStateUsable, +} = require("../shared/stateSchema"); // A fresh, fully-normalized, detached copy of the persisted profile. // // Normalized rather than raw: a legacy or malformed record is self-healed the // same way loadState() heals it for the popup, so the background is never the // one context reasoning about a shape the rest of the extension repairs. +// +// Throws StateUnusableError for a record this build cannot make sense of, +// before normalization gets a chance to paper over it — the same gate, in the +// same place, as the popup's loadState(). Every handler that consults the +// profile comes through here, so a dApp call against such a record is answered +// with the specific error the dispatcher maps that to (src/background/index.js) +// rather than dereferencing its way into a generic -32603. async function getState() { const result = await storageGet("autistmask"); + assertStateUsable(result.autistmask); return normalizePersisted(result.autistmask); } @@ -48,6 +60,10 @@ async function updateStateOnce(mutate) { const s = await getState(); await mutate(s); s.hasWallet = Boolean(s.wallets && s.wallets.length > 0); + // Stamped on every write, exactly as the popup's saveState() stamps it: + // whichever context writes last, the record in storage is in this build's + // shape and says so. + s.schemaVersion = STATE_SCHEMA_VERSION; await storageSet({ autistmask: s }); return s; } diff --git a/src/popup/index.html b/src/popup/index.html index 74d11fa..ed99df5 100644 --- a/src/popup/index.html +++ b/src/popup/index.html @@ -1761,6 +1761,75 @@ + + + + diff --git a/src/popup/index.js b/src/popup/index.js index 671c12d..1a7e01e 100644 --- a/src/popup/index.js +++ b/src/popup/index.js @@ -2,6 +2,7 @@ // Loads state, initializes views, triggers first render. const { state, saveState, loadState } = require("../shared/state"); +const { StateUnusableError } = require("../shared/stateSchema"); const { setRuntimeDebug } = require("../shared/log"); const { refreshPrices } = require("../shared/prices"); const { refreshBalances } = require("../shared/balances"); @@ -16,7 +17,7 @@ const { const { applyTheme } = require("./theme"); // Renders a view the popup lands on without having navigated to it forward: // on restore here, and on Back. Only the views that can be fully re-rendered -// from persisted state (RESTORABLE_VIEWS, src/popup/restorableViews.js) go +// from persisted state (RESTORABLE_VIEWS, src/shared/restorableViews.js) go // through it; anything else falls back to the nearest restorable parent. const { renderView, makeBackRenderer } = require("./viewRouter"); @@ -35,6 +36,7 @@ const settings = require("./views/settings"); const settingsAddToken = require("./views/settingsAddToken"); const deleteAddress = require("./views/deleteAddress"); const approval = require("./views/approval"); +const stateRecovery = require("./views/stateRecovery"); function renderWalletList() { home.render(ctx); @@ -134,7 +136,22 @@ function fallbackView() { } async function init() { - await loadState(); + try { + await loadState(); + } catch (e) { + // A profile this build cannot read is the one failure that must not + // fall through to the rest of init(). It used to: the load "succeeded" + // on a record nothing had validated, and the first dereference below + // threw, leaving a popup with no view, no message and no control on + // it, and no way out of the wallet from inside the product + // (https://git.eeqj.de/sneak/AutistMask/issues/311). Now the load + // refuses, and this is the screen that says so. + if (e instanceof StateUnusableError) { + stateRecovery.show(e); + return; + } + throw e; + } applyTheme(state.theme); // Sync runtime debug flag from persisted state before first render diff --git a/src/popup/viewRouter.js b/src/popup/viewRouter.js index b37d53f..bcfbf35 100644 --- a/src/popup/viewRouter.js +++ b/src/popup/viewRouter.js @@ -12,7 +12,7 @@ // dispatch and its data guards can be tested directly; src/popup/index.js // cannot be required outside a browser. -const { RESTORABLE_VIEWS } = require("./restorableViews"); +const { RESTORABLE_VIEWS } = require("../shared/restorableViews"); // The views this page load has rendered. // diff --git a/src/popup/views/helpers.js b/src/popup/views/helpers.js index a959204..87bc357 100644 --- a/src/popup/views/helpers.js +++ b/src/popup/views/helpers.js @@ -45,6 +45,11 @@ const VIEWS = [ "approve-sign", "export-privkey", "show-phrase", + // Shown by src/popup/views/stateRecovery.js when the stored profile + // cannot be read. It is never reached through showView() — by then the + // state singleton this file writes on every navigation refuses to be read + // — but it is listed so that every view-hiding loop covers it. + "state-recovery", ]; // Cleanup callbacks for views that hold a secret in the DOM. The view diff --git a/src/popup/views/stateRecovery.js b/src/popup/views/stateRecovery.js new file mode 100644 index 0000000..b32f26f --- /dev/null +++ b/src/popup/views/stateRecovery.js @@ -0,0 +1,197 @@ +// The screen the popup shows when it cannot read the stored profile. +// +// Everything else in the popup assumes a loaded profile: showView() reads and +// writes the state singleton, every view renders from it, and the Settings +// gear leads to a screen that does both. None of that is available here — by +// the time this runs, loadState() has REFUSED, deliberately, and reading the +// singleton throws (https://git.eeqj.de/sneak/AutistMask/issues/311). +// +// So this module talks to the DOM directly and touches no state at all. It is +// the one screen that must work when nothing else can, which is also why it +// takes no ctx and needs no init(): whatever the rest of the popup did or did +// not manage to wire up, this shows. +// +// Two controls, and both are required. An export with no reset leaves the user +// looking at their broken profile with no way to use the wallet again; a reset +// with no export destroys the only copy of a record that may hold key material +// a later build could read. So the export is offered first, in the page where +// it cannot fail, and the reset is behind a typed confirmation. + +// $ and VIEWS only: nothing else in helpers is safe here, since showView() and +// everything under it read the state singleton. $ is taken from there rather +// than written again locally so that tests/popupElementIds.test.js sees these +// lookups and holds every id below against the markup. +const { $, VIEWS } = require("./helpers"); +const { storageGet, storageRemove } = require("../../shared/browserApi"); +const { log } = require("../../shared/log"); + +// Typed in full before anything is erased, in the same spirit as the wallet +// name on DeleteWalletLostPassword: this button destroys key material and +// there is no password in front of it, because there is no profile to check a +// password against. Compared case-insensitively — the phrase is the barrier, +// not the shift key. +const RESET_PHRASE = "ERASE MY WALLET"; + +let wired = false; + +function setFlash(message) { + const node = $("state-recovery-flash"); + node.textContent = message; + node.style.visibility = message ? "visible" : "hidden"; +} + +// The stored record exactly as storage hands it back, however malformed, with +// no normalization, no defaulting and no repair on it: this is evidence, and +// the point of the export is that a later build (or a human) sees what is +// actually there. It is not the raw bytes — storage deserializes, and +// exportRecord() re-serializes with JSON.stringify — so a value JSON cannot +// represent is the one thing that does not survive the trip. See there. +async function rawRecord() { + const result = await storageGet("autistmask"); + return result.autistmask; +} + +// Best effort, and never the only route. A download from an extension popup +// depends on the browser, the popup staying open long enough, and the +// extension's content security policy; the textarea below depends on none of +// those, and is filled first. +function offerDownload(text) { + try { + if ( + typeof Blob !== "function" || + typeof URL === "undefined" || + typeof URL.createObjectURL !== "function" + ) { + return false; + } + const url = URL.createObjectURL( + new Blob([text], { type: "application/json" }), + ); + const link = document.createElement("a"); + link.href = url; + link.download = "autistmask-saved-data.json"; + link.click(); + // Revoked in a later task, not in this one: the download is started + // from the click and revoking the URL in the same turn can cancel it + // before it has been read. If the popup closes first the URL dies with + // the document anyway. + if (typeof URL.revokeObjectURL === "function") { + setTimeout(() => URL.revokeObjectURL(url), 0); + } + return true; + } catch (e) { + log.errorf("state recovery: download failed:", e); + return false; + } +} + +// Residual, stated rather than left to be discovered: structured-clone storage +// holds values JSON does not have, and no build here writes one, but the export +// is a funds-recovery path and what it cannot carry has to be written down. +// +// Loud: JSON.stringify THROWS on a reference cycle or a BigInt. That lands in +// the catch below, so the export fails entirely and erase is the only control +// left on the screen. +// +// Silent, and the worse of the two, because the box then looks complete: +// a Date becomes its ISO string, a Map or a Set becomes {}, a property whose +// value is undefined is dropped from the output entirely, and NaN and +// ±Infinity become null. Nothing here can serialize any of it faithfully; +// recovering such a record needs the browser's own storage inspector. +async function exportRecord() { + let text; + try { + const record = await rawRecord(); + text = JSON.stringify(record === undefined ? null : record, null, 2); + } catch (e) { + log.errorf("state recovery: export failed:", e); + setFlash( + "The saved data could not be read out of storage. Nothing has" + + " been changed.", + ); + return; + } + // JSON.stringify answers undefined for a value it cannot represent, and + // an empty box would read as "there was nothing there". + if (typeof text !== "string") text = String(text); + + const box = $("state-recovery-blob"); + box.value = text; + box.classList.remove("hidden"); + const downloaded = offerDownload(text); + setFlash( + downloaded + ? "Saved data downloaded, and shown below. Keep a copy before" + + " erasing anything." + : "Saved data shown below. Copy it and keep it before erasing" + + " anything.", + ); +} + +async function resetProfile() { + const typed = $("state-recovery-reset-input").value || ""; + if (typed.trim().toUpperCase() !== RESET_PHRASE) { + setFlash("Type " + RESET_PHRASE + " to confirm. Nothing was erased."); + return; + } + try { + await storageRemove("autistmask"); + } catch (e) { + log.errorf("state recovery: reset failed:", e); + setFlash("The saved data could not be erased. Nothing was changed."); + return; + } + setFlash("Saved data erased. AutistMask is starting fresh."); + // Back to a first run, which is what the wallet now is. A popup that + // cannot reload says so rather than sitting on a screen describing a + // profile that no longer exists. + if ( + typeof window !== "undefined" && + window.location && + typeof window.location.reload === "function" + ) { + window.location.reload(); + return; + } + setFlash("Saved data erased. Close and reopen AutistMask."); +} + +function wire() { + if (wired) return; + wired = true; + $("btn-state-recovery-export").addEventListener("click", exportRecord); + $("btn-state-recovery-reset").addEventListener("click", resetProfile); +} + +/** + * Show the recovery screen, naming `problem`. + * + * @param {Error|string} problem the StateUnusableError from the read that + * refused, or its sentence. + */ +function show(problem) { + const sentence = + (problem && (problem.problem || problem.message)) || String(problem); + + // Not showView(): that reads and writes the singleton this screen exists + // because nothing could load. + for (const view of VIEWS) { + const node = document.getElementById("view-" + view); + if (node) node.classList.add("hidden"); + } + // The one global control, and it leads to a screen that renders from the + // profile. There is nowhere to go from here but out. + const gear = $("btn-settings"); + if (gear) gear.classList.add("hidden"); + + $("state-recovery-problem").textContent = sentence; + $("state-recovery-blob").value = ""; + $("state-recovery-blob").classList.add("hidden"); + $("state-recovery-reset-input").value = ""; + setFlash(""); + wire(); + $("view-state-recovery").classList.remove("hidden"); + log.errorf("state is unusable, showing the recovery screen:", sentence); +} + +module.exports = { show, RESET_PHRASE }; diff --git a/src/shared/browserApi.js b/src/shared/browserApi.js index 339ccfc..c512286 100644 --- a/src/shared/browserApi.js +++ b/src/shared/browserApi.js @@ -194,6 +194,23 @@ function storageSet(items) { return Promise.resolve(storage.set(items)); } +/** + * Erase stored keys. The one caller is the destructive reset on the recovery + * screen (src/popup/views/stateRecovery.js), which is the only way out of a + * profile no build can read; it rejects rather than defaulting for the same + * reason the two above do — a reset that silently did nothing would leave the + * user in the dead end they were promised an exit from. + * + * @param {string|string[]} keys + * @returns {Promise} + * @throws rejects where `storage.local` is absent. + */ +function storageRemove(keys) { + const storage = storageLocal(); + if (!storage) return storageUnavailable("remove"); + return Promise.resolve(storage.remove(keys)); +} + /** * @param {Object} queryInfo * @returns {Promise} the matching tabs. @@ -251,6 +268,7 @@ module.exports = { sendMessage, storageGet, storageLocal, + storageRemove, storageSet, tabsApi, tabsQuery, diff --git a/src/shared/networks.js b/src/shared/networks.js index 4550590..fcc486f 100644 --- a/src/shared/networks.js +++ b/src/shared/networks.js @@ -31,8 +31,42 @@ const SUPPORTED_CHAIN_IDS = new Set( Object.values(NETWORKS).map((n) => n.chainId), ); +// Thrown rather than defaulted. An id this build does not know used to answer +// with MAINNET, so a stored `{networkId:"base"}` rendered the selector as +// Ethereum Mainnet with no banner and answered eth_chainId 0x1, while rpcUrl +// still pointed at Base — the wallet telling the user and the page one chain +// while transacting on another. Nothing in this codebase has an unknown id to +// offer: stored state is validated against this table before it is loaded +// (src/shared/stateSchema.js), and every other caller passes an id it took +// from here. So an unknown id is a defect, and it says so, the same way +// getProvider() (src/shared/balances.js) already refuses one. +class UnknownNetworkError extends Error { + constructor(id) { + super( + "AutistMask does not know the network " + + JSON.stringify(id) + + "; it supports " + + Object.keys(NETWORKS).join(", "), + ); + this.name = "UnknownNetworkError"; + this.networkId = id; + } +} + +// Own properties only: NETWORKS inherits from Object.prototype, so +// NETWORKS["constructor"] and NETWORKS["__proto__"] both answer with something +// truthy that is not a network. A stored id is untrusted input, and this is +// the test the validator uses to decide whether it may be adopted at all. +function isKnownNetworkId(id) { + return ( + typeof id === "string" && + Object.prototype.hasOwnProperty.call(NETWORKS, id) + ); +} + function networkById(id) { - return NETWORKS[id] || NETWORKS.mainnet; + if (!isKnownNetworkId(id)) throw new UnknownNetworkError(id); + return NETWORKS[id]; } function networkByChainId(chainId) { @@ -51,6 +85,8 @@ function explorerLink(network, type, value) { module.exports = { NETWORKS, SUPPORTED_CHAIN_IDS, + UnknownNetworkError, + isKnownNetworkId, networkById, networkByChainId, explorerLink, diff --git a/src/shared/persistedState.js b/src/shared/persistedState.js index 36a6d27..414cbb1 100644 --- a/src/shared/persistedState.js +++ b/src/shared/persistedState.js @@ -10,8 +10,14 @@ // reaching it anyway and being served DEFAULT_STATE. const { DEFAULT_RPC_URL, DEFAULT_BLOCKSCOUT_URL } = require("./constants"); -// Dependency-free constant module; safe to pull into a background bundle. -const { RESTORABLE_VIEWS } = require("../popup/restorableViews"); +const { isKnownNetworkId } = require("./networks"); +const { STATE_SCHEMA_VERSION } = require("./stateSchema"); +// Dependency-free constant module. It lives under src/shared/ rather than +// src/popup/ precisely because this module is in the background bundle: a +// popup-path module reached from the worker is the shape the prohibition in +// script/lib/forbiddenBundleInputs.js exists to keep out, even when the +// particular module is harmless. +const { RESTORABLE_VIEWS } = require("./restorableViews"); const DEFAULT_STATE = { hasWallet: false, @@ -46,7 +52,10 @@ const DEFAULT_STATE = { // Every field written to and read from the single "autistmask" storage key. // hasWallet is deliberately excluded from the diffing/merge logic in // state.js — like loadState() does, it is always derived from `wallets`, -// never carried as an independent value. +// never carried as an independent value. schemaVersion is excluded for the +// same reason and is absent from DEFAULT_STATE for it: it describes the +// record rather than being part of it, and every write stamps the current +// value rather than diffing whatever was read. const PERSISTED_FIELDS = Object.keys(DEFAULT_STATE) .filter((key) => key !== "hasWallet") .concat([ @@ -58,6 +67,31 @@ const PERSISTED_FIELDS = Object.keys(DEFAULT_STATE) "viewStack", ]); +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +// A list of token references, as everything downstream dereferences them: +// `t.address.toLowerCase()`, with no guard of its own (src/shared/balances.js, +// src/popup/views/helpers.js, and every view that shows a balance line). +// +// Both the container AND the entries, because they are separate defects. A +// container check alone leaves a well-formed list of malformed entries walking +// through to a dereference one level below the check, which is the same blank +// popup: `[1, 2]` and `[{}]` are lists. +// +// A malformed entry is DROPPED rather than repaired: a token reference with no +// address identifies nothing, so there is no value to repair it to, and the +// alternative — refusing the whole record — sends a user whose wallets are +// perfectly readable to an export-or-erase screen over a token list. An entry +// that is a record with a text address is kept verbatim, extra fields and all. +function tokenRefs(value) { + if (!Array.isArray(value)) return []; + return value.filter( + (entry) => isRecord(entry) && typeof entry.address === "string", + ); +} + // Keep only the leading run of stored views the popup is willing to render. // // restoreView() refuses to reopen ONTO a non-restorable view, but the stack @@ -107,11 +141,52 @@ function restorableStack(stored, currentView) { function normalizePersisted(saved) { saved = saved || {}; const out = {}; + // Every write goes out at the current version. That IS the migration for + // the unversioned records every install in the field holds: version 1 is + // the shape that shipped unversioned, so a record that validated is + // carried forward simply by being stamped. A record this build does NOT + // understand never reaches here — assertStateUsable() refuses it on the + // read path first (src/shared/stateSchema.js). + out.schemaVersion = STATE_SCHEMA_VERSION; out.wallets = structuredClone(saved.wallets || []); // Derived, never trusted verbatim off storage — see loadState(). out.hasWallet = out.wallets.length > 0; - out.trackedTokens = structuredClone(saved.trackedTokens || []); - out.networkId = saved.networkId || DEFAULT_STATE.networkId; + // Each address's token holdings, floored to a list of token records on the + // detached copy above. Every reader iterates it behind a `|| []` that only + // covers an ABSENT value, and then dereferences `t.address.toLowerCase()` + // and `t.balance` — so a stored string iterates as characters, a number + // throws on the iterator, and a null entry throws on the field. + // + // This field specifically, because refreshBalances() writes it WHOLESALE + // rather than merging into it: a write that only partly lands is the live + // cause https://git.eeqj.de/sneak/AutistMask/issues/311 names, and this is + // where it lands. The wallet list itself is the gate's (stateSchema.js); + // what is below an address record is not, and gets floored here. + if (Array.isArray(out.wallets)) { + for (const wallet of out.wallets) { + if (!isRecord(wallet) || !Array.isArray(wallet.addresses)) continue; + for (const addr of wallet.addresses) { + if (!isRecord(addr)) continue; + addr.tokenBalances = tokenRefs(addr.tokenBalances); + } + } + } + // An actual list of token records is required, not merely a truthy value + // and not merely a list: everything downstream iterates this and + // dereferences `token.address`, so a stored string or object walks through + // a `|| []`, and a list of numbers walks through an Array.isArray(), and + // both throw on the first read — the blank popup from the issue, for a + // profile whose wallets are perfectly fine. An empty list is a legitimate + // value and survives. + out.trackedTokens = structuredClone(tokenRefs(saved.trackedTokens)); + // The loud refusal for an unknown id is assertStateUsable(); this is the + // floor under it. networkId is an object KEY into networkEndpoints below, + // so a value that is not a network in networks.js must never get that far + // — "__proto__" would set the map's prototype instead of an own key, and + // the user's endpoint would silently not be recorded. + out.networkId = isKnownNetworkId(saved.networkId) + ? saved.networkId + : DEFAULT_STATE.networkId; out.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl; out.blockscoutUrl = saved.blockscoutUrl || DEFAULT_STATE.blockscoutUrl; // An actual object is required, not merely a truthy non-array: the code @@ -127,7 +202,19 @@ function normalizePersisted(saved) { : {}; out.networkEndpoints = {}; for (const netId of Object.keys(rawEndpoints)) { - out.networkEndpoints[netId] = { ...rawEndpoints[netId] }; + // defineProperty, not assignment: a stored map with an own + // "__proto__" key — which JSON can carry and assignment treats as the + // prototype setter — would otherwise replace this object's prototype + // and record no entry at all. Keys other than the known network ids + // are kept rather than dropped, so a profile that has been on a build + // with more networks does not lose their endpoints by passing through + // this one. + Object.defineProperty(out.networkEndpoints, netId, { + value: { ...rawEndpoints[netId] }, + writable: true, + enumerable: true, + configurable: true, + }); } // A profile written before this map existed carries exactly one pair of // endpoints, belonging to whatever network it was last on. Adopt it as @@ -140,7 +227,18 @@ function normalizePersisted(saved) { }; } out.lastBalanceRefresh = saved.lastBalanceRefresh || 0; - out.activeAddress = saved.activeAddress || null; + // A non-empty address, or null, never anything else: this is passed to + // address.slice() and compared against stored addresses, so a stored + // number or object walks through a `|| null` and throws on the first + // render. The empty string is text but it is not an address, and it must + // become null rather than survive: init() auto-selects the first address + // only on a STRICT null, so a stored "" would leave the popup with no + // address ever selected. Nothing in src/ writes one, and this keeps the + // behaviour the `|| null` this check replaced already had. + out.activeAddress = + typeof saved.activeAddress === "string" && saved.activeAddress !== "" + ? saved.activeAddress + : null; out.allowedSites = saved.allowedSites && !Array.isArray(saved.allowedSites) ? structuredClone(saved.allowedSites) diff --git a/src/popup/restorableViews.js b/src/shared/restorableViews.js similarity index 79% rename from src/popup/restorableViews.js rename to src/shared/restorableViews.js index f6053ad..6cf031a 100644 --- a/src/popup/restorableViews.js +++ b/src/shared/restorableViews.js @@ -17,7 +17,13 @@ // // Kept in its own module, with no dependencies, so tests can assert the // exclusion directly rather than trusting a reading of the popup entry -// point, which cannot be required outside a browser. +// point. +// +// It sits under src/shared/ rather than src/popup/ because +// src/shared/persistedState.js needs it and that module is in the BACKGROUND +// bundle: a popup-path module reached from the worker is the shape +// script/lib/forbiddenBundleInputs.js exists to keep out, whether or not the +// particular module is harmless. const RESTORABLE_VIEWS = new Set([ "main", "address", diff --git a/src/shared/state.js b/src/shared/state.js index 1651c26..08442c6 100644 --- a/src/shared/state.js +++ b/src/shared/state.js @@ -29,6 +29,12 @@ const { normalizePersisted, } = require("./persistedState"); +const { + STATE_SCHEMA_VERSION, + assertStateUsable, + migrationNeeded, +} = require("./stateSchema"); + const { storageGet, storageSet } = require("./browserApi"); const { log } = require("./log"); @@ -446,6 +452,14 @@ function mergeNetworkEndpoints(base, ours, theirs) { async function saveStateOnce() { const current = snapshotPersisted(); const result = await storageGet("autistmask"); + // The record in storage right now is about to be merged into and written + // back, so it is validated exactly like a load validates it. Without this, + // a page whose own load succeeded would normalize a record it does not + // understand — one a NEWER build wrote in the meantime, say — and write + // the result back over it, destroying the only copy of whatever that + // record held. Refusing is louder than that and loses nothing: the live + // state is untouched and the next save retries. + assertStateUsable(result.autistmask); // Normalized, not raw: a field this page did not change still has to // come from storage in its loaded (self-healed) shape. See // normalizePersisted() in persistedState.js. @@ -481,6 +495,10 @@ async function saveStateOnce() { } } merged.hasWallet = Boolean(merged.wallets && merged.wallets.length > 0); + // Stamped on every write, never merged or diffed: the record that goes to + // storage is in THIS build's shape whatever shape it was read in, which is + // what migrates the unversioned records every install in the field holds. + merged.schemaVersion = STATE_SCHEMA_VERSION; await storageSet({ autistmask: merged }); @@ -512,8 +530,24 @@ function saveState() { return turn; } +// Rejects with StateUnusableError for a stored record this build cannot make +// sense of. Nothing is assigned and `loaded` stays false in that case, so a +// caller that ignores the rejection gets StateNotLoadedError on the first +// read rather than a half-populated profile. The caller that does NOT ignore +// it is the popup entry point, which shows the recovery screen +// (src/popup/views/stateRecovery.js) instead of proceeding. async function loadState() { const result = await storageGet("autistmask"); + // Before normalization, on the raw bytes: normalizing first would paper + // over the very shapes this refuses, which is how a corrupt record used to + // reach the popup and blank it (issue #311). + assertStateUsable(result.autistmask); + if (migrationNeeded(result.autistmask)) { + log.infof( + "state: migrating an unversioned profile to schema version", + STATE_SCHEMA_VERSION, + ); + } if (result.autistmask) { Object.assign(rawState, normalizePersisted(result.autistmask)); } diff --git a/src/shared/stateSchema.js b/src/shared/stateSchema.js new file mode 100644 index 0000000..fb12cbc --- /dev/null +++ b/src/shared/stateSchema.js @@ -0,0 +1,265 @@ +// The version stamped on the stored profile, and the shape check every read +// of one goes through. +// +// Storage is the one input to this extension that nobody validated. A profile +// carried no version at all, so there was no way to tell a record this build +// understands from one a later build wrote, and loadState() coerced scalars +// while trusting the structure — so a `wallets` that was a string, or an array +// of nulls, or a later schema's wallet records, reached the popup and threw on +// the first dereference. The popup rendered NOTHING: no view, no message, no +// control, and no way out from inside the product +// (https://git.eeqj.de/sneak/AutistMask/issues/311). +// +// Two separate jobs, deliberately not merged: +// +// stateProblem() / assertStateUsable() refuse a record this build cannot +// safely reason about, loudly, naming the problem in a +// sentence that goes on screen. This is the gate. +// normalizePersisted() (persistedState.js) self-heal a record that IS +// usable: absent fields, legacy shapes, out-of-range flags. +// +// The gate runs FIRST, on the raw stored bytes, before normalization has a +// chance to paper over a record whose meaning nobody can vouch for. A blob +// that fails it is left in storage untouched — it is the user's only copy of +// whatever it holds, and the recovery screen exports it before offering to +// erase it. +// +// What is checked HERE is what nothing downstream can floor: the wallet list, +// the version, and the network id that keys an object. Every other field is +// normalizePersisted()'s to make safe, and what that function does today is +// NOT uniform. The four kinds of floor it applies, listed so a reader can tell +// which one a given field has without reading it off: +// +// Type-checked, container AND entries: trackedTokens, each address's +// tokenBalances, networkId, networkEndpoints, activeAddress, viewStack. +// These are the fields something dereferences structurally — iterated, +// indexed, assigned into, or .toLowerCase()'d — where a truthy value of +// the wrong type throws on the first read. The entries matter as much as +// the container: [1, 2] IS a list, and `t.address` is one level below the +// Array.isArray(). +// Container shape only: allowedSites, deniedSites. A falsy value or a list +// becomes {}; anything else is taken as stored and the entries are not +// checked. +// `saved.x || default`, no type check: rpcUrl, blockscoutUrl, +// lastBalanceRefresh, fraudContracts, tokenHolderCache, theme, +// currentView, selectedToken, viewData. +// Present-or-default, value taken verbatim: every boolean flag, +// dustThresholdGwei, selectedWallet, selectedAddress. +// +// A field added to the record needs a check here or a floor there, chosen by +// what reads it: anything dereferenced structurally needs the type check, and +// neither of the last two kinds is one. + +const { isKnownNetworkId } = require("./networks"); + +// Bump this when the MEANING of a stored field changes, and add the migration +// that carries the older version forward. Adding a field with a defaulted +// absent value is not a bump: normalizePersisted() already handles that, and +// bumping for it would send every older install to the recovery screen for no +// reason. +// +// Version 1 is the shape that shipped unversioned. An unversioned record is +// therefore version 1, not a defect — see migrationNeeded() below. +const STATE_SCHEMA_VERSION = 1; + +// Thrown by every read path that finds a record it cannot use. `problem` is +// the sentence shown to the user; `message` carries the same text so a log +// line or a rethrow is not empty. +class StateUnusableError extends Error { + constructor(problem) { + super(problem); + this.name = "StateUnusableError"; + this.problem = problem; + } +} + +function isPlainObject(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +// Own properties only, everywhere in this file. `saved` comes from storage as +// parsed JSON, so `saved.constructor` and `saved.__proto__` answer from the +// prototype chain for a record that carries neither — a check written as a +// plain truthiness test can be satisfied by Object.prototype rather than by +// anything the user's profile actually contains. +function has(obj, key) { + return Object.prototype.hasOwnProperty.call(obj, key); +} + +function ordinal(index) { + return String(index + 1); +} + +function describeType(value) { + if (value === null) return "null"; + if (Array.isArray(value)) return "a list"; + return "a " + typeof value; +} + +// One address record, as every screen dereferences it. +function addressProblem(addr, walletIndex, addrIndex) { + const where = + "address " + + ordinal(addrIndex) + + " of wallet " + + ordinal(walletIndex) + + " in the saved data"; + if (!isPlainObject(addr)) { + return "The " + where + " is " + describeType(addr) + ", not a record."; + } + if (typeof addr.address !== "string" || addr.address === "") { + return "The " + where + " has no address."; + } + return null; +} + +function walletProblem(wallet, index) { + const where = "Wallet " + ordinal(index) + " in the saved data"; + if (!isPlainObject(wallet)) { + return where + " is " + describeType(wallet) + ", not a wallet record."; + } + if (!Array.isArray(wallet.addresses)) { + return where + " has no list of addresses."; + } + if (has(wallet, "name") && typeof wallet.name !== "string") { + return where + " has a name that is not text."; + } + for (let i = 0; i < wallet.addresses.length; i++) { + const problem = addressProblem(wallet.addresses[i], index, i); + if (problem) return problem; + } + return null; +} + +function versionProblem(saved) { + // No version field at all is the shape every install in the field has: + // no build ever wrote one. It is version 1, and it is migrated in place. + if (!has(saved, "schemaVersion")) return null; + const version = saved.schemaVersion; + if ( + typeof version !== "number" || + !Number.isInteger(version) || + version < 1 + ) { + return ( + "The saved data carries a schema version AutistMask does not" + + " recognize (" + + JSON.stringify(version) + + ")." + ); + } + if (version > STATE_SCHEMA_VERSION) { + return ( + "The saved data was written by a newer version of AutistMask" + + " (schema version " + + version + + "; this build understands version " + + STATE_SCHEMA_VERSION + + ")." + ); + } + return null; +} + +/** + * The reason this build cannot use `saved`, as a sentence for the user, or + * null when it can. + * + * @param {*} saved the raw record from storage, or undefined for a fresh + * install. + * @returns {string|null} + */ +function stateProblem(saved) { + // Nothing stored is a first run, not a defect. + if (saved === undefined || saved === null) return null; + if (!isPlainObject(saved)) { + return ( + "The saved data is " + + describeType(saved) + + ", not the record AutistMask stores." + ); + } + + const version = versionProblem(saved); + if (version) return version; + + // Read once, from an OWN property or not at all, so that a polluted + // prototype cannot decide whether a profile is refused. Note that + // normalizePersisted() reads the same field plainly, and so WOULD consult + // the prototype chain: the two halves agree only because a record arriving + // from storage has been through structuredClone and always carries + // Object.prototype. Nothing reachable from storage can put them at odds, + // but a caller that hands either one a hand-built object with an unusual + // prototype is not covered by that. + const wallets = + has(saved, "wallets") && saved.wallets !== undefined + ? saved.wallets + : []; + if (!Array.isArray(wallets)) { + return ( + "The list of wallets in the saved data is " + + describeType(wallets) + + ", not a list." + ); + } + for (let i = 0; i < wallets.length; i++) { + const problem = walletProblem(wallets[i], i); + if (problem) return problem; + } + + // networkId is not merely displayed: it is an object KEY into + // state.networkEndpoints. A corrupt "__proto__" would set that map's + // prototype instead of an own key, so the user's endpoint would silently + // not be recorded and a switch away and back would return the public + // default. isKnownNetworkId() is an own-property test against the network + // table for exactly that reason. + if ( + has(saved, "networkId") && + saved.networkId !== undefined && + !isKnownNetworkId(saved.networkId) + ) { + return ( + "The saved data selects a network AutistMask does not know (" + + JSON.stringify(saved.networkId) + + ")." + ); + } + + return null; +} + +/** + * Refuse a record this build cannot use. + * + * @param {*} saved the raw record from storage. + * @throws {StateUnusableError} + */ +function assertStateUsable(saved) { + const problem = stateProblem(saved); + if (problem) throw new StateUnusableError(problem); +} + +/** + * Whether `saved` is a usable record written before versions existed, and so + * gets the current version stamped on it the next time anything writes. Purely + * informational — the migration itself is that stamp, since version 1 IS the + * unversioned shape. + * + * @param {*} saved + * @returns {boolean} + */ +function migrationNeeded(saved) { + return ( + isPlainObject(saved) && + !has(saved, "schemaVersion") && + stateProblem(saved) === null + ); +} + +module.exports = { + STATE_SCHEMA_VERSION, + StateUnusableError, + assertStateUsable, + migrationNeeded, + stateProblem, +}; diff --git a/tests/alarms.test.js b/tests/alarms.test.js index 759e961..dfdc61f 100644 --- a/tests/alarms.test.js +++ b/tests/alarms.test.js @@ -397,8 +397,23 @@ describe("balance refresh steady-state cadence", () => { return { autistmask: { hasWallet: true, + // A whole wallet record, not a bare address: a stored profile + // is validated against the schema on every read now + // (src/shared/stateSchema.js), and a wallet with no address + // list is one of the shapes that refuses to load. wallets: [ - { address: "0x0000000000000000000000000000000000000001" }, + { + name: "Wallet 1", + type: "hd", + addresses: [ + { + address: + "0x0000000000000000000000000000000000000001", + balance: "0", + tokenBalances: [], + }, + ], + }, ], lastBalanceRefresh: 0, }, diff --git a/tests/backgroundApproval.test.js b/tests/backgroundApproval.test.js index b67ba79..09f8b7e 100644 --- a/tests/backgroundApproval.test.js +++ b/tests/backgroundApproval.test.js @@ -175,8 +175,22 @@ function loadBackground(options) { } const persisted = { + // Address RECORDS, not bare address strings: a stored profile is + // validated against the schema on every read now + // (src/shared/stateSchema.js), and a bare string where a record + // belongs is one of the shapes that refuses to load. wallets: [ - { name: "Wallet 1", type: "hd", addresses: [signer.address] }, + { + name: "Wallet 1", + type: "hd", + addresses: [ + { + address: signer.address, + balance: "0", + tokenBalances: [], + }, + ], + }, ], networkId: "mainnet", rpcUrl: "https://rpc.invalid", diff --git a/tests/deleteWalletLostPassword.test.js b/tests/deleteWalletLostPassword.test.js index 8fa3d8e..88cc05f 100644 --- a/tests/deleteWalletLostPassword.test.js +++ b/tests/deleteWalletLostPassword.test.js @@ -35,7 +35,7 @@ jest.mock("../src/shared/vault", () => ({ decryptWithPassword: jest.fn(), })); -const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews"); +const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews"); const { makeStorageStub } = require("./support/storageStub"); const VIEW = "delete-wallet-lost-password"; diff --git a/tests/exportPrivkey.test.js b/tests/exportPrivkey.test.js index 516026b..06fef69 100644 --- a/tests/exportPrivkey.test.js +++ b/tests/exportPrivkey.test.js @@ -21,7 +21,7 @@ jest.mock("../src/shared/wallet", () => ({ getSignerForAddress: jest.fn(() => ({ privateKey: mockPrivateKey })), })); -const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews"); +const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews"); const VIEW = "export-privkey"; const PASSWORD = "correct horse battery"; diff --git a/tests/showPhrase.test.js b/tests/showPhrase.test.js index d7cf2dd..4a76499 100644 --- a/tests/showPhrase.test.js +++ b/tests/showPhrase.test.js @@ -12,7 +12,7 @@ const fs = require("fs"); const path = require("path"); const { walletHasRecoveryPhrase } = require("../src/shared/wallet"); -const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews"); +const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews"); const SHOW_PHRASE_VIEW = "show-phrase"; diff --git a/tests/state.test.js b/tests/state.test.js index 61c8ad4..878182f 100644 --- a/tests/state.test.js +++ b/tests/state.test.js @@ -1,7 +1,17 @@ const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; +// Address RECORDS, not bare address strings. A stored profile is validated +// against the schema on every read now (src/shared/stateSchema.js), and a bare +// string where an address record belongs is one of the shapes that refuses to +// load — as it should, since every screen dereferences addr.address. function oneWallet() { - return [{ name: "Wallet 1", type: "hd", addresses: [ADDRESS] }]; + return [ + { + name: "Wallet 1", + type: "hd", + addresses: [{ address: ADDRESS, balance: "0", tokenBalances: [] }], + }, + ]; } const { makeStorageStub } = require("./support/storageStub"); diff --git a/tests/stateRecovery.test.js b/tests/stateRecovery.test.js new file mode 100644 index 0000000..a7e48b9 --- /dev/null +++ b/tests/stateRecovery.test.js @@ -0,0 +1,630 @@ +// A stored profile the popup cannot read must produce a SCREEN, not a blank +// popup (https://git.eeqj.de/sneak/AutistMask/issues/311). +// +// The three corrupt blobs below are the ones the pre-1.0 audit wrote into +// storage. Against the build this file was added to, each one rendered nothing +// at all — no view, no message, no control — because init() dereferenced +// `state.wallets[0].addresses` on a record nothing had validated and threw +// before the first showView(). +// +// So the assertions here are deliberately made through the REAL popup entry +// point rather than against the recovery view module directly. A recovery +// screen that renders perfectly when something calls it, and that nothing +// calls, is exactly the defect: what has to be true is that BOOTING the popup +// on a bad blob lands on it. +// +// The DOM stub is built FROM src/popup/index.html — every id in the markup, +// with the classes the markup gives it — so "which views are visible" is +// answered against the real element set, and a recovery screen with no markup +// behind it cannot pass. +// +// The fourth case is the upgrade one, and it is the case that must NOT reach +// the recovery screen: every install in the field has a valid profile with no +// version field, and showing those users a wipe prompt would be a worse defect +// than the one being fixed. It is migrated in place and keeps working. + +const fs = require("fs"); +const path = require("path"); + +const { makeStorageStub } = require("./support/storageStub"); + +const POPUP_HTML = fs.readFileSync( + path.join(__dirname, "..", "src", "popup", "index.html"), + "utf8", +); + +// Fixed address, never used for anything but these tests. +const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; +// A fixed ERC-20 contract address, same rule. +const TOKEN_ADDRESS = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + +// ------------------------------------------------------------- fixtures + +// A profile in the shape every install in the field has it: complete, valid, +// and carrying no version field, because no build ever wrote one. +function unversionedValidProfile() { + return { + hasWallet: true, + wallets: [ + { + type: "hd", + name: "Wallet 1", + xpub: "xpub-wallet-1", + encryptedSecret: "encrypted-secret-1", + nextIndex: 1, + addresses: [ + { address: ADDRESS, balance: "1.5", tokenBalances: [] }, + ], + }, + ], + activeAddress: ADDRESS, + networkId: "mainnet", + rpcUrl: "https://ethereum-rpc.publicnode.com", + blockscoutUrl: "https://eth.blockscout.com/api/v2", + allowedSites: { [ADDRESS]: ["dapp.example"] }, + deniedSites: {}, + trackedTokens: [], + theme: "system", + }; +} + +// The three blobs from the issue, each with the error it produced. +const CORRUPT_BLOBS = [ + { + name: "wallets is a string", + blob: { hasWallet: true, wallets: ADDRESS, activeAddress: ADDRESS }, + }, + { + name: "wallets is an array of garbage", + blob: { + hasWallet: true, + wallets: [null, 42, "wallet"], + activeAddress: ADDRESS, + }, + }, + { + name: "future-schema blob (unknown fields, no version)", + blob: { + hasWallet: true, + // A later schema that renamed the field and moved the key + // material, written by a build this one knows nothing about, and + // stamped with no version because this build never wrote one. + wallets: [ + { + id: "wallet-1", + label: "Wallet 1", + accounts: [{ addr: ADDRESS, wei: "0x0" }], + keyring: { kind: "hd", vault: "…" }, + }, + ], + profileFormat: "am-2", + activeAccount: ADDRESS, + }, + }, +]; + +// ------------------------------------------------------------- DOM stub + +function makeElement(id, className) { + const classes = new Set( + (className || "").split(/\s+/).filter((name) => name !== ""), + ); + const el = { + id, + tagName: "DIV", + textContent: "", + value: "", + innerHTML: "", + href: "", + download: "", + disabled: false, + style: {}, + dataset: {}, + listeners: {}, + clicked: 0, + classList: { + add: (...names) => names.forEach((n) => classes.add(n)), + remove: (...names) => names.forEach((n) => classes.delete(n)), + contains: (n) => classes.has(n), + toggle: (n, force) => { + const on = force === undefined ? !classes.has(n) : force; + if (on) classes.add(n); + else classes.delete(n); + return on; + }, + }, + addEventListener: (name, fn) => { + el.listeners[name] = el.listeners[name] || []; + el.listeners[name].push(fn); + }, + removeEventListener: () => {}, + appendChild: () => {}, + remove: () => {}, + focus: () => {}, + select: () => {}, + setAttribute: (name, value) => { + el[name] = value; + }, + querySelector: () => null, + querySelectorAll: () => [], + click: () => { + el.clicked += 1; + }, + }; + return el; +} + +// Every id in the markup, with the classes the markup gives it. A view the +// popup is supposed to reveal has to exist here, which means it has to exist +// in src/popup/index.html. +function idsFromHtml(html) { + const out = new Map(); + const tags = html.match(/<[a-zA-Z][^>]*>/g) || []; + for (const tag of tags) { + const id = /\bid="([^"]+)"/.exec(tag); + if (!id) continue; + const cls = /\bclass="([^"]*)"/.exec(tag); + out.set(id[1], cls ? cls[1] : ""); + } + return out; +} + +function makeDocument(html) { + const authored = idsFromHtml(html); + const els = new Map(); + for (const [id, className] of authored) { + els.set(id, makeElement(id, className)); + } + const created = []; + const doc = { + listeners: {}, + getElementById(id) { + // Created on demand by updateDebugBanner(); absent is the state a + // non-debug, non-testnet popup is in. + if (id === "debug-banner") return null; + if (!els.has(id)) els.set(id, makeElement(id, "")); + return els.get(id); + }, + createElement(tag) { + const el = makeElement("created-" + tag, ""); + el.tagName = String(tag).toUpperCase(); + created.push(el); + return el; + }, + addEventListener(name, fn) { + doc.listeners[name] = doc.listeners[name] || []; + doc.listeners[name].push(fn); + }, + querySelectorAll: () => [], + documentElement: makeElement("html", ""), + body: { + prepend: () => {}, + appendChild: () => {}, + removeChild: () => {}, + }, + elements: els, + authoredIds: authored, + created, + }; + return doc; +} + +// ------------------------------------------------------------- harness + +// Boot the real popup entry point over `stored`, exactly as the browser does: +// storage already holds the record, the page loads, DOMContentLoaded fires. +async function bootPopup(stored) { + jest.resetModules(); + + // The two modules that reach the network. Neither is on the path under + // test; both would make this suite hit the internet. + jest.doMock("../src/shared/prices", () => ({ + prices: {}, + refreshPrices: jest.fn(async () => {}), + clearPrices: jest.fn(), + getPrice: () => null, + formatUsd: () => "", + formatAddressTotal: () => "", + getAddressValue: () => ({ usd: null, partial: false }), + getWalletValue: () => ({ usd: null, partial: false }), + getTotalValue: () => ({ usd: null, partial: false }), + })); + jest.doMock("../src/shared/balances", () => ({ + fetchTokenBalances: jest.fn(async () => []), + refreshBalances: jest.fn(async () => {}), + lookupTokenInfo: jest.fn(async () => null), + getProvider: () => ({}), + scanForAddresses: jest.fn(async () => []), + })); + jest.doMock("../src/shared/transactions", () => ({ + fetchRecentTransactions: jest.fn(async () => []), + filterTransactions: () => [], + })); + + const storage = makeStorageStub( + stored === undefined ? {} : { autistmask: stored }, + ); + const document = makeDocument(POPUP_HTML); + const reloads = []; + + globalThis.chrome = { + storage: { local: storage.local }, + runtime: { + sendMessage: jest.fn(async () => ({})), + getURL: (p) => "chrome-extension://autistmask/" + p, + onMessage: { addListener: () => {} }, + }, + }; + globalThis.document = document; + globalThis.window = { + location: { + search: "", + href: "chrome-extension://autistmask/src/popup/index.html", + reload: () => reloads.push(Date.now()), + }, + matchMedia: () => ({ + matches: false, + addEventListener: () => {}, + removeEventListener: () => {}, + }), + addEventListener: () => {}, + }; + // The 10s refresh loop init() starts would outlive the test. + const realSetInterval = globalThis.setInterval; + globalThis.setInterval = () => 0; + + require("../src/popup/index"); + + const booted = []; + for (const fn of document.listeners.DOMContentLoaded || []) { + booted.push(fn()); + } + + // What the browser console would have shown. A throw out of init() is the + // blank popup this issue is about, so it is captured rather than thrown: + // the assertion that matters is what ended up on screen. + const pageErrors = []; + for (const p of booted) { + try { + await p; + } catch (e) { + pageErrors.push(String((e && e.message) || e)); + } + } + await settle(); + + globalThis.setInterval = realSetInterval; + + return { + storage, + document, + pageErrors, + reloaded: () => reloads.length, + node: (id) => document.getElementById(id), + text: (id) => document.getElementById(id).textContent, + value: (id) => document.getElementById(id).value, + hidden: (id) => + document.getElementById(id).classList.contains("hidden"), + click: async (id) => { + const el = document.getElementById(id); + const fns = el.listeners.click || []; + for (const fn of fns) await fn(); + await settle(); + }, + // The view ids whose section is not hidden, as the audit measured them. + visibleViews: () => { + const out = []; + for (const [id, el] of document.elements) { + if (!id.startsWith("view-")) continue; + if (!el.classList.contains("hidden")) out.push(id.slice(5)); + } + return out; + }, + }; +} + +async function settle() { + for (let i = 0; i < 50; i++) await Promise.resolve(); +} + +afterEach(() => { + delete globalThis.chrome; + delete globalThis.document; + delete globalThis.window; +}); + +// --------------------------------------------------------------- tests + +describe("a stored profile the popup cannot read", () => { + for (const { name, blob } of CORRUPT_BLOBS) { + test(`${name}: the recovery screen, not a blank popup`, async () => { + const env = await bootPopup(blob); + + // Asserted together, and in the audit's own shape: a failure here + // prints both what was on screen and what the console said, which + // is the pair that identifies this defect. + expect({ + visibleViews: env.visibleViews(), + errors: env.pageErrors, + }).toEqual({ visibleViews: ["state-recovery"], errors: [] }); + + // The screen has to NAME the problem. A blank recovery screen is + // the same dead end with a border around it. + expect(env.text("state-recovery-problem").length).toBeGreaterThan( + 10, + ); + }); + } + + test("the Settings gear is hidden, since every screen behind it reads the profile", async () => { + const env = await bootPopup(CORRUPT_BLOBS[0].blob); + + expect(env.hidden("btn-settings")).toBe(true); + }); + + test("it does not write over the record it could not read", async () => { + // The blob is evidence, and possibly the only copy of key material in + // a shape a later build could recover. A boot that normalized it back + // into storage would destroy exactly that. + const env = await bootPopup(CORRUPT_BLOBS[2].blob); + + expect(env.storage.read("autistmask")).toEqual(CORRUPT_BLOBS[2].blob); + expect(env.storage.set).not.toHaveBeenCalled(); + }); +}); + +describe("the export on the recovery screen", () => { + test("hands back the raw stored record verbatim", async () => { + const env = await bootPopup(CORRUPT_BLOBS[2].blob); + + await env.click("btn-state-recovery-export"); + + // Shown in the page, which always works, whatever the browser does + // with a download from an extension popup. + expect(env.hidden("state-recovery-blob")).toBe(false); + expect(JSON.parse(env.value("state-recovery-blob"))).toEqual( + CORRUPT_BLOBS[2].blob, + ); + }); +}); + +describe("the destructive reset on the recovery screen", () => { + test("erases nothing without the typed confirmation", async () => { + const env = await bootPopup(CORRUPT_BLOBS[0].blob); + + env.node("state-recovery-reset-input").value = "yes"; + await env.click("btn-state-recovery-reset"); + + expect(env.storage.read("autistmask")).toEqual(CORRUPT_BLOBS[0].blob); + expect(env.text("state-recovery-flash").length).toBeGreaterThan(10); + expect(env.reloaded()).toBe(0); + }); + + test("erases the stored profile once the phrase is typed", async () => { + const env = await bootPopup(CORRUPT_BLOBS[0].blob); + + env.node("state-recovery-reset-input").value = "erase my wallet"; + await env.click("btn-state-recovery-reset"); + + expect(env.storage.read("autistmask")).toBeUndefined(); + expect(env.reloaded()).toBe(1); + }); +}); + +describe("an unversioned profile that is perfectly valid", () => { + // The upgrade case. Every install in the field is in this state, and the + // popup must load it, not offer to wipe it. + test("boots to the wallet list, not the recovery screen", async () => { + const env = await bootPopup(unversionedValidProfile()); + + expect(env.visibleViews()).toEqual(["main"]); + expect(env.pageErrors).toEqual([]); + }); + + test("is migrated in place: the version is stamped, the wallet survives", async () => { + const env = await bootPopup(unversionedValidProfile()); + + const stored = env.storage.read("autistmask"); + expect(stored.schemaVersion).toBe(1); + expect(stored.wallets).toHaveLength(1); + expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1"); + expect(stored.wallets[0].addresses[0].address).toBe(ADDRESS); + expect(stored.activeAddress).toBe(ADDRESS); + expect(stored.allowedSites).toEqual({ [ADDRESS]: ["dapp.example"] }); + }); +}); + +describe("a first run with nothing in storage", () => { + test("boots to Welcome", async () => { + const env = await bootPopup(undefined); + + expect(env.visibleViews()).toEqual(["welcome"]); + expect(env.pageErrors).toEqual([]); + }); +}); + +describe("a garbage value in a field the gate does not check", () => { + // The gate refuses only what nothing can floor: the wallet list, the + // version, the network key. Everything else is normalizePersisted()'s job, + // and where that job was written as `saved.x || default` rather than a + // type check, a TRUTHY value of the wrong type walked straight through and + // threw on the first dereference — the same blank popup this issue is + // about, measured the same way. Every row below did, at the head named + // against it; none has ever been removed from this list. + // + // These belong on the floor rather than in the gate: none of these values + // carries key material, all have a sane default, and sending a user whose + // wallets are perfectly readable to an export-or-erase screen over a + // broken token list would destroy more than it saves. + // + // The CONTAINER and its ELEMENTS are separate defects. Round 2 floored the + // containers with Array.isArray(), which left every row whose container is + // a well-formed list of malformed entries still blanking the popup: the + // dereference is `t.address.toLowerCase()`, one level below the check. + const CORRUPT_FIELDS = [ + // Container shapes. Observed at 2e2ecf9, before the round-2 floor: + // trackedTokens: "nope" -> views=[] "Cannot read properties of + // undefined (reading 'toLowerCase')" + // trackedTokens: 42 -> views=[] "trackedTokens is not iterable" + // trackedTokens: {a:1} -> views=[] "trackedTokens is not iterable" + // activeAddress: 42 -> views=[] "address.slice is not a + // function" + // activeAddress: {a:1} -> views=[] "address.slice is not a + // function" + { name: "trackedTokens is a string", patch: { trackedTokens: "nope" } }, + { name: "trackedTokens is a number", patch: { trackedTokens: 42 } }, + { + name: "trackedTokens is an object", + patch: { trackedTokens: { a: 1 } }, + }, + { name: "activeAddress is a number", patch: { activeAddress: 42 } }, + { + name: "activeAddress is an object", + patch: { activeAddress: { a: 1 } }, + }, + // Element shapes: a list, holding entries that are not token records. + // Observed at a10a984, AFTER the container floor: + // [1,2] -> views=[] "Cannot read properties of undefined + // (reading 'toLowerCase')" + // [null] -> views=[] "Cannot read properties of null + // (reading 'address')" + // [{}] -> views=[] "Cannot read properties of undefined + // (reading 'toLowerCase')" + // [{address:42}] -> views=[] "t.address.toLowerCase is not a + // function" + // ["0xAA…"] -> views=[] "Cannot read properties of undefined + // (reading 'toLowerCase')" + { + name: "trackedTokens holds numbers", + patch: { trackedTokens: [1, 2] }, + }, + { + name: "trackedTokens holds null", + patch: { trackedTokens: [null] }, + }, + { + name: "trackedTokens holds a record with no address", + patch: { trackedTokens: [{}] }, + }, + { + name: "trackedTokens holds a record whose address is a number", + patch: { trackedTokens: [{ address: 42 }] }, + }, + { + name: "trackedTokens holds bare address strings", + patch: { trackedTokens: [TOKEN_ADDRESS] }, + }, + ]; + + // The same defect one level deeper, inside a wallet the gate accepted. + // tokenBalances is written WHOLESALE by refreshBalances(), so the partial + // write https://git.eeqj.de/sneak/AutistMask/issues/311 names as the live + // cause of a corrupt record lands exactly here. Observed at a10a984: + // "x" -> views=[] "Cannot read properties of undefined (reading + // 'toLowerCase')" (a string iterates as characters) + // [null] -> views=[] "Cannot read properties of null (reading + // 'balance')" + // [42] -> views=[] "Cannot read properties of undefined (reading + // 'toLowerCase')" + // 42 -> views=[] "number 42 is not iterable" + const CORRUPT_TOKEN_BALANCES = [ + { name: "a string", value: "x" }, + { name: "a list holding null", value: [null] }, + { name: "a list of numbers", value: [42] }, + { name: "a number", value: 42 }, + ]; + + function profileWithTokenBalances(value) { + const profile = unversionedValidProfile(); + profile.wallets[0].addresses[0].tokenBalances = value; + return profile; + } + + for (const { name, patch } of CORRUPT_FIELDS) { + test(`${name}: a working popup, not a blank one`, async () => { + const env = await bootPopup( + Object.assign(unversionedValidProfile(), patch), + ); + + expect({ + visibleViews: env.visibleViews(), + errors: env.pageErrors, + }).toEqual({ visibleViews: ["main"], errors: [] }); + }); + } + + for (const { name, value } of CORRUPT_TOKEN_BALANCES) { + test(`an address whose tokenBalances is ${name}: a working popup, not a blank one`, async () => { + const env = await bootPopup(profileWithTokenBalances(value)); + + expect({ + visibleViews: env.visibleViews(), + errors: env.pageErrors, + }).toEqual({ visibleViews: ["main"], errors: [] }); + }); + } + + test("the wallet is intact afterwards, and the bad value is gone", async () => { + const env = await bootPopup( + Object.assign(unversionedValidProfile(), { + trackedTokens: "nope", + activeAddress: 42, + }), + ); + + const stored = env.storage.read("autistmask"); + expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1"); + expect(stored.trackedTokens).toEqual([]); + // Floored to null, then filled in by init()'s auto-default. + expect(stored.activeAddress).toBe(ADDRESS); + }); + + test("an empty activeAddress does not leave the popup with none selected", async () => { + // "" is text, so a type check alone lets it through — and init() + // auto-selects the first address only on a STRICT null, so it has to + // be floored to null rather than kept. + const env = await bootPopup( + Object.assign(unversionedValidProfile(), { activeAddress: "" }), + ); + + expect(env.visibleViews()).toEqual(["main"]); + expect(env.storage.read("autistmask").activeAddress).toBe(ADDRESS); + }); + + test("a malformed token entry is dropped, and the well-formed ones beside it survive", async () => { + const env = await bootPopup( + Object.assign(unversionedValidProfile(), { + trackedTokens: [ + 1, + null, + {}, + { address: 42 }, + TOKEN_ADDRESS, + { address: TOKEN_ADDRESS, symbol: "AAA", decimals: 18 }, + ], + }), + ); + + const stored = env.storage.read("autistmask"); + expect(stored.trackedTokens).toEqual([ + { address: TOKEN_ADDRESS, symbol: "AAA", decimals: 18 }, + ]); + }); + + test("a malformed tokenBalances entry is dropped, and the wallet and its address survive", async () => { + const env = await bootPopup( + profileWithTokenBalances([ + null, + 42, + { address: TOKEN_ADDRESS, symbol: "AAA", balance: "2.0" }, + ]), + ); + + const stored = env.storage.read("autistmask"); + expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1"); + expect(stored.wallets[0].addresses[0].address).toBe(ADDRESS); + expect(stored.wallets[0].addresses[0].tokenBalances).toEqual([ + { address: TOKEN_ADDRESS, symbol: "AAA", balance: "2.0" }, + ]); + }); +}); diff --git a/tests/stateSchema.test.js b/tests/stateSchema.test.js new file mode 100644 index 0000000..d0d375d --- /dev/null +++ b/tests/stateSchema.test.js @@ -0,0 +1,423 @@ +// The stored-profile version stamp and the shape gate in front of it +// (https://git.eeqj.de/sneak/AutistMask/issues/311). +// +// tests/stateRecovery.test.js covers what the USER sees when a record is +// refused. This file covers what is refused and what is not, which is the +// half that decides whether an upgrade brick or a false alarm ever happens: +// a gate that refuses too much sends a perfectly good wallet to a wipe prompt, +// and one that refuses too little is the blank popup again. + +const fs = require("fs"); +const path = require("path"); + +const { + STATE_SCHEMA_VERSION, + StateUnusableError, + assertStateUsable, + migrationNeeded, + stateProblem, +} = require("../src/shared/stateSchema"); +const { + NETWORKS, + UnknownNetworkError, + isKnownNetworkId, + networkById, +} = require("../src/shared/networks"); +const { normalizePersisted } = require("../src/shared/persistedState"); +const { RESET_PHRASE } = require("../src/popup/views/stateRecovery"); +const { makeStorageStub } = require("./support/storageStub"); + +const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; + +function validProfile(extra) { + return { + hasWallet: true, + wallets: [ + { + type: "hd", + name: "Wallet 1", + xpub: "xpub-wallet-1", + encryptedSecret: "encrypted-secret-1", + nextIndex: 1, + addresses: [ + { address: ADDRESS, balance: "1.5", tokenBalances: [] }, + ], + }, + ], + activeAddress: ADDRESS, + networkId: "mainnet", + ...(extra || {}), + }; +} + +afterEach(() => { + delete global.chrome; +}); + +describe("what the gate accepts", () => { + test("nothing stored at all is a first run, not a defect", () => { + expect(stateProblem(undefined)).toBeNull(); + expect(stateProblem(null)).toBeNull(); + expect(stateProblem({})).toBeNull(); + }); + + test("a valid profile with no version field is accepted and migrated", () => { + const saved = validProfile(); + + expect(stateProblem(saved)).toBeNull(); + expect(migrationNeeded(saved)).toBe(true); + // The migration IS the stamp: version 1 is the shape that shipped + // unversioned, so nothing about the record has to change. + expect(normalizePersisted(saved).schemaVersion).toBe( + STATE_SCHEMA_VERSION, + ); + expect(normalizePersisted(saved).wallets).toEqual(saved.wallets); + }); + + test("a profile already at the current version needs no migration", () => { + const saved = validProfile({ schemaVersion: STATE_SCHEMA_VERSION }); + + expect(stateProblem(saved)).toBeNull(); + expect(migrationNeeded(saved)).toBe(false); + }); + + test("an empty wallet list is fine", () => { + expect(stateProblem({ hasWallet: false, wallets: [] })).toBeNull(); + }); + + test("unknown extra fields alone are not a defect", () => { + // Only a change in the MEANING of a stored field is a version bump, so + // a field this build does not know must not be a refusal on its own — + // otherwise a downgrade would wipe a working wallet. + expect(stateProblem(validProfile({ somethingNew: 42 }))).toBeNull(); + }); +}); + +describe("what the gate refuses", () => { + test("a record that is not the record AutistMask stores", () => { + expect(stateProblem("wallet")).toMatch(/not the record/); + expect(stateProblem([1, 2])).toMatch(/not the record/); + }); + + test("a version from a newer build, naming both versions", () => { + const problem = stateProblem( + validProfile({ schemaVersion: STATE_SCHEMA_VERSION + 1 }), + ); + + expect(problem).toMatch(/newer version/); + expect(problem).toContain(String(STATE_SCHEMA_VERSION + 1)); + expect(problem).toContain(String(STATE_SCHEMA_VERSION)); + }); + + test("a version that is not a version at all", () => { + for (const version of ["1", 1.5, 0, -1, null, {}]) { + expect( + stateProblem(validProfile({ schemaVersion: version })), + ).toMatch(/schema version/); + } + }); + + test("wallets that is not a list", () => { + expect(stateProblem({ wallets: ADDRESS })).toMatch(/not a list/); + expect(stateProblem({ wallets: { 0: {} } })).toMatch(/not a list/); + }); + + test("a wallet that is not a wallet record", () => { + expect(stateProblem({ wallets: [null] })).toMatch(/wallet record/); + expect(stateProblem({ wallets: [42] })).toMatch(/wallet record/); + }); + + test("a wallet whose addresses are missing or not records", () => { + expect(stateProblem({ wallets: [{ name: "Wallet 1" }] })).toMatch( + /no list of addresses/, + ); + expect(stateProblem({ wallets: [{ addresses: [ADDRESS] }] })).toMatch( + /not a record/, + ); + expect(stateProblem({ wallets: [{ addresses: [{}] }] })).toMatch( + /no address/, + ); + }); + + test("the problem names WHICH wallet, counting from one", () => { + const problem = stateProblem({ + wallets: [validProfile().wallets[0], { name: "Wallet 2" }], + }); + + expect(problem).toContain("Wallet 2"); + }); + + test("assertStateUsable throws the sentence, not a generic error", () => { + let thrown = null; + try { + assertStateUsable({ wallets: ADDRESS }); + } catch (e) { + thrown = e; + } + + expect(thrown).toBeInstanceOf(StateUnusableError); + expect(thrown.problem).toMatch(/not a list/); + expect(thrown.message).toBe(thrown.problem); + }); +}); + +describe("networkId, which is used as an object key", () => { + // https://git.eeqj.de/sneak/AutistMask/issues/311#issuecomment-67478: + // state.networkId keys state.networkEndpoints, so a corrupt "__proto__" + // sets that map's prototype instead of an own key and the user's endpoint + // is silently not recorded. + test("a network this build does not know is refused", () => { + expect(stateProblem(validProfile({ networkId: "base" }))).toMatch( + /does not know/, + ); + }); + + test('"__proto__" and "constructor" are refused, not resolved', () => { + for (const id of ["__proto__", "constructor", "toString"]) { + expect(stateProblem(validProfile({ networkId: id }))).toMatch( + /does not know/, + ); + expect(isKnownNetworkId(id)).toBe(false); + } + }); + + test("the gate reads own properties only", () => { + // A record whose PROTOTYPE carries the fields must not be read as + // though it carried them itself: that is how a polluted prototype + // would decide whether a profile is refused. + const inherited = Object.create({ + schemaVersion: STATE_SCHEMA_VERSION + 99, + networkId: "base", + wallets: "not a list", + }); + + expect(stateProblem(inherited)).toBeNull(); + }); + + test("normalizing never turns a stored key into a prototype", () => { + // JSON can carry an own "__proto__" key, and plain assignment would + // treat it as the prototype setter rather than storing an entry. + const saved = JSON.parse( + '{"networkId":"mainnet","networkEndpoints":' + + '{"__proto__":{"rpcUrl":"https://evil.invalid"}}}', + ); + + const out = normalizePersisted(saved); + + expect(Object.prototype.hasOwnProperty.call(out, "rpcUrl")).toBe(true); + expect(out.rpcUrl).not.toBe("https://evil.invalid"); + expect(Object.getPrototypeOf(out.networkEndpoints)).toBe( + Object.prototype, + ); + expect({}.rpcUrl).toBeUndefined(); + }); + + test("an unknown stored networkId never reaches the endpoint map", () => { + // The floor under the gate: normalization alone must not adopt it. + const out = normalizePersisted({ networkId: "__proto__" }); + + expect(out.networkId).toBe("mainnet"); + expect(Object.keys(out.networkEndpoints)).toEqual(["mainnet"]); + }); +}); + +describe("the floors under the gate, for fields the gate does not check", () => { + // The gate's scope is what nothing can floor. Everything it lets through + // is normalizePersisted()'s to make safe, and a floor written as + // `saved.x || default` is not one: a truthy value of the wrong type walks + // through it and throws on the first dereference, which produced the blank + // popup from the issue. Nor is a container check on its own: [1, 2] is a + // list, and the dereference is `t.address.toLowerCase()` one level below + // it. Container AND entries, therefore — an empty list still survives. + const TOKEN = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + + test("trackedTokens that is not a list becomes an empty list", () => { + for (const bad of ["nope", 42, true, { a: 1 }]) { + expect( + normalizePersisted({ trackedTokens: bad }).trackedTokens, + ).toEqual([]); + } + }); + + test("a trackedTokens entry that is not a token record is dropped", () => { + for (const bad of [1, null, {}, { address: 42 }, TOKEN, [], true]) { + expect( + normalizePersisted({ trackedTokens: [bad] }).trackedTokens, + ).toEqual([]); + } + }); + + test("a real trackedTokens list survives, copied not shared", () => { + const saved = { trackedTokens: [{ address: ADDRESS, symbol: "AM" }] }; + + const out = normalizePersisted(saved); + + expect(out.trackedTokens).toEqual(saved.trackedTokens); + expect(out.trackedTokens).not.toBe(saved.trackedTokens); + expect(normalizePersisted({ trackedTokens: [] }).trackedTokens).toEqual( + [], + ); + }); + + test("a good trackedTokens entry beside a malformed one survives", () => { + const good = { address: TOKEN, symbol: "AM", decimals: 18 }; + + expect( + normalizePersisted({ trackedTokens: [1, null, good, {}] }) + .trackedTokens, + ).toEqual([good]); + }); + + // Below an address record, which the gate walks but does not descend into. + // refreshBalances() writes tokenBalances WHOLESALE, so a write that only + // partly lands leaves exactly this field malformed. + function walletWith(tokenBalances) { + return { + wallets: [ + { + name: "Wallet 1", + addresses: [{ address: ADDRESS, tokenBalances }], + }, + ], + }; + } + + function balancesOf(out) { + return out.wallets[0].addresses[0].tokenBalances; + } + + test("an address's tokenBalances that is not a list becomes an empty list", () => { + for (const bad of ["x", 42, true, { a: 1 }, undefined]) { + expect(balancesOf(normalizePersisted(walletWith(bad)))).toEqual([]); + } + }); + + test("a tokenBalances entry that is not a token record is dropped", () => { + for (const bad of [null, 42, "x", {}, { address: 42 }]) { + expect(balancesOf(normalizePersisted(walletWith([bad])))).toEqual( + [], + ); + } + }); + + test("a real tokenBalances entry survives, copied not shared", () => { + const held = { address: TOKEN, symbol: "AM", balance: "2.0" }; + const saved = walletWith([held]); + + const out = normalizePersisted(saved); + + expect(balancesOf(out)).toEqual([held]); + expect(balancesOf(out)[0]).not.toBe(held); + }); + + test("activeAddress that is not text becomes null", () => { + for (const bad of [42, true, { a: 1 }, [ADDRESS]]) { + expect( + normalizePersisted({ activeAddress: bad }).activeAddress, + ).toBeNull(); + } + }); + + test("a real activeAddress survives; the empty string becomes null", () => { + expect( + normalizePersisted({ activeAddress: ADDRESS }).activeAddress, + ).toBe(ADDRESS); + // "" is text but it is not an address, and src/popup/index.js + // auto-selects the first address only on a STRICT null — so keeping + // the empty string would leave the popup with none ever selected. + expect( + normalizePersisted({ activeAddress: "" }).activeAddress, + ).toBeNull(); + }); +}); + +describe("networkById on an unknown id", () => { + test("throws instead of quietly answering mainnet", () => { + expect(() => networkById("base")).toThrow(UnknownNetworkError); + expect(() => networkById(undefined)).toThrow(UnknownNetworkError); + // Both of these used to answer with something truthy off the + // prototype chain rather than with a network. + expect(() => networkById("constructor")).toThrow(UnknownNetworkError); + expect(() => networkById("__proto__")).toThrow(UnknownNetworkError); + }); + + test("still answers every network it does know", () => { + for (const id of Object.keys(NETWORKS)) { + expect(networkById(id).id).toBe(id); + } + }); +}); + +describe("the version stamp on the way out", () => { + function loadStateModule(persisted) { + jest.resetModules(); + const storage = makeStorageStub( + persisted ? { autistmask: persisted } : {}, + ); + global.chrome = { storage }; + return { storage, mod: require("../src/shared/state") }; + } + + test("the popup stamps it on a profile that had none", async () => { + const { storage, mod } = loadStateModule(validProfile()); + + await mod.loadState(); + await mod.saveState(); + + expect(storage.read("autistmask").schemaVersion).toBe( + STATE_SCHEMA_VERSION, + ); + }); + + test("the background stamps it on a profile that had none", async () => { + jest.resetModules(); + const storage = makeStorageStub({ autistmask: validProfile() }); + global.chrome = { storage }; + const { updateState } = require("../src/background/state"); + + await updateState((s) => { + s.lastBalanceRefresh = 1; + }); + + expect(storage.read("autistmask").schemaVersion).toBe( + STATE_SCHEMA_VERSION, + ); + }); + + test("a save refuses to write over a record it cannot read", async () => { + // Another context — a newer build, or something else entirely — wrote + // a record this one does not understand while this page was open. That + // record is the only copy of whatever it holds, and normalizing it + // back into storage would destroy it. + const { storage, mod } = loadStateModule(validProfile()); + await mod.loadState(); + + const hostile = { schemaVersion: STATE_SCHEMA_VERSION + 1 }; + storage.write("autistmask", hostile); + + // By name rather than by constructor: jest.resetModules() above gives + // the module under test its own copy of the error class, so instanceof + // across that boundary would be comparing two identical classes. + const thrown = await mod.saveState().then( + () => null, + (e) => e, + ); + expect(thrown && thrown.name).toBe("StateUnusableError"); + expect(thrown.problem).toMatch(/newer version/); + expect(storage.read("autistmask")).toEqual(hostile); + }); +}); + +describe("the typed confirmation phrase", () => { + test("the markup asks for the phrase the code checks", () => { + // The button is behind a phrase typed by hand. A screen that asks for + // one phrase while the code compares another is an exit that cannot be + // taken, on the one screen that exists to be an exit. + const html = fs.readFileSync( + path.join(__dirname, "..", "src", "popup", "index.html"), + "utf8", + ); + + expect(html).toContain(RESET_PHRASE); + }); +}); diff --git a/tests/stateUnusableRpc.test.js b/tests/stateUnusableRpc.test.js new file mode 100644 index 0000000..66fd70a --- /dev/null +++ b/tests/stateUnusableRpc.test.js @@ -0,0 +1,203 @@ +// What a dApp is told when the wallet's stored profile cannot be read +// (https://git.eeqj.de/sneak/AutistMask/issues/311). +// +// The popup is not the only casualty of a bad blob. getActiveAddress() +// dereferences the stored wallet list on nearly every method, so against the +// build this file was added to, EVERY request from EVERY page came back as +// -32603 "AutistMask could not complete this request because of an internal +// error" — the code the wallet also answers when a signing attempt blows up, +// with nothing in it to tell the page or the user what is actually wrong or +// what to do about it. +// +// So what is pinned here is that the answer is SPECIFIC: its own code, and a +// message that says the saved data cannot be read, that nothing was signed or +// sent, and where to go to fix it. +// +// Same cold-worker shape as tests/coldWorkerChainId.test.js: the real state +// modules, over a storage stub, with no loadState() of the test's own — the +// handler has to reach storage by itself, as a worker revived by the page's +// own message does. + +const CONNECTED_ORIGIN = "https://dapp.example"; +const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; + +// The generic answer, quoted rather than imported: this file's whole point is +// that the state-unusable path stopped using it. +const GENERIC_INTERNAL_ERROR_CODE = -32603; + +// EIP-1474's assigned non-standard codes, verbatim. The spec sets aside +// -32000..-32099 for implementation-defined server errors but hands out +// meanings for the first seven, so those are exactly the codes this condition +// may NOT take: a page reading -32001 is entitled to read "Resource not +// found". -32002 is in this table AND in use here, for a pending approval. +const EIP_1474_ASSIGNED = { + "-32000": "Invalid input", + "-32001": "Resource not found", + "-32002": "Resource unavailable", + "-32003": "Transaction rejected", + "-32004": "Method not supported", + "-32005": "Limit exceeded", + "-32006": "JSON-RPC version not supported", +}; + +// The three blobs from the issue. +const CORRUPT_BLOBS = [ + { + name: "wallets is a string", + blob: { hasWallet: true, wallets: ADDRESS }, + }, + { + name: "wallets is an array of garbage", + blob: { hasWallet: true, wallets: [null, 42, "wallet"] }, + }, + { + name: "future-schema blob (unknown fields, no version)", + blob: { + hasWallet: true, + wallets: [{ id: "wallet-1", accounts: [{ addr: ADDRESS }] }], + profileFormat: "am-2", + }, + }, +]; + +async function settle() { + for (let i = 0; i < 50; i++) await Promise.resolve(); +} + +afterEach(() => { + delete global.chrome; +}); + +function loadColdWorker(stored) { + jest.resetModules(); + + jest.doMock("../src/shared/balances", () => ({ + getProvider: () => ({}), + refreshBalances: jest.fn(async () => {}), + })); + jest.doMock("../src/shared/phishingDomains", () => ({ + isPhishingDomain: () => false, + })); + jest.doMock("../src/shared/alarms", () => ({ + BALANCE_REFRESH_ALARM: "balance", + BALANCE_REFRESH_PERIOD_MINUTES: 1, + ensureRecurringAlarms: jest.fn(async () => {}), + registerAlarmHandlers: jest.fn(), + })); + + const store = { autistmask: structuredClone(stored) }; + let messageListener = null; + const set = jest.fn(async (items) => { + store.autistmask = structuredClone(items.autistmask); + }); + + global.chrome = { + storage: { + local: { + get: jest.fn(async () => structuredClone(store)), + set, + }, + }, + runtime: { + getURL: (p) => "chrome-extension://autistmask/" + p, + onMessage: { + addListener: (fn) => { + messageListener = fn; + }, + }, + onConnect: { addListener: () => {} }, + lastError: null, + }, + windows: { + getLastFocused: (cb) => cb(null), + create: (options, cb) => cb({ id: 1 }), + remove: (id, cb) => { + if (cb) cb(); + }, + onRemoved: { addListener: () => {} }, + }, + tabs: { + query: (queryInfo, cb) => cb([{ id: 1 }]), + sendMessage: (tabId, message, cb) => { + if (cb) cb(); + }, + }, + action: { setPopup: () => {} }, + }; + + require("../src/background/index"); + + async function rpc(method, params) { + let result = null; + messageListener( + { type: "AUTISTMASK_RPC", method, params: params || [] }, + { origin: CONNECTED_ORIGIN }, + (r) => { + result = r; + }, + ); + await settle(); + return result; + } + + return { rpc, persisted: () => store.autistmask, storageSet: set }; +} + +// Every method a page can reach that has to consult the profile. +const METHODS = [ + "eth_accounts", + "eth_requestAccounts", + "eth_chainId", + "personal_sign", + "eth_sendTransaction", +]; + +describe("a dApp call against a profile the wallet cannot read", () => { + for (const { name, blob } of CORRUPT_BLOBS) { + test(`${name}: a specific error, not the generic internal one`, async () => { + const bg = loadColdWorker(blob); + + const answer = await bg.rpc("eth_accounts"); + + expect(answer.error).toBeDefined(); + expect(answer.error.code).not.toBe(GENERIC_INTERNAL_ERROR_CODE); + // The message has to say what is wrong, that nothing was sent, + // and where to go. "Internal error" says none of the three. + expect(answer.error.message).toMatch(/saved data/i); + expect(answer.error.message).toMatch(/nothing was/i); + expect(answer.error.message).toMatch(/AutistMask/); + }); + } + + test("every method that consults the profile answers the same way", async () => { + const bg = loadColdWorker(CORRUPT_BLOBS[0].blob); + + const codes = new Set(); + for (const method of METHODS) { + const answer = await bg.rpc(method, ["0x00", ADDRESS]); + expect(answer.error).toBeDefined(); + codes.add(answer.error.code); + } + + // One code for the condition, whatever the method was. + expect(codes.size).toBe(1); + expect(codes.has(GENERIC_INTERNAL_ERROR_CODE)).toBe(false); + + // And it is a code EIP-1474 has not already given a meaning to, so a + // page reading it by the spec's table is not told something false. + const code = [...codes][0]; + expect(EIP_1474_ASSIGNED[String(code)]).toBeUndefined(); + expect(code).toBeLessThanOrEqual(-32007); + expect(code).toBeGreaterThanOrEqual(-32099); + }); + + test("it does not write over the record it could not read", async () => { + const bg = loadColdWorker(CORRUPT_BLOBS[1].blob); + + await bg.rpc("eth_accounts"); + await bg.rpc("eth_chainId"); + + expect(bg.storageSet).not.toHaveBeenCalled(); + expect(bg.persisted()).toEqual(CORRUPT_BLOBS[1].blob); + }); +}); diff --git a/tests/txStatus.test.js b/tests/txStatus.test.js index 2f92947..267aabe 100644 --- a/tests/txStatus.test.js +++ b/tests/txStatus.test.js @@ -107,7 +107,7 @@ global.chrome = { storage }; const txStatus = require("../src/popup/views/txStatus"); const { state } = require("../src/shared/state"); -const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews"); +const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews"); const TX_HASH = "0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84";