// 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 };