fix: give a wallet whose password is lost a way out, and say the password cannot be reset (closes #312)
All checks were successful
check / check (push) Successful in 31s
e2e / e2e-chrome (push) Successful in 1m10s
e2e / e2e-firefox (push) Successful in 23s

A user who forgot their password but held their recovery phrase was permanently
locked out: deletion was password-gated and re-importing the phrase was refused
as a duplicate. Their only escape was destroying extension storage through
browser internals, taking every other wallet with it.

DeleteWallet gains an "I have lost my password" route that destroys the stored
secret after the wallet's name is typed back. No password gate was added:
requiring one to discard a secret protects nothing, since an attacker who wants
destruction can uninstall the extension, and the only person it stops is the
legitimate user who lost it. The screen is excluded from RESTORABLE_VIEWS and
registers an onViewLeave cleanup.

Deletion was chosen over re-import because a key wallet is duplicate-checked by
address rather than xpub, so an xpub-only relaxation would leave that user
still wedged; because re-import makes the user retype their recovery phrase
into a live popup merely to change a password; and because it reaches no end
state that delete-then-import plus scanForAddresses() does not. The attacker
argument did not decide it — re-import clears the "no worse than the phrase
alone" bar.

All three AddWallet password hints now state the password cannot be recovered
or reset and name that mode's only backup, the xprv mode correctly claiming no
recovery phrase. deleteAddress.js no longer tells the user that deleting a
wallet asks for a password, which this change made false.

The typed confirmation collapses internal whitespace on both sides: a wallet
renamed with two spaces displays with one, so the string a user could see and
type could never match, making the confirmation untypable on the one screen
whose purpose is un-wedging a stuck user.

Measured, not reasoned, after review found the first reserve twice too large
and pushing the Import button below the fold: #btn-add-wallet-confirm bottom
628.13 -> 580.13 at 360x600, scrollHeight 636 -> 600, hint box 48px identical
across all three tabs and on re-entry. make check 40 suites / 828 tests,
test-e2e 55/55, test-e2e-firefox 8/8.
This commit was merged in pull request #334.
This commit is contained in:
2026-08-20 15:12:28 +02:00
parent aea999db85
commit 20e911059a
9 changed files with 853 additions and 52 deletions

View File

@@ -153,12 +153,28 @@
<!-- Shared password fields -->
<div class="mb-2" id="add-wallet-password-section">
<label class="block mb-1">Choose a password</label>
<!-- The hint is swapped in place when the import tab
changes, and it sits directly above the password
fields, so a wording that wraps to a different
number of lines would move them under the pointer.
Two things stop that: the three wordings in
PASSWORD_HINTS are kept within a couple of
characters of each other in length, and this floor
matches what each of them needs. All three measure
48px -- 3 lines at the 16px line height, at the
368px width this box has in the 396px popup body.
Do not raise it: the reserve is unused height on
every tab, and at 6rem it pushed
#btn-add-wallet-confirm to bottom=628px in a 600px
viewport, below the fold. -->
<p
class="text-xs text-muted mb-1"
class="text-xs text-muted mb-1 min-h-[3rem]"
id="add-wallet-password-hint"
>
This password encrypts your recovery phrase on this
device. You will need it to send funds.
device. You will need it to send funds. It cannot be
recovered or reset, so keep your recovery phrase written
down: it is the only backup of this wallet.
</p>
<input
type="password"
@@ -1140,6 +1156,71 @@
>
Confirm Delete
</button>
<p class="text-xs mt-3">
<span
id="btn-delete-wallet-lost-password"
class="underline decoration-dashed cursor-pointer"
>I have lost my password</span
>
</p>
</div>
<!-- ============ DELETE WALLET WITHOUT THE PASSWORD ============ -->
<div id="view-delete-wallet-lost-password" class="view hidden">
<button
id="btn-delete-wallet-lost-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
>
&lt; Back
</button>
<h2 class="font-bold mb-3">Delete Wallet Without a Password</h2>
<p class="text-xs mb-2">
Your password cannot be recovered or reset, so there is no
way to unlock
<strong id="delete-wallet-lost-name"></strong> again. You
can still delete it, and no password is needed to do that.
</p>
<p class="text-xs mb-2">
Deleting it erases the copy of its key that is stored on
this device. Nothing on the blockchain changes, and the
money at its addresses is not moved or destroyed.
</p>
<p class="text-xs mb-2">
If you have the recovery phrase for this wallet written
down, add the wallet again afterwards with a new password
and you will have it back.
<strong
>If you do not have it written down, deleting this
wallet means losing everything it holds,
forever.</strong
>
</p>
<p class="text-xs mb-3">Your other wallets are not touched.</p>
<p class="text-xs mb-1">
To confirm, type the name of the wallet (<strong
id="delete-wallet-lost-name-echo"
></strong
>) below.
</p>
<div class="mb-2">
<input
type="text"
id="delete-wallet-lost-name-input"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="Type the wallet name"
/>
</div>
<div
id="delete-wallet-lost-flash"
class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
style="visibility: hidden"
></div>
<button
id="btn-delete-wallet-lost-confirm"
class="border border-border text-red-500 px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Delete This Wallet Forever
</button>
</div>
<!-- ============ DELETE ADDRESS CONFIRM ============ -->

View File

@@ -10,6 +10,11 @@
// prompt in front of it, on a popup the user may have reopened by accident.
// That is why "export-privkey" and "show-phrase" are absent.
//
// Nor may a view whose button destroys a wallet be listed, for the mirror
// reason: a popup reopened by accident must not land on the screen that
// erases key material. That is why "delete-wallet-confirm" and
// "delete-wallet-lost-password" are absent.
//
// Kept in its own module, with no dependencies, so tests can assert the
// exclusion directly rather than trusting a reading of the popup entry
// point, which cannot be required outside a browser.

View File

@@ -42,12 +42,24 @@ let currentMode = "mnemonic";
const MODES = ["mnemonic", "privkey", "xprv"];
// Each hint names what this import mode's own backup is, because a key
// wallet and an xprv wallet have no recovery phrase to point the user at.
// All three say the same thing about the password: it is gone for good if
// it is forgotten. That sentence is the only warning the user gets before
// the wallet exists, and without it the lost-password route in
// views/deleteWallet.js is the first they hear of it.
//
// Keep the three within a couple of characters of each other in length.
// The hint sits directly above the password fields and the tabs swap it in
// place, so a wording that wraps to a different number of lines would move
// those fields under the pointer; the reserved height on
// #add-wallet-password-hint is the other half of that guarantee.
const PASSWORD_HINTS = {
mnemonic:
"This password encrypts your recovery phrase on this device. You will need it to send funds.",
"This password encrypts your recovery phrase on this device. You will need it to send funds. It cannot be recovered or reset, so keep your recovery phrase written down: it is the only backup of this wallet.",
privkey:
"This password encrypts your private key on this device. You will need it to send funds.",
xprv: "This password encrypts your key on this device. You will need it to send funds.",
"This password encrypts your private key on this device. You will need it to send funds. It cannot be recovered or reset, so keep your private key saved somewhere safe: it is the only backup of this wallet.",
xprv: "This password encrypts your key on this device. You will need it to send funds. It cannot be recovered or reset, so keep your extended private key saved somewhere safe: it is the only backup of this wallet.",
};
function switchMode(mode) {

View File

@@ -45,8 +45,8 @@ function setFlash(msg) {
// wallet.nextIndex is a high-water mark and is deliberately not rewound; and
// re-importing this wallet's key material is refused as a duplicate by
// findWalletByXpub() for as long as the wallet is here. What remains is to
// delete the whole wallet in Settings — which asks for the password and
// destroys the stored secret — and import again, after which
// delete the whole wallet in Settings — which destroys the stored secret,
// with or without the password — and import again, after which
// scanForAddresses() rediscovers the address only if it has on-chain
// activity. An address that was never used is not found by that scan, and
// the copy must not imply otherwise.
@@ -63,8 +63,7 @@ function recoveryPathText(wallet) {
"importing this " +
secret +
" again is refused while this wallet is still here. The way back is " +
"to delete the whole wallet in Settings, which asks for your " +
"password and destroys the stored " +
"to delete the whole wallet in Settings, which destroys the stored " +
secret +
", and then import that " +
secret +

View File

@@ -14,8 +14,29 @@ const {
} = require("../../shared/walletDelete");
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.
function displayName(walletIdx) {
const wallet = state.wallets[walletIdx];
return (wallet && wallet.name) || "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. Comparing collapsed 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.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
@@ -27,19 +48,89 @@ function clear() {
$("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. The button is
// re-enabled here too, so a screen left mid-delete is usable on re-entry.
function clearLostPassword() {
lostPasswordIndex = null;
$("delete-wallet-lost-name-input").value = "";
$("delete-wallet-lost-flash").textContent = "";
$("delete-wallet-lost-flash").style.visibility = "hidden";
const btn = $("btn-delete-wallet-lost-confirm");
btn.disabled = false;
btn.classList.remove("text-muted");
}
function show(walletIdx) {
clear();
deleteWalletIndex = walletIdx;
const wallet = state.wallets[walletIdx];
$("delete-wallet-name").textContent =
wallet.name || "Wallet " + (walletIdx + 1);
$("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) {
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.
@@ -47,6 +138,60 @@ function init(_ctx) {
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 and repeated inner spaces 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.
const typed = $("delete-wallet-lost-name-input").value;
const expected = displayName(lostPasswordIndex);
if (confirmKey(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() navigates, and the leave hook re-enables the
// button and wipes the typed name on the way out.
await finishDelete(lostPasswordIndex);
});
$("btn-delete-wallet-confirm").addEventListener("click", async () => {
const pw = $("delete-wallet-password").value;
if (!pw) {
@@ -82,34 +227,7 @@ function init(_ctx) {
return;
}
// Remove the wallet and repair selection, permissions and hasWallet
const { activeAddressChanged } = removeWalletFromState(
state,
walletIdx,
);
deleteWalletIndex = 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");
} else {
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.");
}
await finishDelete(walletIdx);
});
}

View File

@@ -36,6 +36,7 @@ const VIEWS = [
"add-token",
"settings",
"delete-wallet-confirm",
"delete-wallet-lost-password",
"delete-address-confirm",
"settings-addtoken",
"transaction",