Skip to content

Commit

Permalink
feat: custom derivation paths in Mnemonic ECDSA private key derivation (
Browse files Browse the repository at this point in the history
#1842)

Signed-off-by: Brendan Graetz <bguiz@users.noreply.github.com>
  • Loading branch information
bguiz authored Jul 2, 2024
1 parent d032061 commit 1555eb5
Show file tree
Hide file tree
Showing 2 changed files with 193 additions and 9 deletions.
105 changes: 96 additions & 9 deletions sdk/src/main/java/com/hedera/hashgraph/sdk/Mnemonic.java
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.function.Consumer;
import java.util.function.Supplier;

Expand Down Expand Up @@ -557,28 +559,113 @@ public PrivateKey toStandardEd25519PrivateKey(String passphrase, int index) {
return derivedKey;
}

/**
* 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 derivationPath the derivation path in BIP-44 format,
* e.g. "m/44'/60'/0'/0/0"
* @return an array of integers designed to be used with PrivateKey#derive
*/
private int[] calculateDerivationPathValues(String derivationPath)
throws IllegalArgumentException
{
if (derivationPath == null || derivationPath.isEmpty()) {
throw new IllegalArgumentException("Derivation path cannot be null or empty");
}

// Parse the derivation path from string into values
Pattern pattern = Pattern.compile("m/(\\d+'?)/(\\d+'?)/(\\d+'?)/(\\d+'?)/(\\d+'?)");
Matcher matcher = pattern.matcher(derivationPath);
if (!matcher.matches()) {
throw new IllegalArgumentException("Invalid derivation path format");
}

int[] numbers = new int[5];
boolean[] isHardened = new boolean[5];
try {
// Extract numbers and use apostrophe to select if is hardened
for (int i = 1; i <= 5; i++) {
String value = matcher.group(i);
if (value.endsWith("'")) {
isHardened[i - 1] = true;
value = value.substring(0, value.length() - 1);
} else {
isHardened[i - 1] = false;
}
numbers[i - 1] = Integer.parseInt(value);
}
} catch (NumberFormatException nfe) {
throw new IllegalArgumentException("Invalid number format in derivation path", nfe);
}

// Derive private key one index at a time
int[] values = new int[5];
for (int i = 0; i < numbers.length; i++) {
values[i] = (isHardened[i] ? Bip32Utils.toHardenedIndex(numbers[i]) : numbers[i]);
}

return values;
}

/**
* Common implementation for both `toStandardECDSAsecp256k1PrivateKey`
* functions.
*
* @param passphrase the passphrase used to protect the
* mnemonic, use "" for none
* @param derivationPathValues derivation path as an integer array,
* see: `calculateDerivationPathValues`
* @return a private key
*/
private PrivateKey toStandardECDSAsecp256k1PrivateKeyImpl(String passphrase, int[] derivationPathValues) {
var seed = this.toSeed(passphrase);
PrivateKey derivedKey = PrivateKey.fromSeedECDSAsecp256k1(seed);

for (int derivationPathValue : derivationPathValues) {
derivedKey = derivedKey.derive(derivationPathValue);
}
return derivedKey;
}

/**
* Recover an ECDSAsecp256k1 private key from this mnemonic phrase, with an
* optional passphrase.
* Uses the default derivation path of `m/44'/3030'/0'/0/${index}`.
*
* @param passphrase the passphrase used to protect the mnemonic
* @param passphrase the passphrase used to protect the mnemonic,
* use "" for none
* @param index the derivation index
* @return the private key
*/
public PrivateKey toStandardECDSAsecp256k1PrivateKey(String passphrase, int index) {
var seed = this.toSeed(passphrase);
PrivateKey derivedKey = PrivateKey.fromSeedECDSAsecp256k1(seed);

// Harden the first 3 indexes
for (int i : new int[]{
final int[] derivationPathValues = new int[]{
Bip32Utils.toHardenedIndex(44),
Bip32Utils.toHardenedIndex(3030),
Bip32Utils.toHardenedIndex(0),
0,
index}) {
derivedKey = derivedKey.derive(i);
}
index
};
return toStandardECDSAsecp256k1PrivateKeyImpl(passphrase, derivationPathValues);
}

return derivedKey;
/**
* Recover an ECDSAsecp256k1 private key from this mnemonic phrase and
* derivation path, with an optional passphrase.
*
* @param passphrase the passphrase used to protect the mnemonic,
* use "" for none
* @param derivationPath the derivation path in BIP-44 format,
* e.g. "m/44'/60'/0'/0/0"
* @return the private key
*/
public PrivateKey toStandardECDSAsecp256k1PrivateKeyCustomDerivationPath(String passphrase, String derivationPath) {
final int[] derivationPathValues = calculateDerivationPathValues(derivationPath);
return toStandardECDSAsecp256k1PrivateKeyImpl(passphrase, derivationPathValues);
}
}
97 changes: 97 additions & 0 deletions sdk/src/test/java/com/hedera/hashgraph/sdk/MnemonicTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertThrowsExactly;

public class MnemonicTest {
private static final String MNEMONIC_LEGACY_V1_STRING = "jolly kidnap tom lawn drunk chick optic lust mutter mole bride galley dense member sage neural widow decide curb aboard margin manure";
Expand Down Expand Up @@ -671,4 +673,99 @@ void toStandardECDSAsecp256k1PrivateKey2() throws BadMnemonicException {
assertThat(key6.toStringRaw()).isEqualTo(PRIVATE_KEY6);
assertThat(key6.getPublicKey().toStringRaw()).isSubstringOf(PUBLIC_KEY6);
}

@Test
@DisplayName("Mnemonic.toStandardECDSAsecp256k1PrivateKeyCustomDerivationPath() with custom derivation path invalid inputs vector")
void toStandardECDSAsecp256k1PrivateKeyCustomDpathInvalidInputs() throws BadMnemonicException {
final String DPATH_1 = "XYZ/44'/60'/0'/0/0"; // invalid derivation path
final String PASSPHRASE_1 = "";
final String CHAIN_CODE_1 = "58a9ee31eaf7499abc01952b44dbf0a2a5d6447512367f09d99381c9605bf9e8";
final String PRIVATE_KEY_1 = "78f9545e40025cf7da9126a4d6a861ae34031d1c74c3404df06110c9fde371ad";
final String PUBLIC_KEY_1 = "02a8f4c22eea66617d4f119e3a951b93f584949bbfee90bd555305402da6c4e569";

final String DPATH_2 = ""; // null or empty derivation path
final String PASSPHRASE_2 = "";
final String CHAIN_CODE_2 = "6dcfc7a4914bd0e75b94a2f38afee8c247b34810202a2c64fe599ee1b88afdc9";
final String PRIVATE_KEY_2 = "77ca263661ebdd5a8b33c224aeff5e7bf67eedacee68a1699d97ee8929d7b130";
final String PUBLIC_KEY_2 = "03e84c9be9be53ad722038cc1943e79df27e5c1d31088adb4f0e62444f4dece683";

final String DPATH_3 = "m/44'/60'/0'/0/6-7-8-9-0"; // invalid numeric value in derivation path
final String PASSPHRASE_3 = "";
final String CHAIN_CODE_3 = "c8c798d2b3696be1e7a29d1cea205507eedc2057006b9ef1cde1b4e346089e17";
final String PRIVATE_KEY_3 = "31c24292eac951279b659c335e44a2e812d0f1a228b1d4d87034874d376e605a";
final String PUBLIC_KEY_3 = "0207ff3faf4055c1aa7a5ad94d6ff561fac35b9ae695ef486706243667d2b4d10e";

assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() ->{
Mnemonic mnemonic = Mnemonic.fromString(MNEMONIC_24_WORD_STRING);
mnemonic.toStandardECDSAsecp256k1PrivateKeyCustomDerivationPath(
PASSPHRASE_1, DPATH_1);
}).satisfies(
(iae) -> {
assertThat(iae.getMessage()).isEqualTo("Invalid derivation path format");
}
);

assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() ->{
Mnemonic mnemonic = Mnemonic.fromString(MNEMONIC_24_WORD_STRING);
mnemonic.toStandardECDSAsecp256k1PrivateKeyCustomDerivationPath(
PASSPHRASE_2, DPATH_2);
}).satisfies(
(iae) -> {
assertThat(iae.getMessage()).isEqualTo("Derivation path cannot be null or empty");
}
);

assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() ->{
Mnemonic mnemonic = Mnemonic.fromString(MNEMONIC_24_WORD_STRING);
mnemonic.toStandardECDSAsecp256k1PrivateKeyCustomDerivationPath(
PASSPHRASE_3, DPATH_3);
}).satisfies(
(iae) -> {
assertThat(iae.getMessage()).isEqualTo("Invalid derivation path format");
}
);
}

@Test
@DisplayName("Mnemonic.toStandardECDSAsecp256k1PrivateKeyCustomDerivationPath() with custom derivation path test vector")
void toStandardECDSAsecp256k1PrivateKeyCustomDpath() throws BadMnemonicException {
final String DPATH_1 = "m/44'/60'/0'/0/0";
final String PASSPHRASE_1 = "";
final String CHAIN_CODE_1 = "58a9ee31eaf7499abc01952b44dbf0a2a5d6447512367f09d99381c9605bf9e8";
final String PRIVATE_KEY_1 = "78f9545e40025cf7da9126a4d6a861ae34031d1c74c3404df06110c9fde371ad";
final String PUBLIC_KEY_1 = "02a8f4c22eea66617d4f119e3a951b93f584949bbfee90bd555305402da6c4e569";

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

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

Mnemonic mnemonic = Mnemonic.fromString(MNEMONIC_24_WORD_STRING);

// m/44'/60'/0'/0/0
PrivateKey key1 = mnemonic.toStandardECDSAsecp256k1PrivateKeyCustomDerivationPath(PASSPHRASE_1, DPATH_1);
assertThat(Hex.toHexString(key1.getChainCode().getKey())).isEqualTo(CHAIN_CODE_1);
assertThat(key1.toStringRaw()).isEqualTo(PRIVATE_KEY_1);
assertThat(key1.getPublicKey().toStringRaw()).isSubstringOf(PUBLIC_KEY_1);

// m/44'/60'/0'/0/1
PrivateKey key2 = mnemonic.toStandardECDSAsecp256k1PrivateKeyCustomDerivationPath(PASSPHRASE_2, DPATH_2);
assertThat(Hex.toHexString(key2.getChainCode().getKey())).isEqualTo(CHAIN_CODE_2);
assertThat(key2.toStringRaw()).isEqualTo(PRIVATE_KEY_2);
assertThat(key2.getPublicKey().toStringRaw()).isSubstringOf(PUBLIC_KEY_2);

// m/44'/60'/0'/0/2
PrivateKey key3 = mnemonic.toStandardECDSAsecp256k1PrivateKeyCustomDerivationPath(PASSPHRASE_3, DPATH_3);
assertThat(Hex.toHexString(key3.getChainCode().getKey())).isEqualTo(CHAIN_CODE_3);
assertThat(key3.toStringRaw()).isEqualTo(PRIVATE_KEY_3);
assertThat(key3.getPublicKey().toStringRaw()).isSubstringOf(PUBLIC_KEY_3);
}

}

0 comments on commit 1555eb5

Please sign in to comment.