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
22 changed files with 2119 additions and 125 deletions

147
README.md
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 `playwright-core`, whose version must stay matched to the container's Playwright
version — the browsers ship inside the image. version — the browsers ship inside the image.
It covers popup load, wallet creation through the UI, the Add Token screen and It covers popup load, wallet creation through the UI, the Add Token screen, the
the transaction detail screen for an ERC-20 transfer. All outbound network is 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 intercepted at the browser level and served from fixtures in
`tests/e2e/network.js`, so the run is deterministic and fully offline; `tests/e2e/network.js`, so the run is deterministic and fully offline;
unrecognised outbound requests are reported as failures rather than silently unrecognised outbound requests are reported as failures rather than silently
@@ -145,10 +149,11 @@ page, which it does not by default — `script/test-e2e` sets
`PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1` for it. Because that flag is `PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1` for it. Because that flag is
experimental, the harness does not take it on trust. At launch it waits for the experimental, the harness does not take it on trust. At launch it waits for the
background worker's **own** startup request — the phishing blocklist fetch that background worker's **own** startup request — the phishing blocklist fetch that
`src/background/index.js` issues unconditionally — to arrive in the route `src/background/index.js` issues on startup, which on the suite's throwaway
handler, and aborts the entire suite if none does within 30 seconds profile always happens because no previous fetch timestamp is persisted — to
(`tests/e2e/harness.js`). The check is passive on purpose: a synthetic probe arrive in the route handler, and aborts the entire suite if none does within 30
fetched from inside the worker via `worker.evaluate()` was tried first and seconds (`tests/e2e/harness.js`). The check is passive on purpose: a synthetic
probe fetched from inside the worker via `worker.evaluate()` was tried first and
rejected, because evaluating in an extension service worker that early kills the rejected, because evaluating in an extension service worker that early kills the
worker outright, destroying the thing being measured. Observing traffic the worker outright, destroying the thing being measured. Observing traffic the
extension already generates perturbs nothing. Losing the race fails closed — the extension already generates perturbs nothing. Losing the race fails closed — the
@@ -208,9 +213,10 @@ src/
styles/main.css — Tailwind source styles/main.css — Tailwind source
views/ — one JS module per screen (home, send, approval, etc.) views/ — one JS module per screen (home, send, approval, etc.)
shared/ — modules used by both popup and background shared/ — modules used by both popup and background
alarms.js — recurring background jobs (extension alarms API)
balances.js — ETH + ERC-20 balance fetching via RPC + Blockscout balances.js — ETH + ERC-20 balance fetching via RPC + Blockscout
constants.js — chain IDs, default RPC endpoint, ERC-20 ABI constants.js — chain IDs, default RPC endpoint, ERC-20 ABI
ens.js — ENS forward/reverse resolution ens.js — ENS forward/reverse resolution (popup only)
prices.js — ETH/USD and token/USD via CoinDesk API prices.js — ETH/USD and token/USD via CoinDesk API
scamlist.js — known fraud contract addresses scamlist.js — known fraud contract addresses
state.js — persisted state (extension storage) state.js — persisted state (extension storage)
@@ -224,6 +230,74 @@ manifest/
firefox.json — Manifest V2 for Firefox firefox.json — Manifest V2 for Firefox
``` ```
### Background scheduling
Chrome runs `src/background/index.js` as a Manifest V3 service worker, which the
browser terminates after roughly 30 seconds idle and re-evaluates from scratch
on the next event. Two consequences shape every recurring job in the background:
- `setInterval` and `setTimeout` are useless. They are destroyed with the
worker, so a job scheduled that way runs until the first idle period and never
again. Both recurring jobs — the 60-second balance refresh and the 24-hour
phishing blocklist refresh — are scheduled through the extension alarms API
(`src/shared/alarms.js`) instead. The browser holds the schedule and wakes the
worker to deliver it. Alarm periods are clamped to a one-minute minimum, so
the balance refresh is expressed as exactly one minute and nothing is silently
slowed down.
- Module-level variables do not survive either. Anything that must be remembered
across a restart goes in extension storage, including the timestamp of the
last phishing list fetch: without it a revived worker would either re-fetch on
every wake or, with a naive in-memory guard, never notice that an update is
due. `localStorage` does not exist in a service worker at all — the one
remaining user of it, `src/shared/ens.js`, runs only in the popup and is
marked as such.
Both jobs also carry a freshness guard, and a guard must never be timed to the
alarm period it gates. Each guard is measured from the moment the last run
finished, which is one run-duration after the alarm that started it, so a guard
of exactly one period vetoes the very next tick and the real cadence becomes two
periods. The two jobs solve this differently, because their guards exist for
different reasons:
- The phishing refresh has a 24-hour cache TTL whose job is to keep the worker
off the network on the wakes between scheduled refreshes — Chrome revives the
worker every ~30 seconds while the browser is busy, and every revival runs the
startup path. The scheduled alarm tick is not one of those wakes, so it
bypasses the TTL and fetches unconditionally. Shortening the TTL instead would
not work: the startup path re-checks it on every wake, so a shorter TTL simply
becomes the real refresh rate.
- The balance refresh guard exists to skip work an open popup has already done —
the popup refreshes every 10 seconds and stamps the same field. That has to
keep applying on the scheduled tick, so the guard is shortened to half the
alarm period instead of bypassed: comfortably above the popup's 10 seconds, so
an open popup still suppresses the background job, and comfortably below the
60-second period, so the schedule always wins.
Two timestamps are persisted for the phishing list, not one. `lastFetchTime`
records a fetch that produced a usable delta and drives the TTL.
`lastAttemptTime` records that the network was contacted at all, and is written
even when the result is unusable — a failed request, or a delta over the 256 KiB
cap. Without it those cases leave no freshness mark and the worker re-downloads
the full blocklist on every wake, indefinitely; with it, unscheduled retries are
floored at one hour. Both are discarded on load if they are in the future, since
a stamp from a skewed clock or a restored backup would otherwise suppress
updates until that time arrives, permanently and with no way out.
The startup path (`ensureRecurringAlarms()` plus the phishing list init) runs on
`onInstalled`, on `onStartup`, and at the top level of the worker, so every way
the background context can start re-establishes the schedule. On a fresh install
more than one of those fires, so they share a single in-flight run rather than
racing. It is idempotent: an alarm that already exists with the period the code
asks for is left alone, because re-creating one restarts its schedule and a busy
extension would push the next fire out indefinitely. An alarm carrying a
different period — one created by an earlier version — is re-created once, or a
period changed in a new release would never reach an existing install.
Firefox uses Manifest V2 with a persistent background page, where timers would
survive. Both browsers are built from one bundle and both take the alarm path,
so there is a single code path to reason about; `"alarms"` is declared in both
`manifest/chrome.json` and `manifest/firefox.json`.
### UI Design Philosophy ### UI Design Philosophy
The UI is inspired by _Universal Paperclips_. It's deliberately minimal, The UI is inspired by _Universal Paperclips_. It's deliberately minimal,
@@ -406,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. repeated in the element lists below.
Closing and reopening the popup returns to the screen the user was last on only 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 for the views listed in `RESTORABLE_VIEWS` (`src/popup/restorableViews.js`).
screen, including ExportPrivKey, falls back to Home. 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`) #### Welcome (`welcome`)
@@ -707,8 +784,9 @@ screen, including ExportPrivKey, falls back to Home.
- **When**: User tapped the Settings gear. - **When**: User tapped the Settings gear.
- **Elements**: - **Elements**:
- "Back" button, "Settings" heading - "Back" button, "Settings" heading
- Wallets: one row per wallet with its name (tap to rename inline) and an - Wallets: one row per wallet with its name (tap to rename inline), a
`[x]` delete button, plus a "+ Add wallet" button `[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, - Tracked Tokens: one row per tracked token with an `[x]` remove button,
plus a "+ Add token" button plus a "+ Add token" button
- Display: "Show tracked tokens with zero balance" checkbox and a Theme - Display: "Show tracked tokens with zero balance" checkbox and a Theme
@@ -733,6 +811,7 @@ screen, including ExportPrivKey, falls back to Home.
- **Transitions**: - **Transitions**:
- "+ Add wallet" → **AddWallet** - "+ Add wallet" → **AddWallet**
- "+ Add token" → **SettingsAddToken** - "+ Add token" → **SettingsAddToken**
- `[recovery phrase]` on an HD wallet → **ShowRecoveryPhrase**
- `[x]` on a wallet → **DeleteWallet** - `[x]` on a wallet → **DeleteWallet**
- Tap wallet name → inline rename field (no screen change) - Tap wallet name → inline rename field (no screen change)
- `[x]` on a tracked token or a site → removes it in place (no screen - `[x]` on a tracked token or a site → removes it in place (no screen
@@ -740,6 +819,33 @@ screen, including ExportPrivKey, falls back to Home.
- Ten clicks on the version → reveals the Debug well (no screen change) - Ten clicks on the version → reveals the Debug well (no screen change)
- "Back" (or Settings gear again) → previous screen (Home) - "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`) #### DeleteWallet (`delete-wallet-confirm`)
- **When**: User tapped the `[x]` next to a wallet in Settings. - **When**: User tapped the `[x]` next to a wallet in Settings.
@@ -902,9 +1008,14 @@ CoinDesk price API, and Blockscout API), AutistMask also contacts:
- **Phishing domain blocklist**: A community-maintained phishing domain - **Phishing domain blocklist**: A community-maintained phishing domain
blocklist is vendored into the extension at build time. At runtime, the blocklist is vendored into the extension at build time. At runtime, the
extension fetches the live list once every 24 hours to detect newly added extension fetches the live list once every 24 hours to detect newly added
domains. Only the delta (domains not already in the vendored list) is kept in domains, plus once on a start where the list is more than 24 hours old. Only
memory, keeping runtime memory usage small. The delta is persisted to the delta (domains not already in the vendored list) is kept in memory,
localStorage if it is under 256 KiB. keeping runtime memory usage small. The delta and the timestamp of the fetch
that produced it are persisted to extension storage if the record is under 256
KiB; an oversized delta is dropped along with its timestamp, so a later start
fetches again rather than claiming freshness for data it no longer holds. A
fetch that fails, or one whose delta was too large to store, is not retried
more than once an hour outside the 24-hour schedule.
- **Etherscan address labels**: When confirming a transaction, the extension - **Etherscan address labels**: When confirming a transaction, the extension
performs a best-effort lookup of the recipient address on Etherscan to check performs a best-effort lookup of the recipient address on Etherscan to check
for phishing/scam labels. This is a direct page fetch with no API key; the for phishing/scam labels. This is a direct page fetch with no API key; the
@@ -1127,6 +1238,12 @@ live list once every 24 hours and keeps only the delta (newly added domains not
in the vendored list) in memory. This architecture keeps runtime memory usage in the vendored list) in memory. This architecture keeps runtime memory usage
small while ensuring fresh coverage of new phishing domains. small while ensuring fresh coverage of new phishing domains.
The 24-hour cadence is an alarm, not a timer; the alarm tick fetches
unconditionally rather than re-checking the 24-hour cache TTL that gates the
startup path; and the fetch timestamps live in extension storage rather than in
module variables — see [Background scheduling](#background-scheduling) for why
all three are required.
When a dApp on a blocklisted domain requests a wallet connection, transaction When a dApp on a blocklisted domain requests a wallet connection, transaction
approval, or signature, the approval popup displays a prominent red warning approval, or signature, the approval popup displays a prominent red warning
banner alerting the user. The domain checker matches exact hostnames and all banner alerting the user. The domain checker matches exact hostnames and all
@@ -1182,7 +1299,7 @@ Currently supported:
- [x] Delete wallet (with confirmation) - [x] Delete wallet (with confirmation)
- [ ] Delete address from HD 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 ### Transactions

15
TODO.md
View File

@@ -44,11 +44,26 @@ undefined identifiers, which is how
# Completed Steps # 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 - 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 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 `src/shared/transactions.js` goes through one case-normalising helper so a
checksummed genuine contract is no longer read as a spoof checksummed genuine contract is no longer read as a spoof
([#179](https://git.eeqj.de/sneak/AutistMask/issues/179)). ([#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
the MV3 service worker. Each job's freshness guard was decoupled from its
alarm period at the same time — timed to the period, a guard vetoes its own
scheduled tick and halves the real refresh rate
([#158](https://git.eeqj.de/sneak/AutistMask/issues/158)).
- 2026-08-11: Policy compliance sweep — conditional verbose test rerun, local - 2026-08-11: Policy compliance sweep — conditional verbose test rerun, local
Tailwind binary instead of `npx`, `--frozen-lockfile` on `make install`, and Tailwind binary instead of `npx`, `--frozen-lockfile` on `make install`, and
the Makefile-only targets documented in the README the Makefile-only targets documented in the README

View File

@@ -130,10 +130,14 @@ live list to pick up newly added domains, keeping only the entries not already
in the bundled copy (persisted locally if under 256 KiB). This endpoint is not in the bundled copy (persisted locally if under 256 KiB). This endpoint is not
user-configurable. user-configurable.
When it is contacted: once when the background script starts, and every 24 hours When it is contacted: when the background script starts, if the last fetch was
after that. It is a plain download of a public file — nothing about you is sent, more than 24 hours ago, and every 24 hours after that. The time of the last
but the host sees your IP address. If the fetch fails, the bundled copy is still fetch is remembered across browser and background restarts, so restarting does
used. not cause a re-download. If a fetch fails, or the list is too large to keep, the
extension waits an hour before trying again outside that 24-hour schedule rather
than retrying on every restart. It is a plain download of a public file —
nothing about you is sent, but the host sees your IP address. If the fetch
fails, the bundled copy is still used.
**Etherscan address labels** (`etherscan.io`; `sepolia.etherscan.io` on Sepolia) **Etherscan address labels** (`etherscan.io`; `sepolia.etherscan.io` on Sepolia)

View File

@@ -3,7 +3,7 @@
"name": "AutistMask", "name": "AutistMask",
"version": "0.1.0", "version": "0.1.0",
"description": "Minimal Ethereum wallet for Chrome", "description": "Minimal Ethereum wallet for Chrome",
"permissions": ["storage", "activeTab"], "permissions": ["storage", "activeTab", "alarms"],
"host_permissions": ["<all_urls>"], "host_permissions": ["<all_urls>"],
"action": { "action": {
"default_popup": "src/popup/index.html" "default_popup": "src/popup/index.html"

View File

@@ -3,7 +3,7 @@
"name": "AutistMask", "name": "AutistMask",
"version": "0.1.0", "version": "0.1.0",
"description": "Minimal Ethereum wallet for Firefox", "description": "Minimal Ethereum wallet for Firefox",
"permissions": ["storage", "activeTab", "<all_urls>"], "permissions": ["storage", "activeTab", "alarms", "<all_urls>"],
"browser_action": { "browser_action": {
"default_popup": "src/popup/index.html" "default_popup": "src/popup/index.html"
}, },

View File

@@ -22,6 +22,18 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" 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" MANIFEST="dist/constants-bundles.txt"
MARKER_ON="autistmask-build-debug=on" MARKER_ON="autistmask-build-debug=on"
MARKER_OFF="autistmask-build-debug=off" MARKER_OFF="autistmask-build-debug=off"
@@ -29,11 +41,20 @@ MARKER_OFF="autistmask-build-debug=off"
# Set by read_marker. # Set by read_marker.
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() { fail() {
echo "verify-build: FAIL: $*" >&2 echo "verify-build: FAIL: $*" >&2
exit 1 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 # 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 # (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 # 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 # 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 # unreadable manifest reads as "this file is not listed" and every emitted
# bundle gets reported as an unlisted one. # 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() { is_listed() {
case "$1" in
*"$NEWLINE"*) return 1 ;;
esac
_il_status=0 _il_status=0
grep -q -x -F -e "$1" -- "$MANIFEST" || _il_status=$? grep -q -x -F -e "$1" -- "$MANIFEST" || _il_status=$?
case "$_il_status" in case "$_il_status" in
@@ -117,35 +148,66 @@ read_marker() {
# an endsWith(".js") test; repeating that literal here would mean a bundle # an endsWith(".js") test; repeating that literal here would mean a bundle
# emitted under some other extension escaped the manifest AND this check at # 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 # 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 # avoid. Every regular file and every symlink under dist/ is searched — that
# place the assumption lives and this check is what catches it being wrong. # 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 # That claim only holds if the walk is exhaustive and every name survives it
# here rather than assumed: # 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 # - 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 # stderr and then simply missing from the listing, so an unchecked status
# turns "could not look" into "nothing was there" — the same conflation # turns "could not look" into "nothing was there" — the same conflation
# has_marker exists to prevent. The status cannot be read off a pipeline # has_marker exists to prevent. The status cannot be read off a pipeline,
# ending in sort, so the sort is a separate step. # so the listing lands in a file that xargs then reads back.
# - symlinks are walked too (-type l), not skipped. A marker-carrying bundle # - 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 # 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 # 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 # 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 # 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. # 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() { 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 _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 ] || [ "$_find_status" -eq 0 ] ||
fail "find exited $_find_status enumerating dist/, so part of the tree 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 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 unlisted bundle there went unchecked. That is a permissions or I/O fault on
the artifact, not a stale manifest. Refusing to report success." the artifact, not a stale manifest. Refusing to report success."
_listing="$(printf '%s\n' "$_listing" | sort)"
while read -r _file; do _scan_status=0
[ -n "$_file" ] || continue 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 if is_listed "$_file"; then
continue continue
fi fi
@@ -154,9 +216,7 @@ check_unlisted_bundles() {
fail "$_file carries a debug marker but is absent from $MANIFEST, fail "$_file carries a debug marker but is absent from $MANIFEST,
so the manifest no longer describes the emitted bundles." so the manifest no longer describes the emitted bundles."
fi fi
done <<EOF done
$_listing
EOF
} }
# The requested mode, read from our own environment using build.js's exact # The requested mode, read from our own environment using build.js's exact
@@ -173,9 +233,32 @@ expected_marker() {
main() { main() {
cd "$ROOT" 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)" expected="$(expected_marker)"
echo "Verifying emitted bundles (expecting $expected)..." 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" ] || [ -f "$MANIFEST" ] ||
fail "$MANIFEST is missing. build.js writes it at the end of a fail "$MANIFEST is missing. build.js writes it at the end of a
successful build; run make build first." successful build; run make build first."

View File

@@ -12,13 +12,20 @@ const {
currentNetwork, currentNetwork,
} = require("../shared/state"); } = require("../shared/state");
const { refreshBalances, getProvider } = require("../shared/balances"); const { refreshBalances, getProvider } = require("../shared/balances");
const { debugFetch } = require("../shared/log"); const { debugFetch, log } = require("../shared/log");
const { verifySignedTx, verifySignature } = require("../shared/approvalVerify"); const { verifySignedTx, verifySignature } = require("../shared/approvalVerify");
const { const {
isPhishingDomain, isPhishingDomain,
updatePhishingList, refreshPhishingListOnSchedule,
startPeriodicRefresh, initPhishingList,
} = require("../shared/phishingDomains"); } = require("../shared/phishingDomains");
const {
BALANCE_REFRESH_ALARM,
PHISHING_REFRESH_ALARM,
BALANCE_REFRESH_PERIOD_MINUTES,
ensureRecurringAlarms,
registerAlarmHandlers,
} = require("../shared/alarms");
const storageApi = const storageApi =
typeof browser !== "undefined" typeof browser !== "undefined"
@@ -591,12 +598,22 @@ async function broadcastAccountsChanged() {
// Background balance refresh: every 60 seconds when the popup isn't open. // Background balance refresh: every 60 seconds when the popup isn't open.
// When the popup IS open, its 10-second interval keeps lastBalanceRefresh // When the popup IS open, its 10-second interval keeps lastBalanceRefresh
// fresh, so this naturally skips. // fresh, so this naturally skips.
const BACKGROUND_REFRESH_INTERVAL = 60000; //
// The alarm period alone sets the cadence; this guard only suppresses a
// refresh something else has just done, so it must stay strictly shorter than
// the period. Timed to the period it would veto every tick it gates —
// lastBalanceRefresh is stamped after the refresh runs, so a tick one period
// after the last one always lands inside a guard of equal length and the real
// cadence becomes two periods. Half the period keeps it comfortably above the
// popup's 10-second refresh, so an open popup still suppresses the background
// job, and comfortably below the alarm period, so the schedule always wins.
const BALANCE_REFRESH_PERIOD_MS = BALANCE_REFRESH_PERIOD_MINUTES * 60 * 1000;
const RECENT_BALANCE_REFRESH_MS = Math.floor(BALANCE_REFRESH_PERIOD_MS / 2);
async function backgroundRefresh() { async function backgroundRefresh() {
await loadState(); await loadState();
const now = Date.now(); const now = Date.now();
if (now - (state.lastBalanceRefresh || 0) < BACKGROUND_REFRESH_INTERVAL) if (now - (state.lastBalanceRefresh || 0) < RECENT_BALANCE_REFRESH_MS)
return; return;
if (state.wallets.length === 0) return; if (state.wallets.length === 0) return;
await refreshBalances( await refreshBalances(
@@ -609,12 +626,58 @@ async function backgroundRefresh() {
await saveState(); await saveState();
} }
setInterval(backgroundRefresh, BACKGROUND_REFRESH_INTERVAL); // Both recurring jobs run off alarms, not timers. On Chrome MV3 this file is
// a service worker that the browser terminates after about 30 seconds idle,
// so a setInterval would only ever survive until the first idle period and
// module-level state does not outlive it. Alarms are held by the browser and
// wake the worker to deliver them.
registerAlarmHandlers({
[BALANCE_REFRESH_ALARM]: backgroundRefresh,
// The scheduled refresh, which restores persisted state on a freshly
// revived worker and then fetches unconditionally. The freshness guards
// belong to the startup path; applying them here would make the tick skip
// itself.
[PHISHING_REFRESH_ALARM]: refreshPhishingListOnSchedule,
});
// Fetch the phishing domain blocklist delta on startup and refresh every 24h. // Everything the background context needs re-established on start. This runs
// The vendored blocklist is bundled at build time; this fetches only new entries. // on a fresh install, on browser startup, and on every revival of a
updatePhishingList(); // terminated worker, so it must be idempotent: ensureRecurringAlarms() only
startPeriodicRefresh(); // creates alarms that are missing or carrying a stale period, and
// initPhishingList() fetches only when the persisted timestamps say the list
// is stale.
//
// On a fresh install the top-level call and the onInstalled listener both run,
// close enough together that both could see an alarm missing and create it.
// Sharing one in-flight run makes the "create only when missing" check
// race-free; the memo is dropped once it settles so a later onStartup runs
// again.
let backgroundJobsRun = null;
function startBackgroundJobs() {
if (backgroundJobsRun) return backgroundJobsRun;
backgroundJobsRun = Promise.all([
ensureRecurringAlarms(),
initPhishingList(),
])
.catch((err) => {
// An alarm that failed to schedule means a recurring job silently
// never runs again; it must not be an unhandled rejection.
log.errorf("background job startup failed:", err);
})
.finally(() => {
backgroundJobsRun = null;
});
return backgroundJobsRun;
}
if (runtime.onInstalled) {
runtime.onInstalled.addListener(startBackgroundJobs);
}
if (runtime.onStartup) {
runtime.onStartup.addListener(startBackgroundJobs);
}
startBackgroundJobs();
// When approval window is closed without a response, treat as rejection // When approval window is closed without a response, treat as rejection
if (windowsApi && windowsApi.onRemoved) { if (windowsApi && windowsApi.onRemoved) {

View File

@@ -1098,6 +1098,52 @@
</button> </button>
</div> </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 ============ --> <!-- ============ SETTINGS: ADD TOKEN ============ -->
<div id="view-settings-addtoken" class="view hidden"> <div id="view-settings-addtoken" class="view hidden">
<button <button

View File

@@ -15,6 +15,10 @@ const {
clearViewStack, clearViewStack,
} = require("./views/helpers"); } = require("./views/helpers");
const { applyTheme } = require("./theme"); 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 home = require("./views/home");
const welcome = require("./views/welcome"); 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) { function needsAddress(view) {
return ( return (
view === "address" || 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-tx",
"approve-sign", "approve-sign",
"export-privkey", "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) { function $(id) {
return document.getElementById(id); return document.getElementById(id);
} }
@@ -50,6 +62,11 @@ function hideError(id) {
} }
function showView(name) { function showView(name) {
const leaving = state.currentView;
if (leaving && leaving !== name) {
const onLeave = viewLeaveHandlers.get(leaving);
if (onLeave) onLeave();
}
for (const v of VIEWS) { for (const v of VIEWS) {
const el = document.getElementById(`view-${v}`); const el = document.getElementById(`view-${v}`);
if (el) { if (el) {
@@ -431,10 +448,12 @@ function flashCopyFeedback(el) {
} }
module.exports = { module.exports = {
VIEWS,
$, $,
showError, showError,
hideError, hideError,
showView, showView,
onViewLeave,
updateDebugBanner, updateDebugBanner,
setRenderMain, setRenderMain,
pushCurrentView, pushCurrentView,

View File

@@ -14,6 +14,8 @@ const { NETWORKS, SUPPORTED_CHAIN_IDS } = require("../../shared/networks");
const { onChainSwitch } = require("../../shared/chainSwitch"); const { onChainSwitch } = require("../../shared/chainSwitch");
const { log, debugFetch, setRuntimeDebug } = require("../../shared/log"); const { log, debugFetch, setRuntimeDebug } = require("../../shared/log");
const deleteWallet = require("./deleteWallet"); const deleteWallet = require("./deleteWallet");
const showPhrase = require("./showPhrase");
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
const { const {
BUILD_VERSION, BUILD_VERSION,
BUILD_LICENSE, BUILD_LICENSE,
@@ -99,7 +101,14 @@ function renderWalletListSettings() {
const name = escapeHtml(wallet.name || "Wallet " + (idx + 1)); 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 += `<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="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 += `<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>`; html += `</div>`;
}); });
container.innerHTML = html; 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 // Inline rename on click
container.querySelectorAll(".settings-wallet-name").forEach((span) => { container.querySelectorAll(".settings-wallet-name").forEach((span) => {
span.addEventListener("click", () => { span.addEventListener("click", () => {
@@ -191,6 +209,7 @@ function renderSiteLists() {
function init(ctx) { function init(ctx) {
deleteWallet.init(ctx); deleteWallet.init(ctx);
showPhrase.init();
$("btn-save-rpc").addEventListener("click", async () => { $("btn-save-rpc").addEventListener("click", async () => {
const url = $("settings-rpc").value.trim(); 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 };

114
src/shared/alarms.js Normal file
View File

@@ -0,0 +1,114 @@
// Periodic scheduling for the background context.
//
// The Chrome MV3 service worker is terminated after roughly 30 seconds idle,
// which takes every setInterval/setTimeout with it. The extension alarms API
// is the mechanism that survives: the browser holds the schedule and wakes
// the worker to deliver onAlarm. Firefox MV2 runs a persistent background
// page where timers would survive, but alarms behave identically there, so
// both targets share this path and both manifests declare the "alarms"
// permission.
//
// Periods are whole minutes at or above the browser-enforced one-minute
// minimum, so nothing here is silently clamped to a slower cadence.
//
// Trap for anyone changing a period: each job also carries a freshness guard
// that can veto its own scheduled tick. A guard timed to the alarm period
// halves the real cadence, because the guard is measured from when the last
// run finished and the alarm fires one run-duration earlier than that. Every
// guard must therefore either be strictly shorter than the period it gates or
// be bypassed on the scheduled tick — see backgroundRefresh() in
// src/background/index.js and updatePhishingList() in shared/phishingDomains.js.
const BALANCE_REFRESH_ALARM = "autistmask-balance-refresh";
const PHISHING_REFRESH_ALARM = "autistmask-phishing-refresh";
const MIN_ALARM_PERIOD_MINUTES = 1;
const BALANCE_REFRESH_PERIOD_MINUTES = 1;
const PHISHING_REFRESH_PERIOD_MINUTES = 24 * 60;
// Resolved on use rather than captured at module load: the worker is torn
// down and re-evaluated repeatedly, and tests install a stub after requiring
// the module.
function alarmsApi() {
if (typeof browser !== "undefined" && browser.alarms) return browser.alarms;
if (typeof chrome !== "undefined" && chrome.alarms) return chrome.alarms;
return null;
}
/**
* Create an alarm unless one with the requested period already exists.
*
* The existence check is load-bearing: creating an alarm resets its schedule,
* and this runs on every worker wake. Creating unconditionally would push the
* next fire time out on every incoming message, so a busy extension would
* never see the alarm fire at all.
*
* The period comparison is equally load-bearing in the other direction: an
* alarm created by an older version keeps its old period forever unless a
* changed constant re-creates it, so a period edit would never reach an
* existing install. Re-creating on a period change happens once and then
* settles into the existence check above.
*
* @param {string} name
* @param {number} periodInMinutes
* @returns {Promise<boolean>} true if the alarm was created by this call.
*/
async function ensureAlarm(name, periodInMinutes) {
const api = alarmsApi();
if (!api) return false;
const period = Math.max(periodInMinutes, MIN_ALARM_PERIOD_MINUTES);
const existing = await api.get(name);
if (existing && existing.periodInMinutes === period) return false;
api.create(name, {
periodInMinutes: period,
delayInMinutes: period,
});
return true;
}
/**
* Ensure both recurring background jobs are scheduled. Safe to call on every
* worker start, on onInstalled and on onStartup.
*
* @returns {Promise<{balance: boolean, phishing: boolean}>} which alarms this
* call had to create.
*/
async function ensureRecurringAlarms() {
const balance = await ensureAlarm(
BALANCE_REFRESH_ALARM,
BALANCE_REFRESH_PERIOD_MINUTES,
);
const phishing = await ensureAlarm(
PHISHING_REFRESH_ALARM,
PHISHING_REFRESH_PERIOD_MINUTES,
);
return { balance, phishing };
}
/**
* Register per-alarm handlers. One listener dispatches by alarm name so the
* worker only ever installs a single onAlarm listener.
*
* @param {Object<string, function>} handlers
* @returns {boolean} true if the listener was installed.
*/
function registerAlarmHandlers(handlers) {
const api = alarmsApi();
if (!api || !api.onAlarm) return false;
api.onAlarm.addListener((alarm) => {
const handler = handlers[alarm && alarm.name];
if (handler) handler();
});
return true;
}
module.exports = {
BALANCE_REFRESH_ALARM,
PHISHING_REFRESH_ALARM,
MIN_ALARM_PERIOD_MINUTES,
BALANCE_REFRESH_PERIOD_MINUTES,
PHISHING_REFRESH_PERIOD_MINUTES,
ensureAlarm,
ensureRecurringAlarms,
registerAlarmHandlers,
};

View File

@@ -1,6 +1,11 @@
// Cached ENS reverse resolution. // Cached ENS reverse resolution.
// Resolves addresses to ENS names via ethers provider.lookupAddress(), // Resolves addresses to ENS names via ethers provider.lookupAddress(),
// caching results in localStorage with a 12-hour TTL. // caching results in localStorage with a 12-hour TTL.
//
// POPUP ONLY. localStorage does not exist in the Chrome MV3 service worker,
// so this module must not be pulled into src/background/. Anything the
// background context needs to cache goes in extension storage instead (see
// shared/phishingDomains.js).
const { getProvider } = require("./balances"); const { getProvider } = require("./balances");
const { log } = require("./log"); const { log } = require("./log");

View File

@@ -8,8 +8,14 @@
// The domain-checker checks the in-memory delta first (fresh/recent scam // The domain-checker checks the in-memory delta first (fresh/recent scam
// sites), then falls back to the vendored list. // sites), then falls back to the vendored list.
// //
// If the delta is under 256 KiB it is persisted to localStorage so it // If the delta and its fetch timestamp fit in 256 KiB they are persisted to
// survives extension/service-worker restarts. // extension storage, so they survive termination of the MV3 service worker.
// Extension storage, not localStorage: localStorage does not exist in a
// service worker, so the previous persistence never ran on Chrome at all.
// The stored timestamps are what keep a restarted worker from re-fetching on
// every wake while still noticing an overdue update. Those guards apply to the
// startup path only; the 24-hour alarm tick bypasses them, or it would veto
// its own refresh — see updatePhishingList().
const vendoredConfig = require("./phishingBlocklist.json"); const vendoredConfig = require("./phishingBlocklist.json");
@@ -17,7 +23,14 @@ const BLOCKLIST_URL =
"https://raw.githubusercontent.com/MetaMask/eth-phishing-detect/main/src/config.json"; "https://raw.githubusercontent.com/MetaMask/eth-phishing-detect/main/src/config.json";
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
const REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
// Floor on how often an unscheduled path may hit the network. The worker is
// revived every ~30 seconds while the browser is busy, and every revival runs
// the startup path; without a persisted record of the last attempt, any state
// that leaves lastFetchTime unset — a fetch that failed, or a delta too large
// to store — would download the full list on every single wake.
const MIN_FETCH_ATTEMPT_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
const DELTA_STORAGE_KEY = "phishing-delta"; const DELTA_STORAGE_KEY = "phishing-delta";
const MAX_DELTA_BYTES = 256 * 1024; // 256 KiB const MAX_DELTA_BYTES = 256 * 1024; // 256 KiB
@@ -29,45 +42,104 @@ const vendoredBlacklist = new Set(
// Delta set — only entries from live list that are NOT in vendored. // Delta set — only entries from live list that are NOT in vendored.
let deltaBlacklist = new Set(); let deltaBlacklist = new Set();
let lastFetchTime = 0; let lastFetchTime = 0;
let lastAttemptTime = 0;
let fetchPromise = null; let fetchPromise = null;
let refreshTimer = null; let loadPromise = null;
// Resolved on use rather than captured at module load, so a test can install
// a stub after requiring the module and so the popup — which has no reason to
// touch the delta — does not fail to load where the API is absent.
function storageApi() {
if (typeof browser !== "undefined" && browser.storage) {
return browser.storage.local;
}
if (typeof chrome !== "undefined" && chrome.storage) {
return chrome.storage.local;
}
return null;
}
/** /**
* Load delta entries from localStorage on startup. * Sanitise a timestamp read back from storage.
* Called once during module initialization in the background script. *
* A value in the future is permanent poison: every guard here measures elapsed
* time as `Date.now() - stamp` and tests only the lower bound, so a stamp a
* year ahead suppresses updates for a year with no path that ever clears it.
* Clock skew and a restored profile backup both produce one. Since these
* timestamps only ever gate work, discarding an impossible one is safe: it
* costs at most a single extra fetch and restores a sane value immediately.
*
* @param {unknown} value
* @returns {number} the timestamp, or 0 if it is unusable.
*/ */
function loadDeltaFromStorage() { function sanitizeTimestamp(value) {
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
if (value <= 0 || value > Date.now()) return 0;
return value;
}
/**
* Load the persisted delta and its timestamps from extension storage.
* Runs once per worker lifetime; every entry point funnels through
* ensureDeltaLoaded() so a wake from termination restores state exactly once.
*
* @returns {Promise<void>}
*/
async function loadDeltaFromStorage() {
const storage = storageApi();
if (!storage) return;
try { try {
const raw = localStorage.getItem(DELTA_STORAGE_KEY); const result = await storage.get(DELTA_STORAGE_KEY);
if (!raw) return; const data = result && result[DELTA_STORAGE_KEY];
const data = JSON.parse(raw); if (!data) return;
if (data.blacklist && Array.isArray(data.blacklist)) { if (Array.isArray(data.blacklist)) {
deltaBlacklist = new Set( deltaBlacklist = new Set(
data.blacklist.map((d) => d.toLowerCase()), data.blacklist.map((d) => d.toLowerCase()),
); );
} }
lastFetchTime = sanitizeTimestamp(data.lastFetchTime);
lastAttemptTime = sanitizeTimestamp(data.lastAttemptTime);
} catch { } catch {
// localStorage unavailable or corrupt — start empty // Storage unavailable or corrupt — start empty and re-fetch.
} }
} }
function ensureDeltaLoaded() {
if (!loadPromise) loadPromise = loadDeltaFromStorage();
return loadPromise;
}
/** /**
* Persist delta to localStorage if it fits within MAX_DELTA_BYTES. * Persist the delta and its timestamps if they fit within MAX_DELTA_BYTES.
*
* The 256 KiB cap covers the delta and its freshness claim: when the delta is
* too large to keep, lastFetchTime goes with it, so the next start re-fetches
* rather than trusting a freshness claim for a delta it no longer holds.
* lastAttemptTime is written either way — it records that the network was
* contacted, which stays true whatever became of the response, and it is what
* stops a permanently oversized list from downloading on every worker wake.
*
* @returns {Promise<void>}
*/ */
function saveDeltaToStorage() { async function saveDeltaToStorage() {
const storage = storageApi();
if (!storage) return;
try { try {
const data = { const data = {
blacklist: Array.from(deltaBlacklist), blacklist: Array.from(deltaBlacklist),
lastFetchTime,
lastAttemptTime,
}; };
const json = JSON.stringify(data); const json = JSON.stringify(data);
if (json.length < MAX_DELTA_BYTES) { if (json.length < MAX_DELTA_BYTES) {
localStorage.setItem(DELTA_STORAGE_KEY, json); await storage.set({ [DELTA_STORAGE_KEY]: data });
} else if (lastAttemptTime > 0) {
await storage.set({ [DELTA_STORAGE_KEY]: { lastAttemptTime } });
} else { } else {
// Too large — remove stale key if present await storage.remove(DELTA_STORAGE_KEY);
localStorage.removeItem(DELTA_STORAGE_KEY);
} }
} catch { } catch {
// localStorage unavailable — skip silently // Storage unavailable — skip silently
} }
} }
@@ -76,6 +148,7 @@ function saveDeltaToStorage() {
* Used for both live fetches and testing. * Used for both live fetches and testing.
* *
* @param {{ blacklist?: string[] }} config * @param {{ blacklist?: string[] }} config
* @returns {Promise<void>} resolves once the delta has been persisted.
*/ */
function loadConfig(config) { function loadConfig(config) {
const liveBlacklist = (config.blacklist || []).map((d) => d.toLowerCase()); const liveBlacklist = (config.blacklist || []).map((d) => d.toLowerCase());
@@ -86,7 +159,7 @@ function loadConfig(config) {
); );
lastFetchTime = Date.now(); lastFetchTime = Date.now();
saveDeltaToStorage(); return saveDeltaToStorage();
} }
/** /**
@@ -111,6 +184,11 @@ function hostnameVariants(hostname) {
* Check if a hostname is on the phishing blocklist. * Check if a hostname is on the phishing blocklist.
* Checks delta first (fresh/recent scam sites), then vendored list. * Checks delta first (fresh/recent scam sites), then vendored list.
* *
* Synchronous by design — callers answer an approval prompt with it. On a
* worker that has just woken, the persisted delta may still be loading; the
* vendored list, which is bundled and always present, carries the check until
* it lands.
*
* @param {string} hostname - The hostname to check. * @param {string} hostname - The hostname to check.
* @returns {boolean} * @returns {boolean}
*/ */
@@ -127,28 +205,59 @@ function isPhishingDomain(hostname) {
/** /**
* Fetch the latest blocklist and compute delta against vendored data. * Fetch the latest blocklist and compute delta against vendored data.
* De-duplicates concurrent fetches. Results are cached for CACHE_TTL_MS. * De-duplicates concurrent fetches. Results are cached for CACHE_TTL_MS,
* counted from the persisted timestamp so the cache outlives the worker.
* *
* `force` is what makes the 24-hour alarm actually refresh every 24 hours.
* The alarm fires one period after the previous alarm, but lastFetchTime is
* stamped when that fetch *completed*, so an unforced tick lands one fetch
* latency inside its own TTL, skips, and turns the real cadence into 48 hours.
* Shortening the TTL instead would not fix it: the worker wakes every ~30
* seconds and the startup path re-checks the TTL each time, so a shortened TTL
* simply becomes the real cadence. The TTL is there to stop redundant fetches
* on wake, and the scheduled tick is not redundant, so it bypasses it.
*
* @param {{force?: boolean}} [opts] force: fetch unless one is already in
* flight, ignoring both the freshness and the retry guard. For the scheduled
* alarm tick only.
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async function updatePhishingList() { async function updatePhishingList({ force = false } = {}) {
// Skip if recently fetched // A worker that has just been revived knows nothing until the persisted
if (Date.now() - lastFetchTime < CACHE_TTL_MS && lastFetchTime > 0) { // record is back in memory; without this the freshness check below would
return; // always see 0 and re-fetch on every wake.
await ensureDeltaLoaded();
if (!force) {
const now = Date.now();
// Skip if recently fetched.
if (lastFetchTime > 0 && now - lastFetchTime < CACHE_TTL_MS) return;
// Skip if the network was contacted recently and the result was not
// usable — a failed fetch or an oversized delta leaves lastFetchTime
// unset, and without this every wake would retry.
if (
lastAttemptTime > 0 &&
now - lastAttemptTime < MIN_FETCH_ATTEMPT_INTERVAL_MS
) {
return;
}
} }
// De-duplicate concurrent calls // De-duplicate concurrent calls
if (fetchPromise) return fetchPromise; if (fetchPromise) return fetchPromise;
fetchPromise = (async () => { fetchPromise = (async () => {
lastAttemptTime = Date.now();
try { try {
const resp = await fetch(BLOCKLIST_URL); const resp = await fetch(BLOCKLIST_URL);
if (!resp.ok) throw new Error("HTTP " + resp.status); if (!resp.ok) throw new Error("HTTP " + resp.status);
const config = await resp.json(); const config = await resp.json();
loadConfig(config); await loadConfig(config);
} catch { } catch {
// Silently fail — vendored list still provides coverage. // Silently fail — vendored list still provides coverage. Persist
// We'll retry next time. // the attempt so a persistently failing fetch is retried on the
// schedule rather than on every wake.
await saveDeltaToStorage();
} finally { } finally {
fetchPromise = null; fetchPromise = null;
} }
@@ -158,12 +267,29 @@ async function updatePhishingList() {
} }
/** /**
* Start periodic refresh of the phishing list. * Restore persisted state and fetch if the list is overdue.
* Should be called once from the background script on startup. *
* Called from the background script every time it starts — a fresh install,
* a browser start, and every revival of a terminated service worker all land
* here. The recurring 24-hour schedule itself is an alarm (see
* shared/alarms.js), not a timer, because timers die with the worker.
*
* @returns {Promise<void>}
*/ */
function startPeriodicRefresh() { async function initPhishingList() {
if (refreshTimer) return; await ensureDeltaLoaded();
refreshTimer = setInterval(updatePhishingList, REFRESH_INTERVAL_MS); return updatePhishingList();
}
/**
* The 24-hour alarm tick. Separate from initPhishingList() because this is the
* scheduled refresh and must not be vetoed by the guards that exist to keep
* the unscheduled startup path off the network.
*
* @returns {Promise<void>}
*/
async function refreshPhishingListOnSchedule() {
return updatePhishingList({ force: true });
} }
/** /**
@@ -190,21 +316,22 @@ function getDeltaSize() {
function _reset() { function _reset() {
deltaBlacklist = new Set(); deltaBlacklist = new Set();
lastFetchTime = 0; lastFetchTime = 0;
lastAttemptTime = 0;
fetchPromise = null; fetchPromise = null;
if (refreshTimer) { loadPromise = null;
clearInterval(refreshTimer);
refreshTimer = null;
}
} }
// Load persisted delta on module initialization
loadDeltaFromStorage();
module.exports = { module.exports = {
isPhishingDomain, isPhishingDomain,
updatePhishingList, updatePhishingList,
startPeriodicRefresh, refreshPhishingListOnSchedule,
initPhishingList,
loadDeltaFromStorage,
loadConfig, loadConfig,
CACHE_TTL_MS,
MIN_FETCH_ATTEMPT_INTERVAL_MS,
DELTA_STORAGE_KEY,
MAX_DELTA_BYTES,
getBlocklistSize, getBlocklistSize,
getDeltaSize, getDeltaSize,
hostnameVariants, hostnameVariants,

View File

@@ -74,6 +74,15 @@ function isValidMnemonic(mnemonic) {
return Mnemonic.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 = { module.exports = {
generateMnemonic, generateMnemonic,
deriveAddressFromXpub, deriveAddressFromXpub,
@@ -83,4 +92,5 @@ module.exports = {
addressFromPrivateKey, addressFromPrivateKey,
getSignerForAddress, getSignerForAddress,
isValidMnemonic, isValidMnemonic,
walletHasRecoveryPhrase,
}; };

468
tests/alarms.test.js Normal file
View File

@@ -0,0 +1,468 @@
// Scheduling for the background context.
//
// The Chrome MV3 service worker is terminated after roughly 30 seconds idle,
// so anything scheduled with setInterval/setTimeout dies with it. These tests
// pin the recurring jobs to the alarms API and to the re-registration path a
// revived worker runs.
// A controllable clock plus a stubbed balance refresh, so a cadence test can
// measure the interval between refreshes that actually happened rather than
// asserting the interval someone intended.
let mockNow = 0;
const mockBalanceRefreshAt = [];
// jest.resetModules() clears the call record of every jest.fn, and loading the
// worker is exactly that call — so anything that must be counted across a load
// is counted here rather than read off a mock.
let mockSetIntervalCalls = 0;
// Extension storage reads do not take a constant amount of time, and that is
// what makes a guard timed to the alarm period bite: backgroundRefresh()
// stamps its freshness marker after awaiting loadState(), so any read that is
// quicker than the previous one puts the next tick inside a guard of exactly
// one period and the tick is skipped. A simulation with a constant latency
// would sit exactly on the boundary and hide the bug.
const MOCK_STORAGE_LATENCIES_MS = [7, 3, 11, 2, 9, 4, 13, 1, 6, 5];
const MOCK_MAX_STORAGE_LATENCY_MS = Math.max(...MOCK_STORAGE_LATENCIES_MS);
let mockStorageJitter = false;
let mockStorageOpCount = 0;
function mockStorageTick() {
if (!mockStorageJitter) return;
mockNow +=
MOCK_STORAGE_LATENCIES_MS[
mockStorageOpCount++ % MOCK_STORAGE_LATENCIES_MS.length
];
}
jest.mock("../src/shared/balances", () => ({
refreshBalances: jest.fn(async () => {
mockBalanceRefreshAt.push(Date.now());
}),
getProvider: jest.fn(() => ({})),
}));
function makeAlarmsStub() {
const alarms = new Map();
const listeners = [];
const stub = {
created: [],
alarms,
create: jest.fn((name, info) => {
stub.created.push({ name, info });
alarms.set(name, { name, ...info });
}),
get: jest.fn(async (name) => alarms.get(name)),
clear: jest.fn(async (name) => alarms.delete(name)),
onAlarm: {
addListener: jest.fn((fn) => listeners.push(fn)),
},
fire: (name) => {
for (const fn of listeners) fn({ name });
},
listenerCount: () => listeners.length,
};
return stub;
}
describe("alarms module", () => {
let alarmsStub;
let alarmsMod;
beforeEach(() => {
jest.resetModules();
alarmsStub = makeAlarmsStub();
global.chrome = { alarms: alarmsStub };
alarmsMod = require("../src/shared/alarms");
});
afterEach(() => {
delete global.chrome;
});
test("ensureRecurringAlarms schedules both recurring jobs", async () => {
const created = await alarmsMod.ensureRecurringAlarms();
expect(created).toEqual({ balance: true, phishing: true });
const names = alarmsStub.created.map((c) => c.name).sort();
expect(names).toEqual(
[
alarmsMod.BALANCE_REFRESH_ALARM,
alarmsMod.PHISHING_REFRESH_ALARM,
].sort(),
);
});
test("the balance refresh keeps its 60-second cadence", async () => {
await alarmsMod.ensureRecurringAlarms();
const balance = alarmsStub.alarms.get(alarmsMod.BALANCE_REFRESH_ALARM);
expect(balance.periodInMinutes).toBe(1);
});
test("the phishing refresh keeps its 24-hour cadence", async () => {
await alarmsMod.ensureRecurringAlarms();
const phishing = alarmsStub.alarms.get(
alarmsMod.PHISHING_REFRESH_ALARM,
);
expect(phishing.periodInMinutes).toBe(24 * 60);
});
test("no period is below the browser-enforced minimum", async () => {
// A period under one minute is silently clamped by the browser, so a
// request for one would mean the documented cadence is not the real
// one. Every period must be a whole minute at or above the minimum.
await alarmsMod.ensureRecurringAlarms();
for (const { info } of alarmsStub.created) {
expect(info.periodInMinutes).toBeGreaterThanOrEqual(
alarmsMod.MIN_ALARM_PERIOD_MINUTES,
);
expect(Number.isInteger(info.periodInMinutes)).toBe(true);
}
});
test("a revived worker does not reset an existing alarm's schedule", async () => {
await alarmsMod.ensureRecurringAlarms();
expect(alarmsStub.create).toHaveBeenCalledTimes(2);
// Every wake re-runs the startup path. Re-creating an alarm restarts
// its period, so a busy extension would push the next fire out
// forever and the job would never run.
const again = await alarmsMod.ensureRecurringAlarms();
expect(again).toEqual({ balance: false, phishing: false });
expect(alarmsStub.create).toHaveBeenCalledTimes(2);
});
test("a missing alarm is re-created on the next start", async () => {
await alarmsMod.ensureRecurringAlarms();
await alarmsStub.clear(alarmsMod.BALANCE_REFRESH_ALARM);
const again = await alarmsMod.ensureRecurringAlarms();
expect(again).toEqual({ balance: true, phishing: false });
expect(
alarmsStub.alarms.get(alarmsMod.BALANCE_REFRESH_ALARM),
).toBeDefined();
});
test("an alarm left over with a stale period is re-created", async () => {
// An install carries its alarms across an extension update, so a
// period changed in a new release only ever reaches users if the
// stale one is reconciled.
alarmsStub.create(alarmsMod.PHISHING_REFRESH_ALARM, {
periodInMinutes: 7 * 24 * 60,
});
alarmsStub.create.mockClear();
const created = await alarmsMod.ensureRecurringAlarms();
expect(created.phishing).toBe(true);
expect(
alarmsStub.alarms.get(alarmsMod.PHISHING_REFRESH_ALARM)
.periodInMinutes,
).toBe(alarmsMod.PHISHING_REFRESH_PERIOD_MINUTES);
});
test("reconciling a period settles instead of re-creating forever", async () => {
alarmsStub.create(alarmsMod.BALANCE_REFRESH_ALARM, {
periodInMinutes: 30,
});
await alarmsMod.ensureRecurringAlarms();
alarmsStub.create.mockClear();
const again = await alarmsMod.ensureRecurringAlarms();
expect(again).toEqual({ balance: false, phishing: false });
expect(alarmsStub.create).not.toHaveBeenCalled();
});
test("handlers are dispatched by alarm name from one listener", () => {
const balance = jest.fn();
const phishing = jest.fn();
expect(
alarmsMod.registerAlarmHandlers({
[alarmsMod.BALANCE_REFRESH_ALARM]: balance,
[alarmsMod.PHISHING_REFRESH_ALARM]: phishing,
}),
).toBe(true);
expect(alarmsStub.listenerCount()).toBe(1);
alarmsStub.fire(alarmsMod.BALANCE_REFRESH_ALARM);
expect(balance).toHaveBeenCalledTimes(1);
expect(phishing).not.toHaveBeenCalled();
alarmsStub.fire(alarmsMod.PHISHING_REFRESH_ALARM);
expect(phishing).toHaveBeenCalledTimes(1);
alarmsStub.fire("some-other-extension-alarm");
expect(balance).toHaveBeenCalledTimes(1);
expect(phishing).toHaveBeenCalledTimes(1);
});
test("Firefox MV2 gets the same treatment via browser.alarms", async () => {
// Both targets are built from one bundle. MV2 has a persistent
// background page, but it takes the alarm path too, so the schedule
// is the same code on both browsers.
jest.resetModules();
const firefoxAlarms = makeAlarmsStub();
global.browser = { alarms: firefoxAlarms };
try {
const mod = require("../src/shared/alarms");
const created = await mod.ensureRecurringAlarms();
expect(created).toEqual({ balance: true, phishing: true });
expect(firefoxAlarms.created).toHaveLength(2);
// The Chrome stub must not have been touched.
expect(alarmsStub.create).not.toHaveBeenCalled();
} finally {
delete global.browser;
}
});
test("a context without the alarms API degrades instead of throwing", async () => {
jest.resetModules();
delete global.chrome;
const mod = require("../src/shared/alarms");
await expect(mod.ensureRecurringAlarms()).resolves.toEqual({
balance: false,
phishing: false,
});
expect(mod.registerAlarmHandlers({})).toBe(false);
});
});
// Loads the background worker against stubbed browser APIs. The returned
// store is the extension storage the worker sees, so a test can seed wallet
// state and read back what the worker persisted.
function loadBackground(initialStore = {}) {
const storageStore = initialStore;
const alarmsStub = makeAlarmsStub();
const listeners = { onInstalled: [], onStartup: [] };
global.chrome = {
alarms: alarmsStub,
storage: {
local: {
get: async (key) => {
mockStorageTick();
return Object.prototype.hasOwnProperty.call(
storageStore,
key,
)
? { [key]: storageStore[key] }
: {};
},
set: async (items) => {
mockStorageTick();
Object.assign(storageStore, items);
},
remove: async (key) => {
delete storageStore[key];
},
},
},
runtime: {
onMessage: { addListener: jest.fn() },
onConnect: { addListener: jest.fn() },
onInstalled: {
addListener: jest.fn((fn) => listeners.onInstalled.push(fn)),
},
onStartup: {
addListener: jest.fn((fn) => listeners.onStartup.push(fn)),
},
getURL: (p) => "chrome-extension://test/" + p,
lastError: null,
},
windows: {
onRemoved: { addListener: jest.fn() },
create: jest.fn(),
},
tabs: { query: jest.fn(), sendMessage: jest.fn() },
action: { setPopup: jest.fn() },
};
global.fetch = jest.fn(async () => ({
ok: true,
json: async () => ({ blacklist: [] }),
}));
jest.resetModules();
require("../src/background/index");
return { alarmsStub, listeners, store: storageStore };
}
// Flush the promise chains the startup path and the alarm handlers run on.
async function settle() {
for (let i = 0; i < 3; i++) {
await new Promise((resolve) => setImmediate(resolve));
}
}
describe("background worker scheduling", () => {
let alarmsStub;
let timers;
beforeEach(() => {
mockSetIntervalCalls = 0;
timers = {
setInterval: jest
.spyOn(global, "setInterval")
.mockImplementation(() => {
mockSetIntervalCalls++;
return 0;
}),
};
});
afterEach(() => {
timers.setInterval.mockRestore();
delete global.chrome;
delete global.fetch;
jest.resetModules();
});
test("startup schedules the recurring jobs as alarms, not timers", async () => {
alarmsStub = loadBackground().alarmsStub;
// Let the startup path's promises settle.
await settle();
const names = alarmsStub.created.map((c) => c.name).sort();
const {
BALANCE_REFRESH_ALARM,
PHISHING_REFRESH_ALARM,
} = require("../src/shared/alarms");
expect(names).toEqual(
[BALANCE_REFRESH_ALARM, PHISHING_REFRESH_ALARM].sort(),
);
expect(mockSetIntervalCalls).toBe(0);
});
test("an onAlarm listener is installed on startup", async () => {
alarmsStub = loadBackground().alarmsStub;
await settle();
expect(alarmsStub.listenerCount()).toBe(1);
});
test("onInstalled and onStartup both re-establish the schedule", async () => {
const loaded = loadBackground();
alarmsStub = loaded.alarmsStub;
await settle();
expect(loaded.listeners.onInstalled).toHaveLength(1);
expect(loaded.listeners.onStartup).toHaveLength(1);
// A browser start after the alarms were dropped must put them back.
alarmsStub.alarms.clear();
alarmsStub.created.length = 0;
loaded.listeners.onStartup[0]();
await settle();
expect(alarmsStub.created).toHaveLength(2);
});
test("the install-time listener and the top-level call share one run", async () => {
// On a fresh install both fire, close enough that both could observe
// an alarm missing and create it — and a second create restarts the
// period the first one just set.
const loaded = loadBackground();
alarmsStub = loaded.alarmsStub;
loaded.listeners.onInstalled[0]();
await settle();
expect(alarmsStub.created).toHaveLength(2);
expect(alarmsStub.created.map((c) => c.name).sort()).toEqual(
[
"autistmask-balance-refresh",
"autistmask-phishing-refresh",
].sort(),
);
});
});
// The alarm period alone must set the cadence. A freshness guard timed to the
// period vetoes the very tick it gates, because the guard is measured from
// when the last run finished and the alarm fires one run-duration before that.
// These tests measure the interval between refreshes that actually ran.
describe("balance refresh steady-state cadence", () => {
const {
BALANCE_REFRESH_PERIOD_MINUTES,
BALANCE_REFRESH_ALARM,
} = require("../src/shared/alarms");
const PERIOD_MS = BALANCE_REFRESH_PERIOD_MINUTES * 60 * 1000;
let clockSpy;
let timerSpy;
function seededStore() {
return {
autistmask: {
hasWallet: true,
wallets: [
{ address: "0x0000000000000000000000000000000000000001" },
],
lastBalanceRefresh: 0,
},
};
}
beforeEach(() => {
mockNow = Date.UTC(2026, 0, 1, 0, 0, 0);
mockBalanceRefreshAt.length = 0;
mockSetIntervalCalls = 0;
mockStorageOpCount = 0;
mockStorageJitter = false;
clockSpy = jest.spyOn(Date, "now").mockImplementation(() => mockNow);
timerSpy = jest.spyOn(global, "setInterval").mockImplementation(() => {
mockSetIntervalCalls++;
return 0;
});
});
afterEach(() => {
mockStorageJitter = false;
clockSpy.mockRestore();
timerSpy.mockRestore();
delete global.chrome;
delete global.fetch;
jest.resetModules();
});
test("ten alarm ticks produce ten refreshes, one per period", async () => {
const { alarmsStub } = loadBackground(seededStore());
await settle();
mockStorageJitter = true;
const TICKS = 10;
let tickAt = mockNow + PERIOD_MS;
for (let i = 0; i < TICKS; i++) {
mockNow = tickAt;
tickAt += PERIOD_MS;
alarmsStub.fire(BALANCE_REFRESH_ALARM);
await settle();
}
// No tick was a no-op. This is the assertion that fails when the guard
// is timed to the alarm period.
expect(mockBalanceRefreshAt).toHaveLength(TICKS);
// And the observed cadence is one period, not two.
const intervals = mockBalanceRefreshAt
.slice(1)
.map((t, i) => t - mockBalanceRefreshAt[i]);
for (const interval of intervals) {
expect(interval).toBeGreaterThanOrEqual(
PERIOD_MS - MOCK_MAX_STORAGE_LATENCY_MS,
);
expect(interval).toBeLessThanOrEqual(
PERIOD_MS + MOCK_MAX_STORAGE_LATENCY_MS,
);
}
});
test("a refresh an open popup just did still suppresses the tick", async () => {
// The guard's actual job, and the reason it is shortened rather than
// removed: while the popup is open it refreshes every 10 seconds and
// stamps the same field, and the background job has nothing to add.
const store = seededStore();
const { alarmsStub } = loadBackground(store);
await settle();
mockNow += PERIOD_MS;
store.autistmask.lastBalanceRefresh = mockNow - 10 * 1000;
alarmsStub.fire(BALANCE_REFRESH_ALARM);
await settle();
expect(mockBalanceRefreshAt).toHaveLength(0);
});
});

View File

@@ -255,6 +255,11 @@ async function openPopup(ctx, popupUrl) {
// Full wallet creation through the real UI: BIP-39 generation, libsodium // Full wallet creation through the real UI: BIP-39 generation, libsodium
// vault encryption and extension storage persistence, for real. // 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) { async function createWallet(page) {
await page.click("#btn-welcome-add"); await page.click("#btn-welcome-add");
await visible(page, "#view-add-wallet"); await visible(page, "#view-add-wallet");
@@ -263,10 +268,12 @@ async function createWallet(page) {
const el = document.getElementById("wallet-mnemonic"); const el = document.getElementById("wallet-mnemonic");
return el && el.value.trim().split(/\s+/).length >= 12; 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", PASSWORD);
await page.fill("#add-wallet-password-confirm", PASSWORD); await page.fill("#add-wallet-password-confirm", PASSWORD);
await page.click("#btn-add-wallet-confirm"); await page.click("#btn-add-wallet-confirm");
await visible(page, "#view-main", 60000); await visible(page, "#view-main", 60000);
return phrase;
} }
// Reach the address detail screen from wherever the popup restored to. // Reach the address detail screen from wherever the popup restored to.
@@ -281,6 +288,7 @@ async function openAddressDetail(page) {
} }
module.exports = { module.exports = {
PASSWORD,
createWallet, createWallet,
launch, launch,
openAddressDetail, openAddressDetail,

View File

@@ -10,6 +10,7 @@
"use strict"; "use strict";
const { const {
PASSWORD,
createWallet, createWallet,
launch, launch,
openAddressDetail, openAddressDetail,
@@ -34,6 +35,10 @@ function assert(cond, message) {
if (!cond) throw new Error(message); if (!cond) throw new Error(message);
} }
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function withTimeout(promise, name) { function withTimeout(promise, name) {
let timer; let timer;
const timeout = new Promise((_, reject) => { 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) => { 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 const addrCount = await env.page
.locator("#wallet-list .btn-addr-info") .locator("#wallet-list .btn-addr-info")
.count(); .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"); 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 // ---------------------------------------------------------------- runner
async function main() { async function main() {
@@ -154,6 +413,9 @@ async function main() {
popupUrl: session.popupUrl, popupUrl: session.popupUrl,
routeOpts, routeOpts,
page: null, 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 // Attribution of collected errors is total. session.errors has no

View File

@@ -1,17 +1,24 @@
// Provide a localStorage mock for Node.js test environment. // Extension storage stub for the Node test environment. The module resolves
// Must be set before requiring the module since it calls loadDeltaFromStorage() // the storage API on use, so this only has to exist before the first call.
// at module load time. // Values round-trip through JSON the way structured cloning would, so a test
const localStorageStore = {}; // cannot pass by holding a live reference to the module's own array.
global.localStorage = { const storageStore = {};
getItem: (key) => global.chrome = {
Object.prototype.hasOwnProperty.call(localStorageStore, key) storage: {
? localStorageStore[key] local: {
: null, get: async (key) =>
setItem: (key, value) => { Object.prototype.hasOwnProperty.call(storageStore, key)
localStorageStore[key] = String(value); ? { [key]: JSON.parse(JSON.stringify(storageStore[key])) }
}, : {},
removeItem: (key) => { set: async (items) => {
delete localStorageStore[key]; for (const [key, value] of Object.entries(items)) {
storageStore[key] = JSON.parse(JSON.stringify(value));
}
},
remove: async (key) => {
delete storageStore[key];
},
},
}, },
}; };
@@ -21,19 +28,32 @@ const {
getBlocklistSize, getBlocklistSize,
getDeltaSize, getDeltaSize,
hostnameVariants, hostnameVariants,
DELTA_STORAGE_KEY,
_reset, _reset,
_getVendoredBlacklistSize, _getVendoredBlacklistSize,
_getDeltaBlacklist, _getDeltaBlacklist,
} = require("../src/shared/phishingDomains"); } = require("../src/shared/phishingDomains");
function clearStorage() {
for (const key of Object.keys(storageStore)) {
delete storageStore[key];
}
}
// The MV3 service worker is torn down when idle and re-evaluated on the next
// event, which wipes every module-level variable. Re-requiring the module with
// the registry reset is exactly that: fresh in-memory state, same extension
// storage underneath.
function restartWorker() {
jest.resetModules();
return require("../src/shared/phishingDomains");
}
// Reset delta state before each test to avoid cross-test contamination. // Reset delta state before each test to avoid cross-test contamination.
// Note: vendored sets are immutable and always present. // Note: vendored sets are immutable and always present.
beforeEach(() => { beforeEach(() => {
_reset(); _reset();
// Clear localStorage mock between tests clearStorage();
for (const key of Object.keys(localStorageStore)) {
delete localStorageStore[key];
}
}); });
describe("phishingDomains", () => { describe("phishingDomains", () => {
@@ -169,15 +189,34 @@ describe("phishingDomains", () => {
}); });
}); });
describe("localStorage persistence", () => { describe("extension storage persistence", () => {
test("saveDeltaToStorage persists delta under 256KiB", () => { test("delta is persisted to extension storage, not localStorage", async () => {
loadConfig({ await loadConfig({
blacklist: ["persisted-scam-xyz.com"], blacklist: ["persisted-scam-xyz.com"],
}); });
const stored = localStorage.getItem("phishing-delta"); const stored = storageStore[DELTA_STORAGE_KEY];
expect(stored).not.toBeNull(); expect(stored).toBeDefined();
const data = JSON.parse(stored); expect(stored.blacklist).toContain("persisted-scam-xyz.com");
expect(data.blacklist).toContain("persisted-scam-xyz.com"); });
test("the fetch timestamp is persisted alongside the delta", async () => {
const before = Date.now();
await loadConfig({ blacklist: ["timestamped-scam-xyz.com"] });
const stored = storageStore[DELTA_STORAGE_KEY];
expect(typeof stored.lastFetchTime).toBe("number");
expect(stored.lastFetchTime).toBeGreaterThanOrEqual(before);
});
test("an oversized delta is dropped entirely, timestamp included", async () => {
// A record above the 256 KiB cap is not worth keeping; the
// timestamp goes with it so the next start re-fetches rather than
// claiming freshness for a delta that was never stored.
const huge = [];
for (let i = 0; i < 20000; i++) {
huge.push(`oversize-scam-${i}-xyzxyzxyzxyzxyz.com`);
}
await loadConfig({ blacklist: huge });
expect(storageStore[DELTA_STORAGE_KEY]).toBeUndefined();
}); });
test("delta is cleared on _reset", () => { test("delta is cleared on _reset", () => {
@@ -203,3 +242,332 @@ describe("phishingDomains", () => {
}); });
}); });
}); });
describe("phishing list across a service worker restart", () => {
beforeEach(() => {
clearStorage();
jest.resetModules();
});
afterEach(() => {
delete global.fetch;
});
test("a revived worker restores the persisted delta without re-fetching", async () => {
const first = require("../src/shared/phishingDomains");
await first.loadConfig({ blacklist: ["restart-scam-xyz.com"] });
const revived = restartWorker();
// Nothing in memory yet — this is a brand new module instance.
expect(revived.getDeltaSize()).toBe(0);
global.fetch = jest.fn();
await revived.initPhishingList();
expect(global.fetch).not.toHaveBeenCalled();
expect(revived.getDeltaSize()).toBe(1);
expect(revived.isPhishingDomain("restart-scam-xyz.com")).toBe(true);
});
test("repeated wakes inside the cache window never re-fetch", async () => {
const first = require("../src/shared/phishingDomains");
await first.loadConfig({ blacklist: ["no-storm-scam-xyz.com"] });
global.fetch = jest.fn();
for (let i = 0; i < 5; i++) {
const revived = restartWorker();
await revived.initPhishingList();
}
expect(global.fetch).not.toHaveBeenCalled();
});
test("a persisted timestamp older than the TTL causes a fetch on startup", async () => {
const first = require("../src/shared/phishingDomains");
await first.loadConfig({ blacklist: ["stale-scam-xyz.com"] });
// Age the persisted record past the 24-hour TTL.
storageStore[first.DELTA_STORAGE_KEY].lastFetchTime =
Date.now() - first.CACHE_TTL_MS - 1000;
const revived = restartWorker();
global.fetch = jest.fn(async () => ({
ok: true,
json: async () => ({ blacklist: ["refreshed-scam-xyz.com"] }),
}));
await revived.initPhishingList();
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(revived.isPhishingDomain("refreshed-scam-xyz.com")).toBe(true);
expect(revived.isPhishingDomain("stale-scam-xyz.com")).toBe(false);
});
test("a first start with nothing persisted fetches immediately", async () => {
const fresh = restartWorker();
global.fetch = jest.fn(async () => ({
ok: true,
json: async () => ({ blacklist: ["first-run-scam-xyz.com"] }),
}));
await fresh.initPhishingList();
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(fresh.isPhishingDomain("first-run-scam-xyz.com")).toBe(true);
});
test("updatePhishingList honours the persisted timestamp on its own", async () => {
// The startup path calls updatePhishingList() directly, so it must
// load persisted state itself rather than relying on anything else
// having finished first.
const first = require("../src/shared/phishingDomains");
await first.loadConfig({ blacklist: ["alarm-tick-scam-xyz.com"] });
const revived = restartWorker();
global.fetch = jest.fn();
await revived.updatePhishingList();
expect(global.fetch).not.toHaveBeenCalled();
expect(revived.isPhishingDomain("alarm-tick-scam-xyz.com")).toBe(true);
});
});
// The alarm period alone must set the cadence. lastFetchTime is stamped when
// the fetch completes, so it lands one fetch latency after the alarm that
// caused it; a freshness guard timed to the alarm period therefore vetoes
// every scheduled tick and halves the real refresh rate. These tests measure
// the interval between fetches that actually happened.
describe("phishing refresh steady-state cadence", () => {
const { PHISHING_REFRESH_PERIOD_MINUTES } = require("../src/shared/alarms");
const PERIOD_MS = PHISHING_REFRESH_PERIOD_MINUTES * 60 * 1000;
let clockSpy;
let now;
beforeEach(() => {
clearStorage();
jest.resetModules();
now = Date.UTC(2026, 0, 1, 0, 0, 0);
clockSpy = jest.spyOn(Date, "now").mockImplementation(() => now);
});
afterEach(() => {
clockSpy.mockRestore();
delete global.fetch;
});
function fetchStub(latencyMs, seen) {
return jest.fn(async () => {
seen.push(now);
// A network fetch takes time, and lastFetchTime is stamped after
// it, not when the alarm fired.
now += latencyMs;
return { ok: true, json: async () => ({ blacklist: [] }) };
});
}
test("ten alarm ticks produce ten fetches, one per period", async () => {
const fetchedAt = [];
global.fetch = fetchStub(5000, fetchedAt);
const startup = require("../src/shared/phishingDomains");
const T0 = now;
await startup.initPhishingList();
expect(fetchedAt).toEqual([T0]);
const TICKS = 10;
let tickAt = T0 + PERIOD_MS;
for (let i = 0; i < TICKS; i++) {
now = tickAt;
tickAt += PERIOD_MS;
// The browser wakes a terminated worker to deliver the alarm, so
// every tick starts from cold memory and the persisted record.
const revived = restartWorker();
await revived.refreshPhishingListOnSchedule();
}
expect(fetchedAt).toHaveLength(TICKS + 1);
const intervals = fetchedAt.slice(1).map((t, i) => t - fetchedAt[i]);
expect(intervals).toEqual(new Array(TICKS).fill(PERIOD_MS));
});
test("the scheduled tick fetches whatever the last fetch's latency was", async () => {
// The alarm fires one period after the previous alarm, which is
// `latency` short of one period since the fetch it caused completed.
for (const latency of [200, 1000, 5000]) {
clearStorage();
jest.resetModules();
storageStore[DELTA_STORAGE_KEY] = {
blacklist: [],
lastFetchTime: now - PERIOD_MS + latency,
lastAttemptTime: now - PERIOD_MS,
};
const mod = require("../src/shared/phishingDomains");
const fetchedAt = [];
global.fetch = fetchStub(latency, fetchedAt);
await mod.refreshPhishingListOnSchedule();
expect(fetchedAt).toHaveLength(1);
}
});
test("a worker wake inside the cache window still does not fetch", async () => {
// The TTL is not removed, only taken off the scheduled path. Chrome
// revives the worker every ~30 seconds and every revival runs the
// startup path, so the TTL still has to keep that off the network.
storageStore[DELTA_STORAGE_KEY] = {
blacklist: [],
lastFetchTime: now - PERIOD_MS + 5000,
lastAttemptTime: now - PERIOD_MS,
};
const mod = require("../src/shared/phishingDomains");
global.fetch = jest.fn();
await mod.initPhishingList();
expect(global.fetch).not.toHaveBeenCalled();
});
});
describe("phishing list timestamps that cannot be trusted", () => {
let clockSpy;
let now;
beforeEach(() => {
clearStorage();
jest.resetModules();
now = Date.UTC(2026, 0, 1, 0, 0, 0);
clockSpy = jest.spyOn(Date, "now").mockImplementation(() => now);
});
afterEach(() => {
clockSpy.mockRestore();
delete global.fetch;
});
function okFetch() {
return jest.fn(async () => ({
ok: true,
json: async () => ({ blacklist: ["recovered-scam-xyz.com"] }),
}));
}
// jest.resetModules() clears the call record of a jest.fn, and simulating
// a worker restart is exactly that call. Anything counted across restarts
// has to be counted outside the mock.
function countingFetch(counter, response) {
return async () => {
counter.calls++;
return response();
};
}
test("a lastFetchTime in the future is discarded rather than trusted", async () => {
// Clock skew or a restored profile backup writes one. Every guard
// measures `Date.now() - stamp` and only tests the lower bound, so a
// stamp a year ahead would suppress updates for a year, and now that
// the value is persisted it would outlive every worker.
storageStore[DELTA_STORAGE_KEY] = {
blacklist: ["poisoned-scam-xyz.com"],
lastFetchTime: now + 365 * 24 * 60 * 60 * 1000,
lastAttemptTime: 0,
};
const mod = require("../src/shared/phishingDomains");
global.fetch = okFetch();
await mod.initPhishingList();
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(mod.isPhishingDomain("recovered-scam-xyz.com")).toBe(true);
// And the record it leaves behind is sane, so recovery is permanent.
expect(
storageStore[DELTA_STORAGE_KEY].lastFetchTime,
).toBeLessThanOrEqual(now);
});
test("a lastAttemptTime in the future does not suppress the retry", async () => {
storageStore[DELTA_STORAGE_KEY] = {
lastAttemptTime: now + 365 * 24 * 60 * 60 * 1000,
};
const mod = require("../src/shared/phishingDomains");
global.fetch = okFetch();
await mod.initPhishingList();
expect(global.fetch).toHaveBeenCalledTimes(1);
});
test("an oversized delta does not re-download on every worker wake", async () => {
// The delta and its freshness claim are both dropped, which is right,
// but nothing then says a fetch just happened. Chrome cycles the
// worker roughly every 30 seconds idle, so without the attempt stamp
// this is a full blocklist download per wake, forever.
const huge = [];
for (let i = 0; i < 20000; i++) {
huge.push(`oversize-scam-${i}-xyzxyzxyzxyzxyz.com`);
}
const counter = { calls: 0 };
global.fetch = countingFetch(counter, () => ({
ok: true,
json: async () => ({ blacklist: huge }),
}));
for (let wake = 0; wake < 4; wake++) {
const revived = restartWorker();
await revived.initPhishingList();
now += 30 * 1000; // idle timeout, worker torn down and revived
}
expect(counter.calls).toBe(1);
expect(storageStore[DELTA_STORAGE_KEY].blacklist).toBeUndefined();
expect(typeof storageStore[DELTA_STORAGE_KEY].lastAttemptTime).toBe(
"number",
);
});
test("a failing fetch is not retried on every worker wake either", async () => {
const counter = { calls: 0 };
global.fetch = countingFetch(counter, () => ({
ok: false,
status: 503,
}));
for (let wake = 0; wake < 4; wake++) {
const revived = restartWorker();
await revived.initPhishingList();
now += 30 * 1000;
}
expect(counter.calls).toBe(1);
});
test("the retry floor expires, so a failure is not permanent", async () => {
const {
MIN_FETCH_ATTEMPT_INTERVAL_MS,
} = require("../src/shared/phishingDomains");
const counter = { calls: 0 };
global.fetch = countingFetch(counter, () => ({
ok: false,
status: 503,
}));
await restartWorker().initPhishingList();
expect(counter.calls).toBe(1);
// Still inside the floor: no retry.
now += MIN_FETCH_ATTEMPT_INTERVAL_MS - 1000;
await restartWorker().initPhishingList();
expect(counter.calls).toBe(1);
// Past it: the extension goes back to the network.
now += 2000;
await restartWorker().initPhishingList();
expect(counter.calls).toBe(2);
});
test("the scheduled tick ignores the retry floor", async () => {
// The alarm period is far above the floor, but the floor exists to
// throttle wakes, not the schedule.
storageStore[DELTA_STORAGE_KEY] = { lastAttemptTime: now - 1000 };
const mod = require("../src/shared/phishingDomains");
global.fetch = okFetch();
await mod.refreshPhishingListOnSchedule();
expect(global.fetch).toHaveBeenCalledTimes(1);
});
});

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/);
});
});