diff --git a/CHANGELOG.md b/CHANGELOG.md index d7f4ffe3..8b5bbb11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `uint8ArrayToMnemonic` and `convertMnemonicToWordlistIndices` utilities ([#300](https://github.com/MetaMask/utils/pull/300)) + - `uint8ArrayToMnemonic` converts little-endian `Uint16` English BIP-39 wordlist indices into a mnemonic string. + - `convertMnemonicToWordlistIndices` converts a mnemonic string into little-endian `Uint16` wordlist indices encoded as a `Uint8Array`. +- Add `@metamask/scure-bip39` as a dependency ([#300](https://github.com/MetaMask/utils/pull/300)) + ## [11.11.0] ### Added diff --git a/package.json b/package.json index 34392dad..15028861 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ }, "dependencies": { "@ethereumjs/tx": "^4.2.0", + "@metamask/scure-bip39": "^2.0.3", "@metamask/superstruct": "^3.1.0", "@noble/hashes": "^1.3.1", "@scure/base": "^1.1.3", diff --git a/src/index.test.ts b/src/index.test.ts index 6204787f..f4e41bf3 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -81,6 +81,7 @@ describe('index', () => { "calculateNumberSize", "calculateStringSize", "concatBytes", + "convertMnemonicToWordlistIndices", "createBigInt", "createBytes", "createDataView", @@ -162,6 +163,7 @@ describe('index', () => { "toCaipAssetType", "toCaipChainId", "toWei", + "uint8ArrayToMnemonic", "unitMap", "valueToBytes", "wrapError", diff --git a/src/index.ts b/src/index.ts index 88ff8e79..689aad08 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,7 @@ export * from './json'; export * from './keyring'; export * from './logging'; export * from './misc'; +export * from './mnemonic'; export * from './number'; export * from './opaque'; export * from './promise'; diff --git a/src/mnemonic.test.ts b/src/mnemonic.test.ts new file mode 100644 index 00000000..e42413aa --- /dev/null +++ b/src/mnemonic.test.ts @@ -0,0 +1,91 @@ +import { + convertMnemonicToWordlistIndices, + uint8ArrayToMnemonic, +} from './mnemonic'; + +const TWELVE_WORD_MNEMONIC = + 'bulk riot robust reward museum path chunk health rate soon zone wagon'; +const TWELVE_WORD_INDICES = [ + 240, 1489, 1498, 1477, 1165, 1287, 325, 849, 1425, 1658, 2046, 1970, +]; + +const TWENTY_FOUR_WORD_MNEMONIC = + 'abuse boss fly battle rubber wasp afraid hamster guide essence vibrant task banana pencil owner cube social job emotion member joy sting dash trouble'; +const TWENTY_FOUR_WORD_INDICES = [ + 9, 209, 719, 154, 1510, 1980, 36, 837, 828, 618, 1947, 1776, 145, 1301, 1265, + 427, 1647, 960, 582, 1110, 964, 1711, 445, 1864, +]; + +/** + * Encode BIP-39 word indices as little-endian Uint16 bytes. + * + * @param indices - Wordlist indices to encode. + * @returns The encoded bytes. + */ +function indicesToBytes(indices: number[]): Uint8Array { + return new Uint8Array(new Uint16Array(indices).buffer); +} + +describe('uint8ArrayToMnemonic', () => { + it('throws for an empty array', () => { + expect(() => uint8ArrayToMnemonic(new Uint8Array())).toThrow( + 'The method uint8ArrayToMnemonic expects a non-empty array', + ); + }); + + it('returns a 12-word mnemonic', () => { + expect(uint8ArrayToMnemonic(indicesToBytes(TWELVE_WORD_INDICES))).toBe( + TWELVE_WORD_MNEMONIC, + ); + }); + + it('returns a 24-word mnemonic', () => { + expect(uint8ArrayToMnemonic(indicesToBytes(TWENTY_FOUR_WORD_INDICES))).toBe( + TWENTY_FOUR_WORD_MNEMONIC, + ); + }); + + it('interprets bytes as little-endian word indices', () => { + expect( + uint8ArrayToMnemonic( + new Uint8Array([ + 240, 0, 209, 5, 218, 5, 197, 5, 141, 4, 7, 5, 69, 1, 81, 3, 145, 5, + 122, 6, 254, 7, 178, 7, + ]), + ), + ).toBe(TWELVE_WORD_MNEMONIC); + }); +}); + +describe('convertMnemonicToWordlistIndices', () => { + it('converts a 12-word mnemonic', () => { + expect( + convertMnemonicToWordlistIndices(TWELVE_WORD_MNEMONIC), + ).toStrictEqual(indicesToBytes(TWELVE_WORD_INDICES)); + }); + + it('converts a 24-word mnemonic', () => { + expect( + convertMnemonicToWordlistIndices(TWENTY_FOUR_WORD_MNEMONIC), + ).toStrictEqual(indicesToBytes(TWENTY_FOUR_WORD_INDICES)); + }); + + it('encodes an unknown word as the Uint16 representation of -1', () => { + const mnemonicWithInvalidWord = + 'bulk riot robust reward notaword path chunk health rate soon zone wagon'; + const result = convertMnemonicToWordlistIndices(mnemonicWithInvalidWord); + const indices = new Uint16Array(result.buffer); + + expect(Array.from(indices)).toStrictEqual([ + 240, 1489, 1498, 1477, 65535, 1287, 325, 849, 1425, 1658, 2046, 1970, + ]); + }); + + it('round-trips with uint8ArrayToMnemonic', () => { + expect( + uint8ArrayToMnemonic( + convertMnemonicToWordlistIndices(TWELVE_WORD_MNEMONIC), + ), + ).toBe(TWELVE_WORD_MNEMONIC); + }); +}); diff --git a/src/mnemonic.ts b/src/mnemonic.ts new file mode 100644 index 00000000..16815344 --- /dev/null +++ b/src/mnemonic.ts @@ -0,0 +1,31 @@ +import { wordlist } from '@metamask/scure-bip39/dist/wordlists/english'; + +/** + * Transform a typed array containing mnemonic data to the seed phrase. + * @param uint8Array - Typed array containing mnemonic data. + * @returns The seed phrase. + */ +export function uint8ArrayToMnemonic(uint8Array: Uint8Array): string { + if (uint8Array.length === 0) { + throw new Error( + 'The method uint8ArrayToMnemonic expects a non-empty array', + ); + } + + const recoveredIndices = Array.from( + new Uint16Array(new Uint8Array(uint8Array).buffer), + ); + + return recoveredIndices.map((i) => wordlist[i]).join(' '); +} + +/** + * Encodes a BIP-39 mnemonic as the indices of words in the English BIP-39 wordlist. + * + * @param mnemonic - The BIP-39 mnemonic. + * @returns The Unicode code points for the seed phrase formed from the words in the wordlist. + */ +export function convertMnemonicToWordlistIndices(mnemonic: string): Uint8Array { + const indices = mnemonic.split(' ').map((word) => wordlist.indexOf(word)); + return new Uint8Array(new Uint16Array(indices).buffer); +} diff --git a/src/node.test.ts b/src/node.test.ts index c3b927fd..8926d905 100644 --- a/src/node.test.ts +++ b/src/node.test.ts @@ -81,6 +81,7 @@ describe('node', () => { "calculateNumberSize", "calculateStringSize", "concatBytes", + "convertMnemonicToWordlistIndices", "createBigInt", "createBytes", "createDataView", @@ -169,6 +170,7 @@ describe('node', () => { "toCaipAssetType", "toCaipChainId", "toWei", + "uint8ArrayToMnemonic", "unitMap", "valueToBytes", "wrapError", diff --git a/yarn.lock b/yarn.lock index b6667f51..621cc84c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1070,6 +1070,16 @@ __metadata: languageName: node linkType: hard +"@metamask/scure-bip39@npm:^2.0.3": + version: 2.1.1 + resolution: "@metamask/scure-bip39@npm:2.1.1" + dependencies: + "@noble/hashes": "npm:~1.3.2" + "@scure/base": "npm:~1.1.3" + checksum: 10/785d75e52f85103af7556c25d3dbba6da52ee9e61c31e95786bfbab0e18b9899a367bf714c71dd311c04161c45ba14354bd6fb4fbeb42cb26b7bd2f35c582f73 + languageName: node + linkType: hard + "@metamask/superstruct@npm:^3.1.0": version: 3.1.0 resolution: "@metamask/superstruct@npm:3.1.0" @@ -1089,6 +1099,7 @@ __metadata: "@metamask/eslint-config-jest": "npm:^12.0.0" "@metamask/eslint-config-nodejs": "npm:^12.0.0" "@metamask/eslint-config-typescript": "npm:^12.0.0" + "@metamask/scure-bip39": "npm:^2.0.3" "@metamask/superstruct": "npm:^3.1.0" "@noble/hashes": "npm:^1.3.1" "@scure/base": "npm:^1.1.3" @@ -1140,13 +1151,20 @@ __metadata: languageName: node linkType: hard -"@noble/hashes@npm:1.3.1, @noble/hashes@npm:^1.3.1, @noble/hashes@npm:~1.3.0, @noble/hashes@npm:~1.3.1": +"@noble/hashes@npm:1.3.1": version: 1.3.1 resolution: "@noble/hashes@npm:1.3.1" checksum: 10/39474bab7e7813dbbfd8750476f48046d3004984e161fcd4333e40ca823f07b069010b35a20246e5b4ac20858e29913172a4d69720fd1e93620f7bedb70f9b72 languageName: node linkType: hard +"@noble/hashes@npm:^1.3.1, @noble/hashes@npm:~1.3.0, @noble/hashes@npm:~1.3.1, @noble/hashes@npm:~1.3.2": + version: 1.3.3 + resolution: "@noble/hashes@npm:1.3.3" + checksum: 10/1025ddde4d24630e95c0818e63d2d54ee131b980fe113312d17ed7468bc18f54486ac86c907685759f8a7e13c2f9b9e83ec7b67d1cc20836f36b5e4a65bb102d + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -1297,10 +1315,10 @@ __metadata: languageName: node linkType: hard -"@scure/base@npm:^1.1.3, @scure/base@npm:~1.1.0": - version: 1.1.3 - resolution: "@scure/base@npm:1.1.3" - checksum: 10/cb715fa8cdb043c4d96b6ba0666791d4eb4d033f7b5285a853aba25e0ba94914f05ff5d956029ad060005f9bdd02dab0caef9a0a63f07ed096a2c2a0c0cf9c36 +"@scure/base@npm:^1.1.3, @scure/base@npm:~1.1.0, @scure/base@npm:~1.1.3": + version: 1.1.9 + resolution: "@scure/base@npm:1.1.9" + checksum: 10/f0ab7f687bbcdee2a01377fe3cd808bf63977999672751295b6a92625d5322f4754a96d40f6bd579bc367aad48ecf8a4e6d0390e70296e6ded1076f52adb16bb languageName: node linkType: hard