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.
This commit was merged in pull request #360.
This commit is contained in:
@@ -1761,6 +1761,75 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ STATE RECOVERY ============ -->
|
||||
<!--
|
||||
Shown when the stored profile cannot be read at all. Every
|
||||
other screen renders from that profile, so this one is reached
|
||||
without one and is the only way out of a wallet that would
|
||||
otherwise be a blank popup.
|
||||
-->
|
||||
<div id="view-state-recovery" class="view hidden">
|
||||
<h2 class="font-bold mb-2">Saved Data Cannot Be Read</h2>
|
||||
<p class="text-xs mb-2">
|
||||
AutistMask stopped rather than guessing. Nothing has been
|
||||
changed or erased, and nothing can be signed or sent until
|
||||
this is resolved.
|
||||
</p>
|
||||
<div
|
||||
id="state-recovery-problem"
|
||||
class="text-xs font-bold mb-3 break-words"
|
||||
></div>
|
||||
<p class="text-xs mb-2">
|
||||
Export the saved data first and keep it. It may hold the
|
||||
encrypted keys for your wallets, and it is the only copy.
|
||||
</p>
|
||||
<button
|
||||
id="btn-state-recovery-export"
|
||||
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
|
||||
>
|
||||
Export Saved Data
|
||||
</button>
|
||||
<textarea
|
||||
id="state-recovery-blob"
|
||||
readonly
|
||||
class="hidden border border-border p-1 w-full h-32 font-mono text-xs bg-bg text-fg mt-2"
|
||||
></textarea>
|
||||
<p class="text-xs mt-3 mb-2">
|
||||
<strong
|
||||
>Erasing the saved data deletes every wallet stored in
|
||||
this browser.</strong
|
||||
>
|
||||
Nothing on the blockchain changes and no money is moved, but
|
||||
without the exported copy above, or the recovery phrase for
|
||||
each wallet written down, everything they hold is gone
|
||||
forever.
|
||||
</p>
|
||||
<p class="text-xs mb-1">
|
||||
To confirm, type
|
||||
<strong>ERASE MY WALLET</strong>
|
||||
below.
|
||||
</p>
|
||||
<div class="mb-2">
|
||||
<input
|
||||
type="text"
|
||||
id="state-recovery-reset-input"
|
||||
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
||||
placeholder="Type ERASE MY WALLET"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="state-recovery-flash"
|
||||
class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
|
||||
style="visibility: hidden"
|
||||
></div>
|
||||
<button
|
||||
id="btn-state-recovery-reset"
|
||||
class="border border-border text-red-500 px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
|
||||
>
|
||||
Erase Saved Data
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="index.js"></script>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
// Views the popup may reopen onto.
|
||||
//
|
||||
// The popup persists the current view so that reopening the toolbar popup
|
||||
// lands the user back where they were. Only views that can be fully
|
||||
// re-rendered from persisted state belong here; every other view falls back
|
||||
// to the nearest restorable parent (src/popup/index.js restoreView()).
|
||||
//
|
||||
// A view that displays a secret must NEVER be listed. Restoring onto one
|
||||
// would put a private key or a recovery phrase on screen with no password
|
||||
// prompt in front of it, on a popup the user may have reopened by accident.
|
||||
// That is why "export-privkey" and "show-phrase" are absent.
|
||||
//
|
||||
// Nor may a view whose button destroys a wallet be listed, for the mirror
|
||||
// reason: a popup reopened by accident must not land on the screen that
|
||||
// erases key material. That is why "delete-wallet-confirm" and
|
||||
// "delete-wallet-lost-password" are absent.
|
||||
//
|
||||
// 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.
|
||||
const RESTORABLE_VIEWS = new Set([
|
||||
"main",
|
||||
"address",
|
||||
"address-token",
|
||||
"receive",
|
||||
"settings",
|
||||
"settings-addtoken",
|
||||
"confirm-tx",
|
||||
"transaction",
|
||||
"wait-tx",
|
||||
"success-tx",
|
||||
"error-tx",
|
||||
]);
|
||||
|
||||
module.exports = { RESTORABLE_VIEWS };
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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
|
||||
|
||||
197
src/popup/views/stateRecovery.js
Normal file
197
src/popup/views/stateRecovery.js
Normal file
@@ -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 };
|
||||
Reference in New Issue
Block a user