const { $, showView, showFlash, goBack, clearViewStack, onViewLeave, } = require("./helpers"); const { state, saveState } = require("../../shared/state"); const { decryptWithPassword } = require("../../shared/vault"); const { removeWalletFromState, broadcastActiveChanged, } = require("../../shared/walletDelete"); const { INVISIBLE_CHARACTERS } = require("../../shared/symbolSpoof"); let deleteWalletIndex = null; let lostPasswordIndex = null; let ctx = null; // The name shown for a wallet, and on the lost-password screen the string // the user has to type back. One function so the two cannot disagree: a // confirmation that asks for a name other than the one on screen is // unusable. A name that shows nothing at all (only spaces, or only // zero-width characters) is replaced by "Wallet N" for the same reason: // there would be nothing on screen to type back. function displayName(walletIdx) { const wallet = state.wallets[walletIdx]; const name = wallet && wallet.name; if (name && confirmKey(name)) return name; return "Wallet " + (walletIdx + 1); } // What the typed confirmation and the wallet name are compared as. HTML // collapses runs of whitespace when it renders the name, so a wallet named // "My Wallet" with two spaces DISPLAYS as "My Wallet": the user cannot // see the second space and cannot type a string that matches the stored // name. Characters that paint nothing, such as a zero-width space, are // invisible the same way and are removed first. Comparing this form on // both sides is what keeps the confirmation satisfiable, on the one screen // whose whole purpose is unwedging a user who is already stuck. Case and // surrounding space go the same way. function confirmKey(name) { return name .replace(INVISIBLE_CHARACTERS, "") .trim() .replace(/\s+/g, " ") .toLowerCase(); } // 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"; } // The lost-password screen holds no secret — a wallet name is not one — // but it is wiped on leave for the neighbouring reason: a typed // confirmation left standing in a hidden view is one click away from // destroying a wallet the user has since navigated off. function clearLostPassword() { lostPasswordIndex = null; $("delete-wallet-lost-name-input").value = ""; $("delete-wallet-lost-flash").textContent = ""; $("delete-wallet-lost-flash").style.visibility = "hidden"; } function show(walletIdx) { clear(); deleteWalletIndex = walletIdx; $("delete-wallet-name").textContent = displayName(walletIdx); showView("delete-wallet-confirm"); } // The two delete screens are siblings, not parent and child: nothing is // pushed on the way here, and Back goes to show() rather than goBack(). // Both then have the same Back target — Settings, the screen that pushed // delete-wallet-confirm — and re-entering through show() hands the confirm // screen its wallet selection back, which a bare goBack() onto a view // whose leave hook has already nulled that selection would not. function showLostPassword() { const walletIdx = deleteWalletIndex; if (walletIdx === null) { goBack(); return; } const name = displayName(walletIdx); clearLostPassword(); $("delete-wallet-lost-name").textContent = name; $("delete-wallet-lost-name-echo").textContent = name; // showView() runs the leave hook of delete-wallet-confirm, which nulls // deleteWalletIndex, so this screen's own selection is recorded after // it and not before. showView("delete-wallet-lost-password"); lostPasswordIndex = walletIdx; } // Remove the wallet and put the user somewhere sensible. Shared by both // routes onto this screen, so the selection repair, the site-permission // cleanup and the accountsChanged broadcast cannot drift apart between // them. async function finishDelete(walletIdx) { // Each route's confirm button was disabled by its own click handler // before the delete ran. Re-enable both here, on the one path they // share, so the two routes reset the same way and a second delete in // the same popup session finds a live button instead of a dead one. const passwordBtn = $("btn-delete-wallet-confirm"); passwordBtn.disabled = false; passwordBtn.classList.remove("text-muted"); const lostPasswordBtn = $("btn-delete-wallet-lost-confirm"); lostPasswordBtn.disabled = false; lostPasswordBtn.classList.remove("text-muted"); const { activeAddressChanged } = removeWalletFromState(state, walletIdx); deleteWalletIndex = null; lostPasswordIndex = null; if (!state.hasWallet) { clearViewStack(); await saveState(); // Save before broadcasting: the background reads the active // address back out of storage to build accountsChanged. if (activeAddressChanged) broadcastActiveChanged(); showView("welcome"); return; } await saveState(); if (activeAddressChanged) broadcastActiveChanged(); // Reset stack to [main] so Settings back goes home. // Use require() lazily to avoid circular dependency // (settings.js requires deleteWallet.js). clearViewStack(); state.viewStack.push("main"); ctx.renderWalletList(); const settings = require("./settings"); settings.show(); showFlash("Wallet deleted."); } function init(_ctx) { ctx = _ctx; onViewLeave("delete-wallet-confirm", clear); onViewLeave("delete-wallet-lost-password", clearLostPassword); // No wipe here: goBack() routes through showView(), which runs the // leave hook. $("btn-delete-wallet-back").addEventListener("click", () => { goBack(); }); // The escape hatch, and deliberately not gated on anything a user who // has lost the password cannot produce. A password in front of // DISCARDING a secret protects nobody: an attacker at the popup who // wants the wallet gone can uninstall the extension, so the only // person such a gate stops is the owner who forgot it — and before // this route existed that owner could neither delete the wallet nor // import its recovery phrase again, because AddWallet refuses the xpub // as a duplicate while the wallet is still stored. $("btn-delete-wallet-lost-password").addEventListener("click", () => { showLostPassword(); }); $("btn-delete-wallet-lost-back").addEventListener("click", () => { const walletIdx = lostPasswordIndex; if (walletIdx === null) { goBack(); return; } show(walletIdx); }); $("btn-delete-wallet-lost-confirm").addEventListener("click", async () => { if (lostPasswordIndex === null) { $("delete-wallet-lost-flash").textContent = "No wallet selected for deletion."; $("delete-wallet-lost-flash").style.visibility = "visible"; return; } // Case, surrounding spaces, repeated inner spaces and invisible // characters are not part of the confirmation; see confirmKey(). // This asks whether the user knows which wallet they are on; it is // not a secret, and refusing "wallet 2" for "Wallet 2" would only // teach the user to distrust the control. An empty field is // refused whatever the wallet is called, so no stored name can // ever be confirmed by typing nothing. const typed = confirmKey($("delete-wallet-lost-name-input").value); const expected = displayName(lostPasswordIndex); if (typed === "" || typed !== confirmKey(expected)) { $("delete-wallet-lost-flash").textContent = "That is not the name of this wallet. Type " + expected + " to confirm."; $("delete-wallet-lost-flash").style.visibility = "visible"; return; } const btn = $("btn-delete-wallet-lost-confirm"); btn.disabled = true; btn.classList.add("text-muted"); // finishDelete() re-enables the button; navigating away then runs // the leave hook that wipes the typed name. await finishDelete(lostPasswordIndex); }); $("btn-delete-wallet-confirm").addEventListener("click", async () => { const pw = $("delete-wallet-password").value; if (!pw) { $("delete-wallet-flash").textContent = "Please enter your password."; $("delete-wallet-flash").style.visibility = "visible"; return; } if (deleteWalletIndex === null) { $("delete-wallet-flash").textContent = "No wallet selected for deletion."; $("delete-wallet-flash").style.visibility = "visible"; return; } const btn = $("btn-delete-wallet-confirm"); btn.disabled = true; btn.classList.add("text-muted"); const walletIdx = deleteWalletIndex; const wallet = state.wallets[walletIdx]; // Verify password against the wallet's encrypted data try { await decryptWithPassword(wallet.encryptedSecret, pw); } catch { $("delete-wallet-flash").textContent = "That password is incorrect. Please try again."; $("delete-wallet-flash").style.visibility = "visible"; btn.disabled = false; btn.classList.remove("text-muted"); return; } await finishDelete(walletIdx); }); } module.exports = { init, show };