fix: version the stored profile, and give a record that cannot be read a way out (closes #311)
All checks were successful
check / check (push) Successful in 36s
e2e / e2e-chrome (push) Successful in 1m49s
e2e / e2e-firefox (push) Successful in 38s

The stored profile carried no version, so nothing could tell a record this build wrote from one a later build did, and loadState() coerced scalars while trusting the structure. A wallets that was a string, an array of nulls, or a later schema's wallet records reached the popup and threw on the first dereference: no view, no message, no control, and every dApp call answering a generic -32603 because getActiveAddress() dereferenced the same record. There was no reset or wipe control anywhere in the product, so the only escape was clearing extension storage through browser internals.

saveState() and updateState() now both stamp STATE_SCHEMA_VERSION, and every read goes through assertStateUsable() on the raw bytes before normalization can 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, and refused all the way: not normalized, not written back, not half-loaded, and not overwritten by a save either.

The popup shows a new StateRecovery screen. It names the problem in a sentence, exports the raw record verbatim into a text box on the page (and downloads it where the browser allows one), and offers an erase behind a typed ERASE MY WALLET. Both controls are required: 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 Settings gear is hidden while it is up, and showView() is not used to raise it, because both read the state singleton that by then refuses to be read.

The background refuses the same record and answers dApps -32001 with a message saying the saved data cannot be read and that nothing was signed or sent, rather than the -32603 it also answers when a signing attempt breaks.

networkById() now throws on an id it does not know instead of quietly answering mainnet, which also stops NETWORKS["constructor"] resolving off the prototype chain. Every key test in the gate is an own-property test, because networkId is an object key into networkEndpoints and an unvalidated "__proto__" set that map's prototype instead of an own key, dropping the user's endpoint silently; normalizePersisted() copies endpoint entries with defineProperty for the same reason.

The three corrupt blobs from the issue drive the real popup entry point and the real worker in tests; each rendered nothing at all and answered -32603 before this, and the unversioned-but-valid case is tested too. Three test files used fixture wallets the product cannot produce (a bare address string where an address record belongs, a wallet with no address list) and now use whole records. src/popup/restorableViews.js moved to src/shared/restorableViews.js, since persistedState.js requires it and that module is in the background bundle.
This commit is contained in:
2026-08-23 16:23:58 +00:00
parent 28a527295a
commit 2e2ecf9f78
25 changed files with 1810 additions and 48 deletions

239
src/shared/stateSchema.js Normal file
View File

@@ -0,0 +1,239 @@
// 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 the rest of the code dereferences without a
// floor of its own. Everything else has one in normalizePersisted() and does
// not need a second.
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. Reading `saved.wallets`
// again below would consult the prototype chain for a record that carries
// no wallets of its own, so what gets validated would not be what gets
// loaded.
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,
};