Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/canonical-mnemonic-phrase.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 8 additions & 3 deletions packages/wallets/src/keystore-helpers.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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) {
Expand Down
4 changes: 3 additions & 1 deletion packages/wallets/src/keystore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type AssetValue,
Chain,
CosmosChains,
canonicalizeMnemonic,
type DerivationPathArray,
EVMChains,
filterSupportedChains,
Expand Down Expand Up @@ -209,6 +210,7 @@ export async function createKeystoreWallet<T extends Chain[]>({
supportedChains: KEYSTORE_SUPPORTED_CHAINS,
walletType: WalletOption.KEYSTORE,
});
const canonicalPhrase = canonicalizeMnemonic(phrase);

const walletIndex = typeof derivationPathMapOrIndex === "number" ? derivationPathMapOrIndex : 0;
const walletResults = await Promise.allSettled(
Expand Down Expand Up @@ -241,7 +243,7 @@ export async function createKeystoreWallet<T extends Chain[]>({

const toolbox = await getToolbox(chain, {
derivationPath,
phrase,
phrase: canonicalPhrase,
...(resolvedScriptType ? { scriptType: resolvedScriptType } : {}),
});
const address = (await toolbox.getAddress()) || "";
Expand Down
25 changes: 25 additions & 0 deletions packages/wallets/test/keystore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
});
});
Loading