Compare commits
1 Commits
issue-188-
...
374b4bbce6
| Author | SHA1 | Date | |
|---|---|---|---|
| 374b4bbce6 |
21
README.md
21
README.md
@@ -491,6 +491,14 @@ ExportPrivKey and ShowRecoveryPhrase — are deliberately absent from that list,
|
|||||||
so the popup can never reopen onto one of them with no password prompt in front
|
so the popup can never reopen onto one of them with no password prompt in front
|
||||||
of it.
|
of it.
|
||||||
|
|
||||||
|
Every screen that holds secret material in the page registers a cleanup with
|
||||||
|
`onViewLeave()` (`src/popup/views/helpers.js`), which `showView()` runs on every
|
||||||
|
exit from that screen rather than only on its "Back" button, so nothing secret
|
||||||
|
survives in a hidden view once the user has navigated away by any route. That
|
||||||
|
covers the revealed private key and recovery phrase, the recovery phrase,
|
||||||
|
private key or extended private key entered on AddWallet, and the password typed
|
||||||
|
on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
|
||||||
|
|
||||||
#### Welcome (`welcome`)
|
#### Welcome (`welcome`)
|
||||||
|
|
||||||
- **When**: No wallets exist yet (`state.hasWallet` is false). This is the root
|
- **When**: No wallets exist yet (`state.hasWallet` is false). This is the root
|
||||||
@@ -601,10 +609,15 @@ of it.
|
|||||||
- "Reveal" (correct password) → decrypts the wallet secret, derives this
|
- "Reveal" (correct password) → decrypts the wallet secret, derives this
|
||||||
address's key, hides the password input and shows the key (no screen
|
address's key, hides the password input and shows the key (no screen
|
||||||
change)
|
change)
|
||||||
- "Reveal" (wrong password) → "Wrong password." on the error line, nothing
|
- "Reveal" (wrong password) → full-sentence error on the error line, nothing
|
||||||
revealed
|
revealed (no screen change)
|
||||||
- "Back" → clears the key and password from the DOM, then → previous screen
|
- "Back" → previous screen (AddressDetail)
|
||||||
(AddressDetail)
|
- **Secret handling**: nothing is decrypted, no key is derived, and nothing is
|
||||||
|
written into the page until the password is accepted; the key is never stored
|
||||||
|
in state, and it is wiped from the page whenever the screen is left by any
|
||||||
|
route, including the Settings gear. A decrypt still running when the screen is
|
||||||
|
left is discarded rather than written. The screen is not restorable, so
|
||||||
|
reopening the popup lands on Home rather than back on the key.
|
||||||
|
|
||||||
#### AddressToken (`address-token`)
|
#### AddressToken (`address-token`)
|
||||||
|
|
||||||
|
|||||||
6
TODO.md
6
TODO.md
@@ -44,6 +44,12 @@ undefined identifiers, which is how
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-08-12: The private key export screen now wipes the key from the page
|
||||||
|
whenever it is left by any route, and a decrypt still in flight when the
|
||||||
|
screen is left is discarded instead of written; the same `onViewLeave()`
|
||||||
|
cleanup was extended to every other screen holding secret material in the DOM
|
||||||
|
(AddWallet, ConfirmTx, DeleteWallet, ApproveTx, ApproveSign)
|
||||||
|
([#221](https://git.eeqj.de/sneak/AutistMask/issues/221)).
|
||||||
- 2026-08-12: An xprv wallet already in storage that was imported from a
|
- 2026-08-12: An xprv wallet already in storage that was imported from a
|
||||||
non-master key is detected from the depth of its stored `xpub`, explained in
|
non-master key is detected from the depth of its stored `xpub`, explained in
|
||||||
the wallet list, and blocked from signing, sending and private-key export
|
the wallet list, and blocked from signing, sending and private-key export
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
const { $, showView, showFlash, goBack, clearViewStack } = require("./helpers");
|
const {
|
||||||
|
$,
|
||||||
|
showView,
|
||||||
|
showFlash,
|
||||||
|
goBack,
|
||||||
|
clearViewStack,
|
||||||
|
onViewLeave,
|
||||||
|
} = require("./helpers");
|
||||||
const {
|
const {
|
||||||
generateMnemonic,
|
generateMnemonic,
|
||||||
hdWalletFromMnemonic,
|
hdWalletFromMnemonic,
|
||||||
@@ -66,13 +73,23 @@ function switchMode(mode) {
|
|||||||
$("add-wallet-password-hint").textContent = PASSWORD_HINTS[mode];
|
$("add-wallet-password-hint").textContent = PASSWORD_HINTS[mode];
|
||||||
}
|
}
|
||||||
|
|
||||||
function show() {
|
// Wipe the secret material this screen holds in the DOM: a generated or
|
||||||
|
// pasted recovery phrase, an imported private key or extended private key,
|
||||||
|
// and the password that would encrypt them. Registered as the view-leave
|
||||||
|
// handler as well as run on entry, so none of it survives in the hidden
|
||||||
|
// view after the user navigates away by any route, including the Settings
|
||||||
|
// gear and the import itself.
|
||||||
|
function clear() {
|
||||||
$("wallet-mnemonic").value = "";
|
$("wallet-mnemonic").value = "";
|
||||||
$("import-private-key").value = "";
|
$("import-private-key").value = "";
|
||||||
$("import-xprv-key").value = "";
|
$("import-xprv-key").value = "";
|
||||||
$("add-wallet-password").value = "";
|
$("add-wallet-password").value = "";
|
||||||
$("add-wallet-password-confirm").value = "";
|
$("add-wallet-password-confirm").value = "";
|
||||||
$("add-wallet-phrase-warning").style.visibility = "hidden";
|
$("add-wallet-phrase-warning").style.visibility = "hidden";
|
||||||
|
}
|
||||||
|
|
||||||
|
function show() {
|
||||||
|
clear();
|
||||||
switchMode("mnemonic");
|
switchMode("mnemonic");
|
||||||
showView("add-wallet");
|
showView("add-wallet");
|
||||||
}
|
}
|
||||||
@@ -288,6 +305,8 @@ async function importXprvKey(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function init(ctx) {
|
function init(ctx) {
|
||||||
|
onViewLeave("add-wallet", clear);
|
||||||
|
|
||||||
// Tab click handlers
|
// Tab click handlers
|
||||||
$("tab-mnemonic").addEventListener("click", () => switchMode("mnemonic"));
|
$("tab-mnemonic").addEventListener("click", () => switchMode("mnemonic"));
|
||||||
$("tab-privkey").addEventListener("click", () => switchMode("privkey"));
|
$("tab-privkey").addEventListener("click", () => switchMode("privkey"));
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ const {
|
|||||||
$,
|
$,
|
||||||
showView,
|
showView,
|
||||||
showFlash,
|
showFlash,
|
||||||
flashCopyFeedback,
|
|
||||||
balanceLinesForAddress,
|
balanceLinesForAddress,
|
||||||
addressDotHtml,
|
addressDotHtml,
|
||||||
addressTitle,
|
addressTitle,
|
||||||
@@ -27,8 +26,7 @@ const {
|
|||||||
} = require("./send");
|
} = require("./send");
|
||||||
const { log } = require("../../shared/log");
|
const { log } = require("../../shared/log");
|
||||||
const makeBlockie = require("ethereum-blockies-base64");
|
const makeBlockie = require("ethereum-blockies-base64");
|
||||||
const { decryptWithPassword } = require("../../shared/vault");
|
const exportPrivkey = require("./exportPrivkey");
|
||||||
const { getSignerForAddress } = require("../../shared/wallet");
|
|
||||||
const { walletDefect } = require("../../shared/walletDefects");
|
const { walletDefect } = require("../../shared/walletDefects");
|
||||||
|
|
||||||
// The defect of the wallet the selected address belongs to, or null. Both the
|
// The defect of the wallet the selected address belongs to, or null. Both the
|
||||||
@@ -321,81 +319,12 @@ function init(_ctx) {
|
|||||||
showFlash(defect.shortMessage);
|
showFlash(defect.shortMessage);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pushCurrentView();
|
// No pushCurrentView() here: exportPrivkey.show() can return
|
||||||
const wallet = state.wallets[state.selectedWallet];
|
// without navigating, so it does its own push.
|
||||||
const addr = wallet.addresses[state.selectedAddress];
|
exportPrivkey.show(state.selectedWallet, state.selectedAddress);
|
||||||
const blockieEl = $("export-privkey-jazzicon");
|
|
||||||
blockieEl.innerHTML = "";
|
|
||||||
const bImg = document.createElement("img");
|
|
||||||
bImg.src = makeBlockie(addr.address);
|
|
||||||
bImg.width = 48;
|
|
||||||
bImg.height = 48;
|
|
||||||
bImg.style.imageRendering = "pixelated";
|
|
||||||
bImg.style.borderRadius = "50%";
|
|
||||||
blockieEl.appendChild(bImg);
|
|
||||||
$("export-privkey-title").textContent =
|
|
||||||
wallet.name + " \u2014 Address " + (state.selectedAddress + 1);
|
|
||||||
const exportAddrContainer = $("export-privkey-dot").parentElement;
|
|
||||||
exportAddrContainer.innerHTML = renderAddressHtml(addr.address);
|
|
||||||
attachCopyHandlers(exportAddrContainer);
|
|
||||||
$("export-privkey-password").value = "";
|
|
||||||
$("export-privkey-flash").textContent = "";
|
|
||||||
$("export-privkey-flash").style.visibility = "hidden";
|
|
||||||
$("export-privkey-password-section").classList.remove("hidden");
|
|
||||||
$("export-privkey-result").classList.add("hidden");
|
|
||||||
$("export-privkey-value").textContent = "";
|
|
||||||
showView("export-privkey");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$("btn-export-privkey-confirm").addEventListener("click", async () => {
|
exportPrivkey.init();
|
||||||
const password = $("export-privkey-password").value;
|
|
||||||
if (!password) {
|
|
||||||
$("export-privkey-flash").textContent = "Password is required.";
|
|
||||||
$("export-privkey-flash").style.visibility = "visible";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const btn = $("btn-export-privkey-confirm");
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.classList.add("text-muted");
|
|
||||||
const wallet = state.wallets[state.selectedWallet];
|
|
||||||
try {
|
|
||||||
const secret = await decryptWithPassword(
|
|
||||||
wallet.encryptedSecret,
|
|
||||||
password,
|
|
||||||
);
|
|
||||||
const signer = getSignerForAddress(
|
|
||||||
wallet,
|
|
||||||
state.selectedAddress,
|
|
||||||
secret,
|
|
||||||
);
|
|
||||||
const privateKey = signer.privateKey;
|
|
||||||
$("export-privkey-password-section").classList.add("hidden");
|
|
||||||
$("export-privkey-value").textContent = privateKey;
|
|
||||||
$("export-privkey-result").classList.remove("hidden");
|
|
||||||
$("export-privkey-flash").style.visibility = "hidden";
|
|
||||||
} catch {
|
|
||||||
$("export-privkey-flash").textContent = "Wrong password.";
|
|
||||||
$("export-privkey-flash").style.visibility = "visible";
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.classList.remove("text-muted");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$("export-privkey-value").addEventListener("click", () => {
|
|
||||||
const key = $("export-privkey-value").textContent;
|
|
||||||
if (key) {
|
|
||||||
navigator.clipboard.writeText(key);
|
|
||||||
showFlash("Copied!");
|
|
||||||
flashCopyFeedback($("export-privkey-value"));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$("btn-export-privkey-back").addEventListener("click", () => {
|
|
||||||
$("export-privkey-value").textContent = "";
|
|
||||||
$("export-privkey-password").value = "";
|
|
||||||
goBack();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { init, show };
|
module.exports = { init, show };
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const {
|
|||||||
hideError,
|
hideError,
|
||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
|
onViewLeave,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||||
const {
|
const {
|
||||||
@@ -461,7 +462,24 @@ function findActiveWallet() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drop the password from the DOM when either approval screen is left. The
|
||||||
|
// approval window navigates on after a signature — approve-tx goes to the
|
||||||
|
// wait screen — and the password must not sit in the hidden view for the
|
||||||
|
// life of that window.
|
||||||
|
function clearTxPassword() {
|
||||||
|
$("approve-tx-password").value = "";
|
||||||
|
hideError("approve-tx-error");
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSignPassword() {
|
||||||
|
$("approve-sign-password").value = "";
|
||||||
|
hideError("approve-sign-error");
|
||||||
|
}
|
||||||
|
|
||||||
function init(ctx) {
|
function init(ctx) {
|
||||||
|
onViewLeave("approve-tx", clearTxPassword);
|
||||||
|
onViewLeave("approve-sign", clearSignPassword);
|
||||||
|
|
||||||
$("approve-remember").addEventListener("change", async () => {
|
$("approve-remember").addEventListener("change", async () => {
|
||||||
state.rememberSiteChoice = $("approve-remember").checked;
|
state.rememberSiteChoice = $("approve-remember").checked;
|
||||||
await saveState();
|
await saveState();
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const {
|
|||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
goBack,
|
goBack,
|
||||||
|
onViewLeave,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state, currentNetwork } = require("../../shared/state");
|
const { state, currentNetwork } = require("../../shared/state");
|
||||||
const { getSignerForAddress } = require("../../shared/wallet");
|
const { getSignerForAddress } = require("../../shared/wallet");
|
||||||
@@ -390,7 +391,17 @@ async function checkRecipientHistory(txInfo) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drop the password from the DOM. Registered as the view-leave handler so
|
||||||
|
// it does not sit in the hidden view once the screen navigates on — to the
|
||||||
|
// wait screen after a send, or anywhere else the user goes.
|
||||||
|
function clearPassword() {
|
||||||
|
$("confirm-tx-password").value = "";
|
||||||
|
hideError("confirm-tx-password-error");
|
||||||
|
}
|
||||||
|
|
||||||
function init(ctx) {
|
function init(ctx) {
|
||||||
|
onViewLeave("confirm-tx", clearPassword);
|
||||||
|
|
||||||
$("btn-confirm-send").addEventListener("click", async () => {
|
$("btn-confirm-send").addEventListener("click", async () => {
|
||||||
const password = $("confirm-tx-password").value;
|
const password = $("confirm-tx-password").value;
|
||||||
if (!password) {
|
if (!password) {
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
const { $, showView, showFlash, goBack, clearViewStack } = require("./helpers");
|
const {
|
||||||
|
$,
|
||||||
|
showView,
|
||||||
|
showFlash,
|
||||||
|
goBack,
|
||||||
|
clearViewStack,
|
||||||
|
onViewLeave,
|
||||||
|
} = require("./helpers");
|
||||||
const { state, saveState } = require("../../shared/state");
|
const { state, saveState } = require("../../shared/state");
|
||||||
const { decryptWithPassword } = require("../../shared/vault");
|
const { decryptWithPassword } = require("../../shared/vault");
|
||||||
const {
|
const {
|
||||||
@@ -9,22 +16,34 @@ const {
|
|||||||
let deleteWalletIndex = null;
|
let deleteWalletIndex = null;
|
||||||
let ctx = null;
|
let ctx = null;
|
||||||
|
|
||||||
|
// 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";
|
||||||
|
}
|
||||||
|
|
||||||
function show(walletIdx) {
|
function show(walletIdx) {
|
||||||
|
clear();
|
||||||
deleteWalletIndex = walletIdx;
|
deleteWalletIndex = walletIdx;
|
||||||
const wallet = state.wallets[walletIdx];
|
const wallet = state.wallets[walletIdx];
|
||||||
$("delete-wallet-name").textContent =
|
$("delete-wallet-name").textContent =
|
||||||
wallet.name || "Wallet " + (walletIdx + 1);
|
wallet.name || "Wallet " + (walletIdx + 1);
|
||||||
$("delete-wallet-password").value = "";
|
|
||||||
$("delete-wallet-flash").textContent = "";
|
|
||||||
$("delete-wallet-flash").style.visibility = "hidden";
|
|
||||||
showView("delete-wallet-confirm");
|
showView("delete-wallet-confirm");
|
||||||
}
|
}
|
||||||
|
|
||||||
function init(_ctx) {
|
function init(_ctx) {
|
||||||
ctx = _ctx;
|
ctx = _ctx;
|
||||||
|
|
||||||
|
onViewLeave("delete-wallet-confirm", clear);
|
||||||
|
|
||||||
|
// No wipe here: goBack() routes through showView(), which runs the
|
||||||
|
// leave hook.
|
||||||
$("btn-delete-wallet-back").addEventListener("click", () => {
|
$("btn-delete-wallet-back").addEventListener("click", () => {
|
||||||
deleteWalletIndex = null;
|
|
||||||
goBack();
|
goBack();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
174
src/popup/views/exportPrivkey.js
Normal file
174
src/popup/views/exportPrivkey.js
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
// 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 };
|
||||||
331
tests/exportPrivkey.test.js
Normal file
331
tests/exportPrivkey.test.js
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
// Tests for the private key export screen (issue #221).
|
||||||
|
//
|
||||||
|
// The screen holds the one secret that owns an address outright, so what is
|
||||||
|
// pinned here is disposal: the key is wiped from the DOM whenever the screen
|
||||||
|
// is left by any route, and a decrypt still in flight when the screen is
|
||||||
|
// left never writes at all. That last case is the one a per-button wipe and
|
||||||
|
// a naive leave hook both miss — the write lands after the wipe, with
|
||||||
|
// nothing scheduled to wipe it again.
|
||||||
|
//
|
||||||
|
// The view is driven against a minimal DOM stub rather than a real browser:
|
||||||
|
// the module is deliberately shaped like src/popup/views/showPhrase.js, with
|
||||||
|
// no dependency that needs a document beyond the nodes it reads and writes.
|
||||||
|
|
||||||
|
const mockPrivateKey = "0x" + "ab".repeat(32);
|
||||||
|
|
||||||
|
jest.mock("ethereum-blockies-base64", () => () => "data:image/png;base64,x");
|
||||||
|
jest.mock("../src/shared/vault", () => ({
|
||||||
|
decryptWithPassword: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock("../src/shared/wallet", () => ({
|
||||||
|
getSignerForAddress: jest.fn(() => ({ privateKey: mockPrivateKey })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
|
||||||
|
|
||||||
|
const VIEW = "export-privkey";
|
||||||
|
const PASSWORD = "correct horse battery";
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ DOM stub
|
||||||
|
|
||||||
|
function makeElement(id, withParent) {
|
||||||
|
const classes = new Set();
|
||||||
|
const el = {
|
||||||
|
id,
|
||||||
|
textContent: "",
|
||||||
|
value: "",
|
||||||
|
innerHTML: "",
|
||||||
|
disabled: false,
|
||||||
|
style: {},
|
||||||
|
dataset: {},
|
||||||
|
listeners: {},
|
||||||
|
classList: {
|
||||||
|
add: (...names) => names.forEach((n) => classes.add(n)),
|
||||||
|
remove: (...names) => names.forEach((n) => classes.delete(n)),
|
||||||
|
contains: (n) => classes.has(n),
|
||||||
|
toggle: (n, force) => {
|
||||||
|
const on = force === undefined ? !classes.has(n) : force;
|
||||||
|
if (on) classes.add(n);
|
||||||
|
else classes.delete(n);
|
||||||
|
return on;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
addEventListener: (name, fn) => {
|
||||||
|
el.listeners[name] = el.listeners[name] || [];
|
||||||
|
el.listeners[name].push(fn);
|
||||||
|
},
|
||||||
|
appendChild: () => {},
|
||||||
|
remove: () => {},
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
};
|
||||||
|
el.parentElement = withParent ? makeElement(id + "-parent", false) : null;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDocument() {
|
||||||
|
const els = new Map();
|
||||||
|
return {
|
||||||
|
getElementById(id) {
|
||||||
|
// The debug banner is created on demand by helpers.js; absent
|
||||||
|
// is the state a non-debug, non-testnet popup is in.
|
||||||
|
if (id === "debug-banner") return null;
|
||||||
|
if (!els.has(id)) els.set(id, makeElement(id, true));
|
||||||
|
return els.get(id);
|
||||||
|
},
|
||||||
|
createElement: () => makeElement("created", false),
|
||||||
|
addEventListener: () => {},
|
||||||
|
body: { prepend: () => {} },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ harness
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
jest.resetModules();
|
||||||
|
globalThis.chrome = {
|
||||||
|
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||||
|
};
|
||||||
|
globalThis.document = makeDocument();
|
||||||
|
|
||||||
|
const helpers = require("../src/popup/views/helpers");
|
||||||
|
const { state } = require("../src/shared/state");
|
||||||
|
const vault = require("../src/shared/vault");
|
||||||
|
const wallet = require("../src/shared/wallet");
|
||||||
|
const exportPrivkey = require("../src/popup/views/exportPrivkey");
|
||||||
|
|
||||||
|
state.wallets = [
|
||||||
|
{
|
||||||
|
name: "Wallet 1",
|
||||||
|
type: "key",
|
||||||
|
encryptedSecret: "ciphertext",
|
||||||
|
addresses: [
|
||||||
|
{
|
||||||
|
address: "0x" + "11".repeat(20),
|
||||||
|
balance: "0.0000",
|
||||||
|
tokenBalances: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
address: "0x" + "22".repeat(20),
|
||||||
|
balance: "0.0000",
|
||||||
|
tokenBalances: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
state.viewStack = [];
|
||||||
|
state.currentView = "address";
|
||||||
|
|
||||||
|
exportPrivkey.init();
|
||||||
|
return { helpers, state, vault, wallet, exportPrivkey };
|
||||||
|
}
|
||||||
|
|
||||||
|
function click(id) {
|
||||||
|
const el = globalThis.document.getElementById(id);
|
||||||
|
return Promise.all((el.listeners.click || []).map((fn) => fn()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function node(id) {
|
||||||
|
return globalThis.document.getElementById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start a reveal and hand back both the promise it returns and the resolver
|
||||||
|
// for the decrypt it is waiting on, so a test can navigate away mid-flight.
|
||||||
|
function startReveal(vault) {
|
||||||
|
let resolveDecrypt;
|
||||||
|
let rejectDecrypt;
|
||||||
|
vault.decryptWithPassword.mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
resolveDecrypt = resolve;
|
||||||
|
rejectDecrypt = reject;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
node("export-privkey-password").value = PASSWORD;
|
||||||
|
const pending = click("btn-export-privkey-confirm");
|
||||||
|
return {
|
||||||
|
pending,
|
||||||
|
resolve: (v) => resolveDecrypt(v),
|
||||||
|
reject: (e) => rejectDecrypt(e),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ tests
|
||||||
|
|
||||||
|
describe("a decrypt still running when the screen is left", () => {
|
||||||
|
// The load-bearing case. Without the liveness guard in reveal(), the
|
||||||
|
// write lands after the leave hook has already wiped, and the key sits
|
||||||
|
// in the hidden view for the life of the popup.
|
||||||
|
test("never writes the key into the DOM", async () => {
|
||||||
|
const { helpers, vault, wallet, exportPrivkey } = load();
|
||||||
|
exportPrivkey.show(0, 0);
|
||||||
|
|
||||||
|
const reveal = startReveal(vault);
|
||||||
|
// The settings gear, mid-decrypt.
|
||||||
|
helpers.showView("settings");
|
||||||
|
reveal.resolve("wallet secret");
|
||||||
|
await reveal.pending;
|
||||||
|
|
||||||
|
expect(node("export-privkey-value").textContent).toBe("");
|
||||||
|
// Nothing was even derived: the guard sits in front of the
|
||||||
|
// derivation, not just in front of the write.
|
||||||
|
expect(wallet.getSignerForAddress).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The generation counter, not merely the current-view check: by the time
|
||||||
|
// the stale decrypt resolves the user is back on the screen, so a guard
|
||||||
|
// that only asked "is this view showing?" would let the write through.
|
||||||
|
test("never writes it after the screen is re-entered", async () => {
|
||||||
|
const { helpers, vault, exportPrivkey } = load();
|
||||||
|
exportPrivkey.show(0, 0);
|
||||||
|
|
||||||
|
const stale = startReveal(vault);
|
||||||
|
helpers.showView("settings");
|
||||||
|
exportPrivkey.show(0, 1);
|
||||||
|
expect(node("export-privkey-value").textContent).toBe("");
|
||||||
|
|
||||||
|
stale.resolve("wallet secret");
|
||||||
|
await stale.pending;
|
||||||
|
|
||||||
|
expect(node("export-privkey-value").textContent).toBe("");
|
||||||
|
expect(node("export-privkey-result").classList.contains("hidden")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Same hole on the failure path: a wrong-password error written after
|
||||||
|
// the wipe would restore the flash line on a screen the user has left.
|
||||||
|
test("never writes the failure message either", async () => {
|
||||||
|
const { helpers, vault, exportPrivkey } = load();
|
||||||
|
exportPrivkey.show(0, 0);
|
||||||
|
|
||||||
|
const reveal = startReveal(vault);
|
||||||
|
helpers.showView("settings");
|
||||||
|
reveal.reject(new Error("decryption failed"));
|
||||||
|
await reveal.pending;
|
||||||
|
|
||||||
|
expect(node("export-privkey-flash").textContent).toBe("");
|
||||||
|
expect(node("export-privkey-flash").style.visibility).toBe("hidden");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a reveal that is not interrupted", () => {
|
||||||
|
// Guards the guard: a liveness check that rejected every write would
|
||||||
|
// pass every test above and ship a screen that reveals nothing.
|
||||||
|
test("puts the key on screen", async () => {
|
||||||
|
const { vault, exportPrivkey } = load();
|
||||||
|
exportPrivkey.show(0, 0);
|
||||||
|
|
||||||
|
const reveal = startReveal(vault);
|
||||||
|
reveal.resolve("wallet secret");
|
||||||
|
await reveal.pending;
|
||||||
|
|
||||||
|
expect(node("export-privkey-value").textContent).toBe(mockPrivateKey);
|
||||||
|
expect(node("export-privkey-result").classList.contains("hidden")).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
// The password is dropped as soon as it has been spent.
|
||||||
|
expect(node("export-privkey-password").value).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("writes nothing before the password is accepted", async () => {
|
||||||
|
const { vault, exportPrivkey } = load();
|
||||||
|
exportPrivkey.show(0, 0);
|
||||||
|
|
||||||
|
const reveal = startReveal(vault);
|
||||||
|
expect(node("export-privkey-value").textContent).toBe("");
|
||||||
|
reveal.resolve("wallet secret");
|
||||||
|
await reveal.pending;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reveals nothing when the password is wrong", async () => {
|
||||||
|
const { vault, exportPrivkey } = load();
|
||||||
|
exportPrivkey.show(0, 0);
|
||||||
|
|
||||||
|
const reveal = startReveal(vault);
|
||||||
|
reveal.reject(new Error("decryption failed"));
|
||||||
|
await reveal.pending;
|
||||||
|
|
||||||
|
expect(node("export-privkey-value").textContent).toBe("");
|
||||||
|
expect(node("export-privkey-flash").textContent).toBe(
|
||||||
|
"That password is not correct. Please try again.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("leaving the screen after the key is on it", () => {
|
||||||
|
async function revealed() {
|
||||||
|
const loaded = load();
|
||||||
|
loaded.exportPrivkey.show(0, 0);
|
||||||
|
const reveal = startReveal(loaded.vault);
|
||||||
|
reveal.resolve("wallet secret");
|
||||||
|
await reveal.pending;
|
||||||
|
expect(node("export-privkey-value").textContent).toBe(mockPrivateKey);
|
||||||
|
return loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the Back button clears the key", async () => {
|
||||||
|
await revealed();
|
||||||
|
await click("btn-export-privkey-back");
|
||||||
|
|
||||||
|
expect(node("export-privkey-value").textContent).toBe("");
|
||||||
|
expect(node("export-privkey-password").value).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the settings gear clears the key", async () => {
|
||||||
|
const { helpers } = await revealed();
|
||||||
|
helpers.showView("settings");
|
||||||
|
|
||||||
|
expect(node("export-privkey-value").textContent).toBe("");
|
||||||
|
expect(node("export-privkey-password").value).toBe("");
|
||||||
|
// And the screen is back to its password prompt, not to a result
|
||||||
|
// panel that would flash an empty well on the next visit.
|
||||||
|
expect(node("export-privkey-result").classList.contains("hidden")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
node("export-privkey-password-section").classList.contains(
|
||||||
|
"hidden",
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Any other navigation: the same hook covers routes that do not exist
|
||||||
|
// yet, which is the point of registering it on the view rather than on
|
||||||
|
// the controls that leave it.
|
||||||
|
test("any other navigation clears the key", async () => {
|
||||||
|
const { helpers } = await revealed();
|
||||||
|
helpers.showView("main");
|
||||||
|
|
||||||
|
expect(node("export-privkey-value").textContent).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("views the popup may reopen onto", () => {
|
||||||
|
// Restoring onto this screen would put a private key on display with no
|
||||||
|
// password prompt in front of it, on a popup reopened by accident.
|
||||||
|
test("the private key export screen is not restorable", () => {
|
||||||
|
expect(RESTORABLE_VIEWS.has(VIEW)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it is still a registered view", () => {
|
||||||
|
const { helpers } = load();
|
||||||
|
expect(helpers.VIEWS).toContain(VIEW);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the key cannot reach the logger", () => {
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const source = fs.readFileSync(
|
||||||
|
path.join(__dirname, "..", "src", "popup", "views", "exportPrivkey.js"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
test("the view does not import src/shared/log.js", () => {
|
||||||
|
expect(source).not.toMatch(/require\(["'][^"']*shared\/log["']\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the view calls no logger method", () => {
|
||||||
|
expect(source).not.toMatch(/\blog\.(debugf|infof|warnf|errorf)\b/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user