fix: wipe the exported private key from the DOM on any view leave (closes #221)
All checks were successful
check / check (push) Successful in 29s
All checks were successful
check / check (push) Successful in 29s
This commit was merged in pull request #248.
This commit is contained in:
331
tests/exportPrivkey.test.js
Normal file
331
tests/exportPrivkey.test.js
Normal file
@@ -0,0 +1,331 @@
|
||||
// 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/popup/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 not correct. 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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user