fix: give a wallet whose password is lost a way out, and say the password cannot be reset (closes #312)
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:
@@ -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"
|
||||
>
|
||||
< 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 ============ -->
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 +
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ const VIEWS = [
|
||||
"add-token",
|
||||
"settings",
|
||||
"delete-wallet-confirm",
|
||||
"delete-wallet-lost-password",
|
||||
"delete-address-confirm",
|
||||
"settings-addtoken",
|
||||
"transaction",
|
||||
|
||||
Reference in New Issue
Block a user