diff --git a/README.md b/README.md index 33949f9..49bd13f 100644 --- a/README.md +++ b/README.md @@ -486,6 +486,14 @@ ExportPrivKey and ShowRecoveryPhrase — are deliberately absent from that list, so the popup can never reopen onto one of them with no password prompt in front of it. +Every screen that holds secret material in the page registers a cleanup with +`onViewLeave()` (`src/popup/views/helpers.js`), which `showView()` runs on every +exit from that screen rather than only on its "Back" button, so nothing secret +survives in a hidden view once the user has navigated away by any route. That +covers the revealed private key and recovery phrase, the recovery phrase, +private key or extended private key entered on AddWallet, and the password typed +on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign. + #### Welcome (`welcome`) - **When**: No wallets exist yet (`state.hasWallet` is false). This is the root @@ -596,10 +604,15 @@ of it. - "Reveal" (correct password) → decrypts the wallet secret, derives this address's key, hides the password input and shows the key (no screen change) - - "Reveal" (wrong password) → "Wrong password." on the error line, nothing - revealed - - "Back" → clears the key and password from the DOM, then → previous screen - (AddressDetail) + - "Reveal" (wrong password) → full-sentence error on the error line, nothing + revealed (no screen change) + - "Back" → previous screen (AddressDetail) +- **Secret handling**: nothing is decrypted, no key is derived, and nothing is + written into the page until the password is accepted; the key is never stored + in state, and it is wiped from the page whenever the screen is left by any + route, including the Settings gear. A decrypt still running when the screen is + left is discarded rather than written. The screen is not restorable, so + reopening the popup lands on Home rather than back on the key. #### AddressToken (`address-token`) diff --git a/TODO.md b/TODO.md index dcc905f..f85fcd4 100644 --- a/TODO.md +++ b/TODO.md @@ -44,6 +44,12 @@ undefined identifiers, which is how # Completed Steps +- 2026-08-12: The private key export screen now wipes the key from the page + whenever it is left by any route, and a decrypt still in flight when the + screen is left is discarded instead of written; the same `onViewLeave()` + cleanup was extended to every other screen holding secret material in the DOM + (AddWallet, ConfirmTx, DeleteWallet, ApproveTx, ApproveSign) + ([#221](https://git.eeqj.de/sneak/AutistMask/issues/221)). - 2026-08-12: Bundled token list documentation no longer states a count. The four "top 250" claims in `README.md` and the "roughly 500" claim in `docs/README.md` are replaced with a description of how the list is actually diff --git a/src/popup/views/addWallet.js b/src/popup/views/addWallet.js index 063bbbe..dfb3953 100644 --- a/src/popup/views/addWallet.js +++ b/src/popup/views/addWallet.js @@ -1,4 +1,11 @@ -const { $, showView, showFlash, goBack, clearViewStack } = require("./helpers"); +const { + $, + showView, + showFlash, + goBack, + clearViewStack, + onViewLeave, +} = require("./helpers"); const { generateMnemonic, hdWalletFromMnemonic, @@ -66,13 +73,23 @@ function switchMode(mode) { $("add-wallet-password-hint").textContent = PASSWORD_HINTS[mode]; } -function show() { +// Wipe the secret material this screen holds in the DOM: a generated or +// pasted recovery phrase, an imported private key or extended private key, +// and the password that would encrypt them. Registered as the view-leave +// handler as well as run on entry, so none of it survives in the hidden +// view after the user navigates away by any route, including the Settings +// gear and the import itself. +function clear() { $("wallet-mnemonic").value = ""; $("import-private-key").value = ""; $("import-xprv-key").value = ""; $("add-wallet-password").value = ""; $("add-wallet-password-confirm").value = ""; $("add-wallet-phrase-warning").style.visibility = "hidden"; +} + +function show() { + clear(); switchMode("mnemonic"); showView("add-wallet"); } @@ -288,6 +305,8 @@ async function importXprvKey(ctx) { } function init(ctx) { + onViewLeave("add-wallet", clear); + // Tab click handlers $("tab-mnemonic").addEventListener("click", () => switchMode("mnemonic")); $("tab-privkey").addEventListener("click", () => switchMode("privkey")); diff --git a/src/popup/views/addressDetail.js b/src/popup/views/addressDetail.js index aaf5864..36aad5d 100644 --- a/src/popup/views/addressDetail.js +++ b/src/popup/views/addressDetail.js @@ -2,7 +2,6 @@ const { $, showView, showFlash, - flashCopyFeedback, balanceLinesForAddress, addressDotHtml, addressTitle, @@ -27,8 +26,7 @@ const { } = require("./send"); const { log } = require("../../shared/log"); const makeBlockie = require("ethereum-blockies-base64"); -const { decryptWithPassword } = require("../../shared/vault"); -const { getSignerForAddress } = require("../../shared/wallet"); +const exportPrivkey = require("./exportPrivkey"); let ctx; @@ -298,81 +296,12 @@ function init(_ctx) { $("btn-export-privkey").addEventListener("click", () => { moreDropdown.classList.add("hidden"); moreBtn.classList.remove("bg-fg", "text-bg"); - pushCurrentView(); - const wallet = state.wallets[state.selectedWallet]; - const addr = wallet.addresses[state.selectedAddress]; - const blockieEl = $("export-privkey-jazzicon"); - blockieEl.innerHTML = ""; - const bImg = document.createElement("img"); - bImg.src = makeBlockie(addr.address); - bImg.width = 48; - bImg.height = 48; - bImg.style.imageRendering = "pixelated"; - bImg.style.borderRadius = "50%"; - blockieEl.appendChild(bImg); - $("export-privkey-title").textContent = - wallet.name + " \u2014 Address " + (state.selectedAddress + 1); - const exportAddrContainer = $("export-privkey-dot").parentElement; - exportAddrContainer.innerHTML = renderAddressHtml(addr.address); - attachCopyHandlers(exportAddrContainer); - $("export-privkey-password").value = ""; - $("export-privkey-flash").textContent = ""; - $("export-privkey-flash").style.visibility = "hidden"; - $("export-privkey-password-section").classList.remove("hidden"); - $("export-privkey-result").classList.add("hidden"); - $("export-privkey-value").textContent = ""; - showView("export-privkey"); + // No pushCurrentView() here: exportPrivkey.show() can return + // without navigating, so it does its own push. + exportPrivkey.show(state.selectedWallet, state.selectedAddress); }); - $("btn-export-privkey-confirm").addEventListener("click", async () => { - const password = $("export-privkey-password").value; - if (!password) { - $("export-privkey-flash").textContent = "Password is required."; - $("export-privkey-flash").style.visibility = "visible"; - return; - } - const btn = $("btn-export-privkey-confirm"); - btn.disabled = true; - btn.classList.add("text-muted"); - const wallet = state.wallets[state.selectedWallet]; - try { - const secret = await decryptWithPassword( - wallet.encryptedSecret, - password, - ); - const signer = getSignerForAddress( - wallet, - state.selectedAddress, - secret, - ); - const privateKey = signer.privateKey; - $("export-privkey-password-section").classList.add("hidden"); - $("export-privkey-value").textContent = privateKey; - $("export-privkey-result").classList.remove("hidden"); - $("export-privkey-flash").style.visibility = "hidden"; - } catch { - $("export-privkey-flash").textContent = "Wrong password."; - $("export-privkey-flash").style.visibility = "visible"; - } finally { - btn.disabled = false; - btn.classList.remove("text-muted"); - } - }); - - $("export-privkey-value").addEventListener("click", () => { - const key = $("export-privkey-value").textContent; - if (key) { - navigator.clipboard.writeText(key); - showFlash("Copied!"); - flashCopyFeedback($("export-privkey-value")); - } - }); - - $("btn-export-privkey-back").addEventListener("click", () => { - $("export-privkey-value").textContent = ""; - $("export-privkey-password").value = ""; - goBack(); - }); + exportPrivkey.init(); } module.exports = { init, show }; diff --git a/src/popup/views/approval.js b/src/popup/views/approval.js index c9cc740..fd89efd 100644 --- a/src/popup/views/approval.js +++ b/src/popup/views/approval.js @@ -7,6 +7,7 @@ const { hideError, renderAddressHtml, attachCopyHandlers, + onViewLeave, } = require("./helpers"); const { state, saveState, currentNetwork } = require("../../shared/state"); const { @@ -444,7 +445,24 @@ function findActiveWallet() { return null; } +// Drop the password from the DOM when either approval screen is left. The +// approval window navigates on after a signature — approve-tx goes to the +// wait screen — and the password must not sit in the hidden view for the +// life of that window. +function clearTxPassword() { + $("approve-tx-password").value = ""; + hideError("approve-tx-error"); +} + +function clearSignPassword() { + $("approve-sign-password").value = ""; + hideError("approve-sign-error"); +} + function init(ctx) { + onViewLeave("approve-tx", clearTxPassword); + onViewLeave("approve-sign", clearSignPassword); + $("approve-remember").addEventListener("change", async () => { state.rememberSiteChoice = $("approve-remember").checked; await saveState(); diff --git a/src/popup/views/confirmTx.js b/src/popup/views/confirmTx.js index 910489c..03678f4 100644 --- a/src/popup/views/confirmTx.js +++ b/src/popup/views/confirmTx.js @@ -21,6 +21,7 @@ const { renderAddressHtml, attachCopyHandlers, goBack, + onViewLeave, } = require("./helpers"); const { state, currentNetwork } = require("../../shared/state"); const { getSignerForAddress } = require("../../shared/wallet"); @@ -390,7 +391,17 @@ async function checkRecipientHistory(txInfo) { } } +// Drop the password from the DOM. Registered as the view-leave handler so +// it does not sit in the hidden view once the screen navigates on — to the +// wait screen after a send, or anywhere else the user goes. +function clearPassword() { + $("confirm-tx-password").value = ""; + hideError("confirm-tx-password-error"); +} + function init(ctx) { + onViewLeave("confirm-tx", clearPassword); + $("btn-confirm-send").addEventListener("click", async () => { const password = $("confirm-tx-password").value; if (!password) { diff --git a/src/popup/views/deleteWallet.js b/src/popup/views/deleteWallet.js index 0f0381a..78e440c 100644 --- a/src/popup/views/deleteWallet.js +++ b/src/popup/views/deleteWallet.js @@ -1,4 +1,11 @@ -const { $, showView, showFlash, goBack, clearViewStack } = require("./helpers"); +const { + $, + showView, + showFlash, + goBack, + clearViewStack, + onViewLeave, +} = require("./helpers"); const { state, saveState } = require("../../shared/state"); const { decryptWithPassword } = require("../../shared/vault"); const { @@ -9,22 +16,34 @@ const { let deleteWalletIndex = null; let ctx = null; +// Drop the password from the DOM and the wallet selection from the +// closure. Registered as the view-leave handler as well as run on entry, +// so the typed password does not sit in the hidden view after the user +// navigates away by any route, including the Settings gear. +function clear() { + deleteWalletIndex = null; + $("delete-wallet-password").value = ""; + $("delete-wallet-flash").textContent = ""; + $("delete-wallet-flash").style.visibility = "hidden"; +} + function show(walletIdx) { + clear(); deleteWalletIndex = walletIdx; const wallet = state.wallets[walletIdx]; $("delete-wallet-name").textContent = wallet.name || "Wallet " + (walletIdx + 1); - $("delete-wallet-password").value = ""; - $("delete-wallet-flash").textContent = ""; - $("delete-wallet-flash").style.visibility = "hidden"; showView("delete-wallet-confirm"); } function init(_ctx) { ctx = _ctx; + onViewLeave("delete-wallet-confirm", clear); + + // No wipe here: goBack() routes through showView(), which runs the + // leave hook. $("btn-delete-wallet-back").addEventListener("click", () => { - deleteWalletIndex = null; goBack(); }); diff --git a/src/popup/views/exportPrivkey.js b/src/popup/views/exportPrivkey.js new file mode 100644 index 0000000..6860240 --- /dev/null +++ b/src/popup/views/exportPrivkey.js @@ -0,0 +1,174 @@ +// Private key export for a single address. +// +// The key controls the address outright — anyone holding it can move every +// token in it, from any device, forever — so this screen is handled under +// the same rules as the recovery phrase screen (./showPhrase.js): +// +// 1. Nothing is decrypted, no key is derived, and nothing is written into +// the DOM until decryptWithPassword has accepted the password. +// 2. Leaving the screen by any path wipes it, via the onViewLeave hook, +// and a decrypt still in flight when that happens is discarded +// instead of written (revealGeneration). +// 3. The key never reaches the logger. This module deliberately does not +// import src/shared/log.js. +// +// The key is also never assigned to `state`, so it cannot be persisted to +// extension storage, and "export-privkey" is excluded from RESTORABLE_VIEWS +// so the popup can never reopen onto it. + +const { + $, + showView, + showFlash, + flashCopyFeedback, + goBack, + onViewLeave, + pushCurrentView, + renderAddressHtml, + attachCopyHandlers, +} = require("./helpers"); +const { state } = require("../../shared/state"); +const { decryptWithPassword } = require("../../shared/vault"); +const { getSignerForAddress } = require("../../shared/wallet"); +const makeBlockie = require("ethereum-blockies-base64"); + +const VIEW = "export-privkey"; + +let walletIndex = null; +let addressIndex = null; + +// Bumped by every clear(), which is what leaving the screen runs. reveal() +// captures it before awaiting the decrypt and refuses to touch the DOM if +// it has moved: a decrypt still in flight when the screen is left would +// otherwise write the key *after* the wipe, with nothing scheduled to wipe +// it again, leaving it in the hidden view for the life of the popup. +let revealGeneration = 0; + +// True only if the reveal that captured `generation` is still the live one: +// the screen has not been left, cleared, or re-entered for another address +// since it started. +function isCurrentReveal(generation) { + return ( + generation === revealGeneration && + walletIndex !== null && + addressIndex !== null && + state.currentView === VIEW + ); +} + +function fail(message) { + $("export-privkey-flash").textContent = message; + $("export-privkey-flash").style.visibility = "visible"; +} + +// Wipe every trace of the key and drop the address selection. Safe to call +// when nothing was ever revealed, and safe to call twice. +function clear() { + walletIndex = null; + addressIndex = null; + revealGeneration += 1; + $("export-privkey-value").textContent = ""; + $("export-privkey-password").value = ""; + $("export-privkey-result").classList.add("hidden"); + $("export-privkey-password-section").classList.remove("hidden"); + $("export-privkey-flash").textContent = ""; + $("export-privkey-flash").style.visibility = "hidden"; +} + +function show(walletIdx, addrIdx) { + const wallet = state.wallets[walletIdx]; + const addr = wallet && wallet.addresses[addrIdx]; + if (!addr) { + showFlash("That address is no longer available."); + return; + } + clear(); + walletIndex = walletIdx; + addressIndex = addrIdx; + + const blockieEl = $("export-privkey-jazzicon"); + blockieEl.innerHTML = ""; + const img = document.createElement("img"); + img.src = makeBlockie(addr.address); + img.width = 48; + img.height = 48; + img.style.imageRendering = "pixelated"; + img.style.borderRadius = "50%"; + blockieEl.appendChild(img); + + $("export-privkey-title").textContent = + wallet.name + " — Address " + (addrIdx + 1); + const addrContainer = $("export-privkey-dot").parentElement; + addrContainer.innerHTML = renderAddressHtml(addr.address); + attachCopyHandlers(addrContainer); + + // Pushed here rather than by the caller: this function can return + // without navigating, and a push that happened anyway would leave an + // entry on the stack that no screen transition matches. + pushCurrentView(); + showView(VIEW); +} + +async function reveal() { + const password = $("export-privkey-password").value; + if (!password) { + fail("Password is required."); + return; + } + if (walletIndex === null) { + fail("No address is selected."); + return; + } + const wallet = state.wallets[walletIndex]; + + const btn = $("btn-export-privkey-confirm"); + btn.disabled = true; + btn.classList.add("text-muted"); + const generation = revealGeneration; + try { + const secret = await decryptWithPassword( + wallet.encryptedSecret, + password, + ); + // The only suspension point in this view, and the gate on the only + // place a secret is written: if the screen was left while the + // decrypt ran, the wipe has already happened, so the key is not + // even derived, let alone written. + if (!isCurrentReveal(generation)) return; + const signer = getSignerForAddress(wallet, addressIndex, secret); + $("export-privkey-password").value = ""; + $("export-privkey-password-section").classList.add("hidden"); + $("export-privkey-value").textContent = signer.privateKey; + $("export-privkey-result").classList.remove("hidden"); + $("export-privkey-flash").textContent = ""; + $("export-privkey-flash").style.visibility = "hidden"; + } catch { + if (!isCurrentReveal(generation)) return; + fail("That password is not correct. Please try again."); + } finally { + btn.disabled = false; + btn.classList.remove("text-muted"); + } +} + +function init() { + onViewLeave(VIEW, clear); + + // No wipe here: goBack() routes through showView(), which runs the + // leave hook. A per-button wipe would only cover this one path. + $("btn-export-privkey-back").addEventListener("click", () => { + goBack(); + }); + + $("btn-export-privkey-confirm").addEventListener("click", reveal); + + $("export-privkey-value").addEventListener("click", () => { + const key = $("export-privkey-value").textContent; + if (!key) return; + navigator.clipboard.writeText(key); + showFlash("Copied!"); + flashCopyFeedback($("export-privkey-value")); + }); +} + +module.exports = { init, show }; diff --git a/tests/exportPrivkey.test.js b/tests/exportPrivkey.test.js new file mode 100644 index 0000000..468acca --- /dev/null +++ b/tests/exportPrivkey.test.js @@ -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/); + }); +});