A wallet named only with spaces compared equal to an empty field, so typing nothing would have deleted it, and a zero-width space in a name made the name impossible to type back. An empty typed confirmation is now refused whatever the name is. The characters src/shared/symbolSpoof.js already defines as painting nothing are removed from both sides before comparing. A name that shows nothing at all is shown on the delete screens as "Wallet N", so it can still be typed back. Model: opus-5-5
255 lines
9.8 KiB
JavaScript
255 lines
9.8 KiB
JavaScript
const {
|
|
$,
|
|
showView,
|
|
showFlash,
|
|
goBack,
|
|
clearViewStack,
|
|
onViewLeave,
|
|
} = require("./helpers");
|
|
const { state, saveState } = require("../../shared/state");
|
|
const { decryptWithPassword } = require("../../shared/vault");
|
|
const {
|
|
removeWalletFromState,
|
|
broadcastActiveChanged,
|
|
} = require("../../shared/walletDelete");
|
|
const { INVISIBLE_CHARACTERS } = require("../../shared/symbolSpoof");
|
|
|
|
let deleteWalletIndex = null;
|
|
let lostPasswordIndex = null;
|
|
let ctx = null;
|
|
|
|
// The name shown for a wallet, and on the lost-password screen the string
|
|
// the user has to type back. One function so the two cannot disagree: a
|
|
// confirmation that asks for a name other than the one on screen is
|
|
// unusable. A name that shows nothing at all (only spaces, or only
|
|
// zero-width characters) is replaced by "Wallet N" for the same reason:
|
|
// there would be nothing on screen to type back.
|
|
function displayName(walletIdx) {
|
|
const wallet = state.wallets[walletIdx];
|
|
const name = wallet && wallet.name;
|
|
if (name && confirmKey(name)) return name;
|
|
return "Wallet " + (walletIdx + 1);
|
|
}
|
|
|
|
// What the typed confirmation and the wallet name are compared as. HTML
|
|
// collapses runs of whitespace when it renders the name, so a wallet named
|
|
// "My Wallet" with two spaces DISPLAYS as "My Wallet": the user cannot
|
|
// see the second space and cannot type a string that matches the stored
|
|
// name. Characters that paint nothing, such as a zero-width space, are
|
|
// invisible the same way and are removed first. Comparing this form on
|
|
// both sides is what keeps the confirmation satisfiable, on the one screen
|
|
// whose whole purpose is unwedging a user who is already stuck. Case and
|
|
// surrounding space go the same way.
|
|
function confirmKey(name) {
|
|
return name
|
|
.replace(INVISIBLE_CHARACTERS, "")
|
|
.trim()
|
|
.replace(/\s+/g, " ")
|
|
.toLowerCase();
|
|
}
|
|
|
|
// 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";
|
|
}
|
|
|
|
// The lost-password screen holds no secret — a wallet name is not one —
|
|
// but it is wiped on leave for the neighbouring reason: a typed
|
|
// confirmation left standing in a hidden view is one click away from
|
|
// destroying a wallet the user has since navigated off.
|
|
function clearLostPassword() {
|
|
lostPasswordIndex = null;
|
|
$("delete-wallet-lost-name-input").value = "";
|
|
$("delete-wallet-lost-flash").textContent = "";
|
|
$("delete-wallet-lost-flash").style.visibility = "hidden";
|
|
}
|
|
|
|
function show(walletIdx) {
|
|
clear();
|
|
deleteWalletIndex = walletIdx;
|
|
$("delete-wallet-name").textContent = displayName(walletIdx);
|
|
showView("delete-wallet-confirm");
|
|
}
|
|
|
|
// The two delete screens are siblings, not parent and child: nothing is
|
|
// pushed on the way here, and Back goes to show() rather than goBack().
|
|
// Both then have the same Back target — Settings, the screen that pushed
|
|
// delete-wallet-confirm — and re-entering through show() hands the confirm
|
|
// screen its wallet selection back, which a bare goBack() onto a view
|
|
// whose leave hook has already nulled that selection would not.
|
|
function showLostPassword() {
|
|
const walletIdx = deleteWalletIndex;
|
|
if (walletIdx === null) {
|
|
goBack();
|
|
return;
|
|
}
|
|
const name = displayName(walletIdx);
|
|
clearLostPassword();
|
|
$("delete-wallet-lost-name").textContent = name;
|
|
$("delete-wallet-lost-name-echo").textContent = name;
|
|
// showView() runs the leave hook of delete-wallet-confirm, which nulls
|
|
// deleteWalletIndex, so this screen's own selection is recorded after
|
|
// it and not before.
|
|
showView("delete-wallet-lost-password");
|
|
lostPasswordIndex = walletIdx;
|
|
}
|
|
|
|
// Remove the wallet and put the user somewhere sensible. Shared by both
|
|
// routes onto this screen, so the selection repair, the site-permission
|
|
// cleanup and the accountsChanged broadcast cannot drift apart between
|
|
// them.
|
|
async function finishDelete(walletIdx) {
|
|
// Each route's confirm button was disabled by its own click handler
|
|
// before the delete ran. Re-enable both here, on the one path they
|
|
// share, so the two routes reset the same way and a second delete in
|
|
// the same popup session finds a live button instead of a dead one.
|
|
const passwordBtn = $("btn-delete-wallet-confirm");
|
|
passwordBtn.disabled = false;
|
|
passwordBtn.classList.remove("text-muted");
|
|
const lostPasswordBtn = $("btn-delete-wallet-lost-confirm");
|
|
lostPasswordBtn.disabled = false;
|
|
lostPasswordBtn.classList.remove("text-muted");
|
|
|
|
const { activeAddressChanged } = removeWalletFromState(state, walletIdx);
|
|
|
|
deleteWalletIndex = null;
|
|
lostPasswordIndex = null;
|
|
|
|
if (!state.hasWallet) {
|
|
clearViewStack();
|
|
await saveState();
|
|
// Save before broadcasting: the background reads the active
|
|
// address back out of storage to build accountsChanged.
|
|
if (activeAddressChanged) broadcastActiveChanged();
|
|
showView("welcome");
|
|
return;
|
|
}
|
|
|
|
await saveState();
|
|
if (activeAddressChanged) broadcastActiveChanged();
|
|
// Reset stack to [main] so Settings back goes home.
|
|
// Use require() lazily to avoid circular dependency
|
|
// (settings.js requires deleteWallet.js).
|
|
clearViewStack();
|
|
state.viewStack.push("main");
|
|
ctx.renderWalletList();
|
|
const settings = require("./settings");
|
|
settings.show();
|
|
showFlash("Wallet deleted.");
|
|
}
|
|
|
|
function init(_ctx) {
|
|
ctx = _ctx;
|
|
|
|
onViewLeave("delete-wallet-confirm", clear);
|
|
onViewLeave("delete-wallet-lost-password", clearLostPassword);
|
|
|
|
// No wipe here: goBack() routes through showView(), which runs the
|
|
// leave hook.
|
|
$("btn-delete-wallet-back").addEventListener("click", () => {
|
|
goBack();
|
|
});
|
|
|
|
// The escape hatch, and deliberately not gated on anything a user who
|
|
// has lost the password cannot produce. A password in front of
|
|
// DISCARDING a secret protects nobody: an attacker at the popup who
|
|
// wants the wallet gone can uninstall the extension, so the only
|
|
// person such a gate stops is the owner who forgot it — and before
|
|
// this route existed that owner could neither delete the wallet nor
|
|
// import its recovery phrase again, because AddWallet refuses the xpub
|
|
// as a duplicate while the wallet is still stored.
|
|
$("btn-delete-wallet-lost-password").addEventListener("click", () => {
|
|
showLostPassword();
|
|
});
|
|
|
|
$("btn-delete-wallet-lost-back").addEventListener("click", () => {
|
|
const walletIdx = lostPasswordIndex;
|
|
if (walletIdx === null) {
|
|
goBack();
|
|
return;
|
|
}
|
|
show(walletIdx);
|
|
});
|
|
|
|
$("btn-delete-wallet-lost-confirm").addEventListener("click", async () => {
|
|
if (lostPasswordIndex === null) {
|
|
$("delete-wallet-lost-flash").textContent =
|
|
"No wallet selected for deletion.";
|
|
$("delete-wallet-lost-flash").style.visibility = "visible";
|
|
return;
|
|
}
|
|
|
|
// Case, surrounding spaces, repeated inner spaces and invisible
|
|
// characters are not part of the confirmation; see confirmKey().
|
|
// This asks whether the user knows which wallet they are on; it is
|
|
// not a secret, and refusing "wallet 2" for "Wallet 2" would only
|
|
// teach the user to distrust the control. An empty field is
|
|
// refused whatever the wallet is called, so no stored name can
|
|
// ever be confirmed by typing nothing.
|
|
const typed = confirmKey($("delete-wallet-lost-name-input").value);
|
|
const expected = displayName(lostPasswordIndex);
|
|
if (typed === "" || typed !== confirmKey(expected)) {
|
|
$("delete-wallet-lost-flash").textContent =
|
|
"That is not the name of this wallet. Type " +
|
|
expected +
|
|
" to confirm.";
|
|
$("delete-wallet-lost-flash").style.visibility = "visible";
|
|
return;
|
|
}
|
|
|
|
const btn = $("btn-delete-wallet-lost-confirm");
|
|
btn.disabled = true;
|
|
btn.classList.add("text-muted");
|
|
|
|
// finishDelete() re-enables the button; navigating away then runs
|
|
// the leave hook that wipes the typed name.
|
|
await finishDelete(lostPasswordIndex);
|
|
});
|
|
|
|
$("btn-delete-wallet-confirm").addEventListener("click", async () => {
|
|
const pw = $("delete-wallet-password").value;
|
|
if (!pw) {
|
|
$("delete-wallet-flash").textContent =
|
|
"Please enter your password.";
|
|
$("delete-wallet-flash").style.visibility = "visible";
|
|
return;
|
|
}
|
|
|
|
if (deleteWalletIndex === null) {
|
|
$("delete-wallet-flash").textContent =
|
|
"No wallet selected for deletion.";
|
|
$("delete-wallet-flash").style.visibility = "visible";
|
|
return;
|
|
}
|
|
|
|
const btn = $("btn-delete-wallet-confirm");
|
|
btn.disabled = true;
|
|
btn.classList.add("text-muted");
|
|
|
|
const walletIdx = deleteWalletIndex;
|
|
const wallet = state.wallets[walletIdx];
|
|
|
|
// Verify password against the wallet's encrypted data
|
|
try {
|
|
await decryptWithPassword(wallet.encryptedSecret, pw);
|
|
} catch {
|
|
$("delete-wallet-flash").textContent =
|
|
"That password is incorrect. Please try again.";
|
|
$("delete-wallet-flash").style.visibility = "visible";
|
|
btn.disabled = false;
|
|
btn.classList.remove("text-muted");
|
|
return;
|
|
}
|
|
|
|
await finishDelete(walletIdx);
|
|
});
|
|
}
|
|
|
|
module.exports = { init, show };
|