test: known-answer coverage for HD derivation and the vault (closes #159)
Some checks failed
check / check (push) Has been cancelled

wallet.js and vault.js — the two modules that hold user funds — had no
derivation or encryption tests. Add them, pinned to published vectors
rather than to whatever the implementation returns today.

wallet.js: hdWalletFromMnemonic, hdWalletFromXprv, deriveAddressFromXpub
and getSignerForAddress are pinned to the standard development recovery
phrase's first three accounts at m/44'/60'/0'/0/n and to the BIP-39
all-zero-entropy phrase's first address; addressFromPrivateKey is pinned
to the published key/address pairs, so the HD path and the bare-key path
must meet at the same address from two directions. isValidMnemonic and
isValidXprv cover bad checksum, wrong word count, wrong key type and
empty/garbage input. The absolute-vs-relative path asymmetry between
hdWalletFromMnemonic and hdWalletFromXprv is proven harmless: for the
same master key both reach the same xpub and the same addresses.

vault.js: round trip (including non-ASCII and an empty password), wrong
password rejected as a rejected promise with no partial plaintext,
tampered ciphertext / auth tag / nonce / salt rejected, truncated and
spliced blobs rejected, missing fields rejected, fresh salt and nonce per
encryption, the documented { salt, nonce, ciphertext } shape, and no
trace of the plaintext or password anywhere in the serialized blob. The
Argon2id cost parameters are pinned three ways — the INTERACTIVE
constants still mean 2 passes over 64 MiB, a key independently derived at
that cost opens the vault, and both encrypt and decrypt are observed
calling crypto_pwhash with those constants — because the KDF cost is the
vault's only defence against offline attack on a stolen blob and nothing
else in the suite would notice it being lowered. The tamper cases share
one encrypted fixture to stay inside script/test's 30-second budget.

One test is skipped: isValidXprv accepts an extended private key with a
one-character typo, because ethers skips base58 checksum verification for
the usual 82-byte payload. That defect is tracked separately and is not
fixed here; the skipped test asserts the correct behaviour and cites the
issue.
This commit is contained in:
2026-08-11 12:27:59 +00:00
committed by clawbot
parent cf5f582be9
commit 2dcea6c306
4 changed files with 668 additions and 3 deletions

View File

@@ -1173,8 +1173,8 @@ Currently supported:
### Testing
- [ ] Tests for mnemonic generation and address derivation
- [ ] Tests for xpub derivation and child address generation
- [x] Tests for mnemonic generation and address derivation
- [x] Tests for xpub derivation and child address generation
- [ ] Test on Firefox (Manifest V2)
### Scam List

View File

@@ -44,6 +44,9 @@ undefined identifiers, which is how
# Completed Steps
- 2026-08-11: Known-answer test coverage for the crypto core — BIP-39/BIP-32
derivation in `wallet.js` and the Argon2id vault in `vault.js`
([#159](https://git.eeqj.de/sneak/AutistMask/issues/159)).
- 2026-08-11: Three `README.md` claims corrected against the code — blocklist
attribution, token-display rule, navigation model
([#213](https://git.eeqj.de/sneak/AutistMask/issues/213)).

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

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

View File

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