Compare commits

..

3 Commits

Author SHA1 Message Date
clawbot
a3db61a421 fix: drive background refresh and phishing update from alarms (closes #158)
Some checks failed
check / check (push) Has been cancelled
The Chrome MV3 service worker is terminated after roughly 30 seconds idle,
which destroyed both recurring jobs: the 60-second balance refresh and the
24-hour phishing blocklist refresh were setInterval schedules, so in
practice each ran only while the worker happened to be alive. The phishing
delta was persisted to localStorage, which does not exist in a service
worker, so on Chrome it was never persisted at all.

Both jobs now run off the extension alarms API in the new
src/shared/alarms.js: the browser holds the schedule and wakes the worker to
deliver it. The balance refresh is one minute and the phishing refresh is
1440 minutes, both whole minutes at or above the one-minute minimum, so
neither is silently clamped. Alarms are created only when missing or when the
existing one carries a different period, because creating one restarts its
period and the startup path runs on every wake — while an alarm left at an
older release's period would otherwise never be reconciled.

Each job's freshness guard is decoupled from its alarm period, or the period
would not be the cadence. A guard is measured from when the last run
finished, which is one run-duration after the alarm that started it, so a
guard timed to the period vetoes the very next tick and the real rate halves.
The two are handled differently because the guards differ in purpose: the
phishing cache TTL exists to keep the worker off the network on the wakes
between refreshes, so the scheduled tick bypasses it and fetches
unconditionally; the balance guard exists to skip work an open popup has
already done, so it must keep applying on the tick and is instead shortened
to half the alarm period — above the popup's 10-second refresh, below the
60-second period.

The phishing delta and the timestamps of the fetch that produced it now live
in extension storage, and updatePhishingList() reloads that record before
deciding whether a fetch is due. A revived worker therefore neither
re-fetches on every wake nor sleeps through an overdue update. A timestamp
read back from storage is discarded if it lies in the future: clock skew or a
restored profile backup would otherwise suppress updates until that time
arrived, permanently, now that the value outlives the worker.

Two timestamps are kept, not one. The 256 KiB cap still drops an oversized
delta together with its freshness claim, but the record of having contacted
the network at all is written regardless — as it is after a failed fetch —
and floors unscheduled retries at one hour. Without it, a list that is
persistently oversized or a fetch that persistently fails means a full
blocklist download on every worker wake, indefinitely.

The startup path (ensureRecurringAlarms plus the phishing list init) is
registered on onInstalled and onStartup as well as running at the top level
of the worker, and is idempotent. The concurrent callers on a fresh install
share one in-flight run rather than racing to create the same alarm, and a
failure is logged instead of becoming an unhandled rejection.

Firefox MV2 has a persistent background page where timers would have
survived, but both browsers are built from one bundle and both take the
alarm path, so there is a single code path; "alarms" is declared in both
manifests.

src/shared/ens.js keeps its localStorage cache and gains a comment recording
that it is popup-only, so it does not get pulled into the worker later.
2026-08-11 13:27:47 +00:00
fb9e8f5542 fix: NUL-delimit verify-build's dist walk so no path escapes the check (closes #223)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 15:26:43 +02:00
3e5d6323ce feat: password-gated recovery phrase display for HD wallets (closes #161)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 15:25:17 +02:00
13 changed files with 793 additions and 36 deletions

View File

@@ -123,8 +123,12 @@ unavailable). The suite lives in `tests/e2e/` and is driven by
`playwright-core`, whose version must stay matched to the container's Playwright
version — the browsers ship inside the image.
It covers popup load, wallet creation through the UI, the Add Token screen and
the transaction detail screen for an ERC-20 transfer. All outbound network is
It covers popup load, wallet creation through the UI, the Add Token screen, the
transaction detail screen for an ERC-20 transfer, and the recovery phrase screen
— which wallet types are offered it, that it holds nothing before the password
is accepted, that a wrong password reveals nothing, that leaving it by either
route wipes it — including a leave taken while the decrypt is still running —
and that reopening the popup does not land on it. All outbound network is
intercepted at the browser level and served from fixtures in
`tests/e2e/network.js`, so the run is deterministic and fully offline;
unrecognised outbound requests are reported as failures rather than silently
@@ -476,8 +480,11 @@ runtime debug mode is on, or when the active network is a testnet. They are not
repeated in the element lists below.
Closing and reopening the popup returns to the screen the user was last on only
for the views listed in `RESTORABLE_VIEWS` (`src/popup/index.js`). Every other
screen, including ExportPrivKey, falls back to Home.
for the views listed in `RESTORABLE_VIEWS` (`src/popup/restorableViews.js`).
Every other screen falls back to Home. The screens that display a secret —
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
of it.
#### Welcome (`welcome`)
@@ -777,8 +784,9 @@ screen, including ExportPrivKey, falls back to Home.
- **When**: User tapped the Settings gear.
- **Elements**:
- "Back" button, "Settings" heading
- Wallets: one row per wallet with its name (tap to rename inline) and an
`[x]` delete button, plus a "+ Add wallet" button
- Wallets: one row per wallet with its name (tap to rename inline), a
`[recovery phrase]` button on HD wallets only, and an `[x]` delete button,
plus a "+ Add wallet" button
- Tracked Tokens: one row per tracked token with an `[x]` remove button,
plus a "+ Add token" button
- Display: "Show tracked tokens with zero balance" checkbox and a Theme
@@ -803,6 +811,7 @@ screen, including ExportPrivKey, falls back to Home.
- **Transitions**:
- "+ Add wallet" → **AddWallet**
- "+ Add token" → **SettingsAddToken**
- `[recovery phrase]` on an HD wallet → **ShowRecoveryPhrase**
- `[x]` on a wallet → **DeleteWallet**
- Tap wallet name → inline rename field (no screen change)
- `[x]` on a tracked token or a site → removes it in place (no screen
@@ -810,6 +819,33 @@ screen, including ExportPrivKey, falls back to Home.
- Ten clicks on the version → reveals the Debug well (no screen change)
- "Back" (or Settings gear again) → previous screen (Home)
#### ShowRecoveryPhrase (`show-phrase`)
- **When**: User tapped `[recovery phrase]` on a wallet row in Settings. HD
wallets only: key and xprv wallets have no recovery phrase, so their rows do
not offer the action at all.
- **Elements**:
- "Back" button, "Recovery Phrase" heading
- Wallet name
- Warning box stating that anyone holding these words can take everything in
the wallet, from any device, without the password
- Error line
- Password input + "Reveal" button, shown until the password is accepted
- The recovery phrase itself, in full and click-to-copy, shown only after a
correct password and in place of the password prompt
- **Transitions**:
- "Reveal" (correct password) → the phrase replaces the password prompt (no
screen change)
- "Reveal" (wrong password) → full-sentence error, nothing revealed (no
screen change)
- "Back" → previous screen (Settings)
- **Secret handling**: nothing is decrypted or written into the page until the
password is accepted; the phrase is never stored in state, and it is wiped
from the page whenever the screen is left by any route, including the Settings
gear. A decrypt still running when the screen is left is discarded rather than
written. The screen is not restorable, so reopening the popup lands on Home
rather than back on the phrase.
#### DeleteWallet (`delete-wallet-confirm`)
- **When**: User tapped the `[x]` next to a wallet in Settings.
@@ -1263,7 +1299,7 @@ Currently supported:
- [x] Delete wallet (with confirmation)
- [ ] Delete address from HD wallet (with confirmation)
- [ ] Show wallet's recovery phrase (requires password)
- [x] Show wallet's recovery phrase (requires password)
### Transactions

View File

@@ -44,11 +44,19 @@ undefined identifiers, which is how
# Completed Steps
- 2026-08-11: `script/verify-build` now walks `dist/` NUL-delimited and asserts
`dist/` is a real directory, so a path with a trailing space or a newline can
no longer carry a debug marker past the unlisted-bundle check
([#223](https://git.eeqj.de/sneak/AutistMask/issues/223)).
- 2026-08-11: A dust threshold of `0` now means "hide nothing" instead of
falling back to the 100,000 gwei default, and every address comparison in
`src/shared/transactions.js` goes through one case-normalising helper so a
checksummed genuine contract is no longer read as a spoof
([#179](https://git.eeqj.de/sneak/AutistMask/issues/179)).
- 2026-08-11: Password-gated recovery phrase display for HD wallets, reached
from the wallet row in Settings, wiped on leaving the screen and excluded from
the views the popup can reopen onto
([#161](https://git.eeqj.de/sneak/AutistMask/issues/161)).
- 2026-08-11: the balance refresh and the 24-hour phishing list refresh moved
from `setInterval` to the extension alarms API, with the phishing delta and
its fetch timestamps persisted to extension storage, so neither job dies with

View File

@@ -22,6 +22,18 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Absolute path to this script, resolved before anything cd's anywhere.
# check_unlisted_bundles re-invokes it through xargs, and $0 on its own may be
# relative to a directory we are about to leave.
SELF="$(cd "$(dirname "$0")" && pwd -P)/$(basename "$0")"
# Internal re-entry flag; see scan_dist_paths.
SCAN_FLAG="--scan-dist-paths"
# A literal newline, for the is_listed guard.
NEWLINE='
'
MANIFEST="dist/constants-bundles.txt"
MARKER_ON="autistmask-build-debug=on"
MARKER_OFF="autistmask-build-debug=off"
@@ -29,11 +41,20 @@ MARKER_OFF="autistmask-build-debug=off"
# Set by read_marker.
MARKER=""
# Temporary file holding the NUL-delimited dist/ listing, removed by the EXIT
# trap because fail() exits from wherever it is called.
LISTING=""
fail() {
echo "verify-build: FAIL: $*" >&2
exit 1
}
cleanup() {
[ -z "$LISTING" ] || rm -f "$LISTING"
}
trap cleanup EXIT
# Is the literal $1 present in the file $2? Match (grep exit 0) and no-match
# (exit 1) are answers about the emitted output. Anything else (exit 2: the
# file could not be read) is not an answer at all, and must not be reported as
@@ -58,7 +79,17 @@ has_marker() {
# manifest could not be read and is not an answer at all. Without this, an
# unreadable manifest reads as "this file is not listed" and every emitted
# bundle gets reported as an unlisted one.
#
# A path containing a newline is answered without asking grep, because grep
# would read the pattern as two patterns and report a match on either. That is
# how such a path escaped this check even once the walk stopped splitting it:
# the half before the newline matched a listed line and the file was skipped.
# The manifest is line-delimited, so it cannot name such a path at all, and
# "not listed" is the only true answer.
is_listed() {
case "$1" in
*"$NEWLINE"*) return 1 ;;
esac
_il_status=0
grep -q -x -F -e "$1" -- "$MANIFEST" || _il_status=$?
case "$_il_status" in
@@ -117,35 +148,66 @@ read_marker() {
# an endsWith(".js") test; repeating that literal here would mean a bundle
# emitted under some other extension escaped the manifest AND this check at
# once, which is the correlated blind spot the two-source design exists to
# avoid. Every file under dist/ is searched, so build.js's filter is the only
# place the assumption lives and this check is what catches it being wrong.
# avoid. Every regular file and every symlink under dist/ is searched — that
# is the whole of what a build emits — so build.js's filter is the only place
# the assumption lives and this check is what catches it being wrong.
#
# That claim only holds if the walk is exhaustive, so two things are enforced
# here rather than assumed:
# That claim only holds if the walk is exhaustive and every name survives it
# intact, so four things are enforced here rather than assumed:
#
# - the walk is NUL-delimited and the paths reach the check as arguments, so
# no name can be reshaped on the way in. Read line by line, a name with a
# trailing space lost it to read's field splitting and the remnant then
# matched a manifest line, and a name containing a newline arrived as a
# listed path plus an empty one. Both left a marker-carrying, unlisted file
# unchecked while the script still reported success. Delivering such a name
# intact is only half of it; is_listed also has to keep it out of grep's
# pattern, for the same reason.
# - find's exit status is checked. A subtree it cannot descend is reported on
# stderr and then simply missing from the listing, so an unchecked status
# turns "could not look" into "nothing was there" — the same conflation
# has_marker exists to prevent. The status cannot be read off a pipeline
# ending in sort, so the sort is a separate step.
# has_marker exists to prevent. The status cannot be read off a pipeline,
# so the listing lands in a file that xargs then reads back.
# - symlinks are walked too (-type l), not skipped. A marker-carrying bundle
# reachable under an unlisted path in dist/ is a stale manifest whether the
# path is a link or a file, and grep reads through the link. A link that
# cannot be read through — dangling, or pointing at a directory — fails
# hard via has_marker's exit-2 path, which is the fail-closed answer: the
# build emits neither, so their DEBUG state is unproven, not fine.
# - dist/ itself must be a directory and not a symlink, which main asserts
# before anything reads through it. find does not follow a symlink named on
# its own command line, so a linked dist/ collapses this walk to one entry
# and cross-checks nothing.
#
# Types other than regular files and symlinks are left out on purpose: a build
# emits none of them, and grep on a fifo would hang rather than fail.
check_unlisted_bundles() {
LISTING="$(mktemp "${TMPDIR:-/tmp}/verify-build-dist.XXXXXX")" ||
fail "could not create a temporary file for the dist/ listing, so the
tree was never walked. Refusing to report success."
_find_status=0
_listing="$(find dist \( -type f -o -type l \) -print)" || _find_status=$?
find dist \( -type f -o -type l \) -print0 >"$LISTING" || _find_status=$?
[ "$_find_status" -eq 0 ] ||
fail "find exited $_find_status enumerating dist/, so part of the tree
was never walked and nothing was established about the files in it. Any
unlisted bundle there went unchecked. That is a permissions or I/O fault on
the artifact, not a stale manifest. Refusing to report success."
_listing="$(printf '%s\n' "$_listing" | sort)"
while read -r _file; do
[ -n "$_file" ] || continue
_scan_status=0
xargs -0 "$SELF" "$SCAN_FLAG" <"$LISTING" || _scan_status=$?
[ "$_scan_status" -eq 0 ] ||
fail "the unlisted-bundle scan exited $_scan_status: either a path
under dist/ failed the check reported above, or the scan could not be run
at all. Refusing to report success."
}
# The per-path half of check_unlisted_bundles. It runs in a re-invocation of
# this script, so it uses the same is_listed and has_marker as the rest of the
# file rather than a second copy of them that could drift. Paths arrive as
# arguments and are never split, joined or trimmed.
scan_dist_paths() {
for _file in "$@"; do
if is_listed "$_file"; then
continue
fi
@@ -154,9 +216,7 @@ check_unlisted_bundles() {
fail "$_file carries a debug marker but is absent from $MANIFEST,
so the manifest no longer describes the emitted bundles."
fi
done <<EOF
$_listing
EOF
done
}
# The requested mode, read from our own environment using build.js's exact
@@ -173,9 +233,32 @@ expected_marker() {
main() {
cd "$ROOT"
# Internal re-entry from check_unlisted_bundles' xargs. Not part of the
# command-line interface: nothing else invokes it, and it is a distinct
# entry point rather than a mode flag threaded through the checks below.
if [ "${1-}" = "$SCAN_FLAG" ]; then
shift
scan_dist_paths "$@"
return 0
fi
expected="$(expected_marker)"
echo "Verifying emitted bundles (expecting $expected)..."
# Asserted here rather than left to grep. A symlinked dist/ used to fail
# only because GNU grep exits 2 on a directory, so check_unlisted_bundles'
# single entry hit has_marker's I/O path by luck; under a grep that exits 1
# instead, the whole cross-check would have collapsed into a pass.
if [ -h dist ]; then
fail "dist is a symlink, not a directory. find does not follow a
symlink named on its own command line, so the unlisted-bundle cross-check
would see one entry instead of the emitted tree and establish nothing about
it. Refusing to report success."
fi
[ -d dist ] ||
fail "dist is not a directory, so there is no emitted tree to verify.
build.js writes it; run make build first."
[ -f "$MANIFEST" ] ||
fail "$MANIFEST is missing. build.js writes it at the end of a
successful build; run make build first."

View File

@@ -1098,6 +1098,52 @@
</button>
</div>
<!-- ============ SHOW RECOVERY PHRASE ============ -->
<div id="view-show-phrase" class="view hidden">
<button
id="btn-show-phrase-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-1">Recovery Phrase</h2>
<p class="text-xs mb-3" id="show-phrase-wallet-name"></p>
<div
class="text-xs mb-3 border border-border border-dashed p-2"
>
Anyone who has these words can take every coin and token in
this wallet, from any device, without your password. Never
type them into a website and never show them to anyone.
</div>
<div
id="show-phrase-flash"
class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
style="visibility: hidden"
></div>
<div id="show-phrase-password-section" class="mb-2">
<label class="block mb-1">Password</label>
<input
type="password"
id="show-phrase-password"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="Enter your password to continue"
/>
<button
id="btn-show-phrase-reveal"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mt-2"
>
Reveal
</button>
</div>
<div id="show-phrase-result" class="hidden">
<div
id="show-phrase-value"
class="bg-danger-well rounded p-2 font-mono text-xs break-all cursor-pointer mb-1"
title="Click to copy"
></div>
</div>
</div>
<!-- ============ SETTINGS: ADD TOKEN ============ -->
<div id="view-settings-addtoken" class="view hidden">
<button

View File

@@ -15,6 +15,10 @@ const {
clearViewStack,
} = require("./views/helpers");
const { applyTheme } = require("./theme");
// Views that can be fully re-rendered from persisted state. All others fall
// back to the nearest restorable parent; see the module for why the
// secret-bearing views are absent.
const { RESTORABLE_VIEWS } = require("./restorableViews");
const home = require("./views/home");
const welcome = require("./views/welcome");
@@ -99,21 +103,6 @@ const ctx = {
},
};
// Views that can be fully re-rendered from persisted state.
// All others fall back to the nearest restorable parent.
const RESTORABLE_VIEWS = new Set([
"main",
"address",
"address-token",
"receive",
"settings",
"settings-addtoken",
"confirm-tx",
"transaction",
"success-tx",
"error-tx",
]);
function needsAddress(view) {
return (
view === "address" ||

View File

@@ -0,0 +1,29 @@
// Views the popup may reopen onto.
//
// The popup persists the current view so that reopening the toolbar popup
// lands the user back where they were. Only views that can be fully
// re-rendered from persisted state belong here; every other view falls back
// to the nearest restorable parent (src/popup/index.js restoreView()).
//
// A view that displays a secret must NEVER be listed. Restoring onto one
// would put a private key or a recovery phrase on screen with no password
// 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.
//
// 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.
const RESTORABLE_VIEWS = new Set([
"main",
"address",
"address-token",
"receive",
"settings",
"settings-addtoken",
"confirm-tx",
"transaction",
"success-tx",
"error-tx",
]);
module.exports = { RESTORABLE_VIEWS };

View File

@@ -31,8 +31,20 @@ const VIEWS = [
"approve-tx",
"approve-sign",
"export-privkey",
"show-phrase",
];
// Cleanup callbacks for views that hold a secret in the DOM. The view
// registers one for itself and showView() runs it whenever that view is
// navigated away from, so the secret is wiped no matter which control
// caused the navigation — "Back", the settings gear, or a jump from
// anywhere else. A per-button clear would only cover the one path.
const viewLeaveHandlers = new Map();
function onViewLeave(name, fn) {
viewLeaveHandlers.set(name, fn);
}
function $(id) {
return document.getElementById(id);
}
@@ -50,6 +62,11 @@ function hideError(id) {
}
function showView(name) {
const leaving = state.currentView;
if (leaving && leaving !== name) {
const onLeave = viewLeaveHandlers.get(leaving);
if (onLeave) onLeave();
}
for (const v of VIEWS) {
const el = document.getElementById(`view-${v}`);
if (el) {
@@ -431,10 +448,12 @@ function flashCopyFeedback(el) {
}
module.exports = {
VIEWS,
$,
showError,
hideError,
showView,
onViewLeave,
updateDebugBanner,
setRenderMain,
pushCurrentView,

View File

@@ -14,6 +14,8 @@ const { NETWORKS, SUPPORTED_CHAIN_IDS } = require("../../shared/networks");
const { onChainSwitch } = require("../../shared/chainSwitch");
const { log, debugFetch, setRuntimeDebug } = require("../../shared/log");
const deleteWallet = require("./deleteWallet");
const showPhrase = require("./showPhrase");
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
const {
BUILD_VERSION,
BUILD_LICENSE,
@@ -99,7 +101,14 @@ function renderWalletListSettings() {
const name = escapeHtml(wallet.name || "Wallet " + (idx + 1));
html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`;
html += `<span class="settings-wallet-name cursor-pointer underline decoration-dashed" data-idx="${idx}">${name}</span>`;
html += `<span class="flex items-center gap-1 flex-shrink-0">`;
// Key and xprv wallets have no recovery phrase, so they are never
// offered the action at all.
if (walletHasRecoveryPhrase(wallet)) {
html += `<button class="btn-show-phrase border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-idx="${idx}" title="Show recovery phrase">[recovery phrase]</button>`;
}
html += `<button class="btn-delete-wallet border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-idx="${idx}">[x]</button>`;
html += `</span>`;
html += `</div>`;
});
container.innerHTML = html;
@@ -111,6 +120,15 @@ function renderWalletListSettings() {
});
});
container.querySelectorAll(".btn-show-phrase").forEach((btn) => {
btn.addEventListener("click", () => {
const idx = parseInt(btn.dataset.idx, 10);
// No pushCurrentView() here: showPhrase.show() refuses
// non-HD wallets and pushes only when it navigates.
showPhrase.show(idx);
});
});
// Inline rename on click
container.querySelectorAll(".settings-wallet-name").forEach((span) => {
span.addEventListener("click", () => {
@@ -191,6 +209,7 @@ function renderSiteLists() {
function init(ctx) {
deleteWallet.init(ctx);
showPhrase.init();
$("btn-save-rpc").addEventListener("click", async () => {
const url = $("settings-rpc").value.trim();

View File

@@ -0,0 +1,154 @@
// Recovery phrase display for HD wallets.
//
// The phrase is the secret that owns every address in the wallet, so it is
// handled under four rules:
//
// 1. Only an HD wallet reaches this screen (walletHasRecoveryPhrase).
// 2. Nothing is decrypted, and nothing is written into the DOM, until
// decryptWithPassword has accepted the password.
// 3. Leaving the screen by any path wipes it, via the onViewLeave hook,
// and a decrypt still in flight when that happens is discarded
// instead of written (revealGeneration).
// 4. The phrase never reaches the logger. This module deliberately does
// not import src/shared/log.js, and the failed-decrypt path reports a
// fixed sentence rather than the caught error.
//
// The phrase is also never assigned to `state`, so it cannot be persisted
// to extension storage, and "show-phrase" is excluded from RESTORABLE_VIEWS
// so the popup can never reopen onto it.
const {
$,
showView,
showFlash,
flashCopyFeedback,
goBack,
onViewLeave,
pushCurrentView,
} = require("./helpers");
const { state } = require("../../shared/state");
const { decryptWithPassword } = require("../../shared/vault");
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
const VIEW = "show-phrase";
let walletIndex = null;
// Bumped by every clear(), which is what leaving the screen runs. reveal()
// captures it before awaiting the decrypt and refuses to touch the DOM if
// it has moved: a decrypt still in flight when the screen is left would
// otherwise write the phrase *after* the wipe, with nothing scheduled to
// wipe it again, leaving it in the hidden view for the life of the popup.
let revealGeneration = 0;
// True only if the reveal that captured `generation` is still the live one:
// the screen has not been left, cleared, or re-entered for another wallet
// since it started.
function isCurrentReveal(generation) {
return (
generation === revealGeneration &&
walletIndex !== null &&
state.currentView === VIEW
);
}
function fail(message) {
$("show-phrase-flash").textContent = message;
$("show-phrase-flash").style.visibility = "visible";
}
// Wipe every trace of the phrase and drop the wallet selection. Safe to
// call when nothing was ever revealed, and safe to call twice.
function clear() {
walletIndex = null;
revealGeneration += 1;
$("show-phrase-value").textContent = "";
$("show-phrase-password").value = "";
$("show-phrase-result").classList.add("hidden");
$("show-phrase-password-section").classList.remove("hidden");
$("show-phrase-flash").textContent = "";
$("show-phrase-flash").style.visibility = "hidden";
}
function show(walletIdx) {
const wallet = state.wallets[walletIdx];
if (!walletHasRecoveryPhrase(wallet)) {
showFlash("This wallet does not have a recovery phrase.");
return;
}
clear();
walletIndex = walletIdx;
$("show-phrase-wallet-name").textContent =
wallet.name || "Wallet " + (walletIdx + 1);
// Pushed here rather than by the caller: this function can return
// without navigating, and a push that happened anyway would leave an
// entry on the stack that no screen transition matches.
pushCurrentView();
showView(VIEW);
}
async function reveal() {
const password = $("show-phrase-password").value;
if (!password) {
fail("Please enter your password.");
return;
}
if (walletIndex === null) {
fail("No wallet is selected.");
return;
}
const wallet = state.wallets[walletIndex];
if (!walletHasRecoveryPhrase(wallet)) {
fail("This wallet does not have a recovery phrase.");
return;
}
const btn = $("btn-show-phrase-reveal");
btn.disabled = true;
btn.classList.add("text-muted");
const generation = revealGeneration;
try {
const phrase = await decryptWithPassword(
wallet.encryptedSecret,
password,
);
// The only suspension point in this view, and the only place a
// secret is written: if the screen was left while the decrypt ran,
// the wipe has already happened and this write must not land.
if (!isCurrentReveal(generation)) return;
$("show-phrase-password").value = "";
$("show-phrase-password-section").classList.add("hidden");
$("show-phrase-value").textContent = phrase;
$("show-phrase-result").classList.remove("hidden");
$("show-phrase-flash").textContent = "";
$("show-phrase-flash").style.visibility = "hidden";
} catch {
if (!isCurrentReveal(generation)) return;
// Deliberately not the caught error: the message is fixed so that
// nothing derived from the ciphertext or the attempt can surface.
fail("That password is not correct. Please try again.");
} finally {
btn.disabled = false;
btn.classList.remove("text-muted");
}
}
function init() {
onViewLeave(VIEW, clear);
$("btn-show-phrase-back").addEventListener("click", () => {
goBack();
});
$("btn-show-phrase-reveal").addEventListener("click", reveal);
$("show-phrase-value").addEventListener("click", () => {
const phrase = $("show-phrase-value").textContent;
if (!phrase) return;
navigator.clipboard.writeText(phrase);
showFlash("Copied!");
flashCopyFeedback($("show-phrase-value"));
});
}
module.exports = { init, show };

View File

@@ -74,6 +74,15 @@ function isValidMnemonic(mnemonic) {
return Mnemonic.isValidMnemonic(mnemonic);
}
// Only an HD wallet has a recovery phrase. A "key" wallet holds a bare
// private key and an "xprv" wallet an extended private key; neither can be
// turned back into words, so neither may ever be offered the phrase display.
// Written as an allowlist on purpose: a wallet type added later is excluded
// until someone decides otherwise.
function walletHasRecoveryPhrase(walletData) {
return !!walletData && walletData.type === "hd";
}
module.exports = {
generateMnemonic,
deriveAddressFromXpub,
@@ -83,4 +92,5 @@ module.exports = {
addressFromPrivateKey,
getSignerForAddress,
isValidMnemonic,
walletHasRecoveryPhrase,
};

View File

@@ -255,6 +255,11 @@ async function openPopup(ctx, popupUrl) {
// Full wallet creation through the real UI: BIP-39 generation, libsodium
// vault encryption and extension storage persistence, for real.
//
// Returns the recovery phrase it generated. Tests that assert on a secret
// need the real value — checking for "some 12 words" would pass against the
// wrong wallet's phrase, and checking for nothing at all would pass against
// a screen that shows the phrase it was supposed to hide.
async function createWallet(page) {
await page.click("#btn-welcome-add");
await visible(page, "#view-add-wallet");
@@ -263,10 +268,12 @@ async function createWallet(page) {
const el = document.getElementById("wallet-mnemonic");
return el && el.value.trim().split(/\s+/).length >= 12;
});
const phrase = (await page.inputValue("#wallet-mnemonic")).trim();
await page.fill("#add-wallet-password", PASSWORD);
await page.fill("#add-wallet-password-confirm", PASSWORD);
await page.click("#btn-add-wallet-confirm");
await visible(page, "#view-main", 60000);
return phrase;
}
// Reach the address detail screen from wherever the popup restored to.
@@ -281,6 +288,7 @@ async function openAddressDetail(page) {
}
module.exports = {
PASSWORD,
createWallet,
launch,
openAddressDetail,

View File

@@ -10,6 +10,7 @@
"use strict";
const {
PASSWORD,
createWallet,
launch,
openAddressDetail,
@@ -34,6 +35,10 @@ function assert(cond, message) {
if (!cond) throw new Error(message);
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function withTimeout(promise, name) {
let timer;
const timeout = new Promise((_, reject) => {
@@ -56,7 +61,11 @@ test("popup loads and reaches the welcome view", async (env) => {
});
test("wallet creation through the UI reaches the main view", async (env) => {
await createWallet(env.page);
env.phrase = await createWallet(env.page);
assert(
env.phrase.split(/\s+/).length >= 12,
"wallet creation did not yield a recovery phrase",
);
const addrCount = await env.page
.locator("#wallet-list .btn-addr-info")
.count();
@@ -117,6 +126,256 @@ test("transaction detail renders an ERC-20 transfer (#151)", async (env) => {
assert(dots > 0, "token contract row rendered without its colour dot");
});
// -------------------------------------------- recovery phrase (#161)
// The gear toggles, so pressing it while Settings is already up leaves it.
async function openSettings(page) {
if (!(await page.isVisible("#view-settings"))) {
await page.click("#btn-settings");
}
await visible(page, "#view-settings");
}
// Everything the recovery phrase screen is holding, read straight out of
// the DOM whether or not that screen is the one on top. Reading it while it
// is hidden is the point: "cleared on leave" means the node is empty, not
// merely off-screen.
async function phraseScreenState(page) {
return page.evaluate(() => ({
value: document.getElementById("show-phrase-value").textContent,
error: document.getElementById("show-phrase-flash").textContent,
html: document.getElementById("view-show-phrase").innerHTML,
resultHidden: document
.getElementById("show-phrase-result")
.classList.contains("hidden"),
viewHidden: document
.getElementById("view-show-phrase")
.classList.contains("hidden"),
}));
}
async function openPhraseScreen(page) {
await openSettings(page);
await page.click("#settings-wallet-list .btn-show-phrase");
await visible(page, "#view-show-phrase");
}
async function revealPhrase(page) {
await page.fill("#show-phrase-password", PASSWORD);
await page.click("#btn-show-phrase-reveal");
await visible(page, "#show-phrase-result", 60000);
}
function assertWiped(st, phrase, where) {
assert(st.value === "", "phrase still in the DOM " + where);
assert(st.resultHidden, "result section still shown " + where);
assert(
!st.html.includes(phrase),
"the recovery phrase is still somewhere in the screen markup " + where,
);
}
test("only an HD wallet is offered the recovery phrase action (#161)", async (env) => {
await openSettings(env.page);
const offered = await env.page
.locator("#settings-wallet-list .btn-show-phrase")
.count();
const wallets = await env.page
.locator("#settings-wallet-list .btn-delete-wallet")
.count();
assert(wallets === 1, "expected exactly one wallet row, got " + wallets);
assert(
offered === 1,
"the HD wallet was not offered the recovery phrase action",
);
});
// The other half of the gate, against the real UI: a wallet holding a bare
// private key has no phrase to show, so no row of it may offer the action.
// The key is generated here rather than committed — the repo holds no
// private keys, test ones included.
test("a key wallet is not offered the recovery phrase action (#161)", async (env) => {
const { Wallet } = require("ethers");
await openSettings(env.page);
await env.page.click("#btn-main-add-wallet");
await visible(env.page, "#view-add-wallet");
await env.page.click("#tab-privkey");
await env.page.fill(
"#import-private-key",
Wallet.createRandom().privateKey,
);
await env.page.fill("#add-wallet-password", PASSWORD);
await env.page.fill("#add-wallet-password-confirm", PASSWORD);
await env.page.click("#btn-add-wallet-confirm");
await visible(env.page, "#view-main", 60000);
await openSettings(env.page);
const wallets = await env.page
.locator("#settings-wallet-list .btn-delete-wallet")
.count();
const offered = await env.page
.locator("#settings-wallet-list .btn-show-phrase")
.count();
assert(wallets === 2, "expected two wallet rows, got " + wallets);
assert(
offered === 1,
"the key wallet was offered the recovery phrase action",
);
});
test("the recovery phrase screen holds nothing before the password (#161)", async (env) => {
await openPhraseScreen(env.page);
const st = await phraseScreenState(env.page);
assertWiped(st, env.phrase, "before any password was entered");
const passwordShown = await env.page.isVisible(
"#show-phrase-password-section",
);
assert(passwordShown, "the password prompt is not shown");
});
test("a wrong password reveals nothing (#161)", async (env) => {
await env.page.fill("#show-phrase-password", "not-the-password");
await env.page.click("#btn-show-phrase-reveal");
await env.page.waitForFunction(
() =>
document.getElementById("show-phrase-flash").textContent.length > 0,
null,
{ timeout: 60000 },
);
const st = await phraseScreenState(env.page);
assertWiped(st, env.phrase, "after a wrong password");
assert(
/^[A-Z].*\.$/.test(st.error.trim()),
"the wrong-password error is not a full sentence: " +
JSON.stringify(st.error),
);
});
test("the correct password reveals the full phrase, and nothing logs it (#161)", async (env) => {
const console_ = [];
const listener = (msg) => console_.push(msg.text());
env.page.on("console", listener);
try {
await revealPhrase(env.page);
const st = await phraseScreenState(env.page);
assert(
st.value === env.phrase,
"the displayed phrase is not the wallet's phrase, verbatim",
);
const promptShown = await env.page.isVisible(
"#show-phrase-password-section",
);
assert(!promptShown, "the password prompt is still shown after unlock");
// Full Identifiers Policy: shown whole, and copyable.
const title = await env.page.getAttribute(
"#show-phrase-value",
"title",
);
assert(title === "Click to copy", "the phrase is not click-to-copy");
const leaked = console_.filter((line) => line.includes(env.phrase));
assert(
leaked.length === 0,
"the recovery phrase reached the console: " +
JSON.stringify(leaked),
);
} finally {
env.page.off("console", listener);
}
});
test('"Back" wipes the revealed phrase (#161)', async (env) => {
await env.page.click("#btn-show-phrase-back");
await visible(env.page, "#view-settings");
const st = await phraseScreenState(env.page);
assert(st.viewHidden, "the recovery phrase screen is still on top");
assertWiped(st, env.phrase, "after Back");
});
// The settings gear leaves the screen without touching its Back button. A
// clear wired only to Back would pass the test above and leak here.
test("leaving by the settings gear wipes it too (#161)", async (env) => {
await openPhraseScreen(env.page);
await revealPhrase(env.page);
await env.page.click("#btn-settings");
await visible(env.page, "#view-settings");
const st = await phraseScreenState(env.page);
assertWiped(st, env.phrase, "after leaving via the settings gear");
});
// The same leave, but taken while the decrypt is still running. Both
// clicks are dispatched inside one page task on purpose: "Reveal" runs its
// handler up to the await, the gear then runs the leave — and the wipe with
// it — to completion, and the decrypt's continuation resumes afterwards.
// Without a liveness check that continuation writes the phrase into the
// hidden screen after the wipe, and nothing is left to wipe it again.
//
// A human cannot produce this interleaving by hand once libsodium's wasm is
// warm, because crypto_pwhash is synchronous and the only suspension point
// is a microtask; the window a user can actually hit is a still-pending
// sodium.ready on the first vault use of a page load. Forcing it here is
// the only way to test the guard deterministically.
test("leaving while the decrypt is in flight reveals nothing (#161)", async (env) => {
await openPhraseScreen(env.page);
await env.page.fill("#show-phrase-password", PASSWORD);
await env.page.evaluate(() => {
document.getElementById("btn-show-phrase-reveal").click();
document.getElementById("btn-settings").click();
});
await visible(env.page, "#view-settings");
// The Reveal button is disabled for exactly the duration of the
// decrypt and re-enabled in the same continuation that would have
// written the phrase, so waiting for it to come back is a precise
// "the decrypt has settled and its handler has finished" signal
// rather than a guess at a duration.
await env.page.waitForFunction(
() => !document.getElementById("btn-show-phrase-reveal").disabled,
null,
{ timeout: 60000 },
);
await sleep(2000);
const st = await phraseScreenState(env.page);
// Printed on every run, pass or fail: "the phrase is not there" is
// worth more as a measurement than as a silent assertion, and the
// same line read from a build without the guard is what this test
// exists to prevent.
console.log(
"# probe: len=" +
st.value.length +
" equalsPhrase=" +
(st.value === env.phrase) +
" resultHidden=" +
st.resultHidden +
" viewHidden=" +
st.viewHidden,
);
assert(st.viewHidden, "the recovery phrase screen is still on top");
assertWiped(st, env.phrase, "after leaving mid-decrypt");
});
// Closing and reopening the page rather than reloading it: that is what
// the toolbar popup actually does, and the persisted currentView is
// "show-phrase" at the moment it happens, which is precisely the state
// RESTORABLE_VIEWS has to refuse.
test("reopening the popup never lands on the phrase screen (#161)", async (env) => {
await openPhraseScreen(env.page);
await revealPhrase(env.page);
await env.page.close();
env.page = await openPopup(env.ctx, env.popupUrl);
await visible(env.page, "#view-main");
const st = await phraseScreenState(env.page);
assert(st.viewHidden, "the popup reopened onto the recovery phrase screen");
assertWiped(st, env.phrase, "after reopening the popup");
});
// ---------------------------------------------------------------- runner
async function main() {
@@ -154,6 +413,9 @@ async function main() {
popupUrl: session.popupUrl,
routeOpts,
page: null,
// The recovery phrase of the wallet created in test 2, so later
// tests can assert on the real secret rather than its shape.
phrase: null,
};
// Attribution of collected errors is total. session.errors has no

94
tests/showPhrase.test.js Normal file
View File

@@ -0,0 +1,94 @@
// Tests for the recovery phrase display (issue #161).
//
// These cover the parts that do not need a DOM: which wallet types may be
// offered the action at all, the exclusion of the screen from the set of
// views the popup may reopen onto, and the absence of any path from this
// module to the logger. The DOM behaviour it guards — nothing rendered
// before the password is accepted, a wrong password revealing nothing, and
// the wipe on leaving — is driven against the real popup in a real browser
// by tests/e2e/run.js, which is where every other view behaviour is tested.
const fs = require("fs");
const path = require("path");
const { walletHasRecoveryPhrase } = require("../src/shared/wallet");
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
const SHOW_PHRASE_VIEW = "show-phrase";
// helpers.js pulls in state.js, which reads chrome.storage.local at load.
function loadHelpers() {
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
return require("../src/popup/views/helpers");
}
describe("which wallets have a recovery phrase", () => {
test("an HD wallet does", () => {
expect(walletHasRecoveryPhrase({ type: "hd" })).toBe(true);
});
// A key wallet holds a bare private key and an xprv wallet an extended
// private key. Neither can be turned back into words, so neither may be
// offered the action.
test("a key wallet does not", () => {
expect(walletHasRecoveryPhrase({ type: "key" })).toBe(false);
});
test("an xprv wallet does not", () => {
expect(walletHasRecoveryPhrase({ type: "xprv" })).toBe(false);
});
test("an unknown or missing wallet type does not", () => {
expect(walletHasRecoveryPhrase({ type: "something-new" })).toBe(false);
expect(walletHasRecoveryPhrase({})).toBe(false);
expect(walletHasRecoveryPhrase(undefined)).toBe(false);
});
});
describe("views the popup may reopen onto", () => {
// Restoring onto a secret screen would put the phrase on screen with no
// password prompt in front of it, on a popup the user may have reopened
// by accident.
test("the recovery phrase screen is not restorable", () => {
expect(RESTORABLE_VIEWS.has(SHOW_PHRASE_VIEW)).toBe(false);
});
test("the private key export screen is not restorable either", () => {
expect(RESTORABLE_VIEWS.has("export-privkey")).toBe(false);
});
test("the recovery phrase screen is still a registered view", () => {
const { VIEWS } = loadHelpers();
expect(VIEWS).toContain(SHOW_PHRASE_VIEW);
});
// Guards the other direction: a restorable name that is not a real view
// would leave restoreView() showing nothing at all.
test("every restorable view is a registered view", () => {
const { VIEWS } = loadHelpers();
for (const view of RESTORABLE_VIEWS) {
expect(VIEWS).toContain(view);
}
});
});
describe("the phrase cannot reach the logger", () => {
const source = fs.readFileSync(
path.join(__dirname, "..", "src", "popup", "views", "showPhrase.js"),
"utf8",
);
// The decrypted phrase only ever lives in a local and in the DOM node
// that displays it. The module has no logger to hand it to, and this
// pins that: src/shared/log.js writes to the console, and a console
// record of a recovery phrase outlives the popup.
test("the view does not import src/shared/log.js", () => {
expect(source).not.toMatch(/require\(["'][^"']*shared\/log["']\)/);
});
test("the view calls no logger method", () => {
expect(source).not.toMatch(/\blog\.(debugf|infof|warnf|errorf)\b/);
});
});