// Recovery phrase display for HD wallets. // // The phrase is the secret that owns every address in the wallet, so it is // handled under four rules: // // 1. Only an HD wallet reaches this screen (walletHasRecoveryPhrase). // 2. Nothing is decrypted, and nothing is written into the DOM, until // decryptWithPassword has accepted the password. // 3. 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). // 4. The phrase never reaches the logger. This module deliberately does // not import src/shared/log.js, and the failed-decrypt path reports a // fixed sentence rather than the caught error. // // The phrase is also never assigned to `state`, so it cannot be persisted // to extension storage, and "show-phrase" is excluded from RESTORABLE_VIEWS // so the popup can never reopen onto it. const { $, showView, showFlash, flashCopyFeedback, goBack, onViewLeave, pushCurrentView, } = require("./helpers"); const { state } = require("../../shared/state"); const { decryptWithPassword } = require("../../shared/vault"); const { walletHasRecoveryPhrase } = require("../../shared/wallet"); const VIEW = "show-phrase"; let walletIndex = 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 phrase *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 wallet // since it started. function isCurrentReveal(generation) { return ( generation === revealGeneration && walletIndex !== null && state.currentView === VIEW ); } function fail(message) { $("show-phrase-flash").textContent = message; $("show-phrase-flash").style.visibility = "visible"; } // Wipe every trace of the phrase and drop the wallet selection. Safe to // call when nothing was ever revealed, and safe to call twice. function clear() { walletIndex = null; revealGeneration += 1; $("show-phrase-value").textContent = ""; $("show-phrase-password").value = ""; $("show-phrase-result").classList.add("hidden"); $("show-phrase-password-section").classList.remove("hidden"); $("show-phrase-flash").textContent = ""; $("show-phrase-flash").style.visibility = "hidden"; } function show(walletIdx) { const wallet = state.wallets[walletIdx]; if (!walletHasRecoveryPhrase(wallet)) { showFlash("This wallet does not have a recovery phrase."); return; } clear(); walletIndex = walletIdx; $("show-phrase-wallet-name").textContent = wallet.name || "Wallet " + (walletIdx + 1); // 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 = $("show-phrase-password").value; if (!password) { fail("Please enter your password."); return; } if (walletIndex === null) { fail("No wallet is selected."); return; } const wallet = state.wallets[walletIndex]; if (!walletHasRecoveryPhrase(wallet)) { fail("This wallet does not have a recovery phrase."); return; } const btn = $("btn-show-phrase-reveal"); btn.disabled = true; btn.classList.add("text-muted"); const generation = revealGeneration; try { const phrase = await decryptWithPassword( wallet.encryptedSecret, password, ); // The only suspension point in this view, and the only place a // secret is written: if the screen was left while the decrypt ran, // the wipe has already happened and this write must not land. if (!isCurrentReveal(generation)) return; $("show-phrase-password").value = ""; $("show-phrase-password-section").classList.add("hidden"); $("show-phrase-value").textContent = phrase; $("show-phrase-result").classList.remove("hidden"); $("show-phrase-flash").textContent = ""; $("show-phrase-flash").style.visibility = "hidden"; } catch { if (!isCurrentReveal(generation)) return; // Deliberately not the caught error: the message is fixed so that // nothing derived from the ciphertext or the attempt can surface. fail("That password is not correct. Please try again."); } finally { btn.disabled = false; btn.classList.remove("text-muted"); } } function init() { onViewLeave(VIEW, clear); $("btn-show-phrase-back").addEventListener("click", () => { goBack(); }); $("btn-show-phrase-reveal").addEventListener("click", reveal); $("show-phrase-value").addEventListener("click", () => { const phrase = $("show-phrase-value").textContent; if (!phrase) return; navigator.clipboard.writeText(phrase); showFlash("Copied!"); flashCopyFeedback($("show-phrase-value")); }); } module.exports = { init, show };