Skip to content

feat(crypto): harden ECKey validation - #46

Open
Federico2014 wants to merge 2 commits into
developfrom
feature/ec-key-validation-hardening
Open

feat(crypto): harden ECKey validation#46
Federico2014 wants to merge 2 commits into
developfrom
feature/ec-key-validation-hardening

Conversation

@Federico2014

@Federico2014 Federico2014 commented Aug 3, 2026

Copy link
Copy Markdown
Owner

What does this PR do?

This PR strengthens ECKey input validation, state consistency, and private-key handling.

  • Rejects empty, oversized, zero, and out-of-range secp256k1 private keys before expensive conversion or curve operations.
  • Validates public-key encoding length, curve membership, point validity, and infinity state.
  • Ensures supplied private and public keys belong to the same key pair.
  • Returns defensive copies of cached address and node ID values.
  • Aligns equality with public-key identity.
  • Restricts ECKey generation and signing keys to the Bouncy Castle provider.
  • Removes APIs that bypass key-pair validation or expose private keys through string output.
  • Validates EC witness keystore keys and clears temporary private-key byte arrays after use.

Why are these changes required?

ECKey previously accepted malformed or inconsistent key material, including unbounded private-key byte arrays and mismatched private/public key pairs. It also exposed mutable cached arrays and advertised configurable provider support that the signing implementation did not actually honor. These changes make validation predictable, reduce unnecessary resource consumption from invalid input, and keep key handling consistent with the Bouncy Castle signing implementation.

This PR has been tested by:

  • Unit Tests: ECKeyTest, SignatureInterfaceTest, BouncyCastleTest, and WitnessInitializerTest
  • Build verification: ./gradlew compileJava :framework:checkstyleTest

Follow up

None.

Extra details

This change removes the public constructors that accept an arbitrary Provider, removes fromPrivateAndPrecalculatedPublic and toStringWithPrivate, and changes null or empty private-key input from returning null to throwing IllegalArgumentException. No SM2, signature verification, signature recovery, or consensus behavior is changed.


Summary by cubic

Hardens ECKey validation and key handling to reject malformed keys and prevent misuse. Also restricts signing to the Bouncy Castle provider and validates witness keystore keys.

  • Bug Fixes

    • Reject invalid secp256k1 private keys early (empty, >32 bytes, zero, or ≥ n).
    • Validate public-key encoding and curve membership; reject infinity/invalid points.
    • Ensure private/public keys match; equality now uses public-key identity.
    • Return defensive copies for cached address and node ID; validate keystore keys and zero temporary private-key bytes in WitnessInitializer.
  • Migration

    • ECKey.fromPrivate(...) now throws IllegalArgumentException for null, empty, or out-of-range input (instead of returning null).
    • Removed constructors that accept a custom Provider; key gen and signing use Bouncy Castle only.
    • Removed fromPrivateAndPrecalculatedPublic(...) and toStringWithPrivate().

Written for commit 0a22ffd. Summary will update on new commits.

Review in cubic

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

ECKey now uses Tron Bouncy Castle types, validates private scalars and public points, and returns defensive cache copies. Witness keystore loading validates EC private keys and clears key bytes after processing. Tests cover invalid keys, key pairing, equality, caching, and cleanup.

Changes

Crypto and witness key validation

Layer / File(s) Summary
ECKey construction and validation
crypto/src/main/java/org/tron/common/crypto/ECKey.java, framework/src/test/java/org/tron/common/crypto/ECKeyTest.java
ECKey uses Tron Bouncy Castle types. Constructors and factories reject invalid private scalars, public points, and non-canonical encodings. Tests cover invalid inputs and matching key pairs.
ECKey cache and equality behavior
crypto/src/main/java/org/tron/common/crypto/ECKey.java, framework/src/test/java/org/tron/common/crypto/ECKeyTest.java
Address and node-ID caches use volatile fields and defensive copies. Equality compares public points. Tests cover equality and cache isolation.
Witness keystore validation and cleanup
framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java, framework/src/test/java/org/tron/core/config/args/WitnessInitializerTest.java
EC witness keys are validated before conversion. Invalid keys raise WITNESS_KEYSTORE_LOAD. Loaded key bytes are cleared after success or failure.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary change: strengthening ECKey validation and security handling.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/ec-key-validation-hardening

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
framework/src/test/java/org/tron/common/crypto/ECKeyTest.java (1)

78-96: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Strengthen the oversized private-key case so it isolates the length rule.

new byte[33] is rejected by two independent rules: the 32-byte length limit and the zero-scalar check. The assertion therefore does not prove that the length limit works. Add a 33-byte array that holds a valid nonzero scalar. This case also documents the sign-padded BigInteger.toByteArray() encoding that isValidPrivateKey now rejects.

♻️ Proposed additional assertion
     assertFalse(ECKey.isValidPrivateKey(new byte[33]));
+    // 33-byte sign-padded encoding of a valid scalar is rejected by the length rule.
+    byte[] signPadded = new byte[33];
+    System.arraycopy(Hex.decode(privString), 0, signPadded, 1, 32);
+    assertFalse(ECKey.isValidPrivateKey(signPadded));
     assertFalse(ECKey.isValidPrivateKey(BigInteger.ZERO));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@framework/src/test/java/org/tron/common/crypto/ECKeyTest.java` around lines
78 - 96, Update shouldValidatePrivateKeyRange so the oversized byte-array
assertion uses a 33-byte value containing a valid nonzero scalar, such as the
sign-padded encoding of the existing valid private key, isolating rejection by
length rather than the zero-scalar check. Preserve the current null, empty,
boundary, and fromPrivate assertions.
framework/src/test/java/org/tron/core/config/args/WitnessInitializerTest.java (1)

124-125: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Use a canonical 32-byte encoding for the curve-order boundary.

ECKey.isValidPrivateKey(...bytes...) checks privateKey.length <= 32 before converting bytes with new BigInteger(1, privateKey) and checking < N. ECKey.CURVE.getN().toByteArray() is 33 bytes because the secp256k1 order starts with ff, so this test covers oversized input rather than the scalar-boundary check.

Use ByteArray.fromHexString(ECKey.CURVE.getN().toString(16)) so the invalid scalar has the canonical 32-byte magnitude.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@framework/src/test/java/org/tron/core/config/args/WitnessInitializerTest.java`
around lines 124 - 125, Update the invalidKey initialization in
WitnessInitializerTest to use
ByteArray.fromHexString(ECKey.CURVE.getN().toString(16)), ensuring the
curve-order boundary is represented as a canonical 32-byte value and exercises
the scalar-boundary validation rather than the oversized-input check.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@framework/src/test/java/org/tron/common/crypto/ECKeyTest.java`:
- Around line 78-96: Update shouldValidatePrivateKeyRange so the oversized
byte-array assertion uses a 33-byte value containing a valid nonzero scalar,
such as the sign-padded encoding of the existing valid private key, isolating
rejection by length rather than the zero-scalar check. Preserve the current
null, empty, boundary, and fromPrivate assertions.

In
`@framework/src/test/java/org/tron/core/config/args/WitnessInitializerTest.java`:
- Around line 124-125: Update the invalidKey initialization in
WitnessInitializerTest to use
ByteArray.fromHexString(ECKey.CURVE.getN().toString(16)), ensuring the
curve-order boundary is represented as a canonical 32-byte value and exercises
the scalar-boundary validation rather than the oversized-input check.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a2fe83d0-29a8-4bd2-b2e2-2a5f81219a67

📥 Commits

Reviewing files that changed from the base of the PR and between e89c0d6 and 0a22ffd.

📒 Files selected for processing (4)
  • crypto/src/main/java/org/tron/common/crypto/ECKey.java
  • framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java
  • framework/src/test/java/org/tron/common/crypto/ECKeyTest.java
  • framework/src/test/java/org/tron/core/config/args/WitnessInitializerTest.java

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 issues found across 4 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="crypto/src/main/java/org/tron/common/crypto/ECKey.java">

<violation number="1" location="crypto/src/main/java/org/tron/common/crypto/ECKey.java:220">
P2: Private keys serialized with `BigInteger.toByteArray()` gain a leading `0x00` when their high bit is set, so valid keys now fail `fromPrivate` and keystore validation. Allow exactly one zero sign-padding byte while retaining the scalar-range check.

(Based on your team's feedback about accepting BigInteger sign-padded private-key arrays.)</violation>
</file>

<file name="framework/src/test/java/org/tron/common/crypto/ECKeyTest.java">

<violation number="1" location="framework/src/test/java/org/tron/common/crypto/ECKeyTest.java:84">
P2: Java's `BigInteger.toByteArray()` can encode a valid secp256k1 scalar in 33 bytes with a leading `0x00`, but this case only tests scalar zero and lets rejection of valid sign-padded private keys go unnoticed. Add a valid sign-padded 33-byte scalar that must be accepted, with separate cases for nonzero-leading and oversized arrays.

(Based on your team's feedback about preserving Java sign-padded private-key bytes.)</violation>

<violation number="2" location="framework/src/test/java/org/tron/common/crypto/ECKeyTest.java:286">
P3: This test can pass even when `getAddress()` or `getNodeId()` exposes the mutable cache, so it does not verify the defensive-copy contract it claims to test. Snapshot the expected values with `Arrays.copyOf` before mutating the returned arrays.</violation>
</file>

<file name="framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java">

<violation number="1" location="framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java:88">
P2: Malformed EC keystores fail before this validation runs and escape as `IllegalArgumentException`, rather than the expected `TronError(WITNESS_KEYSTORE_LOAD)`. Wrap this failure at the keystore boundary (or convert it in `Wallet.decrypt`) so witness startup reports the documented load error.</violation>

<violation number="2" location="framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java:115">
P3: The cleanup wipes a re-encoded copy, not the decrypted key buffer allocated by `Wallet.decrypt`, leaving an unnecessary plaintext private key in memory. Clear that source buffer in `Wallet.decrypt` after key construction, including failure paths.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

*/
public static boolean isValidPrivateKey(byte[] privateKey) {
return !ByteArray.isEmpty(privateKey)
&& privateKey.length <= MAX_PRIVATE_KEY_LENGTH

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Private keys serialized with BigInteger.toByteArray() gain a leading 0x00 when their high bit is set, so valid keys now fail fromPrivate and keystore validation. Allow exactly one zero sign-padding byte while retaining the scalar-range check.

(Based on your team's feedback about accepting BigInteger sign-padded private-key arrays.)

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crypto/src/main/java/org/tron/common/crypto/ECKey.java, line 220:

<comment>Private keys serialized with `BigInteger.toByteArray()` gain a leading `0x00` when their high bit is set, so valid keys now fail `fromPrivate` and keystore validation. Allow exactly one zero sign-padding byte while retaining the scalar-range check.

(Based on your team's feedback about accepting BigInteger sign-padded private-key arrays.) </comment>

<file context>
@@ -245,6 +212,51 @@ private static PrivateKey privateKeyFromBigInteger(BigInteger priv) {
+   */
+  public static boolean isValidPrivateKey(byte[] privateKey) {
+    return !ByteArray.isEmpty(privateKey)
+        && privateKey.length <= MAX_PRIVATE_KEY_LENGTH
+        && isValidPrivateKey(new BigInteger(1, privateKey));
+  }
</file context>
Suggested change
&& privateKey.length <= MAX_PRIVATE_KEY_LENGTH
&& (privateKey.length <= MAX_PRIVATE_KEY_LENGTH
|| (privateKey.length == MAX_PRIVATE_KEY_LENGTH + 1 && privateKey[0] == 0))

assertFalse(ECKey.isValidPrivateKey((BigInteger) null));
assertFalse(ECKey.isValidPrivateKey((byte[]) null));
assertFalse(ECKey.isValidPrivateKey(new byte[0]));
assertFalse(ECKey.isValidPrivateKey(new byte[33]));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Java's BigInteger.toByteArray() can encode a valid secp256k1 scalar in 33 bytes with a leading 0x00, but this case only tests scalar zero and lets rejection of valid sign-padded private keys go unnoticed. Add a valid sign-padded 33-byte scalar that must be accepted, with separate cases for nonzero-leading and oversized arrays.

(Based on your team's feedback about preserving Java sign-padded private-key bytes.)

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At framework/src/test/java/org/tron/common/crypto/ECKeyTest.java, line 84:

<comment>Java's `BigInteger.toByteArray()` can encode a valid secp256k1 scalar in 33 bytes with a leading `0x00`, but this case only tests scalar zero and lets rejection of valid sign-padded private keys go unnoticed. Add a valid sign-padded 33-byte scalar that must be accepted, with separate cases for nonzero-leading and oversized arrays.

(Based on your team's feedback about preserving Java sign-padded private-key bytes.) </comment>

<file context>
@@ -69,10 +68,55 @@ public void testFromPrivateKey() {
+    assertFalse(ECKey.isValidPrivateKey((BigInteger) null));
+    assertFalse(ECKey.isValidPrivateKey((byte[]) null));
+    assertFalse(ECKey.isValidPrivateKey(new byte[0]));
+    assertFalse(ECKey.isValidPrivateKey(new byte[33]));
+    assertFalse(ECKey.isValidPrivateKey(BigInteger.ZERO));
+    assertFalse(ECKey.isValidPrivateKey(ECKey.CURVE.getN()));
</file context>

SignInterface sign = credentials.getSignInterface();
String prikey = ByteArray.toHexString(sign.getPrivateKey());
privateKeys.add(prikey);
privateKeyBytes = sign.getPrivateKey();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Malformed EC keystores fail before this validation runs and escape as IllegalArgumentException, rather than the expected TronError(WITNESS_KEYSTORE_LOAD). Wrap this failure at the keystore boundary (or convert it in Wallet.decrypt) so witness startup reports the documented load error.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java, line 88:

<comment>Malformed EC keystores fail before this validation runs and escape as `IllegalArgumentException`, rather than the expected `TronError(WITNESS_KEYSTORE_LOAD)`. Wrap this failure at the keystore boundary (or convert it in `Wallet.decrypt`) so witness startup reports the documented load error.</comment>

<file context>
@@ -78,12 +80,19 @@ public static LocalWitnesses initFromKeystore(
       SignInterface sign = credentials.getSignInterface();
-      String prikey = ByteArray.toHexString(sign.getPrivateKey());
-      privateKeys.add(prikey);
+      privateKeyBytes = sign.getPrivateKey();
+      if (Args.getInstance().isECKeyCryptoEngine()
+          && !ECKey.isValidPrivateKey(privateKeyBytes)) {
</file context>

Comment on lines +286 to +294
byte[] expectedAddress = key.getAddress();
byte[] returnedAddress = key.getAddress();
returnedAddress[0] ^= 1;
assertArrayEquals(expectedAddress, key.getAddress());

byte[] expectedNodeId = key.getNodeId();
byte[] returnedNodeId = key.getNodeId();
returnedNodeId[0] ^= 1;
assertArrayEquals(expectedNodeId, key.getNodeId());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This test can pass even when getAddress() or getNodeId() exposes the mutable cache, so it does not verify the defensive-copy contract it claims to test. Snapshot the expected values with Arrays.copyOf before mutating the returned arrays.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At framework/src/test/java/org/tron/common/crypto/ECKeyTest.java, line 286:

<comment>This test can pass even when `getAddress()` or `getNodeId()` exposes the mutable cache, so it does not verify the defensive-copy contract it claims to test. Snapshot the expected values with `Arrays.copyOf` before mutating the returned arrays.</comment>

<file context>
@@ -215,10 +270,28 @@ public void testEqualsObject() {
+  @Test
+  public void shouldReturnDefensiveCopiesOfCachedValues() {
+    ECKey key = ECKey.fromPrivate(privateKey);
+    byte[] expectedAddress = key.getAddress();
+    byte[] returnedAddress = key.getAddress();
+    returnedAddress[0] ^= 1;
</file context>
Suggested change
byte[] expectedAddress = key.getAddress();
byte[] returnedAddress = key.getAddress();
returnedAddress[0] ^= 1;
assertArrayEquals(expectedAddress, key.getAddress());
byte[] expectedNodeId = key.getNodeId();
byte[] returnedNodeId = key.getNodeId();
returnedNodeId[0] ^= 1;
assertArrayEquals(expectedNodeId, key.getNodeId());
byte[] expectedAddress = Arrays.copyOf(key.getAddress(), key.getAddress().length);
byte[] returnedAddress = key.getAddress();
returnedAddress[0] ^= 1;
assertArrayEquals(expectedAddress, key.getAddress());
byte[] expectedNodeId = Arrays.copyOf(key.getNodeId(), key.getNodeId().length);
byte[] returnedNodeId = key.getNodeId();
returnedNodeId[0] ^= 1;
assertArrayEquals(expectedNodeId, key.getNodeId());

throw new TronError(e, TronError.ErrCode.WITNESS_KEYSTORE_LOAD);
} finally {
if (privateKeyBytes != null) {
Arrays.fill(privateKeyBytes, (byte) 0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The cleanup wipes a re-encoded copy, not the decrypted key buffer allocated by Wallet.decrypt, leaving an unnecessary plaintext private key in memory. Clear that source buffer in Wallet.decrypt after key construction, including failure paths.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java, line 115:

<comment>The cleanup wipes a re-encoded copy, not the decrypted key buffer allocated by `Wallet.decrypt`, leaving an unnecessary plaintext private key in memory. Clear that source buffer in `Wallet.decrypt` after key construction, including failure paths.</comment>

<file context>
@@ -101,6 +110,10 @@ public static LocalWitnesses initFromKeystore(
       throw new TronError(e, TronError.ErrCode.WITNESS_KEYSTORE_LOAD);
+    } finally {
+      if (privateKeyBytes != null) {
+        Arrays.fill(privateKeyBytes, (byte) 0);
+      }
     }
</file context>

@Federico2014 Federico2014 changed the title fix(crypto): harden ECKey validation feat(crypto): harden ECKey validation Aug 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant