Skip to content

Commit

Permalink
Support Cardano key derivation according to CIP3-Icarus (#158)
Browse files Browse the repository at this point in the history
* Add `cip3Icarus` fixtures

* Add `ed25519Bip32` derivation curve

* Differentiate master node derivation based on `curve` `masterNodeGenerationSpec`

* Support 64 byte long private keys

* Support fingerprints from 32 byte long public keys

* Allow `littleEndian` in `numberToUint32` util

* Add `cip3Icarus` deriver

* Add `CIP3IcarusNode`

* Generate and test `cip3Icarus` vectors

* Add `cip3Icarus` test vectors

* Rename `cip3Icarus` to `cip3`

To simplify the interface

* fixup! rename `cip3Icarus` to `cip3` in derivation test vectors

* Add `compressedPublicKeyLength` curve parameter

So we can validate against it when creating fingerprint

* Refactor `bip39` deriveChildKey `masterNodeGenerationSpec` condition to switch

* fixup! curve specification to separate type

* fixup! remove underscore

* fixup! inline unnecessary functions

* fixup! add JSDOCs to ed25519Bip32 file

* fixup! quotes in src/curves/ed25519Bip32.test.ts

Co-authored-by: Maarten Zuidhoorn <maarten@zuidhoorn.com>

* fixup! quotes in src/curves/ed25519Bip32.test.ts

Co-authored-by: Maarten Zuidhoorn <maarten@zuidhoorn.com>

* fixyup! empty line in src/derivation.test.ts

Co-authored-by: Maarten Zuidhoorn <maarten@zuidhoorn.com>

* fixup! `eslint-enable no-bitwise` in src/derivers/bip39.ts

Co-authored-by: Maarten Zuidhoorn <maarten@zuidhoorn.com>

* fixup! add empty lines between test in `cip3` file

* fixup! add JSDOCs to `cip3` file

* fixup! `_` in src/curves/ed25519Bip32.ts

Co-authored-by: Maarten Zuidhoorn <maarten@zuidhoorn.com>

* fixup! improve error message in src/derivers/cip3.ts

Co-authored-by: Maarten Zuidhoorn <maarten@zuidhoorn.com>

* fixup! improve error message in src/utils.ts

Co-authored-by: Maarten Zuidhoorn <maarten@zuidhoorn.com>

* fixup! fix quotes and test in fixes from github

* Test all fns in `ed25519Bip32`

* Test all fns in `cip3`

* fixup! add test to have 100% coverage

* fixup! test coverage

* fixup! lint

---------

Co-authored-by: Maarten Zuidhoorn <maarten@zuidhoorn.com>
  • Loading branch information
PeterBenc and Mrtenz authored Mar 7, 2024
1 parent c6251c5 commit bfa679e
Show file tree
Hide file tree
Showing 25 changed files with 38,933 additions and 45 deletions.
20 changes: 13 additions & 7 deletions scripts/generate-vectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ import {
ed25519,
MAX_BIP_32_INDEX,
secp256k1,
ed25519Bip32,
} from '../src';
import type { Curve } from '../src/curves';
import { createBip39KeyFromSeed } from '../src/derivers/bip39';
import { type Curve } from '../src/curves';
import { deriveChildKey } from '../src/derivers/bip39';

/**
* Get a random boolean value.
Expand Down Expand Up @@ -58,7 +59,7 @@ function getRandomSeed(length = randomInt(16, 64)) {
* @returns A random BIP-32 path.
*/
function getRandomPath(
spec: 'bip32' | 'slip10',
spec: 'bip32' | 'slip10' | 'cip3',
length = randomInt(1, 20),
hardened?: boolean,
) {
Expand All @@ -84,7 +85,7 @@ function getRandomPath(
*/
async function getRandomKeyVector(
node: SLIP10Node,
spec: 'bip32' | 'slip10',
spec: 'bip32' | 'slip10' | 'cip3',
hardened?: boolean,
) {
const path = getRandomPath(spec, undefined, hardened);
Expand Down Expand Up @@ -116,13 +117,13 @@ async function getRandomKeyVector(
* @param curve - The curve to use. Defaults to secp256k1.
*/
async function getRandomVector(
spec: 'bip32' | 'slip10',
spec: 'bip32' | 'slip10' | 'cip3',
amount = 10,
hardened?: boolean,
curve: Curve = secp256k1,
) {
const seed = getRandomSeed();
const node = await createBip39KeyFromSeed(seed, curve);
const node = await deriveChildKey({ path: seed, curve });

return {
hexSeed: bytesToHex(seed),
Expand Down Expand Up @@ -153,7 +154,7 @@ async function getRandomVector(
* @returns The random vectors.
*/
async function getRandomVectors(
spec: 'bip32' | 'slip10',
spec: 'bip32' | 'slip10' | 'cip3',
amount = 10,
hardened?: boolean,
curve: Curve = secp256k1,
Expand Down Expand Up @@ -185,6 +186,11 @@ async function getOutput() {
unhardened: await getRandomVectors('slip10', 50, false),
mixed: await getRandomVectors('bip32', 50),
},
cip3: {
hardened: await getRandomVectors('cip3', 50, true, ed25519Bip32),
unhardened: await getRandomVectors('cip3', 50, false, ed25519Bip32),
mixed: await getRandomVectors('cip3', 50, undefined, ed25519Bip32),
},
};
}

Expand Down
2 changes: 1 addition & 1 deletion src/SLIP10Node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -525,7 +525,7 @@ describe('SLIP10Node', () => {
specification: 'bip32',
}),
).rejects.toThrow(
'Invalid curve: Only the following curves are supported: secp256k1, ed25519.',
'Invalid curve: Only the following curves are supported: secp256k1, ed25519, ed25519Bip32.',
);
});
});
Expand Down
10 changes: 8 additions & 2 deletions src/SLIP10Node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,10 @@ export class SLIP10Node implements SLIP10NodeInterface {
const curveObject = getCurveByName(curve);

if (privateKey) {
const privateKeyBytes = getBytesUnsafe(privateKey, BYTES_KEY_LENGTH);
const privateKeyBytes = getBytesUnsafe(
privateKey,
curveObject.privateKeyLength,
);
assert(
curveObject.isValidPrivateKey(privateKeyBytes),
`Invalid private key: Value is not a valid ${curve} private key.`,
Expand Down Expand Up @@ -344,7 +347,10 @@ export class SLIP10Node implements SLIP10NodeInterface {
}

public get fingerprint(): number {
return getFingerprint(this.compressedPublicKeyBytes);
return getFingerprint(
this.compressedPublicKeyBytes,
getCurveByName(this.curve).compressedPublicKeyLength,
);
}

/**
Expand Down
18 changes: 16 additions & 2 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export type HardenedSLIP10Node = `slip10:${number}'`;
export type UnhardenedSLIP10Node = `slip10:${number}`;
export type SLIP10PathNode = HardenedSLIP10Node | UnhardenedSLIP10Node;

export type HardenedCIP3Node = `cip3:${number}'`;
export type UnhardenedCIP3Node = `cip3:${number}`;
export type CIP3PathNode = HardenedCIP3Node | UnhardenedCIP3Node;

export const BIP44PurposeNodeToken = `bip32:44'`;

export const UNPREFIXED_PATH_REGEX = /^\d+$/u;
Expand All @@ -59,6 +63,13 @@ export const BIP_32_PATH_REGEX = /^bip32:\d+'?$/u;
*/
export const SLIP_10_PATH_REGEX = /^slip10:\d+'?$/u;

/**
* e.g.
* - cip3:0
* - cip3:0'
*/
export const CIP_3_PATH_REGEX = /^cip3:\d+'?$/u;

/**
* bip39:<SPACE_DELMITED_SEED_PHRASE>
*
Expand Down Expand Up @@ -164,10 +175,13 @@ export type HDPathTuple = RootedHDPathTuple | PartialHDPathTuple;

export type RootedSLIP10PathTuple = readonly [
BIP39Node,
...(BIP32Node[] | SLIP10PathNode[]),
...(BIP32Node[] | SLIP10PathNode[] | CIP3PathNode[]),
];

export type SLIP10PathTuple = readonly BIP32Node[] | readonly SLIP10PathNode[];
export type SLIP10PathTuple =
| readonly BIP32Node[]
| readonly SLIP10PathNode[]
| readonly CIP3PathNode[];
export type SLIP10Path = RootedSLIP10PathTuple | SLIP10PathTuple;

export type FullHDPathTuple = RootedHDPathTuple5;
17 changes: 15 additions & 2 deletions src/curves/curve.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,26 @@
import * as ed25519 from './ed25519';
import * as ed25519Bip32 from './ed25519Bip32';
import * as secp256k1 from './secp256k1';

export type SupportedCurve = keyof typeof curves;

export const curves = {
secp256k1,
ed25519,
ed25519Bip32,
};

type CurveSpecification =
| {
masterNodeGenerationSpec: 'slip10';
name: Extract<SupportedCurve, 'secp256k1' | 'ed25519'>;
}
| {
name: Extract<SupportedCurve, 'ed25519Bip32'>;
masterNodeGenerationSpec: 'cip3';
};

export type Curve = {
name: SupportedCurve;
secret: Uint8Array;
deriveUnhardenedKeys: boolean;
publicKeyLength: number;
Expand All @@ -24,7 +35,9 @@ export type Curve = {
publicAdd: (publicKey: Uint8Array, tweak: Uint8Array) => Uint8Array;
compressPublicKey: (publicKey: Uint8Array) => Uint8Array;
decompressPublicKey: (publicKey: Uint8Array) => Uint8Array;
};
privateKeyLength: number;
compressedPublicKeyLength: number;
} & CurveSpecification;

/**
* Get a curve by name.
Expand Down
6 changes: 6 additions & 0 deletions src/curves/ed25519.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,9 @@ export const decompressPublicKey = (publicKey: Uint8Array): Uint8Array => {
// Ed25519 public keys don't have a compressed form.
return publicKey;
};

export const privateKeyLength = 32;

export const masterNodeGenerationSpec = 'slip10';

export const compressedPublicKeyLength = 33;
90 changes: 90 additions & 0 deletions src/curves/ed25519Bip32.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { bytesToHex, hexToBytes } from '@metamask/utils';

import { ed25519Bip32 } from '.';
import fixtures from '../../test/fixtures';
import {
bytesToNumberLE,
compressPublicKey,
decompressPublicKey,
isValidPrivateKey,
multiplyWithBase,
} from './ed25519Bip32';

describe('getPublicKey', () => {
fixtures.cip3.forEach((fixture) => {
Object.values(fixture.nodes).forEach((node) => {
it('returns correct public key from private key', async () => {
const publicKey = await ed25519Bip32.getPublicKey(
hexToBytes(node.privateKey),
);

expect(bytesToHex(publicKey)).toBe(node.publicKey);
});
});
});
});

describe('publicAdd', () => {
it('returns correct public key from private key', async () => {
const publicKey = hexToBytes(fixtures.cip3[0].nodes.bip39Node.publicKey);
const tweak = hexToBytes(fixtures.cip3[0].nodes.purposeNode.publicKey);
const added = ed25519Bip32.publicAdd(publicKey, tweak);

expect(bytesToHex(added)).toBe(
'0xf78d2a445afe9c961ac196fbac282b499d9ab6bbe8801354ee06fc22d46503e2',
);
});
});

describe('isValidPrivateKey', () => {
it('returns true for bigint input', () => {
const { privateKey } = fixtures.cip3[0].nodes.bip39Node;
expect(isValidPrivateKey(privateKey)).toBe(true);
});
});

describe('compressPublicKey', () => {
it('returns the same Uint8Array that was input', () => {
const publicKey = Uint8Array.from(
Buffer.from(fixtures.cip3[0].nodes.bip39Node.publicKey, 'hex'),
);
expect(compressPublicKey(publicKey)).toStrictEqual(publicKey);
});
});

describe('decompressPublicKey', () => {
it('returns the same Uint8Array that was input', () => {
const publicKey = Uint8Array.from(
Buffer.from(fixtures.cip3[0].nodes.bip39Node.publicKey, 'hex'),
);
expect(decompressPublicKey(publicKey)).toStrictEqual(publicKey);
});
});

describe('bytesToNumberLE', () => {
it('converts bytes to little endian bignumber', () => {
const bytes = Uint8Array.from([
240, 230, 228, 13, 229, 184, 174, 13, 156, 72, 248, 206, 127, 130, 146,
49, 175, 244, 32, 215, 146, 255, 153, 93, 197, 96, 64, 249, 123, 140, 119,
72,
]);
expect(bytesToNumberLE(bytes)).toBe(
32777749485515042639882960539696351427945957558989008047469858024981459691248n,
);
});
});

describe('multiplyWithBase', () => {
it('multiplies bytes with the curve base', () => {
const bytes = Uint8Array.from([
240, 230, 228, 13, 229, 184, 174, 13, 156, 72, 248, 206, 127, 130, 146,
49, 175, 244, 32, 215, 146, 255, 153, 93, 197, 96, 64, 249, 123, 140, 119,
72,
]);
const expectedResult = Uint8Array.from([
64, 197, 223, 88, 143, 127, 45, 60, 205, 81, 148, 125, 195, 249, 173, 214,
27, 176, 227, 21, 216, 243, 146, 168, 189, 206, 85, 135, 89, 11, 210, 27,
]);
expect(multiplyWithBase(bytes)).toStrictEqual(expectedResult);
});
});
Loading

0 comments on commit bfa679e

Please sign in to comment.