Compare commits

..

2 Commits

Author SHA1 Message Date
clawbot
61e0cad31f fix: let a user who lost the password delete the wallet, and warn before they can (closes #312)
All checks were successful
check / check (push) Successful in 31s
e2e / e2e-chrome (push) Successful in 1m11s
e2e / e2e-firefox (push) Successful in 24s
Deleting a wallet was password-gated and importing its recovery phrase
again was refused as a duplicate xpub by findWalletByXpub(), so a user who
held the phrase but had forgotten the password could 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, and
nothing in the product ever warned that this was possible.

DeleteWallet now offers "I have lost my password", a screen that destroys
the wallet after the user types its name back. No password: requiring one
to discard a secret protects nobody, because 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, so it is matched with letter
case, surrounding spaces and repeated inner spaces ignored. The last of
those is not a nicety: HTML collapses a doubled inner space when it
renders the name, so comparing raw would leave a wallet named "My  Wallet"
with a confirmation no typing could ever satisfy.

This is the deletion route rather than the re-import route, and only one
of the two. Re-import would have had to be built three times over (hd and
xprv by xpub, key by address), would make the user retype the recovery
phrase into a live popup in order to change a password, and reaches no end
state that delete-then-import does not already reach through the existing
import path and scanForAddresses().

Both routes share one finishDelete(), so the selection repair, the site
permission cleanup and the AUTISTMASK_ACTIVE_CHANGED broadcast cannot
diverge between them. The new screen is not in RESTORABLE_VIEWS, alongside
delete-wallet-confirm: a popup reopened by accident must not land on a
button that erases key material. It registers an onViewLeave() cleanup as
well, not because a wallet name is a secret but because a typed
confirmation left standing in a hidden view leaves a wallet one click from
deletion. The two delete screens are siblings, so nothing is pushed on the
way in and Back re-enters DeleteWallet through show(), which hands it back
its wallet selection.

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 hint
line reserves the 48px all three wordings measure in the popup, so
switching tabs cannot move the password fields under the pointer and the
reserve costs no height the screen needs elsewhere.

The test drives the real view against a chrome.storage.local stub that
structured-clones on both set and get, and asserts against what comes back
out of storage rather than against the live state object, so it fails on
the deletion of saveState() and not only on an in-memory splice.
2026-08-20 13:01:34 +00:00
aea999db85 build: make verify-build take an explicit expectation and a build receipt (closes #309)
All checks were successful
check / check (push) Successful in 30s
e2e / e2e-chrome (push) Successful in 1m10s
e2e / e2e-firefox (push) Successful in 22s
verify-build read its expectation from AUTISTMASK_DEBUG in its own environment
and the Makefile invoked it bare, so an operator with that variable exported
who ran the release target got an INSECURE debug build — every wallet it
creates uses the publicly committed test phrase — verified green, exit 0. It
also had no provenance: a 26-byte file containing the right marker string
passed, the content script and manifest.json were never inspected, and an
entire hand-written dist/ passed.

--expect release|debug and --receipt PATH are now both required, with no
defaults and nothing read from the environment. build.js records every file it
emits with its sha256 and writes the receipt; the Makefile mktemps it outside
the repo per invocation with a trap, and build.js refuses a receipt path inside
dist/. Verification runs three passes in a load-bearing order — receipt shape,
full dist/ walk, then per-file bytes — so an unwalkable subtree cannot make
files look absent. dist/constants-bundles.txt, which was an unsigned trust root
living inside the tree it vouched for, is gone.

What this proves is bounded and stated as such: dist/ is byte-for-byte the
output of the build.js run that just finished, within one make build
invocation. It proves nothing about the honesty of the source tree or build.js,
and nothing to anyone handed a dist/ from elsewhere — that is signing, #310.
The standalone make verify-build target is removed because its only input would
be dist/ itself, i.e. the artifact vouching for itself.

Verified: make check green, test-verify-build 39 cases (was 18), test-e2e 55/55
and test-e2e-firefox 8/8 with make build running uncached inside both images.
All four original bypasses now exit 1. Mutations: digests disabled fails
exactly 4 cases, dropping the dist/ walk fails exactly 8, restoring the ambient
fallback fails exactly 1.
2026-08-20 14:24:55 +02:00
9 changed files with 853 additions and 52 deletions

View File

@@ -805,7 +805,9 @@ for the views listed in `RESTORABLE_VIEWS` (`src/popup/restorableViews.js`).
Every other screen falls back to Home. The screens that display a secret — Every other screen falls back to Home. The screens that display a secret —
ExportPrivKey and ShowRecoveryPhrase — are deliberately absent from that list, 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. So are the two that destroy one, DeleteWallet and
DeleteWalletLostPassword: a popup reopened by accident must not land on a screen
whose button erases key material.
A reopened popup renders the wallet list and the one screen it restores onto, A reopened popup renders the wallet list and the one screen it restores onto,
and nothing else, so every screen on the stack behind that one is still the and nothing else, so every screen on the stack behind that one is still the
@@ -828,7 +830,10 @@ 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 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, covers the revealed private key and recovery phrase, the recovery phrase,
private key or extended private key entered on AddWallet, and the password typed private key or extended private key entered on AddWallet, and the password typed
on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign. on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign. DeleteWalletLostPassword
registers one as well, for the neighbouring reason rather than that one: a
wallet name is not a secret, but a typed confirmation left standing in a hidden
view would leave a wallet one click from deletion.
#### Welcome (`welcome`) #### Welcome (`welcome`)
@@ -889,7 +894,13 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
- **From xprv**: instruction text and a masked extended private key - **From xprv**: instruction text and a masked extended private key
input input
- Password + confirm password inputs, with a hint line whose wording depends - Password + confirm password inputs, with a hint line whose wording depends
on the selected tab on the selected tab. Every wording says that the password cannot be
recovered or reset and names what the only backup of the wallet is — the
recovery phrase, the private key or the extended private key, according to
the tab. This is the only warning the user gets before the wallet exists;
without it, the lost-password route on DeleteWallet is the first they
would hear of it. The hint line reserves its height, so switching tabs
cannot move the password fields under the pointer.
- "Import" button - "Import" button
- **Transitions**: - **Transitions**:
- "Import" with a valid entry and a matching password of at least 12 - "Import" with a valid entry and a matching password of at least 12
@@ -1244,6 +1255,7 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
- Error line - Error line
- Password input - Password input
- "Confirm Delete" button - "Confirm Delete" button
- An underlined "I have lost my password" control
- **Transitions**: - **Transitions**:
- "Confirm Delete" (correct password, other wallets remain) → deletes the - "Confirm Delete" (correct password, other wallets remain) → deletes the
wallet and its site permissions, then → **Settings** with a "Wallet wallet and its site permissions, then → **Settings** with a "Wallet
@@ -1253,10 +1265,54 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
- Either way, the active address moves only if it belonged to the deleted - Either way, the active address moves only if it belonged to the deleted
wallet, and `AUTISTMASK_ACTIVE_CHANGED` is broadcast when it does wallet, and `AUTISTMASK_ACTIVE_CHANGED` is broadcast when it does
(`src/shared/walletDelete.js`) (`src/shared/walletDelete.js`)
- "Confirm Delete" (wrong password) → "Wrong password." on the error line, - "Confirm Delete" (wrong password) → "That password is incorrect. Please
nothing deleted try again." on the error line, nothing deleted
- "I have lost my password" → **DeleteWalletLostPassword**
- "Back" → previous screen (Settings) - "Back" → previous screen (Settings)
#### DeleteWalletLostPassword (`delete-wallet-lost-password`)
- **When**: User tapped "I have lost my password" on DeleteWallet.
- **Why it exists**: without it, a user who has forgotten the password but still
holds the recovery phrase has no route back into the product at all. Deletion
was password-gated, and importing the phrase again is refused as a duplicate
xpub by `findWalletByXpub()` while the wallet is still stored, so the only
escape was clearing extension storage through browser internals — which takes
every other wallet with it.
- **Elements**:
- "Back" button, "Delete Wallet Without a Password" heading
- A statement that the password cannot be recovered or reset, so the wallet
cannot be unlocked again, and that no password is needed to delete it
- What deletion does and does not do: it erases the copy of the key stored
on this device; nothing on chain changes and no money is moved
- The route back — adding the wallet again with the recovery phrase and a
new password — and, in bold, that without that phrase written down the
deletion loses everything the wallet holds, forever
- That the other wallets are not touched
- The wallet's name, and a text input asking for it to be typed back
- Error line
- "Delete This Wallet Forever" button
- **Transitions**:
- "Delete This Wallet Forever" (name typed correctly) → the same two
outcomes as "Confirm Delete" above, through the same `finishDelete()`, so
the selection repair, permission cleanup and `AUTISTMASK_ACTIVE_CHANGED`
broadcast are identical on both routes
- "Delete This Wallet Forever" (name does not match) → "That is not the name
of this wallet. Type <name> to confirm." on the error line, nothing
deleted
- "Back" → **DeleteWallet**, re-entered through its `show()` so the wallet
selection comes back with it. The two delete screens are siblings rather
than parent and child: nothing is pushed on the way here, so both have
Settings as their Back target.
- **Deliberately not password-gated.** 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, not a secret, so it is matched with surrounding spaces and letter case
ignored.
- Not in `RESTORABLE_VIEWS`, alongside `delete-wallet-confirm`: a popup reopened
by accident must not land on a screen whose button erases key material.
#### DeleteAddress (`delete-address-confirm`) #### DeleteAddress (`delete-address-confirm`)
- **When**: User tapped the `[x]` next to an address on Home. Offered only on HD - **When**: User tapped the `[x]` next to an address on Home. Offered only on HD
@@ -1273,13 +1329,13 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
refused: "+" derives the next unused index (`nextIndex` is a high-water refused: "+" derives the next unused index (`nextIndex` is a high-water
mark), and re-importing the wallet's key material is rejected as a mark), and re-importing the wallet's key material is rejected as a
duplicate by `findWalletByXpub` while the wallet is still present. What duplicate by `findWalletByXpub` while the wallet is still present. What
works is deleting the whole wallet in Settings — password-gated, and it works is deleting the whole wallet in Settings — which destroys the stored
destroys the stored secret — then importing again, whereupon secret — then importing again, whereupon `scanForAddresses()` rediscovers
`scanForAddresses()` rediscovers the address **only if it has on-chain the address **only if it has on-chain activity**. An address that was
activity**. An address that was never used is not found by that scan. The never used is not found by that scan. The text is written by
text is written by `recoveryPathText()` rather than sitting in `recoveryPathText()` rather than sitting in `index.html`, so it can name
`index.html`, so it can name the wallet's own kind of key material: an the wallet's own kind of key material: an xprv wallet has no recovery
xprv wallet has no recovery phrase to re-import. phrase to re-import.
- A warning when the address holds anything, ETH or any tracked ERC-20, - A warning when the address holds anything, ETH or any tracked ERC-20,
followed by the holdings themselves via `balanceLinesForAddress()` and the followed by the holdings themselves via `balanceLinesForAddress()` and the
USD total via `formatAddressTotal()` (see USD total via `formatAddressTotal()` (see

26
TODO.md
View File

@@ -44,6 +44,32 @@ but the review is broader than any of them.
# Completed Steps # Completed Steps
- 2026-08-20: A forgotten password no longer wedges the wallet
([#312](https://git.eeqj.de/sneak/AutistMask/issues/312)). Deleting a wallet
was password-gated and importing its recovery phrase again was refused as a
duplicate xpub, so a user who had the phrase but not the password could
neither leave nor come back: the only way out was clearing extension storage
through browser internals, which takes every other wallet with it.
DeleteWallet now offers "I have lost my password", a screen that destroys the
wallet after the user types its name back — no password, because requiring one
to _discard_ a secret protects nobody. An attacker at the popup who wants the
wallet gone can uninstall the extension; the only person such a gate stopped
was the owner who forgot it. That was chosen over allowing a duplicate xpub to
re-encrypt in place: re-import would have had to be built three times over
(`hd` and `xprv` by xpub, `key` by address), would make the user retype the
recovery phrase into a live popup to change a password, and reaches no state
that delete-then-import does not already reach through `scanForAddresses()`.
Both routes share one `finishDelete()`, so the selection repair, the
site-permission cleanup and the `AUTISTMASK_ACTIVE_CHANGED` broadcast cannot
diverge between them, and the new screen is excluded from `RESTORABLE_VIEWS`
a popup reopened by accident must not land on a button that erases key
material. AddWallet's password hint now says, per import mode, that the
password cannot be recovered or reset and what the only backup is; the hint
line reserves its height so switching tabs cannot move the password fields.
The test drives the real view against a `chrome.storage.local` stub that
structured-clones on both `set` and `get` and asserts against the read-back,
so it fails on the deletion of `saveState()` and not only on an in-memory
splice.
- 2026-08-20: `make build` can no longer hand back a debug build, and - 2026-08-20: `make build` can no longer hand back a debug build, and
`script/verify-build` can no longer be satisfied by bytes the build did not `script/verify-build` can no longer be satisfied by bytes the build did not
produce ([#309](https://git.eeqj.de/sneak/AutistMask/issues/309)). The produce ([#309](https://git.eeqj.de/sneak/AutistMask/issues/309)). The

View File

@@ -153,12 +153,28 @@
<!-- Shared password fields --> <!-- Shared password fields -->
<div class="mb-2" id="add-wallet-password-section"> <div class="mb-2" id="add-wallet-password-section">
<label class="block mb-1">Choose a password</label> <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 <p
class="text-xs text-muted mb-1" class="text-xs text-muted mb-1 min-h-[3rem]"
id="add-wallet-password-hint" id="add-wallet-password-hint"
> >
This password encrypts your recovery phrase on this 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> </p>
<input <input
type="password" type="password"
@@ -1140,6 +1156,71 @@
> >
Confirm Delete Confirm Delete
</button> </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> </div>
<!-- ============ DELETE ADDRESS CONFIRM ============ --> <!-- ============ DELETE ADDRESS CONFIRM ============ -->

View File

@@ -10,6 +10,11 @@
// prompt in front of it, on a popup the user may have reopened by accident. // 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. // 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 // Kept in its own module, with no dependencies, so tests can assert the
// exclusion directly rather than trusting a reading of the popup entry // exclusion directly rather than trusting a reading of the popup entry
// point, which cannot be required outside a browser. // point, which cannot be required outside a browser.

View File

@@ -42,12 +42,24 @@ let currentMode = "mnemonic";
const MODES = ["mnemonic", "privkey", "xprv"]; 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 = { const PASSWORD_HINTS = {
mnemonic: 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: privkey:
"This password encrypts your private 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.", 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) { 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 // 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 // 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 // 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 // delete the whole wallet in Settings — which destroys the stored secret,
// destroys the stored secret — and import again, after which // with or without the password — and import again, after which
// scanForAddresses() rediscovers the address only if it has on-chain // scanForAddresses() rediscovers the address only if it has on-chain
// activity. An address that was never used is not found by that scan, and // activity. An address that was never used is not found by that scan, and
// the copy must not imply otherwise. // the copy must not imply otherwise.
@@ -63,8 +63,7 @@ function recoveryPathText(wallet) {
"importing this " + "importing this " +
secret + secret +
" again is refused while this wallet is still here. The way back is " + " again is refused while this wallet is still here. The way back is " +
"to delete the whole wallet in Settings, which asks for your " + "to delete the whole wallet in Settings, which destroys the stored " +
"password and destroys the stored " +
secret + secret +
", and then import that " + ", and then import that " +
secret + secret +

View File

@@ -14,8 +14,29 @@ const {
} = require("../../shared/walletDelete"); } = require("../../shared/walletDelete");
let deleteWalletIndex = null; let deleteWalletIndex = null;
let lostPasswordIndex = null;
let ctx = 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 // 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, // 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 // 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"; $("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) { function show(walletIdx) {
clear(); clear();
deleteWalletIndex = walletIdx; deleteWalletIndex = walletIdx;
const wallet = state.wallets[walletIdx]; $("delete-wallet-name").textContent = displayName(walletIdx);
$("delete-wallet-name").textContent =
wallet.name || "Wallet " + (walletIdx + 1);
showView("delete-wallet-confirm"); 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) { function init(_ctx) {
ctx = _ctx; ctx = _ctx;
onViewLeave("delete-wallet-confirm", clear); onViewLeave("delete-wallet-confirm", clear);
onViewLeave("delete-wallet-lost-password", clearLostPassword);
// No wipe here: goBack() routes through showView(), which runs the // No wipe here: goBack() routes through showView(), which runs the
// leave hook. // leave hook.
@@ -47,6 +138,60 @@ function init(_ctx) {
goBack(); 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 () => { $("btn-delete-wallet-confirm").addEventListener("click", async () => {
const pw = $("delete-wallet-password").value; const pw = $("delete-wallet-password").value;
if (!pw) { if (!pw) {
@@ -82,34 +227,7 @@ function init(_ctx) {
return; return;
} }
// Remove the wallet and repair selection, permissions and hasWallet await finishDelete(walletIdx);
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.");
}
}); });
} }

View File

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

View File

@@ -0,0 +1,503 @@
// The lost-password route off the delete-wallet screen (issue #312).
//
// What is pinned here is that a user who has forgotten the password can
// still get out — no password is asked for and none is checked — and that
// the escape hatch destroys exactly the wallet it names and nothing else.
// The second half is the dangerous one: this is the only control in the
// product that erases key material without the password that encrypted it,
// so an off-by-one in the wallet it removes would take a wallet whose
// owner never asked for it to be touched.
//
// The assertions are made against what came back OUT of extension storage,
// not against the live `state` object. Deleting a wallet in memory and
// never persisting it looks identical from `state`, and a build that never
// wrote at all would pass a check that only reads `state` back.
//
// That makes the storage stub load-bearing, so it is a real store that
// structured-clones on both `set` and `get`. A stub whose `get` hands back
// the same object its `set` was given aliases the caller's own array: the
// test then reads its own in-memory mutation and calls it persistence, and
// passes against a build that persists nothing (see issue #324). The
// aliasing is closed off explicitly by the first test below rather than
// left as an assumption about `structuredClone`.
//
// The view is driven against a minimal DOM stub, in the same shape as
// tests/exportPrivkey.test.js: the module reads and writes named nodes and
// needs nothing else from a document.
const mockSettingsShow = jest.fn();
jest.mock("../src/popup/views/settings", () => ({
show: mockSettingsShow,
}));
jest.mock("../src/shared/vault", () => ({
decryptWithPassword: jest.fn(),
}));
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
const VIEW = "delete-wallet-lost-password";
// Fixed addresses — never used for anything but these tests.
const A0 = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const A1 = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const B0 = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599";
const C0 = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
// ------------------------------------------------------------ DOM stub
function makeElement(id) {
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: () => [],
};
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));
return els.get(id);
},
createElement: () => makeElement("created"),
addEventListener: () => {},
body: { prepend: () => {} },
};
}
// --------------------------------------------------------- storage stub
// A store that behaves the way `chrome.storage.local` does: what goes in is
// serialized, so the caller keeps no handle on what came to rest there, and
// what comes out is a fresh object the caller may mutate freely.
function makeStorage() {
let store = {};
return {
get: async (keys) => {
const wanted =
keys === undefined || keys === null
? Object.keys(store)
: [].concat(keys);
const out = {};
for (const key of wanted) {
if (key in store) out[key] = structuredClone(store[key]);
}
return out;
},
set: async (items) => {
for (const [key, value] of Object.entries(items)) {
store[key] = structuredClone(value);
}
},
// Test-only: what the extension would find on a cold start.
_raw: () => structuredClone(store),
};
}
// ------------------------------------------------------------ harness
function wallet(name, secret, addresses) {
return {
type: "hd",
name,
xpub: "xpub-" + name,
encryptedSecret: secret,
nextIndex: addresses.length,
addresses: addresses.map((address) => ({
address,
balance: "0.0000",
tokenBalances: [],
})),
};
}
function load() {
jest.resetModules();
mockSettingsShow.mockClear();
const storage = makeStorage();
const sent = [];
globalThis.chrome = {
storage: { local: storage },
runtime: { sendMessage: (msg) => sent.push(msg) },
};
globalThis.document = makeDocument();
const helpers = require("../src/popup/views/helpers");
const { state } = require("../src/shared/state");
const vault = require("../src/shared/vault");
const deleteWallet = require("../src/popup/views/deleteWallet");
state.hasWallet = true;
state.wallets = [
wallet("Wallet 1", "secret-one", [A0, A1]),
wallet("Wallet 2", "secret-two", [B0]),
wallet("Wallet 3", "secret-three", [C0]),
];
state.selectedWallet = 0;
state.selectedAddress = 0;
state.activeAddress = A0;
state.allowedSites = { [A0]: ["a.example"], [B0]: ["b.example"] };
state.deniedSites = { [B0]: ["c.example"], [C0]: ["d.example"] };
state.viewStack = ["main", "settings"];
state.currentView = "settings";
const renderWalletList = jest.fn();
deleteWallet.init({ renderWalletList });
return { helpers, state, vault, deleteWallet, storage, sent };
}
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);
}
// The wallets as the extension would read them back on a cold start.
async function persistedWallets(storage) {
const result = await storage.get("autistmask");
return result.autistmask.wallets;
}
// Open the lost-password screen for a wallet, the way the user does.
async function openLostPassword(deleteWallet, walletIdx) {
deleteWallet.show(walletIdx);
await click("btn-delete-wallet-lost-password");
}
// ------------------------------------------------------------ tests
// The stub is what every persistence assertion below rests on, so its one
// dangerous failure mode is closed off first. An aliasing store passes
// every other test in this file against a build that never writes.
describe("the storage stub", () => {
test("does not hand back the object it was given", async () => {
const storage = makeStorage();
const written = { wallets: [{ name: "Wallet 1" }] };
await storage.set({ autistmask: written });
written.wallets.push({ name: "Wallet 2" });
written.wallets[0].name = "renamed after the write";
const readBack = (await storage.get("autistmask")).autistmask;
expect(readBack.wallets).toHaveLength(1);
expect(readBack.wallets[0].name).toBe("Wallet 1");
// And the other direction: mutating what came out must not reach
// back into the store.
readBack.wallets[0].name = "renamed after the read";
const again = (await storage.get("autistmask")).autistmask;
expect(again.wallets[0].name).toBe("Wallet 1");
});
});
describe("reaching the screen", () => {
test("the delete screen offers the route", async () => {
const { deleteWallet, state } = load();
await openLostPassword(deleteWallet, 1);
expect(state.currentView).toBe(VIEW);
expect(node("delete-wallet-lost-name").textContent).toBe("Wallet 2");
expect(node("delete-wallet-lost-name-echo").textContent).toBe(
"Wallet 2",
);
});
// Both delete screens hang off Settings. Pushing one onto the other
// would leave Back on the confirm screen popping onto itself.
test("it does not push the screen it came from", async () => {
const { deleteWallet, state } = load();
await openLostPassword(deleteWallet, 1);
expect(state.viewStack).toEqual(["main", "settings"]);
});
test("Back returns to the delete screen with its wallet still chosen", async () => {
const { deleteWallet, state } = load();
await openLostPassword(deleteWallet, 1);
await click("btn-delete-wallet-lost-back");
expect(state.currentView).toBe("delete-wallet-confirm");
expect(node("delete-wallet-name").textContent).toBe("Wallet 2");
expect(state.viewStack).toEqual(["main", "settings"]);
// The confirm screen is usable, not merely on screen: the wallet
// it holds is the one that was chosen, so its own button does not
// answer "No wallet selected for deletion."
node("delete-wallet-password").value = "some password";
const { decryptWithPassword } = require("../src/shared/vault");
decryptWithPassword.mockRejectedValue(new Error("nope"));
await click("btn-delete-wallet-confirm");
expect(node("delete-wallet-flash").textContent).toBe(
"That password is incorrect. Please try again.",
);
});
});
describe("the typed confirmation", () => {
test("a name that is not the wallet's deletes nothing", async () => {
const { deleteWallet, state, storage } = load();
await openLostPassword(deleteWallet, 1);
node("delete-wallet-lost-name-input").value = "Wallet 3";
await click("btn-delete-wallet-lost-confirm");
expect(node("delete-wallet-lost-flash").textContent).toBe(
"That is not the name of this wallet. Type Wallet 2 to confirm.",
);
expect(node("delete-wallet-lost-flash").style.visibility).toBe(
"visible",
);
expect(state.wallets.map((w) => w.name)).toEqual([
"Wallet 1",
"Wallet 2",
"Wallet 3",
]);
expect(state.currentView).toBe(VIEW);
// Nothing was destroyed on disk either. Storage is not empty —
// showView() persists the current screen on the way in — so what
// is asserted is that all three wallets are still in it.
const persisted = await persistedWallets(storage);
expect(persisted.map((w) => w.encryptedSecret)).toEqual([
"secret-one",
"secret-two",
"secret-three",
]);
});
test("an empty field deletes nothing", async () => {
const { deleteWallet, state } = load();
await openLostPassword(deleteWallet, 1);
await click("btn-delete-wallet-lost-confirm");
expect(node("delete-wallet-lost-flash").style.visibility).toBe(
"visible",
);
expect(state.wallets).toHaveLength(3);
});
// Not a secret and not a password: it asks whether the user knows
// which wallet they are on. Refusing the name they can plainly read,
// over letter case, would only teach them to distrust the control.
test("case and surrounding spaces do not matter", async () => {
const { deleteWallet, state, storage } = load();
await openLostPassword(deleteWallet, 1);
node("delete-wallet-lost-name-input").value = " wALLet 2 ";
await click("btn-delete-wallet-lost-confirm");
expect(state.wallets.map((w) => w.name)).toEqual([
"Wallet 1",
"Wallet 3",
]);
expect(await persistedWallets(storage)).toHaveLength(2);
});
// A name with a doubled inner space RENDERS with one — HTML collapses
// runs of whitespace — so the string the user can see and type is not
// the string the name is stored as. Comparing the two raw would make
// this wallet's confirmation impossible to satisfy by any typing at
// all, wedging the one screen that exists to unwedge people.
test("a doubled space inside the name is typed back as one", async () => {
const { deleteWallet, state, storage } = load();
state.wallets[1].name = "My Wallet";
await openLostPassword(deleteWallet, 1);
// What the DOM was handed still has both spaces; what the user
// reads off the screen, and therefore types, has one.
expect(node("delete-wallet-lost-name").textContent).toBe("My Wallet");
node("delete-wallet-lost-name-input").value = "My Wallet";
await click("btn-delete-wallet-lost-confirm");
expect(state.wallets.map((w) => w.name)).toEqual([
"Wallet 1",
"Wallet 3",
]);
const persisted = await persistedWallets(storage);
expect(persisted.map((w) => w.encryptedSecret)).toEqual([
"secret-one",
"secret-three",
]);
});
});
describe("deleting without the password", () => {
test("no password is asked for and none is checked", async () => {
const { deleteWallet, vault, storage } = load();
await openLostPassword(deleteWallet, 1);
node("delete-wallet-lost-name-input").value = "Wallet 2";
await click("btn-delete-wallet-lost-confirm");
expect(vault.decryptWithPassword).not.toHaveBeenCalled();
expect(await persistedWallets(storage)).toHaveLength(2);
});
// The load-bearing assertion of the whole file, and the one that says
// this control is safe to give a user who cannot prove anything: it
// removes the wallet it named, and every other wallet survives intact,
// key material included.
test("exactly the named wallet is destroyed", async () => {
const { deleteWallet, storage } = load();
await openLostPassword(deleteWallet, 1);
node("delete-wallet-lost-name-input").value = "Wallet 2";
await click("btn-delete-wallet-lost-confirm");
const wallets = await persistedWallets(storage);
expect(wallets.map((w) => w.name)).toEqual(["Wallet 1", "Wallet 3"]);
expect(wallets.map((w) => w.encryptedSecret)).toEqual([
"secret-one",
"secret-three",
]);
expect(wallets.map((w) => w.xpub)).toEqual([
"xpub-Wallet 1",
"xpub-Wallet 3",
]);
expect(wallets[0].addresses.map((a) => a.address)).toEqual([A0, A1]);
expect(wallets[1].addresses.map((a) => a.address)).toEqual([C0]);
// The deleted wallet's secret is gone from storage entirely, not
// merely unreferenced by the wallet list.
expect(JSON.stringify(storage._raw())).not.toContain("secret-two");
expect(JSON.stringify(storage._raw())).not.toContain("xpub-Wallet 2");
});
test("only the deleted wallet's site permissions are dropped", async () => {
const { deleteWallet, storage } = load();
await openLostPassword(deleteWallet, 1);
node("delete-wallet-lost-name-input").value = "Wallet 2";
await click("btn-delete-wallet-lost-confirm");
const saved = (await storage.get("autistmask")).autistmask;
expect(saved.allowedSites).toEqual({ [A0]: ["a.example"] });
expect(saved.deniedSites).toEqual({ [C0]: ["d.example"] });
});
// The route shares finishDelete() with the password route, so the
// selection repair and the accountsChanged broadcast are the same on
// both. Deleting a wallet that did not own the active address must
// leave that address, and the selection, exactly where they were.
test("a selection in another wallet is left alone", async () => {
const { deleteWallet, storage, sent } = load();
await openLostPassword(deleteWallet, 1);
node("delete-wallet-lost-name-input").value = "Wallet 2";
await click("btn-delete-wallet-lost-confirm");
const saved = (await storage.get("autistmask")).autistmask;
expect(saved.activeAddress).toBe(A0);
expect(saved.selectedWallet).toBe(0);
expect(saved.selectedAddress).toBe(0);
expect(sent).toEqual([]);
// Settings is stubbed, so this is where the route hands over, not
// where it renders.
expect(mockSettingsShow).toHaveBeenCalled();
});
test("deleting the wallet holding the active address moves it and says so", async () => {
const { deleteWallet, storage, sent } = load();
await openLostPassword(deleteWallet, 0);
node("delete-wallet-lost-name-input").value = "Wallet 1";
await click("btn-delete-wallet-lost-confirm");
const saved = (await storage.get("autistmask")).autistmask;
expect(saved.wallets.map((w) => w.name)).toEqual([
"Wallet 2",
"Wallet 3",
]);
expect(saved.activeAddress).toBe(B0);
expect(sent).toEqual([{ type: "AUTISTMASK_ACTIVE_CHANGED" }]);
});
test("deleting the last wallet lands on Welcome with nothing left", async () => {
const { deleteWallet, state, storage } = load();
state.wallets = [wallet("Wallet 1", "secret-one", [A0])];
state.allowedSites = { [A0]: ["a.example"] };
state.deniedSites = {};
await openLostPassword(deleteWallet, 0);
node("delete-wallet-lost-name-input").value = "Wallet 1";
await click("btn-delete-wallet-lost-confirm");
const saved = (await storage.get("autistmask")).autistmask;
expect(saved.wallets).toEqual([]);
expect(saved.hasWallet).toBe(false);
expect(saved.activeAddress).toBeNull();
expect(saved.allowedSites).toEqual({});
expect(state.currentView).toBe("welcome");
expect(JSON.stringify(storage._raw())).not.toContain("secret-one");
});
});
describe("what the screen leaves behind", () => {
test("the typed confirmation is wiped when the screen is left", async () => {
const { helpers, deleteWallet } = load();
await openLostPassword(deleteWallet, 1);
node("delete-wallet-lost-name-input").value = "Wallet 2";
// The Settings gear, which is not this screen's Back button.
helpers.showView("settings");
expect(node("delete-wallet-lost-name-input").value).toBe("");
expect(node("delete-wallet-lost-flash").textContent).toBe("");
expect(node("delete-wallet-lost-flash").style.visibility).toBe(
"hidden",
);
});
// Left mid-delete, the screen has to come back usable.
test("the confirm button is re-enabled on the way out", async () => {
const { helpers, deleteWallet } = load();
await openLostPassword(deleteWallet, 1);
node("btn-delete-wallet-lost-confirm").disabled = true;
helpers.showView("settings");
expect(node("btn-delete-wallet-lost-confirm").disabled).toBe(false);
});
// A wallet name is not a secret, so the screen is excluded for the
// other reason: reopening the popup must not land the user on a screen
// whose button erases key material.
test("the popup may not reopen onto it", () => {
expect(RESTORABLE_VIEWS.has(VIEW)).toBe(false);
expect(RESTORABLE_VIEWS.has("delete-wallet-confirm")).toBe(false);
});
});