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 gate covers what nothing downstream can floor. Everything else is normalizePersisted()'s job, and two of those floors were written on truthiness rather than on type, so a truthy value of the wrong type walked straight through and threw on the first dereference — the same blank popup, by a longer route. trackedTokens: "nope" rendered nothing with "Cannot read properties of undefined (reading 'toLowerCase')", and activeAddress: 42 rendered nothing with "address.slice is not a function", for profiles whose wallets were perfectly readable. Both are type checks now, matching what networkEndpoints and viewStack already did in the same file, and an empty list or an empty string still survives. The popup shows a new StateRecovery screen. It names the problem in a sentence, exports the stored record into a text box on the page with no normalization or repair on it (and downloads it where the browser allows), 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 export is JSON.stringify of the deserialized record, so a value JSON cannot represent — a cycle, or a BigInt, which Firefox's storage can hold — fails the export entirely and leaves erase as the only control; that is now stated where the export is written. The background refuses the same record and answers dApps -32007 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. EIP-1474 sets aside -32000..-32099 for implementation-defined server errors but assigns meanings to -32000 through -32006, including -32001 "Resource not found" and the -32002 "Resource unavailable" this wallet already uses for a pending approval; -32007..-32099 are the unassigned ones, and a test pins the code against that table. 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. That own-property discipline is the gate's alone — normalizePersisted() reads the same fields plainly, and the two agree only because a record from storage has been through structuredClone and carries Object.prototype. 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.
478 lines
19 KiB
JavaScript
478 lines
19 KiB
JavaScript
// The lost-password route off the delete-wallet screen (issue #312).
|
|
//
|
|
// What is pinned here is that a user who has forgotten the password can
|
|
// still get out — no password is asked for and none is checked — and that
|
|
// the escape hatch destroys exactly the wallet it names and nothing else.
|
|
// The second half is the dangerous one: this is the only control in the
|
|
// product that erases key material without the password that encrypted it,
|
|
// so an off-by-one in the wallet it removes would take a wallet whose
|
|
// owner never asked for it to be touched.
|
|
//
|
|
// The assertions are made against what came back OUT of extension storage,
|
|
// not against the live `state` object. Deleting a wallet in memory and
|
|
// never persisting it looks identical from `state`, and a build that never
|
|
// wrote at all would pass a check that only reads `state` back.
|
|
//
|
|
// That makes the storage stub load-bearing, so it is the shared one from
|
|
// tests/support/storageStub.js, a real store that structured-clones on both
|
|
// `set` and `get`. A stub whose `get` hands back the same object its `set`
|
|
// was given aliases the caller's own array: the test then reads its own
|
|
// in-memory mutation and calls it persistence, and passes against a build
|
|
// that persists nothing
|
|
// (https://git.eeqj.de/sneak/AutistMask/issues/324). The aliasing is closed
|
|
// off explicitly by the first test below rather than left as an assumption
|
|
// about `structuredClone`.
|
|
//
|
|
// The view is driven against a minimal DOM stub, in the same shape as
|
|
// tests/exportPrivkey.test.js: the module reads and writes named nodes and
|
|
// needs nothing else from a document.
|
|
|
|
const mockSettingsShow = jest.fn();
|
|
jest.mock("../src/popup/views/settings", () => ({
|
|
show: mockSettingsShow,
|
|
}));
|
|
jest.mock("../src/shared/vault", () => ({
|
|
decryptWithPassword: jest.fn(),
|
|
}));
|
|
|
|
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
|
|
const { makeStorageStub } = require("./support/storageStub");
|
|
|
|
const VIEW = "delete-wallet-lost-password";
|
|
|
|
// Fixed addresses — never used for anything but these tests.
|
|
const A0 = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
|
const A1 = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
|
|
const B0 = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599";
|
|
const C0 = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
|
|
|
// ------------------------------------------------------------ DOM stub
|
|
|
|
function makeElement(id) {
|
|
const classes = new Set();
|
|
const el = {
|
|
id,
|
|
textContent: "",
|
|
value: "",
|
|
innerHTML: "",
|
|
disabled: false,
|
|
style: {},
|
|
dataset: {},
|
|
listeners: {},
|
|
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);
|
|
},
|
|
appendChild: () => {},
|
|
remove: () => {},
|
|
querySelectorAll: () => [],
|
|
};
|
|
return el;
|
|
}
|
|
|
|
function makeDocument() {
|
|
const els = new Map();
|
|
return {
|
|
getElementById(id) {
|
|
// The debug banner is created on demand by helpers.js; 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: () => makeElement("created"),
|
|
addEventListener: () => {},
|
|
body: { prepend: () => {} },
|
|
};
|
|
}
|
|
|
|
// ------------------------------------------------------------ harness
|
|
|
|
function wallet(name, secret, addresses) {
|
|
return {
|
|
type: "hd",
|
|
name,
|
|
xpub: "xpub-" + name,
|
|
encryptedSecret: secret,
|
|
nextIndex: addresses.length,
|
|
addresses: addresses.map((address) => ({
|
|
address,
|
|
balance: "0.0000",
|
|
tokenBalances: [],
|
|
})),
|
|
};
|
|
}
|
|
|
|
function load() {
|
|
jest.resetModules();
|
|
mockSettingsShow.mockClear();
|
|
|
|
const storage = makeStorageStub();
|
|
const sent = [];
|
|
globalThis.chrome = {
|
|
storage: { local: storage.local },
|
|
runtime: { sendMessage: (msg) => sent.push(msg) },
|
|
};
|
|
globalThis.document = makeDocument();
|
|
|
|
const helpers = require("../src/popup/views/helpers");
|
|
const { state } = require("../src/shared/state");
|
|
const vault = require("../src/shared/vault");
|
|
const deleteWallet = require("../src/popup/views/deleteWallet");
|
|
|
|
state.hasWallet = true;
|
|
state.wallets = [
|
|
wallet("Wallet 1", "secret-one", [A0, A1]),
|
|
wallet("Wallet 2", "secret-two", [B0]),
|
|
wallet("Wallet 3", "secret-three", [C0]),
|
|
];
|
|
state.selectedWallet = 0;
|
|
state.selectedAddress = 0;
|
|
state.activeAddress = A0;
|
|
state.allowedSites = { [A0]: ["a.example"], [B0]: ["b.example"] };
|
|
state.deniedSites = { [B0]: ["c.example"], [C0]: ["d.example"] };
|
|
state.viewStack = ["main", "settings"];
|
|
state.currentView = "settings";
|
|
|
|
const renderWalletList = jest.fn();
|
|
deleteWallet.init({ renderWalletList });
|
|
|
|
return { helpers, state, vault, deleteWallet, storage, sent };
|
|
}
|
|
|
|
function click(id) {
|
|
const el = globalThis.document.getElementById(id);
|
|
return Promise.all((el.listeners.click || []).map((fn) => fn()));
|
|
}
|
|
|
|
function node(id) {
|
|
return globalThis.document.getElementById(id);
|
|
}
|
|
|
|
// The wallets as the extension would read them back on a cold start.
|
|
async function persistedWallets(storage) {
|
|
const result = await storage.get("autistmask");
|
|
return result.autistmask.wallets;
|
|
}
|
|
|
|
// Open the lost-password screen for a wallet, the way the user does.
|
|
async function openLostPassword(deleteWallet, walletIdx) {
|
|
deleteWallet.show(walletIdx);
|
|
await click("btn-delete-wallet-lost-password");
|
|
}
|
|
|
|
// ------------------------------------------------------------ tests
|
|
|
|
// The stub is what every persistence assertion below rests on, so its one
|
|
// dangerous failure mode is closed off first. An aliasing store passes
|
|
// every other test in this file against a build that never writes.
|
|
describe("the storage stub", () => {
|
|
test("does not hand back the object it was given", async () => {
|
|
const storage = makeStorageStub();
|
|
const written = { wallets: [{ name: "Wallet 1" }] };
|
|
|
|
await storage.set({ autistmask: written });
|
|
written.wallets.push({ name: "Wallet 2" });
|
|
written.wallets[0].name = "renamed after the write";
|
|
|
|
const readBack = (await storage.get("autistmask")).autistmask;
|
|
expect(readBack.wallets).toHaveLength(1);
|
|
expect(readBack.wallets[0].name).toBe("Wallet 1");
|
|
|
|
// And the other direction: mutating what came out must not reach
|
|
// back into the store.
|
|
readBack.wallets[0].name = "renamed after the read";
|
|
const again = (await storage.get("autistmask")).autistmask;
|
|
expect(again.wallets[0].name).toBe("Wallet 1");
|
|
});
|
|
});
|
|
|
|
describe("reaching the screen", () => {
|
|
test("the delete screen offers the route", async () => {
|
|
const { deleteWallet, state } = load();
|
|
await openLostPassword(deleteWallet, 1);
|
|
|
|
expect(state.currentView).toBe(VIEW);
|
|
expect(node("delete-wallet-lost-name").textContent).toBe("Wallet 2");
|
|
expect(node("delete-wallet-lost-name-echo").textContent).toBe(
|
|
"Wallet 2",
|
|
);
|
|
});
|
|
|
|
// Both delete screens hang off Settings. Pushing one onto the other
|
|
// would leave Back on the confirm screen popping onto itself.
|
|
test("it does not push the screen it came from", async () => {
|
|
const { deleteWallet, state } = load();
|
|
await openLostPassword(deleteWallet, 1);
|
|
|
|
expect(state.viewStack).toEqual(["main", "settings"]);
|
|
});
|
|
|
|
test("Back returns to the delete screen with its wallet still chosen", async () => {
|
|
const { deleteWallet, state } = load();
|
|
await openLostPassword(deleteWallet, 1);
|
|
await click("btn-delete-wallet-lost-back");
|
|
|
|
expect(state.currentView).toBe("delete-wallet-confirm");
|
|
expect(node("delete-wallet-name").textContent).toBe("Wallet 2");
|
|
expect(state.viewStack).toEqual(["main", "settings"]);
|
|
|
|
// The confirm screen is usable, not merely on screen: the wallet
|
|
// it holds is the one that was chosen, so its own button does not
|
|
// answer "No wallet selected for deletion."
|
|
node("delete-wallet-password").value = "some password";
|
|
const { decryptWithPassword } = require("../src/shared/vault");
|
|
decryptWithPassword.mockRejectedValue(new Error("nope"));
|
|
await click("btn-delete-wallet-confirm");
|
|
expect(node("delete-wallet-flash").textContent).toBe(
|
|
"That password is incorrect. Please try again.",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("the typed confirmation", () => {
|
|
test("a name that is not the wallet's deletes nothing", async () => {
|
|
const { deleteWallet, state, storage } = load();
|
|
await openLostPassword(deleteWallet, 1);
|
|
|
|
node("delete-wallet-lost-name-input").value = "Wallet 3";
|
|
await click("btn-delete-wallet-lost-confirm");
|
|
|
|
expect(node("delete-wallet-lost-flash").textContent).toBe(
|
|
"That is not the name of this wallet. Type Wallet 2 to confirm.",
|
|
);
|
|
expect(node("delete-wallet-lost-flash").style.visibility).toBe(
|
|
"visible",
|
|
);
|
|
expect(state.wallets.map((w) => w.name)).toEqual([
|
|
"Wallet 1",
|
|
"Wallet 2",
|
|
"Wallet 3",
|
|
]);
|
|
expect(state.currentView).toBe(VIEW);
|
|
// Nothing was destroyed on disk either. Storage is not empty —
|
|
// showView() persists the current screen on the way in — so what
|
|
// is asserted is that all three wallets are still in it.
|
|
const persisted = await persistedWallets(storage);
|
|
expect(persisted.map((w) => w.encryptedSecret)).toEqual([
|
|
"secret-one",
|
|
"secret-two",
|
|
"secret-three",
|
|
]);
|
|
});
|
|
|
|
test("an empty field deletes nothing", async () => {
|
|
const { deleteWallet, state } = load();
|
|
await openLostPassword(deleteWallet, 1);
|
|
|
|
await click("btn-delete-wallet-lost-confirm");
|
|
|
|
expect(node("delete-wallet-lost-flash").style.visibility).toBe(
|
|
"visible",
|
|
);
|
|
expect(state.wallets).toHaveLength(3);
|
|
});
|
|
|
|
// Not a secret and not a password: it asks whether the user knows
|
|
// which wallet they are on. Refusing the name they can plainly read,
|
|
// over letter case, would only teach them to distrust the control.
|
|
test("case and surrounding spaces do not matter", async () => {
|
|
const { deleteWallet, state, storage } = load();
|
|
await openLostPassword(deleteWallet, 1);
|
|
|
|
node("delete-wallet-lost-name-input").value = " wALLet 2 ";
|
|
await click("btn-delete-wallet-lost-confirm");
|
|
|
|
expect(state.wallets.map((w) => w.name)).toEqual([
|
|
"Wallet 1",
|
|
"Wallet 3",
|
|
]);
|
|
expect(await persistedWallets(storage)).toHaveLength(2);
|
|
});
|
|
|
|
// A name with a doubled inner space RENDERS with one — HTML collapses
|
|
// runs of whitespace — so the string the user can see and type is not
|
|
// the string the name is stored as. Comparing the two raw would make
|
|
// this wallet's confirmation impossible to satisfy by any typing at
|
|
// all, wedging the one screen that exists to unwedge people.
|
|
test("a doubled space inside the name is typed back as one", async () => {
|
|
const { deleteWallet, state, storage } = load();
|
|
state.wallets[1].name = "My Wallet";
|
|
await openLostPassword(deleteWallet, 1);
|
|
|
|
// What the DOM was handed still has both spaces; what the user
|
|
// reads off the screen, and therefore types, has one.
|
|
expect(node("delete-wallet-lost-name").textContent).toBe("My Wallet");
|
|
node("delete-wallet-lost-name-input").value = "My Wallet";
|
|
await click("btn-delete-wallet-lost-confirm");
|
|
|
|
expect(state.wallets.map((w) => w.name)).toEqual([
|
|
"Wallet 1",
|
|
"Wallet 3",
|
|
]);
|
|
const persisted = await persistedWallets(storage);
|
|
expect(persisted.map((w) => w.encryptedSecret)).toEqual([
|
|
"secret-one",
|
|
"secret-three",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("deleting without the password", () => {
|
|
test("no password is asked for and none is checked", async () => {
|
|
const { deleteWallet, vault, storage } = load();
|
|
await openLostPassword(deleteWallet, 1);
|
|
|
|
node("delete-wallet-lost-name-input").value = "Wallet 2";
|
|
await click("btn-delete-wallet-lost-confirm");
|
|
|
|
expect(vault.decryptWithPassword).not.toHaveBeenCalled();
|
|
expect(await persistedWallets(storage)).toHaveLength(2);
|
|
});
|
|
|
|
// The load-bearing assertion of the whole file, and the one that says
|
|
// this control is safe to give a user who cannot prove anything: it
|
|
// removes the wallet it named, and every other wallet survives intact,
|
|
// key material included.
|
|
test("exactly the named wallet is destroyed", async () => {
|
|
const { deleteWallet, storage } = load();
|
|
await openLostPassword(deleteWallet, 1);
|
|
|
|
node("delete-wallet-lost-name-input").value = "Wallet 2";
|
|
await click("btn-delete-wallet-lost-confirm");
|
|
|
|
const wallets = await persistedWallets(storage);
|
|
expect(wallets.map((w) => w.name)).toEqual(["Wallet 1", "Wallet 3"]);
|
|
expect(wallets.map((w) => w.encryptedSecret)).toEqual([
|
|
"secret-one",
|
|
"secret-three",
|
|
]);
|
|
expect(wallets.map((w) => w.xpub)).toEqual([
|
|
"xpub-Wallet 1",
|
|
"xpub-Wallet 3",
|
|
]);
|
|
expect(wallets[0].addresses.map((a) => a.address)).toEqual([A0, A1]);
|
|
expect(wallets[1].addresses.map((a) => a.address)).toEqual([C0]);
|
|
|
|
// The deleted wallet's secret is gone from storage entirely, not
|
|
// merely unreferenced by the wallet list.
|
|
expect(JSON.stringify(storage.read())).not.toContain("secret-two");
|
|
expect(JSON.stringify(storage.read())).not.toContain("xpub-Wallet 2");
|
|
});
|
|
|
|
test("only the deleted wallet's site permissions are dropped", async () => {
|
|
const { deleteWallet, storage } = load();
|
|
await openLostPassword(deleteWallet, 1);
|
|
|
|
node("delete-wallet-lost-name-input").value = "Wallet 2";
|
|
await click("btn-delete-wallet-lost-confirm");
|
|
|
|
const saved = (await storage.get("autistmask")).autistmask;
|
|
expect(saved.allowedSites).toEqual({ [A0]: ["a.example"] });
|
|
expect(saved.deniedSites).toEqual({ [C0]: ["d.example"] });
|
|
});
|
|
|
|
// The route shares finishDelete() with the password route, so the
|
|
// selection repair and the accountsChanged broadcast are the same on
|
|
// both. Deleting a wallet that did not own the active address must
|
|
// leave that address, and the selection, exactly where they were.
|
|
test("a selection in another wallet is left alone", async () => {
|
|
const { deleteWallet, storage, sent } = load();
|
|
await openLostPassword(deleteWallet, 1);
|
|
|
|
node("delete-wallet-lost-name-input").value = "Wallet 2";
|
|
await click("btn-delete-wallet-lost-confirm");
|
|
|
|
const saved = (await storage.get("autistmask")).autistmask;
|
|
expect(saved.activeAddress).toBe(A0);
|
|
expect(saved.selectedWallet).toBe(0);
|
|
expect(saved.selectedAddress).toBe(0);
|
|
expect(sent).toEqual([]);
|
|
// Settings is stubbed, so this is where the route hands over, not
|
|
// where it renders.
|
|
expect(mockSettingsShow).toHaveBeenCalled();
|
|
});
|
|
|
|
test("deleting the wallet holding the active address moves it and says so", async () => {
|
|
const { deleteWallet, storage, sent } = load();
|
|
await openLostPassword(deleteWallet, 0);
|
|
|
|
node("delete-wallet-lost-name-input").value = "Wallet 1";
|
|
await click("btn-delete-wallet-lost-confirm");
|
|
|
|
const saved = (await storage.get("autistmask")).autistmask;
|
|
expect(saved.wallets.map((w) => w.name)).toEqual([
|
|
"Wallet 2",
|
|
"Wallet 3",
|
|
]);
|
|
expect(saved.activeAddress).toBe(B0);
|
|
expect(sent).toEqual([{ type: "AUTISTMASK_ACTIVE_CHANGED" }]);
|
|
});
|
|
|
|
test("deleting the last wallet lands on Welcome with nothing left", async () => {
|
|
const { deleteWallet, state, storage } = load();
|
|
state.wallets = [wallet("Wallet 1", "secret-one", [A0])];
|
|
state.allowedSites = { [A0]: ["a.example"] };
|
|
state.deniedSites = {};
|
|
|
|
await openLostPassword(deleteWallet, 0);
|
|
node("delete-wallet-lost-name-input").value = "Wallet 1";
|
|
await click("btn-delete-wallet-lost-confirm");
|
|
|
|
const saved = (await storage.get("autistmask")).autistmask;
|
|
expect(saved.wallets).toEqual([]);
|
|
expect(saved.hasWallet).toBe(false);
|
|
expect(saved.activeAddress).toBeNull();
|
|
expect(saved.allowedSites).toEqual({});
|
|
expect(state.currentView).toBe("welcome");
|
|
expect(JSON.stringify(storage.read())).not.toContain("secret-one");
|
|
});
|
|
});
|
|
|
|
describe("what the screen leaves behind", () => {
|
|
test("the typed confirmation is wiped when the screen is left", async () => {
|
|
const { helpers, deleteWallet } = load();
|
|
await openLostPassword(deleteWallet, 1);
|
|
|
|
node("delete-wallet-lost-name-input").value = "Wallet 2";
|
|
// The Settings gear, which is not this screen's Back button.
|
|
helpers.showView("settings");
|
|
|
|
expect(node("delete-wallet-lost-name-input").value).toBe("");
|
|
expect(node("delete-wallet-lost-flash").textContent).toBe("");
|
|
expect(node("delete-wallet-lost-flash").style.visibility).toBe(
|
|
"hidden",
|
|
);
|
|
});
|
|
|
|
// Left mid-delete, the screen has to come back usable.
|
|
test("the confirm button is re-enabled on the way out", async () => {
|
|
const { helpers, deleteWallet } = load();
|
|
await openLostPassword(deleteWallet, 1);
|
|
|
|
node("btn-delete-wallet-lost-confirm").disabled = true;
|
|
helpers.showView("settings");
|
|
|
|
expect(node("btn-delete-wallet-lost-confirm").disabled).toBe(false);
|
|
});
|
|
|
|
// A wallet name is not a secret, so the screen is excluded for the
|
|
// other reason: reopening the popup must not land the user on a screen
|
|
// whose button erases key material.
|
|
test("the popup may not reopen onto it", () => {
|
|
expect(RESTORABLE_VIEWS.has(VIEW)).toBe(false);
|
|
expect(RESTORABLE_VIEWS.has("delete-wallet-confirm")).toBe(false);
|
|
});
|
|
});
|