diff --git a/README.md b/README.md index 7c10480..f92dbb1 100644 --- a/README.md +++ b/README.md @@ -990,8 +990,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 c210509..d8d4746 100644 --- a/TODO.md +++ b/TODO.md @@ -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: `docs/README.md` rewritten against the code: no competitor names, all five network destinations documented, password/Settings/Add Wallet sections corrected ([#163](https://git.eeqj.de/sneak/AutistMask/issues/163)). diff --git a/tests/vault.test.js b/tests/vault.test.js new file mode 100644 index 0000000..d32205e --- /dev/null +++ b/tests/vault.test.js @@ -0,0 +1,263 @@ +// 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; 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("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..af34b52 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,318 @@ 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. + // Reported on the pull request for + // https://git.eeqj.de/sneak/AutistMask/issues/159 to be filed as its own + // issue; 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]); + }); +});