Skip to content

Commit dc745f1

Browse files
authored
fix: enforce mnemonic validation (#450)
The `HdKeyring` class accepts mnemonics during deserialization without validating that they are valid BIP39 mnemonics. This means invalid mnemonics (words not in the BIP39 wordlist or invalid checksums) could be passed in and would fail later in the key derivation process with unclear error messages. This PR adds a `isValidMnemonic` private method that validates mnemonics using `validateMnemonic` from `@metamask/scure-bip39` before processing them. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Adds strict BIP39 validation during `deserialize`/mnemonic initialization, which can cause previously-accepted (but invalid) secret recovery phrases to fail earlier and may impact restoration flows. > > **Overview** > `HdKeyring` now validates provided mnemonics against the BIP39 english wordlist and checksum (via `validateMnemonic`) before deriving the seed, throwing `Eth-Hd-Keyring: Invalid secret recovery phrase provided` on failure. > > Tests were expanded to cover invalid word counts, invalid words, invalid checksums, and mnemonic inputs in `Buffer`/`Uint8Array`/serialized forms; the changelog notes the new enforcement. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 5075bae. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent f4f52c8 commit dc745f1

File tree

3 files changed

+134
-10
lines changed

3 files changed

+134
-10
lines changed

packages/keyring-eth-hd/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
- Wraps legacy `HdKeyring` to expose accounts via the unified `KeyringV2` API and the `KeyringAccount` type.
1414
- Extends `EthKeyringWrapper` for common Ethereum logic.
1515

16+
### Fixed
17+
18+
- Enforce mnemonics validation ([#450](https://github.com/MetaMask/accounts/pull/450))
19+
- Validates mnemonics against BIP39 specification (word count, wordlist, checksum) before use.
20+
- Throws for invalid mnemonics.
21+
1622
## [13.0.0]
1723

1824
### Added

packages/keyring-eth-hd/src/hd-keyring.test.ts

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ describe('hd-keyring', () => {
250250
expect(cryptographicFunctions.pbkdf2Sha512).toHaveBeenCalledTimes(1);
251251
});
252252

253-
it('throws on invalid mnemonic', async () => {
253+
it('throws on invalid mnemonic with wrong number of words', async () => {
254254
const keyring = new HdKeyring();
255255

256256
await expect(
@@ -259,10 +259,110 @@ describe('hd-keyring', () => {
259259
numberOfAccounts: 2,
260260
}),
261261
).rejects.toThrow(
262-
'Invalid mnemonic phrase: The mnemonic phrase must consist of 12, 15, 18, 21, or 24 words.',
262+
'Eth-Hd-Keyring: Invalid secret recovery phrase provided',
263263
);
264264
});
265265

266+
it('throws on mnemonic with invalid words', async () => {
267+
const keyring = new HdKeyring();
268+
269+
// 12 words but invalid (not in BIP39 wordlist)
270+
await expect(
271+
keyring.deserialize({
272+
mnemonic:
273+
'invalid words that are not in the bip39 wordlist at all here',
274+
numberOfAccounts: 1,
275+
}),
276+
).rejects.toThrow(
277+
'Eth-Hd-Keyring: Invalid secret recovery phrase provided',
278+
);
279+
});
280+
281+
it('throws on mnemonic with invalid checksum', async () => {
282+
const keyring = new HdKeyring();
283+
284+
// Valid BIP39 words but invalid checksum (last word changed from 'mango' to 'abandon')
285+
await expect(
286+
keyring.deserialize({
287+
mnemonic:
288+
'finish oppose decorate face calm tragic certain desk hour urge dinosaur abandon',
289+
numberOfAccounts: 1,
290+
}),
291+
).rejects.toThrow(
292+
'Eth-Hd-Keyring: Invalid secret recovery phrase provided',
293+
);
294+
});
295+
296+
it('throws on invalid mnemonic passed as Buffer', async () => {
297+
const keyring = new HdKeyring();
298+
299+
// Invalid mnemonic as raw Buffer
300+
await expect(
301+
keyring.deserialize({
302+
// @ts-expect-error testing Buffer mnemonic directly
303+
mnemonic: Buffer.from('invalid mnemonic phrase here', 'utf8'),
304+
numberOfAccounts: 1,
305+
}),
306+
).rejects.toThrow(
307+
'Eth-Hd-Keyring: Invalid secret recovery phrase provided',
308+
);
309+
});
310+
311+
it('deserializes valid mnemonic passed as Buffer', async () => {
312+
const keyring = new HdKeyring();
313+
314+
await keyring.deserialize({
315+
// @ts-expect-error testing Buffer mnemonic directly
316+
mnemonic: Buffer.from(sampleMnemonic, 'utf8'),
317+
numberOfAccounts: 1,
318+
});
319+
320+
const accounts = await keyring.getAccounts();
321+
expect(accounts[0]).toStrictEqual(firstAcct);
322+
});
323+
324+
it('validates mnemonic passed as Uint8Array with valid mnemonic', async () => {
325+
const keyring = new HdKeyring();
326+
327+
// Serialize a valid keyring to get the Uint8Array format
328+
const tempKeyring = new HdKeyring();
329+
await tempKeyring.deserialize({ mnemonic: sampleMnemonic });
330+
const { mnemonic } = tempKeyring;
331+
assert(mnemonic, 'Mnemonic should be defined');
332+
333+
await keyring.deserialize({
334+
// @ts-expect-error testing Uint8Array mnemonic directly
335+
mnemonic,
336+
numberOfAccounts: 1,
337+
});
338+
339+
const accounts = await keyring.getAccounts();
340+
expect(accounts[0]).toStrictEqual(firstAcct);
341+
});
342+
343+
it('validates mnemonic passed as plain object (simulating encryption/decryption cycle)', async () => {
344+
const keyring = new HdKeyring();
345+
346+
const tempKeyring = new HdKeyring();
347+
await tempKeyring.deserialize({ mnemonic: sampleMnemonic });
348+
const { mnemonic } = tempKeyring;
349+
assert(mnemonic, 'Mnemonic should be defined');
350+
351+
const mnemonicAsPlainObject: Record<string, number> = {};
352+
mnemonic.forEach((value, index) => {
353+
mnemonicAsPlainObject[index] = value;
354+
});
355+
356+
await keyring.deserialize({
357+
// @ts-expect-error testing plain object mnemonic directly
358+
mnemonic: mnemonicAsPlainObject,
359+
numberOfAccounts: 1,
360+
});
361+
362+
const accounts = await keyring.getAccounts();
363+
expect(accounts[0]).toStrictEqual(firstAcct);
364+
});
365+
266366
it('throws when numberOfAccounts is passed with no mnemonic', async () => {
267367
const keyring = new HdKeyring();
268368

packages/keyring-eth-hd/src/hd-keyring.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
mnemonicToSeed,
2121
} from '@metamask/key-tree';
2222
import type { Keyring } from '@metamask/keyring-utils';
23-
import { generateMnemonic } from '@metamask/scure-bip39';
23+
import { generateMnemonic, validateMnemonic } from '@metamask/scure-bip39';
2424
import { wordlist } from '@metamask/scure-bip39/dist/wordlists/english';
2525
import {
2626
add0x,
@@ -38,6 +38,8 @@ import { keccak256 } from 'ethereum-cryptography/keccak';
3838
const hdPathString = `m/44'/60'/0'/0`;
3939
const type = 'HD Key Tree';
4040

41+
type Mnemonic = string | number[] | SerializedBuffer | Buffer | Uint8Array;
42+
4143
export type HDKeyringOptions = {
4244
cryptographicFunctions?: CryptographicFunctions;
4345
};
@@ -481,9 +483,7 @@ export class HdKeyring implements Keyring {
481483
* @param mnemonic - The mnemonic seed phrase.
482484
* @returns The Uint8Array mnemonic.
483485
*/
484-
#mnemonicToUint8Array(
485-
mnemonic: Buffer | SerializedBuffer | string | Uint8Array | number[],
486-
): Uint8Array {
486+
#mnemonicToUint8Array(mnemonic: Mnemonic): Uint8Array {
487487
let mnemonicData: unknown = mnemonic;
488488
// When using `Buffer.toJSON()`, the Buffer is serialized into an object
489489
// with the structure `{ type: 'Buffer', data: [...] }`
@@ -601,16 +601,18 @@ export class HdKeyring implements Keyring {
601601
* as a string, an array of UTF-8 bytes, or a Buffer. Mnemonic input
602602
* passed as type buffer or array of UTF-8 bytes must be NFKD normalized.
603603
*/
604-
async #initFromMnemonic(
605-
mnemonic: string | number[] | SerializedBuffer | Buffer | Uint8Array,
606-
): Promise<void> {
604+
async #initFromMnemonic(mnemonic: Mnemonic): Promise<void> {
607605
if (this.root) {
608606
throw new Error(
609607
'Eth-Hd-Keyring: Secret recovery phrase already provided',
610608
);
611609
}
612610

613-
this.mnemonic = this.#mnemonicToUint8Array(mnemonic);
611+
// Convert and validate before assigning to instance property
612+
// to avoid inconsistent state if validation fails
613+
const mnemonicAsUint8Array = this.#mnemonicToUint8Array(mnemonic);
614+
this.#assertValidMnemonic(mnemonicAsUint8Array);
615+
this.mnemonic = mnemonicAsUint8Array;
614616

615617
this.seed = await mnemonicToSeed(
616618
this.mnemonic,
@@ -644,4 +646,20 @@ export class HdKeyring implements Keyring {
644646
assert(normalized, 'Expected address to be set');
645647
return add0x(normalized);
646648
}
649+
650+
/**
651+
* Assert that the mnemonic seed phrase is valid.
652+
* Throws an error if the mnemonic is not a valid BIP39 phrase.
653+
*
654+
* @param mnemonic - The mnemonic seed phrase to validate (as Uint8Array).
655+
* @throws If the mnemonic is not a valid BIP39 secret recovery phrase.
656+
*/
657+
#assertValidMnemonic(mnemonic: Uint8Array): void {
658+
const mnemonicString = this.#uint8ArrayToString(mnemonic);
659+
if (!validateMnemonic(mnemonicString, wordlist)) {
660+
throw new Error(
661+
'Eth-Hd-Keyring: Invalid secret recovery phrase provided',
662+
);
663+
}
664+
}
647665
}

0 commit comments

Comments
 (0)