Skip to content

Commit

Permalink
feat: custom derivation paths in Menmonic ECDSA private key derivation
Browse files Browse the repository at this point in the history
Signed-off-by: Brendan Graetz <[email protected]>
  • Loading branch information
bguiz committed Jun 12, 2024
1 parent 1963a89 commit b99c1a6
Show file tree
Hide file tree
Showing 2 changed files with 134 additions and 0 deletions.
75 changes: 75 additions & 0 deletions src/Mnemonic.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import CACHE from "./Cache.js";
* @typedef {import("./PrivateKey.js").default} PrivateKey
*/

const HARDENED_BIT = 0x80000000;

/**
* Multi-word mnemonic phrase (BIP-39).
*
Expand Down Expand Up @@ -136,6 +138,57 @@ export default class Mnemonic {
);
}

/**
* Converts a derivation path from string to an array of integers.
* Note that this expects precisely 5 components in the derivation path,
* as per BIP-44:
* `m / purpose' / coin_type' / account' / change / address_index`
* Takes into account `'` for hardening as per BIP-32,
* and does not prescribe which components should be hardened.
*
* @param {string} derivationPath the derivation path in BIP-44 format,
* e.g. "m/44'/60'/0'/0/0"
* @returns {Array<number>} to be used with PrivateKey#derive
*/
calculateDerivationPathValues(derivationPath) {
// Parse the derivation path from string into values
const pattern = /m\/(\d+'?)\/(\d+'?)\/(\d+'?)\/(\d+'?)\/(\d+'?)/;
const matches = pattern.exec(derivationPath);
const values = new Array(5); // as Array<Number>;
if (matches) {
// Extract numbers and use apostrophe to select if is hardened
for (let i = 1; i <= 5; i++) {
let value = matches[i];
if (value.endsWith("'")) {
value = value.substring(0, value.length - 1);
values[i - 1] = parseInt(value, 10) | HARDENED_BIT;
} else {
values[i - 1] = parseInt(value, 10);
}
}
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return values;
}

/**
* Common implementation for both `toStandardECDSAsecp256k1PrivateKey`
* functions.
*
* @param {string} passphrase the passphrase used to protect the
* mnemonic, use "" for none
* @param {Array<number>} derivationPathValues derivation path as an
* integer array,
* see: `calculateDerivationPathValues`
* @returns a private key

Check warning on line 183 in src/Mnemonic.js

View workflow job for this annotation

GitHub Actions / Test using Node 16

Missing JSDoc @returns type

Check warning on line 183 in src/Mnemonic.js

View workflow job for this annotation

GitHub Actions / Build using Node 16

Missing JSDoc @returns type

Check warning on line 183 in src/Mnemonic.js

View workflow job for this annotation

GitHub Actions / Integration Tests on Node 16

Missing JSDoc @returns type

Check warning on line 183 in src/Mnemonic.js

View workflow job for this annotation

GitHub Actions / Build using Node 18

Missing JSDoc @returns type

Check warning on line 183 in src/Mnemonic.js

View workflow job for this annotation

GitHub Actions / Integration Tests on Node 18

Missing JSDoc @returns type
*/
async toStandardECDSAsecp256k1PrivateKeyImpl(
passphrase,
derivationPathValues,
) {
return this.toEcdsaPrivateKey(passphrase, derivationPathValues);
}

/**
* Recover an ECDSA private key from this mnemonic phrase, with an
* optional passphrase.
Expand All @@ -153,6 +206,28 @@ export default class Mnemonic {
);
}

/**
* Recover an ECDSAsecp256k1 private key from this mnemonic phrase and
* derivation path, with an optional passphrase
*
* @param {string} passphrase the passphrase used to protect the mnemonic,
* use "" for none
* @param {string} derivationPath the derivation path in BIP-44 format,
* e.g. "m/44'/60'/0'/0/0"
* @returns {Promise<PrivateKey>} the private key
*/
async toStandardECDSAsecp256k1PrivateKeyCustomDerivationPath(
passphrase = "",
derivationPath,
) {
const derivationPathValues =
this.calculateDerivationPathValues(derivationPath);
return await this.toStandardECDSAsecp256k1PrivateKeyImpl(
passphrase,
derivationPathValues,
);
}

/**
* Recover a mnemonic phrase from a string, splitting on spaces. Handles 12, 22 (legacy), and 24 words.
*
Expand Down
59 changes: 59 additions & 0 deletions test/unit/Mnemonic.js
Original file line number Diff line number Diff line change
Expand Up @@ -496,4 +496,63 @@ describe("Mnemonic", function () {
expect(key6.toStringRaw()).to.be.equal(PRIVATE_KEY6);
expect(key6.publicKey.toStringRaw()).to.be.equal(PUBLIC_KEY6);
});

it("Mnemonic.toStandardECDSAsecp256k1PrivateKeyCustomDerivationPath() test vector", async function () {
const DPATH_1 = "m/44'/60'/0'/0/0";
const PASSPHRASE_1 = "";
const CHAIN_CODE_1 =
"58a9ee31eaf7499abc01952b44dbf0a2a5d6447512367f09d99381c9605bf9e8";
const PRIVATE_KEY_1 =
"78f9545e40025cf7da9126a4d6a861ae34031d1c74c3404df06110c9fde371ad";
const PUBLIC_KEY_1 =
"02a8f4c22eea66617d4f119e3a951b93f584949bbfee90bd555305402da6c4e569";

const DPATH_2 = "m/44'/60'/0'/0/1";
const PASSPHRASE_2 = "";
const CHAIN_CODE_2 =
"6dcfc7a4914bd0e75b94a2f38afee8c247b34810202a2c64fe599ee1b88afdc9";
const PRIVATE_KEY_2 =
"77ca263661ebdd5a8b33c224aeff5e7bf67eedacee68a1699d97ee8929d7b130";
const PUBLIC_KEY_2 =
"03e84c9be9be53ad722038cc1943e79df27e5c1d31088adb4f0e62444f4dece683";

const DPATH_3 = "m/44'/60'/0'/0/2";
const PASSPHRASE_3 = "";
const CHAIN_CODE_3 =
"c8c798d2b3696be1e7a29d1cea205507eedc2057006b9ef1cde1b4e346089e17";
const PRIVATE_KEY_3 =
"31c24292eac951279b659c335e44a2e812d0f1a228b1d4d87034874d376e605a";
const PUBLIC_KEY_3 =
"0207ff3faf4055c1aa7a5ad94d6ff561fac35b9ae695ef486706243667d2b4d10e";

const mnemonic1 = await Mnemonic.fromString(MNEMONIC_24_WORD_STRING);
const key1 =
await mnemonic1.toStandardECDSAsecp256k1PrivateKeyCustomDerivationPath(
PASSPHRASE_1,
DPATH_1,
);
expect(hex.encode(key1.chainCode)).to.equal(CHAIN_CODE_1);
expect(key1.toStringRaw()).to.equal(PRIVATE_KEY_1);
expect(key1.publicKey.toStringRaw()).to.include(PUBLIC_KEY_1);

const mnemonic2 = await Mnemonic.fromString(MNEMONIC_24_WORD_STRING);
const key2 =
await mnemonic2.toStandardECDSAsecp256k1PrivateKeyCustomDerivationPath(
PASSPHRASE_2,
DPATH_2,
);
expect(hex.encode(key2.chainCode)).to.equal(CHAIN_CODE_2);
expect(key2.toStringRaw()).to.equal(PRIVATE_KEY_2);
expect(key2.publicKey.toStringRaw()).to.include(PUBLIC_KEY_2);

const mnemonic3 = await Mnemonic.fromString(MNEMONIC_24_WORD_STRING);
const key3 =
await mnemonic3.toStandardECDSAsecp256k1PrivateKeyCustomDerivationPath(
PASSPHRASE_3,
DPATH_3,
);
expect(hex.encode(key3.chainCode)).to.equal(CHAIN_CODE_3);
expect(key3.toStringRaw()).to.equal(PRIVATE_KEY_3);
expect(key3.publicKey.toStringRaw()).to.include(PUBLIC_KEY_3);
});
});

0 comments on commit b99c1a6

Please sign in to comment.