Leaving the private key export or recovery phrase screen drops the selection it was showing, but the settings gear had just pushed the screen onto the Back stack, so Back from Settings landed on a password prompt that could only fail. Each screen's leave handler now also takes it off the top of the stack, which is what a reopened popup already does to these screens. Back from Settings goes to the address screen for the export screen; for the recovery phrase screen, opened from Settings, it stays on Settings once, as after a reopen. Jest tests drive the gear and then Back, and each screen's own Back, for both screens; leavePrivkeyScreen() in the e2e suite expects the address screen. Model: opus-5-5
184 lines
6.6 KiB
JavaScript
184 lines
6.6 KiB
JavaScript
// 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-address");
|
|
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("Please enter your password.");
|
|
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 incorrect. Please try again.");
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.classList.remove("text-muted");
|
|
}
|
|
}
|
|
|
|
function init() {
|
|
// Leaving drops the address selection, so the screen also comes off the
|
|
// Back stack, where the settings gear has just put it: Back from Settings
|
|
// must not land on a password prompt that can only fail. A reopened popup
|
|
// drops it from the stack the same way
|
|
// (https://git.eeqj.de/sneak/AutistMask/issues/461).
|
|
onViewLeave(VIEW, () => {
|
|
clear();
|
|
const stack = state.viewStack;
|
|
if (stack[stack.length - 1] === VIEW) stack.pop();
|
|
});
|
|
|
|
// 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 };
|