diff --git a/.changeset/canonical-mnemonic-phrase.md b/.changeset/canonical-mnemonic-phrase.md new file mode 100644 index 0000000..ed8e184 --- /dev/null +++ b/.changeset/canonical-mnemonic-phrase.md @@ -0,0 +1,5 @@ +--- +"@swapkit/wallets": patch +--- + +The keystore wallet now cleans up a seed phrase before deriving any key, using `canonicalizeMnemonic` from `@swapkit/helpers`. A phrase pasted with tabs, repeated spaces, non-breaking spaces or a trailing newline connects to the same addresses as the clean phrase, a phrase with a bad checksum is rejected with `wallet_keystore_invalid_phrase`, and `validatePhrase` follows the same rules. Clean phrases are unaffected. diff --git a/packages/wallets/src/keystore-helpers.ts b/packages/wallets/src/keystore-helpers.ts index 1bde34b..1d8eea8 100644 --- a/packages/wallets/src/keystore-helpers.ts +++ b/packages/wallets/src/keystore-helpers.ts @@ -1,7 +1,7 @@ import { createCipheriv, createDecipheriv, pbkdf2Sync, randomBytes } from "node:crypto"; -import { generateMnemonic, validateMnemonic } from "@scure/bip39"; +import { generateMnemonic } from "@scure/bip39"; import { wordlist } from "@scure/bip39/wordlists/english.js"; -import { SwapKitError } from "@swapkit/helpers"; +import { canonicalizeMnemonic, SwapKitError } from "@swapkit/helpers"; export type PhraseWordCount = 12 | 15 | 18 | 21 | 24; @@ -77,7 +77,12 @@ export async function generateKeystore({ } export function validatePhrase(phrase: string) { - return validateMnemonic(phrase, wordlist); + try { + canonicalizeMnemonic(phrase); + return true; + } catch { + return false; + } } export async function decryptFromKeystore(keystore: Keystore, password: string) { diff --git a/packages/wallets/src/keystore.ts b/packages/wallets/src/keystore.ts index 39a6157..287359f 100644 --- a/packages/wallets/src/keystore.ts +++ b/packages/wallets/src/keystore.ts @@ -3,6 +3,7 @@ import { type AssetValue, Chain, CosmosChains, + canonicalizeMnemonic, type DerivationPathArray, EVMChains, filterSupportedChains, @@ -209,6 +210,7 @@ export async function createKeystoreWallet({ supportedChains: KEYSTORE_SUPPORTED_CHAINS, walletType: WalletOption.KEYSTORE, }); + const canonicalPhrase = canonicalizeMnemonic(phrase); const walletIndex = typeof derivationPathMapOrIndex === "number" ? derivationPathMapOrIndex : 0; const walletResults = await Promise.allSettled( @@ -241,7 +243,7 @@ export async function createKeystoreWallet({ const toolbox = await getToolbox(chain, { derivationPath, - phrase, + phrase: canonicalPhrase, ...(resolvedScriptType ? { scriptType: resolvedScriptType } : {}), }); const address = (await toolbox.getAddress()) || ""; diff --git a/packages/wallets/test/keystore.test.ts b/packages/wallets/test/keystore.test.ts index 14cafa0..245df89 100644 --- a/packages/wallets/test/keystore.test.ts +++ b/packages/wallets/test/keystore.test.ts @@ -93,3 +93,28 @@ describe("keystore phrase generation", () => { expect(await decryptFromKeystore(keystore, "password")).toBe(phrase); }); }); + +describe("keystore phrase canonicalisation", () => { + test("a phrase with stray whitespace connects to the same addresses as the clean phrase", async () => { + const messy = `\n${TEST_PHRASE.split(" ").join("\t ")}\u00a0\r\n`; + const chains = [Chain.Bitcoin, Chain.Ethereum, Chain.Solana]; + + const clean = await createKeystoreWallet({ chains, phrase: TEST_PHRASE }); + const dirty = await createKeystoreWallet({ chains, phrase: messy }); + + for (const chain of chains) { + expect(clean[chain]?.address).toBeTruthy(); + expect(`${chain}: ${dirty[chain]?.address}`).toBe(`${chain}: ${clean[chain]?.address}`); + } + expect(validatePhrase(messy)).toBe(true); + }); + + test("a phrase with a bad checksum is rejected before any chain connects", async () => { + const badChecksum = TEST_PHRASE.replace("about", "abandon"); + + expect(validatePhrase(badChecksum)).toBe(false); + await expect(createKeystoreWallet({ chains: [Chain.Ethereum], phrase: badChecksum })).rejects.toThrow( + "wallet_keystore_invalid_phrase", + ); + }); +});