Compare commits

...

3 Commits

Author SHA1 Message Date
clawbot
c0a9b58e0c fix: NUL-delimit verify-build's dist walk so no path escapes the check (closes #223)
All checks were successful
check / check (push) Successful in 43s
check_unlisted_bundles read dist/ line by line, so two unlisted paths
carrying a debug marker went unchecked while the script still exited 0:
a name with a trailing space (read stripped it and the remnant matched a
manifest line) and a name containing a newline (find printed it as a
listed path plus an empty one).

The walk is now find -print0 into a temporary listing, checked for find's
exit status as before, and xargs -0 hands the paths back to this script as
arguments, where the same is_listed and has_marker run on them. is_listed
also answers "not listed" for any path containing a newline without asking
grep, which would otherwise read such a path as two patterns and match on
the first half — the escape survives NUL delimiting on its own.

dist/ being a symlink is now its own check with its own message. It failed
before only because GNU grep exits 2 on a directory, so a grep that exits 1
instead would have turned the whole cross-check into a pass.

The comment claiming every file under dist/ is searched now says what is
actually guaranteed: every regular file and every symlink, with the four
properties that coverage rests on stated as enforced rather than assumed.
2026-08-11 13:23:49 +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
8 changed files with 904 additions and 56 deletions

View File

@@ -1108,7 +1108,8 @@ indexes it as a real token transfer.
it. AutistMask hides transactions below a configurable dust threshold it. AutistMask hides transactions below a configurable dust threshold
(default: 100,000 gwei / 0.0001 ETH). This is high enough to filter poisoning (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 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, - **User-configurable**: All of the above filters (known symbol verification,
low-holder threshold, fraud contract blocklist, dust threshold) are settings low-holder threshold, fraud contract blocklist, dust threshold) are settings
@@ -1189,8 +1190,8 @@ Currently supported:
### Testing ### Testing
- [ ] Tests for mnemonic generation and address derivation - [x] Tests for mnemonic generation and address derivation
- [ ] Tests for xpub derivation and child address generation - [x] Tests for xpub derivation and child address generation
- [ ] Test on Firefox (Manifest V2) - [ ] Test on Firefox (Manifest V2)
### Scam List ### Scam List

12
TODO.md
View File

@@ -44,6 +44,15 @@ undefined identifiers, which is how
# Completed Steps # Completed Steps
- 2026-08-11: `script/verify-build` now walks `dist/` NUL-delimited and asserts
`dist/` is a real directory, so a path with a trailing space or a newline can
no longer carry a debug marker past the unlisted-bundle check
([#223](https://git.eeqj.de/sneak/AutistMask/issues/223)).
- 2026-08-11: A dust threshold of `0` now means "hide nothing" instead of
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: Policy compliance sweep — conditional verbose test rerun, local - 2026-08-11: Policy compliance sweep — conditional verbose test rerun, local
Tailwind binary instead of `npx`, `--frozen-lockfile` on `make install`, and Tailwind binary instead of `npx`, `--frozen-lockfile` on `make install`, and
the Makefile-only targets documented in the README the Makefile-only targets documented in the README
@@ -54,6 +63,9 @@ undefined identifiers, which is how
lives only in `build.js`, and the unlisted-bundle scan hard-fails when it lives only in `build.js`, and the unlisted-bundle scan hard-fails when it
cannot enumerate `dist/` cannot enumerate `dist/`
([#180](https://git.eeqj.de/sneak/AutistMask/issues/180)). ([#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 - 2026-08-11: Three `README.md` claims corrected against the code — blocklist
attribution, token-display rule, navigation model attribution, token-display rule, navigation model
([#213](https://git.eeqj.de/sneak/AutistMask/issues/213)). ([#213](https://git.eeqj.de/sneak/AutistMask/issues/213)).

View File

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

View File

@@ -304,11 +304,17 @@ function init(ctx) {
$("settings-dust-threshold").value = state.dustThresholdGwei; $("settings-dust-threshold").value = state.dustThresholdGwei;
$("settings-dust-threshold").addEventListener("change", async () => { $("settings-dust-threshold").addEventListener("change", async () => {
const val = parseInt($("settings-dust-threshold").value, 10); const raw = $("settings-dust-threshold").value.trim();
if (!isNaN(val) && val >= 0) { 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; state.dustThresholdGwei = val;
await saveState(); await saveState();
} }
$("settings-dust-threshold").value = state.dustThresholdGwei;
}); });
$("settings-utc-timestamps").checked = state.utcTimestamps; $("settings-utc-timestamps").checked = state.utcTimestamps;

View File

@@ -10,6 +10,14 @@ const { formatEther, formatUnits } = require("ethers");
const { log, debugFetch } = require("./log"); const { log, debugFetch } = require("./log");
const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList"); 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) { function formatTxValue(val) {
const parts = val.split("."); const parts = val.split(".");
if (parts.length === 1) return val + ".0000"; if (parts.length === 1) return val + ".0000";
@@ -30,10 +38,10 @@ function parseTx(tx, addrLower) {
let exactValue = formatEther(rawWei); let exactValue = formatEther(rawWei);
let rawAmount = rawWei; let rawAmount = rawWei;
let rawUnit = "wei"; let rawUnit = "wei";
let direction = from.toLowerCase() === addrLower ? "sent" : "received"; let direction = normalizeAddress(from) === addrLower ? "sent" : "received";
let directionLabel = direction === "sent" ? "Sent" : "Received"; let directionLabel = direction === "sent" ? "Sent" : "Received";
if (toIsContract && method && method !== "transfer") { if (toIsContract && method && method !== "transfer") {
const token = TOKEN_BY_ADDRESS.get(to.toLowerCase()); const token = TOKEN_BY_ADDRESS.get(normalizeAddress(to));
if (token) { if (token) {
symbol = token.symbol; symbol = token.symbol;
} }
@@ -87,7 +95,8 @@ function parseTokenTransfer(tt, addrLower) {
const to = tt.to?.hash || ""; const to = tt.to?.hash || "";
const decimals = parseInt(tt.total?.decimals || "18", 10); const decimals = parseInt(tt.total?.decimals || "18", 10);
const rawVal = tt.total?.value || "0"; 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 || "?"; const sym = tt.token?.symbol || "?";
return { return {
hash: tt.transaction_hash, hash: tt.transaction_hash,
@@ -104,11 +113,9 @@ function parseTokenTransfer(tt, addrLower) {
direction: direction, direction: direction,
directionLabel: direction === "sent" ? "Sent" : "Received", directionLabel: direction === "sent" ? "Sent" : "Received",
isError: false, isError: false,
contractAddress: ( contractAddress: normalizeAddress(
tt.token?.address_hash || tt.token?.address_hash || tt.token?.address || "",
tt.token?.address || ),
""
).toLowerCase(),
holders: parseInt(tt.token?.holders_count || "0", 10), holders: parseInt(tt.token?.holders_count || "0", 10),
}; };
} }
@@ -194,7 +201,7 @@ function mergeTransactions(txs, tokenTransfers) {
async function fetchRecentTransactions(address, blockscoutUrl, count = 25) { async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
log.debugf("fetchRecentTransactions", address); log.debugf("fetchRecentTransactions", address);
const addrLower = address.toLowerCase(); const addrLower = normalizeAddress(address);
const [txResp, ttResp] = await Promise.all([ const [txResp, ttResp] = await Promise.all([
debugFetch(blockscoutUrl + "/addresses/" + address + "/transactions"), debugFetch(blockscoutUrl + "/addresses/" + address + "/transactions"),
@@ -243,34 +250,38 @@ function isSpoofedSymbol(tx) {
if (!KNOWN_SYMBOLS.has(symbol)) return false; if (!KNOWN_SYMBOLS.has(symbol)) return false;
const legit = KNOWN_SYMBOLS.get(symbol); const legit = KNOWN_SYMBOLS.get(symbol);
if (legit === null) return true; // "ETH" as ERC-20 is always fake 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, // Pure filter function. Takes raw transactions and filter settings,
// returns { transactions, newFraudContracts }. // returns { transactions, newFraudContracts }.
function filterTransactions(txs, filters = {}) { function filterTransactions(txs, filters = {}) {
const fraudSet = new Set( 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 newFraud = [];
const filtered = []; const filtered = [];
for (const tx of txs) { for (const tx of txs) {
const contract = normalizeAddress(tx.contractAddress);
// Always filter spoofed known symbols and record the fraud contract // Always filter spoofed known symbols and record the fraud contract
if (isSpoofedSymbol(tx)) { if (isSpoofedSymbol(tx)) {
if (tx.contractAddress && !fraudSet.has(tx.contractAddress)) { if (contract && !fraudSet.has(contract)) {
fraudSet.add(tx.contractAddress); fraudSet.add(contract);
newFraud.push(tx.contractAddress); newFraud.push(contract);
} }
continue; continue;
} }
// Filter fraud contracts if setting is on // Filter fraud contracts if setting is on
if ( if (filters.hideFraudContracts && contract && fraudSet.has(contract)) {
filters.hideFraudContracts &&
tx.contractAddress &&
fraudSet.has(tx.contractAddress)
) {
continue; continue;
} }
@@ -291,7 +302,7 @@ function filterTransactions(txs, filters = {}) {
filters.hideDustTransactions && filters.hideDustTransactions &&
!tx.isContractCall && !tx.isContractCall &&
tx.valueGwei !== null && tx.valueGwei !== null &&
tx.valueGwei < (filters.dustThresholdGwei || 100000) tx.valueGwei < dustThresholdGwei
) { ) {
continue; continue;
} }

View File

@@ -329,18 +329,42 @@ describe("known-symbol spoof verification", () => {
expect(result.newFraudContracts).toEqual([]); expect(result.newFraudContracts).toEqual([]);
}); });
// Documents current behaviour, not desired behaviour: the spoof check // Regression guard (#179): EIP-55 mixed case is a checksum over the
// compares tx.contractAddress against a lowercased known address with // address, not part of its identity, so the contract comparison must be
// ===, so a caller passing a checksummed address for a genuine token has // case-insensitive in both directions — a genuine token in any casing is
// it treated as a spoof. In the app this cannot happen because // genuine, and a spoof cannot escape detection by changing its casing.
// parseTokenTransfer lowercases, but the exported function is not test("a genuine contract in all-lowercase form is not a spoof", () => {
// defensive about it the way the blocklist check is. const tx = tokenTx({ contractAddress: USDC_CONTRACT });
test("current behaviour: a checksummed genuine contract is treated as a spoof", () => { expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
const genuineButChecksummed = tokenTx({ });
test("a genuine contract in EIP-55 checksummed form is not a spoof", () => {
const tx = tokenTx({
contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", 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([]); 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 // 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(tx.holders).toBeNull();
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]); 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", () => { describe("fraud contract blocklist", () => {
@@ -575,16 +614,50 @@ describe("dust threshold filtering", () => {
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]); expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
}); });
// Documents current behaviour: the threshold is read as // Regression guard (#179): 0 is a real threshold meaning "hide nothing",
// `filters.dustThresholdGwei || 100000`, so a user who sets the threshold // not an absent one. It used to be swallowed by `|| 100000`, so the one
// to 0 (the natural way to ask for no dust filtering while leaving the // value a user would pick to see everything was the one that did not
// toggle on) silently gets the 100,000 gwei default instead. // work.
test("current behaviour: a threshold of 0 falls back to the 100,000 gwei default", () => { test("a threshold of 0 hides nothing, leaving the toggle on", () => {
const result = filterTransactions( const dust = dustOf(50);
[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 }), 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 // 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 // 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]);
});
});