Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,27 @@ The `spring.hiero.network` property defines the network that is used to interact
In the given example, the [Hedera](https://hedera.com) testnet network is used.
Hedera is based on Hiero and therefore the testnet can be used to interact with a Hiero network.
The account information (`accountId`, `privateKey`) can all be found at the [Hedera portal](https://portal.hedera.com/) for a testnet or previewnet account.
Today only the "DER Encoded Private Key" of the "ECDSA" key type is supported for the `spring.hiero.privateKey` property.

**Private Key Formats**: The `spring.hiero.privateKey` property supports multiple private key formats:
- **DER Encoded**: Traditional DER-encoded ECDSA private key (default Hedera format)
- **PEM Format**: Standard PEM-encoded private key with BEGIN/END headers
- **Raw Hex**: 32-byte private key as hexadecimal string (with or without 0x prefix)
- **Legacy**: Any format supported by the underlying Hedera SDK

Examples:
```properties
# DER format (existing format)
spring.hiero.privateKey=2130020100312346052b8104400304220420c236508c429395a8180b1230f436d389adc5afaa9145456783b57b2045c6cc37

# Raw hex format (32 bytes)
spring.hiero.privateKey=c236508c429395a8180b1230f436d389adc5afaa9145456783b57b2045c6cc37

# Raw hex with 0x prefix
spring.hiero.privateKey=0xc236508c429395a8180b1230f436d389adc5afaa9145456783b57b2045c6cc37

# PEM format
spring.hiero.privateKey=-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg...\n-----END PRIVATE KEY-----
```

The 2 properties `spring.hiero.accountId` and `spring.hiero.privateKey` define the "operator account".
The operator account is used as the account that sends all transactions against the Hiero network.
Expand Down Expand Up @@ -158,8 +178,9 @@ The tests in the project are working against any Hiero network.
You need to provide the account id and the private key of an account that is used to run the tests.
**If no account is provided, the tests will fail.**
The most easy way to run the tests is to use the Hedera testnet network.
To run the tests, you need to provide the account id and the "DER Encoded Private Key" of the "ECDSA" testnet account.
That information can be provided as environemt variables:
To run the tests, you need to provide the account id and private key of a testnet account.
The private key can be provided in any supported format (DER, PEM, or raw hex).
That information can be provided as environment variables:

```shell
export HEDERA_ACCOUNT_ID=0.0.3447271
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
package com.openelements.hiero.base.config;

import com.hedera.hashgraph.sdk.PrivateKey;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import java.util.regex.Pattern;
import org.jspecify.annotations.NonNull;

/**
* Utility class for parsing private keys from various formats including DER, PEM, and hexadecimal.
* This class provides enhanced private key support beyond the default Hedera SDK capabilities.
*
* <p>Supported formats:
* <ul>
* <li><strong>DER (binary)</strong>: Raw DER-encoded private key as hex string</li>
* <li><strong>PEM</strong>: PEM-formatted private key with BEGIN/END headers</li>
* <li><strong>Hex</strong>: Raw 32-byte private key as hexadecimal string</li>
* <li><strong>Legacy</strong>: Any format supported by Hedera SDK (for backward compatibility)</li>
* </ul>
*
* @since 0.21.0
*/
public final class PrivateKeyParser {

private static final String PEM_HEADER = "-----BEGIN PRIVATE KEY-----";
private static final String PEM_FOOTER = "-----END PRIVATE KEY-----";
private static final String PEM_EC_HEADER = "-----BEGIN EC PRIVATE KEY-----";
private static final String PEM_EC_FOOTER = "-----END EC PRIVATE KEY-----";

// Pattern for hex strings (with or without 0x prefix)
private static final Pattern HEX_PATTERN = Pattern.compile("^(0x)?[0-9a-fA-F]+$");

// Expected length for raw ECDSA private key (32 bytes = 64 hex characters)
private static final int RAW_PRIVATE_KEY_LENGTH = 64;

private PrivateKeyParser() {
// Utility class - prevent instantiation
}

/**
* Parses a private key from various supported formats.
*
* @param privateKeyString the private key string in any supported format
* @return the parsed PrivateKey instance
* @throws IllegalArgumentException if the private key cannot be parsed or is in an unsupported format
* @throws NullPointerException if privateKeyString is null
*/
@NonNull
public static PrivateKey parsePrivateKey(@NonNull final String privateKeyString) {
Objects.requireNonNull(privateKeyString, "privateKeyString must not be null");

final String trimmed = privateKeyString.trim();
if (trimmed.isEmpty()) {
throw new IllegalArgumentException("Private key string cannot be empty");
}

try {
// Try to detect and parse the format
if (isPemFormat(trimmed)) {
return parseFromPem(trimmed);
} else if (isHexFormat(trimmed)) {
return parseFromHex(trimmed);
} else {
// Fall back to default Hedera SDK parsing (for DER and other formats)
return parseFromDefault(trimmed);
}
} catch (Exception e) {
throw new IllegalArgumentException(
"Cannot parse private key. Supported formats: PEM, DER (hex), raw hex (32 bytes). " +
"Provided key format: " + detectFormat(trimmed) +
". Error: " + e.getMessage(), e);
}
}

/**
* Detects the format of the private key string for error reporting.
*
* @param privateKeyString the private key string
* @return a human-readable description of the detected format
*/
@NonNull
public static String detectFormat(@NonNull final String privateKeyString) {
Objects.requireNonNull(privateKeyString, "privateKeyString must not be null");

final String trimmed = privateKeyString.trim();

if (isPemFormat(trimmed)) {
return "PEM format";
} else if (isHexFormat(trimmed)) {
final String cleanHex = trimmed.startsWith("0x") || trimmed.startsWith("0X") ? trimmed.substring(2) : trimmed;
if (cleanHex.length() == RAW_PRIVATE_KEY_LENGTH) {
return "Raw hex format (32 bytes)";
} else {
return "Hex format (" + cleanHex.length()/2 + " bytes)";
}
} else {
return "DER format";
}
}

/**
* Checks if the private key string is in PEM format.
*/
private static boolean isPemFormat(@NonNull final String privateKeyString) {
return privateKeyString.contains(PEM_HEADER) || privateKeyString.contains(PEM_EC_HEADER);
}

/**
* Checks if the private key string is in hexadecimal format.
* Only considers it hex format if it's exactly 64 characters (32 bytes) or has 0x prefix.
*/
private static boolean isHexFormat(@NonNull final String privateKeyString) {
if (!HEX_PATTERN.matcher(privateKeyString).matches()) {
return false;
}

String cleanHex = privateKeyString;
if (cleanHex.startsWith("0x") || cleanHex.startsWith("0X")) {
cleanHex = cleanHex.substring(2);
}

// Only consider it hex format if it's exactly 64 characters (32 bytes) or has 0x prefix
return cleanHex.length() == RAW_PRIVATE_KEY_LENGTH || privateKeyString.startsWith("0x") || privateKeyString.startsWith("0X");
}

/**
* Parses a private key from PEM format.
*/
@NonNull
private static PrivateKey parseFromPem(@NonNull final String pemString) {
try {
// Extract the base64 content between headers and footers
String base64Content = pemString;

// Handle standard PKCS#8 PEM format
if (pemString.contains(PEM_HEADER)) {
base64Content = extractBase64Content(pemString, PEM_HEADER, PEM_FOOTER);
}
// Handle EC private key PEM format
else if (pemString.contains(PEM_EC_HEADER)) {
base64Content = extractBase64Content(pemString, PEM_EC_HEADER, PEM_EC_FOOTER);
}

// Remove any whitespace and newlines
base64Content = base64Content.replaceAll("\\s+", "");

// Try to parse as DER after base64 decoding
final byte[] derBytes = java.util.Base64.getDecoder().decode(base64Content);
final String derHex = bytesToHex(derBytes);

return PrivateKey.fromString(derHex);
} catch (Exception e) {
throw new IllegalArgumentException("Invalid PEM format: " + e.getMessage(), e);
}
}

/**
* Parses a private key from hexadecimal format.
*/
@NonNull
private static PrivateKey parseFromHex(@NonNull final String hexString) {
try {
String cleanHex = hexString;

// Remove 0x prefix if present
if (cleanHex.startsWith("0x") || cleanHex.startsWith("0X")) {
cleanHex = cleanHex.substring(2);
}

// Validate length for raw private key
if (cleanHex.length() == RAW_PRIVATE_KEY_LENGTH) {
// This is likely a raw 32-byte private key
return PrivateKey.fromString(cleanHex);
} else {
// This might be a DER-encoded key in hex format
return PrivateKey.fromString(cleanHex);
}
} catch (Exception e) {
throw new IllegalArgumentException("Invalid hex format: " + e.getMessage(), e);
}
}

/**
* Falls back to the default Hedera SDK parsing.
*/
@NonNull
private static PrivateKey parseFromDefault(@NonNull final String privateKeyString) {
return PrivateKey.fromString(privateKeyString);
}

/**
* Extracts base64 content between PEM headers and footers.
*/
@NonNull
private static String extractBase64Content(@NonNull final String pemString,
@NonNull final String header,
@NonNull final String footer) {
final int headerIndex = pemString.indexOf(header);
final int footerIndex = pemString.indexOf(footer);

if (headerIndex == -1 || footerIndex == -1 || footerIndex <= headerIndex) {
throw new IllegalArgumentException("Invalid PEM format: missing or malformed headers");
}

return pemString.substring(headerIndex + header.length(), footerIndex).trim();
}

/**
* Converts byte array to hexadecimal string.
*/
@NonNull
private static String bytesToHex(@NonNull final byte[] bytes) {
final StringBuilder result = new StringBuilder();
for (byte b : bytes) {
result.append(String.format("%02x", b));
}
return result.toString();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.openelements.hiero.base.test.config;

import com.hedera.hashgraph.sdk.PrivateKey;
import com.openelements.hiero.base.config.PrivateKeyParser;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Assertions;

/**
* Integration test to verify private key parser works with real Hedera SDK integration.
*/
class PrivateKeyParserIntegrationTest {

@Test
void testIntegrationWithHederaSDK() {
// Test that our parser produces keys that work with Hedera SDK operations
// Using a known test vector
final String testPrivateKeyDer = "302e020100300506032b657004220420c236508c429395a8180b1230f436d389adc5afaa9145456783b57b2045c6cc37";
final String testPrivateKeyHex = "c236508c429395a8180b1230f436d389adc5afaa9145456783b57b2045c6cc37";

// Parse both formats
final PrivateKey fromDer = PrivateKeyParser.parsePrivateKey(testPrivateKeyDer);
final PrivateKey fromHex = PrivateKeyParser.parsePrivateKey(testPrivateKeyHex);

// Both should be valid private keys
Assertions.assertNotNull(fromDer);
Assertions.assertNotNull(fromHex);

// Both should have valid public keys
Assertions.assertNotNull(fromDer.getPublicKey());
Assertions.assertNotNull(fromHex.getPublicKey());

// DER format should work with direct SDK parsing for backward compatibility
final PrivateKey directSdk = PrivateKey.fromString(testPrivateKeyDer);
Assertions.assertEquals(directSdk.toString(), fromDer.toString());
}

@Test
void testHexFormatsWithPrefix() {
final String hexWithoutPrefix = "c236508c429395a8180b1230f436d389adc5afaa9145456783b57b2045c6cc37";
final String hexWithPrefix = "0x" + hexWithoutPrefix;

final PrivateKey withoutPrefix = PrivateKeyParser.parsePrivateKey(hexWithoutPrefix);
final PrivateKey withPrefix = PrivateKeyParser.parsePrivateKey(hexWithPrefix);

// Both should produce the same result
Assertions.assertEquals(withoutPrefix.toString(), withPrefix.toString());
Assertions.assertEquals(withoutPrefix.getPublicKey().toString(), withPrefix.getPublicKey().toString());
}

@Test
void testErrorHandling() {
// Test that error messages are helpful
final String invalidKey = "this-is-not-a-valid-key";

final IllegalArgumentException exception = Assertions.assertThrows(
IllegalArgumentException.class,
() -> PrivateKeyParser.parsePrivateKey(invalidKey)
);

// Error message should mention supported formats
final String message = exception.getMessage();
Assertions.assertTrue(message.contains("PEM") || message.contains("DER") || message.contains("hex"));
Assertions.assertTrue(message.contains("format"));
}
}
Loading