From d27a84e3fe612893a95188c50bff77eb3e65a26f Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 22 Nov 2024 12:36:56 +0100 Subject: [PATCH 1/3] Add extended key property to `SLIP10Node` --- src/BIP44Node.ts | 46 +-- src/SLIP10Node.test.ts | 714 +++++++++++++++++++++++++---------------- src/SLIP10Node.ts | 152 ++++++++- src/extended-keys.ts | 4 +- 4 files changed, 586 insertions(+), 330 deletions(-) diff --git a/src/BIP44Node.ts b/src/BIP44Node.ts index b7f93eb3..1877ffe0 100644 --- a/src/BIP44Node.ts +++ b/src/BIP44Node.ts @@ -14,12 +14,7 @@ import { MIN_BIP_44_DEPTH, } from './constants'; import type { SupportedCurve } from './curves'; -import { - decodeExtendedKey, - encodeExtendedKey, - PRIVATE_KEY_VERSION, - PUBLIC_KEY_VERSION, -} from './extended-keys'; +import { decodeExtendedKey, PRIVATE_KEY_VERSION } from './extended-keys'; import { SLIP10Node, validateBIP32Depth } from './SLIP10Node'; import { isHardened } from './utils'; @@ -161,24 +156,10 @@ export class BIP44Node implements BIP44NodeInterface { }); } - const { - privateKey, - publicKey, - chainCode, - depth, - parentFingerprint, - index, - } = options; - - validateBIP44Depth(depth); + validateBIP44Depth(options.depth); const node = await SLIP10Node.fromExtendedKey({ - privateKey, - publicKey, - chainCode, - depth, - parentFingerprint, - index, + ...options, curve: 'secp256k1', }); @@ -279,26 +260,7 @@ export class BIP44Node implements BIP44NodeInterface { } public get extendedKey(): string { - const data = { - depth: this.depth, - parentFingerprint: this.parentFingerprint, - index: this.index, - chainCode: this.chainCodeBytes, - }; - - if (this.privateKeyBytes) { - return encodeExtendedKey({ - ...data, - version: PRIVATE_KEY_VERSION, - privateKey: this.privateKeyBytes, - }); - } - - return encodeExtendedKey({ - ...data, - version: PUBLIC_KEY_VERSION, - publicKey: this.publicKeyBytes, - }); + return this.#node.extendedKey; } public get curve(): SupportedCurve { diff --git a/src/SLIP10Node.test.ts b/src/SLIP10Node.test.ts index d86d4041..38b97c7e 100644 --- a/src/SLIP10Node.test.ts +++ b/src/SLIP10Node.test.ts @@ -5,6 +5,7 @@ import { BIP44PurposeNodeToken } from './constants'; import { ed25519, secp256k1 } from './curves'; import { compressPublicKey } from './curves/secp256k1'; import { createBip39KeyFromSeed, deriveChildKey } from './derivers/bip39'; +import { encodeExtendedKey, PRIVATE_KEY_VERSION } from './extended-keys'; import { SLIP10Node } from './SLIP10Node'; import { hexStringToBytes, mnemonicPhraseToBytes } from './utils'; @@ -38,353 +39,454 @@ describe('SLIP10Node', () => { }); describe('fromExtendedKey', () => { - it('initializes a new node from a private key', async () => { - const { privateKey, chainCode } = await deriveChildKey({ - path: fixtures.local.mnemonic, - curve: secp256k1, - }); + describe('using an object', () => { + it('initializes a new node from a private key', async () => { + const { privateKey, chainCode } = await deriveChildKey({ + path: fixtures.local.mnemonic, + curve: secp256k1, + }); - const node = await SLIP10Node.fromExtendedKey({ - privateKey, - chainCode, - depth: 0, - parentFingerprint: 0, - index: 0, - curve: 'secp256k1', + const node = await SLIP10Node.fromExtendedKey({ + privateKey, + chainCode, + depth: 0, + parentFingerprint: 0, + index: 0, + curve: 'secp256k1', + }); + + expect(node.depth).toBe(0); + expect(node.privateKeyBytes).toHaveLength(32); + expect(node.publicKeyBytes).toHaveLength(65); }); - expect(node.depth).toBe(0); - expect(node.privateKeyBytes).toHaveLength(32); - expect(node.publicKeyBytes).toHaveLength(65); - }); + it('initializes a new node from a hexadecimal private key and chain code', async () => { + const { privateKey, chainCode } = await deriveChildKey({ + path: fixtures.local.mnemonic, + curve: secp256k1, + }); - it('initializes a new node from a hexadecimal private key and chain code', async () => { - const { privateKey, chainCode } = await deriveChildKey({ - path: fixtures.local.mnemonic, - curve: secp256k1, - }); + const node = await SLIP10Node.fromExtendedKey({ + privateKey, + chainCode, + depth: 0, + parentFingerprint: 0, + index: 0, + curve: 'secp256k1', + }); - const node = await SLIP10Node.fromExtendedKey({ - privateKey, - chainCode, - depth: 0, - parentFingerprint: 0, - index: 0, - curve: 'secp256k1', + expect(node.depth).toBe(0); + expect(node.privateKeyBytes).toHaveLength(32); + expect(node.publicKeyBytes).toHaveLength(65); }); - expect(node.depth).toBe(0); - expect(node.privateKeyBytes).toHaveLength(32); - expect(node.publicKeyBytes).toHaveLength(65); - }); + it('initializes a new ed25519 node from a private key', async () => { + const { privateKey, chainCode } = await deriveChildKey({ + path: fixtures.local.mnemonic, + curve: ed25519, + }); - it('initializes a new ed25519 node from a private key', async () => { - const { privateKey, chainCode } = await deriveChildKey({ - path: fixtures.local.mnemonic, - curve: ed25519, - }); + const node = await SLIP10Node.fromExtendedKey({ + privateKey, + chainCode, + depth: 0, + parentFingerprint: 0, + index: 0, + curve: 'ed25519', + }); - const node = await SLIP10Node.fromExtendedKey({ - privateKey, - chainCode, - depth: 0, - parentFingerprint: 0, - index: 0, - curve: 'ed25519', + expect(node.depth).toBe(0); + expect(node.privateKeyBytes).toHaveLength(32); + expect(node.publicKeyBytes).toHaveLength(33); }); - expect(node.depth).toBe(0); - expect(node.privateKeyBytes).toHaveLength(32); - expect(node.publicKeyBytes).toHaveLength(33); - }); + it('initializes a new ed25519 node from a zero private key', async () => { + const node = await SLIP10Node.fromExtendedKey({ + privateKey: new Uint8Array(32).fill(0), + chainCode: new Uint8Array(32).fill(1), + depth: 0, + parentFingerprint: 0, + index: 0, + curve: 'ed25519', + }); - it('initializes a new ed25519 node from a zero private key', async () => { - const node = await SLIP10Node.fromExtendedKey({ - privateKey: new Uint8Array(32).fill(0), - chainCode: new Uint8Array(32).fill(1), - depth: 0, - parentFingerprint: 0, - index: 0, - curve: 'ed25519', + expect(node.depth).toBe(0); + expect(node.privateKeyBytes).toStrictEqual(new Uint8Array(32).fill(0)); + expect(node.publicKeyBytes).toHaveLength(33); }); - expect(node.depth).toBe(0); - expect(node.privateKeyBytes).toStrictEqual(new Uint8Array(32).fill(0)); - expect(node.publicKeyBytes).toHaveLength(33); - }); + it('initializes a new node from a public key', async () => { + const { publicKeyBytes, chainCodeBytes } = await deriveChildKey({ + path: fixtures.local.mnemonic, + curve: secp256k1, + }); - it('initializes a new node from a public key', async () => { - const { publicKeyBytes, chainCodeBytes } = await deriveChildKey({ - path: fixtures.local.mnemonic, - curve: secp256k1, - }); + const node = await SLIP10Node.fromExtendedKey({ + publicKey: publicKeyBytes, + chainCode: chainCodeBytes, + depth: 0, + parentFingerprint: 0, + index: 0, + curve: 'secp256k1', + }); - const node = await SLIP10Node.fromExtendedKey({ - publicKey: publicKeyBytes, - chainCode: chainCodeBytes, - depth: 0, - parentFingerprint: 0, - index: 0, - curve: 'secp256k1', + expect(node.depth).toBe(0); + expect(node.privateKeyBytes).toBeUndefined(); + expect(node.publicKeyBytes).toHaveLength(65); }); - expect(node.depth).toBe(0); - expect(node.privateKeyBytes).toBeUndefined(); - expect(node.publicKeyBytes).toHaveLength(65); - }); + it('initializes a new ed25519 node from a public key', async () => { + const { publicKeyBytes, chainCodeBytes } = await deriveChildKey({ + path: fixtures.local.mnemonic, + curve: ed25519, + }); - it('initializes a new ed25519 node from a public key', async () => { - const { publicKeyBytes, chainCodeBytes } = await deriveChildKey({ - path: fixtures.local.mnemonic, - curve: ed25519, - }); + const node = await SLIP10Node.fromExtendedKey({ + publicKey: publicKeyBytes, + chainCode: chainCodeBytes, + depth: 0, + parentFingerprint: 0, + index: 0, + curve: 'ed25519', + }); - const node = await SLIP10Node.fromExtendedKey({ - publicKey: publicKeyBytes, - chainCode: chainCodeBytes, - depth: 0, - parentFingerprint: 0, - index: 0, - curve: 'ed25519', + expect(node.depth).toBe(0); + expect(node.privateKeyBytes).toBeUndefined(); + expect(node.publicKeyBytes).toHaveLength(33); }); - expect(node.depth).toBe(0); - expect(node.privateKeyBytes).toBeUndefined(); - expect(node.publicKeyBytes).toHaveLength(33); - }); + it('initializes a new node from a hexadecimal public key and chain code', async () => { + const { publicKey, chainCode } = await deriveChildKey({ + path: fixtures.local.mnemonic, + curve: secp256k1, + }); - it('initializes a new node from a hexadecimal public key and chain code', async () => { - const { publicKey, chainCode } = await deriveChildKey({ - path: fixtures.local.mnemonic, - curve: secp256k1, - }); + const node = await SLIP10Node.fromExtendedKey({ + publicKey, + chainCode, + depth: 0, + parentFingerprint: 0, + index: 0, + curve: 'secp256k1', + }); - const node = await SLIP10Node.fromExtendedKey({ - publicKey, - chainCode, - depth: 0, - parentFingerprint: 0, - index: 0, - curve: 'secp256k1', + expect(node.depth).toBe(0); + expect(node.privateKeyBytes).toBeUndefined(); + expect(node.publicKeyBytes).toHaveLength(65); }); - expect(node.depth).toBe(0); - expect(node.privateKeyBytes).toBeUndefined(); - expect(node.publicKeyBytes).toHaveLength(65); - }); + it('initializes a new node from JSON', async () => { + const node = await deriveChildKey({ + path: fixtures.local.mnemonic, + curve: secp256k1, + }); - it('initializes a new node from JSON', async () => { - const node = await deriveChildKey({ - path: fixtures.local.mnemonic, - curve: secp256k1, + expect(await SLIP10Node.fromJSON(node.toJSON())).toStrictEqual(node); }); - expect(await SLIP10Node.fromJSON(node.toJSON())).toStrictEqual(node); - }); + it('initializes a new node from JSON with a public key', async () => { + const { privateKey, chainCode } = await deriveChildKey({ + path: fixtures.local.mnemonic, + curve: secp256k1, + }); - it('initializes a new node from JSON with a public key', async () => { - const { privateKey, chainCode } = await deriveChildKey({ - path: fixtures.local.mnemonic, - curve: secp256k1, + const node = await SLIP10Node.fromExtendedKey({ + privateKey, + chainCode, + depth: 0, + parentFingerprint: 0, + index: 0, + curve: 'secp256k1', + }); + + const neuteredNode = node.neuter(); + + expect(await SLIP10Node.fromJSON(neuteredNode.toJSON())).toStrictEqual( + neuteredNode, + ); }); - const node = await SLIP10Node.fromExtendedKey({ - privateKey, - chainCode, - depth: 0, - parentFingerprint: 0, - index: 0, - curve: 'secp256k1', + it('throws if no public or private key is specified', async () => { + await expect( + SLIP10Node.fromExtendedKey({ + chainCode: new Uint8Array(32).fill(1), + depth: 0, + parentFingerprint: 0, + index: 0, + curve: 'secp256k1', + }), + ).rejects.toThrow( + 'Invalid options: Must provide either a private key or a public key.', + ); }); - const neuteredNode = node.neuter(); + it('throws if the depth is invalid', async () => { + const inputs = [ + -1, + 0.1, + -0.1, + NaN, + Infinity, + '0', + 'zero', + {}, + null, + undefined, + ]; + + for (const input of inputs) { + await expect( + SLIP10Node.fromExtendedKey({ + depth: input as any, + parentFingerprint: 0, + index: 0, + publicKey: new Uint8Array(65).fill(1), + chainCode: new Uint8Array(32).fill(1), + curve: 'secp256k1', + }), + ).rejects.toThrow( + `Invalid HD tree path depth: The depth must be a positive integer. Received: "${String( + input, + )}"`, + ); + } + }); - expect(await SLIP10Node.fromJSON(neuteredNode.toJSON())).toStrictEqual( - neuteredNode, - ); - }); + it('throws if the parent fingerprint is invalid', async () => { + const inputs = [ + -1, + 0.1, + -0.1, + NaN, + Infinity, + '0', + 'zero', + {}, + null, + undefined, + ]; + + for (const input of inputs) { + await expect( + SLIP10Node.fromExtendedKey({ + depth: 0, + parentFingerprint: input as any, + index: 0, + publicKey: new Uint8Array(65).fill(1), + chainCode: new Uint8Array(32).fill(1), + curve: 'secp256k1', + }), + ).rejects.toThrow( + `Invalid parent fingerprint: The fingerprint must be a positive integer. Received: "${String( + input, + )}"`, + ); + } + }); - it('throws if no public or private key is specified', async () => { - await expect( - SLIP10Node.fromExtendedKey({ - chainCode: new Uint8Array(32).fill(1), - depth: 0, - parentFingerprint: 0, - index: 0, - curve: 'secp256k1', - }), - ).rejects.toThrow( - 'Invalid options: Must provide either a private key or a public key.', - ); - }); + it('throws if the private key is invalid', async () => { + await expect( + SLIP10Node.fromExtendedKey({ + privateKey: 'foo', + chainCode: new Uint8Array(32).fill(1), + depth: 0, + parentFingerprint: 0, + index: 0, + curve: 'secp256k1', + }), + ).rejects.toThrow('Value must be a hexadecimal string.'); + }); - it('throws if the depth is invalid', async () => { - const inputs = [ - -1, - 0.1, - -0.1, - NaN, - Infinity, - '0', - 'zero', - {}, - null, - undefined, - ]; - - for (const input of inputs) { + it('throws if the private key is not a Uint8Array or hexadecimal string', async () => { await expect( + // @ts-expect-error Invalid private key type. SLIP10Node.fromExtendedKey({ - depth: input as any, + privateKey: 123, + chainCode: new Uint8Array(32).fill(1), + depth: 0, parentFingerprint: 0, index: 0, - publicKey: new Uint8Array(65).fill(1), + curve: 'secp256k1', + }), + ).rejects.toThrow( + 'Invalid value: Expected an instance of Uint8Array or hexadecimal string.', + ); + }); + + it('throws if the private key is zero for secp256k1', async () => { + await expect( + SLIP10Node.fromExtendedKey({ + privateKey: new Uint8Array(32).fill(0), chainCode: new Uint8Array(32).fill(1), + depth: 0, + parentFingerprint: 0, + index: 0, curve: 'secp256k1', }), ).rejects.toThrow( - `Invalid HD tree path depth: The depth must be a positive integer. Received: "${String( - input, - )}"`, + 'Invalid private key: Value is not a valid secp256k1 private key.', ); - } - }); + }); - it('throws if the parent fingerprint is invalid', async () => { - const inputs = [ - -1, - 0.1, - -0.1, - NaN, - Infinity, - '0', - 'zero', - {}, - null, - undefined, - ]; - - for (const input of inputs) { + it('throws if the depth is zero and the parent fingerprint is not zero', async () => { await expect( SLIP10Node.fromExtendedKey({ + privateKey: new Uint8Array(32).fill(1), + chainCode: new Uint8Array(32).fill(1), depth: 0, - parentFingerprint: input as any, + parentFingerprint: 1, + index: 0, + curve: 'secp256k1', + }), + ).rejects.toThrow( + 'Invalid parent fingerprint: The fingerprint of the root node must be 0. Received: "1".', + ); + }); + + it('throws if the depth is not zero and the parent fingerprint is zero', async () => { + await expect( + SLIP10Node.fromExtendedKey({ + privateKey: new Uint8Array(32).fill(1), + chainCode: new Uint8Array(32).fill(1), + depth: 1, + parentFingerprint: 0, index: 0, - publicKey: new Uint8Array(65).fill(1), + curve: 'secp256k1', + }), + ).rejects.toThrow( + 'Invalid parent fingerprint: The fingerprint of a child node must not be 0. Received: "0".', + ); + }); + + it('throws if the depth is >= 2 and the parent fingerprint is equal to the master fingerprint', async () => { + await expect( + SLIP10Node.fromExtendedKey({ + privateKey: new Uint8Array(32).fill(1), chainCode: new Uint8Array(32).fill(1), + depth: 2, + parentFingerprint: 1, + masterFingerprint: 1, + index: 0, curve: 'secp256k1', }), ).rejects.toThrow( - `Invalid parent fingerprint: The fingerprint must be a positive integer. Received: "${String( - input, - )}"`, + 'Invalid parent fingerprint: The fingerprint of a child node cannot be equal to the master fingerprint. Received: "1".', ); - } - }); + }); - it('throws if the private key is invalid', async () => { - await expect( - SLIP10Node.fromExtendedKey({ - privateKey: 'foo', - chainCode: new Uint8Array(32).fill(1), - depth: 0, - parentFingerprint: 0, - index: 0, - curve: 'secp256k1', - }), - ).rejects.toThrow('Value must be a hexadecimal string.'); + it('throws if the depth is zero and the index is not zero', async () => { + await expect( + SLIP10Node.fromExtendedKey({ + privateKey: new Uint8Array(32).fill(1), + chainCode: new Uint8Array(32).fill(1), + depth: 0, + parentFingerprint: 0, + index: 1, + curve: 'secp256k1', + }), + ).rejects.toThrow( + 'Invalid index: The index of the root node must be 0. Received: "1".', + ); + }); }); - it('throws if the private key is not a Uint8Array or hexadecimal string', async () => { - await expect( - SLIP10Node.fromExtendedKey({ - // @ts-expect-error Invalid private key type. - privateKey: 123, - chainCode: new Uint8Array(32).fill(1), - depth: 0, - parentFingerprint: 0, - index: 0, - curve: 'secp256k1', - }), - ).rejects.toThrow( - 'Invalid value: Expected an instance of Uint8Array or hexadecimal string.', - ); - }); + 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, + curve: secp256k1, + }); - it('throws if the private key is zero for secp256k1', async () => { - await expect( - SLIP10Node.fromExtendedKey({ - privateKey: new Uint8Array(32).fill(0), - chainCode: new Uint8Array(32).fill(1), + const node = await SLIP10Node.fromExtendedKey(extendedKey); + + expect(node.depth).toBe(0); + expect(node.privateKeyBytes).toHaveLength(32); + expect(node.publicKeyBytes).toHaveLength(65); + expect(node.privateKey).toBe(privateKey); + expect(node.chainCode).toBe(chainCode); + }); + + it('initializes a new node from a public key', async () => { + const baseNode = await deriveChildKey({ + path: fixtures.local.mnemonic, + curve: secp256k1, + }); + + const { extendedKey, publicKey } = baseNode.neuter(); + + const node = await SLIP10Node.fromExtendedKey(extendedKey); + + expect(node.depth).toBe(0); + expect(node.privateKeyBytes).toBeUndefined(); + expect(node.publicKeyBytes).toHaveLength(65); + expect(node.publicKey).toBe(publicKey); + }); + + it('throws if the extended key is invalid', async () => { + await expect(SLIP10Node.fromExtendedKey('foo')).rejects.toThrow( + 'Invalid extended key: Value is not base58-encoded, or the checksum is invalid.', + ); + }); + + it('throws if the private key is zero', async () => { + const extendedKey = encodeExtendedKey({ + version: PRIVATE_KEY_VERSION, depth: 0, parentFingerprint: 0, index: 0, - curve: 'secp256k1', - }), - ).rejects.toThrow( - 'Invalid private key: Value is not a valid secp256k1 private key.', - ); - }); - - it('throws if the depth is zero and the parent fingerprint is not zero', async () => { - await expect( - SLIP10Node.fromExtendedKey({ - privateKey: new Uint8Array(32).fill(1), chainCode: new Uint8Array(32).fill(1), + privateKey: new Uint8Array(32).fill(0), + }); + + await expect(SLIP10Node.fromExtendedKey(extendedKey)).rejects.toThrow( + 'Invalid extended key: Key must be a 33-byte non-zero byte array.', + ); + }); + + it('throws if the depth is zero and the parent fingerprint is not zero', async () => { + const extendedKey = encodeExtendedKey({ + version: PRIVATE_KEY_VERSION, depth: 0, parentFingerprint: 1, index: 0, - curve: 'secp256k1', - }), - ).rejects.toThrow( - 'Invalid parent fingerprint: The fingerprint of the root node must be 0. Received: "1".', - ); - }); - - it('throws if the depth is not zero and the parent fingerprint is zero', async () => { - await expect( - SLIP10Node.fromExtendedKey({ - privateKey: new Uint8Array(32).fill(1), chainCode: new Uint8Array(32).fill(1), + privateKey: new Uint8Array(32).fill(1), + }); + + await expect(SLIP10Node.fromExtendedKey(extendedKey)).rejects.toThrow( + 'Invalid parent fingerprint: The fingerprint of the root node must be 0. Received: "1".', + ); + }); + + it('throws if the depth is not zero and the parent fingerprint is zero', async () => { + const extendedKey = encodeExtendedKey({ + version: PRIVATE_KEY_VERSION, depth: 1, parentFingerprint: 0, index: 0, - curve: 'secp256k1', - }), - ).rejects.toThrow( - 'Invalid parent fingerprint: The fingerprint of a child node must not be 0. Received: "0".', - ); - }); - - it('throws if the depth is >= 2 and the parent fingerprint is equal to the master fingerprint', async () => { - await expect( - SLIP10Node.fromExtendedKey({ - privateKey: new Uint8Array(32).fill(1), chainCode: new Uint8Array(32).fill(1), - depth: 2, - parentFingerprint: 1, - masterFingerprint: 1, - index: 0, - curve: 'secp256k1', - }), - ).rejects.toThrow( - 'Invalid parent fingerprint: The fingerprint of a child node cannot be equal to the master fingerprint. Received: "1".', - ); - }); - - it('throws if the depth is zero and the index is not zero', async () => { - await expect( - SLIP10Node.fromExtendedKey({ privateKey: new Uint8Array(32).fill(1), - chainCode: new Uint8Array(32).fill(1), + }); + + await expect(SLIP10Node.fromExtendedKey(extendedKey)).rejects.toThrow( + 'Invalid parent fingerprint: The fingerprint of a child node must not be 0. Received: "0".', + ); + }); + + it('throws if the depth is zero and the index is not zero', async () => { + const extendedKey = encodeExtendedKey({ + version: PRIVATE_KEY_VERSION, depth: 0, parentFingerprint: 0, index: 1, - curve: 'secp256k1', - }), - ).rejects.toThrow( - 'Invalid index: The index of the root node must be 0. Received: "1".', - ); + chainCode: new Uint8Array(32).fill(1), + privateKey: new Uint8Array(32).fill(1), + }); + + await expect(SLIP10Node.fromExtendedKey(extendedKey)).rejects.toThrow( + 'Invalid index: The index of the root node must be 0. Received: "1".', + ); + }); }); }); @@ -793,6 +895,76 @@ describe('SLIP10Node', () => { }); }); + describe('extendedKey', () => { + it.each(fixtures.bip32)( + 'returns the extended private key for an secp256k1 node', + async ({ hexSeed, keys }) => { + const { privateKey, chainCode } = await createBip39KeyFromSeed( + hexStringToBytes(hexSeed), + secp256k1, + ); + + for (const { path, extPrivKey } of keys) { + const node = await SLIP10Node.fromExtendedKey({ + privateKey, + chainCode, + curve: 'secp256k1', + depth: 0, + parentFingerprint: 0, + index: 0, + }); + + if (path.ours.tuple.length === 0) { + continue; + } + + const childNode = await node.derive(path.ours.tuple); + expect(childNode.extendedKey).toBe(extPrivKey); + } + }, + ); + + it.each(fixtures.bip32)( + 'returns the extended public key for an secp256k1 node', + async ({ hexSeed, keys }) => { + const { privateKey, chainCode } = await createBip39KeyFromSeed( + hexStringToBytes(hexSeed), + secp256k1, + ); + + for (const { path, extPubKey } of keys) { + const node = await SLIP10Node.fromExtendedKey({ + privateKey, + chainCode, + curve: 'secp256k1', + depth: 0, + parentFingerprint: 0, + index: 0, + }); + + if (path.ours.tuple.length === 0) { + continue; + } + + const childNode = await node.derive(path.ours.tuple); + const neuteredNode = childNode.neuter(); + expect(neuteredNode.extendedKey).toBe(extPubKey); + } + }, + ); + + it('throws when trying to get an extended key for an ed25519 node', async () => { + const node = await SLIP10Node.fromDerivationPath({ + derivationPath: [defaultBip39NodeToken, `slip10:44'`, `slip10:60'`], + curve: 'ed25519', + }); + + expect(() => node.extendedKey).toThrow( + 'Unable to get extended key for this node: Only secp256k1 is supported.', + ); + }); + }); + describe('neuter', () => { it('returns a SLIP-10 node without a private key', async () => { const node = await SLIP10Node.fromDerivationPath({ diff --git a/src/SLIP10Node.ts b/src/SLIP10Node.ts index af1b2009..f068063d 100644 --- a/src/SLIP10Node.ts +++ b/src/SLIP10Node.ts @@ -8,6 +8,12 @@ import type { SupportedCurve } from './curves'; import { getCurveByName } from './curves'; import { deriveKeyFromPath } from './derivation'; import { publicKeyToEthAddress } from './derivers/bip32'; +import { + decodeExtendedKey, + encodeExtendedKey, + PRIVATE_KEY_VERSION, + PUBLIC_KEY_VERSION, +} from './extended-keys'; import { getBytes, getBytesUnsafe, @@ -123,6 +129,18 @@ export class SLIP10Node implements SLIP10NodeInterface { return SLIP10Node.fromExtendedKey(json); } + /** + * Create a new SLIP-10 node from a BIP-32 serialised extended key string. + * The key may be either public or private. Note that `secp256k1` is assumed + * as the curve for the key. + * + * All parameters are stringently validated, and an error is thrown if + * validation fails. + * + * @param extendedKey - The BIP-32 extended key string. + */ + static async fromExtendedKey(extendedKey: string): Promise; + /** * Create a new SLIP-10 node from a key and chain code. You must specify * either a private key or a public key. When specifying a private key, @@ -145,19 +163,86 @@ export class SLIP10Node implements SLIP10NodeInterface { * @param options.chainCode - The chain code for the node. * @param options.curve - The curve used by the node. */ - static async fromExtendedKey({ - depth, - masterFingerprint, - parentFingerprint, - index, - privateKey, - publicKey, - chainCode, - curve, - }: SLIP10ExtendedKeyOptions) { + static async fromExtendedKey( + // These signatures could technically be combined, but it's easier to + // document them separately. + // eslint-disable-next-line @typescript-eslint/unified-signatures + options: SLIP10ExtendedKeyOptions, + ): Promise; + + /** + * Create a new SLIP-10 node from a key and chain code. You must specify + * either a private key or a public key. When specifying a private key, + * the public key will be derived from the private key. + * + * All parameters are stringently validated, and an error is thrown if + * validation fails. + * + * @param options - The options for the new node. This can be an object + * containing the extended key options, or a string containing the extended + * key. + * @param options.depth - The depth of the node. + * @param options.masterFingerprint - The fingerprint of the master node, i.e., the + * node at depth 0. May be undefined if this node was created from an extended + * key. + * @param options.parentFingerprint - The fingerprint of the parent key, or 0 if + * the node is a master node. + * @param options.index - The index of the node, or 0 if the node is a master node. + * @param options.privateKey - The private key for the node. + * @param options.publicKey - The public key for the node. If a private key is + * specified, this parameter is ignored. + * @param options.chainCode - The chain code for the node. + * @param options.curve - The curve used by the node. + */ + static async fromExtendedKey( + options: SLIP10ExtendedKeyOptions | string, + ): Promise { + if (typeof options === 'string') { + const extendedKey = decodeExtendedKey(options); + + const { chainCode, depth, parentFingerprint, index } = extendedKey; + + if (extendedKey.version === PRIVATE_KEY_VERSION) { + const { privateKey } = extendedKey; + + return SLIP10Node.fromExtendedKey({ + depth, + parentFingerprint, + index, + privateKey, + chainCode, + // BIP-32 key serialisation assumes `secp256k1`. + curve: 'secp256k1', + }); + } + + const { publicKey } = extendedKey; + + return SLIP10Node.fromExtendedKey({ + depth, + parentFingerprint, + index, + publicKey, + chainCode, + // BIP-32 key serialisation assumes `secp256k1`. + curve: 'secp256k1', + }); + } + + const { + depth, + masterFingerprint, + parentFingerprint, + index, + privateKey, + publicKey, + chainCode, + curve: curveName, + } = options; + const chainCodeBytes = getBytes(chainCode, BYTES_KEY_LENGTH); - validateCurve(curve); + validateCurve(curveName); validateBIP32Depth(depth); validateBIP32Index(index); validateRootIndex(index, depth); @@ -168,7 +253,7 @@ export class SLIP10Node implements SLIP10NodeInterface { depth, ); - const curveObject = getCurveByName(curve); + const curveObject = getCurveByName(curveName); if (privateKey) { const privateKeyBytes = getBytesUnsafe( @@ -177,7 +262,7 @@ export class SLIP10Node implements SLIP10NodeInterface { ); assert( curveObject.isValidPrivateKey(privateKeyBytes), - `Invalid private key: Value is not a valid ${curve} private key.`, + `Invalid private key: Value is not a valid ${curveName} private key.`, ); return new SLIP10Node( @@ -189,7 +274,7 @@ export class SLIP10Node implements SLIP10NodeInterface { chainCode: chainCodeBytes, privateKey: privateKeyBytes, publicKey: await curveObject.getPublicKey(privateKeyBytes), - curve, + curve: curveName, }, this.#constructorGuard, ); @@ -206,7 +291,7 @@ export class SLIP10Node implements SLIP10NodeInterface { index, chainCode: chainCodeBytes, publicKey: publicKeyBytes, - curve, + curve: curveName, }, this.#constructorGuard, ); @@ -353,6 +438,43 @@ export class SLIP10Node implements SLIP10NodeInterface { ); } + /** + * Get the extended public or private key for the SLIP-10 node. SLIP-10 + * doesn't specify a format for extended keys, so we use the BIP-32 format. + * + * This property is only supported for `secp256k1` nodes, as other curves + * don't specify a standard format for extended keys. + * + * @returns The extended public or private key for the node. + */ + public get extendedKey(): string { + assert( + this.curve === 'secp256k1', + 'Unable to get extended key for this node: Only secp256k1 is supported.', + ); + + const data = { + depth: this.depth, + parentFingerprint: this.parentFingerprint, + index: this.index, + chainCode: this.chainCodeBytes, + }; + + if (this.privateKeyBytes) { + return encodeExtendedKey({ + ...data, + version: PRIVATE_KEY_VERSION, + privateKey: this.privateKeyBytes, + }); + } + + return encodeExtendedKey({ + ...data, + version: PUBLIC_KEY_VERSION, + publicKey: this.publicKeyBytes, + }); + } + /** * Get a neutered version of this node, i.e. a node without a private key. * diff --git a/src/extended-keys.ts b/src/extended-keys.ts index d4b32a2b..f9c8f5b4 100644 --- a/src/extended-keys.ts +++ b/src/extended-keys.ts @@ -37,8 +37,8 @@ type ExtendedPrivateKey = ExtendedKeyLike & { export type ExtendedKey = ExtendedPublicKey | ExtendedPrivateKey; /** - * Decodes an extended public or private key. In the case of an extended public key, the public key - * is returned in the uncompressed form. + * Decode an extended public or private key. In the case of an extended public + * key, the public key is returned in the uncompressed form. * * Throws an error if the extended key is invalid. * From 518d96a85264b8dd74dc349a35e3543ab9e10e40 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 22 Nov 2024 12:52:18 +0100 Subject: [PATCH 2/3] Revert some unnecessary changes --- src/BIP44Node.ts | 18 ++++++++++++++++-- src/SLIP10Node.ts | 12 ++++++------ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/BIP44Node.ts b/src/BIP44Node.ts index 1877ffe0..30c0590f 100644 --- a/src/BIP44Node.ts +++ b/src/BIP44Node.ts @@ -156,10 +156,24 @@ export class BIP44Node implements BIP44NodeInterface { }); } - validateBIP44Depth(options.depth); + const { + privateKey, + publicKey, + chainCode, + depth, + parentFingerprint, + index, + } = options; + + validateBIP44Depth(depth); const node = await SLIP10Node.fromExtendedKey({ - ...options, + privateKey, + publicKey, + chainCode, + depth, + parentFingerprint, + index, curve: 'secp256k1', }); diff --git a/src/SLIP10Node.ts b/src/SLIP10Node.ts index f068063d..dddb0eb9 100644 --- a/src/SLIP10Node.ts +++ b/src/SLIP10Node.ts @@ -237,12 +237,12 @@ export class SLIP10Node implements SLIP10NodeInterface { privateKey, publicKey, chainCode, - curve: curveName, + curve, } = options; const chainCodeBytes = getBytes(chainCode, BYTES_KEY_LENGTH); - validateCurve(curveName); + validateCurve(curve); validateBIP32Depth(depth); validateBIP32Index(index); validateRootIndex(index, depth); @@ -253,7 +253,7 @@ export class SLIP10Node implements SLIP10NodeInterface { depth, ); - const curveObject = getCurveByName(curveName); + const curveObject = getCurveByName(curve); if (privateKey) { const privateKeyBytes = getBytesUnsafe( @@ -262,7 +262,7 @@ export class SLIP10Node implements SLIP10NodeInterface { ); assert( curveObject.isValidPrivateKey(privateKeyBytes), - `Invalid private key: Value is not a valid ${curveName} private key.`, + `Invalid private key: Value is not a valid ${curve} private key.`, ); return new SLIP10Node( @@ -274,7 +274,7 @@ export class SLIP10Node implements SLIP10NodeInterface { chainCode: chainCodeBytes, privateKey: privateKeyBytes, publicKey: await curveObject.getPublicKey(privateKeyBytes), - curve: curveName, + curve, }, this.#constructorGuard, ); @@ -291,7 +291,7 @@ export class SLIP10Node implements SLIP10NodeInterface { index, chainCode: chainCodeBytes, publicKey: publicKeyBytes, - curve: curveName, + curve, }, this.#constructorGuard, ); From 94a2223c73282d570d021918bd6d721c9c3d5bca Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 22 Nov 2024 13:02:16 +0100 Subject: [PATCH 3/3] Fix test --- src/SLIP10Node.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/SLIP10Node.test.ts b/src/SLIP10Node.test.ts index 38b97c7e..a5384a22 100644 --- a/src/SLIP10Node.test.ts +++ b/src/SLIP10Node.test.ts @@ -41,14 +41,14 @@ describe('SLIP10Node', () => { describe('fromExtendedKey', () => { describe('using an object', () => { it('initializes a new node from a private key', async () => { - const { privateKey, chainCode } = await deriveChildKey({ + const { privateKeyBytes, chainCodeBytes } = await deriveChildKey({ path: fixtures.local.mnemonic, curve: secp256k1, }); const node = await SLIP10Node.fromExtendedKey({ - privateKey, - chainCode, + privateKey: privateKeyBytes, + chainCode: chainCodeBytes, depth: 0, parentFingerprint: 0, index: 0,