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.
182 lines
6.0 KiB
JavaScript
182 lines
6.0 KiB
JavaScript
// 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;
|
|
|
|
// 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);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|