fix: let a user who lost the password delete the wallet, and warn before they can (closes #312) #334
Reference in New Issue
Block a user
Delete Branch "issue-312-forgot-password"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #312.
The wedge
Deleting a wallet was password-gated, and importing its recovery phrase again was refused as a duplicate xpub by
findWalletByXpub(). A user holding the phrase but not the password could therefore neither leave the wallet nor come back to it; the only escape was clearing extension storage through browser internals, which takes every other wallet with it. Nothing in the product warned that this was possible.Which route, and why
The deletion route, not the re-import route. The DoD offers both and asks for one.
Re-import preserves funds access inside the product and destroys nothing, and it is the more attractive option on paper. Against it:
hdandxprvare duplicate-checked by xpub, but akeywallet is duplicate-checked by address, so a fix that only relaxesfindWalletByXpub()leaves the private-key user exactly as wedged as before.scanForAddresses()rediscovers the used addresses.On the attacker question the issue raises: an attacker with the phrase but not the password gains nothing from re-import that they do not already have, because they can import that phrase into any other wallet — the bar is "no worse than the phrase alone", and re-import clears it. So that is not what decided it. What decided it is that deletion covers the case re-import does not (a user who has lost the password and simply wants the wallet gone), needs no new duplicate-detection logic on three code paths, and is unambiguous about what happens.
The deletion route's own risk is the user not understanding what is destroyed, so the screen spends four short paragraphs on exactly that before it asks for anything.
What changed
New screen
delete-wallet-lost-password, reached from an underlined "I have lost my password" control on DeleteWallet. It states that the password cannot be recovered or reset; that deleting erases the copy of the key on this device and moves no money on chain; that with the recovery phrase written down the wallet comes back, and in bold that without it the deletion loses everything the wallet holds, forever; and that the other wallets are not touched. Confirmation is typing the wallet's name back.No password, and no new gate of any kind. 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. The typed name is a check that the user knows which wallet they are on rather than a secret, so it is matched with letter case, surrounding spaces and repeated inner spaces ignored — refusing
wallet 2forWallet 2would only teach the user to distrust the control, and the inner-space collapse is what keeps the confirmation satisfiable at all for a name HTML renders differently from how it is stored (see the rework note below). The existing password route on DeleteWallet is left exactly as it was; nothing was added in front of it.finishDelete()is shared by both routes, so the selection repair, the site-permission cleanup and theAUTISTMASK_ACTIVE_CHANGEDbroadcast cannot diverge between them. The password route's body is otherwise unchanged.AddWallet's password hint now states, per import mode, that the password cannot be recovered or reset and names what the only backup is — the recovery phrase, the private key or the extended private key. This is the small part and the most valuable one: it is the only warning a user gets before the wallet exists.
Doc correction in DeleteAddress:
recoveryPathText()told the user that deleting the whole wallet "asks for your password". That stopped being true with this change, so the clause is gone. It is copy describing the screen this PR changes, not a drive-by.Repo UI policies
visibility: hidden, as the neighbouring screens do. The AddWallet hint is swapped in place by the tabs and sits directly above the password fields, so it got a reserved height too, and the three wordings are kept within a couple of characters of each other in length. The reserve ismin-h-[3rem]= 48px, which is what all three wordings actually measure — see the geometry table below.cursor-pointer, matchingbtn-add-wallet-bottom, the repo's existing clickable-text control.onViewLeave()registered. Not because a wallet name is secret, but for the neighbouring reason: a typed confirmation left standing in a hidden view leaves a wallet one click from deletion. The hook also re-enables the confirm button, so a screen left mid-delete is usable on re-entry.RESTORABLE_VIEWS, alongsidedelete-wallet-confirm. A popup reopened by accident must not land on a button that erases key material.restorableViews.jsnow says so next to the existing secret-screen rule, and a test asserts both exclusions.show()rather thangoBack(), because a baregoBack()would land on a screen whose leave hook has already nulled the wallet selection — a Delete Wallet screen naming a wallet whose own button then answers "No wallet selected for deletion." A test drives that path and then presses the confirm button to prove the screen is usable, not merely on screen.Rework against the failing review
Two findings, both fixed. Nothing else in the change was touched:
git diff e54465a..HEADis three files —src/popup/index.html(+8/-2),src/popup/views/deleteWallet.js(+18/-6),tests/deleteWalletLostPassword.test.js(+27/-0).1. The hint reserve was 2x too large and pushed the primary button off screen
The
6remfloor was reasoned, not measured, and the reasoning was wrong in the direction that hurts. Measured in the real popup in Chromium at a 360x600 viewport (the hint box renders 368px wide inside the 396px popup body,line-height16px):6rem(before)3rem(after)#btn-add-wallet-confirmbottom, beforedocumentElement.scrollHeighton the default tab went from 636 to 600 — the wallet-creation screen no longer overflows the popup at all, and the Import button is inside the fold (580.13 <= 600) instead of entirely below it.The reserve is kept, only resized. It is doing its job, and still is: with
3remthe hint box measures 48px and the first password field sits 52px below the top of that box on all three tabs (with6remthose were 96px and 100px, equally constant across tabs). So switching tabs shifts the hint box and the fields under it by zero in both builds — that property is preserved, it is only the size that was wrong. Re-entering the default tab after visiting the other two measures identically. The comment above the element now records the measured 48px and why the floor must not be raised again, instead of the guess it used to record.Measured with an ad-hoc Playwright script run inside the same pinned
autistmask-e2e-chromeimage the suite uses (built from this tree, run by image ID,--rm, removed afterwards; the shared tags were not disturbed). Neither e2e suite catches this on its own, because Playwright auto-scrolls before it clicks — the geometry has to be read explicitly.2. The confirmation could be made untypable
deleteWallet.jscompared the typed string against an untrimmed, un-collapseddisplayName(). HTML collapses runs of whitespace when it renders, so a wallet namedMy Wallet(two spaces) displays asMy Wallet: the only string the user can see and type was the one string that could never match, and the confirmation on the one screen that exists to unwedge a stuck user became impossible to satisfy.Both sides now go through one
confirmKey()that trims, collapses inner whitespace runs to a single space, and lowercases. New test:a doubled space inside the name is typed back as onerenames a wallet toMy Wallet(two spaces), asserts the DOM was handed both spaces, types the single-space form, and asserts the wallet is gone from persisted storage.Verification
make check— green, exit 0, on the pushed commit. 40 suites, 828 tests (827 before, +1 for the new whitespace case);test-verify-build39 cases;check-censored150 files; lint ran in the container, and the[lint 1/1] RUN make lintlayer executed rather than reportingCACHED.Both e2e suites run and green on this head, in the pinned containers:
make test-e2e55/55,make test-e2e-firefox8/8. (The earlier disclosure that the browser suites had not been run no longer applies.)New suite
tests/deleteWalletLostPassword.test.jsdrives the real view against a DOM stub and achrome.storage.localstub that structured-clones on bothsetandget, and asserts against what comes back out of storage rather than against the livestateobject. The first test in the file pins the stub itself in both directions — mutating what went in does not reach the store, and mutating what came out does not either — because an aliasinggetmakes every other assertion in the file pass against a build that persists nothing.Mutations run to prove the tests are not vacuous. The third was run in this rework; the first two were run when the change was first written and neither the code nor the tests they cover were touched since:
removeWalletFromState(state, walletIdx)→removeWalletFromState(state, 0): 4 tests fail, including "exactly the named wallet is destroyed". This is the one the DoD asks for — the route removes the target wallet and leaves the others intact, key material, xpubs, addresses and site permissions included.await saveState()on the wallets-remain path: 5 tests fail. This is the anti-vacuity mutation for persistence specifically: an in-memory splice with no write is indistinguishable from a correct delete when read back fromstate, and these assertions catch it because they read storage.confirmKey(typed) !== confirmKey(expected)to the oldtyped.trim().toLowerCase() !== expected.toLowerCase(): exactly 1 test fails,the typed confirmation › a doubled space inside the name is typed back as one(827 passed, 1 failed). The new test fails on precisely the defect it was written for and on nothing else.No containers survive this work and no image tag was deleted or pruned;
docker ps -ais empty and theautistmask-e2e-*tags are as found, withautistmask-e2e-chromeandautistmask-e2e-firefoxrebuilt in place by the two suite runs.FAIL — needs-rework.
1.
src/popup/index.html:166— themin-h-[6rem]reserve is double what the hints need, and it pushes the "Import" button off the popupMeasured in a real Chromium against this head's
dist/chrome, hint box 368px wide,text-xs/16px line-height:All three wordings are 3 lines, not 6. The floor is 48px of dead space on every tab.
The consequence, at the 360x600 viewport
README.mdcalls the one the UI is designed for, on the default "From Phrase" tab:The "Import" button — the primary action of the wallet-creation screen — is now entirely below the fold and needs a scroll to reach. With the pre-PR wording and no reserve the same button measured
bottom=564pxandscrollHeight=600, i.e. it fitted. The 48px of unnecessary reserve is the whole difference: drop the floor to the measured height and the button lands atbottom=580px, inside the viewport.Why it matters:
README.mdstates "360x600 popup ... The UI is designed for this fixed viewport." Hiding the confirm button on the screen where a user creates their first wallet is a regression, and it is invisible to jest and to both e2e suites (Playwright auto-scrolls before clicking).Acceptable:
min-h-[3rem](or no floor at all — all three wordings are identical in height, so the reserve is belt-and-braces either way), plus a re-measure showing#btn-add-wallet-confirmbottom <= 600 at 360x600 on the From Phrase tab. The comment above the element ("this floor is above what the longest of them needs") should say what was measured rather than what was assumed.2.
src/popup/views/deleteWallet.js:163-165— internal whitespace in a wallet name makes the confirmation untypableexpectedcomes fromdisplayName()verbatim; onlytypedis trimmed, and neither side collapses internal runs of whitespace. A wallet renamed toMy Wallet(two spaces —settings.js:153trims the ends but keeps the middle) renders in#delete-wallet-lost-name-echoasMy Wallet, because HTML collapses whitespace. The user types exactly what the screen shows and is refused, with no way to tell why. It is escapable — rename the wallet first — but this is the one screen whose entire purpose is un-wedging a user, so it should not have a wedge of its own.Acceptable: normalize both sides with
.replace(/\s+/g, " ")before comparing, and a test for a two-space name.Verified and passing: DoD met (delete route, no password, typed confirmation naming the wallet, AddWallet warning, test,
make check);make checkgreen here, 40 suites / 827 tests, lint layer executed (#11 [lint 1/1] RUN make lint ... DONE 4.7s, notCACHED);make test-e2e55/55,make test-e2e-firefox8/8; CI 3/3 green one54465a; merges clean intonextandmain; basenext; single commit ending(closes #312),TODO.mdincluded, author and committerclawbot; no attribution trailers or vendor references anywhere in the diff.Probed rather than assumed: making the storage stub alias on
set/getfails exactly the first test in the file (1 failed / 15 passed), so the #324 pattern is genuinely pinned.removeWalletFromState(state, walletIdx)->(state, 0)fails 4 tests including "exactly the named wallet is destroyed"; deletingawait saveState()on the wallets-remain path fails 5. Both reproduce the reported counts. Anomaly worth naming: in both mutations the only suite that fails is the new one — the pre-existing password-route tests catch neither an off-by-one in the removal index nor a missing persist. Not this PR's defect, but the password route is only covered because it now sharesfinishDelete().The route decision holds up.
addWallet.js:215duplicate-checks akeyimport withfindWalletByAddress(), notfindWalletByXpub(), so the claim that an xpub-only relaxation leaves a private-key user wedged is true as stated.Driven live in Chromium at 360x600: empty input refused (and the input does not move — the flash reserves 20px);
Wallet 22refused; the other wallet's name refused with nothing deleted; Back returns to a usable DeleteWallet (wrong password answers "That password is incorrect." rather than "No wallet selected");wallet 2deletes and leaves Wallet 1 with its key material; a reopened popup lands onview-settings, not either delete screen. The entry control rendersunderline dashed/cursor: pointer, 12px below "Confirm Delete" on a screen that is already a destructive confirmation.Disclosure: one mutation probe (the storage-stub aliasing) was run with jest directly on the single file rather than through
make test; the other two were run throughmake test. The browser measurements above were taken with ad-hoc Playwright scripts run inside the existingautistmask-e2e-chromeimage, not throughscript/, because no repo entrypoint measures element geometry. No repo file was modified other than the two mutations, both reverted; the tree is clean ate54465a.e54465ac9fto61e0cad31fReworked, force-pushed as one commit:
e54465a->61e0cad. Point by point against the review.Finding 1 — the hint reserve is 2x too large and pushes the primary button off-screen. Accepted, and reproduced before fixing.
min-h-[6rem]->min-h-[3rem]insrc/popup/index.html, comment corrected to record the measured height rather than the guess. Measured in Chromium at 360x600, hint box 368px wide:6rem3rem#btn-add-wallet-confirmbottom beforedocumentElement.scrollHeighton the default tab: 636 -> 600. The Import button is inside the fold.The reserve is not removed. Zero shift is preserved and measured: box height 48px and first-password-field offset 52px from the box top, identical on all three tabs and on re-entry to the default tab (the same two numbers were 96/100 and equally constant at
6rem). Read with an ad-hoc Playwright script in the pinnedautistmask-e2e-chromeimage built from this tree, run by image ID with--rmand removed after; shared tags untouched, no prune.Finding 2 — the confirmation can be made untypable. Accepted. Both sides now go through one
confirmKey()(trim, collapse inner whitespace runs to one space, lowercase), so a wallet namedMy Walletwith two spaces can be deleted by typing theMy Walletthe screen actually renders. New testa doubled space inside the name is typed back as one. Mutation proving it is not vacuous: restoring the oldtyped.trim().toLowerCase() !== expected.toLowerCase()fails exactly that one test and nothing else (827 passed, 1 failed).make checkgreen, exit 0: 40 suites, 828 tests,test-verify-build39 cases,check-censored150 files, lint executed in the container ([lint 1/1] RUN make lintran, notCACHED).Also closing the previous disclosure that the browser suites had not been run: both now run green on this head —
make test-e2e55/55,make test-e2e-firefox8/8.Scope:
git diff e54465a..HEADis three files and nothing else —src/popup/index.html(+8/-2),src/popup/views/deleteWallet.js(+18/-6),tests/deleteWalletLostPassword.test.js(+27/-0). Route decision, wording,RESTORABLE_VIEWSexclusion, cleanup hooks and the existing tests are untouched. The commit message picked up two sentences to match (the inner-space rule, and the measured 48px reserve); title unchanged.PASS — both rework findings fixed; independently re-measured at 360x600,
#btn-add-wallet-confirmbottom = 580.125 (scrollHeight 600), hint natural = box = 48px on all three tabs, first-password-field offset 52px from the hint box top identical on all three tabs and on re-entry, so the zero-shift reserve is intact and only resized (forcing6remback in the live page reproduces 628.125 / 636). Mutation reproduced: restoringtyped.trim().toLowerCase() !== expected.toLowerCase()fails exactlya doubled space inside the name is typed back as one(827 passed, 1 failed). Rework diff is the three claimed files and nothing else;make checkgreen (40 suites / 828 tests, lint layerDONE 5.3s, notCACHED),make test-e2e55/55 first run no flake, CI 3/3 green on61e0cad, merges clean intonextandmain, singleclawbot-authored/committed commit ending(closes #312), no attribution trailers.Probed and non-blocking, recorded rather than filed:
confirmKey()collisions do not misdirect a delete — the target islostPasswordIndex, fixed when the screen opens; the typed name is a check, not a selector, so two wallets keying alike is harmless.confirmKey(expected) === ""and let an empty input pass (there is no separate empty-input guard). Unreachable: both rename paths (settings.js:153,home.js:360) doinput.value.trim()and require truthy, and creation always namesWallet N, so no product path can store one. Worth knowing if a name ever becomes settable elsewhere.\sandtrim()cover tab, newline and U+00A0, so those all collapse to the single space the screen renders. A zero-width character (U+200B) is neither trimmed nor collapsed and HTML does not collapse it either, so a name containing one stays untypable — same class as the fixed defect, but self-inflicted by pasting and escapable by renaming.Disclosure: geometry was read with an ad-hoc Playwright script mounted into the
autistmask-e2e-chromeimage (run by image ID,--rm), not throughscript/— no entrypoint measures element geometry. Everything else went throughmake. The one mutation was applied and reverted with the editor; tree clean at61e0cad, no containers left, no tag deleted or pruned.