Skip to content

Commit b74082a

Browse files
authored
Optimise performance of getting public keys (#213)
* Optimise performance of getting public keys * Fix tests * Add extra check in `deriveSecretExtension` * Move `PUBLIC_KEY_GUARD` to separate file and add comment * Update documentation for `publicKey` parameter * Re-use public key for deriving CIP-3 nodes
1 parent 04e0066 commit b74082a

File tree

12 files changed

+180
-64
lines changed

12 files changed

+180
-64
lines changed

src/SLIP10Node.test.ts

Lines changed: 54 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -710,23 +710,23 @@ describe('SLIP10Node', () => {
710710
});
711711

712712
// getter
713-
expect(() => (node.privateKey = 'foo')).toThrow(
714-
/^Cannot set property privateKey of .+ which has only a getter/iu,
715-
);
713+
['privateKey', 'publicKeyBytes'].forEach((property) => {
714+
expect(() => (node[property] = 'foo')).toThrow(
715+
/^Cannot set property .+ of .+ which has only a getter/iu,
716+
);
717+
});
716718

717719
// frozen / readonly
718-
['depth', 'privateKeyBytes', 'publicKeyBytes', 'chainCodeBytes'].forEach(
719-
(property) => {
720-
expect(() => (node[property] = new Uint8Array(64).fill(1))).toThrow(
721-
expect.objectContaining({
722-
name: 'TypeError',
723-
message: expect.stringMatching(
724-
`Cannot assign to read only property '${property}' of object`,
725-
),
726-
}),
727-
);
728-
},
729-
);
720+
['depth', 'privateKeyBytes', 'chainCodeBytes'].forEach((property) => {
721+
expect(() => (node[property] = new Uint8Array(64).fill(1))).toThrow(
722+
expect.objectContaining({
723+
name: 'TypeError',
724+
message: expect.stringMatching(
725+
`Cannot assign to read only property '${property}' of object`,
726+
),
727+
}),
728+
);
729+
});
730730
});
731731

732732
it('throws an error if no curve is specified', async () => {
@@ -875,23 +875,23 @@ describe('SLIP10Node', () => {
875875
});
876876

877877
// getter
878-
expect(() => (node.privateKey = 'foo')).toThrow(
879-
/^Cannot set property privateKey of .+ which has only a getter/iu,
880-
);
878+
['privateKey', 'publicKeyBytes'].forEach((property) => {
879+
expect(() => (node[property] = 'foo')).toThrow(
880+
/^Cannot set property .+ of .+ which has only a getter/iu,
881+
);
882+
});
881883

882884
// frozen / readonly
883-
['depth', 'privateKeyBytes', 'publicKeyBytes', 'chainCodeBytes'].forEach(
884-
(property) => {
885-
expect(() => (node[property] = new Uint8Array(64).fill(1))).toThrow(
886-
expect.objectContaining({
887-
name: 'TypeError',
888-
message: expect.stringMatching(
889-
`Cannot assign to read only property '${property}' of object`,
890-
),
891-
}),
892-
);
893-
},
894-
);
885+
['depth', 'privateKeyBytes', 'chainCodeBytes'].forEach((property) => {
886+
expect(() => (node[property] = new Uint8Array(64).fill(1))).toThrow(
887+
expect.objectContaining({
888+
name: 'TypeError',
889+
message: expect.stringMatching(
890+
`Cannot assign to read only property '${property}' of object`,
891+
),
892+
}),
893+
);
894+
});
895895
});
896896

897897
it('throws an error if no curve is specified', async () => {
@@ -1107,6 +1107,30 @@ describe('SLIP10Node', () => {
11071107
});
11081108
});
11091109

1110+
describe('publicKeyBytes', () => {
1111+
it('lazily computes the public key bytes', async () => {
1112+
const baseNode = await SLIP10Node.fromDerivationPath({
1113+
derivationPath: [
1114+
defaultBip39NodeToken,
1115+
BIP44PurposeNodeToken,
1116+
`bip32:0'`,
1117+
`bip32:0'`,
1118+
],
1119+
curve: 'secp256k1',
1120+
});
1121+
1122+
const { publicKey, ...json } = baseNode.toJSON();
1123+
1124+
const spy = jest.spyOn(secp256k1, 'getPublicKey');
1125+
1126+
const node = await SLIP10Node.fromExtendedKey(json);
1127+
expect(spy).not.toHaveBeenCalled();
1128+
1129+
expect(node.publicKeyBytes).toStrictEqual(baseNode.publicKeyBytes);
1130+
expect(spy).toHaveBeenCalled();
1131+
});
1132+
});
1133+
11101134
describe('compressedPublicKeyBytes', () => {
11111135
it('returns the public key in compressed form', async () => {
11121136
const node = await SLIP10Node.fromDerivationPath({

src/SLIP10Node.ts

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,20 @@ import { assert, bytesToHex } from '@metamask/utils';
22

33
import type { BIP44CoinTypeNode } from './BIP44CoinTypeNode';
44
import type { BIP44Node } from './BIP44Node';
5+
import { BYTES_KEY_LENGTH } from './constants';
56
import type {
67
Network,
78
RootedSLIP10PathTuple,
89
SLIP10PathTuple,
910
} from './constants';
10-
import { BYTES_KEY_LENGTH } from './constants';
1111
import type { CryptographicFunctions } from './cryptography';
1212
import type { SupportedCurve } from './curves';
1313
import { getCurveByName } from './curves';
1414
import { deriveKeyFromPath } from './derivation';
1515
import { publicKeyToEthAddress } from './derivers/bip32';
1616
import { getDerivationPathWithSeed } from './derivers/bip39';
1717
import { decodeExtendedKey, encodeExtendedKey } from './extended-keys';
18+
import { PUBLIC_KEY_GUARD } from './guard';
1819
import {
1920
getBytes,
2021
getBytesUnsafe,
@@ -99,18 +100,32 @@ export type SLIP10NodeInterface = JsonSLIP10Node & {
99100
toJSON(): JsonSLIP10Node;
100101
};
101102

102-
export type SLIP10NodeConstructorOptions = {
103+
type BaseSLIP10NodeConstructorOptions = {
103104
readonly depth: number;
104105
readonly masterFingerprint?: number | undefined;
105106
readonly parentFingerprint: number;
106107
readonly index: number;
107108
readonly network?: Network | undefined;
108109
readonly chainCode: Uint8Array;
109-
readonly privateKey?: Uint8Array | undefined;
110-
readonly publicKey: Uint8Array;
111110
readonly curve: SupportedCurve;
112111
};
113112

113+
type SLIP10NodePrivateKeyConstructorOptions =
114+
BaseSLIP10NodeConstructorOptions & {
115+
readonly privateKey: Uint8Array;
116+
readonly publicKey?: Uint8Array | undefined;
117+
};
118+
119+
type SLIP10NodePublicKeyConstructorOptions =
120+
BaseSLIP10NodeConstructorOptions & {
121+
readonly privateKey?: Uint8Array | undefined;
122+
readonly publicKey: Uint8Array;
123+
};
124+
125+
export type SLIP10NodeConstructorOptions =
126+
| SLIP10NodePrivateKeyConstructorOptions
127+
| SLIP10NodePublicKeyConstructorOptions;
128+
114129
export type SLIP10ExtendedKeyOptions = {
115130
readonly depth: number;
116131
readonly masterFingerprint?: number | undefined;
@@ -121,6 +136,12 @@ export type SLIP10ExtendedKeyOptions = {
121136
readonly privateKey?: string | Uint8Array | undefined;
122137
readonly publicKey?: string | Uint8Array | undefined;
123138
readonly curve: SupportedCurve;
139+
140+
/**
141+
* For internal use only. This is used to ensure the public key provided to
142+
* the constructor is trusted.
143+
*/
144+
readonly guard?: typeof PUBLIC_KEY_GUARD;
124145
};
125146

126147
export type SLIP10DerivationPathOptions = {
@@ -276,6 +297,7 @@ export class SLIP10Node implements SLIP10NodeInterface {
276297
publicKey,
277298
chainCode,
278299
curve,
300+
guard,
279301
} = options;
280302

281303
const chainCodeBytes = getBytes(chainCode, BYTES_KEY_LENGTH);
@@ -299,11 +321,20 @@ export class SLIP10Node implements SLIP10NodeInterface {
299321
privateKey,
300322
curveObject.privateKeyLength,
301323
);
324+
302325
assert(
303326
curveObject.isValidPrivateKey(privateKeyBytes),
304327
`Invalid private key: Value is not a valid ${curve} private key.`,
305328
);
306329

330+
const trustedPublicKey =
331+
guard === PUBLIC_KEY_GUARD && publicKey
332+
? // `publicKey` is typed as `string | Uint8Array`, but we know it's
333+
// a `Uint8Array` because of the guard. We use `getBytes` to ensure
334+
// the type is correct.
335+
getBytes(publicKey, curveObject.publicKeyLength)
336+
: undefined;
337+
307338
return new SLIP10Node(
308339
{
309340
depth,
@@ -313,7 +344,7 @@ export class SLIP10Node implements SLIP10NodeInterface {
313344
network,
314345
chainCode: chainCodeBytes,
315346
privateKey: privateKeyBytes,
316-
publicKey: await curveObject.getPublicKey(privateKeyBytes),
347+
publicKey: trustedPublicKey,
317348
curve,
318349
},
319350
cryptographicFunctions,
@@ -478,7 +509,7 @@ export class SLIP10Node implements SLIP10NodeInterface {
478509

479510
public readonly privateKeyBytes?: Uint8Array | undefined;
480511

481-
public readonly publicKeyBytes: Uint8Array;
512+
#publicKeyBytes: Uint8Array | undefined;
482513

483514
readonly #cryptographicFunctions: CryptographicFunctions;
484515

@@ -503,15 +534,20 @@ export class SLIP10Node implements SLIP10NodeInterface {
503534
'SLIP10Node can only be constructed using `SLIP10Node.fromJSON`, `SLIP10Node.fromExtendedKey`, `SLIP10Node.fromDerivationPath`, or `SLIP10Node.fromSeed`.',
504535
);
505536

537+
assert(
538+
privateKey !== undefined || publicKey !== undefined,
539+
'SLIP10Node requires either a private key or a public key to be set.',
540+
);
541+
506542
this.depth = depth;
507543
this.masterFingerprint = masterFingerprint;
508544
this.parentFingerprint = parentFingerprint;
509545
this.index = index;
510546
this.network = network;
511547
this.chainCodeBytes = chainCode;
512548
this.privateKeyBytes = privateKey;
513-
this.publicKeyBytes = publicKey;
514549
this.curve = curve;
550+
this.#publicKeyBytes = publicKey;
515551
this.#cryptographicFunctions = cryptographicFunctions;
516552

517553
Object.freeze(this);
@@ -533,6 +569,31 @@ export class SLIP10Node implements SLIP10NodeInterface {
533569
return bytesToHex(this.publicKeyBytes);
534570
}
535571

572+
/**
573+
* Get the public key bytes. This will lazily derive the public key from the
574+
* private key if it is not already set.
575+
*
576+
* @returns The public key bytes.
577+
*/
578+
public get publicKeyBytes(): Uint8Array {
579+
if (this.#publicKeyBytes !== undefined) {
580+
return this.#publicKeyBytes;
581+
}
582+
583+
// This assertion is mainly for type safety, as `SLIP10Node` requires either
584+
// a private key or a public key to always be set.
585+
assert(
586+
this.privateKeyBytes,
587+
'Either a private key or public key is required.',
588+
);
589+
590+
this.#publicKeyBytes = getCurveByName(this.curve).getPublicKey(
591+
this.privateKeyBytes,
592+
);
593+
594+
return this.#publicKeyBytes;
595+
}
596+
536597
public get compressedPublicKeyBytes(): Uint8Array {
537598
return getCurveByName(this.curve).compressPublicKey(this.publicKeyBytes);
538599
}

src/curves/curve.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,7 @@ export type Curve = {
2727
curve: {
2828
n: bigint;
2929
};
30-
getPublicKey: (
31-
privateKey: Uint8Array,
32-
compressed?: boolean,
33-
) => Uint8Array | Promise<Uint8Array>;
30+
getPublicKey: (privateKey: Uint8Array, compressed?: boolean) => Uint8Array;
3431
isValidPrivateKey: (privateKey: Uint8Array) => boolean;
3532
publicAdd: (publicKey: Uint8Array, tweak: Uint8Array) => Uint8Array;
3633
compressPublicKey: (publicKey: Uint8Array) => Uint8Array;

src/curves/ed25519Bip32.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ import fixtures from '../../test/fixtures';
1313
describe('getPublicKey', () => {
1414
fixtures.cip3.forEach((fixture) => {
1515
Object.values(fixture.nodes).forEach((node) => {
16-
it('returns correct public key from private key', async () => {
17-
const publicKey = await ed25519Bip32.getPublicKey(
16+
it('returns correct public key from private key', () => {
17+
const publicKey = ed25519Bip32.getPublicKey(
1818
hexToBytes(node.privateKey),
1919
);
2020

src/curves/ed25519Bip32.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,10 @@ export const multiplyWithBase = (key: Uint8Array): Uint8Array => {
9999
* @param _compressed - Optional parameter to indicate if the public key should be compressed.
100100
* @returns The public key.
101101
*/
102-
export const getPublicKey = async (
102+
export const getPublicKey = (
103103
privateKey: Uint8Array,
104104
_compressed?: boolean,
105-
): Promise<Uint8Array> => {
105+
): Uint8Array => {
106106
return multiplyWithBase(privateKey.slice(0, 32));
107107
};
108108

src/derivers/bip32.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,14 +96,15 @@ async function handleError(
9696
options: DeriveNodeArgs,
9797
cryptographicFunctions?: CryptographicFunctions,
9898
): Promise<DeriveNodeArgs> {
99-
const { childIndex, privateKey, publicKey, isHardened, curve, chainCode } =
99+
const { childIndex, privateKey, publicKey, isHardened, chainCode, curve } =
100100
options;
101101

102102
validateBIP32Index(childIndex + 1);
103103

104104
if (privateKey) {
105105
const secretExtension = await deriveSecretExtension({
106106
privateKey,
107+
publicKey: curve.compressPublicKey(publicKey),
107108
childIndex: childIndex + 1,
108109
isHardened,
109110
curve,

src/derivers/bip39.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type { CryptographicFunctions } from '../cryptography';
1515
import { hmacSha512, pbkdf2Sha512 } from '../cryptography';
1616
import type { Curve, SupportedCurve } from '../curves';
1717
import { getCurveByName } from '../curves';
18+
import { PUBLIC_KEY_GUARD } from '../guard';
1819
import { SLIP10Node } from '../SLIP10Node';
1920
import { getFingerprint } from '../utils';
2021

@@ -244,21 +245,24 @@ export async function createBip39KeyFromSeed(
244245
'Invalid private key: The private key must greater than 0 and less than the curve order.',
245246
);
246247

248+
const publicKey = curve.getPublicKey(privateKey, false);
247249
const masterFingerprint = getFingerprint(
248-
await curve.getPublicKey(privateKey, true),
250+
curve.compressPublicKey(publicKey),
249251
curve.compressedPublicKeyLength,
250252
);
251253

252254
return SLIP10Node.fromExtendedKey(
253255
{
254256
privateKey,
257+
publicKey,
255258
chainCode,
256259
masterFingerprint,
257260
network,
258261
depth: 0,
259262
parentFingerprint: 0,
260263
index: 0,
261264
curve: curve.name,
265+
guard: PUBLIC_KEY_GUARD,
262266
},
263267
cryptographicFunctions,
264268
);
@@ -310,21 +314,24 @@ export async function entropyToCip3MasterNode(
310314

311315
assert(curve.isValidPrivateKey(privateKey), 'Invalid private key.');
312316

317+
const publicKey = curve.getPublicKey(privateKey, false);
313318
const masterFingerprint = getFingerprint(
314-
await curve.getPublicKey(privateKey),
319+
curve.compressPublicKey(publicKey),
315320
curve.compressedPublicKeyLength,
316321
);
317322

318323
return SLIP10Node.fromExtendedKey(
319324
{
320325
privateKey,
326+
publicKey,
321327
chainCode,
322328
masterFingerprint,
323329
network,
324330
depth: 0,
325331
parentFingerprint: 0,
326332
index: 0,
327333
curve: curve.name,
334+
guard: PUBLIC_KEY_GUARD,
328335
},
329336
cryptographicFunctions,
330337
);

0 commit comments

Comments
 (0)