Files
AutistMask/src/popup/views/exportPrivkey.js
T
clawbot c40c7432a5
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
fix: sign and export with each address's own derivation index (closes #510)
Sending, site transactions, site signatures and private key export
derived the key at the address's position in the wallet's list. After
an import whose scan passed over an unused index, or after removing an
address that was not the last, that position named another address's
key.

Each HD and xprv address record now stores the index it was derived at
(at wallet creation, by the import scan, by "+"). getSignerForAddress()
derives at that index, refuses a record without one, and refuses any key
whose address differs from the record's. The confirmation screen signs
as the From address it shows, not the selected one. The export screen
reports a refusal instead of calling the password wrong.

Model: opus-5-5
2026-10-14 18:22:12 +02:00

196 lines
6.8 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,
showError,
hideError,
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
);
}
// 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");
hideError("export-privkey-password-error");
}
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) {
showError(
"export-privkey-password-error",
"Please enter your password.",
);
return;
}
if (walletIndex === null) {
showError("export-privkey-password-error", "No address is selected.");
return;
}
const wallet = state.wallets[walletIndex];
hideError("export-privkey-password-error");
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;
let signer;
try {
signer = getSignerForAddress(
wallet,
wallet.addresses[addressIndex],
secret,
);
} catch (e) {
// The password was right; it is the key that was refused.
showError("export-privkey-password-error", e.message);
return;
}
$("export-privkey-password").value = "";
$("export-privkey-password-section").classList.add("hidden");
$("export-privkey-value").textContent = signer.privateKey;
$("export-privkey-result").classList.remove("hidden");
} catch {
if (!isCurrentReveal(generation)) return;
showError(
"export-privkey-password-error",
"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 };