From 22cf2f43f5713bc2a2798bc02bba1b26971ffad3 Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 11 Aug 2026 12:27:59 +0000 Subject: [PATCH] test: known-answer coverage for HD derivation and the vault (closes #159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 4 +- TODO.md | 3 + tests/vault.test.js | 346 +++++++++++++++++++++++++++++++++++++++++++ tests/wallet.test.js | 318 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 668 insertions(+), 3 deletions(-) create mode 100644 tests/vault.test.js diff --git a/README.md b/README.md index da2d16e..956769b 100644 --- a/README.md +++ b/README.md @@ -1189,8 +1189,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 diff --git a/TODO.md b/TODO.md index 680d63b..08910e2 100644 --- a/TODO.md +++ b/TODO.md @@ -54,6 +54,9 @@ undefined identifiers, which is how lives only in `build.js`, and the unlisted-bundle scan hard-fails when it cannot enumerate `dist/` ([#180](https://git.eeqj.de/sneak/AutistMask/issues/180)). +- 2026-08-11: Known-answer test coverage for the crypto core — BIP-39/BIP-32 + derivation in `wallet.js` and the Argon2id vault in `vault.js` + ([#159](https://git.eeqj.de/sneak/AutistMask/issues/159)). - 2026-08-11: Three `README.md` claims corrected against the code — blocklist attribution, token-display rule, navigation model ([#213](https://git.eeqj.de/sneak/AutistMask/issues/213)). diff --git a/tests/vault.test.js b/tests/vault.test.js new file mode 100644 index 0000000..a81a6d8 --- /dev/null +++ b/tests/vault.test.js @@ -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(); + } + }); +}); diff --git a/tests/wallet.test.js b/tests/wallet.test.js index 620bd19..9054552 100644 --- a/tests/wallet.test.js +++ b/tests/wallet.test.js @@ -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]); + }); +});