Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Accept BIP-39 mnemonic phrase as Uint8Array #107

Merged
merged 4 commits into from
Jan 18, 2023
Merged
Show file tree
Hide file tree
Changes from 2 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
30 changes: 30 additions & 0 deletions src/BIP44CoinTypeNode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ import {
} from '.';
import fixtures from '../test/fixtures';
import { encodeExtendedKey, PRIVATE_KEY_VERSION } from './extended-keys';
import { mnemonicPhraseToBytes } from './utils';

const defaultBip39NodeToken = `bip39:${fixtures.local.mnemonic}` as const;
const defaultBip39BytesToken = mnemonicPhraseToBytes(fixtures.local.mnemonic);

describe('BIP44CoinTypeNode', () => {
describe('fromJSON', () => {
Expand Down Expand Up @@ -221,6 +223,34 @@ describe('BIP44CoinTypeNode', () => {
});
});

it('initializes a BIP44CoinTypeNode with a Uint8Array', async () => {
Mrtenz marked this conversation as resolved.
Show resolved Hide resolved
const node = await BIP44CoinTypeNode.fromDerivationPath([
defaultBip39BytesToken,
BIP44PurposeNodeToken,
`bip32:60'`,
]);
const coinType = 60;
const pathString = `m / bip32:44' / bip32:${coinType}'`;

expect(node.coin_type).toStrictEqual(coinType);
expect(node.depth).toBe(2);
expect(node.privateKeyBytes).toHaveLength(32);
expect(node.publicKeyBytes).toHaveLength(65);
expect(node.path).toStrictEqual(pathString);

expect(node.toJSON()).toStrictEqual({
coin_type: coinType,
depth: node.depth,
masterFingerprint: node.masterFingerprint,
parentFingerprint: node.parentFingerprint,
index: node.index,
path: pathString,
privateKey: node.privateKey,
publicKey: node.publicKey,
chainCode: node.chainCode,
});
});

it('throws if derivation path has invalid depth', async () => {
await expect(
BIP44CoinTypeNode.fromDerivationPath([
Expand Down
24 changes: 23 additions & 1 deletion src/BIP44Node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ import {
PRIVATE_KEY_VERSION,
PUBLIC_KEY_VERSION,
} from './extended-keys';
import { hexStringToBytes } from './utils';
import { hexStringToBytes, mnemonicPhraseToBytes } from './utils';

const defaultBip39NodeToken = `bip39:${fixtures.local.mnemonic}` as const;
const defaultBip39BytesToken = mnemonicPhraseToBytes(fixtures.local.mnemonic);

describe('BIP44Node', () => {
describe('fromExtendedKey', () => {
Expand Down Expand Up @@ -122,6 +123,27 @@ describe('BIP44Node', () => {
});
});

it('initializes a new node from a derivation path with a Uint8Array', async () => {
Mrtenz marked this conversation as resolved.
Show resolved Hide resolved
const node = await BIP44Node.fromDerivationPath({
derivationPath: [
defaultBip39BytesToken,
BIP44PurposeNodeToken,
`bip32:60'`,
],
});

expect(node.depth).toBe(2);
expect(node.toJSON()).toStrictEqual({
depth: node.depth,
masterFingerprint: node.masterFingerprint,
parentFingerprint: node.parentFingerprint,
index: node.index,
privateKey: node.privateKey,
publicKey: node.publicKey,
chainCode: node.chainCode,
});
});

it('throws an error if attempting to modify the fields of a node', async () => {
const node: any = await BIP44Node.fromDerivationPath({
derivationPath: [
Expand Down
25 changes: 17 additions & 8 deletions src/BIP44Node.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { assert } from '@metamask/utils';

import {
BIP44Depth,
BIP44PurposeNodeToken,
Expand Down Expand Up @@ -407,16 +409,23 @@ function validateBIP44DerivationPath(
path.forEach((nodeToken, index) => {
const currentDepth = startingDepth + index;

if (currentDepth === MIN_BIP_44_DEPTH) {
if (
!(nodeToken instanceof Uint8Array) &&
!BIP_39_PATH_REGEX.test(nodeToken)
) {
throw new Error(
'Invalid derivation path: The "m" / seed node (depth 0) must be a BIP-39 node.',
);
}

return;
}

assert(typeof nodeToken === 'string');

// eslint-disable-next-line default-case
switch (currentDepth) {
case MIN_BIP_44_DEPTH:
if (!BIP_39_PATH_REGEX.test(nodeToken)) {
throw new Error(
'Invalid derivation path: The "m" / seed node (depth 0) must be a BIP-39 node.',
);
}
break;

case 1:
if (nodeToken !== BIP44PurposeNodeToken) {
throw new Error(
Expand Down
26 changes: 25 additions & 1 deletion src/SLIP10Node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import { ed25519, secp256k1 } from './curves';
import { compressPublicKey } from './curves/secp256k1';
import { createBip39KeyFromSeed, deriveChildKey } from './derivers/bip39';
import { SLIP10Node } from './SLIP10Node';
import { hexStringToBytes } from './utils';
import { hexStringToBytes, mnemonicPhraseToBytes } from './utils';

const defaultBip39NodeToken = `bip39:${fixtures.local.mnemonic}` as const;
const defaultBip39BytesToken = mnemonicPhraseToBytes(fixtures.local.mnemonic);

describe('SLIP10Node', () => {
describe('fromExtendedKey', () => {
Expand Down Expand Up @@ -289,6 +290,29 @@ describe('SLIP10Node', () => {
});
});

it('initializes a new node from a derivation path with a Uint8Array', async () => {
const node = await SLIP10Node.fromDerivationPath({
Mrtenz marked this conversation as resolved.
Show resolved Hide resolved
derivationPath: [
defaultBip39BytesToken,
BIP44PurposeNodeToken,
`bip32:60'`,
],
curve: 'secp256k1',
});

expect(node.depth).toBe(2);
expect(node.toJSON()).toStrictEqual({
depth: node.depth,
masterFingerprint: node.masterFingerprint,
parentFingerprint: node.parentFingerprint,
index: node.index,
curve: 'secp256k1',
FrederikBolding marked this conversation as resolved.
Show resolved Hide resolved
privateKey: node.privateKey,
publicKey: node.publicKey,
chainCode: node.chainCode,
});
});

it('throws if the derivation path is empty', async () => {
await expect(
SLIP10Node.fromDerivationPath({
Expand Down
3 changes: 2 additions & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ export type BIP44Depth = MinBIP44Depth | 1 | 2 | 3 | 4 | MaxBIP44Depth;
// m / 44' / 60' / 0' / 0 / 0

export type AnonymizedBIP39Node = 'm';
export type BIP39Node = `bip39:${string}`;
export type BIP39StringNode = `bip39:${string}`;
export type BIP39Node = BIP39StringNode | Uint8Array;
export type HardenedBIP32Node = `bip32:${number}'`;
export type UnhardenedBIP32Node = `bip32:${number}`;
export type BIP32Node = HardenedBIP32Node | UnhardenedBIP32Node;
Expand Down
26 changes: 25 additions & 1 deletion src/derivation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { deriveKeyFromPath } from './derivation';
import { derivers } from './derivers';
import { privateKeyToEthAddress } from './derivers/bip32';
import { SLIP10Node } from './SLIP10Node';
import { getUnhardenedBIP32NodeToken } from './utils';
import { getUnhardenedBIP32NodeToken, mnemonicPhraseToBytes } from './utils';

const {
bip32: { deriveChildKey: bip32Derive },
Expand Down Expand Up @@ -55,6 +55,30 @@ describe('derivation', () => {
});
});

it('derives from Uint8Array BIP-39 nodes', async () => {
const keys = await Promise.all(
expectedAddresses.map(async (_, index) => {
const bip32Part = [
...ethereumBip32PathParts,
getUnhardenedBIP32NodeToken(index),
] as const;

const multipath = [
mnemonicPhraseToBytes(mnemonic),
...bip32Part,
] as HDPathTuple;

return deriveKeyFromPath({ path: multipath, curve: 'secp256k1' });
FrederikBolding marked this conversation as resolved.
Show resolved Hide resolved
}),
);

// validate addresses
keys.forEach(({ privateKeyBytes }, index) => {
const address = privateKeyToEthAddress(privateKeyBytes as Uint8Array);
expect(bytesToHex(address)).toStrictEqual(expectedAddresses[index]);
});
});

it('derives the correct keys using a previously derived parent key', async () => {
// generate parent key
const bip39Part = bip39MnemonicToMultipath(mnemonic);
Expand Down
40 changes: 28 additions & 12 deletions src/derivation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,15 @@ export async function deriveKeyFromPath(
depth,
);

// derive through each part of path
// `pathSegment` needs to be cast to `string[]` because `HDPathTuple.reduce()` doesn't work
return await (path as readonly string[]).reduce<Promise<SLIP10Node>>(
async (promise, pathNode) => {
const derivedNode = await promise;

// Derive through each part of path. `pathSegment` needs to be cast because
// `HDPathTuple.reduce()` doesn't work. Note that the first element of the
// path can be a Uint8Array.
return await (path as readonly [Uint8Array | string, ...string[]]).reduce<
Promise<SLIP10Node>
>(async (promise, pathNode, index) => {
const derivedNode = await promise;

if (typeof pathNode === 'string') {
const [pathType, pathPart] = pathNode.split(':');
assert(hasDeriver(pathType), `Unknown derivation type: "${pathType}".`);

Expand All @@ -107,9 +110,16 @@ export async function deriveKeyFromPath(
node: derivedNode,
curve: getCurveByName(curve),
});
},
Promise.resolve(node as SLIP10Node),
);
}

// Only the first path segment can be a Uint8Array.
assert(index === 0, getMalformedError());

return await derivers.bip39.deriveChildKey({
path: pathNode,
node: derivedNode,
});
}, Promise.resolve(node as SLIP10Node));
}

/**
Expand Down Expand Up @@ -144,11 +154,17 @@ export function validatePathSegment(
let startsWithBip39 = false;
path.forEach((node, index) => {
if (index === 0) {
startsWithBip39 = BIP_39_PATH_REGEX.test(node);
if (!startsWithBip39 && !BIP_32_PATH_REGEX.test(node)) {
startsWithBip39 =
node instanceof Uint8Array || BIP_39_PATH_REGEX.test(node);

if (
Mrtenz marked this conversation as resolved.
Show resolved Hide resolved
!(node instanceof Uint8Array) &&
!startsWithBip39 &&
Mrtenz marked this conversation as resolved.
Show resolved Hide resolved
!BIP_32_PATH_REGEX.test(node)
) {
throw getMalformedError();
}
} else if (!BIP_32_PATH_REGEX.test(node)) {
} else if (!(node instanceof Uint8Array) && !BIP_32_PATH_REGEX.test(node)) {
throw getMalformedError();
}
});
Expand Down
2 changes: 2 additions & 0 deletions src/derivers/bip32.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ export async function deriveChildKey({
node,
curve = secp256k1,
}: DeriveChildKeyArgs): Promise<SLIP10Node> {
assert(typeof path === 'string', 'Invalid path: Must be a string.');

const isHardened = path.includes(`'`);
if (!isHardened && !curve.deriveUnhardenedKeys) {
throw new Error(
Expand Down
4 changes: 2 additions & 2 deletions src/derivers/bip39.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { hmac } from '@noble/hashes/hmac';
import { sha512 } from '@noble/hashes/sha512';

import { DeriveChildKeyArgs } from '.';
import { BIP39Node } from '../constants';
import { BIP39StringNode } from '../constants';
import { Curve, secp256k1 } from '../curves';
import { SLIP10Node } from '../SLIP10Node';
import { getFingerprint } from '../utils';
Expand All @@ -15,7 +15,7 @@ import { getFingerprint } from '../utils';
* @param mnemonic - The BIP-39 mnemonic phrase to convert.
* @returns The multi path.
*/
export function bip39MnemonicToMultipath(mnemonic: string): BIP39Node {
export function bip39MnemonicToMultipath(mnemonic: string): BIP39StringNode {
return `bip39:${mnemonic.toLowerCase().trim()}`;
}

Expand Down
2 changes: 1 addition & 1 deletion src/derivers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export type DerivedKeys = {
};

export type DeriveChildKeyArgs = {
path: string;
path: Uint8Array | string;
curve?: Curve;
node?: SLIP10Node;
};
Expand Down
16 changes: 16 additions & 0 deletions src/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { mnemonicToSeed } from '@metamask/scure-bip39';
import { wordlist } from '@metamask/scure-bip39/dist/wordlists/english';
import { hexToBytes, stringToBytes } from '@metamask/utils';

import fixtures from '../test/fixtures';
import { BIP44Node } from './BIP44Node';
import {
getBIP32NodeToken,
Expand All @@ -18,6 +21,7 @@ import {
isHardened,
encodeBase58check,
decodeBase58check,
mnemonicPhraseToBytes,
} from './utils';

// Inputs used for testing non-negative integers
Expand Down Expand Up @@ -345,3 +349,15 @@ describe('getFingerprint', () => {
);
});
});

describe('mnemonicPhraseToBytes', () => {
it.each([fixtures.local.mnemonic, fixtures['eth-hd-keyring'].mnemonic])(
'converts a mnemonic phrase to a Uint8Array',
async (mnemonicPhrase) => {
const array = mnemonicPhraseToBytes(mnemonicPhrase);
expect(await mnemonicToSeed(array, wordlist)).toStrictEqual(
await mnemonicToSeed(mnemonicPhrase, wordlist),
);
},
);
});
25 changes: 24 additions & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createDataView, hexToBytes } from '@metamask/utils';
import { wordlist as englishWordlist } from '@metamask/scure-bip39/dist/wordlists/english';
import { assert, createDataView, hexToBytes } from '@metamask/utils';
import { ripemd160 } from '@noble/hashes/ripemd160';
import { sha256 } from '@noble/hashes/sha256';
import { base58check as scureBase58check } from '@scure/base';
Expand Down Expand Up @@ -329,3 +330,25 @@ export const getFingerprint = (publicKey: Uint8Array): number => {

return view.getUint32(0, false);
};

/**
* Get a secret recovery phrase (or mnemonic phrase) in string form as a
* `Uint8Array`. The secret recovery phrase is split into words, and each word
* is converted to a number using the BIP-39 word list. The numbers are then
* converted to bytes, and the bytes are concatenated into a single
* `Uint8Array`.
*
* @param mnemonicPhrase - The secret recovery phrase to convert.
* @returns The `Uint8Array` corresponding to the secret recovery phrase.
*/
export function mnemonicPhraseToBytes(mnemonicPhrase: string): Uint8Array {
const words = mnemonicPhrase.split(' ');
const indices = words.map((word) => {
const index = englishWordlist.indexOf(word);
assert(index !== -1, `Invalid mnemonic phrase: Unknown word "${word}".`);

return index;
});

return new Uint8Array(new Uint16Array(indices).buffer);
FrederikBolding marked this conversation as resolved.
Show resolved Hide resolved
}