diff --git a/src/BIP44CoinTypeNode.test.ts b/src/BIP44CoinTypeNode.test.ts index 3c269b6c..4eaf5542 100644 --- a/src/BIP44CoinTypeNode.test.ts +++ b/src/BIP44CoinTypeNode.test.ts @@ -333,6 +333,74 @@ describe('BIP44CoinTypeNode', () => { }); }); + describe('fromSeed', () => { + it('initializes a BIP44CoinTypeNode from a seed', async () => { + const node = await BIP44CoinTypeNode.fromSeed({ + derivationPath: [ + fixtures.local.seed, + BIP44PurposeNodeToken, + `bip32:60'`, + ], + }); + + const mnemonicNode = await BIP44CoinTypeNode.fromDerivationPath([ + defaultBip39NodeToken, + BIP44PurposeNodeToken, + `bip32:60'`, + ]); + + expect(node.toJSON()).toStrictEqual(mnemonicNode.toJSON()); + }); + + it('initializes a BIP44CoinTypeNode from a seed with custom cryptographic functions', async () => { + const functions = getMockFunctions(); + const node = await BIP44CoinTypeNode.fromSeed( + { + derivationPath: [ + fixtures.local.seed, + BIP44PurposeNodeToken, + `bip32:60'`, + ], + }, + functions, + ); + + 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(functions.hmacSha512).toHaveBeenCalledTimes(3); + expect(functions.pbkdf2Sha512).not.toHaveBeenCalled(); + }); + + it('throws if derivation path has invalid depth', async () => { + await expect( + BIP44CoinTypeNode.fromSeed({ + // @ts-expect-error: Invalid derivation path. + derivationPath: [fixtures.local.seed, BIP44PurposeNodeToken], + }), + ).rejects.toThrow( + `Invalid depth: Coin type nodes must be of depth ${BIP_44_COIN_TYPE_DEPTH}. Received: "1"`, + ); + + await expect( + BIP44CoinTypeNode.fromDerivationPath([ + defaultBip39NodeToken, + BIP44PurposeNodeToken, + `bip32:60'`, + `bip32:0'`, + ] as any), + ).rejects.toThrow( + `Invalid depth: Coin type nodes must be of depth ${BIP_44_COIN_TYPE_DEPTH}. Received: "3"`, + ); + }); + }); + describe('deriveBIP44AddressKey', () => { const coinTypePath = [ defaultBip39NodeToken, diff --git a/src/BIP44CoinTypeNode.ts b/src/BIP44CoinTypeNode.ts index ab913fd4..95418042 100644 --- a/src/BIP44CoinTypeNode.ts +++ b/src/BIP44CoinTypeNode.ts @@ -1,5 +1,3 @@ -import { assert } from '@metamask/utils'; - import type { BIP44NodeInterface, JsonBIP44Node } from './BIP44Node'; import { BIP44Node } from './BIP44Node'; import type { @@ -16,6 +14,7 @@ import type { SupportedCurve } from './curves'; import { deriveChildNode } from './SLIP10Node'; import type { CoinTypeToAddressIndices } from './utils'; import { + getBIP44CoinType, getBIP32NodeToken, getBIP44ChangePathString, getBIP44CoinTypePathString, @@ -32,6 +31,12 @@ export type CoinTypeHDPathTuple = [ HardenedBIP32Node, ]; +export type CoinTypeSeedPathTuple = [ + Uint8Array, + typeof BIP44PurposeNodeToken, + HardenedBIP32Node, +]; + export const BIP_44_COIN_TYPE_DEPTH = 2; export type JsonBIP44CoinTypeNode = JsonBIP44Node & { @@ -44,6 +49,11 @@ export type BIP44CoinTypeNodeInterface = BIP44NodeInterface & { readonly path: CoinTypeHDPathString; }; +export type BIP44CoinTypeSeedOptions = { + readonly derivationPath: CoinTypeSeedPathTuple; + readonly network?: Network | undefined; +}; + /** * A wrapper object for BIP-44 `coin_type` keys. `coin_type` is the index * specifying the protocol for which deeper keys are intended. For the @@ -106,7 +116,7 @@ export class BIP44CoinTypeNode implements BIP44CoinTypeNodeInterface { } /** - * Constructs a BIP-44 `coin_type` node. `coin_type` is the index + * Construct a BIP-44 `coin_type` node. `coin_type` is the index * specifying the protocol for which deeper keys are intended. For the * authoritative list of coin types, please see * [SLIP-44](https://github.com/satoshilabs/slips/blob/master/slip-0044.md). @@ -141,14 +151,42 @@ export class BIP44CoinTypeNode implements BIP44CoinTypeNodeInterface { cryptographicFunctions, ); - // Split the bip32 string token and extract the coin_type index. - const pathPart = derivationPath[BIP_44_COIN_TYPE_DEPTH].split( - ':', - )[1]?.replace(`'`, ''); + const coinType = getBIP44CoinType(derivationPath); + return new BIP44CoinTypeNode(node, coinType); + } + + /** + * Create a new BIP-44 coin type node from a BIP-39 seed. The derivation path + * must be rooted, i.e. it must begin with a BIP-39 node, given as a + * `Uint8Array` of the seed bytes. + * + * All parameters are stringently validated, and an error is thrown if + * validation fails. + * + * @param options - The options for the new node. + * @param options.derivationPath - The rooted HD tree path that will be used + * to derive the key of this node. + * @param options.network - The network for the node. This is only used for + * extended keys, and defaults to `mainnet`. + * @param cryptographicFunctions - The cryptographic functions to use. If + * provided, these will be used instead of the built-in implementations. + * @returns A new BIP-44 node. + */ + static async fromSeed( + { derivationPath, network }: BIP44CoinTypeSeedOptions, + cryptographicFunctions?: CryptographicFunctions, + ): Promise { + validateCoinTypeNodeDepth(derivationPath.length - 1); - assert(pathPart, 'Invalid derivation path.'); - const coinType = Number.parseInt(pathPart, 10); + const node = await BIP44Node.fromSeed( + { + derivationPath, + network, + }, + cryptographicFunctions, + ); + const coinType = getBIP44CoinType(derivationPath); return new BIP44CoinTypeNode(node, coinType); } diff --git a/src/BIP44Node.test.ts b/src/BIP44Node.test.ts index 6a7fc20d..f553fe0f 100644 --- a/src/BIP44Node.test.ts +++ b/src/BIP44Node.test.ts @@ -33,7 +33,7 @@ describe('BIP44Node', () => { describe('fromExtendedKey', () => { it('initializes a new node from a private key', async () => { const { privateKey, chainCode } = await deriveChildKey({ - path: fixtures.local.mnemonic, + path: fixtures.local.seed, curve: secp256k1, }); @@ -61,7 +61,7 @@ describe('BIP44Node', () => { it('initializes a new node from a private key with custom cryptographic functions', async () => { const { privateKey, chainCode } = await deriveChildKey({ - path: fixtures.local.mnemonic, + path: fixtures.local.seed, curve: secp256k1, }); @@ -98,7 +98,7 @@ describe('BIP44Node', () => { it('initializes a new node from JSON', async () => { const { privateKey, chainCode } = await deriveChildKey({ - path: fixtures.local.mnemonic, + path: fixtures.local.seed, curve: secp256k1, }); @@ -116,7 +116,7 @@ describe('BIP44Node', () => { it('initializes a new node from JSON with custom cryptographic functions', async () => { const { privateKey, chainCode } = await deriveChildKey({ - path: fixtures.local.mnemonic, + path: fixtures.local.seed, curve: secp256k1, }); @@ -162,7 +162,7 @@ describe('BIP44Node', () => { it('throws if the depth is invalid', async () => { const { privateKey, chainCode } = await deriveChildKey({ - path: fixtures.local.mnemonic, + path: fixtures.local.seed, curve: secp256k1, }); @@ -366,6 +366,159 @@ describe('BIP44Node', () => { }); }); + describe('fromSeed', () => { + it('initializes a new node from a seed', async () => { + const node = await BIP44Node.fromSeed({ + derivationPath: [ + fixtures.local.seed, + BIP44PurposeNodeToken, + `bip32:60'`, + ], + }); + + const mnemonicNode = await BIP44Node.fromDerivationPath({ + derivationPath: [ + defaultBip39NodeToken, + BIP44PurposeNodeToken, + `bip32:60'`, + ], + }); + + expect(node.toJSON()).toStrictEqual(mnemonicNode.toJSON()); + }); + + it('initializes a new node from a seed with custom cryptographic functions', async () => { + const functions = getMockFunctions(); + + const node = await BIP44Node.fromSeed( + { + derivationPath: [ + fixtures.local.seed, + BIP44PurposeNodeToken, + `bip32:60'`, + ], + }, + functions, + ); + + expect(node.depth).toBe(2); + expect(node.toJSON()).toStrictEqual({ + depth: node.depth, + masterFingerprint: node.masterFingerprint, + parentFingerprint: node.parentFingerprint, + index: node.index, + network: node.network, + privateKey: node.privateKey, + publicKey: node.publicKey, + chainCode: node.chainCode, + }); + + expect(functions.hmacSha512).toHaveBeenCalled(); + expect(functions.pbkdf2Sha512).not.toHaveBeenCalled(); + }); + + it('throws an error if attempting to modify the fields of a node', async () => { + const node: any = await BIP44Node.fromSeed({ + derivationPath: [ + fixtures.local.seed, + BIP44PurposeNodeToken, + `bip32:60'`, + ], + }); + + ['depth', 'privateKey', 'publicKey', 'address'].forEach((property) => { + expect(() => (node[property] = new Uint8Array(64).fill(1))).toThrow( + expect.objectContaining({ + name: 'TypeError', + message: expect.stringMatching( + /^Cannot set property .+ of .+ which has only a getter/iu, + ), + }), + ); + }); + }); + + it('throws if the derivation path is of depth 0 and not a single BIP-39 node', async () => { + await expect( + BIP44Node.fromSeed({ derivationPath: [`bip32:0'`] as any }), + ).rejects.toThrow( + 'Invalid derivation path: The "m" / seed node (depth 0) must be a BIP-39 node.', + ); + }); + + it('throws if the depth 1 node of the derivation path is not the BIP-44 purpose node', async () => { + await expect( + BIP44Node.fromSeed({ + derivationPath: [fixtures.local.seed, `bip32:43'`] as any, + }), + ).rejects.toThrow( + `Invalid derivation path: The "purpose" node (depth 1) must be the string "${BIP44PurposeNodeToken}".`, + ); + }); + + it('throws if the depth 2 node of the derivation path is not a hardened BIP-32 node', async () => { + await expect( + BIP44Node.fromSeed({ + derivationPath: [ + fixtures.local.seed, + BIP44PurposeNodeToken, + `bip32:60`, + ] as any, + }), + ).rejects.toThrow( + 'Invalid derivation path: The "coin_type" node (depth 2) must be a hardened BIP-32 node.', + ); + }); + + it('throws if the depth 3 node of the derivation path is not a hardened BIP-32 node', async () => { + await expect( + BIP44Node.fromSeed({ + derivationPath: [ + fixtures.local.seed, + BIP44PurposeNodeToken, + `bip32:60'`, + `bip32:0`, + ] as any, + }), + ).rejects.toThrow( + 'Invalid derivation path: The "account" node (depth 3) must be a hardened BIP-32 node.', + ); + }); + + it('throws if the depth 4 node of the derivation path is not a BIP-32 node', async () => { + await expect( + BIP44Node.fromSeed({ + derivationPath: [ + fixtures.local.seed, + BIP44PurposeNodeToken, + `bip32:60'`, + `bip32:0'`, + `bip32:-1`, + ], + }), + ).rejects.toThrow( + 'Invalid derivation path: The "change" node (depth 4) must be a BIP-32 node.', + ); + }); + + it('throws if the depth 5 node of the derivation path is not a BIP-32 node', async () => { + await expect( + BIP44Node.fromSeed({ + derivationPath: [ + fixtures.local.seed, + BIP44PurposeNodeToken, + `bip32:60'`, + `bip32:0'`, + `bip32:0`, + `bip32:-1`, + ], + }), + ).rejects.toThrow( + 'Invalid derivation path: The "address_index" node (depth 5) must be a BIP-32 node.', + ); + }); + }); + describe('derive', () => { it('derives a child node', async () => { const coinTypeNode = `bip32:40'`; diff --git a/src/BIP44Node.ts b/src/BIP44Node.ts index 4cd6f506..c66c3220 100644 --- a/src/BIP44Node.ts +++ b/src/BIP44Node.ts @@ -5,6 +5,7 @@ import type { Network, PartialHDPathTuple, RootedSLIP10PathTuple, + RootedSLIP10SeedPathTuple, SLIP10Path, } from './constants'; import { @@ -35,6 +36,11 @@ export type BIP44DerivationPathOptions = { readonly network?: Network | undefined; }; +export type BIP44SeedOptions = { + readonly derivationPath: RootedSLIP10SeedPathTuple; + readonly network?: Network | undefined; +}; + /** * A wrapper for BIP-44 Hierarchical Deterministic (HD) tree nodes, i.e. * cryptographic keys used to generate keypairs and addresses for cryptocurrency @@ -261,6 +267,42 @@ export class BIP44Node implements BIP44NodeInterface { return new BIP44Node(node); } + /** + * Create a new BIP-44 node from a BIP-39 seed. The derivation path must be + * rooted, i.e. it must begin with a BIP-39 node, given as a `Uint8Array` of + * the seed bytes. + * + * All parameters are stringently validated, and an error is thrown if + * validation fails. + * + * @param options - The options for the new node. + * @param options.derivationPath - The rooted HD tree path that will be used + * to derive the key of this node. + * @param options.network - The network for the node. This is only used for + * extended keys, and defaults to `mainnet`. + * @param cryptographicFunctions - The cryptographic functions to use. If + * provided, these will be used instead of the built-in implementations. + * @returns A new BIP-44 node. + */ + static async fromSeed( + { derivationPath, network }: BIP44SeedOptions, + cryptographicFunctions?: CryptographicFunctions, + ): Promise { + validateBIP44Depth(derivationPath.length - 1); + validateBIP44DerivationPath(derivationPath, MIN_BIP_44_DEPTH); + + const node = await SLIP10Node.fromSeed( + { + derivationPath, + network, + curve: 'secp256k1', + }, + cryptographicFunctions, + ); + + return new BIP44Node(node); + } + readonly #node: SLIP10Node; public get depth(): BIP44Depth { diff --git a/src/SLIP10Node.test.ts b/src/SLIP10Node.test.ts index 92007f09..404d29dd 100644 --- a/src/SLIP10Node.test.ts +++ b/src/SLIP10Node.test.ts @@ -35,7 +35,7 @@ describe('SLIP10Node', () => { describe('constructor', () => { it('throws an error when the constructor guard is not provided', async () => { const { privateKey, chainCode } = await deriveChildKey({ - path: fixtures.local.mnemonic, + path: fixtures.local.seed, curve: secp256k1, }); @@ -52,7 +52,7 @@ describe('SLIP10Node', () => { curve: 'secp256k1', }), ).toThrow( - 'SLIP10Node can only be constructed using `SLIP10Node.fromJSON`, `SLIP10Node.fromExtendedKey`, or `SLIP10Node.fromDerivationPath`.', + 'SLIP10Node can only be constructed using `SLIP10Node.fromJSON`, `SLIP10Node.fromExtendedKey`, `SLIP10Node.fromDerivationPath`, or `SLIP10Node.fromSeed`.', ); }); }); @@ -61,7 +61,7 @@ describe('SLIP10Node', () => { describe('using an object', () => { it('initializes a new node from a private key', async () => { const { privateKeyBytes, chainCodeBytes } = await deriveChildKey({ - path: fixtures.local.mnemonic, + path: fixtures.local.seed, curve: secp256k1, }); @@ -81,7 +81,7 @@ describe('SLIP10Node', () => { it('initializes a new node from a hexadecimal private key and chain code', async () => { const { privateKey, chainCode } = await deriveChildKey({ - path: fixtures.local.mnemonic, + path: fixtures.local.seed, curve: secp256k1, }); @@ -101,7 +101,7 @@ describe('SLIP10Node', () => { it('initializes a new ed25519 node from a private key', async () => { const { privateKey, chainCode } = await deriveChildKey({ - path: fixtures.local.mnemonic, + path: fixtures.local.seed, curve: ed25519, }); @@ -136,7 +136,7 @@ describe('SLIP10Node', () => { it('initializes a new node from a public key', async () => { const { publicKeyBytes, chainCodeBytes } = await deriveChildKey({ - path: fixtures.local.mnemonic, + path: fixtures.local.seed, curve: secp256k1, }); @@ -156,7 +156,7 @@ describe('SLIP10Node', () => { it('initializes a new ed25519 node from a public key', async () => { const { publicKeyBytes, chainCodeBytes } = await deriveChildKey({ - path: fixtures.local.mnemonic, + path: fixtures.local.seed, curve: ed25519, }); @@ -176,7 +176,7 @@ describe('SLIP10Node', () => { it('initializes a new node from a hexadecimal public key and chain code', async () => { const { publicKey, chainCode } = await deriveChildKey({ - path: fixtures.local.mnemonic, + path: fixtures.local.seed, curve: secp256k1, }); @@ -196,7 +196,7 @@ describe('SLIP10Node', () => { it('initializes a new node from JSON', async () => { const node = await deriveChildKey({ - path: fixtures.local.mnemonic, + path: fixtures.local.seed, curve: secp256k1, }); @@ -205,7 +205,7 @@ describe('SLIP10Node', () => { it('initializes a new node from JSON with a public key', async () => { const { privateKey, chainCode } = await deriveChildKey({ - path: fixtures.local.mnemonic, + path: fixtures.local.seed, curve: secp256k1, }); @@ -227,7 +227,7 @@ describe('SLIP10Node', () => { it('initializes a new node from a private key with custom cryptographic functions', async () => { const { privateKeyBytes, chainCodeBytes } = await deriveChildKey({ - path: fixtures.local.mnemonic, + path: fixtures.local.seed, curve: secp256k1, }); @@ -254,7 +254,7 @@ describe('SLIP10Node', () => { it('initializes a new node from JSON with custom cryptographic functions', async () => { const baseNode = await deriveChildKey({ - path: fixtures.local.mnemonic, + path: fixtures.local.seed, curve: secp256k1, }); @@ -454,7 +454,7 @@ describe('SLIP10Node', () => { describe('using a BIP-32 serialised extended key', () => { it('initializes a new node from a private key', async () => { const { extendedKey, privateKey, chainCode } = await deriveChildKey({ - path: fixtures.local.mnemonic, + path: fixtures.local.seed, curve: secp256k1, }); @@ -469,7 +469,7 @@ describe('SLIP10Node', () => { it('initializes a new node from a public key', async () => { const baseNode = await deriveChildKey({ - path: fixtures.local.mnemonic, + path: fixtures.local.seed, curve: secp256k1, }); @@ -485,7 +485,7 @@ describe('SLIP10Node', () => { it('initializes a new node from a private key with custom cryptographic functions', async () => { const { extendedKey, privateKey, chainCode } = await deriveChildKey({ - path: fixtures.local.mnemonic, + path: fixtures.local.seed, curve: secp256k1, }); @@ -695,7 +695,7 @@ describe('SLIP10Node', () => { curve: 'secp256k1', }), ).rejects.toThrow( - 'Invalid HD path segment: The segment must consist of a single BIP-39 node for depths of 0. Received: "bip32:0\'".', + 'Invalid HD path segment: The BIP-39 path must start with "bip39:".', ); }); @@ -749,6 +749,171 @@ describe('SLIP10Node', () => { }); }); + describe('fromSeed', () => { + it('initializes a new node from a seed', async () => { + const node = await SLIP10Node.fromSeed({ + derivationPath: [ + fixtures.local.seed, + BIP44PurposeNodeToken, + `bip32:60'`, + ], + curve: 'secp256k1', + }); + + const mnemonicNode = await SLIP10Node.fromDerivationPath({ + derivationPath: [ + defaultBip39NodeToken, + BIP44PurposeNodeToken, + `bip32:60'`, + ], + curve: 'secp256k1', + }); + + expect(node.toJSON()).toStrictEqual(mnemonicNode.toJSON()); + }); + + it('initializes a new node from a seed with a Uint8Array using ed25519', async () => { + const node = await SLIP10Node.fromSeed({ + derivationPath: [fixtures.local.seed, `slip10:44'`, `slip10:60'`], + curve: 'ed25519', + }); + + const mnemonicNode = await SLIP10Node.fromDerivationPath({ + derivationPath: [defaultBip39NodeToken, `slip10:44'`, `slip10:60'`], + curve: 'ed25519', + }); + + expect(node.toJSON()).toStrictEqual(mnemonicNode.toJSON()); + }); + + it('initializes a new node from a seed with custom cryptographic functions', async () => { + const functions = getMockFunctions(); + + const node = await SLIP10Node.fromSeed( + { + derivationPath: [ + fixtures.local.seed, + BIP44PurposeNodeToken, + `bip32:60'`, + ], + curve: 'secp256k1', + }, + functions, + ); + + expect(node.depth).toBe(2); + expect(node.toJSON()).toStrictEqual({ + depth: node.depth, + masterFingerprint: node.masterFingerprint, + parentFingerprint: node.parentFingerprint, + index: node.index, + network: node.network, + curve: 'secp256k1', + privateKey: node.privateKey, + publicKey: node.publicKey, + chainCode: node.chainCode, + }); + + expect(functions.hmacSha512).toHaveBeenCalled(); + expect(functions.pbkdf2Sha512).not.toHaveBeenCalled(); + }); + + it('throws if the curve is `ed25519Bip32`', async () => { + await expect( + SLIP10Node.fromSeed({ + derivationPath: [ + fixtures.local.seed, + BIP44PurposeNodeToken, + `bip32:60'`, + ], + curve: 'ed25519Bip32', + }), + ).rejects.toThrow( + 'Invalid curve: The curve "ed25519Bip32" is not supported by the `fromSeed` function.', + ); + }); + + it('throws if the derivation path is empty', async () => { + await expect( + SLIP10Node.fromSeed({ + derivationPath: [] as any, + curve: 'secp256k1', + }), + ).rejects.toThrow( + 'Invalid derivation path: May not specify an empty derivation path.', + ); + }); + + it('throws if no derivation path is specified', async () => { + await expect( + // @ts-expect-error No derivation path specified + SLIP10Node.fromSeed({ + curve: 'secp256k1', + }), + ).rejects.toThrow('Invalid options: Must provide a derivation path.'); + }); + + it('throws if the derivation path is of depth 0 and not a single BIP-39 node', async () => { + await expect( + SLIP10Node.fromSeed({ + derivationPath: [`bip32:0'`] as any, + curve: 'secp256k1', + }), + ).rejects.toThrow( + 'Invalid HD path segment: The segment must consist of a single BIP-39 node for depths of 0. Received: "bip32:0\'".', + ); + }); + + it('throws an error if attempting to modify the fields of a node', async () => { + const node: any = await SLIP10Node.fromSeed({ + derivationPath: [ + fixtures.local.seed, + BIP44PurposeNodeToken, + `bip32:60'`, + ], + curve: 'secp256k1', + }); + + // getter + expect(() => (node.privateKey = 'foo')).toThrow( + /^Cannot set property privateKey of .+ which has only a getter/iu, + ); + + // frozen / readonly + ['depth', 'privateKeyBytes', 'publicKeyBytes', 'chainCodeBytes'].forEach( + (property) => { + expect(() => (node[property] = new Uint8Array(64).fill(1))).toThrow( + expect.objectContaining({ + name: 'TypeError', + message: expect.stringMatching( + `Cannot assign to read only property '${property}' of object`, + ), + }), + ); + }, + ); + }); + + it('throws an error if no curve is specified', async () => { + await expect( + // @ts-expect-error No curve specified, but required in type + SLIP10Node.fromSeed({}), + ).rejects.toThrow('Invalid curve: Must specify a curve.'); + }); + + it('throws an error for unsupported curves', async () => { + await expect( + SLIP10Node.fromSeed({ + // @ts-expect-error Invalid curve name for type + curve: 'foo bar', + specification: 'bip32', + }), + ).rejects.toThrow( + 'Invalid curve: Only the following curves are supported: secp256k1, ed25519, ed25519Bip32.', + ); + }); + }); + describe('derive', () => { it('derives a child node', async () => { const coinTypeNode = `bip32:40'`; diff --git a/src/SLIP10Node.ts b/src/SLIP10Node.ts index 3357fd7e..1061952b 100644 --- a/src/SLIP10Node.ts +++ b/src/SLIP10Node.ts @@ -13,6 +13,7 @@ import type { SupportedCurve } from './curves'; import { getCurveByName } from './curves'; import { deriveKeyFromPath } from './derivation'; import { publicKeyToEthAddress } from './derivers/bip32'; +import { getDerivationPathWithSeed } from './derivers/bip39'; import { decodeExtendedKey, encodeExtendedKey } from './extended-keys'; import { getBytes, @@ -387,6 +388,67 @@ export class SLIP10Node implements SLIP10NodeInterface { ); } + // `deriveKeyFromPath` expects a seed derivation path, so we need to + // convert the rooted path to a seed path. + const seedDerivationPath = await getDerivationPathWithSeed( + { + path: derivationPath, + curve, + }, + cryptographicFunctions, + ); + + return await deriveKeyFromPath( + { + path: seedDerivationPath, + depth: derivationPath.length - 1, + network, + curve, + }, + cryptographicFunctions, + ); + } + + /** + * Create a new SLIP-10 node from a BIP-39 seed. The derivation path + * must be rooted, i.e. it must begin with a BIP-39 node, given as a + * `Uint8Array` of the seed bytes. + * + * All parameters are stringently validated, and an error is thrown if + * validation fails. + * + * @param options - The options for the new node. + * @param options.derivationPath - The rooted HD tree path that will be used + * to derive the key of this node. + * @param options.curve - The curve used by the node. + * @param options.network - The network for the node. This is only used for + * extended keys, and defaults to `mainnet`. + * @param cryptographicFunctions - The cryptographic functions to use. If + * provided, these will be used instead of the built-in implementations. + * @returns A new SLIP-10 node. + */ + static async fromSeed( + { derivationPath, network, curve }: SLIP10DerivationPathOptions, + cryptographicFunctions?: CryptographicFunctions, + ): Promise { + validateCurve(curve); + + if (curve === 'ed25519Bip32') { + throw new Error( + 'Invalid curve: The curve "ed25519Bip32" is not supported by the `fromSeed` function.', + ); + } + + if (!derivationPath) { + throw new Error('Invalid options: Must provide a derivation path.'); + } + + if (derivationPath.length === 0) { + throw new Error( + 'Invalid derivation path: May not specify an empty derivation path.', + ); + } + return await deriveKeyFromPath( { path: derivationPath, @@ -438,7 +500,7 @@ export class SLIP10Node implements SLIP10NodeInterface { ) { assert( constructorGuard === SLIP10Node.#constructorGuard, - 'SLIP10Node can only be constructed using `SLIP10Node.fromJSON`, `SLIP10Node.fromExtendedKey`, or `SLIP10Node.fromDerivationPath`.', + 'SLIP10Node can only be constructed using `SLIP10Node.fromJSON`, `SLIP10Node.fromExtendedKey`, `SLIP10Node.fromDerivationPath`, or `SLIP10Node.fromSeed`.', ); this.depth = depth; diff --git a/src/constants.ts b/src/constants.ts index a48186ab..2e3d2456 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -178,6 +178,11 @@ export type RootedSLIP10PathTuple = readonly [ ...(BIP32Node[] | SLIP10PathNode[] | CIP3PathNode[]), ]; +export type RootedSLIP10SeedPathTuple = readonly [ + Uint8Array, + ...(BIP32Node[] | SLIP10PathNode[] | CIP3PathNode[]), +]; + export type SLIP10PathTuple = | readonly BIP32Node[] | readonly SLIP10PathNode[] diff --git a/src/derivation.test.ts b/src/derivation.test.ts index 6723287e..dd4a81fc 100755 --- a/src/derivation.test.ts +++ b/src/derivation.test.ts @@ -1,10 +1,13 @@ +import { mnemonicToEntropy } from '@metamask/scure-bip39'; +import { wordlist } from '@metamask/scure-bip39/dist/wordlists/english'; import { bytesToHex } from '@metamask/utils'; -import type { HDPathTuple, SLIP10Path } from './constants'; +import type { RootedHDPathTuple, SLIP10Path } from './constants'; import { secp256k1 } from './curves'; import { deriveKeyFromPath, validatePathSegment } from './derivation'; -import { derivers } from './derivers'; +import { derivers, mnemonicToSeed } from './derivers'; import { privateKeyToEthAddress } from './derivers/bip32'; +import { getDerivationPathWithSeed } from './derivers/bip39'; import type { SLIP10Node } from './SLIP10Node'; import { getUnhardenedBIP32NodeToken, mnemonicPhraseToBytes } from './utils'; import fixtures from '../test/fixtures'; @@ -26,16 +29,17 @@ describe('derivation', () => { describe('deriveKeyFromPath', () => { it('derives the correct account key for cip3', async () => { const fixture = fixtures.cip3[0]; - const { accountNode } = fixture.nodes; + // generate parent key - const bip39Part = bip39MnemonicToMultipath(fixture.mnemonic); + const seed = mnemonicToEntropy(fixture.mnemonic, wordlist); const accountPath = fixture.derivationPath.slice(0, 3); const cardanoBip32Path = accountPath.map( (pathElement) => `cip3:${pathElement}`, ); - const multipath = [bip39Part, ...cardanoBip32Path] as SLIP10Path; + + const multipath = [seed, ...cardanoBip32Path] as SLIP10Path; const node = await deriveKeyFromPath({ path: multipath, curve: 'ed25519Bip32', @@ -54,7 +58,7 @@ describe('derivation', () => { ] as const; const bip39Part = bip39MnemonicToMultipath(mnemonic); - const multipath = [bip39Part, ...bip32Part] as HDPathTuple; + const multipath = [bip39Part, ...bip32Part] as RootedHDPathTuple; expect(multipath).toStrictEqual([ `bip39:${mnemonic}`, @@ -66,7 +70,10 @@ describe('derivation', () => { ]); return deriveKeyFromPath({ - path: multipath, + path: await getDerivationPathWithSeed({ + path: multipath, + curve: 'secp256k1', + }), curve: 'secp256k1', }); }), @@ -90,10 +97,13 @@ describe('derivation', () => { const multipath = [ mnemonicPhraseToBytes(mnemonic), ...bip32Part, - ] as HDPathTuple; + ] as RootedHDPathTuple; return deriveKeyFromPath({ - path: multipath, + path: await getDerivationPathWithSeed({ + path: multipath, + curve: 'secp256k1', + }), curve: 'secp256k1', }); }), @@ -109,9 +119,16 @@ describe('derivation', () => { it('derives the correct keys using a previously derived parent key', async () => { // generate parent key const bip39Part = bip39MnemonicToMultipath(mnemonic); - const multipath = [bip39Part, ...ethereumBip32PathParts] as HDPathTuple; + const multipath = [ + bip39Part, + ...ethereumBip32PathParts, + ] as RootedHDPathTuple; + const node = await deriveKeyFromPath({ - path: multipath, + path: await getDerivationPathWithSeed({ + path: multipath, + curve: 'secp256k1', + }), curve: 'secp256k1', }); @@ -136,7 +153,10 @@ describe('derivation', () => { const bip39Part = bip39MnemonicToMultipath(mnemonic); const multipath = [bip39Part, ...ethereumBip32PathParts] as const; const node = await deriveKeyFromPath({ - path: multipath, + path: await getDerivationPathWithSeed({ + path: multipath, + curve: 'secp256k1', + }), curve: 'secp256k1', }); @@ -256,7 +276,11 @@ describe('derivation', () => { let node: SLIP10Node; /* eslint-disable require-atomic-updates */ - node = await bip39Derive({ path: mnemonic, curve: secp256k1 }); + node = await bip39Derive({ + path: await mnemonicToSeed(mnemonic), + curve: secp256k1, + }); + node = await bip32Derive({ path: `44'`, node, @@ -299,7 +323,11 @@ describe('derivation', () => { }); it('throws for invalid inputs', async () => { - const node = await bip39Derive({ path: mnemonic, curve: secp256k1 }); + const node = await bip39Derive({ + path: await mnemonicToSeed(mnemonic), + curve: secp256k1, + }); + const inputs = [ String(-1), String(1.1), @@ -335,7 +363,11 @@ describe('derivation', () => { }); it('throws when trying to derive from a public key node', async () => { - const node = await bip39Derive({ path: mnemonic, curve: secp256k1 }); + const node = await bip39Derive({ + path: await mnemonicToSeed(mnemonic), + curve: secp256k1, + }); + const publicNode = node.neuter(); await expect( diff --git a/src/derivers/bip39.test.ts b/src/derivers/bip39.test.ts index 4f88059d..d755d5e5 100644 --- a/src/derivers/bip39.test.ts +++ b/src/derivers/bip39.test.ts @@ -1,3 +1,5 @@ +import { mnemonicToEntropy } from '@metamask/scure-bip39'; +import { wordlist } from '@metamask/scure-bip39/dist/wordlists/english'; import { assert, bigIntToBytes, @@ -10,6 +12,8 @@ import { createBip39KeyFromSeed, deriveChildKey, mnemonicToSeed, + getDerivationPathWithSeed, + bip39MnemonicToMultipath, } from './bip39'; import fixtures from '../../test/fixtures'; import * as cryptography from '../cryptography'; @@ -86,6 +90,59 @@ describe('mnemonicToSeed', () => { }); }); +describe('getDerivationPathWithSeed', () => { + it('returns a derivation path with a seed for the `secp256k1` curve', async () => { + const derivationPath = await getDerivationPathWithSeed({ + path: [ + bip39MnemonicToMultipath(fixtures.local.mnemonic), + 'bip32:0', + 'bip32:1', + ], + curve: 'secp256k1', + }); + + expect(derivationPath).toStrictEqual([ + fixtures.local.seed, + 'bip32:0', + 'bip32:1', + ]); + }); + + it('returns a derivation path with a seed for the `ed25519` curve', async () => { + const derivationPath = await getDerivationPathWithSeed({ + path: [ + bip39MnemonicToMultipath(fixtures.local.mnemonic), + 'bip32:0', + 'bip32:1', + ], + curve: 'ed25519', + }); + + expect(derivationPath).toStrictEqual([ + fixtures.local.seed, + 'bip32:0', + 'bip32:1', + ]); + }); + + it('returns a derivation path with entropy for the `ed25519Bip32` curve', async () => { + const derivationPath = await getDerivationPathWithSeed({ + path: [ + bip39MnemonicToMultipath(fixtures.cip3[0].mnemonic), + 'bip32:0', + 'bip32:1', + ], + curve: 'ed25519Bip32', + }); + + expect(derivationPath).toStrictEqual([ + hexToBytes(fixtures.cip3[0].entropyHex), + 'bip32:0', + 'bip32:1', + ]); + }); +}); + describe('createBip39KeyFromSeed', () => { const RANDOM_SEED = hexToBytes( '0xea82e6ee9d319c083007d0b011a37b0e480ae02417a988ac90355abd53cd04fc', @@ -142,10 +199,10 @@ describe('createBip39KeyFromSeed', () => { }, ); - it('throws with unsupported masterNodeGenerationSpec error', async () => { + it('throws with unsupported master node generation error', async () => { await expect( deriveChildKey({ - path: '', + path: new Uint8Array(), curve: { masterNodeGenerationSpec: 'notValidMasterNodeGenerationSpec', } as unknown as Curve, @@ -191,9 +248,10 @@ describe('Cip3', () => { 'derives the correct child key for ed25519Bip32 curve from mnemonic', async (fixture) => { const result = await deriveChildKey({ - path: fixture.mnemonic, + path: mnemonicToEntropy(fixture.mnemonic, wordlist), curve: ed25519Bip32, }); + const { bip39Node } = fixture.nodes; expect(result.privateKey).toBe(bip39Node.privateKey); expect(result.chainCode).toBe(bip39Node.chainCode); diff --git a/src/derivers/bip39.ts b/src/derivers/bip39.ts index 3afd2e8d..bd50b036 100755 --- a/src/derivers/bip39.ts +++ b/src/derivers/bip39.ts @@ -1,13 +1,20 @@ import { mnemonicToEntropy } from '@metamask/scure-bip39'; import { wordlist as englishWordlist } from '@metamask/scure-bip39/dist/wordlists/english'; -import { assert, stringToBytes } from '@metamask/utils'; +import { assert, assertExhaustive, stringToBytes } from '@metamask/utils'; import type { DeriveChildKeyArgs } from '.'; -import type { BIP39StringNode, Network } from '../constants'; +import type { + BIP39Node, + BIP39StringNode, + Network, + RootedSLIP10PathTuple, + RootedSLIP10SeedPathTuple, +} from '../constants'; import { BYTES_KEY_LENGTH } from '../constants'; import type { CryptographicFunctions } from '../cryptography'; import { hmacSha512, pbkdf2Sha512 } from '../cryptography'; -import type { Curve } from '../curves'; +import type { Curve, SupportedCurve } from '../curves'; +import { getCurveByName } from '../curves'; import { SLIP10Node } from '../SLIP10Node'; import { getFingerprint } from '../utils'; @@ -99,10 +106,76 @@ export function bip39MnemonicToMultipath(mnemonic: string): BIP39StringNode { } /** - * Create a {@link SLIP10Node} from a BIP-39 mnemonic phrase. + * Convert a multi path to a BIP-39 mnemonic phrase. + * + * @param value - The multi path to convert. + * @returns The BIP-39 mnemonic phrase. + */ +export function multipathToBip39Mnemonic( + value: BIP39Node, +): string | Uint8Array { + if (value instanceof Uint8Array) { + return value; + } + + assert( + value.startsWith('bip39:'), + 'Invalid HD path segment: The BIP-39 path must start with "bip39:".', + ); + + return value.slice(6); +} + +export type GetDerivationPathWithSeedOptions = { + path: RootedSLIP10PathTuple; + curve: SupportedCurve; +}; + +/** + * Get a {@link RootedSLIP10SeedPathTuple} from a {@link RootedSLIP10PathTuple}. + * + * @param options - The options for getting the derivation path. + * @param options.path - The derivation path to convert. + * @param options.curve - The curve to use for derivation. + * @param cryptographicFunctions - The cryptographic functions to use. If + * provided, these will be used instead of the built-in implementations. + * @returns The derivation path with the seed, or entropy in the case of CIP-3. + */ +export async function getDerivationPathWithSeed( + { path, curve: curveName }: GetDerivationPathWithSeedOptions, + cryptographicFunctions?: CryptographicFunctions, +): Promise { + const [mnemonicPhrase, ...rest] = path; + const plainMnemonicPhrase = multipathToBip39Mnemonic(mnemonicPhrase); + + const curve = getCurveByName(curveName); + switch (curve.masterNodeGenerationSpec) { + case 'slip10': { + const seed = await mnemonicToSeed( + plainMnemonicPhrase, + '', + cryptographicFunctions, + ); + return [seed, ...rest]; + } + + case 'cip3': { + const seed = mnemonicToEntropy(plainMnemonicPhrase, englishWordlist); + return [seed, ...rest]; + } + + /* istanbul ignore next */ + default: + return assertExhaustive(curve); + } +} + +/** + * Create a {@link SLIP10Node} from a BIP-39 seed. * * @param options - The options for creating the node. - * @param options.path - The multi path. + * @param options.path - The multi path. This is expected to be the BIP-39 seed, + * or the entropy in the case of CIP-3, not the mnemonic phrase itself. * @param options.curve - The curve to use for derivation. * @param options.network - The network for the node. This is only used for * extended keys, and defaults to `mainnet`. @@ -114,17 +187,22 @@ export async function deriveChildKey( { path, curve, network }: DeriveChildKeyArgs, cryptographicFunctions?: CryptographicFunctions, ): Promise { + assert( + path instanceof Uint8Array, + 'Invalid path: The path must be a Uint8Array.', + ); + switch (curve.masterNodeGenerationSpec) { case 'slip10': return createBip39KeyFromSeed( - await mnemonicToSeed(path, '', cryptographicFunctions), + path, curve, network, cryptographicFunctions, ); case 'cip3': return entropyToCip3MasterNode( - mnemonicToEntropy(path, englishWordlist), + path, curve, network, cryptographicFunctions, diff --git a/src/utils.ts b/src/utils.ts index 2346c825..be760c50 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -2,6 +2,11 @@ import { wordlist as englishWordlist } from '@metamask/scure-bip39/dist/wordlist import { assert, createDataView, hexToBytes } from '@metamask/utils'; import { base58check as scureBase58check } from '@scure/base'; +import type { + CoinTypeHDPathTuple, + CoinTypeSeedPathTuple, +} from './BIP44CoinTypeNode'; +import { BIP_44_COIN_TYPE_DEPTH } from './BIP44CoinTypeNode'; import type { BIP32Node, ChangeHDPathString, @@ -492,3 +497,31 @@ export function validateNetwork( ); } } + +/** + * Get the BIP-44 coin type from a {@link CoinTypeHDPathTuple} or + * {@link CoinTypeSeedPathTuple}. + * + * This function does not validate the derivation path, and assumes that the + * derivation path is valid. + * + * @param derivationPath - The derivation path to get the BIP-44 coin type from. + * @returns The BIP-44 coin type. + */ +export function getBIP44CoinType( + derivationPath: CoinTypeHDPathTuple | CoinTypeSeedPathTuple, +): number { + const pathPart = derivationPath[BIP_44_COIN_TYPE_DEPTH].split( + ':', + )[1]?.replace(`'`, ''); + + assert(pathPart, 'Invalid derivation path: Coin type is not specified.'); + + const value = Number.parseInt(pathPart, 10); + assert( + isValidInteger(value), + 'Invalid derivation path: Coin type is not a valid integer.', + ); + + return value; +} diff --git a/test/fixtures.ts b/test/fixtures.ts index 1a8b0cd5..9e2bdae6 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -1,8 +1,15 @@ +import { mnemonicToSeedSync } from '@metamask/scure-bip39'; +import { wordlist } from '@metamask/scure-bip39/dist/wordlists/english'; + export default { // Fixtures defined and used in this package local: { mnemonic: 'romance hurry grit huge rifle ordinary loud toss sound congress upset twist', + seed: mnemonicToSeedSync( + 'romance hurry grit huge rifle ordinary loud toss sound congress upset twist', + wordlist, + ), addresses: [ '0x5df603999c3d5ca2ab828339a9883585b1bce11b', '0x441c07e32a609afd319ffbb66432b424058bcfe9', diff --git a/test/reference-implementations.test.ts b/test/reference-implementations.test.ts index b3fdac83..b0864828 100644 --- a/test/reference-implementations.test.ts +++ b/test/reference-implementations.test.ts @@ -6,7 +6,10 @@ import type { SLIP10Node, HDPathTuple } from '../src'; import { BIP44Node, BIP44PurposeNodeToken } from '../src'; import { ed25519, secp256k1 } from '../src/curves'; import { deriveKeyFromPath } from '../src/derivation'; -import { createBip39KeyFromSeed } from '../src/derivers/bip39'; +import { + createBip39KeyFromSeed, + getDerivationPathWithSeed, +} from '../src/derivers/bip39'; import { getBIP44CoinTypeToAddressPathTuple, hexStringToBytes, @@ -53,7 +56,10 @@ describe('reference implementation tests', () => { it('derives the expected keys', async () => { // Ethereum coin type key const node = await deriveKeyFromPath({ - path: [mnemonicBip39Node, BIP44PurposeNodeToken, `bip32:60'`], + path: await getDerivationPathWithSeed({ + path: [mnemonicBip39Node, BIP44PurposeNodeToken, `bip32:60'`], + curve: 'secp256k1', + }), curve: 'secp256k1', }); @@ -133,7 +139,10 @@ describe('reference implementation tests', () => { it('derives the same keys as the reference implementation', async () => { // Ethereum coin type key const node = await deriveKeyFromPath({ - path: [mnemonicBip39Node, BIP44PurposeNodeToken, `bip32:60'`], + path: await getDerivationPathWithSeed({ + path: [mnemonicBip39Node, BIP44PurposeNodeToken, `bip32:60'`], + curve: 'secp256k1', + }), curve: 'secp256k1', }); @@ -367,7 +376,10 @@ describe('reference implementation tests', () => { it('derives the expected keys', async () => { // Ethereum coin type key const node = await deriveKeyFromPath({ - path: [mnemonicBip39Node, BIP44PurposeNodeToken, `bip32:60'`], + path: await getDerivationPathWithSeed({ + path: [mnemonicBip39Node, BIP44PurposeNodeToken, `bip32:60'`], + curve: 'secp256k1', + }), curve: 'secp256k1', }); @@ -447,7 +459,10 @@ describe('reference implementation tests', () => { it('derives the same keys as the reference implementation', async () => { // Ethereum coin type key const node = await deriveKeyFromPath({ - path: [mnemonicBip39Node, BIP44PurposeNodeToken, `bip32:60'`], + path: await getDerivationPathWithSeed({ + path: [mnemonicBip39Node, BIP44PurposeNodeToken, `bip32:60'`], + curve: 'secp256k1', + }), curve: 'secp256k1', });