Files
AutistMask/tests/exportPrivkey.test.js
sneak 82425496cb
All checks were successful
check / check (push) Successful in 33s
e2e / e2e-chrome (push) Successful in 1m46s
e2e / e2e-firefox (push) Successful in 33s
fix: version the stored profile, and give a record that cannot be read a way out (closes #311)
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 three separate gaps there let a gate-accepted record reach a dereference and blank the popup anyway. trackedTokens and activeAddress were floored on truthiness rather than on type: trackedTokens: "nope" rendered nothing with "Cannot read properties of undefined (reading 'toLowerCase')", activeAddress: 42 rendered nothing with "address.slice is not a function". A container check is not enough either, because [1, 2] IS a list and the dereference is t.address.toLowerCase() one level below the Array.isArray(): [1,2], [null], [{}], [{address:42}] and ["0xAA..."] each still rendered nothing. And each address's tokenBalances had no floor at all — which matters most, since refreshBalances() writes that field wholesale and the partial write the issue names as the live cause of a corrupt record lands exactly there — so "x", 42, [null] and [42] rendered nothing too. All of them are type-checked now, container AND entries: an entry that is not a record with a text address is dropped, the well-formed entries beside it survive, and an empty list is still a legitimate value. activeAddress's empty string now floors to null rather than surviving, because init() auto-selects the first address only on a strict null, so a kept "" would leave the popup with no address ever selected; that restores what the || null this check replaced already did.

The header of stateSchema.js claimed every non-gated field's floor was a type check. It is not, and now it says so field by field: rpcUrl, blockscoutUrl, lastBalanceRefresh, fraudContracts, tokenHolderCache, theme, currentView, selectedToken and viewData are saved.x || default; every boolean flag, dustThresholdGwei, selectedWallet and selectedAddress are taken verbatim when present; allowedSites and deniedSites are checked as containers only, never per entry. The header and the README now list which field is in which category rather than asserting a rule the module does not follow.

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 what JSON cannot carry is stated where the export is written: a cycle or a BigInt throws and fails the export entirely, and a Date, a Map, a Set, an undefined property or a NaN is mangled silently instead, which is the worse residual because the box then looks complete.

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. Every corrupt-field shape that has ever been observed to blank the popup is a row in tests/stateRecovery.test.js, measured through the same entry point; none has been removed. 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.
2026-08-23 17:18:29 +00:00

332 lines
12 KiB
JavaScript

// Tests for the private key export screen (issue #221).
//
// The screen holds the one secret that owns an address outright, so what is
// pinned here is disposal: the key is wiped from the DOM whenever the screen
// is left by any route, and a decrypt still in flight when the screen is
// left never writes at all. That last case is the one a per-button wipe and
// a naive leave hook both miss — the write lands after the wipe, with
// nothing scheduled to wipe it again.
//
// The view is driven against a minimal DOM stub rather than a real browser:
// the module is deliberately shaped like src/popup/views/showPhrase.js, with
// no dependency that needs a document beyond the nodes it reads and writes.
const mockPrivateKey = "0x" + "ab".repeat(32);
jest.mock("ethereum-blockies-base64", () => () => "data:image/png;base64,x");
jest.mock("../src/shared/vault", () => ({
decryptWithPassword: jest.fn(),
}));
jest.mock("../src/shared/wallet", () => ({
getSignerForAddress: jest.fn(() => ({ privateKey: mockPrivateKey })),
}));
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
const VIEW = "export-privkey";
const PASSWORD = "correct horse battery";
// ------------------------------------------------------------ DOM stub
function makeElement(id, withParent) {
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: () => [],
};
el.parentElement = withParent ? makeElement(id + "-parent", false) : null;
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, true));
return els.get(id);
},
createElement: () => makeElement("created", false),
addEventListener: () => {},
body: { prepend: () => {} },
};
}
// ------------------------------------------------------------ harness
function load() {
jest.resetModules();
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
globalThis.document = makeDocument();
const helpers = require("../src/popup/views/helpers");
const { state } = require("../src/shared/state");
const vault = require("../src/shared/vault");
const wallet = require("../src/shared/wallet");
const exportPrivkey = require("../src/popup/views/exportPrivkey");
state.wallets = [
{
name: "Wallet 1",
type: "key",
encryptedSecret: "ciphertext",
addresses: [
{
address: "0x" + "11".repeat(20),
balance: "0.0000",
tokenBalances: [],
},
{
address: "0x" + "22".repeat(20),
balance: "0.0000",
tokenBalances: [],
},
],
},
];
state.viewStack = [];
state.currentView = "address";
exportPrivkey.init();
return { helpers, state, vault, wallet, exportPrivkey };
}
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);
}
// Start a reveal and hand back both the promise it returns and the resolver
// for the decrypt it is waiting on, so a test can navigate away mid-flight.
function startReveal(vault) {
let resolveDecrypt;
let rejectDecrypt;
vault.decryptWithPassword.mockImplementation(
() =>
new Promise((resolve, reject) => {
resolveDecrypt = resolve;
rejectDecrypt = reject;
}),
);
node("export-privkey-password").value = PASSWORD;
const pending = click("btn-export-privkey-confirm");
return {
pending,
resolve: (v) => resolveDecrypt(v),
reject: (e) => rejectDecrypt(e),
};
}
// ------------------------------------------------------------ tests
describe("a decrypt still running when the screen is left", () => {
// The load-bearing case. Without the liveness guard in reveal(), the
// write lands after the leave hook has already wiped, and the key sits
// in the hidden view for the life of the popup.
test("never writes the key into the DOM", async () => {
const { helpers, vault, wallet, exportPrivkey } = load();
exportPrivkey.show(0, 0);
const reveal = startReveal(vault);
// The settings gear, mid-decrypt.
helpers.showView("settings");
reveal.resolve("wallet secret");
await reveal.pending;
expect(node("export-privkey-value").textContent).toBe("");
// Nothing was even derived: the guard sits in front of the
// derivation, not just in front of the write.
expect(wallet.getSignerForAddress).not.toHaveBeenCalled();
});
// The generation counter, not merely the current-view check: by the time
// the stale decrypt resolves the user is back on the screen, so a guard
// that only asked "is this view showing?" would let the write through.
test("never writes it after the screen is re-entered", async () => {
const { helpers, vault, exportPrivkey } = load();
exportPrivkey.show(0, 0);
const stale = startReveal(vault);
helpers.showView("settings");
exportPrivkey.show(0, 1);
expect(node("export-privkey-value").textContent).toBe("");
stale.resolve("wallet secret");
await stale.pending;
expect(node("export-privkey-value").textContent).toBe("");
expect(node("export-privkey-result").classList.contains("hidden")).toBe(
true,
);
});
// Same hole on the failure path: a wrong-password error written after
// the wipe would restore the flash line on a screen the user has left.
test("never writes the failure message either", async () => {
const { helpers, vault, exportPrivkey } = load();
exportPrivkey.show(0, 0);
const reveal = startReveal(vault);
helpers.showView("settings");
reveal.reject(new Error("decryption failed"));
await reveal.pending;
expect(node("export-privkey-flash").textContent).toBe("");
expect(node("export-privkey-flash").style.visibility).toBe("hidden");
});
});
describe("a reveal that is not interrupted", () => {
// Guards the guard: a liveness check that rejected every write would
// pass every test above and ship a screen that reveals nothing.
test("puts the key on screen", async () => {
const { vault, exportPrivkey } = load();
exportPrivkey.show(0, 0);
const reveal = startReveal(vault);
reveal.resolve("wallet secret");
await reveal.pending;
expect(node("export-privkey-value").textContent).toBe(mockPrivateKey);
expect(node("export-privkey-result").classList.contains("hidden")).toBe(
false,
);
// The password is dropped as soon as it has been spent.
expect(node("export-privkey-password").value).toBe("");
});
test("writes nothing before the password is accepted", async () => {
const { vault, exportPrivkey } = load();
exportPrivkey.show(0, 0);
const reveal = startReveal(vault);
expect(node("export-privkey-value").textContent).toBe("");
reveal.resolve("wallet secret");
await reveal.pending;
});
test("reveals nothing when the password is wrong", async () => {
const { vault, exportPrivkey } = load();
exportPrivkey.show(0, 0);
const reveal = startReveal(vault);
reveal.reject(new Error("decryption failed"));
await reveal.pending;
expect(node("export-privkey-value").textContent).toBe("");
expect(node("export-privkey-flash").textContent).toBe(
"That password is incorrect. Please try again.",
);
});
});
describe("leaving the screen after the key is on it", () => {
async function revealed() {
const loaded = load();
loaded.exportPrivkey.show(0, 0);
const reveal = startReveal(loaded.vault);
reveal.resolve("wallet secret");
await reveal.pending;
expect(node("export-privkey-value").textContent).toBe(mockPrivateKey);
return loaded;
}
test("the Back button clears the key", async () => {
await revealed();
await click("btn-export-privkey-back");
expect(node("export-privkey-value").textContent).toBe("");
expect(node("export-privkey-password").value).toBe("");
});
test("the settings gear clears the key", async () => {
const { helpers } = await revealed();
helpers.showView("settings");
expect(node("export-privkey-value").textContent).toBe("");
expect(node("export-privkey-password").value).toBe("");
// And the screen is back to its password prompt, not to a result
// panel that would flash an empty well on the next visit.
expect(node("export-privkey-result").classList.contains("hidden")).toBe(
true,
);
expect(
node("export-privkey-password-section").classList.contains(
"hidden",
),
).toBe(false);
});
// Any other navigation: the same hook covers routes that do not exist
// yet, which is the point of registering it on the view rather than on
// the controls that leave it.
test("any other navigation clears the key", async () => {
const { helpers } = await revealed();
helpers.showView("main");
expect(node("export-privkey-value").textContent).toBe("");
});
});
describe("views the popup may reopen onto", () => {
// Restoring onto this screen would put a private key on display with no
// password prompt in front of it, on a popup reopened by accident.
test("the private key export screen is not restorable", () => {
expect(RESTORABLE_VIEWS.has(VIEW)).toBe(false);
});
test("it is still a registered view", () => {
const { helpers } = load();
expect(helpers.VIEWS).toContain(VIEW);
});
});
describe("the key cannot reach the logger", () => {
const fs = require("fs");
const path = require("path");
const source = fs.readFileSync(
path.join(__dirname, "..", "src", "popup", "views", "exportPrivkey.js"),
"utf8",
);
test("the view does not import src/shared/log.js", () => {
expect(source).not.toMatch(/require\(["'][^"']*shared\/log["']\)/);
});
test("the view calls no logger method", () => {
expect(source).not.toMatch(/\blog\.(debugf|infof|warnf|errorf)\b/);
});
});