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 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
18 changes: 18 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,22 @@ describe('BIP44CoinTypeNode', () => {
});
});

it('initializes a BIP44CoinTypeNode with a Uint8Array', async () => {
const node = await BIP44CoinTypeNode.fromDerivationPath([
defaultBip39BytesToken,
BIP44PurposeNodeToken,
`bip32:60'`,
]);

const stringNode = await BIP44CoinTypeNode.fromDerivationPath([
defaultBip39NodeToken,
BIP44PurposeNodeToken,
`bip32:60'`,
]);

expect(node.toJSON()).toStrictEqual(stringNode.toJSON());
});

it('throws if derivation path has invalid depth', async () => {
await expect(
BIP44CoinTypeNode.fromDerivationPath([
Expand Down
23 changes: 22 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,26 @@ describe('BIP44Node', () => {
});
});

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

const stringNode = await BIP44Node.fromDerivationPath({
derivationPath: [
defaultBip39NodeToken,
BIP44PurposeNodeToken,
`bip32:60'`,
],
});

expect(node.toJSON()).toStrictEqual(stringNode.toJSON());
});

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
25 changes: 24 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,28 @@ describe('SLIP10Node', () => {
});
});

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

const stringNode = await SLIP10Node.fromDerivationPath({
derivationPath: [
defaultBip39NodeToken,
BIP44PurposeNodeToken,
`bip32:60'`,
],
curve: 'secp256k1',
});

expect(node.toJSON()).toStrictEqual(stringNode.toJSON());
});

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
61 changes: 59 additions & 2 deletions src/derivation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import { bytesToHex } from '@metamask/utils';

import fixtures from '../test/fixtures';
import { HDPathTuple } from './constants';
import { deriveKeyFromPath } from './derivation';
import { deriveKeyFromPath, validatePathSegment } 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' });
}),
);

// 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 Expand Up @@ -300,3 +324,36 @@ describe('derivation', () => {
});
});
});

describe('validatePathSegment', () => {
it('accepts a Uint8Array or string path for the first segment', () => {
expect(() =>
validatePathSegment(
[mnemonicPhraseToBytes(fixtures.local.mnemonic)],
false,
),
).not.toThrow();

expect(() =>
validatePathSegment([`bip39:${fixtures.local.mnemonic}`], false),
).not.toThrow();
});

it('does not accept a Uint8Array for BIP-32 segments', () => {
expect(() =>
validatePathSegment(
// @ts-expect-error Invalid type.
[`bip39:${fixtures.local.mnemonic}`, new Uint8Array(32)],
false,
),
).toThrow('Invalid HD path segment: The path segment is malformed.');

expect(() =>
validatePathSegment(
// @ts-expect-error Invalid type.
[`bip39:${fixtures.local.mnemonic}`, `bip32:0`, new Uint8Array(32)],
false,
),
).toThrow('Invalid HD path segment: The path segment is malformed.');
});
});
42 changes: 30 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,19 @@ 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 (
// TypeScript is unable to infer that `node` is a string here, so we
// need to explicitly check it again.
!(node instanceof Uint8Array) &&
!startsWithBip39 &&
!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),
);
},
);
});
Loading