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

Add cryptographic functions with option to specify custom functions #197

Merged
merged 11 commits into from
Nov 25, 2024
125 changes: 84 additions & 41 deletions src/SLIP10Node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { BIP44CoinTypeNode } from './BIP44CoinTypeNode';
import type { BIP44Node } from './BIP44Node';
import type { RootedSLIP10PathTuple, SLIP10PathTuple } from './constants';
import { BYTES_KEY_LENGTH } from './constants';
import type { CryptographicFunctions } from './cryptography';
import type { SupportedCurve } from './curves';
import { getCurveByName } from './curves';
import { deriveKeyFromPath } from './derivation';
Expand Down Expand Up @@ -124,9 +125,14 @@ export class SLIP10Node implements SLIP10NodeInterface {
* for documentation.
*
* @param json - The JSON representation of a SLIP-10 node.
* @param cryptographicFunctions - The cryptographic functions to use. If
* provided, these will be used instead of the built-in implementations.
*/
static async fromJSON(json: JsonSLIP10Node): Promise<SLIP10Node> {
return SLIP10Node.fromExtendedKey(json);
static async fromJSON(
json: JsonSLIP10Node,
cryptographicFunctions?: CryptographicFunctions,
): Promise<SLIP10Node> {
return SLIP10Node.fromExtendedKey(json, cryptographicFunctions);
}

/**
Expand All @@ -138,8 +144,13 @@ export class SLIP10Node implements SLIP10NodeInterface {
* validation fails.
*
* @param extendedKey - The BIP-32 extended key string.
* @param cryptographicFunctions - The cryptographic functions to use. If
* provided, these will be used instead of the built-in implementations.
*/
static async fromExtendedKey(extendedKey: string): Promise<SLIP10Node>;
static async fromExtendedKey(
extendedKey: string,
cryptographicFunctions?: CryptographicFunctions,
): Promise<SLIP10Node>;

/**
* Create a new SLIP-10 node from a key and chain code. You must specify
Expand All @@ -162,12 +173,15 @@ export class SLIP10Node implements SLIP10NodeInterface {
* specified, this parameter is ignored.
* @param options.chainCode - The chain code for the node.
* @param options.curve - The curve used by the node.
* @param cryptographicFunctions - The cryptographic functions to use. If
* provided, these will be used instead of the built-in implementations.
*/
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,
cryptographicFunctions?: CryptographicFunctions,
): Promise<SLIP10Node>;

/**
Expand All @@ -193,9 +207,12 @@ export class SLIP10Node implements SLIP10NodeInterface {
* specified, this parameter is ignored.
* @param options.chainCode - The chain code for the node.
* @param options.curve - The curve used by the node.
* @param cryptographicFunctions - The cryptographic functions to use. If
* provided, these will be used instead of the built-in implementations.
*/
static async fromExtendedKey(
options: SLIP10ExtendedKeyOptions | string,
cryptographicFunctions?: CryptographicFunctions,
): Promise<SLIP10Node> {
if (typeof options === 'string') {
const extendedKey = decodeExtendedKey(options);
Expand All @@ -205,28 +222,34 @@ export class SLIP10Node implements SLIP10NodeInterface {
if (extendedKey.version === PRIVATE_KEY_VERSION) {
const { privateKey } = extendedKey;

return SLIP10Node.fromExtendedKey({
return SLIP10Node.fromExtendedKey(
{
depth,
parentFingerprint,
index,
privateKey,
chainCode,
// BIP-32 key serialisation assumes `secp256k1`.
curve: 'secp256k1',
},
cryptographicFunctions,
);
}

const { publicKey } = extendedKey;

return SLIP10Node.fromExtendedKey(
{
depth,
parentFingerprint,
index,
privateKey,
publicKey,
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',
});
},
cryptographicFunctions,
);
}

const {
Expand Down Expand Up @@ -276,6 +299,7 @@ export class SLIP10Node implements SLIP10NodeInterface {
publicKey: await curveObject.getPublicKey(privateKeyBytes),
curve,
},
cryptographicFunctions,
this.#constructorGuard,
);
}
Expand All @@ -293,6 +317,7 @@ export class SLIP10Node implements SLIP10NodeInterface {
publicKey: publicKeyBytes,
curve,
},
cryptographicFunctions,
this.#constructorGuard,
);
}
Expand Down Expand Up @@ -323,12 +348,14 @@ export class SLIP10Node implements SLIP10NodeInterface {
* @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 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 fromDerivationPath({
derivationPath,
curve,
}: SLIP10DerivationPathOptions) {
static async fromDerivationPath(
{ derivationPath, curve }: SLIP10DerivationPathOptions,
cryptographicFunctions?: CryptographicFunctions,
) {
validateCurve(curve);

if (!derivationPath) {
Expand All @@ -341,11 +368,14 @@ export class SLIP10Node implements SLIP10NodeInterface {
);
}

return await deriveKeyFromPath({
path: derivationPath,
depth: derivationPath.length - 1,
curve,
});
return await deriveKeyFromPath(
{
path: derivationPath,
depth: derivationPath.length - 1,
curve,
},
cryptographicFunctions,
);
}

static #constructorGuard = Symbol('SLIP10Node.constructor');
Expand All @@ -366,6 +396,8 @@ export class SLIP10Node implements SLIP10NodeInterface {

public readonly publicKeyBytes: Uint8Array;

#cryptographicFunctions: CryptographicFunctions;

// eslint-disable-next-line no-restricted-syntax
private constructor(
{
Expand All @@ -378,6 +410,7 @@ export class SLIP10Node implements SLIP10NodeInterface {
publicKey,
curve,
}: SLIP10NodeConstructorOptions,
cryptographicFunctions: CryptographicFunctions = {},
constructorGuard?: symbol,
) {
assert(
Expand All @@ -393,6 +426,7 @@ export class SLIP10Node implements SLIP10NodeInterface {
this.privateKeyBytes = privateKey;
this.publicKeyBytes = publicKey;
this.curve = curve;
this.#cryptographicFunctions = cryptographicFunctions;

Object.freeze(this);
}
Expand Down Expand Up @@ -491,6 +525,7 @@ export class SLIP10Node implements SLIP10NodeInterface {
publicKey: this.publicKeyBytes,
curve: this.curve,
},
this.#cryptographicFunctions,
SLIP10Node.#constructorGuard,
);
}
Expand All @@ -506,10 +541,13 @@ export class SLIP10Node implements SLIP10NodeInterface {
* @returns The {@link SLIP10Node} corresponding to the derived child key.
*/
public async derive(path: SLIP10PathTuple): Promise<SLIP10Node> {
return await deriveChildNode({
path,
node: this,
});
return await deriveChildNode(
{
path,
node: this,
},
this.#cryptographicFunctions,
);
}

// This is documented in the interface of this class.
Expand Down Expand Up @@ -638,12 +676,14 @@ type DeriveChildNodeArgs = {
* @param options - The options to use when deriving the child key.
* @param options.node - The node to derive from.
* @param options.path - The path to the child node / key.
* @param cryptographicFunctions - The cryptographic functions to use. If
* provided, these will be used instead of the built-in implementations.
* @returns The derived key and depth.
*/
export async function deriveChildNode({
path,
node,
}: DeriveChildNodeArgs): Promise<SLIP10Node> {
export async function deriveChildNode(
{ path, node }: DeriveChildNodeArgs,
cryptographicFunctions?: CryptographicFunctions,
): Promise<SLIP10Node> {
if (path.length === 0) {
throw new Error(
'Invalid HD tree derivation path: Deriving a path of length 0 is not defined.',
Expand All @@ -655,9 +695,12 @@ export async function deriveChildNode({
const newDepth = node.depth + path.length;
validateBIP32Depth(newDepth);

return await deriveKeyFromPath({
path,
node,
depth: newDepth,
});
return await deriveKeyFromPath(
{
path,
node,
depth: newDepth,
},
cryptographicFunctions,
);
}
135 changes: 135 additions & 0 deletions src/cryptography.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { bytesToHex } from '@metamask/utils';
import { webcrypto } from 'crypto';

import {
hmacSha512,
keccak256,
pbkdf2Sha512,
ripemd160,
sha256,
} from './cryptography';
import * as utils from './utils';

// Node.js <20 doesn't have `globalThis.crypto`, so we need to define it.
// TODO: Remove this once we drop support for Node.js <20.
Object.defineProperty(globalThis, 'crypto', { value: webcrypto });

describe('hmacSha512', () => {
it('returns the HMAC-SHA-512 when using a custom implementation', async () => {
const key = new Uint8Array(32);
const data = new Uint8Array(32);

const hash = new Uint8Array(64).fill(1);
const customHmacSha512 = jest.fn().mockResolvedValue(hash);

const result = await hmacSha512(key, data, {
hmacSha512: customHmacSha512,
});

expect(result).toBe(hash);
expect(customHmacSha512).toHaveBeenCalledWith(key, data);
});

it('returns the HMAC-SHA-512 when using the Web Crypto API', async () => {
const key = new Uint8Array(32);
const data = new Uint8Array(32);

const result = await hmacSha512(key, data);
expect(bytesToHex(result)).toBe(
'0xbae46cebebbb90409abc5acf7ac21fdb339c01ce15192c52fb9e8aa11a8de9a4ea15a045f2be245fbb98916a9ae81b353e33b9c42a55380c5158241daeb3c6dd',
);
});

it('returns the HMAC-SHA-512 when using the fallback', async () => {
jest.spyOn(utils, 'isWebCryptoSupported').mockReturnValueOnce(false);

const key = new Uint8Array(32);
const data = new Uint8Array(32);

const result = await hmacSha512(key, data);
expect(bytesToHex(result)).toBe(
'0xbae46cebebbb90409abc5acf7ac21fdb339c01ce15192c52fb9e8aa11a8de9a4ea15a045f2be245fbb98916a9ae81b353e33b9c42a55380c5158241daeb3c6dd',
);
});
});

describe('keccak256', () => {
it('returns the keccak-256 hash of the data', () => {
const data = new Uint8Array(32).fill(1);
const hash = keccak256(data);

expect(bytesToHex(hash)).toBe(
'0xcebc8882fecbec7fb80d2cf4b312bec018884c2d66667c67a90508214bd8bafc',
);
});
});

describe('pbkdf2Sha512', () => {
it('returns the PBKDF2-SHA-512 when using a custom implementation', async () => {
const password = new Uint8Array(32);
const salt = new Uint8Array(32);
const iterations = 1000;
const keyLength = 64;

const hash = new Uint8Array(64).fill(1);
const customPbkdf2Sha512 = jest.fn().mockResolvedValue(hash);

const result = await pbkdf2Sha512(password, salt, iterations, keyLength, {
pbkdf2Sha512: customPbkdf2Sha512,
});

expect(result).toBe(hash);
expect(customPbkdf2Sha512).toHaveBeenCalledWith(
password,
salt,
iterations,
keyLength,
);
});

it('returns the PBKDF2-SHA-512 when using the Web Crypto API', async () => {
const password = new Uint8Array(32);
const salt = new Uint8Array(32);
const iterations = 1000;
const keyLength = 64;

const result = await pbkdf2Sha512(password, salt, iterations, keyLength);
expect(bytesToHex(result)).toBe(
'0xab3d65e9e6341a924c752a77b8dc6b78f1e6db5d31df7dd0cc534039dd9662a97bcaf0b959fe78248a49859c7952ddb25d66840f052b27ef1ab60b9446c0c9fd',
);
});

it('returns the PBKDF2-SHA-512 when using the fallback', async () => {
jest.spyOn(utils, 'isWebCryptoSupported').mockReturnValueOnce(false);

const password = new Uint8Array(32);
const salt = new Uint8Array(32);
const iterations = 1000;
const keyLength = 64;

const result = await pbkdf2Sha512(password, salt, iterations, keyLength);
expect(bytesToHex(result)).toBe(
'0xab3d65e9e6341a924c752a77b8dc6b78f1e6db5d31df7dd0cc534039dd9662a97bcaf0b959fe78248a49859c7952ddb25d66840f052b27ef1ab60b9446c0c9fd',
);
});
});

describe('ripemd160', () => {
it('returns the RIPEMD-160 hash of the data', () => {
const data = new Uint8Array(32).fill(1);
const hash = ripemd160(data);

expect(bytesToHex(hash)).toBe('0x422d0010f16ae8539c53eb57a912890244a9eb5a');
});
});

describe('sha256', () => {
it('returns the SHA-256 hash of the data', () => {
const data = new Uint8Array(32).fill(1);
const hash = sha256(data);

expect(bytesToHex(hash)).toBe(
'0x72cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793',
);
});
});
Loading
Loading