Compare commits

...

3 Commits

Author SHA1 Message Date
clawbot
2c1f724545 feat: password-gated recovery phrase display for HD wallets (closes #161)
All checks were successful
check / check (push) Successful in 22s
A user who created a wallet in AutistMask and did not write the phrase
down had no way to retrieve it. Adds a "Show recovery phrase" action on
the wallet row in Settings, next to the per-wallet actions that already
live there, mirroring the per-address private key export in structure,
password gate and warning treatment.

The screen displays the secret that owns every address in the wallet, so:

  - Only HD wallets are offered it. walletHasRecoveryPhrase() is an
    allowlist on type "hd", so the key and xprv types — which have no
    phrase at all — are excluded, as is any type added later.
  - Nothing is decrypted and nothing enters the page until
    decryptWithPassword accepts the password. A wrong password produces a
    full-sentence error and leaves the value node empty.
  - Leaving the screen wipes it by any route, not just "Back": views that
    hold a secret register a cleanup with showView() via onViewLeave(),
    which also covers the settings gear.
  - A decrypt still in flight when the screen is left is discarded rather
    than written. reveal() captures a generation counter that every
    clear() bumps and refuses to touch the DOM if it has moved: without
    that check the write lands after the wipe, and nothing is scheduled to
    wipe again, so the phrase stays in the hidden screen for the life of
    the popup. crypto_pwhash is synchronous, so the reachable window is a
    still-pending sodium.ready on the first vault use of a page load.
  - The phrase is never assigned to state, so it cannot be persisted, and
    the view is not in RESTORABLE_VIEWS — reopening the popup lands on
    Home. That set moves to src/popup/restorableViews.js so the exclusion
    can be asserted directly; the popup entry point cannot be required
    outside a browser.
  - The phrase cannot reach the logger: the view does not import
    src/shared/log.js, and the failed-decrypt path reports a fixed
    sentence rather than the caught error.

show() also pushes the navigation stack itself, because it can return
without navigating when the wallet has no phrase; pushing in the Settings
click handler left an entry no screen transition matched.

Tests: unit coverage for the type gate, the RESTORABLE_VIEWS exclusion
and the absence of any logger path; the DOM behaviour is driven against
the real popup in the e2e suite, which is where this repo tests views —
including a probe that leaves the screen while the decrypt is in flight
by dispatching both clicks in one page task, since a human cannot
interleave them once libsodium's wasm is warm.
2026-08-11 13:22:29 +00:00
12acf4dc8c fix: honour a dust threshold of 0 and compare addresses case-insensitively (closes #179)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 15:16:48 +02:00
f455b0ae7f test: known-answer coverage for HD derivation and the vault (closes #159)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 15:06:35 +02:00
16 changed files with 1497 additions and 66 deletions

View File

@@ -123,8 +123,12 @@ unavailable). The suite lives in `tests/e2e/` and is driven by
`playwright-core`, whose version must stay matched to the container's Playwright
version — the browsers ship inside the image.
It covers popup load, wallet creation through the UI, the Add Token screen and
the transaction detail screen for an ERC-20 transfer. All outbound network is
It covers popup load, wallet creation through the UI, the Add Token screen, the
transaction detail screen for an ERC-20 transfer, and the recovery phrase screen
— which wallet types are offered it, that it holds nothing before the password
is accepted, that a wrong password reveals nothing, that leaving it by either
route wipes it — including a leave taken while the decrypt is still running —
and that reopening the popup does not land on it. All outbound network is
intercepted at the browser level and served from fixtures in
`tests/e2e/network.js`, so the run is deterministic and fully offline;
unrecognised outbound requests are reported as failures rather than silently
@@ -406,8 +410,11 @@ runtime debug mode is on, or when the active network is a testnet. They are not
repeated in the element lists below.
Closing and reopening the popup returns to the screen the user was last on only
for the views listed in `RESTORABLE_VIEWS` (`src/popup/index.js`). Every other
screen, including ExportPrivKey, falls back to Home.
for the views listed in `RESTORABLE_VIEWS` (`src/popup/restorableViews.js`).
Every other screen falls back to Home. The screens that display a secret —
ExportPrivKey and ShowRecoveryPhrase — are deliberately absent from that list,
so the popup can never reopen onto one of them with no password prompt in front
of it.
#### Welcome (`welcome`)
@@ -707,8 +714,9 @@ screen, including ExportPrivKey, falls back to Home.
- **When**: User tapped the Settings gear.
- **Elements**:
- "Back" button, "Settings" heading
- Wallets: one row per wallet with its name (tap to rename inline) and an
`[x]` delete button, plus a "+ Add wallet" button
- Wallets: one row per wallet with its name (tap to rename inline), a
`[recovery phrase]` button on HD wallets only, and an `[x]` delete button,
plus a "+ Add wallet" button
- Tracked Tokens: one row per tracked token with an `[x]` remove button,
plus a "+ Add token" button
- Display: "Show tracked tokens with zero balance" checkbox and a Theme
@@ -733,6 +741,7 @@ screen, including ExportPrivKey, falls back to Home.
- **Transitions**:
- "+ Add wallet" → **AddWallet**
- "+ Add token" → **SettingsAddToken**
- `[recovery phrase]` on an HD wallet → **ShowRecoveryPhrase**
- `[x]` on a wallet → **DeleteWallet**
- Tap wallet name → inline rename field (no screen change)
- `[x]` on a tracked token or a site → removes it in place (no screen
@@ -740,6 +749,33 @@ screen, including ExportPrivKey, falls back to Home.
- Ten clicks on the version → reveals the Debug well (no screen change)
- "Back" (or Settings gear again) → previous screen (Home)
#### ShowRecoveryPhrase (`show-phrase`)
- **When**: User tapped `[recovery phrase]` on a wallet row in Settings. HD
wallets only: key and xprv wallets have no recovery phrase, so their rows do
not offer the action at all.
- **Elements**:
- "Back" button, "Recovery Phrase" heading
- Wallet name
- Warning box stating that anyone holding these words can take everything in
the wallet, from any device, without the password
- Error line
- Password input + "Reveal" button, shown until the password is accepted
- The recovery phrase itself, in full and click-to-copy, shown only after a
correct password and in place of the password prompt
- **Transitions**:
- "Reveal" (correct password) → the phrase replaces the password prompt (no
screen change)
- "Reveal" (wrong password) → full-sentence error, nothing revealed (no
screen change)
- "Back" → previous screen (Settings)
- **Secret handling**: nothing is decrypted or written into the page until the
password is accepted; the phrase is never stored in state, and it is wiped
from the page whenever the screen is left by any route, including the Settings
gear. A decrypt still running when the screen is left is discarded rather than
written. The screen is not restorable, so reopening the popup lands on Home
rather than back on the phrase.
#### DeleteWallet (`delete-wallet-confirm`)
- **When**: User tapped the `[x]` next to a wallet in Settings.
@@ -1108,7 +1144,8 @@ indexes it as a real token transfer.
it. AutistMask hides transactions below a configurable dust threshold
(default: 100,000 gwei / 0.0001 ETH). This is high enough to filter poisoning
dust while low enough to preserve any transfer a user would plausibly care
about. The threshold is user-configurable in Settings.
about. The threshold is user-configurable in Settings; a threshold of `0`
hides nothing, exactly as clearing the checkbox does.
- **User-configurable**: All of the above filters (known symbol verification,
low-holder threshold, fraud contract blocklist, dust threshold) are settings
@@ -1181,7 +1218,7 @@ Currently supported:
- [x] Delete wallet (with confirmation)
- [ ] Delete address from HD wallet (with confirmation)
- [ ] Show wallet's recovery phrase (requires password)
- [x] Show wallet's recovery phrase (requires password)
### Transactions
@@ -1189,8 +1226,8 @@ Currently supported:
### Testing
- [ ] Tests for mnemonic generation and address derivation
- [ ] Tests for xpub derivation and child address generation
- [x] Tests for mnemonic generation and address derivation
- [x] Tests for xpub derivation and child address generation
- [ ] Test on Firefox (Manifest V2)
### Scam List

12
TODO.md
View File

@@ -44,6 +44,15 @@ undefined identifiers, which is how
# Completed Steps
- 2026-08-11: A dust threshold of `0` now means "hide nothing" instead of
falling back to the 100,000 gwei default, and every address comparison in
`src/shared/transactions.js` goes through one case-normalising helper so a
checksummed genuine contract is no longer read as a spoof
([#179](https://git.eeqj.de/sneak/AutistMask/issues/179)).
- 2026-08-11: Password-gated recovery phrase display for HD wallets, reached
from the wallet row in Settings, wiped on leaving the screen and excluded from
the views the popup can reopen onto
([#161](https://git.eeqj.de/sneak/AutistMask/issues/161)).
- 2026-08-11: Policy compliance sweep — conditional verbose test rerun, local
Tailwind binary instead of `npx`, `--frozen-lockfile` on `make install`, and
the Makefile-only targets documented in the README
@@ -54,6 +63,9 @@ undefined identifiers, which is how
lives only in `build.js`, and the unlisted-bundle scan hard-fails when it
cannot enumerate `dist/`
([#180](https://git.eeqj.de/sneak/AutistMask/issues/180)).
- 2026-08-11: Known-answer test coverage for the crypto core — BIP-39/BIP-32
derivation in `wallet.js` and the Argon2id vault in `vault.js`
([#159](https://git.eeqj.de/sneak/AutistMask/issues/159)).
- 2026-08-11: Three `README.md` claims corrected against the code — blocklist
attribution, token-display rule, navigation model
([#213](https://git.eeqj.de/sneak/AutistMask/issues/213)).

View File

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

View File

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

View File

@@ -0,0 +1,29 @@
// Views the popup may reopen onto.
//
// The popup persists the current view so that reopening the toolbar popup
// lands the user back where they were. Only views that can be fully
// re-rendered from persisted state belong here; every other view falls back
// to the nearest restorable parent (src/popup/index.js restoreView()).
//
// A view that displays a secret must NEVER be listed. Restoring onto one
// would put a private key or a recovery phrase on screen with no password
// prompt in front of it, on a popup the user may have reopened by accident.
// That is why "export-privkey" and "show-phrase" are absent.
//
// Kept in its own module, with no dependencies, so tests can assert the
// exclusion directly rather than trusting a reading of the popup entry
// point, which cannot be required outside a browser.
const RESTORABLE_VIEWS = new Set([
"main",
"address",
"address-token",
"receive",
"settings",
"settings-addtoken",
"confirm-tx",
"transaction",
"success-tx",
"error-tx",
]);
module.exports = { RESTORABLE_VIEWS };

View File

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

View File

@@ -14,6 +14,8 @@ const { NETWORKS, SUPPORTED_CHAIN_IDS } = require("../../shared/networks");
const { onChainSwitch } = require("../../shared/chainSwitch");
const { log, debugFetch, setRuntimeDebug } = require("../../shared/log");
const deleteWallet = require("./deleteWallet");
const showPhrase = require("./showPhrase");
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
const {
BUILD_VERSION,
BUILD_LICENSE,
@@ -99,7 +101,14 @@ function renderWalletListSettings() {
const name = escapeHtml(wallet.name || "Wallet " + (idx + 1));
html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`;
html += `<span class="settings-wallet-name cursor-pointer underline decoration-dashed" data-idx="${idx}">${name}</span>`;
html += `<span class="flex items-center gap-1 flex-shrink-0">`;
// Key and xprv wallets have no recovery phrase, so they are never
// offered the action at all.
if (walletHasRecoveryPhrase(wallet)) {
html += `<button class="btn-show-phrase border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-idx="${idx}" title="Show recovery phrase">[recovery phrase]</button>`;
}
html += `<button class="btn-delete-wallet border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-idx="${idx}">[x]</button>`;
html += `</span>`;
html += `</div>`;
});
container.innerHTML = html;
@@ -111,6 +120,15 @@ function renderWalletListSettings() {
});
});
container.querySelectorAll(".btn-show-phrase").forEach((btn) => {
btn.addEventListener("click", () => {
const idx = parseInt(btn.dataset.idx, 10);
// No pushCurrentView() here: showPhrase.show() refuses
// non-HD wallets and pushes only when it navigates.
showPhrase.show(idx);
});
});
// Inline rename on click
container.querySelectorAll(".settings-wallet-name").forEach((span) => {
span.addEventListener("click", () => {
@@ -191,6 +209,7 @@ function renderSiteLists() {
function init(ctx) {
deleteWallet.init(ctx);
showPhrase.init();
$("btn-save-rpc").addEventListener("click", async () => {
const url = $("settings-rpc").value.trim();
@@ -304,11 +323,17 @@ function init(ctx) {
$("settings-dust-threshold").value = state.dustThresholdGwei;
$("settings-dust-threshold").addEventListener("change", async () => {
const val = parseInt($("settings-dust-threshold").value, 10);
if (!isNaN(val) && val >= 0) {
const raw = $("settings-dust-threshold").value.trim();
const val = Number(raw);
// 0 is accepted and means "hide nothing". Empty, negative,
// fractional and non-numeric input is rejected outright rather than
// coerced, and the field is put back to the stored threshold so it
// never shows a value the wallet is not using.
if (raw !== "" && Number.isInteger(val) && val >= 0) {
state.dustThresholdGwei = val;
await saveState();
}
$("settings-dust-threshold").value = state.dustThresholdGwei;
});
$("settings-utc-timestamps").checked = state.utcTimestamps;

View File

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

View File

@@ -10,6 +10,14 @@ const { formatEther, formatUnits } = require("ethers");
const { log, debugFetch } = require("./log");
const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList");
// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
// over the address, not part of its identity. Every address comparison in
// this file goes through this helper, so an address arriving in checksummed
// or upper-case form can never be read as a different address.
function normalizeAddress(addr) {
return (addr || "").toLowerCase();
}
function formatTxValue(val) {
const parts = val.split(".");
if (parts.length === 1) return val + ".0000";
@@ -30,10 +38,10 @@ function parseTx(tx, addrLower) {
let exactValue = formatEther(rawWei);
let rawAmount = rawWei;
let rawUnit = "wei";
let direction = from.toLowerCase() === addrLower ? "sent" : "received";
let direction = normalizeAddress(from) === addrLower ? "sent" : "received";
let directionLabel = direction === "sent" ? "Sent" : "Received";
if (toIsContract && method && method !== "transfer") {
const token = TOKEN_BY_ADDRESS.get(to.toLowerCase());
const token = TOKEN_BY_ADDRESS.get(normalizeAddress(to));
if (token) {
symbol = token.symbol;
}
@@ -87,7 +95,8 @@ function parseTokenTransfer(tt, addrLower) {
const to = tt.to?.hash || "";
const decimals = parseInt(tt.total?.decimals || "18", 10);
const rawVal = tt.total?.value || "0";
const direction = from.toLowerCase() === addrLower ? "sent" : "received";
const direction =
normalizeAddress(from) === addrLower ? "sent" : "received";
const sym = tt.token?.symbol || "?";
return {
hash: tt.transaction_hash,
@@ -104,11 +113,9 @@ function parseTokenTransfer(tt, addrLower) {
direction: direction,
directionLabel: direction === "sent" ? "Sent" : "Received",
isError: false,
contractAddress: (
tt.token?.address_hash ||
tt.token?.address ||
""
).toLowerCase(),
contractAddress: normalizeAddress(
tt.token?.address_hash || tt.token?.address || "",
),
holders: parseInt(tt.token?.holders_count || "0", 10),
};
}
@@ -194,7 +201,7 @@ function mergeTransactions(txs, tokenTransfers) {
async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
log.debugf("fetchRecentTransactions", address);
const addrLower = address.toLowerCase();
const addrLower = normalizeAddress(address);
const [txResp, ttResp] = await Promise.all([
debugFetch(blockscoutUrl + "/addresses/" + address + "/transactions"),
@@ -243,34 +250,38 @@ function isSpoofedSymbol(tx) {
if (!KNOWN_SYMBOLS.has(symbol)) return false;
const legit = KNOWN_SYMBOLS.get(symbol);
if (legit === null) return true; // "ETH" as ERC-20 is always fake
return tx.contractAddress !== legit;
return normalizeAddress(tx.contractAddress) !== normalizeAddress(legit);
}
// Pure filter function. Takes raw transactions and filter settings,
// returns { transactions, newFraudContracts }.
function filterTransactions(txs, filters = {}) {
const fraudSet = new Set(
(filters.fraudContracts || []).map((a) => a.toLowerCase()),
(filters.fraudContracts || []).map(normalizeAddress),
);
// The dust threshold defaults only when it is unset (nullish): a
// threshold of 0 is a real value meaning "hide nothing", since no
// transaction has a value below 0 gwei. It is therefore equivalent to
// clearing the hide-dust checkbox, and the two controls cannot override
// each other in either direction.
const dustThresholdGwei = filters.dustThresholdGwei ?? 100000;
const newFraud = [];
const filtered = [];
for (const tx of txs) {
const contract = normalizeAddress(tx.contractAddress);
// Always filter spoofed known symbols and record the fraud contract
if (isSpoofedSymbol(tx)) {
if (tx.contractAddress && !fraudSet.has(tx.contractAddress)) {
fraudSet.add(tx.contractAddress);
newFraud.push(tx.contractAddress);
if (contract && !fraudSet.has(contract)) {
fraudSet.add(contract);
newFraud.push(contract);
}
continue;
}
// Filter fraud contracts if setting is on
if (
filters.hideFraudContracts &&
tx.contractAddress &&
fraudSet.has(tx.contractAddress)
) {
if (filters.hideFraudContracts && contract && fraudSet.has(contract)) {
continue;
}
@@ -291,7 +302,7 @@ function filterTransactions(txs, filters = {}) {
filters.hideDustTransactions &&
!tx.isContractCall &&
tx.valueGwei !== null &&
tx.valueGwei < (filters.dustThresholdGwei || 100000)
tx.valueGwei < dustThresholdGwei
) {
continue;
}

View File

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

View File

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

View File

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

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

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

View File

@@ -329,18 +329,42 @@ describe("known-symbol spoof verification", () => {
expect(result.newFraudContracts).toEqual([]);
});
// Documents current behaviour, not desired behaviour: the spoof check
// compares tx.contractAddress against a lowercased known address with
// ===, so a caller passing a checksummed address for a genuine token has
// it treated as a spoof. In the app this cannot happen because
// parseTokenTransfer lowercases, but the exported function is not
// defensive about it the way the blocklist check is.
test("current behaviour: a checksummed genuine contract is treated as a spoof", () => {
const genuineButChecksummed = tokenTx({
// Regression guard (#179): EIP-55 mixed case is a checksum over the
// address, not part of its identity, so the contract comparison must be
// case-insensitive in both directions — a genuine token in any casing is
// genuine, and a spoof cannot escape detection by changing its casing.
test("a genuine contract in all-lowercase form is not a spoof", () => {
const tx = tokenTx({ contractAddress: USDC_CONTRACT });
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
});
test("a genuine contract in EIP-55 checksummed form is not a spoof", () => {
const tx = tokenTx({
contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
});
const result = filterTransactions([genuineButChecksummed], filters());
const result = filterTransactions([tx], filters());
expect(result.transactions).toEqual([tx]);
expect(result.newFraudContracts).toEqual([]);
});
test("a genuine contract in all-uppercase form is not a spoof", () => {
const tx = tokenTx({
contractAddress: "0X" + USDC_CONTRACT.slice(2).toUpperCase(),
});
const result = filterTransactions([tx], filters());
expect(result.transactions).toEqual([tx]);
expect(result.newFraudContracts).toEqual([]);
});
test("a genuinely different contract claiming USDC is still a spoof in any casing", () => {
const tx = tokenTx({
contractAddress: "0xD05339F9EA5AB9D9F03B9D57F671D2ABD1F55C82",
});
const result = filterTransactions([tx], filters());
expect(result.transactions).toEqual([]);
// The recorded fraud contract is normalised, so the persisted
// blocklist matches later transfers whatever casing they arrive in.
expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]);
});
// Documents current behaviour: README.md:810-814 says all four filters
@@ -412,6 +436,21 @@ describe("low-holder token filtering (the 1,000-holder rule)", () => {
expect(tx.holders).toBeNull();
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
});
// Regression guard (#179): an unknown holder count on a real token — the
// explorer rate-limited the call, or a self-hosted instance omits the
// field — must not be read as zero holders. Reading it that way hides a
// legitimate transfer from the user's history, the same over-filtering
// harm as the zero-threshold bug. This pins the `tx.holders !== null`
// guard, which no fixture previously reached.
test("a token whose holder count is unknown is not filtered", () => {
const tx = tokenTx({
symbol: NOVEL_SPAM_SYMBOL,
contractAddress: NOVEL_SPAM_CONTRACT,
holders: null,
});
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
});
});
describe("fraud contract blocklist", () => {
@@ -575,16 +614,50 @@ describe("dust threshold filtering", () => {
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
});
// Documents current behaviour: the threshold is read as
// `filters.dustThresholdGwei || 100000`, so a user who sets the threshold
// to 0 (the natural way to ask for no dust filtering while leaving the
// toggle on) silently gets the 100,000 gwei default instead.
test("current behaviour: a threshold of 0 falls back to the 100,000 gwei default", () => {
const result = filterTransactions(
[dustOf(50)],
// Regression guard (#179): 0 is a real threshold meaning "hide nothing",
// not an absent one. It used to be swallowed by `|| 100000`, so the one
// value a user would pick to see everything was the one that did not
// work.
test("a threshold of 0 hides nothing, leaving the toggle on", () => {
const dust = dustOf(50);
const zero = dustOf(0);
const opts = filters({ dustThresholdGwei: 0 });
expect(filterTransactions([dust], opts).transactions).toEqual([dust]);
expect(filterTransactions([zero], opts).transactions).toEqual([zero]);
});
test("a threshold of 0 agrees with clearing the hide-dust checkbox", () => {
const tx = nativeDustTransfer();
const thresholdZero = filterTransactions(
[tx],
filters({ dustThresholdGwei: 0 }),
);
expect(result.transactions).toEqual([]);
const toggleOff = filterTransactions(
[tx],
filters({ hideDustTransactions: false }),
);
expect(thresholdZero.transactions).toEqual([tx]);
expect(toggleOff.transactions).toEqual([tx]);
});
test("0, unset and a set threshold are three distinct behaviours", () => {
const tx = dustOf(50);
expect(
filterTransactions([tx], filters({ dustThresholdGwei: 0 }))
.transactions,
).toEqual([tx]);
expect(
filterTransactions([tx], filters({ dustThresholdGwei: undefined }))
.transactions,
).toEqual([]);
expect(
filterTransactions([tx], filters({ dustThresholdGwei: 40 }))
.transactions,
).toEqual([tx]);
expect(
filterTransactions([tx], filters({ dustThresholdGwei: 60 }))
.transactions,
).toEqual([]);
});
});

346
tests/vault.test.js Normal file
View File

@@ -0,0 +1,346 @@
// Tests for src/shared/vault.js: the Argon2id + XSalsa20-Poly1305 encryption
// that protects recovery phrases and private keys at rest.
//
// The properties that matter here are the ones whose failure is silent. A
// vault that decrypts under the wrong password, that hands back plaintext from
// a ciphertext an attacker edited, that reuses a nonce, or that leaves the
// recovery phrase readable somewhere in the stored blob all look exactly like
// a working vault from the UI. So each test below asserts a negative: the
// thing that must not happen.
//
// Cost: every encrypt and decrypt runs one Argon2id pwhash at the production
// interactive parameters, which the module hardcodes. The parameters are not
// weakened or overridden anywhere in this file — they are pinned by the "key
// derivation cost" tests, since they are the vault's only defence against an
// offline attack on a stolen blob. The suite is kept inside script/test's
// 30-second budget by sharing one encrypted fixture across the tamper cases
// instead of re-encrypting per test.
const sodium = require("libsodium-wrappers-sumo");
const {
encryptWithPassword,
decryptWithPassword,
} = require("../src/shared/vault");
// A publicly known development phrase. Never fund it.
const SECRET = "test test test test test test test test test test test junk";
const PASSWORD = "correct horse battery staple";
const WRONG_PASSWORD = "correct horse battery stapl";
const SALT_BYTES = 16;
const NONCE_BYTES = 24;
const POLY1305_TAG_BYTES = 16;
const BASE64 = /^[A-Za-z0-9+/_-]+={0,2}$/;
function b64decode(s) {
return sodium.from_base64(s);
}
// A shallow copy with one field replaced, so the shared fixture is never
// mutated by a tamper test.
function withField(blob, field, value) {
return { ...blob, [field]: value };
}
// Flip the low bit of one byte of a base64-encoded field.
function flipByte(b64, index) {
const bytes = b64decode(b64);
bytes[index] ^= 0x01;
return sodium.to_base64(bytes);
}
let vault;
beforeAll(async () => {
await sodium.ready;
vault = await encryptWithPassword(SECRET, PASSWORD);
});
describe("stored blob shape", () => {
test("is exactly the documented { salt, nonce, ciphertext }", () => {
expect(Object.keys(vault).sort()).toEqual([
"ciphertext",
"nonce",
"salt",
]);
});
test("every field is a base64 string", () => {
for (const field of ["salt", "nonce", "ciphertext"]) {
expect(typeof vault[field]).toBe("string");
expect(vault[field]).toMatch(BASE64);
}
});
test("salt and nonce are full length", () => {
expect(b64decode(vault.salt)).toHaveLength(SALT_BYTES);
expect(b64decode(vault.nonce)).toHaveLength(NONCE_BYTES);
});
test("ciphertext carries a Poly1305 authentication tag", () => {
expect(b64decode(vault.ciphertext)).toHaveLength(
SECRET.length + POLY1305_TAG_BYTES,
);
});
test("the blob survives JSON storage unchanged", async () => {
const stored = JSON.parse(JSON.stringify(vault));
await expect(decryptWithPassword(stored, PASSWORD)).resolves.toBe(
SECRET,
);
});
});
describe("no plaintext leakage", () => {
test("the secret does not appear in the serialized vault", () => {
const serialized = JSON.stringify(vault);
expect(serialized).not.toContain(SECRET);
for (const word of new Set(SECRET.split(" "))) {
expect(serialized).not.toContain(word);
}
});
test("the ciphertext bytes do not contain the secret bytes", () => {
const bytes = Buffer.from(b64decode(vault.ciphertext));
expect(bytes.includes(Buffer.from(SECRET, "utf8"))).toBe(false);
// Not even the first word, which would betray an unencrypted prefix.
expect(bytes.includes(Buffer.from("test test", "utf8"))).toBe(false);
});
test("the password does not appear in the serialized vault", () => {
expect(JSON.stringify(vault)).not.toContain(PASSWORD);
});
});
describe("round trip", () => {
test("decrypts back to the original secret", async () => {
await expect(decryptWithPassword(vault, PASSWORD)).resolves.toBe(
SECRET,
);
});
test("survives a non-ASCII plaintext byte for byte", async () => {
const unicode = "recovery phrase é中文\u{1f600}";
const blob = await encryptWithPassword(unicode, PASSWORD);
await expect(decryptWithPassword(blob, PASSWORD)).resolves.toBe(
unicode,
);
});
test("an empty password still round-trips and is not a bypass", async () => {
const blob = await encryptWithPassword(SECRET, "");
await expect(decryptWithPassword(blob, "")).resolves.toBe(SECRET);
// An empty password must not act as a skeleton key on other vaults,
// nor may a real password open an empty-password vault.
await expect(decryptWithPassword(vault, "")).rejects.toThrow();
await expect(decryptWithPassword(blob, PASSWORD)).rejects.toThrow();
});
});
describe("fresh salt and nonce", () => {
test("two encryptions of the same plaintext differ in all three fields", async () => {
const second = await encryptWithPassword(SECRET, PASSWORD);
expect(second.salt).not.toBe(vault.salt);
expect(second.nonce).not.toBe(vault.nonce);
expect(second.ciphertext).not.toBe(vault.ciphertext);
await expect(decryptWithPassword(second, PASSWORD)).resolves.toBe(
SECRET,
);
});
});
describe("key derivation cost", () => {
// Argon2id's opslimit and memlimit are the whole of the vault's resistance
// to an offline attack on a stolen blob, and lowering them breaks nothing
// any other test here can see — the suite merely runs faster. So pin them
// directly, both to libsodium's INTERACTIVE constants and to the absolute
// values those constants must keep meaning.
const INTERACTIVE_OPSLIMIT = 2;
const INTERACTIVE_MEMLIMIT = 64 * 1024 * 1024;
test("the interactive constants still mean 2 passes over 64 MiB", () => {
expect(sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE).toBe(
INTERACTIVE_OPSLIMIT,
);
expect(sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE).toBe(
INTERACTIVE_MEMLIMIT,
);
// The floor these must never quietly be swapped for: _MIN is one pass
// over 8 KiB, an 8192x reduction in memory cost.
expect(sodium.crypto_pwhash_OPSLIMIT_MIN).toBeLessThan(
INTERACTIVE_OPSLIMIT,
);
expect(sodium.crypto_pwhash_MEMLIMIT_MIN).toBeLessThan(
INTERACTIVE_MEMLIMIT,
);
});
test("a key derived at the interactive parameters opens the vault", () => {
// Independent of any spy, and of the module's own code path: derive
// the key here from the vault's published salt at the interactive cost
// and open its ciphertext directly. A vault whose key came from any
// other opslimit, memlimit or Argon2id variant yields a different key
// and cannot be opened this way.
const key = sodium.crypto_pwhash(
sodium.crypto_secretbox_KEYBYTES,
PASSWORD,
b64decode(vault.salt),
INTERACTIVE_OPSLIMIT,
INTERACTIVE_MEMLIMIT,
sodium.crypto_pwhash_ALG_ARGON2ID13,
);
const opened = sodium.crypto_secretbox_open_easy(
b64decode(vault.ciphertext),
b64decode(vault.nonce),
key,
);
expect(sodium.to_string(opened)).toBe(SECRET);
});
test.each([
[
"encrypt",
async () => {
await encryptWithPassword(SECRET, PASSWORD);
},
],
[
"decrypt",
async () => {
await decryptWithPassword(vault, PASSWORD);
},
],
])("%s derives exactly one key at the interactive cost", async (_, run) => {
const spy = jest.spyOn(sodium, "crypto_pwhash");
try {
await run();
expect(spy).toHaveBeenCalledTimes(1);
const [keyBytes, , salt, opslimit, memlimit, alg] =
spy.mock.calls[0];
expect(keyBytes).toBe(sodium.crypto_secretbox_KEYBYTES);
expect(salt).toHaveLength(SALT_BYTES);
expect(opslimit).toBe(sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE);
expect(memlimit).toBe(sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE);
expect(alg).toBe(sodium.crypto_pwhash_ALG_ARGON2ID13);
} finally {
spy.mockRestore();
}
});
});
describe("wrong password", () => {
test("is rejected, and rejects cleanly", async () => {
// rejects.toThrow asserts a rejected promise, not a synchronous throw
// and not an unhandled rejection: the caller can catch this.
await expect(
decryptWithPassword(vault, WRONG_PASSWORD),
).rejects.toThrow();
});
test("returns no plaintext, not even partially", async () => {
const result = await decryptWithPassword(vault, WRONG_PASSWORD).catch(
(err) => err,
);
expect(result).toBeInstanceOf(Error);
expect(String(result)).not.toContain("test");
});
test("the empty password is rejected on a password-protected vault", async () => {
await expect(decryptWithPassword(vault, "")).rejects.toThrow();
});
});
describe("tampering", () => {
test("a flipped ciphertext bit is rejected by the auth tag", async () => {
const tampered = withField(
vault,
"ciphertext",
flipByte(vault.ciphertext, 0),
);
await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
});
test("a flipped bit in the authentication tag itself is rejected", async () => {
const tagStart = b64decode(vault.ciphertext).length - 1;
const tampered = withField(
vault,
"ciphertext",
flipByte(vault.ciphertext, tagStart),
);
await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
});
test("a flipped nonce bit is rejected", async () => {
const tampered = withField(vault, "nonce", flipByte(vault.nonce, 0));
await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
});
test("a flipped salt bit is rejected", async () => {
const tampered = withField(vault, "salt", flipByte(vault.salt, 0));
await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
});
test("a truncated ciphertext is rejected", async () => {
const bytes = b64decode(vault.ciphertext);
const tampered = withField(
vault,
"ciphertext",
sodium.to_base64(bytes.slice(0, bytes.length - 4)),
);
await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
});
test("a ciphertext shorter than the auth tag is rejected", async () => {
const tampered = withField(
vault,
"ciphertext",
sodium.to_base64(b64decode(vault.ciphertext).slice(0, 4)),
);
await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
});
test("a truncated nonce is rejected", async () => {
const tampered = withField(
vault,
"nonce",
sodium.to_base64(b64decode(vault.nonce).slice(0, NONCE_BYTES - 1)),
);
await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
});
test("a ciphertext from another vault is rejected", async () => {
const other = await encryptWithPassword("a different secret", PASSWORD);
const spliced = withField(vault, "ciphertext", other.ciphertext);
await expect(decryptWithPassword(spliced, PASSWORD)).rejects.toThrow();
});
test("a missing field is rejected rather than decrypted", async () => {
for (const field of ["salt", "nonce", "ciphertext"]) {
const broken = { ...vault };
delete broken[field];
await expect(
decryptWithPassword(broken, PASSWORD),
).rejects.toThrow();
}
});
});

View File

@@ -1,4 +1,6 @@
// Tests for the DEBUG build flag as it gates mnemonic generation.
// Tests for src/shared/wallet.js: the DEBUG build flag as it gates mnemonic
// generation (first two describes), and HD key derivation against published
// known-answer vectors (rest of the file).
//
// The modules read the __BUILD_DEBUG__ global that esbuild replaces at bundle
// time. Under jest the global is absent, which is exactly the release-build
@@ -92,3 +94,317 @@ describe("generateMnemonic in a debug build", () => {
);
});
});
// ---------------------------------------------------------------------------
// Key derivation.
//
// Every address below is a published constant, not something this codebase
// produced. Asserting against what the implementation happens to return today
// would pass just as happily with the wrong coin type, the wrong path depth or
// a non-empty seed passphrase, all of which silently send funds to addresses
// no other wallet can recover.
//
// Vector sources:
//
// VECTOR_PHRASE / VECTOR_ADDRESSES / VECTOR_PRIVATE_KEYS — the standard
// development recovery phrase and the first three accounts it yields at
// m/44'/60'/0'/0/n with an empty seed passphrase, as published in the
// Hardhat and Ganache documentation. Publicly known; never fund it.
//
// ZERO_ENTROPY_PHRASE / ZERO_ENTROPY_ADDRESS — the BIP-39 all-zero-entropy
// phrase (Trezor's official BIP-39 vector set, first entry) and its
// m/44'/60'/0'/0/0 Ethereum address with an empty seed passphrase. A second,
// independently published phrase so the pin is not one vector deep.
//
// BIP32_VECTOR_1_XPRV — the master key of BIP-32 test vector 1
// (seed 000102030405060708090a0b0c0d0e0f).
//
// The two Hardhat facts cross-check each other: VECTOR_PRIVATE_KEYS[n] is the
// published key for VECTOR_ADDRESSES[n], so addressFromPrivateKey and the HD
// path must meet at the same address from two different directions.
const { HDNodeWallet, Mnemonic, verifyMessage } = require("ethers");
const wallet = require("../src/shared/wallet");
const { BIP44_ETH_PATH } = require("../src/shared/constants");
const VECTOR_PHRASE =
"test test test test test test test test test test test junk";
const VECTOR_ADDRESSES = [
"0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
"0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
"0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",
];
const VECTOR_PRIVATE_KEYS = [
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d",
"0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a",
];
const ZERO_ENTROPY_PHRASE =
"abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon about";
const ZERO_ENTROPY_ADDRESS = "0x9858EfFD232B4033E47d90003D41EC34EcaEda94";
const BIP32_VECTOR_1_XPRV =
"xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqji" +
"ChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi";
// The master (depth-0) extended private key for a phrase, which is what the
// import-an-xprv flow is handed. Built with ethers rather than with the module
// under test, so hdWalletFromXprv is not being checked against itself.
function masterXprv(phrase, passphrase = "") {
return HDNodeWallet.fromSeed(
Mnemonic.fromPhrase(phrase, passphrase).computeSeed(),
).extendedKey;
}
describe("hdWalletFromMnemonic", () => {
test("first address matches the published vector for m/44'/60'/0'/0/0", () => {
expect(wallet.hdWalletFromMnemonic(VECTOR_PHRASE).firstAddress).toBe(
VECTOR_ADDRESSES[0],
);
});
test("second published phrase derives its published address", () => {
expect(
wallet.hdWalletFromMnemonic(ZERO_ENTROPY_PHRASE).firstAddress,
).toBe(ZERO_ENTROPY_ADDRESS);
});
test("returns the account-level xpub, which is watch-only", () => {
const { xpub } = wallet.hdWalletFromMnemonic(VECTOR_PHRASE);
expect(xpub.startsWith("xpub")).toBe(true);
// A neutered ethers node exposes no private key at all, so accept
// either absent or null rather than pinning which.
expect(
HDNodeWallet.fromExtendedKey(xpub).privateKey ?? null,
).toBeNull();
expect(wallet.isValidXprv(xpub)).toBe(false);
});
test("the account path is the documented BIP-44 Ethereum path", () => {
expect(BIP44_ETH_PATH).toBe("m/44'/60'/0'/0");
});
test("rejects an invalid recovery phrase rather than deriving from it", () => {
expect(() => wallet.hdWalletFromMnemonic("not a phrase")).toThrow();
});
});
describe("deriveAddressFromXpub", () => {
const { xpub } = wallet.hdWalletFromMnemonic(VECTOR_PHRASE);
test.each([0, 1, 2])(
"child %i matches the published vector address",
(index) => {
expect(wallet.deriveAddressFromXpub(xpub, index)).toBe(
VECTOR_ADDRESSES[index],
);
},
);
test("agrees with hdWalletFromMnemonic at index 0", () => {
expect(wallet.deriveAddressFromXpub(xpub, 0)).toBe(
wallet.hdWalletFromMnemonic(VECTOR_PHRASE).firstAddress,
);
});
test("rejects garbage instead of returning an address", () => {
expect(() =>
wallet.deriveAddressFromXpub("xpub-nonsense", 0),
).toThrow();
});
});
describe("hdWalletFromMnemonic seed passphrase handling", () => {
// The vectors above are only reproducible with an empty BIP-39 seed
// passphrase. This pins that the empty string reaching
// HDNodeWallet.fromPhrase is load-bearing: with any passphrase applied the
// published address is unreachable, and a wallet derived that way could
// not be restored anywhere else from the phrase alone.
test("a non-empty seed passphrase would yield a different address", () => {
const withPassphrase = HDNodeWallet.fromPhrase(
VECTOR_PHRASE,
"TREZOR",
BIP44_ETH_PATH,
).deriveChild(0).address;
expect(withPassphrase).not.toBe(VECTOR_ADDRESSES[0]);
});
});
describe("hdWalletFromXprv", () => {
// hdWalletFromMnemonic derives the absolute path "m/44'/60'/0'/0" while
// hdWalletFromXprv derives the relative path "44'/60'/0'/0". For a
// depth-0 master key the two are the same derivation; these tests pin that
// equivalence to a published address rather than assuming it.
test("master xprv for the vector phrase yields the vector address", () => {
expect(
wallet.hdWalletFromXprv(masterXprv(VECTOR_PHRASE)).firstAddress,
).toBe(VECTOR_ADDRESSES[0]);
});
test("agrees with hdWalletFromMnemonic on xpub and address", () => {
const fromPhrase = wallet.hdWalletFromMnemonic(VECTOR_PHRASE);
const fromXprv = wallet.hdWalletFromXprv(masterXprv(VECTOR_PHRASE));
expect(fromXprv).toEqual(fromPhrase);
});
test("derived xpub generates the same child addresses", () => {
const { xpub } = wallet.hdWalletFromXprv(masterXprv(VECTOR_PHRASE));
expect(
[0, 1, 2].map((i) => wallet.deriveAddressFromXpub(xpub, i)),
).toEqual(VECTOR_ADDRESSES);
});
test("accepts the BIP-32 test vector 1 master key", () => {
const { xpub, firstAddress } =
wallet.hdWalletFromXprv(BIP32_VECTOR_1_XPRV);
expect(xpub.startsWith("xpub")).toBe(true);
expect(firstAddress).toMatch(/^0x[0-9a-fA-F]{40}$/);
});
test("rejects a watch-only xpub", () => {
const { xpub } = wallet.hdWalletFromMnemonic(VECTOR_PHRASE);
expect(() => wallet.hdWalletFromXprv(xpub)).toThrow();
});
test("rejects garbage", () => {
expect(() => wallet.hdWalletFromXprv("nonsense")).toThrow();
});
});
describe("isValidXprv", () => {
test.each([
["BIP-32 test vector 1 master key", BIP32_VECTOR_1_XPRV, true],
["the empty string", "", false],
["garbage", "not-a-key", false],
["a bare private key", VECTOR_PRIVATE_KEYS[0], false],
["a truncated xprv", BIP32_VECTOR_1_XPRV.slice(0, -6), false],
["an xprv with an extra character", BIP32_VECTOR_1_XPRV + "a", false],
])("%s -> %s", (_name, key, expected) => {
expect(wallet.isValidXprv(key)).toBe(expected);
});
test("a watch-only xpub is not an xprv", () => {
const { xpub } = wallet.hdWalletFromMnemonic(VECTOR_PHRASE);
expect(wallet.isValidXprv(xpub)).toBe(false);
});
// Skipped: this asserts the correct behaviour, which the code does not
// currently have. isValidXprv gates the paste-your-extended-private-key
// import in src/popup/views/addWallet.js:215, and it accepts a key with a
// one-character typo: ethers' HDNodeWallet.fromExtendedKey skips base58
// checksum verification whenever the decoded payload is the usual 82
// bytes, which is the whole point of that checksum. Measured on this
// vector: changing any one of the last 14 characters passes validation,
// and for 9 of those 14 positions the import silently yields a *different*
// wallet (e.g. 0x3F334f0a356d6B46B1d70B590E7437D77100d28D instead of
// 0x022b971dFF0C43305e691DEd7a14367AF19D6407) with no error shown.
// Tracked as https://git.eeqj.de/sneak/AutistMask/issues/210; out of scope
// here, which is tests only. Unskip when it is fixed.
test.skip("rejects an extended key with a one-character typo", () => {
const index = BIP32_VECTOR_1_XPRV.length - 8;
const typo =
BIP32_VECTOR_1_XPRV.slice(0, index) +
(BIP32_VECTOR_1_XPRV[index] === "a" ? "b" : "a") +
BIP32_VECTOR_1_XPRV.slice(index + 1);
expect(wallet.isValidXprv(typo)).toBe(false);
});
});
describe("isValidMnemonic", () => {
test.each([
["the vector phrase", VECTOR_PHRASE, true],
["the BIP-39 zero-entropy phrase", ZERO_ENTROPY_PHRASE, true],
[
"a 12-word phrase with a bad checksum",
"abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon abandon",
false,
],
["an 11-word phrase", "abandon ".repeat(10) + "about", false],
["a word outside the wordlist", VECTOR_PHRASE + " zzzzzz", false],
["the empty string", "", false],
["garbage", "correct horse battery staple", false],
])("%s -> %s", (_name, phrase, expected) => {
expect(wallet.isValidMnemonic(phrase)).toBe(expected);
});
});
describe("addressFromPrivateKey", () => {
test.each([0, 1, 2])(
"published key %i yields its published address",
(index) => {
expect(
wallet.addressFromPrivateKey(VECTOR_PRIVATE_KEYS[index]),
).toBe(VECTOR_ADDRESSES[index]);
},
);
test("rejects a key of the wrong length", () => {
expect(() => wallet.addressFromPrivateKey("0xdeadbeef")).toThrow();
});
test("rejects the empty string", () => {
expect(() => wallet.addressFromPrivateKey("")).toThrow();
});
});
describe("getSignerForAddress", () => {
test.each([0, 1, 2])("hd wallet, address index %i", (index) => {
const signer = wallet.getSignerForAddress(
{ type: "hd" },
index,
VECTOR_PHRASE,
);
expect(signer.address).toBe(VECTOR_ADDRESSES[index]);
expect(signer.privateKey).toBe(VECTOR_PRIVATE_KEYS[index]);
});
test.each([0, 1, 2])("xprv wallet, address index %i", (index) => {
const signer = wallet.getSignerForAddress(
{ type: "xprv" },
index,
masterXprv(VECTOR_PHRASE),
);
expect(signer.address).toBe(VECTOR_ADDRESSES[index]);
expect(signer.privateKey).toBe(VECTOR_PRIVATE_KEYS[index]);
});
test("single private key ignores the address index", () => {
for (const index of [0, 1, 2]) {
const signer = wallet.getSignerForAddress(
{ type: "privkey" },
index,
VECTOR_PRIVATE_KEYS[1],
);
expect(signer.address).toBe(VECTOR_ADDRESSES[1]);
}
});
test("the returned signer signs recoverably as the expected address", async () => {
const signer = wallet.getSignerForAddress(
{ type: "hd" },
1,
VECTOR_PHRASE,
);
const message = "AutistMask derivation test";
const signature = await signer.signMessage(message);
expect(verifyMessage(message, signature)).toBe(VECTOR_ADDRESSES[1]);
});
});