Compare commits
2 Commits
f4ee5f779f
...
d0202fde58
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0202fde58 | ||
| f455b0ae7f |
30
README.md
30
README.md
@@ -720,6 +720,7 @@ screen, including ExportPrivKey, falls back to Home.
|
|||||||
- Blockscout API: endpoint URL input + "Save" button (validated against
|
- Blockscout API: endpoint URL input + "Save" button (validated against
|
||||||
`/stats` before being saved)
|
`/stats` before being saved)
|
||||||
- Token Spam Protection:
|
- Token Spam Protection:
|
||||||
|
- "Hide fake tokens impersonating a known symbol" checkbox
|
||||||
- "Hide tokens with fewer than 1,000 holders" checkbox
|
- "Hide tokens with fewer than 1,000 holders" checkbox
|
||||||
- "Hide transactions from detected fraud contracts" checkbox
|
- "Hide transactions from detected fraud contracts" checkbox
|
||||||
- "Hide dust transactions below N gwei" checkbox + threshold input
|
- "Hide dust transactions below N gwei" checkbox + threshold input
|
||||||
@@ -1081,7 +1082,14 @@ indexes it as a real token transfer.
|
|||||||
a spoof and filtered from display. The fake "Ethereum" token in the attack
|
a spoof and filtered from display. The fake "Ethereum" token in the attack
|
||||||
above used symbol "ETH" from contract
|
above used symbol "ETH" from contract
|
||||||
`0xD05339f9Ea5ab9d9F03B9d57F671d2abD1F55c82`, which does not match the known
|
`0xD05339f9Ea5ab9d9F03B9d57F671d2abD1F55c82`, which does not match the known
|
||||||
WETH contract — so it would be caught by this check.
|
WETH contract — so it would be caught by this check. Detecting a spoof is also
|
||||||
|
what adds a contract to the fraud contract blocklist below; that is the only
|
||||||
|
thing that populates it. In the transaction history the check is the "Hide
|
||||||
|
fake tokens impersonating a known symbol" setting, on by default; with it off,
|
||||||
|
spoofed transfers are shown and no new blocklist entries are learned from
|
||||||
|
them. The same check on the balance list and on the send-screen token selector
|
||||||
|
is unconditional, because those decide which tokens the user can act on rather
|
||||||
|
than what the history displays.
|
||||||
|
|
||||||
- **Low-holder token filtering**: Token transfers from ERC-20 contracts with
|
- **Low-holder token filtering**: Token transfers from ERC-20 contracts with
|
||||||
fewer than 1,000 holders are hidden from transaction history by default.
|
fewer than 1,000 holders are hidden from transaction history by default.
|
||||||
@@ -1110,11 +1118,17 @@ indexes it as a real token transfer.
|
|||||||
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.
|
||||||
|
|
||||||
- **User-configurable**: All of the above filters (known symbol verification,
|
- **User-configurable**: All four filters (known symbol verification, low-holder
|
||||||
low-holder threshold, fraud contract blocklist, dust threshold) are settings
|
threshold, fraud contract blocklist, dust threshold) are settings that default
|
||||||
that default to on but can be individually disabled by the user. AutistMask is
|
to on but can be individually disabled by the user. AutistMask is designed as
|
||||||
designed as a sharp tool — users who understand the risks can configure the
|
a sharp tool — users who understand the risks can configure the wallet to show
|
||||||
wallet to show everything unfiltered, unix-style.
|
everything unfiltered, unix-style. All four settings govern the transaction
|
||||||
|
history; what else each one reaches varies. The known-symbol check also runs
|
||||||
|
unconditionally on the balance list and on the send-screen token selector, and
|
||||||
|
the fraud contract blocklist is applied unconditionally on that selector. The
|
||||||
|
low-holder setting also gates the send selector, while the balance list's own
|
||||||
|
1,000-holder floor is unconditional (see Data Model). The dust threshold
|
||||||
|
applies to the transaction history alone.
|
||||||
|
|
||||||
#### Phishing Domain Protection
|
#### Phishing Domain Protection
|
||||||
|
|
||||||
@@ -1189,8 +1203,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
|
||||||
|
|||||||
7
TODO.md
7
TODO.md
@@ -44,6 +44,10 @@ undefined identifiers, which is how
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-08-11: Known-symbol spoof verification became a Settings toggle
|
||||||
|
(`hideSpoofedSymbols`), on by default, governing the transaction-history
|
||||||
|
filter and the fraud-contract learning it feeds
|
||||||
|
([#176](https://git.eeqj.de/sneak/AutistMask/issues/176)).
|
||||||
- 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 +58,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)).
|
||||||
|
|||||||
@@ -323,7 +323,11 @@ by default:
|
|||||||
**Known token symbol verification.** AutistMask ships a list of roughly 500
|
**Known token symbol verification.** AutistMask ships a list of roughly 500
|
||||||
legitimate ERC-20 tokens with their contract addresses. If a transaction or
|
legitimate ERC-20 tokens with their contract addresses. If a transaction or
|
||||||
balance claims to involve a known symbol (like "ETH" or "USDT") but comes from
|
balance claims to involve a known symbol (like "ETH" or "USDT") but comes from
|
||||||
an unrecognized contract, it is identified as a spoof and hidden.
|
an unrecognized contract, it is identified as a spoof and hidden. In your
|
||||||
|
transaction history this is the "Hide fake tokens impersonating a known symbol"
|
||||||
|
setting, which you can switch off; doing so also stops new entries being added
|
||||||
|
to the fraud contract blocklist below, since detecting a spoof is what fills it.
|
||||||
|
Your balances and the send token list always apply the check.
|
||||||
|
|
||||||
**Low-holder token filtering.** Tokens with fewer than 1,000 holders are hidden
|
**Low-holder token filtering.** Tokens with fewer than 1,000 holders are hidden
|
||||||
from transaction history and the send token list, and are left out of your
|
from transaction history and the send token list, and are left out of your
|
||||||
|
|||||||
@@ -948,6 +948,15 @@
|
|||||||
transfers and prevent interaction with suspicious
|
transfers and prevent interaction with suspicious
|
||||||
tokens.
|
tokens.
|
||||||
</p>
|
</p>
|
||||||
|
<label
|
||||||
|
class="text-xs flex items-center gap-1 cursor-pointer mb-2"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="settings-hide-spoofed-symbols"
|
||||||
|
/>
|
||||||
|
Hide fake tokens impersonating a known symbol
|
||||||
|
</label>
|
||||||
<label
|
<label
|
||||||
class="text-xs flex items-center gap-1 cursor-pointer mb-2"
|
class="text-xs flex items-center gap-1 cursor-pointer mb-2"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ async function loadTransactions(address) {
|
|||||||
state.blockscoutUrl,
|
state.blockscoutUrl,
|
||||||
);
|
);
|
||||||
const result = filterTransactions(rawTxs, {
|
const result = filterTransactions(rawTxs, {
|
||||||
|
hideSpoofedSymbols: state.hideSpoofedSymbols,
|
||||||
hideLowHolderTokens: state.hideLowHolderTokens,
|
hideLowHolderTokens: state.hideLowHolderTokens,
|
||||||
hideFraudContracts: state.hideFraudContracts,
|
hideFraudContracts: state.hideFraudContracts,
|
||||||
hideDustTransactions: state.hideDustTransactions,
|
hideDustTransactions: state.hideDustTransactions,
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ async function loadTransactions(address, tokenId) {
|
|||||||
state.blockscoutUrl,
|
state.blockscoutUrl,
|
||||||
);
|
);
|
||||||
const result = filterTransactions(rawTxs, {
|
const result = filterTransactions(rawTxs, {
|
||||||
|
hideSpoofedSymbols: state.hideSpoofedSymbols,
|
||||||
hideLowHolderTokens: state.hideLowHolderTokens,
|
hideLowHolderTokens: state.hideLowHolderTokens,
|
||||||
hideFraudContracts: state.hideFraudContracts,
|
hideFraudContracts: state.hideFraudContracts,
|
||||||
hideDustTransactions: state.hideDustTransactions,
|
hideDustTransactions: state.hideDustTransactions,
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ async function loadHomeTxs(ctx) {
|
|||||||
if (allAddresses.length === 0) return;
|
if (allAddresses.length === 0) return;
|
||||||
|
|
||||||
const filters = {
|
const filters = {
|
||||||
|
hideSpoofedSymbols: state.hideSpoofedSymbols,
|
||||||
hideLowHolderTokens: state.hideLowHolderTokens,
|
hideLowHolderTokens: state.hideLowHolderTokens,
|
||||||
hideFraudContracts: state.hideFraudContracts,
|
hideFraudContracts: state.hideFraudContracts,
|
||||||
hideDustTransactions: state.hideDustTransactions,
|
hideDustTransactions: state.hideDustTransactions,
|
||||||
|
|||||||
@@ -284,6 +284,12 @@ function init(ctx) {
|
|||||||
applyTheme(state.theme);
|
applyTheme(state.theme);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$("settings-hide-spoofed-symbols").checked = state.hideSpoofedSymbols;
|
||||||
|
$("settings-hide-spoofed-symbols").addEventListener("change", async () => {
|
||||||
|
state.hideSpoofedSymbols = $("settings-hide-spoofed-symbols").checked;
|
||||||
|
await saveState();
|
||||||
|
});
|
||||||
|
|
||||||
$("settings-hide-low-holders").checked = state.hideLowHolderTokens;
|
$("settings-hide-low-holders").checked = state.hideLowHolderTokens;
|
||||||
$("settings-hide-low-holders").addEventListener("change", async () => {
|
$("settings-hide-low-holders").addEventListener("change", async () => {
|
||||||
state.hideLowHolderTokens = $("settings-hide-low-holders").checked;
|
state.hideLowHolderTokens = $("settings-hide-low-holders").checked;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const DEFAULT_STATE = {
|
|||||||
deniedSites: {},
|
deniedSites: {},
|
||||||
rememberSiteChoice: true,
|
rememberSiteChoice: true,
|
||||||
showZeroBalanceTokens: true,
|
showZeroBalanceTokens: true,
|
||||||
|
hideSpoofedSymbols: true,
|
||||||
hideLowHolderTokens: true,
|
hideLowHolderTokens: true,
|
||||||
hideFraudContracts: true,
|
hideFraudContracts: true,
|
||||||
hideDustTransactions: true,
|
hideDustTransactions: true,
|
||||||
@@ -61,6 +62,7 @@ async function saveState() {
|
|||||||
deniedSites: state.deniedSites,
|
deniedSites: state.deniedSites,
|
||||||
rememberSiteChoice: state.rememberSiteChoice,
|
rememberSiteChoice: state.rememberSiteChoice,
|
||||||
showZeroBalanceTokens: state.showZeroBalanceTokens,
|
showZeroBalanceTokens: state.showZeroBalanceTokens,
|
||||||
|
hideSpoofedSymbols: state.hideSpoofedSymbols,
|
||||||
hideLowHolderTokens: state.hideLowHolderTokens,
|
hideLowHolderTokens: state.hideLowHolderTokens,
|
||||||
hideFraudContracts: state.hideFraudContracts,
|
hideFraudContracts: state.hideFraudContracts,
|
||||||
hideDustTransactions: state.hideDustTransactions,
|
hideDustTransactions: state.hideDustTransactions,
|
||||||
@@ -112,6 +114,12 @@ async function loadState() {
|
|||||||
saved.showZeroBalanceTokens !== undefined
|
saved.showZeroBalanceTokens !== undefined
|
||||||
? saved.showZeroBalanceTokens
|
? saved.showZeroBalanceTokens
|
||||||
: true;
|
: true;
|
||||||
|
// A profile written before this setting existed has no key for it.
|
||||||
|
// It is a safety filter, so absent must load as on, not as undefined.
|
||||||
|
state.hideSpoofedSymbols =
|
||||||
|
saved.hideSpoofedSymbols !== undefined
|
||||||
|
? saved.hideSpoofedSymbols
|
||||||
|
: true;
|
||||||
state.hideLowHolderTokens =
|
state.hideLowHolderTokens =
|
||||||
saved.hideLowHolderTokens !== undefined
|
saved.hideLowHolderTokens !== undefined
|
||||||
? saved.hideLowHolderTokens
|
? saved.hideLowHolderTokens
|
||||||
|
|||||||
@@ -254,10 +254,17 @@ function filterTransactions(txs, filters = {}) {
|
|||||||
);
|
);
|
||||||
const newFraud = [];
|
const newFraud = [];
|
||||||
const filtered = [];
|
const filtered = [];
|
||||||
|
// Fail-safe, unlike the three flags below: this one is off only when the
|
||||||
|
// caller says so explicitly, so a caller that omits the key keeps the
|
||||||
|
// check rather than silently losing it. The setting also governs the
|
||||||
|
// blocklist learning below, which exists only to serve this check —
|
||||||
|
// leaving learning on while the check is off would re-hide the very rows
|
||||||
|
// the user asked to see, through the fraud-contract rule.
|
||||||
|
const hideSpoofed = filters.hideSpoofedSymbols !== false;
|
||||||
|
|
||||||
for (const tx of txs) {
|
for (const tx of txs) {
|
||||||
// Always filter spoofed known symbols and record the fraud contract
|
// Filter spoofed known symbols and record the fraud contract
|
||||||
if (isSpoofedSymbol(tx)) {
|
if (hideSpoofed && isSpoofedSymbol(tx)) {
|
||||||
if (tx.contractAddress && !fraudSet.has(tx.contractAddress)) {
|
if (tx.contractAddress && !fraudSet.has(tx.contractAddress)) {
|
||||||
fraudSet.add(tx.contractAddress);
|
fraudSet.add(tx.contractAddress);
|
||||||
newFraud.push(tx.contractAddress);
|
newFraud.push(tx.contractAddress);
|
||||||
|
|||||||
@@ -102,3 +102,60 @@ describe("loadState hasWallet reconciliation", () => {
|
|||||||
expect(mod.state.activeAddress).toBe(ADDRESS);
|
expect(mod.state.activeAddress).toBe(ADDRESS);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The known-symbol spoof filter is a safety filter, so an existing profile
|
||||||
|
// stored before the setting existed must load with it on rather than with
|
||||||
|
// undefined, which would read as off.
|
||||||
|
describe("hideSpoofedSymbols persistence", () => {
|
||||||
|
test("defaults to on with empty storage", async () => {
|
||||||
|
const { mod } = loadModuleWith(null);
|
||||||
|
await mod.loadState();
|
||||||
|
expect(mod.state.hideSpoofedSymbols).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a profile stored without the key loads with it on", async () => {
|
||||||
|
const { mod } = loadModuleWith({ wallets: oneWallet() });
|
||||||
|
await mod.loadState();
|
||||||
|
expect(mod.state.hideSpoofedSymbols).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an explicit false survives the load", async () => {
|
||||||
|
const { mod } = loadModuleWith({
|
||||||
|
wallets: oneWallet(),
|
||||||
|
hideSpoofedSymbols: false,
|
||||||
|
});
|
||||||
|
await mod.loadState();
|
||||||
|
expect(mod.state.hideSpoofedSymbols).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("saveState persists the flag", async () => {
|
||||||
|
const { mod, set } = loadModuleWith(null);
|
||||||
|
mod.state.hideSpoofedSymbols = false;
|
||||||
|
await mod.saveState();
|
||||||
|
expect(set).toHaveBeenCalledWith({
|
||||||
|
autistmask: expect.objectContaining({ hideSpoofedSymbols: false }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the flag round-trips off through save and load", async () => {
|
||||||
|
const first = loadModuleWith(null);
|
||||||
|
first.mod.state.hideSpoofedSymbols = false;
|
||||||
|
await first.mod.saveState();
|
||||||
|
const persisted = first.set.mock.calls[0][0].autistmask;
|
||||||
|
|
||||||
|
const second = loadModuleWith(persisted);
|
||||||
|
await second.mod.loadState();
|
||||||
|
expect(second.mod.state.hideSpoofedSymbols).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the flag round-trips back on through save and load", async () => {
|
||||||
|
const first = loadModuleWith(null);
|
||||||
|
first.mod.state.hideSpoofedSymbols = true;
|
||||||
|
await first.mod.saveState();
|
||||||
|
const persisted = first.set.mock.calls[0][0].autistmask;
|
||||||
|
|
||||||
|
const second = loadModuleWith(persisted);
|
||||||
|
await second.mod.loadState();
|
||||||
|
expect(second.mod.state.hideSpoofedSymbols).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ const ORDINARY_PEER = "0x5aa0f9f1e0a1d0e0e5c1e7ce3b7dbbe9c19f0a11";
|
|||||||
|
|
||||||
// The documented default settings (README.md:810-814, state.js:24-27).
|
// The documented default settings (README.md:810-814, state.js:24-27).
|
||||||
const DEFAULT_FILTERS = {
|
const DEFAULT_FILTERS = {
|
||||||
|
hideSpoofedSymbols: true,
|
||||||
hideLowHolderTokens: true,
|
hideLowHolderTokens: true,
|
||||||
hideFraudContracts: true,
|
hideFraudContracts: true,
|
||||||
hideDustTransactions: true,
|
hideDustTransactions: true,
|
||||||
@@ -343,30 +344,112 @@ describe("known-symbol spoof verification", () => {
|
|||||||
expect(result.transactions).toEqual([]);
|
expect(result.transactions).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Documents current behaviour: README.md:810-814 says all four filters
|
// Turning the other three filters off must not turn this one off: each
|
||||||
// "default to on but can be individually disabled". There is no setting
|
// filter is independent, and this is the one the README calls out as the
|
||||||
// for known-symbol verification, and filterTransactions applies it
|
// defense against the fake "ETH" attack.
|
||||||
// unconditionally, so it cannot be turned off.
|
test("the check still runs when the other three filters are off", () => {
|
||||||
test("current behaviour: spoof filtering cannot be disabled by any setting", () => {
|
|
||||||
const allFiltersOff = {
|
|
||||||
hideLowHolderTokens: false,
|
|
||||||
hideFraudContracts: false,
|
|
||||||
hideDustTransactions: false,
|
|
||||||
dustThresholdGwei: 1,
|
|
||||||
fraudContracts: [],
|
|
||||||
};
|
|
||||||
const result = filterTransactions(
|
const result = filterTransactions(
|
||||||
[fakeEthTokenTransfer()],
|
[fakeEthTokenTransfer()],
|
||||||
allFiltersOff,
|
filters({
|
||||||
|
hideLowHolderTokens: false,
|
||||||
|
hideFraudContracts: false,
|
||||||
|
hideDustTransactions: false,
|
||||||
|
dustThresholdGwei: 1,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
expect(result.transactions).toEqual([]);
|
expect(result.transactions).toEqual([]);
|
||||||
expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]);
|
expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("current behaviour: spoof filtering also applies with no filters argument", () => {
|
test("spoof filtering also applies with no filters argument", () => {
|
||||||
const result = filterTransactions([fakeEthTokenTransfer()]);
|
const result = filterTransactions([fakeEthTokenTransfer()]);
|
||||||
expect(result.transactions).toEqual([]);
|
expect(result.transactions).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fail-safe: unlike the other three flags, an absent hideSpoofedSymbols
|
||||||
|
// leaves the check ON. A caller that forgets the key keeps the wallet's
|
||||||
|
// headline protection; only a user who deliberately switched the setting
|
||||||
|
// off sends an explicit false.
|
||||||
|
test("an absent hideSpoofedSymbols leaves the check on", () => {
|
||||||
|
const result = filterTransactions([fakeEthTokenTransfer()], {
|
||||||
|
fraudContracts: [],
|
||||||
|
});
|
||||||
|
expect(result.transactions).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a truthy-but-not-true hideSpoofedSymbols leaves the check on", () => {
|
||||||
|
const result = filterTransactions(
|
||||||
|
[fakeEthTokenTransfer()],
|
||||||
|
filters({ hideSpoofedSymbols: undefined }),
|
||||||
|
);
|
||||||
|
expect(result.transactions).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("disabling known-symbol spoof verification", () => {
|
||||||
|
test("the spoofed transfer is shown when hideSpoofedSymbols is false", () => {
|
||||||
|
const attack = fakeEthTokenTransfer();
|
||||||
|
const result = filterTransactions(
|
||||||
|
[attack],
|
||||||
|
filters({
|
||||||
|
hideSpoofedSymbols: false,
|
||||||
|
// The blocklist rule would otherwise hide the same row via a
|
||||||
|
// contract this pass had already learned.
|
||||||
|
hideFraudContracts: false,
|
||||||
|
hideLowHolderTokens: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.transactions).toEqual([attack]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The blocklist is populated only by this check, so switching the check
|
||||||
|
// off stops the learning too. Leaving learning on would make the setting
|
||||||
|
// a no-op: the contract it recorded would immediately hide the same row
|
||||||
|
// through the fraud-contract rule, which is on by default.
|
||||||
|
test("no fraud contract is learned when hideSpoofedSymbols is false", () => {
|
||||||
|
const result = filterTransactions(
|
||||||
|
[fakeEthTokenTransfer()],
|
||||||
|
filters({ hideSpoofedSymbols: false }),
|
||||||
|
);
|
||||||
|
expect(result.newFraudContracts).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the setting off does not stop the other three rules", () => {
|
||||||
|
const dust = nativeDustTransfer();
|
||||||
|
const lowHolder = tokenTx({
|
||||||
|
symbol: NOVEL_SPAM_SYMBOL,
|
||||||
|
contractAddress: NOVEL_SPAM_CONTRACT,
|
||||||
|
holders: 0,
|
||||||
|
});
|
||||||
|
const result = filterTransactions(
|
||||||
|
[dust, lowHolder],
|
||||||
|
filters({ hideSpoofedSymbols: false }),
|
||||||
|
);
|
||||||
|
expect(result.transactions).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// An already-persisted fraud contract keeps being filtered: the blocklist
|
||||||
|
// rule is a separate setting and is unaffected by this one.
|
||||||
|
test("an already-blocklisted contract is still hidden with the check off", () => {
|
||||||
|
const result = filterTransactions(
|
||||||
|
[fakeEthTokenTransfer()],
|
||||||
|
filters({
|
||||||
|
hideSpoofedSymbols: false,
|
||||||
|
fraudContracts: [FAKE_ETH_CONTRACT],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.transactions).toEqual([]);
|
||||||
|
expect(result.newFraudContracts).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a genuine transfer is unaffected by the setting either way", () => {
|
||||||
|
const tx = tokenTx();
|
||||||
|
expect(
|
||||||
|
filterTransactions([tx], filters({ hideSpoofedSymbols: false }))
|
||||||
|
.transactions,
|
||||||
|
).toEqual([tx]);
|
||||||
|
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("low-holder token filtering (the 1,000-holder rule)", () => {
|
describe("low-holder token filtering (the 1,000-holder rule)", () => {
|
||||||
@@ -589,7 +672,8 @@ describe("dust threshold filtering", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("filter defaults promised by the README and Settings", () => {
|
describe("filter defaults promised by the README and Settings", () => {
|
||||||
test("all three toggles default to on and the threshold to 100,000 gwei", () => {
|
test("all four toggles default to on and the threshold to 100,000 gwei", () => {
|
||||||
|
expect(state.hideSpoofedSymbols).toBe(true);
|
||||||
expect(state.hideLowHolderTokens).toBe(true);
|
expect(state.hideLowHolderTokens).toBe(true);
|
||||||
expect(state.hideFraudContracts).toBe(true);
|
expect(state.hideFraudContracts).toBe(true);
|
||||||
expect(state.hideDustTransactions).toBe(true);
|
expect(state.hideDustTransactions).toBe(true);
|
||||||
@@ -600,10 +684,10 @@ describe("filter defaults promised by the README and Settings", () => {
|
|||||||
expect(state.fraudContracts).toEqual([]);
|
expect(state.fraudContracts).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Documents current behaviour: filterTransactions itself defaults every
|
// Documents current behaviour: filterTransactions defaults the other three
|
||||||
// optional filter to off. The "default to on" promise is satisfied by
|
// optional filters to off. Their "default to on" promise is satisfied by
|
||||||
// the state defaults above, which every caller passes in; the pure
|
// the state defaults above, which every caller passes in. Spoof
|
||||||
// function makes no assumption of its own.
|
// verification is the exception and stays on unless explicitly disabled.
|
||||||
test("current behaviour: with no filters argument only spoof filtering runs", () => {
|
test("current behaviour: with no filters argument only spoof filtering runs", () => {
|
||||||
const dust = nativeDustTransfer();
|
const dust = nativeDustTransfer();
|
||||||
const lowHolder = tokenTx({
|
const lowHolder = tokenTx({
|
||||||
|
|||||||
346
tests/vault.test.js
Normal file
346
tests/vault.test.js
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user