From b54a7edf71fe74b084fbfb35497169567c825d89 Mon Sep 17 00:00:00 2001 From: imalasong <2879499479@qq.com> Date: Mon, 21 Apr 2025 22:34:12 +0800 Subject: [PATCH 01/41] opt(common): GenesisBlock timestamp valid message error Signed-off-by: imalasong --- .../java/org/tron/common/args/GenesisBlock.java | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/common/src/main/java/org/tron/common/args/GenesisBlock.java b/common/src/main/java/org/tron/common/args/GenesisBlock.java index 1cc3394a0e1..fe6d30944d3 100644 --- a/common/src/main/java/org/tron/common/args/GenesisBlock.java +++ b/common/src/main/java/org/tron/common/args/GenesisBlock.java @@ -61,18 +61,17 @@ public void setAssets(final List assets) { */ public void setTimestamp(final String timestamp) { this.timestamp = timestamp; - if (this.timestamp == null) { this.timestamp = DEFAULT_TIMESTAMP; - } - - try { - long l = Long.parseLong(this.timestamp); - if (l < 0) { + } else { + try { + long l = Long.parseLong(this.timestamp); + if (l < 0) { + throw new IllegalArgumentException("Timestamp(" + timestamp + ") must be greater than or equal to 0."); + } + } catch (NumberFormatException e) { throw new IllegalArgumentException("Timestamp(" + timestamp + ") must be a Long type."); } - } catch (NumberFormatException e) { - throw new IllegalArgumentException("Timestamp(" + timestamp + ") must be a Long type."); } } From 871808fb2a48f0e1d8a6351d9b071a850c0ce10b Mon Sep 17 00:00:00 2001 From: Leem Date: Tue, 10 Mar 2026 19:18:39 +0800 Subject: [PATCH 02/41] add desc about how to create an issue and fix "submit code" --- CONTRIBUTING.md | 59 ++++++++++++++++++++++++++++++------------------- 1 file changed, 36 insertions(+), 23 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6b5e9aacf86..e582dad7f50 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,20 +23,20 @@ Here are some guidelines to get started quickly and easily: - [Conduct](#Conduct) -### Reporting An Issue +## Reporting An Issue -If you're about to raise an issue because you think you've found a problem or bug with java-tron, please respect the following restrictions: +If you have any question about java-tron, please search [existing issues](https://github.com/tronprotocol/java-tron/issues?q=is%3Aissue%20state%3Aclosed%20OR%20state%3Aopen) to avoid duplicates. Your questions might already be under discussion or part of our roadmap. Checking first helps us streamline efforts and focus on new contributions. -- Please search for existing issues. Help us keep duplicate issues to a minimum by checking to see if someone has already reported your problem or requested your idea. +### Ask a question +Feel free to ask any java-tron related question to solve your doubt. Please click **Ask a question** in GitHub Issues, using [Ask a question](.github/ISSUE_TEMPLATE/ask-a-question.md) template. -- Use the Issue Report Template below. - ``` - 1.What did you do? +### Report a bug - 2.What did you expect to see? +If you think you've found a bug with java-tron, please click **Report a bug** in GitHub Issues, using [Report a bug](.github/ISSUE_TEMPLATE/report-a-bug.md) template. - 3.What did you see instead? - ``` +### Request a feature + +If you have any good feature suggestions for java-tron, please click **Request a feature** in GitHub Issues, using [Request a feature](.github/ISSUE_TEMPLATE/request-a-feature.md) template. ## Working on java-tron @@ -69,43 +69,56 @@ java-tron only has `master`, `develop`, `release-*`, `feature-*`, and `hotfix-*` ### Submitting Code -If you want to contribute codes to java-tron, please follow the following steps: +If you want to contribute code to java-tron, please follow the following steps. + +* Fork the Repository + + Visit [tronprotocol/java-tron](https://github.com/tronprotocol/java-tron/) and click **Fork** to create a fork repository under your GitHub account. -* Fork code repository - Fork a new repository from tronprotocol/java-tron to your personal code repository +* Setup Local Environment -* Edit the code in the fork repository + Clone your fork repository to local and add the official repository as **upstream**. ``` git clone https://github.com/yourname/java-tron.git - git remote add upstream https://github.com/tronprotocol/java-tron.git ("upstream" refers to upstream projects repositories, namely tronprotocol's repositories, and can be named as you like it. We usually call it "upstream" for convenience) + cd java-tron + + git remote add upstream https://github.com/tronprotocol/java-tron.git ``` - Before developing new features, please synchronize your fork repository with the upstream repository. + +* Synchronize and Develop + + Before developing new features, please synchronize your local `develop` branch with the upstream repository and update to your fork repository. ``` - git fetch upstream - git checkout develop - git merge upstream/develop --no-ff (Add --no-ff to turn off the default fast merge mode) + git fetch upstream + git checkout develop + # `--no-ff` means to turn off the default fast merge mode + git merge upstream/develop --no-ff + git push origin develop ``` - Pull a new branch from the develop branch of your repository for local development. Please refer to [Branch Naming Conventions](#Branch-Naming-Conventions), + Create a new branch for development. Please refer to [Branch Naming Conventions](#Branch-Naming-Conventions). ``` git checkout -b feature/branch_name develop ``` - Write and commit the new code when it is completed. Please refer to [Commit Messages](#Commit-Messages) +* Commit and Push + + Write and commit the new code when it is completed. Please refer to [Commit Messages](#Commit-Messages). ``` git add . git commit -m 'commit message' ``` - Commit the new branch to your personal remote repository + + Push the new branch to your fork repository ``` git push origin feature/branch_name ``` -* Push code +* Submit a pull request Submit a pull request (PR) from your repository to `tronprotocol/java-tron`. - Please be sure to click on the link in the red box shown below. Select the base branch for tronprotocol and the compare branch for your personal fork repository. + Please be sure to click on the link in the red box shown below. Select the base branch for tronprotocol and the compare branch for your fork repository. ![image](https://raw.githubusercontent.com/tronprotocol/documentation-en/master/images/javatron_pr.png) From acdeb9fd7fc9c413a69c6531ef67d2f8f5978872 Mon Sep 17 00:00:00 2001 From: Leem Date: Tue, 10 Mar 2026 19:28:18 +0800 Subject: [PATCH 03/41] Revise two sentences to make the description clearer --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e582dad7f50..3c14ce1cb97 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,7 +25,7 @@ Here are some guidelines to get started quickly and easily: ## Reporting An Issue -If you have any question about java-tron, please search [existing issues](https://github.com/tronprotocol/java-tron/issues?q=is%3Aissue%20state%3Aclosed%20OR%20state%3Aopen) to avoid duplicates. Your questions might already be under discussion or part of our roadmap. Checking first helps us streamline efforts and focus on new contributions. +If you have any question about java-tron, please search [existing issues](https://github.com/tronprotocol/java-tron/issues?q=is%3Aissue%20state%3Aclosed%20OR%20state%3Aopen) first to avoid duplicates. Your questions might already be under discussion or part of our roadmap. Checking first helps us streamline efforts and focus on new contributions. ### Ask a question Feel free to ask any java-tron related question to solve your doubt. Please click **Ask a question** in GitHub Issues, using [Ask a question](.github/ISSUE_TEMPLATE/ask-a-question.md) template. @@ -117,7 +117,7 @@ If you want to contribute code to java-tron, please follow the following steps. * Submit a pull request - Submit a pull request (PR) from your repository to `tronprotocol/java-tron`. + Submit a pull request (PR) from your fork repository to `tronprotocol/java-tron`. Please be sure to click on the link in the red box shown below. Select the base branch for tronprotocol and the compare branch for your fork repository. ![image](https://raw.githubusercontent.com/tronprotocol/documentation-en/master/images/javatron_pr.png) From 25c9fc28d30e1c1494eccc8133d28369c66aa6d8 Mon Sep 17 00:00:00 2001 From: Leem Date: Wed, 11 Mar 2026 17:01:28 +0800 Subject: [PATCH 04/41] fix official repository name --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3c14ce1cb97..53a9dd75824 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -118,7 +118,7 @@ If you want to contribute code to java-tron, please follow the following steps. * Submit a pull request Submit a pull request (PR) from your fork repository to `tronprotocol/java-tron`. - Please be sure to click on the link in the red box shown below. Select the base branch for tronprotocol and the compare branch for your fork repository. + Please be sure to click on the link in the red box shown below. Select the base branch for `tronprotocol/java-tron` and the compare branch for your fork repository. ![image](https://raw.githubusercontent.com/tronprotocol/documentation-en/master/images/javatron_pr.png) From ae18d8369130e96aca9173f0bce69bf6f38ed1fe Mon Sep 17 00:00:00 2001 From: Asuka Date: Tue, 31 Mar 2026 12:05:06 +0800 Subject: [PATCH 05/41] refactor(tvm): optimize energy cost calculation for vote witness opcode Use BigInteger for memory size computation to improve precision and add unit tests. Co-Authored-By: Claude Opus 4.6 --- .../java/org/tron/core/vm/EnergyCost.java | 25 ++ .../org/tron/core/vm/OperationRegistry.java | 12 + .../org/tron/core/vm/config/VMConfig.java | 10 + .../runtime/vm/VoteWitnessCost3Test.java | 244 ++++++++++++++++++ 4 files changed, 291 insertions(+) create mode 100644 framework/src/test/java/org/tron/common/runtime/vm/VoteWitnessCost3Test.java diff --git a/actuator/src/main/java/org/tron/core/vm/EnergyCost.java b/actuator/src/main/java/org/tron/core/vm/EnergyCost.java index d47f716943f..3641548b3e5 100644 --- a/actuator/src/main/java/org/tron/core/vm/EnergyCost.java +++ b/actuator/src/main/java/org/tron/core/vm/EnergyCost.java @@ -387,6 +387,27 @@ public static long getVoteWitnessCost2(Program program) { ? amountArrayMemoryNeeded : witnessArrayMemoryNeeded), 0, Op.VOTEWITNESS); } + public static long getVoteWitnessCost3(Program program) { + Stack stack = program.getStack(); + long oldMemSize = program.getMemSize(); + BigInteger amountArrayLength = stack.get(stack.size() - 1).value(); + BigInteger amountArrayOffset = stack.get(stack.size() - 2).value(); + BigInteger witnessArrayLength = stack.get(stack.size() - 3).value(); + BigInteger witnessArrayOffset = stack.get(stack.size() - 4).value(); + + BigInteger wordSize = BigInteger.valueOf(DataWord.WORD_SIZE); + + BigInteger amountArraySize = amountArrayLength.multiply(wordSize).add(wordSize); + BigInteger amountArrayMemoryNeeded = memNeeded(amountArrayOffset, amountArraySize); + + BigInteger witnessArraySize = witnessArrayLength.multiply(wordSize).add(wordSize); + BigInteger witnessArrayMemoryNeeded = memNeeded(witnessArrayOffset, witnessArraySize); + + return VOTE_WITNESS + calcMemEnergy(oldMemSize, + (amountArrayMemoryNeeded.compareTo(witnessArrayMemoryNeeded) > 0 + ? amountArrayMemoryNeeded : witnessArrayMemoryNeeded), 0, Op.VOTEWITNESS); + } + public static long getWithdrawRewardCost(Program ignored) { return WITHDRAW_REWARD; } @@ -550,6 +571,10 @@ private static BigInteger memNeeded(DataWord offset, DataWord size) { return size.isZero() ? BigInteger.ZERO : offset.value().add(size.value()); } + private static BigInteger memNeeded(BigInteger offset, BigInteger size) { + return size.equals(BigInteger.ZERO) ? BigInteger.ZERO : offset.add(size); + } + private static boolean isDeadAccount(Program program, DataWord address) { return program.getContractState().getAccount(address.toTronAddress()) == null; } diff --git a/actuator/src/main/java/org/tron/core/vm/OperationRegistry.java b/actuator/src/main/java/org/tron/core/vm/OperationRegistry.java index f6140107efb..f2d251ceee9 100644 --- a/actuator/src/main/java/org/tron/core/vm/OperationRegistry.java +++ b/actuator/src/main/java/org/tron/core/vm/OperationRegistry.java @@ -83,6 +83,10 @@ public static JumpTable getTable() { adjustSelfdestruct(table); } + if (VMConfig.allowTvmOsaka()) { + adjustVoteWitnessCost(table); + } + return table; } @@ -706,4 +710,12 @@ public static void adjustSelfdestruct(JumpTable table) { EnergyCost::getSuicideCost3, OperationActions::suicideAction2)); } + + public static void adjustVoteWitnessCost(JumpTable table) { + table.set(new Operation( + Op.VOTEWITNESS, 4, 1, + EnergyCost::getVoteWitnessCost3, + OperationActions::voteWitnessAction, + VMConfig::allowTvmVote)); + } } diff --git a/common/src/main/java/org/tron/core/vm/config/VMConfig.java b/common/src/main/java/org/tron/core/vm/config/VMConfig.java index 578827b2f8c..1a7f0c058a4 100644 --- a/common/src/main/java/org/tron/core/vm/config/VMConfig.java +++ b/common/src/main/java/org/tron/core/vm/config/VMConfig.java @@ -61,6 +61,8 @@ public class VMConfig { private static boolean ALLOW_TVM_SELFDESTRUCT_RESTRICTION = false; + private static boolean ALLOW_TVM_OSAKA = false; + private VMConfig() { } @@ -172,6 +174,10 @@ public static void initAllowTvmSelfdestructRestriction(long allow) { ALLOW_TVM_SELFDESTRUCT_RESTRICTION = allow == 1; } + public static void initAllowTvmOsaka(long allow) { + ALLOW_TVM_OSAKA = allow == 1; + } + public static boolean getEnergyLimitHardFork() { return CommonParameter.ENERGY_LIMIT_HARD_FORK; } @@ -271,4 +277,8 @@ public static boolean allowTvmBlob() { public static boolean allowTvmSelfdestructRestriction() { return ALLOW_TVM_SELFDESTRUCT_RESTRICTION; } + + public static boolean allowTvmOsaka() { + return ALLOW_TVM_OSAKA; + } } diff --git a/framework/src/test/java/org/tron/common/runtime/vm/VoteWitnessCost3Test.java b/framework/src/test/java/org/tron/common/runtime/vm/VoteWitnessCost3Test.java new file mode 100644 index 00000000000..cc084284d96 --- /dev/null +++ b/framework/src/test/java/org/tron/common/runtime/vm/VoteWitnessCost3Test.java @@ -0,0 +1,244 @@ +package org.tron.common.runtime.vm; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.math.BigInteger; +import lombok.extern.slf4j.Slf4j; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; +import org.tron.common.BaseTest; +import org.tron.common.TestConstants; +import org.tron.common.parameter.CommonParameter; +import org.tron.core.config.args.Args; +import org.tron.core.vm.EnergyCost; +import org.tron.core.vm.JumpTable; +import org.tron.core.vm.Op; +import org.tron.core.vm.Operation; +import org.tron.core.vm.OperationRegistry; +import org.tron.core.vm.config.ConfigLoader; +import org.tron.core.vm.config.VMConfig; +import org.tron.core.vm.program.Program; +import org.tron.core.vm.program.Stack; + +@Slf4j +public class VoteWitnessCost3Test extends BaseTest { + + static { + Args.setParam(new String[]{"--output-directory", dbPath()}, TestConstants.TEST_CONF); + } + + @BeforeClass + public static void init() { + CommonParameter.getInstance().setDebug(true); + VMConfig.initAllowTvmVote(1); + VMConfig.initAllowEnergyAdjustment(1); + } + + @AfterClass + public static void destroy() { + ConfigLoader.disable = false; + VMConfig.initAllowTvmVote(0); + VMConfig.initAllowEnergyAdjustment(0); + VMConfig.initAllowTvmOsaka(0); + Args.clearParam(); + } + + private Program mockProgram(long witnessOffset, long witnessLength, + long amountOffset, long amountLength, int memSize) { + Program program = mock(Program.class); + Stack stack = new Stack(); + // Stack order: bottom -> top: witnessOffset, witnessLength, amountOffset, amountLength + stack.push(new DataWord(witnessOffset)); + stack.push(new DataWord(witnessLength)); + stack.push(new DataWord(amountOffset)); + stack.push(new DataWord(amountLength)); + when(program.getStack()).thenReturn(stack); + when(program.getMemSize()).thenReturn(memSize); + return program; + } + + private Program mockProgram(DataWord witnessOffset, DataWord witnessLength, + DataWord amountOffset, DataWord amountLength, int memSize) { + Program program = mock(Program.class); + Stack stack = new Stack(); + stack.push(witnessOffset); + stack.push(witnessLength); + stack.push(amountOffset); + stack.push(amountLength); + when(program.getStack()).thenReturn(stack); + when(program.getMemSize()).thenReturn(memSize); + return program; + } + + @Test + public void testNormalCase() { + // 2 witnesses at offset 0, 2 amounts at offset 128 + Program program = mockProgram(0, 2, 128, 2, 0); + long cost = EnergyCost.getVoteWitnessCost3(program); + // amountArraySize = 2 * 32 + 32 = 96, memNeeded = 128 + 96 = 224 + // witnessArraySize = 2 * 32 + 32 = 96, memNeeded = 0 + 96 = 96 + // max = 224, memWords = (224 + 31) / 32 * 32 / 32 = 7 + // memEnergy = 3 * 7 + 7 * 7 / 512 = 21 + // total = 30000 + 21 = 30021 + assertEquals(30021, cost); + } + + @Test + public void testConsistentWithCost2ForSmallValues() { + // For small values, cost3 should produce the same result as cost2 + long[][] testCases = { + {0, 1, 64, 1, 0}, // 1 witness, 1 amount + {0, 3, 128, 3, 0}, // 3 witnesses, 3 amounts + {0, 5, 256, 5, 0}, // 5 witnesses, 5 amounts + {64, 2, 192, 2, 0}, // non-zero offsets + {0, 10, 512, 10, 0}, // 10 witnesses + }; + + for (long[] tc : testCases) { + Program p2 = mockProgram(tc[0], tc[1], tc[2], tc[3], (int) tc[4]); + Program p3 = mockProgram(tc[0], tc[1], tc[2], tc[3], (int) tc[4]); + long cost2 = EnergyCost.getVoteWitnessCost2(p2); + long cost3 = EnergyCost.getVoteWitnessCost3(p3); + assertEquals("Mismatch for case: witnessOff=" + tc[0] + " witnessLen=" + tc[1] + + " amountOff=" + tc[2] + " amountLen=" + tc[3], cost2, cost3); + } + } + + @Test + public void testZeroLengthArrays() { + // Both arrays have zero length, but cost3 always adds wordSize for dynamic array prefix + Program program = mockProgram(0, 0, 0, 0, 0); + long cost = EnergyCost.getVoteWitnessCost3(program); + // arraySize = 0 * 32 + 32 = 32, memNeeded = 0 + 32 = 32 + // memWords = (32 + 31) / 32 * 32 / 32 = 1 + // memEnergy = 3 * 1 + 1 * 1 / 512 = 3 + assertEquals(30003, cost); + } + + @Test + public void testZeroLengthOneArray() { + // witness array zero, amount array non-zero + Program program = mockProgram(0, 0, 64, 1, 0); + long cost = EnergyCost.getVoteWitnessCost3(program); + // witnessMemNeeded = 0 (size is zero) + // amountArraySize = 1 * 32 + 32 = 64, memNeeded = 64 + 64 = 128 + // memWords = 128 / 32 = 4 + // memEnergy = 3 * 4 + 4 * 4 / 512 = 12 + assertEquals(30012, cost); + } + + @Test + public void testLargeArrayLengthOverflow() { + // Use a very large value that would overflow in DataWord.mul() in cost2 + // DataWord max is 2^256-1, multiplying by 32 would overflow + // In cost3, BigInteger handles this correctly and should trigger memoryOverflow + String maxHex = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + DataWord largeLength = new DataWord(maxHex); + DataWord zeroOffset = new DataWord(0); + + Program program = mockProgram(zeroOffset, new DataWord(1), + zeroOffset, largeLength, 0); + + boolean overflowCaught = false; + try { + EnergyCost.getVoteWitnessCost3(program); + } catch (Program.OutOfMemoryException e) { + // cost3 should detect memory overflow via checkMemorySize + overflowCaught = true; + } + assertTrue("cost3 should throw memoryOverflow for huge array length", overflowCaught); + } + + @Test + public void testLargeOffsetOverflow() { + // Large offset + normal size should trigger memoryOverflow in cost3 + String largeHex = "00ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + DataWord largeOffset = new DataWord(largeHex); + + Program program = mockProgram(largeOffset, new DataWord(1), + new DataWord(0), new DataWord(1), 0); + + boolean overflowCaught = false; + try { + EnergyCost.getVoteWitnessCost3(program); + } catch (Program.OutOfMemoryException e) { + overflowCaught = true; + } + assertTrue("cost3 should throw memoryOverflow for huge offset", overflowCaught); + } + + @Test + public void testExistingMemorySize() { + // When program already has memory allocated, additional cost is incremental + Program p1 = mockProgram(0, 2, 128, 2, 0); + long costFromZero = EnergyCost.getVoteWitnessCost3(p1); + + Program p2 = mockProgram(0, 2, 128, 2, 224); + long costWithExistingMem = EnergyCost.getVoteWitnessCost3(p2); + + // With existing memory >= needed, no additional mem cost + assertEquals(30000, costWithExistingMem); + assertTrue(costFromZero > costWithExistingMem); + } + + @Test + public void testAmountArrayLargerThanWitnessArray() { + // amount array needs more memory => amount determines cost + Program program = mockProgram(0, 1, 0, 5, 0); + long cost = EnergyCost.getVoteWitnessCost3(program); + // witnessArraySize = 1 * 32 + 32 = 64, memNeeded = 0 + 64 = 64 + // amountArraySize = 5 * 32 + 32 = 192, memNeeded = 0 + 192 = 192 + // max = 192, memWords = (192 + 31) / 32 * 32 / 32 = 6 + // memEnergy = 3 * 6 + 6 * 6 / 512 = 18 + assertEquals(30018, cost); + } + + @Test + public void testWitnessArrayLargerThanAmountArray() { + // witness array needs more memory => witness determines cost + Program program = mockProgram(0, 5, 0, 1, 0); + long cost = EnergyCost.getVoteWitnessCost3(program); + // witnessArraySize = 5 * 32 + 32 = 192, memNeeded = 0 + 192 = 192 + // amountArraySize = 1 * 32 + 32 = 64, memNeeded = 0 + 64 = 64 + // max = 192 + assertEquals(30018, cost); + } + + @Test + public void testOperationRegistryWithoutOsaka() { + VMConfig.initAllowTvmOsaka(0); + JumpTable table = OperationRegistry.getTable(); + Operation voteOp = table.get(Op.VOTEWITNESS); + assertTrue(voteOp.isEnabled()); + + // Without osaka, should use cost2 (from adjustForFairEnergy since allowEnergyAdjustment=1) + Program program = mockProgram(0, 2, 128, 2, 0); + long cost = voteOp.getEnergyCost(program); + long expectedCost2 = EnergyCost.getVoteWitnessCost2( + mockProgram(0, 2, 128, 2, 0)); + assertEquals(expectedCost2, cost); + } + + @Test + public void testOperationRegistryWithOsaka() { + VMConfig.initAllowTvmOsaka(1); + try { + JumpTable table = OperationRegistry.getTable(); + Operation voteOp = table.get(Op.VOTEWITNESS); + assertTrue(voteOp.isEnabled()); + + // With osaka, should use cost3 + Program program = mockProgram(0, 2, 128, 2, 0); + long cost = voteOp.getEnergyCost(program); + long expectedCost3 = EnergyCost.getVoteWitnessCost3( + mockProgram(0, 2, 128, 2, 0)); + assertEquals(expectedCost3, cost); + } finally { + VMConfig.initAllowTvmOsaka(0); + } + } +} From 721b165e2f16fb2557f76c40205755531ff40076 Mon Sep 17 00:00:00 2001 From: Edward Date: Thu, 2 Apr 2026 17:34:24 +0800 Subject: [PATCH 06/41] chore(github): add CODEOWNERS for PR review assignments (#6636) --- .github/CODEOWNERS | 96 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000000..085821d3217 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,96 @@ +# CODEOWNERS — java-tron PR Review assignments +# +# Rule: when multiple rules match the same file, the last one takes precedence. +# Recommendation: put more specific paths further down. + +# Default owner — applies to any file not matched by a more specific rule below. +* @zeusoo001 @317787106 + +# ──────────────────────────────────────────────────────────────── +# 1. Top-level Gradle modules (corresponds to the "Level-1/Level-2 module" column) +# ──────────────────────────────────────────────────────────────── + +/framework/ @xxo1shine @bladehan1 +/chainbase/ @halibobo1205 @bladehan1 +/actuator/ @Sunny6889 @lxcmyf +/consensus/ @kuny0707 @xxo1shine +/protocol/ @lvs0075 @waynercheung +/common/ @lvs0075 @lxcmyf +/crypto/ @Federico2014 @3for +/plugins/ @halibobo1205 @warku123 +/docker/ @3for @kuny0707 + +# ──────────────────────────────────────────────────────────────── +# 2. CI / Build configuration +# ──────────────────────────────────────────────────────────────── + +/.github/ @317787106 @halibobo1205 +*.gradle @317787106 @halibobo1205 +/gradle/ @317787106 @halibobo1205 + +# ──────────────────────────────────────────────────────────────── +# 3. Sub-module paths (more specific than top-level; placed later to override rules above) +# ──────────────────────────────────────────────────────────────── + +# db — covers the db, db2, and storage packages inside chainbase +/chainbase/src/main/java/org/tron/core/db/ @halibobo1205 @bladehan1 +/chainbase/src/main/java/org/tron/core/db2/ @halibobo1205 @bladehan1 +/chainbase/src/main/java/org/tron/common/storage/ @halibobo1205 @bladehan1 + +# trie +/framework/src/main/java/org/tron/core/trie/ @halibobo1205 @317787106 + +# net +/framework/src/main/java/org/tron/core/net/ @317787106 @xxo1shine + +# vm / tvm +/actuator/src/main/java/org/tron/core/vm/ @yanghang8612 @CodeNinjaEvan + +# jsonrpc +/framework/src/main/java/org/tron/core/services/jsonrpc/ @0xbigapple @waynercheung +/framework/src/main/java/org/tron/core/services/interfaceJsonRpcOnSolidity/ @0xbigapple @waynercheung + +# rpc (gRPC) — non-HTTP parts of interfaceOnPBFT / interfaceOnSolidity + RpcService +/framework/src/main/java/org/tron/core/services/interfaceOnSolidity/ @317787106 @waynercheung +/framework/src/main/java/org/tron/common/application/ @317787106 @waynercheung + +# http (REST) — overrides the rpc rule above for the http sub-directories inside interfaceOnXxx +/framework/src/main/java/org/tron/core/services/http/ @Sunny6889 @waynercheung +/framework/src/main/java/org/tron/core/services/interfaceOnSolidity/http/ @Sunny6889 @waynercheung + +# event +/framework/src/main/java/org/tron/core/services/event/ @xxo1shine @0xbigapple +/framework/src/main/java/org/tron/common/logsfilter/ @xxo1shine @0xbigapple + +# config +/framework/src/main/java/org/tron/core/config/ @317787106 @kuny0707 +/chainbase/src/main/java/org/tron/core/config/ @317787106 @kuny0707 + +# backup +/framework/src/main/java/org/tron/common/backup/ @xxo1shine @317787106 +/framework/src/main/java/org/tron/core/db/backup/ @xxo1shine @317787106 + +# metrics +/framework/src/main/java/org/tron/core/metrics/ @halibobo1205 @Sunny6889 + +# rewards — logic is spread across chainbase service/store +/chainbase/src/main/java/org/tron/core/service/ @Sunny6889 @kuny0707 +/chainbase/src/main/java/org/tron/core/store/ @Sunny6889 @kuny0707 + +# lite — DbLite in plugins module; lite-related filters in framework +/plugins/src/main/java/common/org/tron/plugins/DbLite.java @bladehan1 @halibobo1205 +/plugins/src/main/java/common/org/tron/plugins/DbCopy.java @bladehan1 @halibobo1205 +/framework/src/main/java/org/tron/core/services/filter/ @bladehan1 @halibobo1205 + +# ──────────────────────────────────────────────────────────────── +# 4. Test code — mirrors the module ownership above +# ──────────────────────────────────────────────────────────────── + +/framework/src/test/ @xxo1shine @bladehan1 +/chainbase/src/test/ @halibobo1205 @bladehan1 +/actuator/src/test/ @Sunny6889 @lxcmyf +/consensus/src/test/ @kuny0707 @xxo1shine +/protocol/src/test/ @lvs0075 @waynercheung +/common/src/test/ @lvs0075 @lxcmyf +/crypto/src/test/ @Federico2014 @3for +/plugins/src/test/ @halibobo1205 @warku123 From 3ab5adf8220f7b5bb07d0a66056bdf3d942996f3 Mon Sep 17 00:00:00 2001 From: bladehan1 Date: Wed, 8 Apr 2026 19:44:49 +0800 Subject: [PATCH 07/41] feat(ci): add coverage check workflow and auto reviewer assignment (#6635) --- .github/CODEOWNERS | 96 ------------ .github/workflows/pr-build.yml | 240 ++++++++++++++++++++++++++++-- .github/workflows/pr-cancel.yml | 6 +- .github/workflows/pr-reviewer.yml | 142 ++++++++++++++++++ README.md | 1 - codecov.yml | 4 + 6 files changed, 382 insertions(+), 107 deletions(-) delete mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/pr-reviewer.yml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index 085821d3217..00000000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1,96 +0,0 @@ -# CODEOWNERS — java-tron PR Review assignments -# -# Rule: when multiple rules match the same file, the last one takes precedence. -# Recommendation: put more specific paths further down. - -# Default owner — applies to any file not matched by a more specific rule below. -* @zeusoo001 @317787106 - -# ──────────────────────────────────────────────────────────────── -# 1. Top-level Gradle modules (corresponds to the "Level-1/Level-2 module" column) -# ──────────────────────────────────────────────────────────────── - -/framework/ @xxo1shine @bladehan1 -/chainbase/ @halibobo1205 @bladehan1 -/actuator/ @Sunny6889 @lxcmyf -/consensus/ @kuny0707 @xxo1shine -/protocol/ @lvs0075 @waynercheung -/common/ @lvs0075 @lxcmyf -/crypto/ @Federico2014 @3for -/plugins/ @halibobo1205 @warku123 -/docker/ @3for @kuny0707 - -# ──────────────────────────────────────────────────────────────── -# 2. CI / Build configuration -# ──────────────────────────────────────────────────────────────── - -/.github/ @317787106 @halibobo1205 -*.gradle @317787106 @halibobo1205 -/gradle/ @317787106 @halibobo1205 - -# ──────────────────────────────────────────────────────────────── -# 3. Sub-module paths (more specific than top-level; placed later to override rules above) -# ──────────────────────────────────────────────────────────────── - -# db — covers the db, db2, and storage packages inside chainbase -/chainbase/src/main/java/org/tron/core/db/ @halibobo1205 @bladehan1 -/chainbase/src/main/java/org/tron/core/db2/ @halibobo1205 @bladehan1 -/chainbase/src/main/java/org/tron/common/storage/ @halibobo1205 @bladehan1 - -# trie -/framework/src/main/java/org/tron/core/trie/ @halibobo1205 @317787106 - -# net -/framework/src/main/java/org/tron/core/net/ @317787106 @xxo1shine - -# vm / tvm -/actuator/src/main/java/org/tron/core/vm/ @yanghang8612 @CodeNinjaEvan - -# jsonrpc -/framework/src/main/java/org/tron/core/services/jsonrpc/ @0xbigapple @waynercheung -/framework/src/main/java/org/tron/core/services/interfaceJsonRpcOnSolidity/ @0xbigapple @waynercheung - -# rpc (gRPC) — non-HTTP parts of interfaceOnPBFT / interfaceOnSolidity + RpcService -/framework/src/main/java/org/tron/core/services/interfaceOnSolidity/ @317787106 @waynercheung -/framework/src/main/java/org/tron/common/application/ @317787106 @waynercheung - -# http (REST) — overrides the rpc rule above for the http sub-directories inside interfaceOnXxx -/framework/src/main/java/org/tron/core/services/http/ @Sunny6889 @waynercheung -/framework/src/main/java/org/tron/core/services/interfaceOnSolidity/http/ @Sunny6889 @waynercheung - -# event -/framework/src/main/java/org/tron/core/services/event/ @xxo1shine @0xbigapple -/framework/src/main/java/org/tron/common/logsfilter/ @xxo1shine @0xbigapple - -# config -/framework/src/main/java/org/tron/core/config/ @317787106 @kuny0707 -/chainbase/src/main/java/org/tron/core/config/ @317787106 @kuny0707 - -# backup -/framework/src/main/java/org/tron/common/backup/ @xxo1shine @317787106 -/framework/src/main/java/org/tron/core/db/backup/ @xxo1shine @317787106 - -# metrics -/framework/src/main/java/org/tron/core/metrics/ @halibobo1205 @Sunny6889 - -# rewards — logic is spread across chainbase service/store -/chainbase/src/main/java/org/tron/core/service/ @Sunny6889 @kuny0707 -/chainbase/src/main/java/org/tron/core/store/ @Sunny6889 @kuny0707 - -# lite — DbLite in plugins module; lite-related filters in framework -/plugins/src/main/java/common/org/tron/plugins/DbLite.java @bladehan1 @halibobo1205 -/plugins/src/main/java/common/org/tron/plugins/DbCopy.java @bladehan1 @halibobo1205 -/framework/src/main/java/org/tron/core/services/filter/ @bladehan1 @halibobo1205 - -# ──────────────────────────────────────────────────────────────── -# 4. Test code — mirrors the module ownership above -# ──────────────────────────────────────────────────────────────── - -/framework/src/test/ @xxo1shine @bladehan1 -/chainbase/src/test/ @halibobo1205 @bladehan1 -/actuator/src/test/ @Sunny6889 @lxcmyf -/consensus/src/test/ @kuny0707 @xxo1shine -/protocol/src/test/ @lvs0075 @waynercheung -/common/src/test/ @lvs0075 @lxcmyf -/crypto/src/test/ @Federico2014 @3for -/plugins/src/test/ @halibobo1205 @warku123 diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index cd76487fefe..8ef800e15ff 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -29,9 +29,6 @@ jobs: fail-fast: false matrix: include: - - java: '8' - runner: macos-26-intel - arch: x86_64 - java: '17' runner: macos-26 arch: aarch64 @@ -57,10 +54,6 @@ jobs: - name: Build run: ./gradlew clean build --no-daemon - - name: Test with RocksDB engine - if: matrix.arch == 'x86_64' - run: ./gradlew :framework:testWithRocksDb --no-daemon - build-ubuntu: name: Build ubuntu24 (JDK 17 / aarch64) if: ${{ github.event_name == 'pull_request' || inputs.job == 'all' || inputs.job == 'ubuntu' }} @@ -177,7 +170,236 @@ jobs: debian11-x86_64-gradle- - name: Build - run: ./gradlew clean build --no-daemon + run: ./gradlew clean build --no-daemon --no-build-cache - name: Test with RocksDB engine - run: ./gradlew :framework:testWithRocksDb --no-daemon + run: ./gradlew :framework:testWithRocksDb --no-daemon --no-build-cache + + - name: Generate module coverage reports + run: ./gradlew jacocoTestReport --no-daemon + + - name: Upload PR coverage reports + uses: actions/upload-artifact@v6 + with: + name: jacoco-coverage-pr + path: | + **/build/reports/jacoco/test/jacocoTestReport.xml + if-no-files-found: error + + coverage-base: + name: Coverage Base (JDK 8 / x86_64) + if: ${{ github.event_name == 'pull_request' }} + runs-on: ubuntu-latest + timeout-minutes: 60 + container: + image: eclipse-temurin:8-jdk # base image is Debian 11 (Bullseye) + defaults: + run: + shell: bash + env: + GRADLE_USER_HOME: /github/home/.gradle + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + ref: ${{ github.event.pull_request.base.sha }} + + - name: Install dependencies (Debian + build tools) + run: | + set -euxo pipefail + apt-get update + apt-get install -y git wget unzip build-essential curl jq + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + /github/home/.gradle/caches + /github/home/.gradle/wrapper + key: coverage-base-x86_64-gradle-${{ hashFiles('**/*.gradle', '**/gradle-wrapper.properties') }} + restore-keys: | + coverage-base-x86_64-gradle- + + - name: Build (base) + run: ./gradlew clean build --no-daemon --no-build-cache + + - name: Test with RocksDB engine (base) + run: ./gradlew :framework:testWithRocksDb --no-daemon --no-build-cache + + - name: Generate module coverage reports (base) + run: ./gradlew jacocoTestReport --no-daemon + + - name: Upload base coverage reports + uses: actions/upload-artifact@v6 + with: + name: jacoco-coverage-base + path: | + **/build/reports/jacoco/test/jacocoTestReport.xml + if-no-files-found: error + + coverage-gate: + name: Coverage Gate + needs: [docker-build-debian11, coverage-base] + if: ${{ github.event_name == 'pull_request' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Download base coverage reports + uses: actions/download-artifact@v8 + with: + name: jacoco-coverage-base + path: coverage/base + + - name: Download PR coverage reports + uses: actions/download-artifact@v8 + with: + name: jacoco-coverage-pr + path: coverage/pr + + - name: Collect coverage report paths + id: collect-xml + run: | + BASE_XMLS=$(find coverage/base -name "jacocoTestReport.xml" | sort | paste -sd, -) + PR_XMLS=$(find coverage/pr -name "jacocoTestReport.xml" | sort | paste -sd, -) + if [ -z "$BASE_XMLS" ] || [ -z "$PR_XMLS" ]; then + echo "Missing jacocoTestReport.xml files for base or PR." + exit 1 + fi + echo "base_xmls=$BASE_XMLS" >> "$GITHUB_OUTPUT" + echo "pr_xmls=$PR_XMLS" >> "$GITHUB_OUTPUT" + + - name: Aggregate base coverage + id: jacoco-base + uses: madrapps/jacoco-report@v1.7.2 + with: + paths: ${{ steps.collect-xml.outputs.base_xmls }} + token: ${{ secrets.GITHUB_TOKEN }} + min-coverage-overall: 0 + min-coverage-changed-files: 0 + skip-if-no-changes: true + title: '## Base Coverage Snapshot' + update-comment: false + + - name: Aggregate PR coverage + id: jacoco-pr + uses: madrapps/jacoco-report@v1.7.2 + with: + paths: ${{ steps.collect-xml.outputs.pr_xmls }} + token: ${{ secrets.GITHUB_TOKEN }} + min-coverage-overall: 0 + min-coverage-changed-files: 0 + skip-if-no-changes: true + title: '## PR Code Coverage Report' + update-comment: false + + - name: Enforce coverage gates + env: + BASE_OVERALL_RAW: ${{ steps.jacoco-base.outputs.coverage-overall }} + PR_OVERALL_RAW: ${{ steps.jacoco-pr.outputs.coverage-overall }} + PR_CHANGED_RAW: ${{ steps.jacoco-pr.outputs.coverage-changed-files }} + run: | + set -euo pipefail + + MIN_CHANGED=60 + MAX_DROP=-0.1 + + sanitize() { + echo "$1" | tr -d ' %' + } + is_number() { + [[ "$1" =~ ^-?[0-9]+([.][0-9]+)?$ ]] + } + compare_float() { + # Usage: compare_float "" + # Example: compare_float "1.2 >= -0.1" + awk "BEGIN { if ($1) print 1; else print 0 }" + } + + # 1) Parse metrics from jacoco-report outputs + BASE_OVERALL="$(sanitize "$BASE_OVERALL_RAW")" + PR_OVERALL="$(sanitize "$PR_OVERALL_RAW")" + PR_CHANGED="$(sanitize "$PR_CHANGED_RAW")" + + if ! is_number "$BASE_OVERALL" || ! is_number "$PR_OVERALL"; then + echo "Failed to parse coverage values: base='${BASE_OVERALL}', pr='${PR_OVERALL}'." + exit 1 + fi + + # 2) Compare metrics against thresholds + DELTA=$(awk -v pr="$PR_OVERALL" -v base="$BASE_OVERALL" 'BEGIN { printf "%.4f", pr - base }') + DELTA_OK=$(compare_float "${DELTA} >= ${MAX_DROP}") + + CHANGED_STATUS="SKIPPED (no changed coverage value)" + CHANGED_OK=1 + if [ -n "$PR_CHANGED" ] && [ "$PR_CHANGED" != "NaN" ]; then + if ! is_number "$PR_CHANGED"; then + echo "Failed to parse changed-files coverage: changed='${PR_CHANGED}'." + exit 1 + fi + CHANGED_OK=$(compare_float "${PR_CHANGED} > ${MIN_CHANGED}") + if [ "$CHANGED_OK" -eq 1 ]; then + CHANGED_STATUS="PASS (> ${MIN_CHANGED}%)" + else + CHANGED_STATUS="FAIL (<= ${MIN_CHANGED}%)" + fi + fi + + # 3) Output base metrics (always visible in logs + step summary) + OVERALL_STATUS="PASS (>= ${MAX_DROP}%)" + if [ "$DELTA_OK" -ne 1 ]; then + OVERALL_STATUS="FAIL (< ${MAX_DROP}%)" + fi + + METRICS_TEXT=$(cat <> "$GITHUB_STEP_SUMMARY" + + # 4) Decide CI pass/fail + if [ "$DELTA_OK" -ne 1 ]; then + echo "Coverage gate failed: overall coverage dropped more than 0.1%." + echo "base=${BASE_OVERALL}% pr=${PR_OVERALL}% delta=${DELTA}%" + exit 1 + fi + + if [ -z "$PR_CHANGED" ] || [ "$PR_CHANGED" = "NaN" ]; then + echo "No changed-files coverage value detected, skip changed-files gate." + exit 0 + fi + + if [ "$CHANGED_OK" -ne 1 ]; then + echo "Coverage gate failed: changed files coverage must be > 60%." + echo "changed=${PR_CHANGED}%" + exit 1 + fi + + echo "Coverage gates passed." diff --git a/.github/workflows/pr-cancel.yml b/.github/workflows/pr-cancel.yml index 7be169661aa..bbd0e68c235 100644 --- a/.github/workflows/pr-cancel.yml +++ b/.github/workflows/pr-cancel.yml @@ -37,7 +37,11 @@ jobs: ); for (const run of runs) { - const isTargetPr = !run.pull_requests?.length || run.pull_requests.some((pr) => pr.number === prNumber); + if (!run) { + continue; + } + const prs = Array.isArray(run.pull_requests) ? run.pull_requests : []; + const isTargetPr = prs.length === 0 || prs.some((pr) => pr.number === prNumber); if (run.head_sha === headSha && isTargetPr) { await github.rest.actions.cancelWorkflowRun({ owner: context.repo.owner, diff --git a/.github/workflows/pr-reviewer.yml b/.github/workflows/pr-reviewer.yml new file mode 100644 index 00000000000..45561c6a6ad --- /dev/null +++ b/.github/workflows/pr-reviewer.yml @@ -0,0 +1,142 @@ +name: Auto Assign Reviewers + +on: + pull_request: + branches: [ 'develop', 'release_**' ] + types: [ opened, edited, reopened ] + +jobs: + assign-reviewers: + name: Assign Reviewers by Scope + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + + steps: + - name: Assign reviewers based on PR title scope + uses: actions/github-script@v8 + with: + script: | + const title = context.payload.pull_request.title; + const prAuthor = context.payload.pull_request.user.login; + + // ── Scope → Reviewer mapping ────────────────────────────── + const scopeReviewers = { + 'framework': ['xxo1shine', '317787106'], + 'chainbase': ['halibobo1205', 'lvs0075'], + 'db': ['halibobo1205', 'xxo1shine'], + 'trie': ['halibobo1205', '317787106'], + 'actuator': ['yanghang8612', 'lxcmyf'], + 'consensus': ['lvs0075', 'xxo1shine'], + 'protocol': ['lvs0075', 'waynercheung'], + 'common': ['xxo1shine', 'lxcmyf'], + 'crypto': ['Federico2014', '3for'], + 'net': ['317787106', 'xxo1shine'], + 'vm': ['yanghang8612', 'CodeNinjaEvan'], + 'tvm': ['yanghang8612', 'CodeNinjaEvan'], + 'jsonrpc': ['0xbigapple', 'bladehan1'], + 'rpc': ['317787106', 'Sunny6889'], + 'http': ['Sunny6889', 'bladehan1'], + 'event': ['xxo1shine', '0xbigapple'], + 'config': ['317787106', 'halibobo1205'], + 'backup': ['xxo1shine', '317787106'], + 'lite': ['bladehan1', 'halibobo1205'], + 'toolkit': ['halibobo1205', 'Sunny6889'], + 'plugins': ['halibobo1205', 'Sunny6889'], + 'docker': ['3for', 'kuny0707'], + 'test': ['bladehan1', 'lxcmyf'], + 'metrics': ['halibobo1205', 'Sunny6889'], + 'api': ['0xbigapple', 'waynercheung', 'bladehan1'], + 'ci': ['bladehan1', 'halibobo1205'], + }; + const defaultReviewers = ['halibobo1205', '317787106']; + + // ── Normalize helper ───────────────────────────────────── + // Strip spaces, hyphens, underscores and lower-case so that + // "VM", " json rpc ", "chain-base", "Json_Rpc" all normalize + // to their canonical key form ("vm", "jsonrpc", "chainbase"). + const normalize = s => s.toLowerCase().replace(/[\s\-_]/g, ''); + + // ── Extract scope from conventional commit title ────────── + // Format: type(scope): description + // Also supports: type(scope1,scope2): description + const scopeMatch = title.match(/^\w+\(([^)]+)\):/); + const rawScope = scopeMatch ? scopeMatch[1] : null; + + core.info(`PR title : ${title}`); + core.info(`Raw scope: ${rawScope || '(none)'}`); + + // ── Skip if reviewers already assigned ────────────────── + const pr = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + }); + const existing = pr.data.requested_reviewers || []; + if (existing.length > 0) { + core.info(`Reviewers already assigned (${existing.map(r => r.login).join(', ')}). Skipping.`); + return; + } + + // ── Determine reviewers ─────────────────────────────────── + // 1. Split by comma to support multi-scope: feat(vm,rpc): ... + // 2. Normalize each scope token + // 3. Match against keys: exact match first, then contains match + // (longest key wins to avoid "net" matching inside "jsonrpc") + let matched = new Set(); + let matchedScopes = []; + + if (rawScope) { + const tokens = rawScope.split(',').map(s => normalize(s.trim())); + // Pre-sort keys by length descending so longer keys match first + const sortedKeys = Object.keys(scopeReviewers) + .sort((a, b) => b.length - a.length); + + for (const token of tokens) { + if (!token) continue; + // Exact match + if (scopeReviewers[token]) { + matchedScopes.push(token); + scopeReviewers[token].forEach(r => matched.add(r)); + continue; + } + // Contains match: token contains a key, or key contains token + // Prefer longest key that matches + const found = sortedKeys.find(k => token.includes(k) || k.includes(token)); + if (found) { + matchedScopes.push(`${token}→${found}`); + scopeReviewers[found].forEach(r => matched.add(r)); + } + } + } + + let reviewers = matched.size > 0 + ? [...matched] + : defaultReviewers; + + core.info(`Matched scopes: ${matchedScopes.length > 0 ? matchedScopes.join(', ') : '(none — using default)'}`); + core.info(`Candidate reviewers: ${reviewers.join(', ')}`); + + // Exclude the PR author from the reviewer list + reviewers = reviewers.filter(r => r.toLowerCase() !== prAuthor.toLowerCase()); + + if (reviewers.length === 0) { + core.info('No eligible reviewers after excluding PR author. Skipping.'); + return; + } + + core.info(`Assigning reviewers: ${reviewers.join(', ')}`); + + // ── Request reviews ─────────────────────────────────────── + try { + await github.rest.pulls.requestReviewers({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + reviewers: reviewers, + }); + core.info('Reviewers assigned successfully.'); + } catch (error) { + // If a reviewer is not a collaborator the API returns 422; + // log the error but do not fail the workflow. + core.warning(`Failed to assign some reviewers: ${error.message}`); + } diff --git a/README.md b/README.md index 0c8051d353b..575409b3a96 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,6 @@

- diff --git a/codecov.yml b/codecov.yml index fd5929fb024..1b46f3fa8db 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,3 +1,7 @@ +# DEPRECATED: Codecov integration is no longer active. +# Coverage is now handled by JaCoCo + madrapps/jacoco-report in pr-build.yml. +# This file is retained for reference only and can be safely deleted. + # Post a Codecov comment on pull requests. If don't need comment, use comment: false, else use following comment: false #comment: From 29f475e51dc6c6485e96f2d2ccd93af361e7d141 Mon Sep 17 00:00:00 2001 From: bladehan1 Date: Thu, 9 Apr 2026 12:43:22 +0800 Subject: [PATCH 08/41] fix(ci): use pull_request_target and add write permissions for reviewer assignment (#6663) --- .github/workflows/pr-reviewer.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-reviewer.yml b/.github/workflows/pr-reviewer.yml index 45561c6a6ad..bf124acf576 100644 --- a/.github/workflows/pr-reviewer.yml +++ b/.github/workflows/pr-reviewer.yml @@ -1,15 +1,17 @@ name: Auto Assign Reviewers on: - pull_request: + pull_request_target: branches: [ 'develop', 'release_**' ] types: [ opened, edited, reopened ] jobs: assign-reviewers: name: Assign Reviewers by Scope - if: github.event_name == 'pull_request' runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write steps: - name: Assign reviewers based on PR title scope From 4971754a38c28cd954694a799d5486f42b23be55 Mon Sep 17 00:00:00 2001 From: Asuka Date: Thu, 9 Apr 2026 15:43:40 +0800 Subject: [PATCH 09/41] fix(test): correct comment in testZeroLengthOneArray Co-Authored-By: Claude Opus 4.6 --- .../java/org/tron/common/runtime/vm/VoteWitnessCost3Test.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/framework/src/test/java/org/tron/common/runtime/vm/VoteWitnessCost3Test.java b/framework/src/test/java/org/tron/common/runtime/vm/VoteWitnessCost3Test.java index cc084284d96..66de45a0658 100644 --- a/framework/src/test/java/org/tron/common/runtime/vm/VoteWitnessCost3Test.java +++ b/framework/src/test/java/org/tron/common/runtime/vm/VoteWitnessCost3Test.java @@ -124,8 +124,8 @@ public void testZeroLengthOneArray() { // witness array zero, amount array non-zero Program program = mockProgram(0, 0, 64, 1, 0); long cost = EnergyCost.getVoteWitnessCost3(program); - // witnessMemNeeded = 0 (size is zero) - // amountArraySize = 1 * 32 + 32 = 64, memNeeded = 64 + 64 = 128 + // witnessArraySize = 0 * 32 + 32 = 32, witnessMemNeeded = 0 + 32 = 32 + // amountArraySize = 1 * 32 + 32 = 64, amountMemNeeded = 64 + 64 = 128 // memWords = 128 / 32 = 4 // memEnergy = 3 * 4 + 4 * 4 / 512 = 12 assertEquals(30012, cost); From 3f7ff93a30dae3c618ebf7bc185a40a5c6e8db94 Mon Sep 17 00:00:00 2001 From: 317787106 <317787106@qq.com> Date: Thu, 9 Apr 2026 17:36:12 +0800 Subject: [PATCH 10/41] imporove feature template (#6657) --- .github/ISSUE_TEMPLATE/request-a-feature.md | 98 ++++++--------------- 1 file changed, 27 insertions(+), 71 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/request-a-feature.md b/.github/ISSUE_TEMPLATE/request-a-feature.md index d0ee11c4a1a..d8234f92a25 100644 --- a/.github/ISSUE_TEMPLATE/request-a-feature.md +++ b/.github/ISSUE_TEMPLATE/request-a-feature.md @@ -7,85 +7,41 @@ assignees: '' --- - - -## Problem Statement - - +# Summary + -## Proposed Solution +# Problem +### Motivation + - +### Current State + -### Specification +### Limitations or Risks + - +# Proposed Solution -**API Changes** (if applicable) - +### Proposed Design + -**Configuration Changes** (if applicable) - +### Key Changes + -**Protocol Changes** (if applicable) - +# Impact + -## Scope of Impact - - -**Breaking Changes** - - -**Backward Compatibility** - - -## Implementation - -**Do you have ideas regarding the implementation?** - - -**Are you willing to implement this feature?** - - -**Estimated Complexity** - - -## Testing Strategy - - - -**Test Scenarios** - -**Performance Considerations** - - -## Alternatives Considered (Optional) - - - -## Additional Context (Optional) - - - -**Related Issues/PRs** - +# References (Optional) + -**References** - +# Additional Notes +- Do you have ideas regarding implementation? Yes / No +- Are you willing to implement this feature? Yes / No \ No newline at end of file From 59b13398d712a27667244d263f2d6513337e566f Mon Sep 17 00:00:00 2001 From: halibobo1205 Date: Thu, 9 Apr 2026 17:51:32 +0800 Subject: [PATCH 11/41] fix(actuator): correct protobuf unpack types in getOwnerAddress() Fix incorrect protobuf types used in any.unpack() within getOwnerAddress() for three actuators: - UpdateAssetActuator: AccountUpdateContract -> UpdateAssetContract - MarketCancelOrderActuator: AssetIssueContract -> MarketCancelOrderContract - MarketSellAssetActuator: AssetIssueContract -> MarketSellAssetContract Also remove unused imports and add unit tests for getOwnerAddress(). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tron/core/actuator/MarketCancelOrderActuator.java | 3 +-- .../tron/core/actuator/MarketSellAssetActuator.java | 3 +-- .../org/tron/core/actuator/UpdateAssetActuator.java | 3 +-- .../core/actuator/MarketCancelOrderActuatorTest.java | 11 +++++++++++ .../core/actuator/MarketSellAssetActuatorTest.java | 11 +++++++++++ .../tron/core/actuator/UpdateAssetActuatorTest.java | 11 +++++++++++ 6 files changed, 36 insertions(+), 6 deletions(-) diff --git a/actuator/src/main/java/org/tron/core/actuator/MarketCancelOrderActuator.java b/actuator/src/main/java/org/tron/core/actuator/MarketCancelOrderActuator.java index 31b5e87e12d..d4260e38163 100644 --- a/actuator/src/main/java/org/tron/core/actuator/MarketCancelOrderActuator.java +++ b/actuator/src/main/java/org/tron/core/actuator/MarketCancelOrderActuator.java @@ -43,7 +43,6 @@ import org.tron.protos.Protocol.MarketOrder.State; import org.tron.protos.Protocol.Transaction.Contract.ContractType; import org.tron.protos.Protocol.Transaction.Result.code; -import org.tron.protos.contract.AssetIssueContractOuterClass.AssetIssueContract; import org.tron.protos.contract.MarketContract.MarketCancelOrderContract; @Slf4j(topic = "actuator") @@ -221,7 +220,7 @@ public boolean validate() throws ContractValidateException { @Override public ByteString getOwnerAddress() throws InvalidProtocolBufferException { - return any.unpack(AssetIssueContract.class).getOwnerAddress(); + return any.unpack(MarketCancelOrderContract.class).getOwnerAddress(); } @Override diff --git a/actuator/src/main/java/org/tron/core/actuator/MarketSellAssetActuator.java b/actuator/src/main/java/org/tron/core/actuator/MarketSellAssetActuator.java index d196cdef06f..369857ae6c1 100644 --- a/actuator/src/main/java/org/tron/core/actuator/MarketSellAssetActuator.java +++ b/actuator/src/main/java/org/tron/core/actuator/MarketSellAssetActuator.java @@ -54,7 +54,6 @@ import org.tron.protos.Protocol.MarketPrice; import org.tron.protos.Protocol.Transaction.Contract.ContractType; import org.tron.protos.Protocol.Transaction.Result.code; -import org.tron.protos.contract.AssetIssueContractOuterClass.AssetIssueContract; import org.tron.protos.contract.MarketContract.MarketSellAssetContract; @Slf4j(topic = "actuator") @@ -283,7 +282,7 @@ public boolean validate() throws ContractValidateException { @Override public ByteString getOwnerAddress() throws InvalidProtocolBufferException { - return any.unpack(AssetIssueContract.class).getOwnerAddress(); + return any.unpack(MarketSellAssetContract.class).getOwnerAddress(); } @Override diff --git a/actuator/src/main/java/org/tron/core/actuator/UpdateAssetActuator.java b/actuator/src/main/java/org/tron/core/actuator/UpdateAssetActuator.java index 193d352a72d..36ec42cb8ba 100644 --- a/actuator/src/main/java/org/tron/core/actuator/UpdateAssetActuator.java +++ b/actuator/src/main/java/org/tron/core/actuator/UpdateAssetActuator.java @@ -17,7 +17,6 @@ import org.tron.core.utils.TransactionUtil; import org.tron.protos.Protocol.Transaction.Contract.ContractType; import org.tron.protos.Protocol.Transaction.Result.code; -import org.tron.protos.contract.AccountContract.AccountUpdateContract; import org.tron.protos.contract.AssetIssueContractOuterClass.UpdateAssetContract; @Slf4j(topic = "actuator") @@ -171,7 +170,7 @@ public boolean validate() throws ContractValidateException { @Override public ByteString getOwnerAddress() throws InvalidProtocolBufferException { - return any.unpack(AccountUpdateContract.class).getOwnerAddress(); + return any.unpack(UpdateAssetContract.class).getOwnerAddress(); } @Override diff --git a/framework/src/test/java/org/tron/core/actuator/MarketCancelOrderActuatorTest.java b/framework/src/test/java/org/tron/core/actuator/MarketCancelOrderActuatorTest.java index 4c6b7b58b89..d2ec8c30994 100644 --- a/framework/src/test/java/org/tron/core/actuator/MarketCancelOrderActuatorTest.java +++ b/framework/src/test/java/org/tron/core/actuator/MarketCancelOrderActuatorTest.java @@ -2,6 +2,7 @@ import com.google.protobuf.Any; import com.google.protobuf.ByteString; +import com.google.protobuf.InvalidProtocolBufferException; import java.util.List; import lombok.extern.slf4j.Slf4j; import org.junit.Assert; @@ -759,4 +760,14 @@ public void onlyOneOrderAtThisPriceAndHasOnlyOnePrice() throws Exception { Assert.assertNull(orderIdListCapsule); } + + @Test + public void testGetOwnerAddress() throws InvalidProtocolBufferException { + MarketCancelOrderActuator actuator = new MarketCancelOrderActuator(); + actuator.setChainBaseManager(dbManager.getChainBaseManager()) + .setAny(getContract(OWNER_ADDRESS_FIRST, ByteString.copyFromUtf8("orderId"))); + + Assert.assertEquals(OWNER_ADDRESS_FIRST, + ByteArray.toHexString(actuator.getOwnerAddress().toByteArray())); + } } \ No newline at end of file diff --git a/framework/src/test/java/org/tron/core/actuator/MarketSellAssetActuatorTest.java b/framework/src/test/java/org/tron/core/actuator/MarketSellAssetActuatorTest.java index 2789f261fd1..c8eb2e66686 100644 --- a/framework/src/test/java/org/tron/core/actuator/MarketSellAssetActuatorTest.java +++ b/framework/src/test/java/org/tron/core/actuator/MarketSellAssetActuatorTest.java @@ -4,6 +4,7 @@ import com.google.protobuf.Any; import com.google.protobuf.ByteString; +import com.google.protobuf.InvalidProtocolBufferException; import java.util.List; import lombok.extern.slf4j.Slf4j; import org.junit.After; @@ -1871,4 +1872,14 @@ public void exceedMaxMatchNumLimit() throws Exception { } } + @Test + public void testGetOwnerAddress() throws InvalidProtocolBufferException { + MarketSellAssetActuator actuator = new MarketSellAssetActuator(); + actuator.setChainBaseManager(dbManager.getChainBaseManager()) + .setAny(getContract(OWNER_ADDRESS_FIRST, "sellToken", 100L, "buyToken", 200L)); + + Assert.assertEquals(OWNER_ADDRESS_FIRST, + ByteArray.toHexString(actuator.getOwnerAddress().toByteArray())); + } + } \ No newline at end of file diff --git a/framework/src/test/java/org/tron/core/actuator/UpdateAssetActuatorTest.java b/framework/src/test/java/org/tron/core/actuator/UpdateAssetActuatorTest.java index 1c17b3873c4..45a956b59f3 100644 --- a/framework/src/test/java/org/tron/core/actuator/UpdateAssetActuatorTest.java +++ b/framework/src/test/java/org/tron/core/actuator/UpdateAssetActuatorTest.java @@ -4,6 +4,7 @@ import com.google.protobuf.Any; import com.google.protobuf.ByteString; +import com.google.protobuf.InvalidProtocolBufferException; import java.util.Date; import lombok.extern.slf4j.Slf4j; import org.junit.Assert; @@ -488,4 +489,14 @@ public void commonErrorCheck() { actuatorTest.nullDBManger(); } + @Test + public void testGetOwnerAddress() throws InvalidProtocolBufferException { + UpdateAssetActuator actuator = new UpdateAssetActuator(); + actuator.setChainBaseManager(dbManager.getChainBaseManager()) + .setAny(getContract(OWNER_ADDRESS, DESCRIPTION, URL, 500L, 8000L)); + + Assert.assertEquals(OWNER_ADDRESS, + ByteArray.toHexString(actuator.getOwnerAddress().toByteArray())); + } + } From 2de63bb9b012db04c7d1959bf91dfbdc85731302 Mon Sep 17 00:00:00 2001 From: 0xbigapple Date: Thu, 16 Apr 2026 15:19:28 +0800 Subject: [PATCH 12/41] feat(protocol): add protoLint check for enum validation (#6631) * feat(protocol): add protoLint script for enum validation * feat(protocol): optimize protoLint performance and caching * fix(proto): resolve gradle implicit task dependency warning * docs(protocol): clarify enum discriminator in protoLint * build(protocol): harden proto lint buf config * docs: update comment to reflect dynamic include path derivation --- gradle/verification-metadata.xml | 23 ++++ protocol/build.gradle | 1 + protocol/protoLint.gradle | 179 +++++++++++++++++++++++++++++++ 3 files changed, 203 insertions(+) create mode 100644 protocol/protoLint.gradle diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 4d0bf1013d6..86880157f35 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -26,6 +26,29 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/protocol/build.gradle b/protocol/build.gradle index 789d27b6360..04d970b59db 100644 --- a/protocol/build.gradle +++ b/protocol/build.gradle @@ -1,4 +1,5 @@ apply plugin: 'com.google.protobuf' +apply from: 'protoLint.gradle' def protobufVersion = '3.25.8' def grpcVersion = '1.75.0' diff --git a/protocol/protoLint.gradle b/protocol/protoLint.gradle new file mode 100644 index 00000000000..0c76ffa5cfe --- /dev/null +++ b/protocol/protoLint.gradle @@ -0,0 +1,179 @@ +/** + * This is a Gradle script for proto linting. + * + * Implementation: + * 1. Integrates the 'buf' CLI tool to compile .proto files and generate a JSON AST (Abstract Syntax Tree) image. + * 2. Uses Groovy's JsonSlurper to parse the AST image. + * 3. Traverses all Enum definitions and validates them against preset rules. + * + * Current Validation: + * Enforces the java-tron API evolution standard (see https://github.com/tronprotocol/java-tron/issues/6515). + * Except for legacy enums in the 'legacyEnums' whitelist, all newly defined Enums MUST reserve index 0 for a field starting with 'UNKNOWN_'. + * This ensures robust forward/backward compatibility during proto3 JSON serialization. + */ +import groovy.json.JsonBuilder +import groovy.json.JsonSlurper +import org.gradle.internal.os.OperatingSystem + +// Define the required buf CLI version +def bufVersion = "1.61.0" +def currentOs = OperatingSystem.current() +def platform = currentOs.isMacOsX() ? "osx" : (currentOs.isWindows() ? "windows" : "linux") +def machine = rootProject.archInfo.isArm64 ? "aarch_64" : "x86_64" + +// Create a custom configuration for the buf CLI tool to keep it isolated from the classpath +configurations { + bufTool +} + +// Depend on the buf executable published on Maven Central +dependencies { + bufTool "build.buf:buf:${bufVersion}:${platform}-${machine}@exe" +} + +task protoLint { + group = "verification" + description = "Validate Protobuf Enums using buf generated JSON AST. Enforces 'UNKNOWN_' prefix for index 0 to ensure JSON serialization backward compatibility." + + // Explicitly depend on: + // 1. extractIncludeProto: ensure external protos are extracted before buf runs. + // The include root is derived from that task's actual output below. + // 2. generateProto: fix Gradle implicit dependency warning due to output directory overlap. + dependsOn 'extractIncludeProto', 'generateProto' + + // Wire the include proto directory from the extractIncludeProto task's actual output + def extractTask = tasks.named('extractIncludeProto').get() + def includeProtoDir = extractTask.destDir.get().asFile + def includeProtoDirRel = projectDir.toPath().relativize(includeProtoDir.toPath()).toString() + + // Incremental build support: re-run when any file buf physically reads changes. + // Include protos are not lint targets, but buf reads them for import resolution, + // so they must be declared as inputs to keep the task cache hermetic. + inputs.dir('src/main/protos') + inputs.dir(includeProtoDir) + inputs.file('protoLint.gradle') + + def markerFile = file("${buildDir}/tmp/protoLint.done") + outputs.file(markerFile) + + doLast { + def bufExe = configurations.bufTool.singleFile + if (!bufExe.exists() || !bufExe.canExecute()) { + bufExe.setExecutable(true) + } + + // 1. Legacy Whitelist + // Contains enums that existed before the 'UNKNOWN_' standard was enforced. + // Format: "filename.proto:EnumName" or "filename.proto:MessageName.EnumName" + def legacyEnums = [ + "core/contract/common.proto:ResourceCode", + "core/contract/smart_contract.proto:SmartContract.ABI.Entry.EntryType", + "core/contract/smart_contract.proto:SmartContract.ABI.Entry.StateMutabilityType", + "core/Tron.proto:AccountType", + "core/Tron.proto:ReasonCode", + "core/Tron.proto:Proposal.State", + "core/Tron.proto:MarketOrder.State", + "core/Tron.proto:Permission.PermissionType", + "core/Tron.proto:Transaction.Contract.ContractType", + "core/Tron.proto:Transaction.Result.code", + "core/Tron.proto:Transaction.Result.contractResult", + "core/Tron.proto:TransactionInfo.code", + "core/Tron.proto:BlockInventory.Type", + "core/Tron.proto:Inventory.InventoryType", + "core/Tron.proto:Items.ItemType", + "core/Tron.proto:PBFTMessage.MsgType", + "core/Tron.proto:PBFTMessage.DataType", + "api/api.proto:Return.response_code", + "api/api.proto:TransactionSignWeight.Result.response_code", + "api/api.proto:TransactionApprovedList.Result.response_code", + "api/zksnark.proto:ZksnarkResponse.Code" + ].collect { it.toString() } as Set + + // 2. Build JSON AST Image using buf CLI + def imageDir = file("${buildDir}/tmp/buf") + def imageFile = file("${imageDir}/proto-ast.json") + imageDir.mkdirs() + + println "🔍 Generating Proto AST image using buf CLI..." + + def bufConfig = new JsonBuilder([version: "v1beta1", build: [roots: ["src/main/protos", includeProtoDirRel]]]).toString() + + def execResult = exec { + commandLine bufExe.absolutePath, 'build', '.', '--config', bufConfig, '-o', "${imageFile.absolutePath}#format=json" + ignoreExitValue = true + } + + if (execResult.exitValue != 0) { + throw new GradleException("Failed to generate AST image. Ensure your .proto files are valid. Buf exited with code ${execResult.exitValue}") + } + + if (!imageFile.exists()) { + throw new GradleException("Failed to locate generated buf image at ${imageFile.absolutePath}") + } + + // 3. Parse AST and Validate Enums + def descriptorSet + try { + descriptorSet = new JsonSlurper().parse(imageFile) + } catch (Exception e) { + throw new GradleException("Failed to parse buf generated JSON AST: ${e.message}", e) + } + + def errors = [] + + descriptorSet.file?.each { protoFile -> + // Skip Google's and gRPC's internal protos as they are outside our control + if (protoFile.name?.startsWith("google/") || protoFile.name?.startsWith("grpc/")) { + return + } + + // A queue-based (BFS) approach to safely traverse all nested messages and enums + // without using recursion, ensuring support for any nesting depth. + Queue queue = new ArrayDeque() + + // Initial seed: top-level enums and messages + protoFile.enumType?.each { queue.add([def: it, parentName: ""]) } + protoFile.messageType?.each { queue.add([def: it, parentName: ""]) } + + while (!queue.isEmpty()) { + def item = queue.poll() + def definition = item.def + def parentName = item.parentName + + // In buf's JSON image, enums expose EnumDescriptorProto.value while + // message descriptors do not, so we use that field as the discriminator here. + if (definition.value != null) { + // This is an Enum definition + def fullName = parentName ? "${parentName}.${definition.name}" : definition.name + def identifier = "${protoFile.name}:${fullName}".toString() + + if (!legacyEnums.contains(identifier)) { + def zeroValue = definition.value?.find { it.number == 0 } + if (zeroValue && !zeroValue.name?.startsWith("UNKNOWN_")) { + errors << "[${protoFile.name}] Enum \"${fullName}\" has index 0: \"${zeroValue.name}\". It MUST start with \"UNKNOWN_\"." + } + } + } else { + // This is a Message definition, look for nested enums and nested messages + def currentMsgName = parentName ? "${parentName}.${definition.name}" : definition.name + + definition.enumType?.each { queue << [def: it, parentName: currentMsgName] } + definition.nestedType?.each { queue << [def: it, parentName: currentMsgName] } + } + } + } + + // 4. Report Results + if (!errors.isEmpty()) { + println "\n❌ [Protocol Design Violation] The following enums violate the java-tron API evolution standard (Issue #6515):" + errors.each { println " - $it" } + throw new GradleException("Proto Enum validation failed. See above for details.") + } else { + println "✅ Proto Enum validation passed!" + // Update marker file for Gradle incremental build cache + markerFile.text = "Success" + } + } +} + +check.dependsOn protoLint From f0a8f0f66fbe09aef1bf1ddf2e50ee0952e730c4 Mon Sep 17 00:00:00 2001 From: 3for Date: Tue, 21 Apr 2026 11:23:51 +0800 Subject: [PATCH 13/41] test(framework): consolidate and stabilize CredentialsTest (#6614) * test(framework): merge duplicate CredentialsTest coverage Consolidate the misplaced keystroe CredentialsTest into org.tron.keystore.CredentialsTest. - remove the duplicate test under the misspelled keystroe package - add explicit equals behavior coverage for address and cryptoEngine - normalize assertions to JUnit Assert and remove legacy TestCase usage * test(framework): stabilize CredentialsTest fixtures Replace random Credentials test setup with deterministic SignInterface mocks so the suite no longer depends on platform-specific SecureRandom providers or probabilistic retries. - remove NativePRNG usage from CredentialsTest - replace random key generation with fixed address fixtures via mocked SignInterface - assert create(SignInterface) returns the expected base58check address - keep equals/hashCode contract coverage with deterministic inputs --- .../org/tron/keystore/CredentialsTest.java | 83 +++++++++++++------ .../org/tron/keystroe/CredentialsTest.java | 33 -------- 2 files changed, 58 insertions(+), 58 deletions(-) delete mode 100644 framework/src/test/java/org/tron/keystroe/CredentialsTest.java diff --git a/framework/src/test/java/org/tron/keystore/CredentialsTest.java b/framework/src/test/java/org/tron/keystore/CredentialsTest.java index 3fe2ce02b63..8aabd887bb0 100644 --- a/framework/src/test/java/org/tron/keystore/CredentialsTest.java +++ b/framework/src/test/java/org/tron/keystore/CredentialsTest.java @@ -1,25 +1,36 @@ package org.tron.keystore; -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; -import junit.framework.TestCase; -import lombok.extern.slf4j.Slf4j; +import org.junit.Assert; import org.junit.Test; -import org.springframework.util.Assert; -import org.tron.common.crypto.SignUtils; +import org.mockito.Mockito; +import org.tron.common.crypto.SignInterface; import org.tron.common.crypto.sm2.SM2; import org.tron.common.utils.ByteUtil; +import org.tron.common.utils.StringUtil; -@Slf4j -public class CredentialsTest extends TestCase { +public class CredentialsTest { + + private static final byte[] ADDRESS_1 = ByteUtil.hexToBytes( + "410102030405060708090a0b0c0d0e0f1011121314"); + private static final byte[] ADDRESS_2 = ByteUtil.hexToBytes( + "411415161718191a1b1c1d1e1f2021222324252627"); + + private SignInterface mockSignInterface(byte[] address) { + SignInterface signInterface = Mockito.mock(SignInterface.class); + Mockito.when(signInterface.getAddress()).thenReturn(address); + return signInterface; + } @Test - public void testCreate() throws NoSuchAlgorithmException { - Credentials credentials = Credentials.create(SignUtils.getGeneratedRandomSign( - SecureRandom.getInstance("NativePRNG"),true)); - Assert.hasText(credentials.getAddress(),"Credentials address create failed!"); - Assert.notNull(credentials.getSignInterface(), - "Credentials cryptoEngine create failed"); + public void testCreate() { + SignInterface signInterface = mockSignInterface(ADDRESS_1); + + Credentials credentials = Credentials.create(signInterface); + + Assert.assertEquals("Credentials address create failed!", + StringUtil.encode58Check(ADDRESS_1), credentials.getAddress()); + Assert.assertSame("Credentials cryptoEngine create failed", signInterface, + credentials.getSignInterface()); } @Test @@ -28,21 +39,43 @@ public void testCreateFromSM2() { Credentials.create(SM2.fromNodeId(ByteUtil.hexToBytes("fffffffffff" + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + "fffffffffffffffffffffffffffffffffffffff"))); + Assert.fail("Expected IllegalArgumentException"); } catch (Exception e) { - Assert.isInstanceOf(IllegalArgumentException.class, e); + Assert.assertTrue(e instanceof IllegalArgumentException); } } @Test - public void testEquals() throws NoSuchAlgorithmException { - Credentials credentials1 = Credentials.create(SignUtils.getGeneratedRandomSign( - SecureRandom.getInstance("NativePRNG"),true)); - Credentials credentials2 = Credentials.create(SignUtils.getGeneratedRandomSign( - SecureRandom.getInstance("NativePRNG"),true)); - Assert.isTrue(!credentials1.equals(credentials2), - "Credentials instance should be not equal!"); - Assert.isTrue(!(credentials1.hashCode() == credentials2.hashCode()), - "Credentials instance hashcode should be not equal!"); + public void testEquals() { + Credentials credentials1 = Credentials.create(mockSignInterface(ADDRESS_1)); + Credentials credentials2 = Credentials.create(mockSignInterface(ADDRESS_2)); + + Assert.assertNotEquals("Credentials address fixtures should differ", + credentials1.getAddress(), credentials2.getAddress()); + Assert.assertNotEquals("Credentials instance should be not equal!", + credentials1, credentials2); + } + + @Test + public void testEqualsWithAddressAndCryptoEngine() { + Object aObject = new Object(); + SignInterface signInterface = mockSignInterface(ADDRESS_1); + SignInterface signInterface2 = mockSignInterface(ADDRESS_1); + SignInterface signInterface3 = mockSignInterface(ADDRESS_2); + + Credentials credential = Credentials.create(signInterface); + Credentials sameCredential = Credentials.create(signInterface); + Credentials sameAddressDifferentEngineCredential = Credentials.create(signInterface2); + Credentials differentCredential = Credentials.create(signInterface3); + + Assert.assertFalse(aObject.equals(credential)); + Assert.assertFalse(credential.equals(aObject)); + Assert.assertFalse(credential.equals(null)); + Assert.assertEquals(credential, sameCredential); + Assert.assertEquals("Equal credentials must have the same hashCode", + credential.hashCode(), sameCredential.hashCode()); + Assert.assertNotEquals(credential, sameAddressDifferentEngineCredential); + Assert.assertFalse(credential.equals(differentCredential)); } -} \ No newline at end of file +} diff --git a/framework/src/test/java/org/tron/keystroe/CredentialsTest.java b/framework/src/test/java/org/tron/keystroe/CredentialsTest.java deleted file mode 100644 index 2642129e00a..00000000000 --- a/framework/src/test/java/org/tron/keystroe/CredentialsTest.java +++ /dev/null @@ -1,33 +0,0 @@ -package org.tron.keystroe; - -import org.junit.Assert; -import org.junit.Test; -import org.mockito.Mockito; -import org.tron.common.crypto.SignInterface; -import org.tron.keystore.Credentials; - -public class CredentialsTest { - - @Test - public void test_equality() { - Object aObject = new Object(); - SignInterface si = Mockito.mock(SignInterface.class); - SignInterface si2 = Mockito.mock(SignInterface.class); - SignInterface si3 = Mockito.mock(SignInterface.class); - byte[] address = "TQhZ7W1RudxFdzJMw6FvMnujPxrS6sFfmj".getBytes(); - byte[] address2 = "TNCmcTdyrYKMtmE1KU2itzeCX76jGm5Not".getBytes(); - Mockito.when(si.getAddress()).thenReturn(address); - Mockito.when(si2.getAddress()).thenReturn(address); - Mockito.when(si3.getAddress()).thenReturn(address2); - Credentials aCredential = Credentials.create(si); - Assert.assertFalse(aObject.equals(aCredential)); - Assert.assertFalse(aCredential.equals(aObject)); - Assert.assertFalse(aCredential.equals(null)); - Credentials anotherCredential = Credentials.create(si); - Assert.assertTrue(aCredential.equals(anotherCredential)); - Credentials aCredential2 = Credentials.create(si2); - Assert.assertTrue(aCredential.equals(anotherCredential)); - Credentials aCredential3 = Credentials.create(si3); - Assert.assertFalse(aCredential.equals(aCredential3)); - } -} From 0526128f5235cffa5528ee1b97082e19d9ff544f Mon Sep 17 00:00:00 2001 From: Barbatos Date: Thu, 2 Apr 2026 15:30:05 +0800 Subject: [PATCH 14/41] refactor(crypto): extract keystore library from framework module Move keystore package (Wallet, WalletUtils, Credentials, WalletFile) from framework to crypto module to enable reuse by Toolkit.jar without pulling in the entire framework dependency. - Replace Args.getInstance().isECKeyCryptoEngine() calls with injected boolean ecKey parameter in Wallet.decrypt(), WalletUtils.loadCredentials(), generateNewWalletFile(), generateFullNewWalletFile(), generateLightNewWalletFile() - Update callers (KeystoreFactory, WitnessInitializer) to pass ecKey - Add implementation project(":crypto") to plugins build - Merge two CredentialsTest files (fix keystroe typo dir) into crypto module - Move WalletFileTest to crypto module - CipherException already in common module, no move needed --- .../java/org/tron/keystore/Credentials.java | 0 .../main/java/org/tron/keystore/Wallet.java | 7 +++---- .../java/org/tron/keystore/WalletFile.java | 0 .../java/org/tron/keystore/WalletUtils.java | 20 +++++++++---------- .../org/tron/keystore/CredentialsTest.java | 1 - .../org/tron/keystore/WalletFileTest.java | 0 .../core/config/args/WitnessInitializer.java | 3 ++- .../org/tron/program/KeystoreFactory.java | 8 ++++++-- .../config/args/WitnessInitializerTest.java | 3 ++- .../java/org/tron/program/SupplementTest.java | 4 ++-- plugins/build.gradle | 1 + 11 files changed, 26 insertions(+), 21 deletions(-) rename {framework => crypto}/src/main/java/org/tron/keystore/Credentials.java (100%) rename {framework => crypto}/src/main/java/org/tron/keystore/Wallet.java (98%) rename {framework => crypto}/src/main/java/org/tron/keystore/WalletFile.java (100%) rename {framework => crypto}/src/main/java/org/tron/keystore/WalletUtils.java (96%) rename {framework => crypto}/src/test/java/org/tron/keystore/CredentialsTest.java (99%) rename {framework => crypto}/src/test/java/org/tron/keystore/WalletFileTest.java (100%) diff --git a/framework/src/main/java/org/tron/keystore/Credentials.java b/crypto/src/main/java/org/tron/keystore/Credentials.java similarity index 100% rename from framework/src/main/java/org/tron/keystore/Credentials.java rename to crypto/src/main/java/org/tron/keystore/Credentials.java diff --git a/framework/src/main/java/org/tron/keystore/Wallet.java b/crypto/src/main/java/org/tron/keystore/Wallet.java similarity index 98% rename from framework/src/main/java/org/tron/keystore/Wallet.java rename to crypto/src/main/java/org/tron/keystore/Wallet.java index d38b1c74984..5d3b2e09904 100644 --- a/framework/src/main/java/org/tron/keystore/Wallet.java +++ b/crypto/src/main/java/org/tron/keystore/Wallet.java @@ -23,7 +23,6 @@ import org.tron.common.crypto.SignUtils; import org.tron.common.utils.ByteArray; import org.tron.common.utils.StringUtil; -import org.tron.core.config.args.Args; import org.tron.core.exception.CipherException; /** @@ -168,8 +167,8 @@ private static byte[] generateMac(byte[] derivedKey, byte[] cipherText) { return Hash.sha3(result); } - public static SignInterface decrypt(String password, WalletFile walletFile) - throws CipherException { + public static SignInterface decrypt(String password, WalletFile walletFile, + boolean ecKey) throws CipherException { validate(walletFile); @@ -212,7 +211,7 @@ public static SignInterface decrypt(String password, WalletFile walletFile) byte[] encryptKey = Arrays.copyOfRange(derivedKey, 0, 16); byte[] privateKey = performCipherOperation(Cipher.DECRYPT_MODE, iv, encryptKey, cipherText); - return SignUtils.fromPrivate(privateKey, Args.getInstance().isECKeyCryptoEngine()); + return SignUtils.fromPrivate(privateKey, ecKey); } static void validate(WalletFile walletFile) throws CipherException { diff --git a/framework/src/main/java/org/tron/keystore/WalletFile.java b/crypto/src/main/java/org/tron/keystore/WalletFile.java similarity index 100% rename from framework/src/main/java/org/tron/keystore/WalletFile.java rename to crypto/src/main/java/org/tron/keystore/WalletFile.java diff --git a/framework/src/main/java/org/tron/keystore/WalletUtils.java b/crypto/src/main/java/org/tron/keystore/WalletUtils.java similarity index 96% rename from framework/src/main/java/org/tron/keystore/WalletUtils.java rename to crypto/src/main/java/org/tron/keystore/WalletUtils.java index 8bcc68cbab0..6aa546a4e90 100644 --- a/framework/src/main/java/org/tron/keystore/WalletUtils.java +++ b/crypto/src/main/java/org/tron/keystore/WalletUtils.java @@ -17,7 +17,6 @@ import org.tron.common.crypto.SignInterface; import org.tron.common.crypto.SignUtils; import org.tron.common.utils.Utils; -import org.tron.core.config.args.Args; import org.tron.core.exception.CipherException; /** @@ -32,27 +31,28 @@ public class WalletUtils { objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); } - public static String generateFullNewWalletFile(String password, File destinationDirectory) + public static String generateFullNewWalletFile(String password, File destinationDirectory, + boolean ecKey) throws NoSuchAlgorithmException, NoSuchProviderException, InvalidAlgorithmParameterException, CipherException, IOException { - return generateNewWalletFile(password, destinationDirectory, true); + return generateNewWalletFile(password, destinationDirectory, true, ecKey); } - public static String generateLightNewWalletFile(String password, File destinationDirectory) + public static String generateLightNewWalletFile(String password, File destinationDirectory, + boolean ecKey) throws NoSuchAlgorithmException, NoSuchProviderException, InvalidAlgorithmParameterException, CipherException, IOException { - return generateNewWalletFile(password, destinationDirectory, false); + return generateNewWalletFile(password, destinationDirectory, false, ecKey); } public static String generateNewWalletFile( - String password, File destinationDirectory, boolean useFullScrypt) + String password, File destinationDirectory, boolean useFullScrypt, boolean ecKey) throws CipherException, IOException, InvalidAlgorithmParameterException, NoSuchAlgorithmException, NoSuchProviderException { - SignInterface ecKeyPair = SignUtils.getGeneratedRandomSign(Utils.getRandom(), - Args.getInstance().isECKeyCryptoEngine()); + SignInterface ecKeyPair = SignUtils.getGeneratedRandomSign(Utils.getRandom(), ecKey); return generateWalletFile(password, ecKeyPair, destinationDirectory, useFullScrypt); } @@ -75,10 +75,10 @@ public static String generateWalletFile( return fileName; } - public static Credentials loadCredentials(String password, File source) + public static Credentials loadCredentials(String password, File source, boolean ecKey) throws IOException, CipherException { WalletFile walletFile = objectMapper.readValue(source, WalletFile.class); - return Credentials.create(Wallet.decrypt(password, walletFile)); + return Credentials.create(Wallet.decrypt(password, walletFile, ecKey)); } private static String getWalletFileName(WalletFile walletFile) { diff --git a/framework/src/test/java/org/tron/keystore/CredentialsTest.java b/crypto/src/test/java/org/tron/keystore/CredentialsTest.java similarity index 99% rename from framework/src/test/java/org/tron/keystore/CredentialsTest.java rename to crypto/src/test/java/org/tron/keystore/CredentialsTest.java index 8aabd887bb0..df1b4440e08 100644 --- a/framework/src/test/java/org/tron/keystore/CredentialsTest.java +++ b/crypto/src/test/java/org/tron/keystore/CredentialsTest.java @@ -77,5 +77,4 @@ public void testEqualsWithAddressAndCryptoEngine() { Assert.assertNotEquals(credential, sameAddressDifferentEngineCredential); Assert.assertFalse(credential.equals(differentCredential)); } - } diff --git a/framework/src/test/java/org/tron/keystore/WalletFileTest.java b/crypto/src/test/java/org/tron/keystore/WalletFileTest.java similarity index 100% rename from framework/src/test/java/org/tron/keystore/WalletFileTest.java rename to crypto/src/test/java/org/tron/keystore/WalletFileTest.java diff --git a/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java b/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java index 30711eb6190..0ef242e2e01 100644 --- a/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java +++ b/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java @@ -79,7 +79,8 @@ public static LocalWitnesses initFromKeystore( List privateKeys = new ArrayList<>(); try { - Credentials credentials = WalletUtils.loadCredentials(pwd, new File(fileName)); + Credentials credentials = WalletUtils.loadCredentials(pwd, new File(fileName), + Args.getInstance().isECKeyCryptoEngine()); SignInterface sign = credentials.getSignInterface(); String prikey = ByteArray.toHexString(sign.getPrivateKey()); privateKeys.add(prikey); diff --git a/framework/src/main/java/org/tron/program/KeystoreFactory.java b/framework/src/main/java/org/tron/program/KeystoreFactory.java index 8199d7e9076..10bebea6368 100755 --- a/framework/src/main/java/org/tron/program/KeystoreFactory.java +++ b/framework/src/main/java/org/tron/program/KeystoreFactory.java @@ -63,9 +63,11 @@ private void genKeystore() throws CipherException, IOException { CommonParameter.getInstance().isECKeyCryptoEngine()); File file = new File(FilePath); fileCheck(file); + boolean ecKey = CommonParameter.getInstance().isECKeyCryptoEngine(); String fileName = WalletUtils.generateWalletFile(password, eCkey, file, true); System.out.println("Gen a keystore its name " + fileName); - Credentials credentials = WalletUtils.loadCredentials(password, new File(file, fileName)); + Credentials credentials = WalletUtils.loadCredentials(password, new File(file, fileName), + ecKey); System.out.println("Your address is " + credentials.getAddress()); } @@ -88,9 +90,11 @@ private void importPrivateKey() throws CipherException, IOException { CommonParameter.getInstance().isECKeyCryptoEngine()); File file = new File(FilePath); fileCheck(file); + boolean ecKey = CommonParameter.getInstance().isECKeyCryptoEngine(); String fileName = WalletUtils.generateWalletFile(password, eCkey, file, true); System.out.println("Gen a keystore its name " + fileName); - Credentials credentials = WalletUtils.loadCredentials(password, new File(file, fileName)); + Credentials credentials = WalletUtils.loadCredentials(password, new File(file, fileName), + ecKey); System.out.println("Your address is " + credentials.getAddress()); } diff --git a/framework/src/test/java/org/tron/core/config/args/WitnessInitializerTest.java b/framework/src/test/java/org/tron/core/config/args/WitnessInitializerTest.java index 3ecef5b10c9..e0aa2606473 100644 --- a/framework/src/test/java/org/tron/core/config/args/WitnessInitializerTest.java +++ b/framework/src/test/java/org/tron/core/config/args/WitnessInitializerTest.java @@ -6,6 +6,7 @@ import static org.junit.Assert.assertNull; import static org.junit.Assert.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; @@ -106,7 +107,7 @@ public void testInitFromKeystore() { byte[] keyBytes = Hex.decode(privateKey); when(signInterface.getPrivateKey()).thenReturn(keyBytes); mockedWallet.when(() -> WalletUtils.loadCredentials( - anyString(), any(File.class))).thenReturn(credentials); + anyString(), any(File.class), anyBoolean())).thenReturn(credentials); mockedByteArray.when(() -> ByteArray.toHexString(any())) .thenReturn(privateKey); mockedByteArray.when(() -> ByteArray.fromHexString(anyString())) diff --git a/framework/src/test/java/org/tron/program/SupplementTest.java b/framework/src/test/java/org/tron/program/SupplementTest.java index 38a1b8426dd..d5557614c46 100644 --- a/framework/src/test/java/org/tron/program/SupplementTest.java +++ b/framework/src/test/java/org/tron/program/SupplementTest.java @@ -54,8 +54,8 @@ public void testGet() throws Exception { String p = dbPath + File.separator; dbBackupConfig.initArgs(true, p + "propPath", p + "bak1path/", p + "bak2path/", 1); - WalletUtils.generateFullNewWalletFile("123456", new File(dbPath)); - WalletUtils.generateLightNewWalletFile("123456", new File(dbPath)); + WalletUtils.generateFullNewWalletFile("123456", new File(dbPath), true); + WalletUtils.generateLightNewWalletFile("123456", new File(dbPath), true); WalletUtils.getDefaultKeyDirectory(); WalletUtils.getTestnetKeyDirectory(); WalletUtils.getMainnetKeyDirectory(); diff --git a/plugins/build.gradle b/plugins/build.gradle index 85dcdd2342d..5fdbb0c7309 100644 --- a/plugins/build.gradle +++ b/plugins/build.gradle @@ -34,6 +34,7 @@ dependencies { implementation fileTree(dir: 'libs', include: '*.jar') testImplementation project(":framework") testImplementation project(":framework").sourceSets.test.output + implementation project(":crypto") implementation group: 'info.picocli', name: 'picocli', version: '4.6.3' implementation group: 'com.typesafe', name: 'config', version: '1.3.2' implementation group: 'me.tongfei', name: 'progressbar', version: '0.9.3' From 7f0c0ba42c158f76d128fca97715cf786db0ec12 Mon Sep 17 00:00:00 2001 From: Barbatos Date: Thu, 2 Apr 2026 15:30:38 +0800 Subject: [PATCH 15/41] feat(plugins): add keystore new and import commands to Toolkit Add picocli subcommands for keystore management in Toolkit.jar: - `keystore new`: generate new keypair and encrypt to keystore file - `keystore import`: import existing private key into keystore file Both commands support: - --password-file for non-interactive password input - --keystore-dir for custom output directory - --json for structured output - Console.readPassword() for no-echo interactive input - Clear error when no TTY available (directs to --password-file) Import reads private key from --key-file or interactive prompt, never from command-line arguments (security: avoids ps/history exposure). Also adds roundtrip property tests (100 random encrypt/decrypt cycles) and cross-implementation compatibility tests to crypto module. Note: jqwik was planned for property testing but replaced with plain JUnit loops due to Gradle dependency verification overhead. --- .../java/org/tron/keystore/CrossImplTest.java | 114 +++++++++++++ .../org/tron/keystore/WalletPropertyTest.java | 76 +++++++++ .../common/org/tron/plugins/Keystore.java | 17 ++ .../org/tron/plugins/KeystoreImport.java | 151 ++++++++++++++++++ .../common/org/tron/plugins/KeystoreNew.java | 111 +++++++++++++ .../java/common/org/tron/plugins/Toolkit.java | 2 +- .../org/tron/plugins/KeystoreImportTest.java | 99 ++++++++++++ .../org/tron/plugins/KeystoreNewTest.java | 103 ++++++++++++ 8 files changed, 672 insertions(+), 1 deletion(-) create mode 100644 crypto/src/test/java/org/tron/keystore/CrossImplTest.java create mode 100644 crypto/src/test/java/org/tron/keystore/WalletPropertyTest.java create mode 100644 plugins/src/main/java/common/org/tron/plugins/Keystore.java create mode 100644 plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java create mode 100644 plugins/src/main/java/common/org/tron/plugins/KeystoreNew.java create mode 100644 plugins/src/test/java/org/tron/plugins/KeystoreImportTest.java create mode 100644 plugins/src/test/java/org/tron/plugins/KeystoreNewTest.java diff --git a/crypto/src/test/java/org/tron/keystore/CrossImplTest.java b/crypto/src/test/java/org/tron/keystore/CrossImplTest.java new file mode 100644 index 00000000000..f4a0228602e --- /dev/null +++ b/crypto/src/test/java/org/tron/keystore/CrossImplTest.java @@ -0,0 +1,114 @@ +package org.tron.keystore; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.File; +import org.junit.Test; +import org.tron.common.crypto.SignInterface; +import org.tron.common.crypto.SignUtils; +import org.tron.common.utils.Utils; + +/** + * Cross-implementation compatibility tests. + * Verifies that keystore files can survive a roundtrip through the + * Java implementation (encrypt → serialize → deserialize → decrypt). + * + * Also verifies that keystore files generated by legacy --keystore-factory + * code are compatible with the new library. + */ +public class CrossImplTest { + + private static final ObjectMapper MAPPER = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + @Test + public void testLightKeystoreRoundtrip() throws Exception { + String password = "testpassword123"; + SignInterface keyPair = SignUtils.getGeneratedRandomSign(Utils.getRandom(), true); + byte[] originalKey = keyPair.getPrivateKey(); + + // Create keystore → write to temp file → read back → decrypt + WalletFile walletFile = Wallet.createLight(password, keyPair); + File tempFile = File.createTempFile("keystore-test-", ".json"); + tempFile.deleteOnExit(); + MAPPER.writeValue(tempFile, walletFile); + + WalletFile loaded = MAPPER.readValue(tempFile, WalletFile.class); + SignInterface recovered = Wallet.decrypt(password, loaded, true); + + assertArrayEquals("File roundtrip must preserve private key", + originalKey, recovered.getPrivateKey()); + } + + @Test + public void testStandardKeystoreRoundtrip() throws Exception { + String password = "testpassword456"; + SignInterface keyPair = SignUtils.getGeneratedRandomSign(Utils.getRandom(), true); + byte[] originalKey = keyPair.getPrivateKey(); + + WalletFile walletFile = Wallet.createStandard(password, keyPair); + File tempFile = File.createTempFile("keystore-std-", ".json"); + tempFile.deleteOnExit(); + MAPPER.writeValue(tempFile, walletFile); + + WalletFile loaded = MAPPER.readValue(tempFile, WalletFile.class); + SignInterface recovered = Wallet.decrypt(password, loaded, true); + + assertArrayEquals("Standard scrypt file roundtrip must preserve private key", + originalKey, recovered.getPrivateKey()); + } + + @Test + public void testKeystoreAddressConsistency() throws Exception { + String password = "addresscheck"; + SignInterface keyPair = SignUtils.getGeneratedRandomSign(Utils.getRandom(), true); + Credentials original = Credentials.create(keyPair); + + WalletFile walletFile = Wallet.createLight(password, keyPair); + assertEquals("WalletFile address must match credentials address", + original.getAddress(), walletFile.getAddress()); + + SignInterface recovered = Wallet.decrypt(password, walletFile, true); + Credentials recoveredCreds = Credentials.create(recovered); + assertEquals("Recovered address must match original", + original.getAddress(), recoveredCreds.getAddress()); + } + + @Test + public void testLoadCredentialsIntegration() throws Exception { + String password = "integration789"; + SignInterface keyPair = SignUtils.getGeneratedRandomSign(Utils.getRandom(), true); + byte[] originalKey = keyPair.getPrivateKey(); + String originalAddress = Credentials.create(keyPair).getAddress(); + + // Use WalletUtils full flow + File tempDir = new File(System.getProperty("java.io.tmpdir"), "keystore-test-" + + System.currentTimeMillis()); + tempDir.mkdirs(); + try { + String fileName = WalletUtils.generateWalletFile(password, keyPair, tempDir, false); + assertNotNull(fileName); + + File keystoreFile = new File(tempDir, fileName); + Credentials loaded = WalletUtils.loadCredentials(password, keystoreFile, true); + + assertEquals("Address must survive full WalletUtils roundtrip", + originalAddress, loaded.getAddress()); + assertArrayEquals("Key must survive full WalletUtils roundtrip", + originalKey, loaded.getSignInterface().getPrivateKey()); + } finally { + // Cleanup + File[] files = tempDir.listFiles(); + if (files != null) { + for (File f : files) { + f.delete(); + } + } + tempDir.delete(); + } + } +} diff --git a/crypto/src/test/java/org/tron/keystore/WalletPropertyTest.java b/crypto/src/test/java/org/tron/keystore/WalletPropertyTest.java new file mode 100644 index 00000000000..fafc43eaa3c --- /dev/null +++ b/crypto/src/test/java/org/tron/keystore/WalletPropertyTest.java @@ -0,0 +1,76 @@ +package org.tron.keystore; + +import static org.junit.Assert.assertArrayEquals; + +import java.security.SecureRandom; +import org.junit.Test; +import org.tron.common.crypto.SignInterface; +import org.tron.common.crypto.SignUtils; +import org.tron.common.utils.Utils; +import org.tron.core.exception.CipherException; + +/** + * Property-based roundtrip tests: decrypt(encrypt(privateKey, password)) == privateKey. + * Uses randomized inputs via loop instead of jqwik to avoid dependency verification overhead. + */ +public class WalletPropertyTest { + + private static final SecureRandom RANDOM = new SecureRandom(); + private static final String CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + + @Test + public void encryptDecryptRoundtripLight() throws Exception { + for (int i = 0; i < 100; i++) { + String password = randomPassword(6, 32); + SignInterface keyPair = SignUtils.getGeneratedRandomSign(Utils.getRandom(), true); + byte[] originalKey = keyPair.getPrivateKey(); + + WalletFile walletFile = Wallet.createLight(password, keyPair); + SignInterface recovered = Wallet.decrypt(password, walletFile, true); + + assertArrayEquals("Roundtrip failed at iteration " + i, + originalKey, recovered.getPrivateKey()); + } + } + + @Test + public void encryptDecryptRoundtripStandard() throws Exception { + // Fewer iterations for standard scrypt (slow) + for (int i = 0; i < 5; i++) { + String password = randomPassword(6, 16); + SignInterface keyPair = SignUtils.getGeneratedRandomSign(Utils.getRandom(), true); + byte[] originalKey = keyPair.getPrivateKey(); + + WalletFile walletFile = Wallet.createStandard(password, keyPair); + SignInterface recovered = Wallet.decrypt(password, walletFile, true); + + assertArrayEquals("Standard roundtrip failed at iteration " + i, + originalKey, recovered.getPrivateKey()); + } + } + + @Test + public void wrongPasswordFailsDecrypt() throws Exception { + for (int i = 0; i < 50; i++) { + String password = randomPassword(6, 16); + SignInterface keyPair = SignUtils.getGeneratedRandomSign(Utils.getRandom(), true); + WalletFile walletFile = Wallet.createLight(password, keyPair); + + try { + Wallet.decrypt(password + "X", walletFile, true); + throw new AssertionError("Expected CipherException at iteration " + i); + } catch (CipherException e) { + // Expected + } + } + } + + private String randomPassword(int minLen, int maxLen) { + int len = minLen + RANDOM.nextInt(maxLen - minLen + 1); + StringBuilder sb = new StringBuilder(len); + for (int i = 0; i < len; i++) { + sb.append(CHARS.charAt(RANDOM.nextInt(CHARS.length()))); + } + return sb.toString(); + } +} diff --git a/plugins/src/main/java/common/org/tron/plugins/Keystore.java b/plugins/src/main/java/common/org/tron/plugins/Keystore.java new file mode 100644 index 00000000000..9159fe4b705 --- /dev/null +++ b/plugins/src/main/java/common/org/tron/plugins/Keystore.java @@ -0,0 +1,17 @@ +package org.tron.plugins; + +import picocli.CommandLine; +import picocli.CommandLine.Command; + +@Command(name = "keystore", + mixinStandardHelpOptions = true, + version = "keystore command 1.0", + description = "Manage keystore files for witness account keys.", + subcommands = {CommandLine.HelpCommand.class, + KeystoreNew.class, + KeystoreImport.class + }, + commandListHeading = "%nCommands:%n%nThe most commonly used keystore commands are:%n" +) +public class Keystore { +} diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java new file mode 100644 index 00000000000..f661c37791b --- /dev/null +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java @@ -0,0 +1,151 @@ +package org.tron.plugins; + +import java.io.Console; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.concurrent.Callable; +import org.apache.commons.lang3.StringUtils; +import org.tron.common.crypto.SignInterface; +import org.tron.common.crypto.SignUtils; +import org.tron.common.utils.ByteArray; +import org.tron.core.exception.CipherException; +import org.tron.keystore.Credentials; +import org.tron.keystore.WalletUtils; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +@Command(name = "import", + mixinStandardHelpOptions = true, + description = "Import a private key into a new keystore file.") +public class KeystoreImport implements Callable { + + @Option(names = {"--keystore-dir"}, + description = "Keystore directory (default: ./Wallet)", + defaultValue = "Wallet") + private File keystoreDir; + + @Option(names = {"--json"}, + description = "Output in JSON format") + private boolean json; + + @Option(names = {"--key-file"}, + description = "Read private key from file instead of interactive prompt") + private File keyFile; + + @Option(names = {"--password-file"}, + description = "Read password from file instead of interactive prompt") + private File passwordFile; + + @Override + public Integer call() { + try { + ensureDirectory(keystoreDir); + + String privateKey = readPrivateKey(); + if (privateKey == null) { + return 1; + } + + if (!isValidPrivateKey(privateKey)) { + System.err.println("Invalid private key: must be 64 hex characters."); + return 1; + } + + String password = readPassword(); + if (password == null) { + return 1; + } + + boolean ecKey = true; + SignInterface keyPair = SignUtils.fromPrivate( + ByteArray.fromHexString(privateKey), ecKey); + String fileName = WalletUtils.generateWalletFile(password, keyPair, keystoreDir, true); + Credentials credentials = WalletUtils.loadCredentials(password, + new File(keystoreDir, fileName), ecKey); + + if (json) { + System.out.printf("{\"address\":\"%s\",\"file\":\"%s\"}%n", + credentials.getAddress(), fileName); + } else { + System.out.println("Imported keystore: " + fileName); + System.out.println("Address: " + credentials.getAddress()); + } + return 0; + } catch (CipherException e) { + System.err.println("Encryption error: " + e.getMessage()); + return 1; + } catch (Exception e) { + System.err.println("Error: " + e.getMessage()); + return 1; + } + } + + private String readPrivateKey() throws IOException { + if (keyFile != null) { + return new String(Files.readAllBytes(keyFile.toPath()), + StandardCharsets.UTF_8).trim(); + } + + Console console = System.console(); + if (console == null) { + System.err.println("No interactive terminal available. " + + "Use --key-file to provide private key."); + return null; + } + + char[] key = console.readPassword("Enter private key (hex): "); + return new String(key); + } + + private String readPassword() throws IOException { + if (passwordFile != null) { + String password = new String(Files.readAllBytes(passwordFile.toPath()), + StandardCharsets.UTF_8).trim(); + if (!WalletUtils.passwordValid(password)) { + System.err.println("Invalid password: must be at least 6 characters."); + return null; + } + return password; + } + + Console console = System.console(); + if (console == null) { + System.err.println("No interactive terminal available. " + + "Use --password-file to provide password."); + return null; + } + + char[] pwd1 = console.readPassword("Enter password: "); + char[] pwd2 = console.readPassword("Confirm password: "); + String password1 = new String(pwd1); + String password2 = new String(pwd2); + + if (!password1.equals(password2)) { + System.err.println("Passwords do not match."); + return null; + } + if (!WalletUtils.passwordValid(password1)) { + System.err.println("Invalid password: must be at least 6 characters."); + return null; + } + return password1; + } + + private boolean isValidPrivateKey(String key) { + if (StringUtils.isEmpty(key) || key.length() != 64) { + return false; + } + return key.matches("[0-9a-fA-F]+"); + } + + private void ensureDirectory(File dir) throws IOException { + if (!dir.exists() && !dir.mkdirs()) { + throw new IOException("Cannot create directory: " + dir.getAbsolutePath()); + } + if (dir.exists() && !dir.isDirectory()) { + throw new IOException("Path exists but is not a directory: " + dir.getAbsolutePath()); + } + } +} diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreNew.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreNew.java new file mode 100644 index 00000000000..5e60ae050fd --- /dev/null +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreNew.java @@ -0,0 +1,111 @@ +package org.tron.plugins; + +import java.io.Console; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.concurrent.Callable; +import org.tron.common.crypto.SignInterface; +import org.tron.common.crypto.SignUtils; +import org.tron.common.utils.Utils; +import org.tron.core.exception.CipherException; +import org.tron.keystore.Credentials; +import org.tron.keystore.WalletUtils; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +@Command(name = "new", + mixinStandardHelpOptions = true, + description = "Generate a new keystore file with a random keypair.") +public class KeystoreNew implements Callable { + + @Option(names = {"--keystore-dir"}, + description = "Keystore directory (default: ./Wallet)", + defaultValue = "Wallet") + private File keystoreDir; + + @Option(names = {"--json"}, + description = "Output in JSON format") + private boolean json; + + @Option(names = {"--password-file"}, + description = "Read password from file instead of interactive prompt") + private File passwordFile; + + @Override + public Integer call() { + try { + ensureDirectory(keystoreDir); + + String password = readPassword(); + if (password == null) { + return 1; + } + + boolean ecKey = true; + SignInterface keyPair = SignUtils.getGeneratedRandomSign(Utils.getRandom(), ecKey); + String fileName = WalletUtils.generateWalletFile(password, keyPair, keystoreDir, true); + Credentials credentials = WalletUtils.loadCredentials(password, + new File(keystoreDir, fileName), ecKey); + + if (json) { + System.out.printf("{\"address\":\"%s\",\"file\":\"%s\"}%n", + credentials.getAddress(), fileName); + } else { + System.out.println("Generated keystore: " + fileName); + System.out.println("Address: " + credentials.getAddress()); + } + return 0; + } catch (CipherException e) { + System.err.println("Encryption error: " + e.getMessage()); + return 1; + } catch (Exception e) { + System.err.println("Error: " + e.getMessage()); + return 1; + } + } + + private String readPassword() throws IOException { + if (passwordFile != null) { + String password = new String(Files.readAllBytes(passwordFile.toPath()), + StandardCharsets.UTF_8).trim(); + if (!WalletUtils.passwordValid(password)) { + System.err.println("Invalid password: must be at least 6 characters."); + return null; + } + return password; + } + + Console console = System.console(); + if (console == null) { + System.err.println("No interactive terminal available. " + + "Use --password-file to provide password."); + return null; + } + + char[] pwd1 = console.readPassword("Enter password: "); + char[] pwd2 = console.readPassword("Confirm password: "); + String password1 = new String(pwd1); + String password2 = new String(pwd2); + + if (!password1.equals(password2)) { + System.err.println("Passwords do not match."); + return null; + } + if (!WalletUtils.passwordValid(password1)) { + System.err.println("Invalid password: must be at least 6 characters."); + return null; + } + return password1; + } + + private void ensureDirectory(File dir) throws IOException { + if (!dir.exists() && !dir.mkdirs()) { + throw new IOException("Cannot create directory: " + dir.getAbsolutePath()); + } + if (dir.exists() && !dir.isDirectory()) { + throw new IOException("Path exists but is not a directory: " + dir.getAbsolutePath()); + } + } +} diff --git a/plugins/src/main/java/common/org/tron/plugins/Toolkit.java b/plugins/src/main/java/common/org/tron/plugins/Toolkit.java index 3b9972de1c5..7a979fe256c 100644 --- a/plugins/src/main/java/common/org/tron/plugins/Toolkit.java +++ b/plugins/src/main/java/common/org/tron/plugins/Toolkit.java @@ -3,7 +3,7 @@ import java.util.concurrent.Callable; import picocli.CommandLine; -@CommandLine.Command(subcommands = { CommandLine.HelpCommand.class, Db.class}) +@CommandLine.Command(subcommands = { CommandLine.HelpCommand.class, Db.class, Keystore.class}) public class Toolkit implements Callable { diff --git a/plugins/src/test/java/org/tron/plugins/KeystoreImportTest.java b/plugins/src/test/java/org/tron/plugins/KeystoreImportTest.java new file mode 100644 index 00000000000..190a8013524 --- /dev/null +++ b/plugins/src/test/java/org/tron/plugins/KeystoreImportTest.java @@ -0,0 +1,99 @@ +package org.tron.plugins; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.security.SecureRandom; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.common.crypto.SignInterface; +import org.tron.common.crypto.SignUtils; +import org.tron.common.utils.ByteArray; +import org.tron.keystore.Credentials; +import org.tron.keystore.WalletUtils; +import picocli.CommandLine; + +public class KeystoreImportTest { + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + @Test + public void testImportWithKeyFileAndPasswordFile() throws Exception { + File dir = tempFolder.newFolder("keystore"); + + // Generate a known private key + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + String privateKeyHex = ByteArray.toHexString(keyPair.getPrivateKey()); + String expectedAddress = Credentials.create(keyPair).getAddress(); + + File keyFile = tempFolder.newFile("private.key"); + Files.write(keyFile.toPath(), privateKeyHex.getBytes(StandardCharsets.UTF_8)); + + File pwFile = tempFolder.newFile("password.txt"); + Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + + CommandLine cmd = new CommandLine(new Toolkit()); + int exitCode = cmd.execute("keystore", "import", + "--keystore-dir", dir.getAbsolutePath(), + "--key-file", keyFile.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals("Exit code should be 0", 0, exitCode); + + File[] files = dir.listFiles((d, name) -> name.endsWith(".json")); + assertNotNull(files); + assertEquals(1, files.length); + + // Verify roundtrip: decrypt should recover the same private key + Credentials creds = WalletUtils.loadCredentials("test123456", files[0], true); + assertEquals("Address must match", expectedAddress, creds.getAddress()); + assertArrayEquals("Private key must survive import roundtrip", + keyPair.getPrivateKey(), creds.getSignInterface().getPrivateKey()); + } + + @Test + public void testImportInvalidKeyTooShort() throws Exception { + File dir = tempFolder.newFolder("keystore-bad"); + File keyFile = tempFolder.newFile("bad.key"); + Files.write(keyFile.toPath(), "abcdef1234".getBytes(StandardCharsets.UTF_8)); + + File pwFile = tempFolder.newFile("pw.txt"); + Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + + CommandLine cmd = new CommandLine(new Toolkit()); + int exitCode = cmd.execute("keystore", "import", + "--keystore-dir", dir.getAbsolutePath(), + "--key-file", keyFile.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals("Should fail with invalid key", 1, exitCode); + } + + @Test + public void testImportInvalidKeyNonHex() throws Exception { + File dir = tempFolder.newFolder("keystore-hex"); + File keyFile = tempFolder.newFile("nonhex.key"); + Files.write(keyFile.toPath(), + "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" + .getBytes(StandardCharsets.UTF_8)); + + File pwFile = tempFolder.newFile("pw.txt"); + Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + + CommandLine cmd = new CommandLine(new Toolkit()); + int exitCode = cmd.execute("keystore", "import", + "--keystore-dir", dir.getAbsolutePath(), + "--key-file", keyFile.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals("Should fail with non-hex key", 1, exitCode); + } +} diff --git a/plugins/src/test/java/org/tron/plugins/KeystoreNewTest.java b/plugins/src/test/java/org/tron/plugins/KeystoreNewTest.java new file mode 100644 index 00000000000..760ae279167 --- /dev/null +++ b/plugins/src/test/java/org/tron/plugins/KeystoreNewTest.java @@ -0,0 +1,103 @@ +package org.tron.plugins; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.keystore.Credentials; +import org.tron.keystore.WalletUtils; +import picocli.CommandLine; + +public class KeystoreNewTest { + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + @Test + public void testNewKeystoreWithPasswordFile() throws Exception { + File dir = tempFolder.newFolder("keystore"); + File pwFile = tempFolder.newFile("password.txt"); + Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + + StringWriter out = new StringWriter(); + StringWriter err = new StringWriter(); + CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setOut(new PrintWriter(out)); + cmd.setErr(new PrintWriter(err)); + + int exitCode = cmd.execute("keystore", "new", + "--keystore-dir", dir.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals("Exit code should be 0", 0, exitCode); + + File[] files = dir.listFiles((d, name) -> name.endsWith(".json")); + assertNotNull(files); + assertEquals("Should create exactly one keystore file", 1, files.length); + + // Verify the file is a valid keystore + Credentials creds = WalletUtils.loadCredentials("test123456", files[0], true); + assertNotNull(creds.getAddress()); + assertTrue(creds.getAddress().startsWith("T")); + } + + @Test + public void testNewKeystoreJsonOutput() throws Exception { + File dir = tempFolder.newFolder("keystore-json"); + File pwFile = tempFolder.newFile("password-json.txt"); + Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + + StringWriter out = new StringWriter(); + CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setOut(new PrintWriter(out)); + + int exitCode = cmd.execute("keystore", "new", + "--keystore-dir", dir.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath(), + "--json"); + + assertEquals(0, exitCode); + // stdout is captured by picocli's setOut but System.out goes to console + // The JSON output goes through System.out directly + } + + @Test + public void testNewKeystoreInvalidPassword() throws Exception { + File dir = tempFolder.newFolder("keystore-bad"); + File pwFile = tempFolder.newFile("short.txt"); + Files.write(pwFile.toPath(), "abc".getBytes(StandardCharsets.UTF_8)); + + CommandLine cmd = new CommandLine(new Toolkit()); + int exitCode = cmd.execute("keystore", "new", + "--keystore-dir", dir.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals("Should fail with short password", 1, exitCode); + } + + @Test + public void testNewKeystoreCustomDir() throws Exception { + File dir = new File(tempFolder.getRoot(), "custom/nested/dir"); + File pwFile = tempFolder.newFile("pw.txt"); + Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + + CommandLine cmd = new CommandLine(new Toolkit()); + int exitCode = cmd.execute("keystore", "new", + "--keystore-dir", dir.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals(0, exitCode); + assertTrue("Custom dir should be created", dir.exists()); + File[] files = dir.listFiles((d, name) -> name.endsWith(".json")); + assertNotNull(files); + assertEquals(1, files.length); + } +} From 2bced5f1c7af13dcb28ce978b1ae6149d2996b23 Mon Sep 17 00:00:00 2001 From: Barbatos Date: Thu, 2 Apr 2026 15:51:58 +0800 Subject: [PATCH 16/41] feat(plugins): add keystore list and update commands, deprecate --keystore-factory Add remaining keystore subcommands to Toolkit.jar: - `keystore list`: display all keystore files and addresses in a directory - `keystore update

`: re-encrypt a keystore with a new password Both support --keystore-dir, --json, and --password-file options. Add deprecation warning to --keystore-factory in FullNode.jar, directing users to the new Toolkit.jar keystore commands. The old REPL continues to function normally during the transition period. --- .../main/java/org/tron/keystore/Wallet.java | 2 +- .../java/org/tron/keystore/CrossImplTest.java | 145 ++++++++----- .../org/tron/keystore/WalletPropertyTest.java | 9 +- .../org/tron/program/KeystoreFactory.java | 31 ++- .../args/WitnessInitializerKeystoreTest.java | 86 ++++++++ .../KeystoreFactoryDeprecationTest.java | 35 ++++ plugins/build.gradle | 7 +- .../common/org/tron/plugins/Keystore.java | 4 +- .../org/tron/plugins/KeystoreCliUtils.java | 145 +++++++++++++ .../org/tron/plugins/KeystoreImport.java | 102 ++++----- .../common/org/tron/plugins/KeystoreList.java | 99 +++++++++ .../common/org/tron/plugins/KeystoreNew.java | 67 ++---- .../org/tron/plugins/KeystoreUpdate.java | 198 ++++++++++++++++++ .../org/tron/plugins/KeystoreImportTest.java | 107 ++++++++++ .../org/tron/plugins/KeystoreListTest.java | 139 ++++++++++++ .../org/tron/plugins/KeystoreNewTest.java | 117 +++++++++-- .../org/tron/plugins/KeystoreUpdateTest.java | 165 +++++++++++++++ 17 files changed, 1269 insertions(+), 189 deletions(-) create mode 100644 framework/src/test/java/org/tron/core/config/args/WitnessInitializerKeystoreTest.java create mode 100644 framework/src/test/java/org/tron/program/KeystoreFactoryDeprecationTest.java create mode 100644 plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java create mode 100644 plugins/src/main/java/common/org/tron/plugins/KeystoreList.java create mode 100644 plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java create mode 100644 plugins/src/test/java/org/tron/plugins/KeystoreListTest.java create mode 100644 plugins/src/test/java/org/tron/plugins/KeystoreUpdateTest.java diff --git a/crypto/src/main/java/org/tron/keystore/Wallet.java b/crypto/src/main/java/org/tron/keystore/Wallet.java index 5d3b2e09904..b5cb37a23ab 100644 --- a/crypto/src/main/java/org/tron/keystore/Wallet.java +++ b/crypto/src/main/java/org/tron/keystore/Wallet.java @@ -204,7 +204,7 @@ public static SignInterface decrypt(String password, WalletFile walletFile, byte[] derivedMac = generateMac(derivedKey, cipherText); - if (!Arrays.equals(derivedMac, mac)) { + if (!java.security.MessageDigest.isEqual(derivedMac, mac)) { throw new CipherException("Invalid password provided"); } diff --git a/crypto/src/test/java/org/tron/keystore/CrossImplTest.java b/crypto/src/test/java/org/tron/keystore/CrossImplTest.java index f4a0228602e..6b00c57c1f9 100644 --- a/crypto/src/test/java/org/tron/keystore/CrossImplTest.java +++ b/crypto/src/test/java/org/tron/keystore/CrossImplTest.java @@ -3,62 +3,127 @@ import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.File; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; import org.tron.common.crypto.SignInterface; import org.tron.common.crypto.SignUtils; import org.tron.common.utils.Utils; /** - * Cross-implementation compatibility tests. - * Verifies that keystore files can survive a roundtrip through the - * Java implementation (encrypt → serialize → deserialize → decrypt). + * Format compatibility tests. * - * Also verifies that keystore files generated by legacy --keystore-factory - * code are compatible with the new library. + *

All tests generate keystores dynamically at test time — no static + * fixtures or secrets stored in the repository. Verifies that keystore + * files can survive a full roundtrip: generate keypair, encrypt, serialize + * to JSON file, deserialize, decrypt, compare private key and address. */ public class CrossImplTest { private static final ObjectMapper MAPPER = new ObjectMapper() .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + // --- Ethereum standard test vectors (from Web3 Secret Storage spec, inline) --- + // Source: web3j WalletTest.java — password and private key are public test data. + + private static final String ETH_PASSWORD = "Insecure Pa55w0rd"; + private static final String ETH_PRIVATE_KEY = + "a392604efc2fad9c0b3da43b5f698a2e3f270f170d859912be0d54742275c5f6"; + + private static final String ETH_PBKDF2_KEYSTORE = "{" + + "\"crypto\":{\"cipher\":\"aes-128-ctr\"," + + "\"cipherparams\":{\"iv\":\"02ebc768684e5576900376114625ee6f\"}," + + "\"ciphertext\":\"7ad5c9dd2c95f34a92ebb86740b92103a5d1cc4c2eabf3b9a59e1f83f3181216\"," + + "\"kdf\":\"pbkdf2\"," + + "\"kdfparams\":{\"c\":262144,\"dklen\":32,\"prf\":\"hmac-sha256\"," + + "\"salt\":\"0e4cf3893b25bb81efaae565728b5b7cde6a84e224cbf9aed3d69a31c981b702\"}," + + "\"mac\":\"2b29e4641ec17f4dc8b86fc8592090b50109b372529c30b001d4d96249edaf62\"}," + + "\"id\":\"af0451b4-6020-4ef0-91ec-794a5a965b01\",\"version\":3}"; + + private static final String ETH_SCRYPT_KEYSTORE = "{" + + "\"crypto\":{\"cipher\":\"aes-128-ctr\"," + + "\"cipherparams\":{\"iv\":\"3021e1ef4774dfc5b08307f3a4c8df00\"}," + + "\"ciphertext\":\"4dd29ba18478b98cf07a8a44167acdf7e04de59777c4b9c139e3d3fa5cb0b931\"," + + "\"kdf\":\"scrypt\"," + + "\"kdfparams\":{\"dklen\":32,\"n\":262144,\"r\":8,\"p\":1," + + "\"salt\":\"4f9f68c71989eb3887cd947c80b9555fce528f210199d35c35279beb8c2da5ca\"}," + + "\"mac\":\"7e8f2192767af9be18e7a373c1986d9190fcaa43ad689bbb01a62dbde159338d\"}," + + "\"id\":\"7654525c-17e0-4df5-94b5-c7fde752c9d2\",\"version\":3}"; + + @Test + public void testDecryptEthPbkdf2Keystore() throws Exception { + WalletFile walletFile = MAPPER.readValue(ETH_PBKDF2_KEYSTORE, WalletFile.class); + SignInterface recovered = Wallet.decrypt(ETH_PASSWORD, walletFile, true); + assertEquals("Private key must match Ethereum test vector", + ETH_PRIVATE_KEY, + org.tron.common.utils.ByteArray.toHexString(recovered.getPrivateKey())); + } + @Test - public void testLightKeystoreRoundtrip() throws Exception { - String password = "testpassword123"; + public void testDecryptEthScryptKeystore() throws Exception { + WalletFile walletFile = MAPPER.readValue(ETH_SCRYPT_KEYSTORE, WalletFile.class); + SignInterface recovered = Wallet.decrypt(ETH_PASSWORD, walletFile, true); + assertEquals("Private key must match Ethereum test vector", + ETH_PRIVATE_KEY, + org.tron.common.utils.ByteArray.toHexString(recovered.getPrivateKey())); + } + + // --- Dynamic format compatibility (no static secrets) --- + + @Test + public void testKeystoreFormatCompatibility() throws Exception { SignInterface keyPair = SignUtils.getGeneratedRandomSign(Utils.getRandom(), true); byte[] originalKey = keyPair.getPrivateKey(); + String password = "dynamicTest123"; - // Create keystore → write to temp file → read back → decrypt - WalletFile walletFile = Wallet.createLight(password, keyPair); - File tempFile = File.createTempFile("keystore-test-", ".json"); - tempFile.deleteOnExit(); - MAPPER.writeValue(tempFile, walletFile); + WalletFile walletFile = Wallet.createStandard(password, keyPair); + // Verify Web3 Secret Storage structure + assertEquals("version must be 3", 3, walletFile.getVersion()); + assertNotNull("must have address", walletFile.getAddress()); + assertNotNull("must have crypto", walletFile.getCrypto()); + assertEquals("cipher must be aes-128-ctr", + "aes-128-ctr", walletFile.getCrypto().getCipher()); + assertTrue("kdf must be scrypt or pbkdf2", + "scrypt".equals(walletFile.getCrypto().getKdf()) + || "pbkdf2".equals(walletFile.getCrypto().getKdf())); + + // Write to file, read back — simulates cross-process interop + File tempFile = new File(tempFolder.getRoot(), "compat-test.json"); + MAPPER.writeValue(tempFile, walletFile); WalletFile loaded = MAPPER.readValue(tempFile, WalletFile.class); - SignInterface recovered = Wallet.decrypt(password, loaded, true); - assertArrayEquals("File roundtrip must preserve private key", + SignInterface recovered = Wallet.decrypt(password, loaded, true); + assertArrayEquals("Key must survive file roundtrip", originalKey, recovered.getPrivateKey()); + + // Verify TRON address format + byte[] tronAddr = recovered.getAddress(); + assertEquals("TRON address must be 21 bytes", 21, tronAddr.length); + assertEquals("First byte must be TRON prefix", 0x41, tronAddr[0] & 0xFF); } @Test - public void testStandardKeystoreRoundtrip() throws Exception { - String password = "testpassword456"; + public void testLightScryptFormatCompatibility() throws Exception { SignInterface keyPair = SignUtils.getGeneratedRandomSign(Utils.getRandom(), true); byte[] originalKey = keyPair.getPrivateKey(); + String password = "lightCompat456"; - WalletFile walletFile = Wallet.createStandard(password, keyPair); - File tempFile = File.createTempFile("keystore-std-", ".json"); - tempFile.deleteOnExit(); + WalletFile walletFile = Wallet.createLight(password, keyPair); + File tempFile = new File(tempFolder.getRoot(), "light-compat.json"); MAPPER.writeValue(tempFile, walletFile); - WalletFile loaded = MAPPER.readValue(tempFile, WalletFile.class); - SignInterface recovered = Wallet.decrypt(password, loaded, true); - assertArrayEquals("Standard scrypt file roundtrip must preserve private key", + SignInterface recovered = Wallet.decrypt(password, loaded, true); + assertArrayEquals("Key must survive light scrypt file roundtrip", originalKey, recovered.getPrivateKey()); } @@ -85,30 +150,16 @@ public void testLoadCredentialsIntegration() throws Exception { byte[] originalKey = keyPair.getPrivateKey(); String originalAddress = Credentials.create(keyPair).getAddress(); - // Use WalletUtils full flow - File tempDir = new File(System.getProperty("java.io.tmpdir"), "keystore-test-" + - System.currentTimeMillis()); - tempDir.mkdirs(); - try { - String fileName = WalletUtils.generateWalletFile(password, keyPair, tempDir, false); - assertNotNull(fileName); - - File keystoreFile = new File(tempDir, fileName); - Credentials loaded = WalletUtils.loadCredentials(password, keystoreFile, true); - - assertEquals("Address must survive full WalletUtils roundtrip", - originalAddress, loaded.getAddress()); - assertArrayEquals("Key must survive full WalletUtils roundtrip", - originalKey, loaded.getSignInterface().getPrivateKey()); - } finally { - // Cleanup - File[] files = tempDir.listFiles(); - if (files != null) { - for (File f : files) { - f.delete(); - } - } - tempDir.delete(); - } + File tempDir = tempFolder.newFolder("wallet-integration"); + String fileName = WalletUtils.generateWalletFile(password, keyPair, tempDir, false); + assertNotNull(fileName); + + File keystoreFile = new File(tempDir, fileName); + Credentials loaded = WalletUtils.loadCredentials(password, keystoreFile, true); + + assertEquals("Address must survive full WalletUtils roundtrip", + originalAddress, loaded.getAddress()); + assertArrayEquals("Key must survive full WalletUtils roundtrip", + originalKey, loaded.getSignInterface().getPrivateKey()); } } diff --git a/crypto/src/test/java/org/tron/keystore/WalletPropertyTest.java b/crypto/src/test/java/org/tron/keystore/WalletPropertyTest.java index fafc43eaa3c..3028d2a7799 100644 --- a/crypto/src/test/java/org/tron/keystore/WalletPropertyTest.java +++ b/crypto/src/test/java/org/tron/keystore/WalletPropertyTest.java @@ -16,7 +16,8 @@ public class WalletPropertyTest { private static final SecureRandom RANDOM = new SecureRandom(); - private static final String CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + private static final String CHARS = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; @Test public void encryptDecryptRoundtripLight() throws Exception { @@ -33,10 +34,10 @@ public void encryptDecryptRoundtripLight() throws Exception { } } - @Test + @Test(timeout = 120000) public void encryptDecryptRoundtripStandard() throws Exception { - // Fewer iterations for standard scrypt (slow) - for (int i = 0; i < 5; i++) { + // Fewer iterations for standard scrypt (slow, ~10s each) + for (int i = 0; i < 2; i++) { String password = randomPassword(6, 16); SignInterface keyPair = SignUtils.getGeneratedRandomSign(Utils.getRandom(), true); byte[] originalKey = keyPair.getPrivateKey(); diff --git a/framework/src/main/java/org/tron/program/KeystoreFactory.java b/framework/src/main/java/org/tron/program/KeystoreFactory.java index 10bebea6368..a88cdca904a 100755 --- a/framework/src/main/java/org/tron/program/KeystoreFactory.java +++ b/framework/src/main/java/org/tron/program/KeystoreFactory.java @@ -15,11 +15,20 @@ import org.tron.keystore.WalletUtils; @Slf4j(topic = "app") +@Deprecated public class KeystoreFactory { private static final String FilePath = "Wallet"; public static void start() { + System.err.println("WARNING: --keystore-factory is deprecated and will be removed " + + "in a future release."); + System.err.println("Please use: java -jar Toolkit.jar keystore "); + System.err.println(" keystore new - Generate a new keystore"); + System.err.println(" keystore import - Import a private key"); + System.err.println(" keystore list - List keystores"); + System.err.println(" keystore update - Change password"); + System.err.println(); KeystoreFactory cli = new KeystoreFactory(); cli.run(); } @@ -57,13 +66,12 @@ private void fileCheck(File file) throws IOException { private void genKeystore() throws CipherException, IOException { + boolean ecKey = CommonParameter.getInstance().isECKeyCryptoEngine(); String password = WalletUtils.inputPassword2Twice(); - SignInterface eCkey = SignUtils.getGeneratedRandomSign(Utils.random, - CommonParameter.getInstance().isECKeyCryptoEngine()); + SignInterface eCkey = SignUtils.getGeneratedRandomSign(Utils.random, ecKey); File file = new File(FilePath); fileCheck(file); - boolean ecKey = CommonParameter.getInstance().isECKeyCryptoEngine(); String fileName = WalletUtils.generateWalletFile(password, eCkey, file, true); System.out.println("Gen a keystore its name " + fileName); Credentials credentials = WalletUtils.loadCredentials(password, new File(file, fileName), @@ -86,11 +94,10 @@ private void importPrivateKey() throws CipherException, IOException { String password = WalletUtils.inputPassword2Twice(); - SignInterface eCkey = SignUtils.fromPrivate(ByteArray.fromHexString(privateKey), - CommonParameter.getInstance().isECKeyCryptoEngine()); + boolean ecKey = CommonParameter.getInstance().isECKeyCryptoEngine(); + SignInterface eCkey = SignUtils.fromPrivate(ByteArray.fromHexString(privateKey), ecKey); File file = new File(FilePath); fileCheck(file); - boolean ecKey = CommonParameter.getInstance().isECKeyCryptoEngine(); String fileName = WalletUtils.generateWalletFile(password, eCkey, file, true); System.out.println("Gen a keystore its name " + fileName); Credentials credentials = WalletUtils.loadCredentials(password, new File(file, fileName), @@ -99,11 +106,13 @@ private void importPrivateKey() throws CipherException, IOException { } private void help() { - System.out.println("You can enter the following command: "); - System.out.println("GenKeystore"); - System.out.println("ImportPrivateKey"); - System.out.println("Exit or Quit"); - System.out.println("Input any one of them, you will get more tips."); + System.out.println("NOTE: --keystore-factory is deprecated. Use Toolkit.jar instead:"); + System.out.println(" java -jar Toolkit.jar keystore new|import|list|update"); + System.out.println(); + System.out.println("Legacy commands (will be removed):"); + System.out.println(" GenKeystore"); + System.out.println(" ImportPrivateKey"); + System.out.println(" Exit or Quit"); } private void run() { diff --git a/framework/src/test/java/org/tron/core/config/args/WitnessInitializerKeystoreTest.java b/framework/src/test/java/org/tron/core/config/args/WitnessInitializerKeystoreTest.java new file mode 100644 index 00000000000..0a7717cb1a0 --- /dev/null +++ b/framework/src/test/java/org/tron/core/config/args/WitnessInitializerKeystoreTest.java @@ -0,0 +1,86 @@ +package org.tron.core.config.args; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; + +import java.io.File; +import java.security.SecureRandom; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.common.crypto.SignInterface; +import org.tron.common.crypto.SignUtils; +import org.tron.common.utils.ByteArray; +import org.tron.common.utils.LocalWitnesses; +import org.tron.keystore.Credentials; +import org.tron.keystore.WalletUtils; + +/** + * Backward compatibility: verifies that keystore files generated by + * the new Toolkit code path can be loaded by WitnessInitializer + * (used by FullNode at startup via localwitnesskeystore config). + */ +public class WitnessInitializerKeystoreTest { + + @ClassRule + public static final TemporaryFolder tempFolder = new TemporaryFolder(); + + // WitnessInitializer prepends user.dir to the filename, so we must + // create the keystore dir relative to user.dir. Use unique name to + // avoid collisions with parallel test runs. + private static final String DIR_NAME = + ".test-keystore-" + System.currentTimeMillis(); + + private static String keystoreFileName; + private static String expectedPrivateKey; + private static final String PASSWORD = "backcompat123"; + + @BeforeClass + public static void setUp() throws Exception { + Args.setParam(new String[]{"-d", tempFolder.newFolder().toString()}, + "config-test.conf"); + + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + expectedPrivateKey = ByteArray.toHexString(keyPair.getPrivateKey()); + + File dir = new File(System.getProperty("user.dir"), DIR_NAME); + dir.mkdirs(); + String generatedName = + WalletUtils.generateWalletFile(PASSWORD, keyPair, dir, true); + keystoreFileName = DIR_NAME + "/" + generatedName; + } + + @AfterClass + public static void tearDown() { + Args.clearParam(); + File dir = new File(System.getProperty("user.dir"), DIR_NAME); + if (dir.exists()) { + File[] files = dir.listFiles(); + if (files != null) { + for (File f : files) { + f.delete(); + } + } + dir.delete(); + } + } + + @Test + public void testNewKeystoreLoadableByWitnessInitializer() { + java.util.List keystores = + java.util.Collections.singletonList(keystoreFileName); + + LocalWitnesses result = WitnessInitializer.initFromKeystore( + keystores, PASSWORD, null); + + assertNotNull("WitnessInitializer should load new keystore", result); + assertFalse("Should have at least one private key", + result.getPrivateKeys().isEmpty()); + assertEquals("Private key must match original", + expectedPrivateKey, result.getPrivateKeys().get(0)); + } +} diff --git a/framework/src/test/java/org/tron/program/KeystoreFactoryDeprecationTest.java b/framework/src/test/java/org/tron/program/KeystoreFactoryDeprecationTest.java new file mode 100644 index 00000000000..bf13117f6c0 --- /dev/null +++ b/framework/src/test/java/org/tron/program/KeystoreFactoryDeprecationTest.java @@ -0,0 +1,35 @@ +package org.tron.program; + +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.PrintStream; +import org.junit.Test; + +/** + * Verifies that --keystore-factory prints deprecation warning to stderr. + */ +public class KeystoreFactoryDeprecationTest { + + @Test(timeout = 10000) + public void testDeprecationWarningPrinted() throws Exception { + PrintStream originalErr = System.err; + InputStream originalIn = System.in; + ByteArrayOutputStream errContent = new ByteArrayOutputStream(); + System.setErr(new PrintStream(errContent)); + System.setIn(new java.io.ByteArrayInputStream("exit\n".getBytes())); + try { + KeystoreFactory.start(); + } finally { + System.setErr(originalErr); + System.setIn(originalIn); + } + + String errOutput = errContent.toString("UTF-8"); + assertTrue("Should contain deprecation warning", + errOutput.contains("--keystore-factory is deprecated")); + assertTrue("Should point to Toolkit.jar", + errOutput.contains("Toolkit.jar keystore")); + } +} diff --git a/plugins/build.gradle b/plugins/build.gradle index 5fdbb0c7309..2e358a884a3 100644 --- a/plugins/build.gradle +++ b/plugins/build.gradle @@ -34,7 +34,12 @@ dependencies { implementation fileTree(dir: 'libs', include: '*.jar') testImplementation project(":framework") testImplementation project(":framework").sourceSets.test.output - implementation project(":crypto") + implementation(project(":crypto")) { + exclude group: 'io.github.tronprotocol', module: 'libp2p' + exclude group: 'io.prometheus' + exclude group: 'org.aspectj' + exclude group: 'org.apache.httpcomponents' + } implementation group: 'info.picocli', name: 'picocli', version: '4.6.3' implementation group: 'com.typesafe', name: 'config', version: '1.3.2' implementation group: 'me.tongfei', name: 'progressbar', version: '0.9.3' diff --git a/plugins/src/main/java/common/org/tron/plugins/Keystore.java b/plugins/src/main/java/common/org/tron/plugins/Keystore.java index 9159fe4b705..954fa2d346a 100644 --- a/plugins/src/main/java/common/org/tron/plugins/Keystore.java +++ b/plugins/src/main/java/common/org/tron/plugins/Keystore.java @@ -9,7 +9,9 @@ description = "Manage keystore files for witness account keys.", subcommands = {CommandLine.HelpCommand.class, KeystoreNew.class, - KeystoreImport.class + KeystoreImport.class, + KeystoreList.class, + KeystoreUpdate.class }, commandListHeading = "%nCommands:%n%nThe most commonly used keystore commands are:%n" ) diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java new file mode 100644 index 00000000000..65e595e5ed7 --- /dev/null +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java @@ -0,0 +1,145 @@ +package org.tron.plugins; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.Console; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.util.Arrays; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import org.tron.keystore.WalletUtils; + +/** + * Shared utilities for keystore CLI commands. + */ +final class KeystoreCliUtils { + + private static final Set OWNER_ONLY = EnumSet.of( + PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE); + + private static final long MAX_FILE_SIZE = 1024; + + private KeystoreCliUtils() { + } + + static String readPassword(File passwordFile) throws IOException { + if (passwordFile != null) { + if (passwordFile.length() > MAX_FILE_SIZE) { + System.err.println("Password file too large (max 1KB)."); + return null; + } + byte[] bytes = Files.readAllBytes(passwordFile.toPath()); + try { + String password = stripLineEndings( + new String(bytes, StandardCharsets.UTF_8)); + if (!WalletUtils.passwordValid(password)) { + System.err.println("Invalid password: must be at least 6 characters."); + return null; + } + return password; + } finally { + Arrays.fill(bytes, (byte) 0); + } + } + + Console console = System.console(); + if (console == null) { + System.err.println("No interactive terminal available. " + + "Use --password-file to provide password."); + return null; + } + + char[] pwd1 = console.readPassword("Enter password: "); + if (pwd1 == null) { + System.err.println("Password input cancelled."); + return null; + } + char[] pwd2 = console.readPassword("Confirm password: "); + if (pwd2 == null) { + Arrays.fill(pwd1, '\0'); + System.err.println("Password input cancelled."); + return null; + } + try { + if (!Arrays.equals(pwd1, pwd2)) { + System.err.println("Passwords do not match."); + return null; + } + String password = new String(pwd1); + if (!WalletUtils.passwordValid(password)) { + System.err.println("Invalid password: must be at least 6 characters."); + return null; + } + return password; + } finally { + Arrays.fill(pwd1, '\0'); + Arrays.fill(pwd2, '\0'); + } + } + + static void ensureDirectory(File dir) throws IOException { + Path path = dir.toPath(); + if (Files.exists(path) && !Files.isDirectory(path)) { + throw new IOException( + "Path exists but is not a directory: " + dir.getAbsolutePath()); + } + Files.createDirectories(path); + } + + private static final ObjectMapper MAPPER = new ObjectMapper() + .configure( + com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + static ObjectMapper mapper() { + return MAPPER; + } + + static void printJson(Map fields) { + try { + System.out.println(MAPPER.writeValueAsString(fields)); + } catch (Exception e) { + System.err.println("Error writing JSON output"); + } + } + + static Map jsonMap(String... keyValues) { + Map map = new LinkedHashMap<>(); + for (int i = 0; i < keyValues.length - 1; i += 2) { + map.put(keyValues[i], keyValues[i + 1]); + } + return map; + } + + static String stripLineEndings(String s) { + // Strip UTF-8 BOM if present (Windows Notepad adds this) + if (s.length() > 0 && s.charAt(0) == '\uFEFF') { + s = s.substring(1); + } + int end = s.length(); + while (end > 0) { + char c = s.charAt(end - 1); + if (c == '\n' || c == '\r') { + end--; + } else { + break; + } + } + return s.substring(0, end); + } + + static void setOwnerOnly(File file) { + try { + Files.setPosixFilePermissions(file.toPath(), OWNER_ONLY); + } catch (UnsupportedOperationException e) { + // Windows — skip + } catch (IOException e) { + System.err.println("Warning: could not set file permissions on " + file.getName()); + } + } +} diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java index f661c37791b..001ccb7177b 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java @@ -5,6 +5,7 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.util.Arrays; import java.util.concurrent.Callable; import org.apache.commons.lang3.StringUtils; import org.tron.common.crypto.SignInterface; @@ -38,39 +39,53 @@ public class KeystoreImport implements Callable { description = "Read password from file instead of interactive prompt") private File passwordFile; + @Option(names = {"--sm2"}, + description = "Use SM2 algorithm instead of ECDSA") + private boolean sm2; + @Override public Integer call() { try { - ensureDirectory(keystoreDir); + KeystoreCliUtils.ensureDirectory(keystoreDir); String privateKey = readPrivateKey(); if (privateKey == null) { return 1; } + if (privateKey.startsWith("0x") || privateKey.startsWith("0X")) { + privateKey = privateKey.substring(2); + } if (!isValidPrivateKey(privateKey)) { System.err.println("Invalid private key: must be 64 hex characters."); return 1; } - String password = readPassword(); + String password = KeystoreCliUtils.readPassword(passwordFile); if (password == null) { return 1; } - boolean ecKey = true; - SignInterface keyPair = SignUtils.fromPrivate( - ByteArray.fromHexString(privateKey), ecKey); + boolean ecKey = !sm2; + SignInterface keyPair; + try { + keyPair = SignUtils.fromPrivate( + ByteArray.fromHexString(privateKey), ecKey); + } catch (Exception e) { + System.err.println("Invalid private key: not a valid key" + + " for the selected algorithm."); + return 1; + } String fileName = WalletUtils.generateWalletFile(password, keyPair, keystoreDir, true); - Credentials credentials = WalletUtils.loadCredentials(password, - new File(keystoreDir, fileName), ecKey); + KeystoreCliUtils.setOwnerOnly(new File(keystoreDir, fileName)); + String address = Credentials.create(keyPair).getAddress(); if (json) { - System.out.printf("{\"address\":\"%s\",\"file\":\"%s\"}%n", - credentials.getAddress(), fileName); + KeystoreCliUtils.printJson(KeystoreCliUtils.jsonMap( + "address", address, "file", fileName)); } else { System.out.println("Imported keystore: " + fileName); - System.out.println("Address: " + credentials.getAddress()); + System.out.println("Address: " + address); } return 0; } catch (CipherException e) { @@ -84,68 +99,41 @@ public Integer call() { private String readPrivateKey() throws IOException { if (keyFile != null) { - return new String(Files.readAllBytes(keyFile.toPath()), - StandardCharsets.UTF_8).trim(); - } - - Console console = System.console(); - if (console == null) { - System.err.println("No interactive terminal available. " - + "Use --key-file to provide private key."); - return null; - } - - char[] key = console.readPassword("Enter private key (hex): "); - return new String(key); - } - - private String readPassword() throws IOException { - if (passwordFile != null) { - String password = new String(Files.readAllBytes(passwordFile.toPath()), - StandardCharsets.UTF_8).trim(); - if (!WalletUtils.passwordValid(password)) { - System.err.println("Invalid password: must be at least 6 characters."); + if (keyFile.length() > 1024) { + System.err.println("Key file too large (max 1KB)."); return null; } - return password; + byte[] bytes = Files.readAllBytes(keyFile.toPath()); + try { + return new String(bytes, StandardCharsets.UTF_8).trim(); + } finally { + Arrays.fill(bytes, (byte) 0); + } } Console console = System.console(); if (console == null) { System.err.println("No interactive terminal available. " - + "Use --password-file to provide password."); + + "Use --key-file to provide private key."); return null; } - char[] pwd1 = console.readPassword("Enter password: "); - char[] pwd2 = console.readPassword("Confirm password: "); - String password1 = new String(pwd1); - String password2 = new String(pwd2); - - if (!password1.equals(password2)) { - System.err.println("Passwords do not match."); + char[] key = console.readPassword("Enter private key (hex): "); + if (key == null) { + System.err.println("Input cancelled."); return null; } - if (!WalletUtils.passwordValid(password1)) { - System.err.println("Invalid password: must be at least 6 characters."); - return null; + try { + return new String(key); + } finally { + Arrays.fill(key, '\0'); } - return password1; } - private boolean isValidPrivateKey(String key) { - if (StringUtils.isEmpty(key) || key.length() != 64) { - return false; - } - return key.matches("[0-9a-fA-F]+"); - } + private static final java.util.regex.Pattern HEX_PATTERN = + java.util.regex.Pattern.compile("[0-9a-fA-F]{64}"); - private void ensureDirectory(File dir) throws IOException { - if (!dir.exists() && !dir.mkdirs()) { - throw new IOException("Cannot create directory: " + dir.getAbsolutePath()); - } - if (dir.exists() && !dir.isDirectory()) { - throw new IOException("Path exists but is not a directory: " + dir.getAbsolutePath()); - } + private boolean isValidPrivateKey(String key) { + return !StringUtils.isEmpty(key) && HEX_PATTERN.matcher(key).matches(); } } diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreList.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreList.java new file mode 100644 index 00000000000..eb28be831b1 --- /dev/null +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreList.java @@ -0,0 +1,99 @@ +package org.tron.plugins; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.File; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import org.tron.keystore.WalletFile; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +@Command(name = "list", + mixinStandardHelpOptions = true, + description = "List all keystore files in a directory.") +public class KeystoreList implements Callable { + + private static final ObjectMapper MAPPER = KeystoreCliUtils.mapper(); + + @Option(names = {"--keystore-dir"}, + description = "Keystore directory (default: ./Wallet)", + defaultValue = "Wallet") + private File keystoreDir; + + @Option(names = {"--json"}, + description = "Output in JSON format") + private boolean json; + + @Override + public Integer call() { + if (!keystoreDir.exists() || !keystoreDir.isDirectory()) { + if (json) { + return printEmptyJson(); + } else { + System.out.println("No keystores found in: " + keystoreDir.getAbsolutePath()); + } + return 0; + } + + File[] files = keystoreDir.listFiles((dir, name) -> name.endsWith(".json")); + if (files == null || files.length == 0) { + if (json) { + return printEmptyJson(); + } else { + System.out.println("No keystores found in: " + keystoreDir.getAbsolutePath()); + } + return 0; + } + + List> entries = new ArrayList<>(); + for (File file : files) { + try { + WalletFile walletFile = MAPPER.readValue(file, WalletFile.class); + if (walletFile.getAddress() == null + || walletFile.getCrypto() == null + || walletFile.getVersion() != 3) { + continue; + } + Map entry = new LinkedHashMap<>(); + entry.put("address", walletFile.getAddress()); + entry.put("file", file.getName()); + entries.add(entry); + } catch (Exception e) { + // Skip files that aren't valid keystore JSON + } + } + + if (json) { + try { + Map result = new LinkedHashMap<>(); + result.put("keystores", entries); + System.out.println(MAPPER.writeValueAsString(result)); + } catch (Exception e) { + System.err.println("Error writing JSON output"); + return 1; + } + } else if (entries.isEmpty()) { + System.out.println("No valid keystores found in: " + keystoreDir.getAbsolutePath()); + } else { + for (Map entry : entries) { + System.out.printf("%-45s %s%n", entry.get("address"), entry.get("file")); + } + } + return 0; + } + + private int printEmptyJson() { + try { + Map result = new LinkedHashMap<>(); + result.put("keystores", new ArrayList<>()); + System.out.println(MAPPER.writeValueAsString(result)); + return 0; + } catch (Exception e) { + System.err.println("Error writing JSON output"); + return 1; + } + } +} diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreNew.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreNew.java index 5e60ae050fd..98fad8c1953 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreNew.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreNew.java @@ -1,10 +1,6 @@ package org.tron.plugins; -import java.io.Console; import java.io.File; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; import java.util.concurrent.Callable; import org.tron.common.crypto.SignInterface; import org.tron.common.crypto.SignUtils; @@ -33,28 +29,32 @@ public class KeystoreNew implements Callable { description = "Read password from file instead of interactive prompt") private File passwordFile; + @Option(names = {"--sm2"}, + description = "Use SM2 algorithm instead of ECDSA") + private boolean sm2; + @Override public Integer call() { try { - ensureDirectory(keystoreDir); + KeystoreCliUtils.ensureDirectory(keystoreDir); - String password = readPassword(); + String password = KeystoreCliUtils.readPassword(passwordFile); if (password == null) { return 1; } - boolean ecKey = true; + boolean ecKey = !sm2; SignInterface keyPair = SignUtils.getGeneratedRandomSign(Utils.getRandom(), ecKey); String fileName = WalletUtils.generateWalletFile(password, keyPair, keystoreDir, true); - Credentials credentials = WalletUtils.loadCredentials(password, - new File(keystoreDir, fileName), ecKey); + KeystoreCliUtils.setOwnerOnly(new File(keystoreDir, fileName)); + String address = Credentials.create(keyPair).getAddress(); if (json) { - System.out.printf("{\"address\":\"%s\",\"file\":\"%s\"}%n", - credentials.getAddress(), fileName); + KeystoreCliUtils.printJson(KeystoreCliUtils.jsonMap( + "address", address, "file", fileName)); } else { System.out.println("Generated keystore: " + fileName); - System.out.println("Address: " + credentials.getAddress()); + System.out.println("Address: " + address); } return 0; } catch (CipherException e) { @@ -65,47 +65,4 @@ public Integer call() { return 1; } } - - private String readPassword() throws IOException { - if (passwordFile != null) { - String password = new String(Files.readAllBytes(passwordFile.toPath()), - StandardCharsets.UTF_8).trim(); - if (!WalletUtils.passwordValid(password)) { - System.err.println("Invalid password: must be at least 6 characters."); - return null; - } - return password; - } - - Console console = System.console(); - if (console == null) { - System.err.println("No interactive terminal available. " - + "Use --password-file to provide password."); - return null; - } - - char[] pwd1 = console.readPassword("Enter password: "); - char[] pwd2 = console.readPassword("Confirm password: "); - String password1 = new String(pwd1); - String password2 = new String(pwd2); - - if (!password1.equals(password2)) { - System.err.println("Passwords do not match."); - return null; - } - if (!WalletUtils.passwordValid(password1)) { - System.err.println("Invalid password: must be at least 6 characters."); - return null; - } - return password1; - } - - private void ensureDirectory(File dir) throws IOException { - if (!dir.exists() && !dir.mkdirs()) { - throw new IOException("Cannot create directory: " + dir.getAbsolutePath()); - } - if (dir.exists() && !dir.isDirectory()) { - throw new IOException("Path exists but is not a directory: " + dir.getAbsolutePath()); - } - } } diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java new file mode 100644 index 00000000000..66e1c467d38 --- /dev/null +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java @@ -0,0 +1,198 @@ +package org.tron.plugins; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.Console; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.util.Arrays; +import java.util.concurrent.Callable; +import org.tron.common.crypto.SignInterface; +import org.tron.core.exception.CipherException; +import org.tron.keystore.Wallet; +import org.tron.keystore.WalletFile; +import org.tron.keystore.WalletUtils; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +@Command(name = "update", + mixinStandardHelpOptions = true, + description = "Change the password of a keystore file.") +public class KeystoreUpdate implements Callable { + + private static final ObjectMapper MAPPER = KeystoreCliUtils.mapper(); + private static final String INPUT_CANCELLED = "Password input cancelled."; + + @Parameters(index = "0", description = "Address of the keystore to update") + private String address; + + @Option(names = {"--keystore-dir"}, + description = "Keystore directory (default: ./Wallet)", + defaultValue = "Wallet") + private File keystoreDir; + + @Option(names = {"--json"}, + description = "Output in JSON format") + private boolean json; + + @Option(names = {"--password-file"}, + description = "Read old and new passwords from file (one per line)") + private File passwordFile; + + @Option(names = {"--sm2"}, + description = "Use SM2 algorithm instead of ECDSA") + private boolean sm2; + + @Override + public Integer call() { + try { + File keystoreFile = findKeystoreByAddress(address); + if (keystoreFile == null) { + System.err.println("No keystore found for address: " + address); + return 1; + } + + String oldPassword; + String newPassword; + + if (passwordFile != null) { + if (passwordFile.length() > 1024) { + System.err.println("Password file too large (max 1KB)."); + return 1; + } + byte[] bytes = Files.readAllBytes(passwordFile.toPath()); + try { + String content = new String(bytes, StandardCharsets.UTF_8); + // Strip UTF-8 BOM if present (Windows Notepad) + if (content.length() > 0 && content.charAt(0) == '\uFEFF') { + content = content.substring(1); + } + String[] lines = content.split("\\r?\\n"); + if (lines.length < 2) { + System.err.println( + "Password file must contain old and new passwords" + + " on separate lines."); + return 1; + } + oldPassword = lines[0]; + newPassword = lines[1]; + } finally { + Arrays.fill(bytes, (byte) 0); + } + } else { + Console console = System.console(); + if (console == null) { + System.err.println("No interactive terminal available. " + + "Use --password-file to provide passwords."); + return 1; + } + char[] oldPwd = console.readPassword("Enter current password: "); + if (oldPwd == null) { + System.err.println(INPUT_CANCELLED); + return 1; + } + char[] newPwd = console.readPassword("Enter new password: "); + if (newPwd == null) { + Arrays.fill(oldPwd, '\0'); + System.err.println(INPUT_CANCELLED); + return 1; + } + char[] confirmPwd = console.readPassword("Confirm new password: "); + if (confirmPwd == null) { + Arrays.fill(oldPwd, '\0'); + Arrays.fill(newPwd, '\0'); + System.err.println(INPUT_CANCELLED); + return 1; + } + try { + oldPassword = new String(oldPwd); + newPassword = new String(newPwd); + String confirmPassword = new String(confirmPwd); + if (!newPassword.equals(confirmPassword)) { + System.err.println("New passwords do not match."); + return 1; + } + } finally { + Arrays.fill(oldPwd, '\0'); + Arrays.fill(newPwd, '\0'); + Arrays.fill(confirmPwd, '\0'); + } + } + + // Skip validation on old password: keystore may predate the minimum-length policy + if (!WalletUtils.passwordValid(newPassword)) { + System.err.println("Invalid new password: must be at least 6 characters."); + return 1; + } + + boolean ecKey = !sm2; + WalletFile walletFile = MAPPER.readValue(keystoreFile, WalletFile.class); + SignInterface keyPair = Wallet.decrypt(oldPassword, walletFile, ecKey); + + WalletFile newWalletFile = Wallet.createStandard(newPassword, keyPair); + newWalletFile.setAddress(walletFile.getAddress()); + // Write to temp file first, then atomic rename to prevent corruption + File tempFile = File.createTempFile("keystore-", ".tmp", + keystoreFile.getParentFile()); + try { + KeystoreCliUtils.setOwnerOnly(tempFile); + MAPPER.writeValue(tempFile, newWalletFile); + try { + Files.move(tempFile.toPath(), keystoreFile.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE); + } catch (java.nio.file.AtomicMoveNotSupportedException e) { + // Fallback for NFS, FAT32, cross-partition + Files.move(tempFile.toPath(), keystoreFile.toPath(), + StandardCopyOption.REPLACE_EXISTING); + } + } catch (Exception e) { + if (!tempFile.delete()) { + System.err.println("Warning: could not delete temp file: " + + tempFile.getName()); + } + throw e; + } + + if (json) { + KeystoreCliUtils.printJson(KeystoreCliUtils.jsonMap( + "address", walletFile.getAddress(), + "file", keystoreFile.getName(), + "status", "updated")); + } else { + System.out.println("Password updated for: " + walletFile.getAddress()); + } + return 0; + } catch (CipherException e) { + System.err.println("Decryption failed: " + e.getMessage()); + return 1; + } catch (Exception e) { + System.err.println("Error: " + e.getMessage()); + return 1; + } + } + + private File findKeystoreByAddress(String targetAddress) { + if (!keystoreDir.exists() || !keystoreDir.isDirectory()) { + return null; + } + File[] files = keystoreDir.listFiles((dir, name) -> name.endsWith(".json")); + if (files == null) { + return null; + } + for (File file : files) { + try { + WalletFile wf = MAPPER.readValue(file, WalletFile.class); + if (targetAddress.equals(wf.getAddress())) { + return file; + } + } catch (Exception e) { + // Skip invalid files + } + } + return null; + } +} diff --git a/plugins/src/test/java/org/tron/plugins/KeystoreImportTest.java b/plugins/src/test/java/org/tron/plugins/KeystoreImportTest.java index 190a8013524..6704e8bfb6f 100644 --- a/plugins/src/test/java/org/tron/plugins/KeystoreImportTest.java +++ b/plugins/src/test/java/org/tron/plugins/KeystoreImportTest.java @@ -96,4 +96,111 @@ public void testImportInvalidKeyNonHex() throws Exception { assertEquals("Should fail with non-hex key", 1, exitCode); } + + @Test + public void testImportNoTtyNoKeyFile() throws Exception { + File dir = tempFolder.newFolder("keystore-notty"); + File pwFile = tempFolder.newFile("pw2.txt"); + Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + + // No --key-file and System.console() is null in CI + CommandLine cmd = new CommandLine(new Toolkit()); + int exitCode = cmd.execute("keystore", "import", + "--keystore-dir", dir.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals("Should fail when no TTY and no --key-file", 1, exitCode); + } + + @Test + public void testImportWithSm2() throws Exception { + File dir = tempFolder.newFolder("keystore-sm2"); + // SM2 uses same 32-byte private key format + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), false); + String privateKeyHex = ByteArray.toHexString(keyPair.getPrivateKey()); + + File keyFile = tempFolder.newFile("sm2.key"); + Files.write(keyFile.toPath(), privateKeyHex.getBytes(StandardCharsets.UTF_8)); + File pwFile = tempFolder.newFile("pw-sm2.txt"); + Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + + CommandLine cmd = new CommandLine(new Toolkit()); + int exitCode = cmd.execute("keystore", "import", + "--keystore-dir", dir.getAbsolutePath(), + "--key-file", keyFile.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath(), + "--sm2"); + + assertEquals("SM2 import should succeed", 0, exitCode); + File[] files = dir.listFiles((d, name) -> name.endsWith(".json")); + assertNotNull(files); + assertEquals(1, files.length); + + // Verify SM2 keystore can be decrypted + Credentials creds = WalletUtils.loadCredentials("test123456", files[0], false); + assertArrayEquals("SM2 key must survive import roundtrip", + keyPair.getPrivateKey(), creds.getSignInterface().getPrivateKey()); + } + + @Test + public void testImportKeyFileWithWhitespace() throws Exception { + File dir = tempFolder.newFolder("keystore-ws"); + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + String privateKeyHex = ByteArray.toHexString(keyPair.getPrivateKey()); + + // Key file with leading/trailing whitespace and newlines + File keyFile = tempFolder.newFile("ws.key"); + Files.write(keyFile.toPath(), + (" " + privateKeyHex + " \n\n").getBytes(StandardCharsets.UTF_8)); + File pwFile = tempFolder.newFile("pw-ws.txt"); + Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + + CommandLine cmd = new CommandLine(new Toolkit()); + int exitCode = cmd.execute("keystore", "import", + "--keystore-dir", dir.getAbsolutePath(), + "--key-file", keyFile.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals("Import with whitespace-padded key should succeed", 0, exitCode); + + File[] files = dir.listFiles((d, name) -> name.endsWith(".json")); + assertNotNull(files); + assertEquals(1, files.length); + Credentials creds = WalletUtils.loadCredentials("test123456", files[0], true); + assertArrayEquals("Key must survive whitespace-trimmed import", + keyPair.getPrivateKey(), creds.getSignInterface().getPrivateKey()); + } + + @Test + public void testImportDuplicateAddress() throws Exception { + File dir = tempFolder.newFolder("keystore-dup"); + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + String privateKeyHex = ByteArray.toHexString(keyPair.getPrivateKey()); + + File keyFile = tempFolder.newFile("dup.key"); + Files.write(keyFile.toPath(), privateKeyHex.getBytes(StandardCharsets.UTF_8)); + File pwFile = tempFolder.newFile("pw-dup.txt"); + Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + + // Import same key twice + CommandLine cmd1 = new CommandLine(new Toolkit()); + assertEquals(0, cmd1.execute("keystore", "import", + "--keystore-dir", dir.getAbsolutePath(), + "--key-file", keyFile.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath())); + + CommandLine cmd2 = new CommandLine(new Toolkit()); + assertEquals(0, cmd2.execute("keystore", "import", + "--keystore-dir", dir.getAbsolutePath(), + "--key-file", keyFile.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath())); + + // Should create two separate files (timestamped names differ) + File[] files = dir.listFiles((d, name) -> name.endsWith(".json")); + assertNotNull(files); + assertEquals("Duplicate import should create 2 separate files", 2, files.length); + } } diff --git a/plugins/src/test/java/org/tron/plugins/KeystoreListTest.java b/plugins/src/test/java/org/tron/plugins/KeystoreListTest.java new file mode 100644 index 00000000000..dbd1c9f065a --- /dev/null +++ b/plugins/src/test/java/org/tron/plugins/KeystoreListTest.java @@ -0,0 +1,139 @@ +package org.tron.plugins; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.security.SecureRandom; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.common.crypto.SignInterface; +import org.tron.common.crypto.SignUtils; +import org.tron.keystore.WalletUtils; +import picocli.CommandLine; + +public class KeystoreListTest { + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + @Test + public void testListMultipleKeystores() throws Exception { + File dir = tempFolder.newFolder("keystore"); + String password = "test123456"; + + // Create 3 keystores + for (int i = 0; i < 3; i++) { + SignInterface key = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + WalletUtils.generateWalletFile(password, key, dir, false); + } + + PrintStream originalOut = System.out; + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + System.setOut(new PrintStream(baos)); + try { + CommandLine cmd = new CommandLine(new Toolkit()); + int exitCode = cmd.execute("keystore", "list", + "--keystore-dir", dir.getAbsolutePath()); + + assertEquals(0, exitCode); + String output = baos.toString(StandardCharsets.UTF_8.name()).trim(); + assertTrue("Output should not be empty", output.length() > 0); + // Should have 3 lines of output (one per keystore) + String[] lines = output.split("\\n"); + assertEquals("Should list 3 keystores", 3, lines.length); + } finally { + System.setOut(originalOut); + } + } + + @Test + public void testListEmptyDirectory() throws Exception { + File dir = tempFolder.newFolder("empty"); + + CommandLine cmd = new CommandLine(new Toolkit()); + int exitCode = cmd.execute("keystore", "list", + "--keystore-dir", dir.getAbsolutePath()); + + assertEquals(0, exitCode); + } + + @Test + public void testListNonExistentDirectory() throws Exception { + File dir = new File(tempFolder.getRoot(), "nonexistent"); + + CommandLine cmd = new CommandLine(new Toolkit()); + int exitCode = cmd.execute("keystore", "list", + "--keystore-dir", dir.getAbsolutePath()); + + assertEquals(0, exitCode); + } + + @Test + public void testListJsonOutput() throws Exception { + File dir = tempFolder.newFolder("keystore-json"); + String password = "test123456"; + SignInterface key = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + WalletUtils.generateWalletFile(password, key, dir, false); + + PrintStream originalOut = System.out; + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + System.setOut(new PrintStream(baos)); + try { + CommandLine cmd = new CommandLine(new Toolkit()); + int exitCode = cmd.execute("keystore", "list", + "--keystore-dir", dir.getAbsolutePath(), "--json"); + + assertEquals(0, exitCode); + String output = baos.toString(StandardCharsets.UTF_8.name()).trim(); + assertTrue("Should start with keystores JSON array", + output.startsWith("{\"keystores\":[")); + assertTrue("Should end with JSON array close", + output.endsWith("]}")); + } finally { + System.setOut(originalOut); + } + } + + @Test + public void testListSkipsNonKeystoreFiles() throws Exception { + File dir = tempFolder.newFolder("keystore-mixed"); + String password = "test123456"; + + // Create one valid keystore + SignInterface key = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + WalletUtils.generateWalletFile(password, key, dir, false); + + // Create non-keystore files + Files.write(new File(dir, "readme.json").toPath(), + "{\"not\":\"a keystore\"}".getBytes(StandardCharsets.UTF_8)); + Files.write(new File(dir, "notes.txt").toPath(), + "plain text".getBytes(StandardCharsets.UTF_8)); + + PrintStream originalOut = System.out; + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + System.setOut(new PrintStream(baos)); + try { + CommandLine cmd = new CommandLine(new Toolkit()); + int exitCode = cmd.execute("keystore", "list", + "--keystore-dir", dir.getAbsolutePath()); + + assertEquals(0, exitCode); + String output = baos.toString(StandardCharsets.UTF_8.name()).trim(); + assertTrue("Output should not be empty", output.length() > 0); + String[] lines = output.split("\\n"); + // Should list only the valid keystore, not the readme.json or notes.txt + assertEquals("Should list only 1 valid keystore", 1, lines.length); + } finally { + System.setOut(originalOut); + } + } +} diff --git a/plugins/src/test/java/org/tron/plugins/KeystoreNewTest.java b/plugins/src/test/java/org/tron/plugins/KeystoreNewTest.java index 760ae279167..106880ec490 100644 --- a/plugins/src/test/java/org/tron/plugins/KeystoreNewTest.java +++ b/plugins/src/test/java/org/tron/plugins/KeystoreNewTest.java @@ -55,18 +55,25 @@ public void testNewKeystoreJsonOutput() throws Exception { File pwFile = tempFolder.newFile("password-json.txt"); Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); - StringWriter out = new StringWriter(); - CommandLine cmd = new CommandLine(new Toolkit()); - cmd.setOut(new PrintWriter(out)); - - int exitCode = cmd.execute("keystore", "new", - "--keystore-dir", dir.getAbsolutePath(), - "--password-file", pwFile.getAbsolutePath(), - "--json"); - - assertEquals(0, exitCode); - // stdout is captured by picocli's setOut but System.out goes to console - // The JSON output goes through System.out directly + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); + java.io.PrintStream originalOut = System.out; + System.setOut(new java.io.PrintStream(baos)); + try { + CommandLine cmd = new CommandLine(new Toolkit()); + int exitCode = cmd.execute("keystore", "new", + "--keystore-dir", dir.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath(), + "--json"); + + assertEquals(0, exitCode); + String output = baos.toString(StandardCharsets.UTF_8.name()).trim(); + assertTrue("JSON output should contain address", + output.contains("\"address\"")); + assertTrue("JSON output should contain file", + output.contains("\"file\"")); + } finally { + System.setOut(originalOut); + } } @Test @@ -100,4 +107,90 @@ public void testNewKeystoreCustomDir() throws Exception { assertNotNull(files); assertEquals(1, files.length); } + + @Test + public void testNewKeystoreNoTtyNoPasswordFile() throws Exception { + // In CI/test environment, System.console() is null. + // Without --password-file, should fail with exit code 1. + File dir = tempFolder.newFolder("keystore-notty"); + + CommandLine cmd = new CommandLine(new Toolkit()); + int exitCode = cmd.execute("keystore", "new", + "--keystore-dir", dir.getAbsolutePath()); + + assertEquals("Should fail when no TTY and no --password-file", 1, exitCode); + } + + @Test + public void testNewKeystoreEmptyPassword() throws Exception { + File dir = tempFolder.newFolder("keystore-empty"); + File pwFile = tempFolder.newFile("empty.txt"); + Files.write(pwFile.toPath(), "".getBytes(StandardCharsets.UTF_8)); + + CommandLine cmd = new CommandLine(new Toolkit()); + int exitCode = cmd.execute("keystore", "new", + "--keystore-dir", dir.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals("Should fail with empty password", 1, exitCode); + } + + @Test + public void testNewKeystoreWithSm2() throws Exception { + File dir = tempFolder.newFolder("keystore-sm2"); + File pwFile = tempFolder.newFile("pw-sm2.txt"); + Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + + CommandLine cmd = new CommandLine(new Toolkit()); + int exitCode = cmd.execute("keystore", "new", + "--keystore-dir", dir.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath(), + "--sm2"); + + assertEquals("SM2 keystore creation should succeed", 0, exitCode); + File[] files = dir.listFiles((d, name) -> name.endsWith(".json")); + assertNotNull(files); + assertEquals(1, files.length); + + // Verify SM2 keystore can be decrypted with ecKey=false + org.tron.keystore.Credentials creds = + org.tron.keystore.WalletUtils.loadCredentials("test123456", files[0], false); + assertNotNull(creds.getAddress()); + } + + @Test + public void testNewKeystoreSpecialCharPassword() throws Exception { + File dir = tempFolder.newFolder("keystore-special"); + File pwFile = tempFolder.newFile("pw-special.txt"); + String password = "p@$$w0rd!#%^&*()_+-=[]{}"; + Files.write(pwFile.toPath(), password.getBytes(StandardCharsets.UTF_8)); + + CommandLine cmd = new CommandLine(new Toolkit()); + int exitCode = cmd.execute("keystore", "new", + "--keystore-dir", dir.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals(0, exitCode); + File[] files = dir.listFiles((d, name) -> name.endsWith(".json")); + assertNotNull(files); + assertEquals(1, files.length); + + // Verify can decrypt with same special-char password + Credentials creds = WalletUtils.loadCredentials(password, files[0], true); + assertNotNull(creds.getAddress()); + } + + @Test + public void testNewKeystoreDirIsFile() throws Exception { + File notADir = tempFolder.newFile("not-a-dir"); + File pwFile = tempFolder.newFile("pw-dir.txt"); + Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + + CommandLine cmd = new CommandLine(new Toolkit()); + int exitCode = cmd.execute("keystore", "new", + "--keystore-dir", notADir.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals("Should fail when dir is a file", 1, exitCode); + } } diff --git a/plugins/src/test/java/org/tron/plugins/KeystoreUpdateTest.java b/plugins/src/test/java/org/tron/plugins/KeystoreUpdateTest.java new file mode 100644 index 00000000000..24c6b4c99f4 --- /dev/null +++ b/plugins/src/test/java/org/tron/plugins/KeystoreUpdateTest.java @@ -0,0 +1,165 @@ +package org.tron.plugins; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.security.SecureRandom; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.common.crypto.SignInterface; +import org.tron.common.crypto.SignUtils; +import org.tron.keystore.Credentials; +import org.tron.keystore.WalletUtils; +import picocli.CommandLine; + +public class KeystoreUpdateTest { + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + @Test + public void testUpdatePassword() throws Exception { + File dir = tempFolder.newFolder("keystore"); + String oldPassword = "oldpass123"; + String newPassword = "newpass456"; + + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + byte[] originalKey = keyPair.getPrivateKey(); + String fileName = WalletUtils.generateWalletFile(oldPassword, keyPair, dir, true); + + // Read address from the generated file + Credentials creds = WalletUtils.loadCredentials(oldPassword, + new File(dir, fileName), true); + String address = creds.getAddress(); + + // Create password file with old + new passwords + File pwFile = tempFolder.newFile("passwords.txt"); + Files.write(pwFile.toPath(), + (oldPassword + "\n" + newPassword).getBytes(StandardCharsets.UTF_8)); + + CommandLine cmd = new CommandLine(new Toolkit()); + int exitCode = cmd.execute("keystore", "update", address, + "--keystore-dir", dir.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals("Exit code should be 0", 0, exitCode); + + // Verify: new password works + Credentials updated = WalletUtils.loadCredentials(newPassword, + new File(dir, fileName), true); + assertArrayEquals("Key must survive password change", + originalKey, updated.getSignInterface().getPrivateKey()); + } + + @Test + public void testUpdateWrongOldPassword() throws Exception { + File dir = tempFolder.newFolder("keystore-bad"); + String password = "correct123"; + + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + String fileName = WalletUtils.generateWalletFile(password, keyPair, dir, true); + + Credentials creds = WalletUtils.loadCredentials(password, + new File(dir, fileName), true); + String address = creds.getAddress(); + + File pwFile = tempFolder.newFile("wrong.txt"); + Files.write(pwFile.toPath(), + ("wrongpass1\nnewpass456").getBytes(StandardCharsets.UTF_8)); + + CommandLine cmd = new CommandLine(new Toolkit()); + int exitCode = cmd.execute("keystore", "update", address, + "--keystore-dir", dir.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals("Should fail with wrong password", 1, exitCode); + + // Verify: original password still works (file unchanged) + Credentials unchanged = WalletUtils.loadCredentials(password, + new File(dir, fileName), true); + assertEquals(address, unchanged.getAddress()); + } + + @Test + public void testUpdateNonExistentAddress() throws Exception { + File dir = tempFolder.newFolder("keystore-noaddr"); + String password = "test123456"; + + // Create a keystore so the dir isn't empty + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + WalletUtils.generateWalletFile(password, keyPair, dir, true); + + File pwFile = tempFolder.newFile("pw.txt"); + Files.write(pwFile.toPath(), + ("test123456\nnewpass789").getBytes(StandardCharsets.UTF_8)); + + CommandLine cmd = new CommandLine(new Toolkit()); + int exitCode = cmd.execute("keystore", "update", "TNonExistentAddress123456789", + "--keystore-dir", dir.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals("Should fail for non-existent address", 1, exitCode); + } + + @Test + public void testUpdateNewPasswordTooShort() throws Exception { + File dir = tempFolder.newFolder("keystore-shortpw"); + String password = "test123456"; + + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + String fileName = WalletUtils.generateWalletFile(password, keyPair, dir, true); + + Credentials creds = WalletUtils.loadCredentials(password, + new File(dir, fileName), true); + + File pwFile = tempFolder.newFile("shortpw.txt"); + Files.write(pwFile.toPath(), + (password + "\nabc").getBytes(StandardCharsets.UTF_8)); + + CommandLine cmd = new CommandLine(new Toolkit()); + int exitCode = cmd.execute("keystore", "update", creds.getAddress(), + "--keystore-dir", dir.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals("Should fail with short new password", 1, exitCode); + } + + @Test + public void testUpdateWithWindowsLineEndings() throws Exception { + File dir = tempFolder.newFolder("keystore-crlf"); + String oldPassword = "oldpass123"; + String newPassword = "newpass456"; + + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + byte[] originalKey = keyPair.getPrivateKey(); + String fileName = WalletUtils.generateWalletFile(oldPassword, keyPair, dir, true); + Credentials creds = WalletUtils.loadCredentials(oldPassword, + new File(dir, fileName), true); + + // Password file with Windows line endings \r\n + File pwFile = tempFolder.newFile("crlf.txt"); + Files.write(pwFile.toPath(), + (oldPassword + "\r\n" + newPassword + "\r\n").getBytes(StandardCharsets.UTF_8)); + + CommandLine cmd = new CommandLine(new Toolkit()); + int exitCode = cmd.execute("keystore", "update", creds.getAddress(), + "--keystore-dir", dir.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals("Update with CRLF password file should succeed", 0, exitCode); + + Credentials updated = WalletUtils.loadCredentials(newPassword, + new File(dir, fileName), true); + assertArrayEquals("Key must survive update with CRLF passwords", + originalKey, updated.getSignInterface().getPrivateKey()); + } +} From 1a641b484e5398c3175a4cb05af51a68c7cf8411 Mon Sep 17 00:00:00 2001 From: Barbatos Date: Fri, 3 Apr 2026 17:05:33 +0800 Subject: [PATCH 17/41] fix(plugins): handle duplicate-address keystores and add import warning - KeystoreUpdate: findKeystoreByAddress now detects multiple keystores with the same address and returns an error with file list, instead of silently picking one nondeterministically. - KeystoreImport: scan directory before writing and print WARNING if a keystore for the same address already exists. Import still proceeds (legitimate use case) but user is made aware. - KeystoreUpdate: strip UTF-8 BOM from password file before parsing. - KeystoreUpdate: add comment clarifying old password skips validation. - KeystoreList: add version != 3 check to filter non-keystore JSON. --- .../org/tron/plugins/KeystoreImport.java | 29 +++++++++++++++++-- .../org/tron/plugins/KeystoreUpdate.java | 14 +++++++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java index 001ccb7177b..21b714b76d6 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java @@ -76,10 +76,10 @@ public Integer call() { + " for the selected algorithm."); return 1; } + String address = Credentials.create(keyPair).getAddress(); + warnIfAddressExists(keystoreDir, address); String fileName = WalletUtils.generateWalletFile(password, keyPair, keystoreDir, true); KeystoreCliUtils.setOwnerOnly(new File(keystoreDir, fileName)); - - String address = Credentials.create(keyPair).getAddress(); if (json) { KeystoreCliUtils.printJson(KeystoreCliUtils.jsonMap( "address", address, "file", fileName)); @@ -136,4 +136,29 @@ private String readPrivateKey() throws IOException { private boolean isValidPrivateKey(String key) { return !StringUtils.isEmpty(key) && HEX_PATTERN.matcher(key).matches(); } + + private void warnIfAddressExists(File dir, String address) { + if (!dir.exists() || !dir.isDirectory()) { + return; + } + File[] files = dir.listFiles((d, name) -> name.endsWith(".json")); + if (files == null) { + return; + } + com.fasterxml.jackson.databind.ObjectMapper mapper = + KeystoreCliUtils.mapper(); + for (File file : files) { + try { + org.tron.keystore.WalletFile wf = + mapper.readValue(file, org.tron.keystore.WalletFile.class); + if (address.equals(wf.getAddress())) { + System.err.println("WARNING: keystore for address " + + address + " already exists: " + file.getName()); + return; + } + } catch (Exception e) { + // Skip invalid files + } + } + } } diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java index 66e1c467d38..e9dd372e83b 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java @@ -183,16 +183,26 @@ private File findKeystoreByAddress(String targetAddress) { if (files == null) { return null; } + java.util.List matches = new java.util.ArrayList<>(); for (File file : files) { try { WalletFile wf = MAPPER.readValue(file, WalletFile.class); if (targetAddress.equals(wf.getAddress())) { - return file; + matches.add(file); } } catch (Exception e) { // Skip invalid files } } - return null; + if (matches.size() > 1) { + System.err.println("Multiple keystores found for address " + + targetAddress + ":"); + for (File m : matches) { + System.err.println(" " + m.getName()); + } + System.err.println("Please remove duplicates and retry."); + return null; + } + return matches.isEmpty() ? null : matches.get(0); } } From 791d91f8d819d8e2daac612d14ebbdaee1ffb1ea Mon Sep 17 00:00:00 2001 From: Barbatos Date: Sun, 5 Apr 2026 10:52:23 +0800 Subject: [PATCH 18/41] fix(plugins): block duplicate import, improve error messages and security tips - Reject duplicate-address import by default, add --force flag to override - Add friendly error messages when --password-file or --key-file not found - Print security tips after keystore new/import (aligned with geth output) - Add file existence checks in KeystoreCliUtils and KeystoreUpdate --- .../org/tron/plugins/KeystoreCliUtils.java | 32 ++++++++++ .../org/tron/plugins/KeystoreImport.java | 31 +++++++--- .../common/org/tron/plugins/KeystoreNew.java | 5 +- .../org/tron/plugins/KeystoreUpdate.java | 5 ++ .../org/tron/plugins/KeystoreImportTest.java | 58 +++++++++++++++++-- .../org/tron/plugins/KeystoreNewTest.java | 12 ++++ 6 files changed, 127 insertions(+), 16 deletions(-) diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java index 65e595e5ed7..d3a94a8dd83 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java @@ -30,6 +30,11 @@ private KeystoreCliUtils() { static String readPassword(File passwordFile) throws IOException { if (passwordFile != null) { + if (!passwordFile.exists()) { + System.err.println("Password file not found: " + passwordFile.getPath() + + ". Omit --password-file for interactive input."); + return null; + } if (passwordFile.length() > MAX_FILE_SIZE) { System.err.println("Password file too large (max 1KB)."); return null; @@ -133,6 +138,33 @@ static String stripLineEndings(String s) { return s.substring(0, end); } + static boolean checkFileExists(File file, String label) { + if (file != null && !file.exists()) { + System.err.println(label + " not found: " + file.getPath()); + return false; + } + return true; + } + + static void printSecurityTips(String address, String fileName) { + System.out.println(); + System.out.println("Public address of the key: " + address); + System.out.println("Path of the secret key file: " + fileName); + System.out.println(); + System.out.println( + "- You can share your public address with anyone." + + " Others need it to interact with you."); + System.out.println( + "- You must NEVER share the secret key with anyone!" + + " The key controls access to your funds!"); + System.out.println( + "- You must BACKUP your key file!" + + " Without the key, it's impossible to access account funds!"); + System.out.println( + "- You must REMEMBER your password!" + + " Without the password, it's impossible to decrypt the key!"); + } + static void setOwnerOnly(File file) { try { Files.setPosixFilePermissions(file.toPath(), OWNER_ONLY); diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java index 21b714b76d6..c3f9a78e8e1 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java @@ -43,9 +43,16 @@ public class KeystoreImport implements Callable { description = "Use SM2 algorithm instead of ECDSA") private boolean sm2; + @Option(names = {"--force"}, + description = "Allow import even if address already exists") + private boolean force; + @Override public Integer call() { try { + if (!KeystoreCliUtils.checkFileExists(keyFile, "Key file")) { + return 1; + } KeystoreCliUtils.ensureDirectory(keystoreDir); String privateKey = readPrivateKey(); @@ -77,15 +84,22 @@ public Integer call() { return 1; } String address = Credentials.create(keyPair).getAddress(); - warnIfAddressExists(keystoreDir, address); + String existingFile = findExistingKeystore(keystoreDir, address); + if (existingFile != null && !force) { + System.err.println("Keystore for address " + address + + " already exists: " + existingFile + + ". Use --force to import anyway."); + return 1; + } String fileName = WalletUtils.generateWalletFile(password, keyPair, keystoreDir, true); KeystoreCliUtils.setOwnerOnly(new File(keystoreDir, fileName)); if (json) { KeystoreCliUtils.printJson(KeystoreCliUtils.jsonMap( "address", address, "file", fileName)); } else { - System.out.println("Imported keystore: " + fileName); - System.out.println("Address: " + address); + System.out.println("Imported keystore successfully"); + KeystoreCliUtils.printSecurityTips(address, + new File(keystoreDir, fileName).getPath()); } return 0; } catch (CipherException e) { @@ -137,13 +151,13 @@ private boolean isValidPrivateKey(String key) { return !StringUtils.isEmpty(key) && HEX_PATTERN.matcher(key).matches(); } - private void warnIfAddressExists(File dir, String address) { + private String findExistingKeystore(File dir, String address) { if (!dir.exists() || !dir.isDirectory()) { - return; + return null; } File[] files = dir.listFiles((d, name) -> name.endsWith(".json")); if (files == null) { - return; + return null; } com.fasterxml.jackson.databind.ObjectMapper mapper = KeystoreCliUtils.mapper(); @@ -152,13 +166,12 @@ private void warnIfAddressExists(File dir, String address) { org.tron.keystore.WalletFile wf = mapper.readValue(file, org.tron.keystore.WalletFile.class); if (address.equals(wf.getAddress())) { - System.err.println("WARNING: keystore for address " - + address + " already exists: " + file.getName()); - return; + return file.getName(); } } catch (Exception e) { // Skip invalid files } } + return null; } } diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreNew.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreNew.java index 98fad8c1953..8016b417a34 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreNew.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreNew.java @@ -53,8 +53,9 @@ public Integer call() { KeystoreCliUtils.printJson(KeystoreCliUtils.jsonMap( "address", address, "file", fileName)); } else { - System.out.println("Generated keystore: " + fileName); - System.out.println("Address: " + address); + System.out.println("Your new key was generated"); + KeystoreCliUtils.printSecurityTips(address, + new File(keystoreDir, fileName).getPath()); } return 0; } catch (CipherException e) { diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java index e9dd372e83b..09009795662 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java @@ -59,6 +59,11 @@ public Integer call() { String newPassword; if (passwordFile != null) { + if (!passwordFile.exists()) { + System.err.println("Password file not found: " + passwordFile.getPath() + + ". Omit --password-file for interactive input."); + return 1; + } if (passwordFile.length() > 1024) { System.err.println("Password file too large (max 1KB)."); return 1; diff --git a/plugins/src/test/java/org/tron/plugins/KeystoreImportTest.java b/plugins/src/test/java/org/tron/plugins/KeystoreImportTest.java index 6704e8bfb6f..59c9a53d162 100644 --- a/plugins/src/test/java/org/tron/plugins/KeystoreImportTest.java +++ b/plugins/src/test/java/org/tron/plugins/KeystoreImportTest.java @@ -174,7 +174,7 @@ public void testImportKeyFileWithWhitespace() throws Exception { } @Test - public void testImportDuplicateAddress() throws Exception { + public void testImportDuplicateAddressBlocked() throws Exception { File dir = tempFolder.newFolder("keystore-dup"); SignInterface keyPair = SignUtils.getGeneratedRandomSign( SecureRandom.getInstance("NativePRNG"), true); @@ -185,22 +185,70 @@ public void testImportDuplicateAddress() throws Exception { File pwFile = tempFolder.newFile("pw-dup.txt"); Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); - // Import same key twice + // First import succeeds CommandLine cmd1 = new CommandLine(new Toolkit()); assertEquals(0, cmd1.execute("keystore", "import", "--keystore-dir", dir.getAbsolutePath(), "--key-file", keyFile.getAbsolutePath(), "--password-file", pwFile.getAbsolutePath())); + // Second import of same key is blocked CommandLine cmd2 = new CommandLine(new Toolkit()); - assertEquals(0, cmd2.execute("keystore", "import", + assertEquals("Duplicate import should be blocked", 1, + cmd2.execute("keystore", "import", + "--keystore-dir", dir.getAbsolutePath(), + "--key-file", keyFile.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath())); + + File[] files = dir.listFiles((d, name) -> name.endsWith(".json")); + assertNotNull(files); + assertEquals("Should still have only 1 keystore", 1, files.length); + } + + @Test + public void testImportDuplicateAddressWithForce() throws Exception { + File dir = tempFolder.newFolder("keystore-force"); + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + String privateKeyHex = ByteArray.toHexString(keyPair.getPrivateKey()); + + File keyFile = tempFolder.newFile("force.key"); + Files.write(keyFile.toPath(), privateKeyHex.getBytes(StandardCharsets.UTF_8)); + File pwFile = tempFolder.newFile("pw-force.txt"); + Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + + // First import + CommandLine cmd1 = new CommandLine(new Toolkit()); + assertEquals(0, cmd1.execute("keystore", "import", "--keystore-dir", dir.getAbsolutePath(), "--key-file", keyFile.getAbsolutePath(), "--password-file", pwFile.getAbsolutePath())); - // Should create two separate files (timestamped names differ) + // Second import with --force succeeds + CommandLine cmd2 = new CommandLine(new Toolkit()); + assertEquals(0, cmd2.execute("keystore", "import", + "--keystore-dir", dir.getAbsolutePath(), + "--key-file", keyFile.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath(), + "--force")); + File[] files = dir.listFiles((d, name) -> name.endsWith(".json")); assertNotNull(files); - assertEquals("Duplicate import should create 2 separate files", 2, files.length); + assertEquals("Force import should create 2 files", 2, files.length); + } + + @Test + public void testImportKeyFileNotFound() throws Exception { + File dir = tempFolder.newFolder("keystore-nokey"); + File pwFile = tempFolder.newFile("pw-nokey.txt"); + Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + + CommandLine cmd = new CommandLine(new Toolkit()); + int exitCode = cmd.execute("keystore", "import", + "--keystore-dir", dir.getAbsolutePath(), + "--key-file", "/tmp/nonexistent-key-file.txt", + "--password-file", pwFile.getAbsolutePath()); + + assertEquals("Should fail when key file not found", 1, exitCode); } } diff --git a/plugins/src/test/java/org/tron/plugins/KeystoreNewTest.java b/plugins/src/test/java/org/tron/plugins/KeystoreNewTest.java index 106880ec490..8304e9596f0 100644 --- a/plugins/src/test/java/org/tron/plugins/KeystoreNewTest.java +++ b/plugins/src/test/java/org/tron/plugins/KeystoreNewTest.java @@ -180,6 +180,18 @@ public void testNewKeystoreSpecialCharPassword() throws Exception { assertNotNull(creds.getAddress()); } + @Test + public void testNewKeystorePasswordFileNotFound() throws Exception { + File dir = tempFolder.newFolder("keystore-nopw"); + + CommandLine cmd = new CommandLine(new Toolkit()); + int exitCode = cmd.execute("keystore", "new", + "--keystore-dir", dir.getAbsolutePath(), + "--password-file", "/tmp/nonexistent-pw.txt"); + + assertEquals("Should fail when password file not found", 1, exitCode); + } + @Test public void testNewKeystoreDirIsFile() throws Exception { File notADir = tempFolder.newFile("not-a-dir"); From 3c0f8eba3cf873b5320873c2925cdc5f3589d271 Mon Sep 17 00:00:00 2001 From: Barbatos Date: Thu, 16 Apr 2026 23:31:52 +0800 Subject: [PATCH 19/41] style(plugins): use picocli output streams and address review findings - Replace System.out/err with spec.commandLine().getOut()/getErr() in all keystore commands, consistent with existing db commands pattern - Fix description from "witness account keys" to "account keys" - Unify permission warning for UnsupportedOperationException and IOException - Add Keystore section to plugins/README.md with migration guide - Update tests to use cmd.setOut()/setErr() instead of System.setOut() --- plugins/README.md | 69 ++++++++++++ .../common/org/tron/plugins/Keystore.java | 2 +- .../org/tron/plugins/KeystoreCliUtils.java | 56 +++++----- .../org/tron/plugins/KeystoreImport.java | 40 ++++--- .../common/org/tron/plugins/KeystoreList.java | 31 ++++-- .../common/org/tron/plugins/KeystoreNew.java | 22 ++-- .../org/tron/plugins/KeystoreUpdate.java | 50 +++++---- .../org/tron/plugins/KeystoreListTest.java | 100 ++++++++---------- .../org/tron/plugins/KeystoreNewTest.java | 36 +++---- 9 files changed, 250 insertions(+), 156 deletions(-) diff --git a/plugins/README.md b/plugins/README.md index db25811882f..dc16b3ecf35 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -143,3 +143,72 @@ NOTE: large db may GC overhead limit exceeded. - ``: Source path for database. Default: output-directory/database - `--db`: db name. - `-h | --help`: provide the help info + +## Keystore + +Keystore provides commands for managing account keystore files (Web3 Secret Storage format). + +> **Migrating from `--keystore-factory`**: The legacy `FullNode.jar --keystore-factory` interactive mode is deprecated. Use the Toolkit keystore commands below instead. The mapping is: +> - `GenKeystore` → `keystore new` +> - `ImportPrivateKey` → `keystore import` +> - (new) `keystore list` — list all keystores in a directory +> - (new) `keystore update` — change the password of a keystore + +### Subcommands + +#### keystore new + +Generate a new keystore file with a random keypair. + +```shell script +# full command + java -jar Toolkit.jar keystore new [-h] [--keystore-dir=

] [--password-file=] [--sm2] [--json] +# examples + java -jar Toolkit.jar keystore new # interactive prompt + java -jar Toolkit.jar keystore new --keystore-dir /data/keystores # custom directory + java -jar Toolkit.jar keystore new --password-file pass.txt --json # non-interactive with JSON output +``` + +#### keystore import + +Import a private key into a new keystore file. + +```shell script +# full command + java -jar Toolkit.jar keystore import [-h] [--keystore-dir=] [--password-file=] [--private-key-file=] [--sm2] [--json] +# examples + java -jar Toolkit.jar keystore import # interactive prompt + java -jar Toolkit.jar keystore import --private-key-file key.txt --json # from file with JSON output +``` + +#### keystore list + +List all keystore files in a directory. + +```shell script +# full command + java -jar Toolkit.jar keystore list [-h] [--keystore-dir=] [--json] +# examples + java -jar Toolkit.jar keystore list # list default ./Wallet directory + java -jar Toolkit.jar keystore list --keystore-dir /data/keystores # custom directory +``` + +#### keystore update + +Change the password of a keystore file. + +```shell script +# full command + java -jar Toolkit.jar keystore update [-h]
[--keystore-dir=] [--password-file=] [--new-password-file=] [--json] +# examples + java -jar Toolkit.jar keystore update TXyz...abc # interactive prompt + java -jar Toolkit.jar keystore update TXyz...abc --keystore-dir /data/ks # custom directory +``` + +### Common Options + +- `--keystore-dir`: Keystore directory, default: `./Wallet`. +- `--password-file`: Read password from a file instead of interactive prompt. +- `--sm2`: Use SM2 algorithm instead of ECDSA (for `new` and `import`). +- `--json`: Output in JSON format for scripting. +- `-h | --help`: Provide the help info. diff --git a/plugins/src/main/java/common/org/tron/plugins/Keystore.java b/plugins/src/main/java/common/org/tron/plugins/Keystore.java index 954fa2d346a..6929bb406ea 100644 --- a/plugins/src/main/java/common/org/tron/plugins/Keystore.java +++ b/plugins/src/main/java/common/org/tron/plugins/Keystore.java @@ -6,7 +6,7 @@ @Command(name = "keystore", mixinStandardHelpOptions = true, version = "keystore command 1.0", - description = "Manage keystore files for witness account keys.", + description = "Manage keystore files for account keys.", subcommands = {CommandLine.HelpCommand.class, KeystoreNew.class, KeystoreImport.class, diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java index d3a94a8dd83..3851aa5e8bd 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java @@ -4,6 +4,7 @@ import java.io.Console; import java.io.File; import java.io.IOException; +import java.io.PrintWriter; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -28,15 +29,15 @@ final class KeystoreCliUtils { private KeystoreCliUtils() { } - static String readPassword(File passwordFile) throws IOException { + static String readPassword(File passwordFile, PrintWriter err) throws IOException { if (passwordFile != null) { if (!passwordFile.exists()) { - System.err.println("Password file not found: " + passwordFile.getPath() + err.println("Password file not found: " + passwordFile.getPath() + ". Omit --password-file for interactive input."); return null; } if (passwordFile.length() > MAX_FILE_SIZE) { - System.err.println("Password file too large (max 1KB)."); + err.println("Password file too large (max 1KB)."); return null; } byte[] bytes = Files.readAllBytes(passwordFile.toPath()); @@ -44,7 +45,7 @@ static String readPassword(File passwordFile) throws IOException { String password = stripLineEndings( new String(bytes, StandardCharsets.UTF_8)); if (!WalletUtils.passwordValid(password)) { - System.err.println("Invalid password: must be at least 6 characters."); + err.println("Invalid password: must be at least 6 characters."); return null; } return password; @@ -55,30 +56,30 @@ static String readPassword(File passwordFile) throws IOException { Console console = System.console(); if (console == null) { - System.err.println("No interactive terminal available. " + err.println("No interactive terminal available. " + "Use --password-file to provide password."); return null; } char[] pwd1 = console.readPassword("Enter password: "); if (pwd1 == null) { - System.err.println("Password input cancelled."); + err.println("Password input cancelled."); return null; } char[] pwd2 = console.readPassword("Confirm password: "); if (pwd2 == null) { Arrays.fill(pwd1, '\0'); - System.err.println("Password input cancelled."); + err.println("Password input cancelled."); return null; } try { if (!Arrays.equals(pwd1, pwd2)) { - System.err.println("Passwords do not match."); + err.println("Passwords do not match."); return null; } String password = new String(pwd1); if (!WalletUtils.passwordValid(password)) { - System.err.println("Invalid password: must be at least 6 characters."); + err.println("Invalid password: must be at least 6 characters."); return null; } return password; @@ -105,11 +106,11 @@ static ObjectMapper mapper() { return MAPPER; } - static void printJson(Map fields) { + static void printJson(PrintWriter out, PrintWriter err, Map fields) { try { - System.out.println(MAPPER.writeValueAsString(fields)); + out.println(MAPPER.writeValueAsString(fields)); } catch (Exception e) { - System.err.println("Error writing JSON output"); + err.println("Error writing JSON output"); } } @@ -138,40 +139,39 @@ static String stripLineEndings(String s) { return s.substring(0, end); } - static boolean checkFileExists(File file, String label) { + static boolean checkFileExists(File file, String label, PrintWriter err) { if (file != null && !file.exists()) { - System.err.println(label + " not found: " + file.getPath()); + err.println(label + " not found: " + file.getPath()); return false; } return true; } - static void printSecurityTips(String address, String fileName) { - System.out.println(); - System.out.println("Public address of the key: " + address); - System.out.println("Path of the secret key file: " + fileName); - System.out.println(); - System.out.println( + static void printSecurityTips(PrintWriter out, String address, String fileName) { + out.println(); + out.println("Public address of the key: " + address); + out.println("Path of the secret key file: " + fileName); + out.println(); + out.println( "- You can share your public address with anyone." + " Others need it to interact with you."); - System.out.println( + out.println( "- You must NEVER share the secret key with anyone!" + " The key controls access to your funds!"); - System.out.println( + out.println( "- You must BACKUP your key file!" + " Without the key, it's impossible to access account funds!"); - System.out.println( + out.println( "- You must REMEMBER your password!" + " Without the password, it's impossible to decrypt the key!"); } - static void setOwnerOnly(File file) { + static void setOwnerOnly(File file, PrintWriter err) { try { Files.setPosixFilePermissions(file.toPath(), OWNER_ONLY); - } catch (UnsupportedOperationException e) { - // Windows — skip - } catch (IOException e) { - System.err.println("Warning: could not set file permissions on " + file.getName()); + } catch (UnsupportedOperationException | IOException e) { + err.println("Warning: could not set file permissions on " + file.getName() + + ". Please manually restrict access (e.g. chmod 600)."); } } } diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java index c3f9a78e8e1..6919fbc4bf9 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java @@ -3,6 +3,7 @@ import java.io.Console; import java.io.File; import java.io.IOException; +import java.io.PrintWriter; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.util.Arrays; @@ -15,13 +16,18 @@ import org.tron.keystore.Credentials; import org.tron.keystore.WalletUtils; import picocli.CommandLine.Command; +import picocli.CommandLine.Model.CommandSpec; import picocli.CommandLine.Option; +import picocli.CommandLine.Spec; @Command(name = "import", mixinStandardHelpOptions = true, description = "Import a private key into a new keystore file.") public class KeystoreImport implements Callable { + @Spec + private CommandSpec spec; + @Option(names = {"--keystore-dir"}, description = "Keystore directory (default: ./Wallet)", defaultValue = "Wallet") @@ -49,13 +55,15 @@ public class KeystoreImport implements Callable { @Override public Integer call() { + PrintWriter out = spec.commandLine().getOut(); + PrintWriter err = spec.commandLine().getErr(); try { - if (!KeystoreCliUtils.checkFileExists(keyFile, "Key file")) { + if (!KeystoreCliUtils.checkFileExists(keyFile, "Key file", err)) { return 1; } KeystoreCliUtils.ensureDirectory(keystoreDir); - String privateKey = readPrivateKey(); + String privateKey = readPrivateKey(err); if (privateKey == null) { return 1; } @@ -64,11 +72,11 @@ public Integer call() { privateKey = privateKey.substring(2); } if (!isValidPrivateKey(privateKey)) { - System.err.println("Invalid private key: must be 64 hex characters."); + err.println("Invalid private key: must be 64 hex characters."); return 1; } - String password = KeystoreCliUtils.readPassword(passwordFile); + String password = KeystoreCliUtils.readPassword(passwordFile, err); if (password == null) { return 1; } @@ -79,42 +87,42 @@ public Integer call() { keyPair = SignUtils.fromPrivate( ByteArray.fromHexString(privateKey), ecKey); } catch (Exception e) { - System.err.println("Invalid private key: not a valid key" + err.println("Invalid private key: not a valid key" + " for the selected algorithm."); return 1; } String address = Credentials.create(keyPair).getAddress(); String existingFile = findExistingKeystore(keystoreDir, address); if (existingFile != null && !force) { - System.err.println("Keystore for address " + address + err.println("Keystore for address " + address + " already exists: " + existingFile + ". Use --force to import anyway."); return 1; } String fileName = WalletUtils.generateWalletFile(password, keyPair, keystoreDir, true); - KeystoreCliUtils.setOwnerOnly(new File(keystoreDir, fileName)); + KeystoreCliUtils.setOwnerOnly(new File(keystoreDir, fileName), err); if (json) { - KeystoreCliUtils.printJson(KeystoreCliUtils.jsonMap( + KeystoreCliUtils.printJson(out, err, KeystoreCliUtils.jsonMap( "address", address, "file", fileName)); } else { - System.out.println("Imported keystore successfully"); - KeystoreCliUtils.printSecurityTips(address, + out.println("Imported keystore successfully"); + KeystoreCliUtils.printSecurityTips(out, address, new File(keystoreDir, fileName).getPath()); } return 0; } catch (CipherException e) { - System.err.println("Encryption error: " + e.getMessage()); + err.println("Encryption error: " + e.getMessage()); return 1; } catch (Exception e) { - System.err.println("Error: " + e.getMessage()); + err.println("Error: " + e.getMessage()); return 1; } } - private String readPrivateKey() throws IOException { + private String readPrivateKey(PrintWriter err) throws IOException { if (keyFile != null) { if (keyFile.length() > 1024) { - System.err.println("Key file too large (max 1KB)."); + err.println("Key file too large (max 1KB)."); return null; } byte[] bytes = Files.readAllBytes(keyFile.toPath()); @@ -127,14 +135,14 @@ private String readPrivateKey() throws IOException { Console console = System.console(); if (console == null) { - System.err.println("No interactive terminal available. " + err.println("No interactive terminal available. " + "Use --key-file to provide private key."); return null; } char[] key = console.readPassword("Enter private key (hex): "); if (key == null) { - System.err.println("Input cancelled."); + err.println("Input cancelled."); return null; } try { diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreList.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreList.java index eb28be831b1..d4de950af8f 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreList.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreList.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import java.io.File; +import java.io.PrintWriter; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -9,7 +10,9 @@ import java.util.concurrent.Callable; import org.tron.keystore.WalletFile; import picocli.CommandLine.Command; +import picocli.CommandLine.Model.CommandSpec; import picocli.CommandLine.Option; +import picocli.CommandLine.Spec; @Command(name = "list", mixinStandardHelpOptions = true, @@ -18,6 +21,9 @@ public class KeystoreList implements Callable { private static final ObjectMapper MAPPER = KeystoreCliUtils.mapper(); + @Spec + private CommandSpec spec; + @Option(names = {"--keystore-dir"}, description = "Keystore directory (default: ./Wallet)", defaultValue = "Wallet") @@ -29,11 +35,14 @@ public class KeystoreList implements Callable { @Override public Integer call() { + PrintWriter out = spec.commandLine().getOut(); + PrintWriter err = spec.commandLine().getErr(); + if (!keystoreDir.exists() || !keystoreDir.isDirectory()) { if (json) { - return printEmptyJson(); + return printEmptyJson(out, err); } else { - System.out.println("No keystores found in: " + keystoreDir.getAbsolutePath()); + out.println("No keystores found in: " + keystoreDir.getAbsolutePath()); } return 0; } @@ -41,9 +50,9 @@ public Integer call() { File[] files = keystoreDir.listFiles((dir, name) -> name.endsWith(".json")); if (files == null || files.length == 0) { if (json) { - return printEmptyJson(); + return printEmptyJson(out, err); } else { - System.out.println("No keystores found in: " + keystoreDir.getAbsolutePath()); + out.println("No keystores found in: " + keystoreDir.getAbsolutePath()); } return 0; } @@ -70,29 +79,29 @@ public Integer call() { try { Map result = new LinkedHashMap<>(); result.put("keystores", entries); - System.out.println(MAPPER.writeValueAsString(result)); + out.println(MAPPER.writeValueAsString(result)); } catch (Exception e) { - System.err.println("Error writing JSON output"); + err.println("Error writing JSON output"); return 1; } } else if (entries.isEmpty()) { - System.out.println("No valid keystores found in: " + keystoreDir.getAbsolutePath()); + out.println("No valid keystores found in: " + keystoreDir.getAbsolutePath()); } else { for (Map entry : entries) { - System.out.printf("%-45s %s%n", entry.get("address"), entry.get("file")); + out.printf("%-45s %s%n", entry.get("address"), entry.get("file")); } } return 0; } - private int printEmptyJson() { + private int printEmptyJson(PrintWriter out, PrintWriter err) { try { Map result = new LinkedHashMap<>(); result.put("keystores", new ArrayList<>()); - System.out.println(MAPPER.writeValueAsString(result)); + out.println(MAPPER.writeValueAsString(result)); return 0; } catch (Exception e) { - System.err.println("Error writing JSON output"); + err.println("Error writing JSON output"); return 1; } } diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreNew.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreNew.java index 8016b417a34..ed7651105ba 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreNew.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreNew.java @@ -1,6 +1,7 @@ package org.tron.plugins; import java.io.File; +import java.io.PrintWriter; import java.util.concurrent.Callable; import org.tron.common.crypto.SignInterface; import org.tron.common.crypto.SignUtils; @@ -9,13 +10,18 @@ import org.tron.keystore.Credentials; import org.tron.keystore.WalletUtils; import picocli.CommandLine.Command; +import picocli.CommandLine.Model.CommandSpec; import picocli.CommandLine.Option; +import picocli.CommandLine.Spec; @Command(name = "new", mixinStandardHelpOptions = true, description = "Generate a new keystore file with a random keypair.") public class KeystoreNew implements Callable { + @Spec + private CommandSpec spec; + @Option(names = {"--keystore-dir"}, description = "Keystore directory (default: ./Wallet)", defaultValue = "Wallet") @@ -35,10 +41,12 @@ public class KeystoreNew implements Callable { @Override public Integer call() { + PrintWriter out = spec.commandLine().getOut(); + PrintWriter err = spec.commandLine().getErr(); try { KeystoreCliUtils.ensureDirectory(keystoreDir); - String password = KeystoreCliUtils.readPassword(passwordFile); + String password = KeystoreCliUtils.readPassword(passwordFile, err); if (password == null) { return 1; } @@ -46,23 +54,23 @@ public Integer call() { boolean ecKey = !sm2; SignInterface keyPair = SignUtils.getGeneratedRandomSign(Utils.getRandom(), ecKey); String fileName = WalletUtils.generateWalletFile(password, keyPair, keystoreDir, true); - KeystoreCliUtils.setOwnerOnly(new File(keystoreDir, fileName)); + KeystoreCliUtils.setOwnerOnly(new File(keystoreDir, fileName), err); String address = Credentials.create(keyPair).getAddress(); if (json) { - KeystoreCliUtils.printJson(KeystoreCliUtils.jsonMap( + KeystoreCliUtils.printJson(out, err, KeystoreCliUtils.jsonMap( "address", address, "file", fileName)); } else { - System.out.println("Your new key was generated"); - KeystoreCliUtils.printSecurityTips(address, + out.println("Your new key was generated"); + KeystoreCliUtils.printSecurityTips(out, address, new File(keystoreDir, fileName).getPath()); } return 0; } catch (CipherException e) { - System.err.println("Encryption error: " + e.getMessage()); + err.println("Encryption error: " + e.getMessage()); return 1; } catch (Exception e) { - System.err.println("Error: " + e.getMessage()); + err.println("Error: " + e.getMessage()); return 1; } } diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java index 09009795662..9b88668c102 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java @@ -4,6 +4,7 @@ import java.io.Console; import java.io.File; import java.io.IOException; +import java.io.PrintWriter; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.StandardCopyOption; @@ -15,8 +16,10 @@ import org.tron.keystore.WalletFile; import org.tron.keystore.WalletUtils; import picocli.CommandLine.Command; +import picocli.CommandLine.Model.CommandSpec; import picocli.CommandLine.Option; import picocli.CommandLine.Parameters; +import picocli.CommandLine.Spec; @Command(name = "update", mixinStandardHelpOptions = true, @@ -26,6 +29,9 @@ public class KeystoreUpdate implements Callable { private static final ObjectMapper MAPPER = KeystoreCliUtils.mapper(); private static final String INPUT_CANCELLED = "Password input cancelled."; + @Spec + private CommandSpec spec; + @Parameters(index = "0", description = "Address of the keystore to update") private String address; @@ -48,10 +54,12 @@ public class KeystoreUpdate implements Callable { @Override public Integer call() { + PrintWriter out = spec.commandLine().getOut(); + PrintWriter err = spec.commandLine().getErr(); try { - File keystoreFile = findKeystoreByAddress(address); + File keystoreFile = findKeystoreByAddress(address, err); if (keystoreFile == null) { - System.err.println("No keystore found for address: " + address); + err.println("No keystore found for address: " + address); return 1; } @@ -60,12 +68,12 @@ public Integer call() { if (passwordFile != null) { if (!passwordFile.exists()) { - System.err.println("Password file not found: " + passwordFile.getPath() + err.println("Password file not found: " + passwordFile.getPath() + ". Omit --password-file for interactive input."); return 1; } if (passwordFile.length() > 1024) { - System.err.println("Password file too large (max 1KB)."); + err.println("Password file too large (max 1KB)."); return 1; } byte[] bytes = Files.readAllBytes(passwordFile.toPath()); @@ -77,7 +85,7 @@ public Integer call() { } String[] lines = content.split("\\r?\\n"); if (lines.length < 2) { - System.err.println( + err.println( "Password file must contain old and new passwords" + " on separate lines."); return 1; @@ -90,26 +98,26 @@ public Integer call() { } else { Console console = System.console(); if (console == null) { - System.err.println("No interactive terminal available. " + err.println("No interactive terminal available. " + "Use --password-file to provide passwords."); return 1; } char[] oldPwd = console.readPassword("Enter current password: "); if (oldPwd == null) { - System.err.println(INPUT_CANCELLED); + err.println(INPUT_CANCELLED); return 1; } char[] newPwd = console.readPassword("Enter new password: "); if (newPwd == null) { Arrays.fill(oldPwd, '\0'); - System.err.println(INPUT_CANCELLED); + err.println(INPUT_CANCELLED); return 1; } char[] confirmPwd = console.readPassword("Confirm new password: "); if (confirmPwd == null) { Arrays.fill(oldPwd, '\0'); Arrays.fill(newPwd, '\0'); - System.err.println(INPUT_CANCELLED); + err.println(INPUT_CANCELLED); return 1; } try { @@ -117,7 +125,7 @@ public Integer call() { newPassword = new String(newPwd); String confirmPassword = new String(confirmPwd); if (!newPassword.equals(confirmPassword)) { - System.err.println("New passwords do not match."); + err.println("New passwords do not match."); return 1; } } finally { @@ -129,7 +137,7 @@ public Integer call() { // Skip validation on old password: keystore may predate the minimum-length policy if (!WalletUtils.passwordValid(newPassword)) { - System.err.println("Invalid new password: must be at least 6 characters."); + err.println("Invalid new password: must be at least 6 characters."); return 1; } @@ -143,7 +151,7 @@ public Integer call() { File tempFile = File.createTempFile("keystore-", ".tmp", keystoreFile.getParentFile()); try { - KeystoreCliUtils.setOwnerOnly(tempFile); + KeystoreCliUtils.setOwnerOnly(tempFile, err); MAPPER.writeValue(tempFile, newWalletFile); try { Files.move(tempFile.toPath(), keystoreFile.toPath(), @@ -156,31 +164,31 @@ public Integer call() { } } catch (Exception e) { if (!tempFile.delete()) { - System.err.println("Warning: could not delete temp file: " + err.println("Warning: could not delete temp file: " + tempFile.getName()); } throw e; } if (json) { - KeystoreCliUtils.printJson(KeystoreCliUtils.jsonMap( + KeystoreCliUtils.printJson(out, err, KeystoreCliUtils.jsonMap( "address", walletFile.getAddress(), "file", keystoreFile.getName(), "status", "updated")); } else { - System.out.println("Password updated for: " + walletFile.getAddress()); + out.println("Password updated for: " + walletFile.getAddress()); } return 0; } catch (CipherException e) { - System.err.println("Decryption failed: " + e.getMessage()); + err.println("Decryption failed: " + e.getMessage()); return 1; } catch (Exception e) { - System.err.println("Error: " + e.getMessage()); + err.println("Error: " + e.getMessage()); return 1; } } - private File findKeystoreByAddress(String targetAddress) { + private File findKeystoreByAddress(String targetAddress, PrintWriter err) { if (!keystoreDir.exists() || !keystoreDir.isDirectory()) { return null; } @@ -200,12 +208,12 @@ private File findKeystoreByAddress(String targetAddress) { } } if (matches.size() > 1) { - System.err.println("Multiple keystores found for address " + err.println("Multiple keystores found for address " + targetAddress + ":"); for (File m : matches) { - System.err.println(" " + m.getName()); + err.println(" " + m.getName()); } - System.err.println("Please remove duplicates and retry."); + err.println("Please remove duplicates and retry."); return null; } return matches.isEmpty() ? null : matches.get(0); diff --git a/plugins/src/test/java/org/tron/plugins/KeystoreListTest.java b/plugins/src/test/java/org/tron/plugins/KeystoreListTest.java index dbd1c9f065a..398c98a6043 100644 --- a/plugins/src/test/java/org/tron/plugins/KeystoreListTest.java +++ b/plugins/src/test/java/org/tron/plugins/KeystoreListTest.java @@ -3,9 +3,9 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; -import java.io.ByteArrayOutputStream; import java.io.File; -import java.io.PrintStream; +import java.io.PrintWriter; +import java.io.StringWriter; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.security.SecureRandom; @@ -34,23 +34,21 @@ public void testListMultipleKeystores() throws Exception { WalletUtils.generateWalletFile(password, key, dir, false); } - PrintStream originalOut = System.out; - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - System.setOut(new PrintStream(baos)); - try { - CommandLine cmd = new CommandLine(new Toolkit()); - int exitCode = cmd.execute("keystore", "list", - "--keystore-dir", dir.getAbsolutePath()); - - assertEquals(0, exitCode); - String output = baos.toString(StandardCharsets.UTF_8.name()).trim(); - assertTrue("Output should not be empty", output.length() > 0); - // Should have 3 lines of output (one per keystore) - String[] lines = output.split("\\n"); - assertEquals("Should list 3 keystores", 3, lines.length); - } finally { - System.setOut(originalOut); - } + StringWriter out = new StringWriter(); + StringWriter err = new StringWriter(); + CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setOut(new PrintWriter(out)); + cmd.setErr(new PrintWriter(err)); + + int exitCode = cmd.execute("keystore", "list", + "--keystore-dir", dir.getAbsolutePath()); + + assertEquals(0, exitCode); + String output = out.toString().trim(); + assertTrue("Output should not be empty", output.length() > 0); + // Should have 3 lines of output (one per keystore) + String[] lines = output.split("\\n"); + assertEquals("Should list 3 keystores", 3, lines.length); } @Test @@ -83,23 +81,21 @@ public void testListJsonOutput() throws Exception { SecureRandom.getInstance("NativePRNG"), true); WalletUtils.generateWalletFile(password, key, dir, false); - PrintStream originalOut = System.out; - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - System.setOut(new PrintStream(baos)); - try { - CommandLine cmd = new CommandLine(new Toolkit()); - int exitCode = cmd.execute("keystore", "list", - "--keystore-dir", dir.getAbsolutePath(), "--json"); - - assertEquals(0, exitCode); - String output = baos.toString(StandardCharsets.UTF_8.name()).trim(); - assertTrue("Should start with keystores JSON array", - output.startsWith("{\"keystores\":[")); - assertTrue("Should end with JSON array close", - output.endsWith("]}")); - } finally { - System.setOut(originalOut); - } + StringWriter out = new StringWriter(); + StringWriter err = new StringWriter(); + CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setOut(new PrintWriter(out)); + cmd.setErr(new PrintWriter(err)); + + int exitCode = cmd.execute("keystore", "list", + "--keystore-dir", dir.getAbsolutePath(), "--json"); + + assertEquals(0, exitCode); + String output = out.toString().trim(); + assertTrue("Should start with keystores JSON array", + output.startsWith("{\"keystores\":[")); + assertTrue("Should end with JSON array close", + output.endsWith("]}")); } @Test @@ -118,22 +114,20 @@ public void testListSkipsNonKeystoreFiles() throws Exception { Files.write(new File(dir, "notes.txt").toPath(), "plain text".getBytes(StandardCharsets.UTF_8)); - PrintStream originalOut = System.out; - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - System.setOut(new PrintStream(baos)); - try { - CommandLine cmd = new CommandLine(new Toolkit()); - int exitCode = cmd.execute("keystore", "list", - "--keystore-dir", dir.getAbsolutePath()); - - assertEquals(0, exitCode); - String output = baos.toString(StandardCharsets.UTF_8.name()).trim(); - assertTrue("Output should not be empty", output.length() > 0); - String[] lines = output.split("\\n"); - // Should list only the valid keystore, not the readme.json or notes.txt - assertEquals("Should list only 1 valid keystore", 1, lines.length); - } finally { - System.setOut(originalOut); - } + StringWriter out = new StringWriter(); + StringWriter err = new StringWriter(); + CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setOut(new PrintWriter(out)); + cmd.setErr(new PrintWriter(err)); + + int exitCode = cmd.execute("keystore", "list", + "--keystore-dir", dir.getAbsolutePath()); + + assertEquals(0, exitCode); + String output = out.toString().trim(); + assertTrue("Output should not be empty", output.length() > 0); + String[] lines = output.split("\\n"); + // Should list only the valid keystore, not the readme.json or notes.txt + assertEquals("Should list only 1 valid keystore", 1, lines.length); } } diff --git a/plugins/src/test/java/org/tron/plugins/KeystoreNewTest.java b/plugins/src/test/java/org/tron/plugins/KeystoreNewTest.java index 8304e9596f0..1efbc382ede 100644 --- a/plugins/src/test/java/org/tron/plugins/KeystoreNewTest.java +++ b/plugins/src/test/java/org/tron/plugins/KeystoreNewTest.java @@ -55,25 +55,23 @@ public void testNewKeystoreJsonOutput() throws Exception { File pwFile = tempFolder.newFile("password-json.txt"); Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); - java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); - java.io.PrintStream originalOut = System.out; - System.setOut(new java.io.PrintStream(baos)); - try { - CommandLine cmd = new CommandLine(new Toolkit()); - int exitCode = cmd.execute("keystore", "new", - "--keystore-dir", dir.getAbsolutePath(), - "--password-file", pwFile.getAbsolutePath(), - "--json"); - - assertEquals(0, exitCode); - String output = baos.toString(StandardCharsets.UTF_8.name()).trim(); - assertTrue("JSON output should contain address", - output.contains("\"address\"")); - assertTrue("JSON output should contain file", - output.contains("\"file\"")); - } finally { - System.setOut(originalOut); - } + StringWriter out = new StringWriter(); + StringWriter err = new StringWriter(); + CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setOut(new PrintWriter(out)); + cmd.setErr(new PrintWriter(err)); + + int exitCode = cmd.execute("keystore", "new", + "--keystore-dir", dir.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath(), + "--json"); + + assertEquals(0, exitCode); + String output = out.toString().trim(); + assertTrue("JSON output should contain address", + output.contains("\"address\"")); + assertTrue("JSON output should contain file", + output.contains("\"file\"")); } @Test From cf823dfde95c2bc812f04db8ef67655edb541a3e Mon Sep 17 00:00:00 2001 From: Barbatos Date: Thu, 16 Apr 2026 23:34:14 +0800 Subject: [PATCH 20/41] fix(plugins): secure keystore file creation and improve robustness - Fix TOCTOU race: keystore files were world-readable between creation and permission setting. Now use temp-file + setOwnerOnly + atomic-rename pattern (already used by update command) for new and import commands - Fix KeystoreUpdate password file parsing: apply stripLineEndings to both old and new passwords to handle old-Mac line endings - Warn on unreadable JSON files during keystore lookup instead of silently skipping, so users are aware of corrupted files - Make WalletUtils.getWalletFileName public for reuse - Extract atomicMove utility to KeystoreCliUtils for shared use --- .../java/org/tron/keystore/WalletUtils.java | 2 +- .../org/tron/plugins/KeystoreCliUtils.java | 56 ++++++++++++++++++- .../org/tron/plugins/KeystoreImport.java | 15 +++-- .../common/org/tron/plugins/KeystoreList.java | 2 +- .../common/org/tron/plugins/KeystoreNew.java | 5 +- .../org/tron/plugins/KeystoreUpdate.java | 17 ++---- 6 files changed, 70 insertions(+), 27 deletions(-) diff --git a/crypto/src/main/java/org/tron/keystore/WalletUtils.java b/crypto/src/main/java/org/tron/keystore/WalletUtils.java index 6aa546a4e90..ad4f434005f 100644 --- a/crypto/src/main/java/org/tron/keystore/WalletUtils.java +++ b/crypto/src/main/java/org/tron/keystore/WalletUtils.java @@ -81,7 +81,7 @@ public static Credentials loadCredentials(String password, File source, boolean return Credentials.create(Wallet.decrypt(password, walletFile, ecKey)); } - private static String getWalletFileName(WalletFile walletFile) { + public static String getWalletFileName(WalletFile walletFile) { DateTimeFormatter format = DateTimeFormatter.ofPattern( "'UTC--'yyyy-MM-dd'T'HH-mm-ss.nVV'--'"); ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC); diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java index 3851aa5e8bd..c28736f6dd7 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java @@ -8,12 +8,17 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.nio.file.attribute.PosixFilePermission; import java.util.Arrays; import java.util.EnumSet; import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; +import org.tron.common.crypto.SignInterface; +import org.tron.core.exception.CipherException; +import org.tron.keystore.Wallet; +import org.tron.keystore.WalletFile; import org.tron.keystore.WalletUtils; /** @@ -29,6 +34,55 @@ final class KeystoreCliUtils { private KeystoreCliUtils() { } + /** + * Generate a keystore file using temp-file + atomic-rename to avoid + * a TOCTOU window where the file is world-readable before permissions are set. + * + * @return the final keystore file name (not the full path) + */ + static String generateKeystoreFile(String password, SignInterface keyPair, + File destDir, boolean useFullScrypt, PrintWriter err) + throws CipherException, IOException { + + WalletFile walletFile; + if (useFullScrypt) { + walletFile = Wallet.createStandard(password, keyPair); + } else { + walletFile = Wallet.createLight(password, keyPair); + } + + String fileName = WalletUtils.getWalletFileName(walletFile); + File destination = new File(destDir, fileName); + + File tempFile = File.createTempFile("keystore-", ".tmp", destDir); + try { + setOwnerOnly(tempFile, err); + MAPPER.writeValue(tempFile, walletFile); + atomicMove(tempFile, destination); + } catch (Exception e) { + if (!tempFile.delete()) { + err.println("Warning: could not delete temp file: " + tempFile.getName()); + } + throw e; + } + + return fileName; + } + + /** + * Atomic move with fallback for filesystems that don't support it. + */ + static void atomicMove(File source, File target) throws IOException { + try { + Files.move(source.toPath(), target.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE); + } catch (java.nio.file.AtomicMoveNotSupportedException e) { + Files.move(source.toPath(), target.toPath(), + StandardCopyOption.REPLACE_EXISTING); + } + } + static String readPassword(File passwordFile, PrintWriter err) throws IOException { if (passwordFile != null) { if (!passwordFile.exists()) { @@ -171,7 +225,7 @@ static void setOwnerOnly(File file, PrintWriter err) { Files.setPosixFilePermissions(file.toPath(), OWNER_ONLY); } catch (UnsupportedOperationException | IOException e) { err.println("Warning: could not set file permissions on " + file.getName() - + ". Please manually restrict access (e.g. chmod 600)."); + + ". Please manually restrict access to this file."); } } } diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java index 6919fbc4bf9..90d0cd30c64 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java @@ -14,7 +14,7 @@ import org.tron.common.utils.ByteArray; import org.tron.core.exception.CipherException; import org.tron.keystore.Credentials; -import org.tron.keystore.WalletUtils; +import org.tron.keystore.WalletFile; import picocli.CommandLine.Command; import picocli.CommandLine.Model.CommandSpec; import picocli.CommandLine.Option; @@ -92,15 +92,15 @@ public Integer call() { return 1; } String address = Credentials.create(keyPair).getAddress(); - String existingFile = findExistingKeystore(keystoreDir, address); + String existingFile = findExistingKeystore(keystoreDir, address, err); if (existingFile != null && !force) { err.println("Keystore for address " + address + " already exists: " + existingFile + ". Use --force to import anyway."); return 1; } - String fileName = WalletUtils.generateWalletFile(password, keyPair, keystoreDir, true); - KeystoreCliUtils.setOwnerOnly(new File(keystoreDir, fileName), err); + String fileName = KeystoreCliUtils.generateKeystoreFile( + password, keyPair, keystoreDir, true, err); if (json) { KeystoreCliUtils.printJson(out, err, KeystoreCliUtils.jsonMap( "address", address, "file", fileName)); @@ -159,7 +159,7 @@ private boolean isValidPrivateKey(String key) { return !StringUtils.isEmpty(key) && HEX_PATTERN.matcher(key).matches(); } - private String findExistingKeystore(File dir, String address) { + private String findExistingKeystore(File dir, String address, PrintWriter err) { if (!dir.exists() || !dir.isDirectory()) { return null; } @@ -171,13 +171,12 @@ private String findExistingKeystore(File dir, String address) { KeystoreCliUtils.mapper(); for (File file : files) { try { - org.tron.keystore.WalletFile wf = - mapper.readValue(file, org.tron.keystore.WalletFile.class); + WalletFile wf = mapper.readValue(file, WalletFile.class); if (address.equals(wf.getAddress())) { return file.getName(); } } catch (Exception e) { - // Skip invalid files + err.println("Warning: skipping unreadable file: " + file.getName()); } } return null; diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreList.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreList.java index d4de950af8f..80a299402f4 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreList.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreList.java @@ -71,7 +71,7 @@ public Integer call() { entry.put("file", file.getName()); entries.add(entry); } catch (Exception e) { - // Skip files that aren't valid keystore JSON + err.println("Warning: skipping unreadable file: " + file.getName()); } } diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreNew.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreNew.java index ed7651105ba..c154d81d95d 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreNew.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreNew.java @@ -8,7 +8,6 @@ import org.tron.common.utils.Utils; import org.tron.core.exception.CipherException; import org.tron.keystore.Credentials; -import org.tron.keystore.WalletUtils; import picocli.CommandLine.Command; import picocli.CommandLine.Model.CommandSpec; import picocli.CommandLine.Option; @@ -53,8 +52,8 @@ public Integer call() { boolean ecKey = !sm2; SignInterface keyPair = SignUtils.getGeneratedRandomSign(Utils.getRandom(), ecKey); - String fileName = WalletUtils.generateWalletFile(password, keyPair, keystoreDir, true); - KeystoreCliUtils.setOwnerOnly(new File(keystoreDir, fileName), err); + String fileName = KeystoreCliUtils.generateKeystoreFile( + password, keyPair, keystoreDir, true, err); String address = Credentials.create(keyPair).getAddress(); if (json) { diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java index 9b88668c102..8959b00921f 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java @@ -7,7 +7,6 @@ import java.io.PrintWriter; import java.nio.charset.StandardCharsets; import java.nio.file.Files; -import java.nio.file.StandardCopyOption; import java.util.Arrays; import java.util.concurrent.Callable; import org.tron.common.crypto.SignInterface; @@ -90,8 +89,8 @@ public Integer call() { + " on separate lines."); return 1; } - oldPassword = lines[0]; - newPassword = lines[1]; + oldPassword = KeystoreCliUtils.stripLineEndings(lines[0]); + newPassword = KeystoreCliUtils.stripLineEndings(lines[1]); } finally { Arrays.fill(bytes, (byte) 0); } @@ -153,15 +152,7 @@ public Integer call() { try { KeystoreCliUtils.setOwnerOnly(tempFile, err); MAPPER.writeValue(tempFile, newWalletFile); - try { - Files.move(tempFile.toPath(), keystoreFile.toPath(), - StandardCopyOption.REPLACE_EXISTING, - StandardCopyOption.ATOMIC_MOVE); - } catch (java.nio.file.AtomicMoveNotSupportedException e) { - // Fallback for NFS, FAT32, cross-partition - Files.move(tempFile.toPath(), keystoreFile.toPath(), - StandardCopyOption.REPLACE_EXISTING); - } + KeystoreCliUtils.atomicMove(tempFile, keystoreFile); } catch (Exception e) { if (!tempFile.delete()) { err.println("Warning: could not delete temp file: " @@ -204,7 +195,7 @@ private File findKeystoreByAddress(String targetAddress, PrintWriter err) { matches.add(file); } } catch (Exception e) { - // Skip invalid files + err.println("Warning: skipping unreadable file: " + file.getName()); } } if (matches.size() > 1) { From ce482cb787e34d7117a0d9694fee7f90fd8d7484 Mon Sep 17 00:00:00 2001 From: Barbatos Date: Thu, 16 Apr 2026 23:36:35 +0800 Subject: [PATCH 21/41] test(plugins): improve keystore test coverage and assertions - Add tests for update: JSON output, single-line password file, no TTY, password file not found, SM2 keystore, multiple same-address keystores, large password file, BOM password file - Add tests for import: 0x/0X prefix key, corrupted file warning, POSIX file permissions - Add tests for new: large password file, BOM password file, POSIX file permissions - Add tests for list: empty/nonexistent directory JSON output, corrupted file warning, output content verification - Strengthen existing tests with error message assertions to prevent false passes from wrong failure reasons --- .../org/tron/plugins/KeystoreImportTest.java | 122 ++++++++ .../org/tron/plugins/KeystoreListTest.java | 82 ++++- .../org/tron/plugins/KeystoreNewTest.java | 76 ++++- .../org/tron/plugins/KeystoreUpdateTest.java | 294 +++++++++++++++++- 4 files changed, 565 insertions(+), 9 deletions(-) diff --git a/plugins/src/test/java/org/tron/plugins/KeystoreImportTest.java b/plugins/src/test/java/org/tron/plugins/KeystoreImportTest.java index 59c9a53d162..f4ba941fbb0 100644 --- a/plugins/src/test/java/org/tron/plugins/KeystoreImportTest.java +++ b/plugins/src/test/java/org/tron/plugins/KeystoreImportTest.java @@ -193,12 +193,16 @@ public void testImportDuplicateAddressBlocked() throws Exception { "--password-file", pwFile.getAbsolutePath())); // Second import of same key is blocked + java.io.StringWriter err = new java.io.StringWriter(); CommandLine cmd2 = new CommandLine(new Toolkit()); + cmd2.setErr(new java.io.PrintWriter(err)); assertEquals("Duplicate import should be blocked", 1, cmd2.execute("keystore", "import", "--keystore-dir", dir.getAbsolutePath(), "--key-file", keyFile.getAbsolutePath(), "--password-file", pwFile.getAbsolutePath())); + assertTrue("Error should mention already exists", + err.toString().contains("already exists")); File[] files = dir.listFiles((d, name) -> name.endsWith(".json")); assertNotNull(files); @@ -251,4 +255,122 @@ public void testImportKeyFileNotFound() throws Exception { assertEquals("Should fail when key file not found", 1, exitCode); } + + @Test + public void testImportWith0xPrefix() throws Exception { + File dir = tempFolder.newFolder("keystore-0x"); + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + String privateKeyHex = ByteArray.toHexString(keyPair.getPrivateKey()); + String expectedAddress = Credentials.create(keyPair).getAddress(); + + File keyFile = tempFolder.newFile("0x.key"); + Files.write(keyFile.toPath(), + ("0x" + privateKeyHex).getBytes(StandardCharsets.UTF_8)); + File pwFile = tempFolder.newFile("pw-0x.txt"); + Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + + CommandLine cmd = new CommandLine(new Toolkit()); + int exitCode = cmd.execute("keystore", "import", + "--keystore-dir", dir.getAbsolutePath(), + "--key-file", keyFile.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals("Import with 0x prefix should succeed", 0, exitCode); + File[] files = dir.listFiles((d, name) -> name.endsWith(".json")); + assertNotNull(files); + assertEquals(1, files.length); + Credentials creds = WalletUtils.loadCredentials("test123456", files[0], true); + assertEquals("Address must match", expectedAddress, creds.getAddress()); + } + + @Test + public void testImportWith0XUppercasePrefix() throws Exception { + File dir = tempFolder.newFolder("keystore-0X"); + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + String privateKeyHex = ByteArray.toHexString(keyPair.getPrivateKey()); + + File keyFile = tempFolder.newFile("0X.key"); + Files.write(keyFile.toPath(), + ("0X" + privateKeyHex).getBytes(StandardCharsets.UTF_8)); + File pwFile = tempFolder.newFile("pw-0X.txt"); + Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + + CommandLine cmd = new CommandLine(new Toolkit()); + int exitCode = cmd.execute("keystore", "import", + "--keystore-dir", dir.getAbsolutePath(), + "--key-file", keyFile.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals("Import with 0X prefix should succeed", 0, exitCode); + } + + @Test + public void testImportWarnsOnCorruptedFile() throws Exception { + File dir = tempFolder.newFolder("keystore-corrupt"); + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + String privateKeyHex = ByteArray.toHexString(keyPair.getPrivateKey()); + + // Create a corrupted JSON in the keystore dir + Files.write(new File(dir, "corrupted.json").toPath(), + "not valid json{{{".getBytes(StandardCharsets.UTF_8)); + + File keyFile = tempFolder.newFile("warn.key"); + Files.write(keyFile.toPath(), privateKeyHex.getBytes(StandardCharsets.UTF_8)); + File pwFile = tempFolder.newFile("pw-warn.txt"); + Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + + java.io.StringWriter out = new java.io.StringWriter(); + java.io.StringWriter err = new java.io.StringWriter(); + CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setOut(new java.io.PrintWriter(out)); + cmd.setErr(new java.io.PrintWriter(err)); + int exitCode = cmd.execute("keystore", "import", + "--keystore-dir", dir.getAbsolutePath(), + "--key-file", keyFile.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals(0, exitCode); + String errOutput = err.toString(); + assertTrue("Should warn about corrupted file", + errOutput.contains("Warning: skipping unreadable file: corrupted.json")); + } + + @Test + public void testImportKeystoreFilePermissions() throws Exception { + String os = System.getProperty("os.name").toLowerCase(); + org.junit.Assume.assumeTrue("POSIX permissions test, skip on Windows", + !os.contains("win")); + + File dir = tempFolder.newFolder("keystore-perms"); + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + String privateKeyHex = ByteArray.toHexString(keyPair.getPrivateKey()); + + File keyFile = tempFolder.newFile("perm.key"); + Files.write(keyFile.toPath(), privateKeyHex.getBytes(StandardCharsets.UTF_8)); + File pwFile = tempFolder.newFile("pw-perm.txt"); + Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + + CommandLine cmd = new CommandLine(new Toolkit()); + int exitCode = cmd.execute("keystore", "import", + "--keystore-dir", dir.getAbsolutePath(), + "--key-file", keyFile.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals(0, exitCode); + File[] files = dir.listFiles((d, name) -> name.endsWith(".json")); + assertNotNull(files); + assertEquals(1, files.length); + + java.util.Set perms = + Files.getPosixFilePermissions(files[0].toPath()); + assertEquals("Keystore file should have owner-only permissions (rw-------)", + java.util.EnumSet.of( + java.nio.file.attribute.PosixFilePermission.OWNER_READ, + java.nio.file.attribute.PosixFilePermission.OWNER_WRITE), + perms); + } } diff --git a/plugins/src/test/java/org/tron/plugins/KeystoreListTest.java b/plugins/src/test/java/org/tron/plugins/KeystoreListTest.java index 398c98a6043..2fa7d858ab9 100644 --- a/plugins/src/test/java/org/tron/plugins/KeystoreListTest.java +++ b/plugins/src/test/java/org/tron/plugins/KeystoreListTest.java @@ -39,7 +39,6 @@ public void testListMultipleKeystores() throws Exception { CommandLine cmd = new CommandLine(new Toolkit()); cmd.setOut(new PrintWriter(out)); cmd.setErr(new PrintWriter(err)); - int exitCode = cmd.execute("keystore", "list", "--keystore-dir", dir.getAbsolutePath()); @@ -49,28 +48,75 @@ public void testListMultipleKeystores() throws Exception { // Should have 3 lines of output (one per keystore) String[] lines = output.split("\\n"); assertEquals("Should list 3 keystores", 3, lines.length); + // Each line should contain a T-address and a .json filename + for (String line : lines) { + assertTrue("Each line should contain an address starting with T", + line.trim().startsWith("T")); + assertTrue("Each line should reference a .json file", + line.contains(".json")); + } } @Test public void testListEmptyDirectory() throws Exception { File dir = tempFolder.newFolder("empty"); + StringWriter out = new StringWriter(); CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setOut(new PrintWriter(out)); int exitCode = cmd.execute("keystore", "list", "--keystore-dir", dir.getAbsolutePath()); assertEquals(0, exitCode); + assertTrue("Should print no-keystores message", + out.toString().contains("No keystores found")); } @Test public void testListNonExistentDirectory() throws Exception { File dir = new File(tempFolder.getRoot(), "nonexistent"); + StringWriter out = new StringWriter(); CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setOut(new PrintWriter(out)); int exitCode = cmd.execute("keystore", "list", "--keystore-dir", dir.getAbsolutePath()); assertEquals(0, exitCode); + assertTrue("Should print no-keystores message", + out.toString().contains("No keystores found")); + } + + @Test + public void testListEmptyDirectoryJsonOutput() throws Exception { + File dir = tempFolder.newFolder("empty-json"); + + StringWriter out = new StringWriter(); + CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setOut(new PrintWriter(out)); + int exitCode = cmd.execute("keystore", "list", + "--keystore-dir", dir.getAbsolutePath(), "--json"); + + assertEquals(0, exitCode); + String output = out.toString().trim(); + assertTrue("Empty dir JSON should have empty keystores array", + output.contains("{\"keystores\":[]}")); + } + + @Test + public void testListNonExistentDirectoryJsonOutput() throws Exception { + File dir = new File(tempFolder.getRoot(), "nonexistent-json"); + + StringWriter out = new StringWriter(); + CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setOut(new PrintWriter(out)); + int exitCode = cmd.execute("keystore", "list", + "--keystore-dir", dir.getAbsolutePath(), "--json"); + + assertEquals(0, exitCode); + String output = out.toString().trim(); + assertTrue("Non-existent dir JSON should have empty keystores array", + output.contains("{\"keystores\":[]}")); } @Test @@ -86,7 +132,6 @@ public void testListJsonOutput() throws Exception { CommandLine cmd = new CommandLine(new Toolkit()); cmd.setOut(new PrintWriter(out)); cmd.setErr(new PrintWriter(err)); - int exitCode = cmd.execute("keystore", "list", "--keystore-dir", dir.getAbsolutePath(), "--json"); @@ -119,7 +164,6 @@ public void testListSkipsNonKeystoreFiles() throws Exception { CommandLine cmd = new CommandLine(new Toolkit()); cmd.setOut(new PrintWriter(out)); cmd.setErr(new PrintWriter(err)); - int exitCode = cmd.execute("keystore", "list", "--keystore-dir", dir.getAbsolutePath()); @@ -130,4 +174,36 @@ public void testListSkipsNonKeystoreFiles() throws Exception { // Should list only the valid keystore, not the readme.json or notes.txt assertEquals("Should list only 1 valid keystore", 1, lines.length); } + + @Test + public void testListWarnsOnCorruptedJsonFiles() throws Exception { + File dir = tempFolder.newFolder("keystore-corrupt"); + String password = "test123456"; + + // Create one valid keystore + SignInterface key = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + WalletUtils.generateWalletFile(password, key, dir, false); + + // Create a corrupted JSON file + Files.write(new File(dir, "corrupted.json").toPath(), + "not valid json{{{".getBytes(StandardCharsets.UTF_8)); + + StringWriter out = new StringWriter(); + StringWriter err = new StringWriter(); + CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setOut(new PrintWriter(out)); + cmd.setErr(new PrintWriter(err)); + int exitCode = cmd.execute("keystore", "list", + "--keystore-dir", dir.getAbsolutePath()); + + assertEquals(0, exitCode); + String errOutput = err.toString(); + assertTrue("Should warn about corrupted file", + errOutput.contains("Warning: skipping unreadable file: corrupted.json")); + + // Valid keystore should still be listed + String output = out.toString().trim(); + assertTrue("Should still list the valid keystore", output.length() > 0); + } } diff --git a/plugins/src/test/java/org/tron/plugins/KeystoreNewTest.java b/plugins/src/test/java/org/tron/plugins/KeystoreNewTest.java index 1efbc382ede..94705cfc84c 100644 --- a/plugins/src/test/java/org/tron/plugins/KeystoreNewTest.java +++ b/plugins/src/test/java/org/tron/plugins/KeystoreNewTest.java @@ -60,7 +60,6 @@ public void testNewKeystoreJsonOutput() throws Exception { CommandLine cmd = new CommandLine(new Toolkit()); cmd.setOut(new PrintWriter(out)); cmd.setErr(new PrintWriter(err)); - int exitCode = cmd.execute("keystore", "new", "--keystore-dir", dir.getAbsolutePath(), "--password-file", pwFile.getAbsolutePath(), @@ -80,12 +79,16 @@ public void testNewKeystoreInvalidPassword() throws Exception { File pwFile = tempFolder.newFile("short.txt"); Files.write(pwFile.toPath(), "abc".getBytes(StandardCharsets.UTF_8)); + StringWriter err = new StringWriter(); CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setErr(new PrintWriter(err)); int exitCode = cmd.execute("keystore", "new", "--keystore-dir", dir.getAbsolutePath(), "--password-file", pwFile.getAbsolutePath()); assertEquals("Should fail with short password", 1, exitCode); + assertTrue("Error should mention password length", + err.toString().contains("at least 6 characters")); } @Test @@ -125,12 +128,16 @@ public void testNewKeystoreEmptyPassword() throws Exception { File pwFile = tempFolder.newFile("empty.txt"); Files.write(pwFile.toPath(), "".getBytes(StandardCharsets.UTF_8)); + StringWriter err = new StringWriter(); CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setErr(new PrintWriter(err)); int exitCode = cmd.execute("keystore", "new", "--keystore-dir", dir.getAbsolutePath(), "--password-file", pwFile.getAbsolutePath()); assertEquals("Should fail with empty password", 1, exitCode); + assertTrue("Error should mention password length", + err.toString().contains("at least 6 characters")); } @Test @@ -203,4 +210,71 @@ public void testNewKeystoreDirIsFile() throws Exception { assertEquals("Should fail when dir is a file", 1, exitCode); } + + @Test + public void testNewKeystorePasswordFileTooLarge() throws Exception { + File dir = tempFolder.newFolder("keystore-bigpw"); + File pwFile = tempFolder.newFile("bigpw.txt"); + byte[] bigContent = new byte[1025]; + java.util.Arrays.fill(bigContent, (byte) 'a'); + Files.write(pwFile.toPath(), bigContent); + + StringWriter err = new StringWriter(); + CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setErr(new PrintWriter(err)); + int exitCode = cmd.execute("keystore", "new", + "--keystore-dir", dir.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals("Should fail with large password file", 1, exitCode); + assertTrue("Error should mention file too large", + err.toString().contains("too large")); + } + + @Test + public void testNewKeystorePasswordFileWithBom() throws Exception { + File dir = tempFolder.newFolder("keystore-bom"); + File pwFile = tempFolder.newFile("bom.txt"); + Files.write(pwFile.toPath(), + ("\uFEFF" + "test123456").getBytes(StandardCharsets.UTF_8)); + + CommandLine cmd = new CommandLine(new Toolkit()); + int exitCode = cmd.execute("keystore", "new", + "--keystore-dir", dir.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals("Should succeed with BOM password file", 0, exitCode); + File[] files = dir.listFiles((d, name) -> name.endsWith(".json")); + assertNotNull(files); + assertEquals(1, files.length); + } + + @Test + public void testNewKeystoreFilePermissions() throws Exception { + String os = System.getProperty("os.name").toLowerCase(); + org.junit.Assume.assumeTrue("POSIX permissions test, skip on Windows", + !os.contains("win")); + + File dir = tempFolder.newFolder("keystore-perms"); + File pwFile = tempFolder.newFile("pw-perms.txt"); + Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + + CommandLine cmd = new CommandLine(new Toolkit()); + int exitCode = cmd.execute("keystore", "new", + "--keystore-dir", dir.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals(0, exitCode); + File[] files = dir.listFiles((d, name) -> name.endsWith(".json")); + assertNotNull(files); + assertEquals(1, files.length); + + java.util.Set perms = + Files.getPosixFilePermissions(files[0].toPath()); + assertEquals("Keystore file should have owner-only permissions (rw-------)", + java.util.EnumSet.of( + java.nio.file.attribute.PosixFilePermission.OWNER_READ, + java.nio.file.attribute.PosixFilePermission.OWNER_WRITE), + perms); + } } diff --git a/plugins/src/test/java/org/tron/plugins/KeystoreUpdateTest.java b/plugins/src/test/java/org/tron/plugins/KeystoreUpdateTest.java index 24c6b4c99f4..253e4fd7724 100644 --- a/plugins/src/test/java/org/tron/plugins/KeystoreUpdateTest.java +++ b/plugins/src/test/java/org/tron/plugins/KeystoreUpdateTest.java @@ -2,8 +2,12 @@ import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import com.fasterxml.jackson.databind.ObjectMapper; import java.io.File; +import java.io.PrintWriter; +import java.io.StringWriter; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.security.SecureRandom; @@ -13,6 +17,7 @@ import org.tron.common.crypto.SignInterface; import org.tron.common.crypto.SignUtils; import org.tron.keystore.Credentials; +import org.tron.keystore.WalletFile; import org.tron.keystore.WalletUtils; import picocli.CommandLine; @@ -21,6 +26,8 @@ public class KeystoreUpdateTest { @Rule public TemporaryFolder tempFolder = new TemporaryFolder(); + private static final ObjectMapper MAPPER = new ObjectMapper(); + @Test public void testUpdatePassword() throws Exception { File dir = tempFolder.newFolder("keystore"); @@ -32,12 +39,10 @@ public void testUpdatePassword() throws Exception { byte[] originalKey = keyPair.getPrivateKey(); String fileName = WalletUtils.generateWalletFile(oldPassword, keyPair, dir, true); - // Read address from the generated file Credentials creds = WalletUtils.loadCredentials(oldPassword, new File(dir, fileName), true); String address = creds.getAddress(); - // Create password file with old + new passwords File pwFile = tempFolder.newFile("passwords.txt"); Files.write(pwFile.toPath(), (oldPassword + "\n" + newPassword).getBytes(StandardCharsets.UTF_8)); @@ -49,11 +54,16 @@ public void testUpdatePassword() throws Exception { assertEquals("Exit code should be 0", 0, exitCode); - // Verify: new password works + // Verify: new password works and key survives Credentials updated = WalletUtils.loadCredentials(newPassword, new File(dir, fileName), true); assertArrayEquals("Key must survive password change", originalKey, updated.getSignInterface().getPrivateKey()); + + // Verify: address field preserved in keystore JSON + WalletFile wf = MAPPER.readValue(new File(dir, fileName), WalletFile.class); + assertEquals("Address must be preserved in updated keystore", + address, wf.getAddress()); } @Test @@ -73,12 +83,16 @@ public void testUpdateWrongOldPassword() throws Exception { Files.write(pwFile.toPath(), ("wrongpass1\nnewpass456").getBytes(StandardCharsets.UTF_8)); + StringWriter err = new StringWriter(); CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setErr(new PrintWriter(err)); int exitCode = cmd.execute("keystore", "update", address, "--keystore-dir", dir.getAbsolutePath(), "--password-file", pwFile.getAbsolutePath()); assertEquals("Should fail with wrong password", 1, exitCode); + assertTrue("Error should mention decryption", + err.toString().contains("Decryption failed")); // Verify: original password still works (file unchanged) Credentials unchanged = WalletUtils.loadCredentials(password, @@ -91,7 +105,6 @@ public void testUpdateNonExistentAddress() throws Exception { File dir = tempFolder.newFolder("keystore-noaddr"); String password = "test123456"; - // Create a keystore so the dir isn't empty SignInterface keyPair = SignUtils.getGeneratedRandomSign( SecureRandom.getInstance("NativePRNG"), true); WalletUtils.generateWalletFile(password, keyPair, dir, true); @@ -100,12 +113,16 @@ public void testUpdateNonExistentAddress() throws Exception { Files.write(pwFile.toPath(), ("test123456\nnewpass789").getBytes(StandardCharsets.UTF_8)); + StringWriter err = new StringWriter(); CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setErr(new PrintWriter(err)); int exitCode = cmd.execute("keystore", "update", "TNonExistentAddress123456789", "--keystore-dir", dir.getAbsolutePath(), "--password-file", pwFile.getAbsolutePath()); assertEquals("Should fail for non-existent address", 1, exitCode); + assertTrue("Error should mention no keystore found", + err.toString().contains("No keystore found for address")); } @Test @@ -124,12 +141,16 @@ public void testUpdateNewPasswordTooShort() throws Exception { Files.write(pwFile.toPath(), (password + "\nabc").getBytes(StandardCharsets.UTF_8)); + StringWriter err = new StringWriter(); CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setErr(new PrintWriter(err)); int exitCode = cmd.execute("keystore", "update", creds.getAddress(), "--keystore-dir", dir.getAbsolutePath(), "--password-file", pwFile.getAbsolutePath()); assertEquals("Should fail with short new password", 1, exitCode); + assertTrue("Error should mention password length", + err.toString().contains("at least 6 characters")); } @Test @@ -145,7 +166,6 @@ public void testUpdateWithWindowsLineEndings() throws Exception { Credentials creds = WalletUtils.loadCredentials(oldPassword, new File(dir, fileName), true); - // Password file with Windows line endings \r\n File pwFile = tempFolder.newFile("crlf.txt"); Files.write(pwFile.toPath(), (oldPassword + "\r\n" + newPassword + "\r\n").getBytes(StandardCharsets.UTF_8)); @@ -162,4 +182,268 @@ public void testUpdateWithWindowsLineEndings() throws Exception { assertArrayEquals("Key must survive update with CRLF passwords", originalKey, updated.getSignInterface().getPrivateKey()); } + + @Test + public void testUpdateJsonOutput() throws Exception { + File dir = tempFolder.newFolder("keystore-json"); + String oldPassword = "oldpass123"; + String newPassword = "newpass456"; + + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + String fileName = WalletUtils.generateWalletFile(oldPassword, keyPair, dir, true); + Credentials creds = WalletUtils.loadCredentials(oldPassword, + new File(dir, fileName), true); + + File pwFile = tempFolder.newFile("pw-json.txt"); + Files.write(pwFile.toPath(), + (oldPassword + "\n" + newPassword).getBytes(StandardCharsets.UTF_8)); + + StringWriter out = new StringWriter(); + StringWriter err = new StringWriter(); + CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setOut(new PrintWriter(out)); + cmd.setErr(new PrintWriter(err)); + int exitCode = cmd.execute("keystore", "update", creds.getAddress(), + "--keystore-dir", dir.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath(), + "--json"); + + assertEquals(0, exitCode); + String output = out.toString().trim(); + assertTrue("JSON should contain address", + output.contains("\"address\"")); + assertTrue("JSON should contain status updated", + output.contains("\"updated\"")); + assertTrue("JSON should contain file", + output.contains("\"file\"")); + } + + @Test + public void testUpdateWarnsOnCorruptedFile() throws Exception { + File dir = tempFolder.newFolder("keystore-corrupt"); + String password = "test123456"; + + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + String fileName = WalletUtils.generateWalletFile(password, keyPair, dir, true); + Credentials creds = WalletUtils.loadCredentials(password, + new File(dir, fileName), true); + + Files.write(new File(dir, "corrupted.json").toPath(), + "not valid json{{{".getBytes(StandardCharsets.UTF_8)); + + File pwFile = tempFolder.newFile("pw-corrupt.txt"); + Files.write(pwFile.toPath(), + (password + "\nnewpass789").getBytes(StandardCharsets.UTF_8)); + + StringWriter out = new StringWriter(); + StringWriter err = new StringWriter(); + CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setOut(new PrintWriter(out)); + cmd.setErr(new PrintWriter(err)); + int exitCode = cmd.execute("keystore", "update", creds.getAddress(), + "--keystore-dir", dir.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals(0, exitCode); + assertTrue("Should warn about corrupted file", + err.toString().contains("Warning: skipping unreadable file: corrupted.json")); + } + + @Test + public void testUpdatePasswordFileOnlyOneLine() throws Exception { + File dir = tempFolder.newFolder("keystore-1line"); + String password = "test123456"; + + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + String fileName = WalletUtils.generateWalletFile(password, keyPair, dir, true); + Credentials creds = WalletUtils.loadCredentials(password, + new File(dir, fileName), true); + + File pwFile = tempFolder.newFile("oneline.txt"); + Files.write(pwFile.toPath(), + "onlyoldpassword".getBytes(StandardCharsets.UTF_8)); + + StringWriter err = new StringWriter(); + CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setErr(new PrintWriter(err)); + int exitCode = cmd.execute("keystore", "update", creds.getAddress(), + "--keystore-dir", dir.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals("Should fail with single-line password file", 1, exitCode); + assertTrue("Error should mention separate lines", + err.toString().contains("separate lines")); + } + + @Test + public void testUpdateNoTtyNoPasswordFile() throws Exception { + File dir = tempFolder.newFolder("keystore-notty"); + String password = "test123456"; + + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + String fileName = WalletUtils.generateWalletFile(password, keyPair, dir, true); + Credentials creds = WalletUtils.loadCredentials(password, + new File(dir, fileName), true); + + StringWriter err = new StringWriter(); + CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setErr(new PrintWriter(err)); + int exitCode = cmd.execute("keystore", "update", creds.getAddress(), + "--keystore-dir", dir.getAbsolutePath()); + + assertEquals("Should fail when no TTY and no --password-file", 1, exitCode); + assertTrue("Error should mention no terminal", + err.toString().contains("No interactive terminal")); + } + + @Test + public void testUpdatePasswordFileNotFound() throws Exception { + File dir = tempFolder.newFolder("keystore-nopwf"); + String password = "test123456"; + + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + String fileName = WalletUtils.generateWalletFile(password, keyPair, dir, true); + Credentials creds = WalletUtils.loadCredentials(password, + new File(dir, fileName), true); + + StringWriter err = new StringWriter(); + CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setErr(new PrintWriter(err)); + int exitCode = cmd.execute("keystore", "update", creds.getAddress(), + "--keystore-dir", dir.getAbsolutePath(), + "--password-file", "/tmp/nonexistent-pw-update.txt"); + + assertEquals("Should fail when password file not found", 1, exitCode); + assertTrue("Error should mention file not found", + err.toString().contains("Password file not found")); + } + + @Test + public void testUpdateSm2Keystore() throws Exception { + File dir = tempFolder.newFolder("keystore-sm2"); + String oldPassword = "oldpass123"; + String newPassword = "newpass456"; + + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), false); + byte[] originalKey = keyPair.getPrivateKey(); + String fileName = WalletUtils.generateWalletFile(oldPassword, keyPair, dir, true); + Credentials creds = WalletUtils.loadCredentials(oldPassword, + new File(dir, fileName), false); + + File pwFile = tempFolder.newFile("pw-sm2.txt"); + Files.write(pwFile.toPath(), + (oldPassword + "\n" + newPassword).getBytes(StandardCharsets.UTF_8)); + + CommandLine cmd = new CommandLine(new Toolkit()); + int exitCode = cmd.execute("keystore", "update", creds.getAddress(), + "--keystore-dir", dir.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath(), + "--sm2"); + + assertEquals("SM2 keystore update should succeed", 0, exitCode); + + Credentials updated = WalletUtils.loadCredentials(newPassword, + new File(dir, fileName), false); + assertArrayEquals("SM2 key must survive password change", + originalKey, updated.getSignInterface().getPrivateKey()); + } + + @Test + public void testUpdateMultipleKeystoresSameAddress() throws Exception { + File dir = tempFolder.newFolder("keystore-multi"); + String password = "test123456"; + + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + String address = Credentials.create(keyPair).getAddress(); + + // Create two keystores for the same address via direct API + WalletUtils.generateWalletFile(password, keyPair, dir, true); + // Small delay to get different filename timestamps + Thread.sleep(50); + WalletUtils.generateWalletFile(password, keyPair, dir, true); + + File pwFile = tempFolder.newFile("pw-multi.txt"); + Files.write(pwFile.toPath(), + (password + "\nnewpass789").getBytes(StandardCharsets.UTF_8)); + + StringWriter err = new StringWriter(); + CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setErr(new PrintWriter(err)); + int exitCode = cmd.execute("keystore", "update", address, + "--keystore-dir", dir.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals("Should fail with multiple keystores for same address", 1, exitCode); + assertTrue("Error should mention multiple keystores", + err.toString().contains("Multiple keystores found")); + assertTrue("Error should mention remove duplicates", + err.toString().contains("remove duplicates")); + } + + @Test + public void testUpdatePasswordFileTooLarge() throws Exception { + File dir = tempFolder.newFolder("keystore-bigpw"); + String password = "test123456"; + + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + String fileName = WalletUtils.generateWalletFile(password, keyPair, dir, true); + Credentials creds = WalletUtils.loadCredentials(password, + new File(dir, fileName), true); + + // Create a password file > 1KB + File pwFile = tempFolder.newFile("bigpw.txt"); + byte[] bigContent = new byte[1025]; + java.util.Arrays.fill(bigContent, (byte) 'a'); + Files.write(pwFile.toPath(), bigContent); + + StringWriter err = new StringWriter(); + CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setErr(new PrintWriter(err)); + int exitCode = cmd.execute("keystore", "update", creds.getAddress(), + "--keystore-dir", dir.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals("Should fail with large password file", 1, exitCode); + assertTrue("Error should mention file too large", + err.toString().contains("too large")); + } + + @Test + public void testUpdatePasswordFileWithBom() throws Exception { + File dir = tempFolder.newFolder("keystore-bom"); + String oldPassword = "oldpass123"; + String newPassword = "newpass456"; + + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + byte[] originalKey = keyPair.getPrivateKey(); + String fileName = WalletUtils.generateWalletFile(oldPassword, keyPair, dir, true); + Credentials creds = WalletUtils.loadCredentials(oldPassword, + new File(dir, fileName), true); + + // Password file with UTF-8 BOM + File pwFile = tempFolder.newFile("bom.txt"); + Files.write(pwFile.toPath(), + ("\uFEFF" + oldPassword + "\n" + newPassword).getBytes(StandardCharsets.UTF_8)); + + CommandLine cmd = new CommandLine(new Toolkit()); + int exitCode = cmd.execute("keystore", "update", creds.getAddress(), + "--keystore-dir", dir.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals("Update with BOM password file should succeed", 0, exitCode); + + Credentials updated = WalletUtils.loadCredentials(newPassword, + new File(dir, fileName), true); + assertArrayEquals("Key must survive update with BOM password file", + originalKey, updated.getSignInterface().getPrivateKey()); + } } From 115e226c983244701561d50ac469ca00c9963e22 Mon Sep 17 00:00:00 2001 From: Barbatos Date: Fri, 17 Apr 2026 07:18:35 +0800 Subject: [PATCH 22/41] fix(plugins): unify keystore validation and fix inconsistent error messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract isValidKeystoreFile(WalletFile) to KeystoreCliUtils, requiring address != null, crypto != null, and version == 3 consistently across list, import, and update commands - Fix update command printing both "Multiple keystores found" and "No keystore found" for the same address — move error messages into findKeystoreByAddress so each case prints exactly one message --- .../common/org/tron/plugins/KeystoreCliUtils.java | 9 +++++++++ .../common/org/tron/plugins/KeystoreImport.java | 3 ++- .../java/common/org/tron/plugins/KeystoreList.java | 4 +--- .../common/org/tron/plugins/KeystoreUpdate.java | 13 ++++++++++--- 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java index c28736f6dd7..961f28eb552 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java @@ -220,6 +220,15 @@ static void printSecurityTips(PrintWriter out, String address, String fileName) + " Without the password, it's impossible to decrypt the key!"); } + /** + * Check if a WalletFile represents a valid V3 keystore. + */ + static boolean isValidKeystoreFile(WalletFile wf) { + return wf.getAddress() != null + && wf.getCrypto() != null + && wf.getVersion() == 3; + } + static void setOwnerOnly(File file, PrintWriter err) { try { Files.setPosixFilePermissions(file.toPath(), OWNER_ONLY); diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java index 90d0cd30c64..7b3589c11f6 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java @@ -172,7 +172,8 @@ private String findExistingKeystore(File dir, String address, PrintWriter err) { for (File file : files) { try { WalletFile wf = mapper.readValue(file, WalletFile.class); - if (address.equals(wf.getAddress())) { + if (KeystoreCliUtils.isValidKeystoreFile(wf) + && address.equals(wf.getAddress())) { return file.getName(); } } catch (Exception e) { diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreList.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreList.java index 80a299402f4..52a82ba6527 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreList.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreList.java @@ -61,9 +61,7 @@ public Integer call() { for (File file : files) { try { WalletFile walletFile = MAPPER.readValue(file, WalletFile.class); - if (walletFile.getAddress() == null - || walletFile.getCrypto() == null - || walletFile.getVersion() != 3) { + if (!KeystoreCliUtils.isValidKeystoreFile(walletFile)) { continue; } Map entry = new LinkedHashMap<>(); diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java index 8959b00921f..7af7e3c39b4 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java @@ -58,7 +58,7 @@ public Integer call() { try { File keystoreFile = findKeystoreByAddress(address, err); if (keystoreFile == null) { - err.println("No keystore found for address: " + address); + // findKeystoreByAddress already prints the specific error return 1; } @@ -181,17 +181,20 @@ public Integer call() { private File findKeystoreByAddress(String targetAddress, PrintWriter err) { if (!keystoreDir.exists() || !keystoreDir.isDirectory()) { + err.println("No keystore found for address: " + targetAddress); return null; } File[] files = keystoreDir.listFiles((dir, name) -> name.endsWith(".json")); if (files == null) { + err.println("No keystore found for address: " + targetAddress); return null; } java.util.List matches = new java.util.ArrayList<>(); for (File file : files) { try { WalletFile wf = MAPPER.readValue(file, WalletFile.class); - if (targetAddress.equals(wf.getAddress())) { + if (KeystoreCliUtils.isValidKeystoreFile(wf) + && targetAddress.equals(wf.getAddress())) { matches.add(file); } } catch (Exception e) { @@ -207,6 +210,10 @@ private File findKeystoreByAddress(String targetAddress, PrintWriter err) { err.println("Please remove duplicates and retry."); return null; } - return matches.isEmpty() ? null : matches.get(0); + if (matches.isEmpty()) { + err.println("No keystore found for address: " + targetAddress); + return null; + } + return matches.get(0); } } From b0798393f2226f7682155fb62791707a200d056d Mon Sep 17 00:00:00 2001 From: Barbatos Date: Fri, 17 Apr 2026 08:12:42 +0800 Subject: [PATCH 23/41] test(plugins): improve coverage for keystore validation and edge cases - Add tests for update with nonexistent/file keystore-dir, old Mac CR line endings, and invalid-version keystore files - Add test for import duplicate check skipping invalid-version files - Add test for list skipping invalid-version and no-crypto keystores - Fix password file split regex to handle old Mac CR-only line endings --- .../org/tron/plugins/KeystoreUpdate.java | 2 +- .../org/tron/plugins/KeystoreImportTest.java | 29 +++++ .../org/tron/plugins/KeystoreListTest.java | 34 ++++++ .../org/tron/plugins/KeystoreUpdateTest.java | 102 ++++++++++++++++++ 4 files changed, 166 insertions(+), 1 deletion(-) diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java index 7af7e3c39b4..1df0bc89630 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java @@ -82,7 +82,7 @@ public Integer call() { if (content.length() > 0 && content.charAt(0) == '\uFEFF') { content = content.substring(1); } - String[] lines = content.split("\\r?\\n"); + String[] lines = content.split("\\r?\\n|\\r"); if (lines.length < 2) { err.println( "Password file must contain old and new passwords" diff --git a/plugins/src/test/java/org/tron/plugins/KeystoreImportTest.java b/plugins/src/test/java/org/tron/plugins/KeystoreImportTest.java index f4ba941fbb0..4ad6e650c42 100644 --- a/plugins/src/test/java/org/tron/plugins/KeystoreImportTest.java +++ b/plugins/src/test/java/org/tron/plugins/KeystoreImportTest.java @@ -373,4 +373,33 @@ public void testImportKeystoreFilePermissions() throws Exception { java.nio.file.attribute.PosixFilePermission.OWNER_WRITE), perms); } + + @Test + public void testImportDuplicateCheckSkipsInvalidVersion() throws Exception { + File dir = tempFolder.newFolder("keystore-badver"); + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + String privateKeyHex = ByteArray.toHexString(keyPair.getPrivateKey()); + String address = Credentials.create(keyPair).getAddress(); + + // Create a JSON with correct address but wrong version — should NOT count as duplicate + String fakeKeystore = "{\"address\":\"" + address + + "\",\"version\":2,\"crypto\":{\"cipher\":\"aes-128-ctr\"}}"; + Files.write(new File(dir, "fake.json").toPath(), + fakeKeystore.getBytes(StandardCharsets.UTF_8)); + + File keyFile = tempFolder.newFile("ver.key"); + Files.write(keyFile.toPath(), privateKeyHex.getBytes(StandardCharsets.UTF_8)); + File pwFile = tempFolder.newFile("pw-ver.txt"); + Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + + CommandLine cmd = new CommandLine(new Toolkit()); + int exitCode = cmd.execute("keystore", "import", + "--keystore-dir", dir.getAbsolutePath(), + "--key-file", keyFile.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals("Import should succeed — invalid-version file is not a real duplicate", 0, + exitCode); + } } diff --git a/plugins/src/test/java/org/tron/plugins/KeystoreListTest.java b/plugins/src/test/java/org/tron/plugins/KeystoreListTest.java index 2fa7d858ab9..f76ec0be254 100644 --- a/plugins/src/test/java/org/tron/plugins/KeystoreListTest.java +++ b/plugins/src/test/java/org/tron/plugins/KeystoreListTest.java @@ -206,4 +206,38 @@ public void testListWarnsOnCorruptedJsonFiles() throws Exception { String output = out.toString().trim(); assertTrue("Should still list the valid keystore", output.length() > 0); } + + @Test + public void testListSkipsInvalidVersionKeystores() throws Exception { + File dir = tempFolder.newFolder("keystore-version"); + String password = "test123456"; + + // Create one valid keystore + SignInterface key = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + WalletUtils.generateWalletFile(password, key, dir, false); + + // Create a JSON with address and crypto but wrong version + String fakeV2 = "{\"address\":\"TFakeAddress\",\"version\":2," + + "\"crypto\":{\"cipher\":\"aes-128-ctr\"}}"; + Files.write(new File(dir, "v2-keystore.json").toPath(), + fakeV2.getBytes(StandardCharsets.UTF_8)); + + // Create a JSON with address but null crypto + String noCrypto = "{\"address\":\"TFakeAddress2\",\"version\":3}"; + Files.write(new File(dir, "no-crypto.json").toPath(), + noCrypto.getBytes(StandardCharsets.UTF_8)); + + StringWriter out = new StringWriter(); + CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setOut(new PrintWriter(out)); + int exitCode = cmd.execute("keystore", "list", + "--keystore-dir", dir.getAbsolutePath()); + + assertEquals(0, exitCode); + String output = out.toString().trim(); + String[] lines = output.split("\\n"); + assertEquals("Should list only the valid v3 keystore, not v2 or no-crypto", + 1, lines.length); + } } diff --git a/plugins/src/test/java/org/tron/plugins/KeystoreUpdateTest.java b/plugins/src/test/java/org/tron/plugins/KeystoreUpdateTest.java index 253e4fd7724..4b7d205e74e 100644 --- a/plugins/src/test/java/org/tron/plugins/KeystoreUpdateTest.java +++ b/plugins/src/test/java/org/tron/plugins/KeystoreUpdateTest.java @@ -446,4 +446,106 @@ public void testUpdatePasswordFileWithBom() throws Exception { assertArrayEquals("Key must survive update with BOM password file", originalKey, updated.getSignInterface().getPrivateKey()); } + + @Test + public void testUpdateNonExistentKeystoreDir() throws Exception { + File dir = new File(tempFolder.getRoot(), "does-not-exist"); + + File pwFile = tempFolder.newFile("pw-nodir.txt"); + Files.write(pwFile.toPath(), + ("oldpass123\nnewpass456").getBytes(StandardCharsets.UTF_8)); + + StringWriter err = new StringWriter(); + CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setErr(new PrintWriter(err)); + int exitCode = cmd.execute("keystore", "update", "TSomeAddress", + "--keystore-dir", dir.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals(1, exitCode); + assertTrue("Error should mention no keystore found", + err.toString().contains("No keystore found for address")); + } + + @Test + public void testUpdateKeystoreDirIsFile() throws Exception { + File notADir = tempFolder.newFile("not-a-dir"); + + File pwFile = tempFolder.newFile("pw-notdir.txt"); + Files.write(pwFile.toPath(), + ("oldpass123\nnewpass456").getBytes(StandardCharsets.UTF_8)); + + StringWriter err = new StringWriter(); + CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setErr(new PrintWriter(err)); + int exitCode = cmd.execute("keystore", "update", "TSomeAddress", + "--keystore-dir", notADir.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals(1, exitCode); + assertTrue("Error should mention no keystore found", + err.toString().contains("No keystore found for address")); + } + + @Test + public void testUpdateWithOldMacLineEndings() throws Exception { + File dir = tempFolder.newFolder("keystore-cr"); + String oldPassword = "oldpass123"; + String newPassword = "newpass456"; + + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + byte[] originalKey = keyPair.getPrivateKey(); + String fileName = WalletUtils.generateWalletFile(oldPassword, keyPair, dir, true); + Credentials creds = WalletUtils.loadCredentials(oldPassword, + new File(dir, fileName), true); + + // Password file with old Mac line endings (\r only) + File pwFile = tempFolder.newFile("cr.txt"); + Files.write(pwFile.toPath(), + (oldPassword + "\r" + newPassword + "\r").getBytes(StandardCharsets.UTF_8)); + + CommandLine cmd = new CommandLine(new Toolkit()); + int exitCode = cmd.execute("keystore", "update", creds.getAddress(), + "--keystore-dir", dir.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals("Update with old Mac CR line endings should succeed", 0, exitCode); + + Credentials updated = WalletUtils.loadCredentials(newPassword, + new File(dir, fileName), true); + assertArrayEquals("Key must survive update with CR passwords", + originalKey, updated.getSignInterface().getPrivateKey()); + } + + @Test + public void testUpdateSkipsInvalidVersionKeystores() throws Exception { + File dir = tempFolder.newFolder("keystore-badver"); + String password = "test123456"; + + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + String address = Credentials.create(keyPair).getAddress(); + + // Create a JSON file with correct address but wrong version + String fakeKeystore = "{\"address\":\"" + address + + "\",\"version\":2,\"crypto\":{\"cipher\":\"aes-128-ctr\"}}"; + Files.write(new File(dir, "fake.json").toPath(), + fakeKeystore.getBytes(StandardCharsets.UTF_8)); + + File pwFile = tempFolder.newFile("pw-badver.txt"); + Files.write(pwFile.toPath(), + (password + "\nnewpass789").getBytes(StandardCharsets.UTF_8)); + + StringWriter err = new StringWriter(); + CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setErr(new PrintWriter(err)); + int exitCode = cmd.execute("keystore", "update", address, + "--keystore-dir", dir.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals("Should not find keystore with wrong version", 1, exitCode); + assertTrue("Error should mention no keystore found", + err.toString().contains("No keystore found")); + } } From b083fc60e8f47b5dd963f7bb198d6a6fd7b45e1f Mon Sep 17 00:00:00 2001 From: Barbatos Date: Fri, 17 Apr 2026 08:54:13 +0800 Subject: [PATCH 24/41] test(plugins): add direct unit tests for KeystoreCliUtils Cover utility methods (stripLineEndings, jsonMap, isValidKeystoreFile, checkFileExists, readPassword, ensureDirectory, printJson, printSecurityTips, atomicMove, generateKeystoreFile, setOwnerOnly) with direct unit tests, including edge cases like BOM, various line endings, empty strings, and both ECDSA/SM2 key paths. --- .../tron/plugins/KeystoreCliUtilsTest.java | 350 ++++++++++++++++++ 1 file changed, 350 insertions(+) create mode 100644 plugins/src/test/java/org/tron/plugins/KeystoreCliUtilsTest.java diff --git a/plugins/src/test/java/org/tron/plugins/KeystoreCliUtilsTest.java b/plugins/src/test/java/org/tron/plugins/KeystoreCliUtilsTest.java new file mode 100644 index 00000000000..72d0803665f --- /dev/null +++ b/plugins/src/test/java/org/tron/plugins/KeystoreCliUtilsTest.java @@ -0,0 +1,350 @@ +package org.tron.plugins; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.security.SecureRandom; +import java.util.Map; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.common.crypto.SignInterface; +import org.tron.common.crypto.SignUtils; +import org.tron.keystore.WalletFile; + +public class KeystoreCliUtilsTest { + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + @Test + public void testStripLineEndingsNoChange() { + assertEquals("password", KeystoreCliUtils.stripLineEndings("password")); + } + + @Test + public void testStripLineEndingsTrailingLf() { + assertEquals("password", KeystoreCliUtils.stripLineEndings("password\n")); + } + + @Test + public void testStripLineEndingsTrailingCrLf() { + assertEquals("password", KeystoreCliUtils.stripLineEndings("password\r\n")); + } + + @Test + public void testStripLineEndingsTrailingCr() { + assertEquals("password", KeystoreCliUtils.stripLineEndings("password\r")); + } + + @Test + public void testStripLineEndingsMultipleTrailing() { + assertEquals("password", KeystoreCliUtils.stripLineEndings("password\r\n\r\n")); + } + + @Test + public void testStripLineEndingsBom() { + assertEquals("password", KeystoreCliUtils.stripLineEndings("\uFEFFpassword")); + } + + @Test + public void testStripLineEndingsBomAndTrailing() { + assertEquals("password", + KeystoreCliUtils.stripLineEndings("\uFEFFpassword\r\n")); + } + + @Test + public void testStripLineEndingsEmpty() { + assertEquals("", KeystoreCliUtils.stripLineEndings("")); + } + + @Test + public void testStripLineEndingsOnlyLineEndings() { + assertEquals("", KeystoreCliUtils.stripLineEndings("\r\n\r\n")); + } + + @Test + public void testJsonMapEven() { + Map m = KeystoreCliUtils.jsonMap("a", "1", "b", "2"); + assertEquals(2, m.size()); + assertEquals("1", m.get("a")); + assertEquals("2", m.get("b")); + } + + @Test + public void testJsonMapPreservesOrder() { + Map m = KeystoreCliUtils.jsonMap( + "z", "1", "a", "2", "m", "3"); + String[] keys = m.keySet().toArray(new String[0]); + assertEquals("z", keys[0]); + assertEquals("a", keys[1]); + assertEquals("m", keys[2]); + } + + @Test + public void testJsonMapEmpty() { + Map m = KeystoreCliUtils.jsonMap(); + assertTrue(m.isEmpty()); + } + + @Test + public void testIsValidKeystoreFileValid() { + WalletFile wf = new WalletFile(); + wf.setAddress("TAddr"); + wf.setVersion(3); + wf.setCrypto(new WalletFile.Crypto()); + assertTrue(KeystoreCliUtils.isValidKeystoreFile(wf)); + } + + @Test + public void testIsValidKeystoreFileNullAddress() { + WalletFile wf = new WalletFile(); + wf.setVersion(3); + wf.setCrypto(new WalletFile.Crypto()); + assertFalse(KeystoreCliUtils.isValidKeystoreFile(wf)); + } + + @Test + public void testIsValidKeystoreFileNullCrypto() { + WalletFile wf = new WalletFile(); + wf.setAddress("TAddr"); + wf.setVersion(3); + assertFalse(KeystoreCliUtils.isValidKeystoreFile(wf)); + } + + @Test + public void testIsValidKeystoreFileWrongVersion() { + WalletFile wf = new WalletFile(); + wf.setAddress("TAddr"); + wf.setVersion(2); + wf.setCrypto(new WalletFile.Crypto()); + assertFalse(KeystoreCliUtils.isValidKeystoreFile(wf)); + } + + @Test + public void testCheckFileExistsNull() { + StringWriter err = new StringWriter(); + assertTrue(KeystoreCliUtils.checkFileExists(null, "Label", + new PrintWriter(err))); + assertEquals("", err.toString()); + } + + @Test + public void testCheckFileExistsMissing() { + StringWriter err = new StringWriter(); + File missing = new File("/tmp/nonexistent-cli-utils-test-file"); + assertFalse(KeystoreCliUtils.checkFileExists(missing, "Key file", + new PrintWriter(err))); + assertTrue(err.toString().contains("Key file not found")); + } + + @Test + public void testCheckFileExistsPresent() throws Exception { + StringWriter err = new StringWriter(); + File f = tempFolder.newFile("present.txt"); + assertTrue(KeystoreCliUtils.checkFileExists(f, "Key file", + new PrintWriter(err))); + } + + @Test + public void testReadPasswordFromFile() throws Exception { + File pwFile = tempFolder.newFile("pw.txt"); + Files.write(pwFile.toPath(), "goodpassword".getBytes(StandardCharsets.UTF_8)); + StringWriter err = new StringWriter(); + String pw = KeystoreCliUtils.readPassword(pwFile, new PrintWriter(err)); + assertEquals("goodpassword", pw); + } + + @Test + public void testReadPasswordFromFileWithLineEndings() throws Exception { + File pwFile = tempFolder.newFile("pw-crlf.txt"); + Files.write(pwFile.toPath(), "goodpassword\r\n".getBytes(StandardCharsets.UTF_8)); + StringWriter err = new StringWriter(); + String pw = KeystoreCliUtils.readPassword(pwFile, new PrintWriter(err)); + assertEquals("goodpassword", pw); + } + + @Test + public void testReadPasswordFromFileWithBom() throws Exception { + File pwFile = tempFolder.newFile("pw-bom.txt"); + Files.write(pwFile.toPath(), + "\uFEFFgoodpassword".getBytes(StandardCharsets.UTF_8)); + StringWriter err = new StringWriter(); + String pw = KeystoreCliUtils.readPassword(pwFile, new PrintWriter(err)); + assertEquals("goodpassword", pw); + } + + @Test + public void testReadPasswordFileTooLarge() throws Exception { + File pwFile = tempFolder.newFile("pw-big.txt"); + byte[] big = new byte[1025]; + java.util.Arrays.fill(big, (byte) 'a'); + Files.write(pwFile.toPath(), big); + StringWriter err = new StringWriter(); + String pw = KeystoreCliUtils.readPassword(pwFile, new PrintWriter(err)); + assertNull(pw); + assertTrue(err.toString().contains("too large")); + } + + @Test + public void testReadPasswordFileShort() throws Exception { + File pwFile = tempFolder.newFile("pw-short.txt"); + Files.write(pwFile.toPath(), "abc".getBytes(StandardCharsets.UTF_8)); + StringWriter err = new StringWriter(); + String pw = KeystoreCliUtils.readPassword(pwFile, new PrintWriter(err)); + assertNull(pw); + assertTrue(err.toString().contains("at least 6")); + } + + @Test + public void testReadPasswordFileNotFound() throws Exception { + StringWriter err = new StringWriter(); + String pw = KeystoreCliUtils.readPassword( + new File("/tmp/nonexistent-pw-direct-test.txt"), new PrintWriter(err)); + assertNull(pw); + assertTrue(err.toString().contains("Password file not found")); + } + + @Test + public void testEnsureDirectoryCreatesNested() throws Exception { + File dir = new File(tempFolder.getRoot(), "a/b/c"); + assertFalse(dir.exists()); + KeystoreCliUtils.ensureDirectory(dir); + assertTrue(dir.exists()); + assertTrue(dir.isDirectory()); + } + + @Test + public void testEnsureDirectoryExisting() throws Exception { + File dir = tempFolder.newFolder("existing"); + KeystoreCliUtils.ensureDirectory(dir); + assertTrue(dir.isDirectory()); + } + + @Test(expected = java.io.IOException.class) + public void testEnsureDirectoryPathIsFile() throws Exception { + File f = tempFolder.newFile("not-a-dir"); + KeystoreCliUtils.ensureDirectory(f); + } + + @Test + public void testPrintJsonValidOutput() { + StringWriter out = new StringWriter(); + StringWriter err = new StringWriter(); + KeystoreCliUtils.printJson(new PrintWriter(out), new PrintWriter(err), + KeystoreCliUtils.jsonMap("address", "TAddr", "file", "file.json")); + String s = out.toString().trim(); + assertTrue(s.contains("\"address\":\"TAddr\"")); + assertTrue(s.contains("\"file\":\"file.json\"")); + } + + @Test + public void testPrintSecurityTipsIncludesAddressAndFile() { + StringWriter out = new StringWriter(); + KeystoreCliUtils.printSecurityTips(new PrintWriter(out), + "TMyAddress", "/path/to/keystore.json"); + String s = out.toString(); + assertTrue(s.contains("TMyAddress")); + assertTrue(s.contains("/path/to/keystore.json")); + assertTrue(s.contains("NEVER share")); + assertTrue(s.contains("BACKUP")); + assertTrue(s.contains("REMEMBER")); + } + + @Test + public void testAtomicMove() throws Exception { + File src = tempFolder.newFile("src.txt"); + Files.write(src.toPath(), "hello".getBytes(StandardCharsets.UTF_8)); + File target = new File(tempFolder.getRoot(), "target.txt"); + + KeystoreCliUtils.atomicMove(src, target); + assertFalse(src.exists()); + assertTrue(target.exists()); + assertEquals("hello", + new String(Files.readAllBytes(target.toPath()), StandardCharsets.UTF_8)); + } + + @Test + public void testAtomicMoveReplacesExisting() throws Exception { + File src = tempFolder.newFile("src2.txt"); + Files.write(src.toPath(), "new".getBytes(StandardCharsets.UTF_8)); + File target = tempFolder.newFile("target2.txt"); + Files.write(target.toPath(), "old".getBytes(StandardCharsets.UTF_8)); + + KeystoreCliUtils.atomicMove(src, target); + assertEquals("new", + new String(Files.readAllBytes(target.toPath()), StandardCharsets.UTF_8)); + } + + @Test + public void testGenerateKeystoreFileFullScrypt() throws Exception { + File dir = tempFolder.newFolder("gen-full"); + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + StringWriter err = new StringWriter(); + + String fileName = KeystoreCliUtils.generateKeystoreFile( + "password123", keyPair, dir, true, new PrintWriter(err)); + + assertNotNull(fileName); + assertTrue(fileName.endsWith(".json")); + File file = new File(dir, fileName); + assertTrue(file.exists()); + } + + @Test + public void testGenerateKeystoreFileLightScrypt() throws Exception { + File dir = tempFolder.newFolder("gen-light"); + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + StringWriter err = new StringWriter(); + + String fileName = KeystoreCliUtils.generateKeystoreFile( + "password123", keyPair, dir, false, new PrintWriter(err)); + + assertNotNull(fileName); + File file = new File(dir, fileName); + assertTrue(file.exists()); + } + + @Test + public void testGenerateKeystoreFileLeavesNoTempFile() throws Exception { + File dir = tempFolder.newFolder("gen-notemp"); + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + StringWriter err = new StringWriter(); + + KeystoreCliUtils.generateKeystoreFile( + "password123", keyPair, dir, false, new PrintWriter(err)); + + File[] tempFiles = dir.listFiles((d, name) -> name.startsWith("keystore-") + && name.endsWith(".tmp")); + assertNotNull(tempFiles); + assertEquals("No temp files should remain after generation", 0, tempFiles.length); + } + + @Test + public void testSetOwnerOnly() throws Exception { + String os = System.getProperty("os.name").toLowerCase(); + org.junit.Assume.assumeTrue("POSIX permissions test", !os.contains("win")); + + File f = tempFolder.newFile("perm-test.txt"); + StringWriter err = new StringWriter(); + KeystoreCliUtils.setOwnerOnly(f, new PrintWriter(err)); + + java.util.Set perms = + Files.getPosixFilePermissions(f.toPath()); + assertEquals(java.util.EnumSet.of( + java.nio.file.attribute.PosixFilePermission.OWNER_READ, + java.nio.file.attribute.PosixFilePermission.OWNER_WRITE), + perms); + } +} From 241590cd9846430197b2100b1710b2950266a612 Mon Sep 17 00:00:00 2001 From: Barbatos Date: Fri, 17 Apr 2026 13:17:11 +0800 Subject: [PATCH 25/41] test(framework): expand coverage for WalletFile POJO and KeystoreFactory - Add WalletFilePojoTest covering all getter/setter, equals, hashCode paths for WalletFile and its inner classes (Crypto, CipherParams, Aes128CtrKdfParams, ScryptKdfParams), plus JSON deserialization with both scrypt and pbkdf2 KDF types - Expand KeystoreFactoryDeprecationTest to cover help, invalid command, quit, empty line, and genKeystore/importprivatekey dispatch paths - Tests live in framework module so their coverage data is included in crypto module's jacoco report (crypto:jacocoTestReport reads framework exec files) --- .../org/tron/keystore/WalletFilePojoTest.java | 389 ++++++++++++++++++ .../KeystoreFactoryDeprecationTest.java | 132 +++++- 2 files changed, 511 insertions(+), 10 deletions(-) create mode 100644 framework/src/test/java/org/tron/keystore/WalletFilePojoTest.java diff --git a/framework/src/test/java/org/tron/keystore/WalletFilePojoTest.java b/framework/src/test/java/org/tron/keystore/WalletFilePojoTest.java new file mode 100644 index 00000000000..83c7096665b --- /dev/null +++ b/framework/src/test/java/org/tron/keystore/WalletFilePojoTest.java @@ -0,0 +1,389 @@ +package org.tron.keystore; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Test; + +public class WalletFilePojoTest { + + @Test + public void testWalletFileGettersSetters() { + WalletFile wf = new WalletFile(); + wf.setAddress("TAddr"); + wf.setId("uuid-123"); + wf.setVersion(3); + WalletFile.Crypto c = new WalletFile.Crypto(); + wf.setCrypto(c); + + assertEquals("TAddr", wf.getAddress()); + assertEquals("uuid-123", wf.getId()); + assertEquals(3, wf.getVersion()); + assertEquals(c, wf.getCrypto()); + } + + @Test + public void testWalletFileCryptoV1Setter() { + WalletFile wf = new WalletFile(); + WalletFile.Crypto c = new WalletFile.Crypto(); + wf.setCryptoV1(c); + assertEquals(c, wf.getCrypto()); + } + + @Test + public void testWalletFileEqualsAllBranches() { + WalletFile a = new WalletFile(); + a.setAddress("TAddr"); + a.setId("id1"); + a.setVersion(3); + WalletFile.Crypto c = new WalletFile.Crypto(); + a.setCrypto(c); + + WalletFile b = new WalletFile(); + b.setAddress("TAddr"); + b.setId("id1"); + b.setVersion(3); + b.setCrypto(c); + + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + assertTrue(a.equals(a)); + assertFalse(a.equals(null)); + assertFalse(a.equals("string")); + + // Different address + b.setAddress("TOther"); + assertNotEquals(a, b); + b.setAddress("TAddr"); + + // Different id + b.setId("id2"); + assertNotEquals(a, b); + b.setId("id1"); + + // Different version + b.setVersion(4); + assertNotEquals(a, b); + b.setVersion(3); + + // Different crypto + b.setCrypto(new WalletFile.Crypto()); + // Still equal since Cryptos are equal (both empty) + assertEquals(a, b); + + // Null fields + WalletFile empty = new WalletFile(); + WalletFile empty2 = new WalletFile(); + assertEquals(empty, empty2); + assertEquals(empty.hashCode(), empty2.hashCode()); + + // One side null + empty2.setAddress("X"); + assertNotEquals(empty, empty2); + } + + @Test + public void testCryptoGettersSetters() { + WalletFile.Crypto c = new WalletFile.Crypto(); + c.setCipher("aes-128-ctr"); + c.setCiphertext("ciphertext"); + c.setKdf("scrypt"); + c.setMac("mac-value"); + + WalletFile.CipherParams cp = new WalletFile.CipherParams(); + cp.setIv("ivvalue"); + c.setCipherparams(cp); + + WalletFile.ScryptKdfParams kp = new WalletFile.ScryptKdfParams(); + c.setKdfparams(kp); + + assertEquals("aes-128-ctr", c.getCipher()); + assertEquals("ciphertext", c.getCiphertext()); + assertEquals("scrypt", c.getKdf()); + assertEquals("mac-value", c.getMac()); + assertEquals(cp, c.getCipherparams()); + assertEquals(kp, c.getKdfparams()); + } + + @Test + public void testCryptoEqualsAllBranches() { + WalletFile.Crypto a = new WalletFile.Crypto(); + a.setCipher("c1"); + a.setCiphertext("txt"); + a.setKdf("kdf"); + a.setMac("mac"); + WalletFile.CipherParams cp = new WalletFile.CipherParams(); + cp.setIv("iv"); + a.setCipherparams(cp); + WalletFile.Aes128CtrKdfParams kp = new WalletFile.Aes128CtrKdfParams(); + a.setKdfparams(kp); + + WalletFile.Crypto b = new WalletFile.Crypto(); + b.setCipher("c1"); + b.setCiphertext("txt"); + b.setKdf("kdf"); + b.setMac("mac"); + b.setCipherparams(cp); + b.setKdfparams(kp); + + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + assertTrue(a.equals(a)); + assertFalse(a.equals(null)); + assertFalse(a.equals("string")); + + // cipher differs + b.setCipher("c2"); + assertNotEquals(a, b); + b.setCipher("c1"); + + // ciphertext differs + b.setCiphertext("other"); + assertNotEquals(a, b); + b.setCiphertext("txt"); + + // kdf differs + b.setKdf("other"); + assertNotEquals(a, b); + b.setKdf("kdf"); + + // mac differs + b.setMac("other"); + assertNotEquals(a, b); + b.setMac("mac"); + + // cipherparams differs + WalletFile.CipherParams cp2 = new WalletFile.CipherParams(); + cp2.setIv("other"); + b.setCipherparams(cp2); + assertNotEquals(a, b); + b.setCipherparams(cp); + + // kdfparams differs + WalletFile.Aes128CtrKdfParams kp2 = new WalletFile.Aes128CtrKdfParams(); + kp2.setC(5); + b.setKdfparams(kp2); + assertNotEquals(a, b); + } + + @Test + public void testCryptoNullFields() { + WalletFile.Crypto a = new WalletFile.Crypto(); + WalletFile.Crypto b = new WalletFile.Crypto(); + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + + a.setCipher("x"); + assertNotEquals(a, b); + } + + @Test + public void testCipherParamsGettersSetters() { + WalletFile.CipherParams cp = new WalletFile.CipherParams(); + cp.setIv("ivvalue"); + assertEquals("ivvalue", cp.getIv()); + } + + @Test + public void testCipherParamsEquals() { + WalletFile.CipherParams a = new WalletFile.CipherParams(); + WalletFile.CipherParams b = new WalletFile.CipherParams(); + assertEquals(a, b); + a.setIv("iv"); + assertNotEquals(a, b); + b.setIv("iv"); + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + b.setIv("other"); + assertNotEquals(a, b); + assertTrue(a.equals(a)); + assertFalse(a.equals(null)); + assertFalse(a.equals("string")); + } + + @Test + public void testAes128CtrKdfParamsAllAccessors() { + WalletFile.Aes128CtrKdfParams p = new WalletFile.Aes128CtrKdfParams(); + p.setDklen(32); + p.setC(262144); + p.setPrf("hmac-sha256"); + p.setSalt("saltvalue"); + + assertEquals(32, p.getDklen()); + assertEquals(262144, p.getC()); + assertEquals("hmac-sha256", p.getPrf()); + assertEquals("saltvalue", p.getSalt()); + } + + @Test + public void testAes128CtrKdfParamsEquals() { + WalletFile.Aes128CtrKdfParams a = new WalletFile.Aes128CtrKdfParams(); + a.setDklen(32); + a.setC(262144); + a.setPrf("hmac-sha256"); + a.setSalt("salt"); + + WalletFile.Aes128CtrKdfParams b = new WalletFile.Aes128CtrKdfParams(); + b.setDklen(32); + b.setC(262144); + b.setPrf("hmac-sha256"); + b.setSalt("salt"); + + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + assertTrue(a.equals(a)); + assertFalse(a.equals(null)); + assertFalse(a.equals("string")); + + b.setDklen(64); + assertNotEquals(a, b); + b.setDklen(32); + + b.setC(1); + assertNotEquals(a, b); + b.setC(262144); + + b.setPrf("other"); + assertNotEquals(a, b); + b.setPrf("hmac-sha256"); + + b.setSalt("other"); + assertNotEquals(a, b); + b.setSalt("salt"); + + // null fields + WalletFile.Aes128CtrKdfParams x = new WalletFile.Aes128CtrKdfParams(); + WalletFile.Aes128CtrKdfParams y = new WalletFile.Aes128CtrKdfParams(); + assertEquals(x, y); + x.setPrf("x"); + assertNotEquals(x, y); + } + + @Test + public void testScryptKdfParamsAllAccessors() { + WalletFile.ScryptKdfParams p = new WalletFile.ScryptKdfParams(); + p.setDklen(32); + p.setN(262144); + p.setP(1); + p.setR(8); + p.setSalt("saltvalue"); + + assertEquals(32, p.getDklen()); + assertEquals(262144, p.getN()); + assertEquals(1, p.getP()); + assertEquals(8, p.getR()); + assertEquals("saltvalue", p.getSalt()); + } + + @Test + public void testScryptKdfParamsEquals() { + WalletFile.ScryptKdfParams a = new WalletFile.ScryptKdfParams(); + a.setDklen(32); + a.setN(262144); + a.setP(1); + a.setR(8); + a.setSalt("salt"); + + WalletFile.ScryptKdfParams b = new WalletFile.ScryptKdfParams(); + b.setDklen(32); + b.setN(262144); + b.setP(1); + b.setR(8); + b.setSalt("salt"); + + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + assertTrue(a.equals(a)); + assertFalse(a.equals(null)); + assertFalse(a.equals("string")); + + b.setDklen(64); + assertNotEquals(a, b); + b.setDklen(32); + + b.setN(1); + assertNotEquals(a, b); + b.setN(262144); + + b.setP(2); + assertNotEquals(a, b); + b.setP(1); + + b.setR(16); + assertNotEquals(a, b); + b.setR(8); + + b.setSalt("other"); + assertNotEquals(a, b); + + // null salt + WalletFile.ScryptKdfParams x = new WalletFile.ScryptKdfParams(); + WalletFile.ScryptKdfParams y = new WalletFile.ScryptKdfParams(); + assertEquals(x, y); + x.setSalt("x"); + assertNotEquals(x, y); + } + + @Test + public void testJsonDeserializeWithScryptKdf() throws Exception { + String json = "{" + + "\"address\":\"TAddr\"," + + "\"version\":3," + + "\"id\":\"uuid\"," + + "\"crypto\":{" + + " \"cipher\":\"aes-128-ctr\"," + + " \"ciphertext\":\"ct\"," + + " \"cipherparams\":{\"iv\":\"iv\"}," + + " \"kdf\":\"scrypt\"," + + " \"kdfparams\":{\"dklen\":32,\"n\":262144,\"p\":1,\"r\":8,\"salt\":\"salt\"}," + + " \"mac\":\"mac\"" + + "}}"; + + WalletFile wf = new ObjectMapper().readValue(json, WalletFile.class); + assertEquals("TAddr", wf.getAddress()); + assertEquals(3, wf.getVersion()); + assertNotNull(wf.getCrypto()); + assertNotNull(wf.getCrypto().getKdfparams()); + assertTrue(wf.getCrypto().getKdfparams() instanceof WalletFile.ScryptKdfParams); + } + + @Test + public void testJsonDeserializeWithAes128Kdf() throws Exception { + String json = "{" + + "\"address\":\"TAddr\"," + + "\"version\":3," + + "\"crypto\":{" + + " \"cipher\":\"aes-128-ctr\"," + + " \"ciphertext\":\"ct\"," + + " \"cipherparams\":{\"iv\":\"iv\"}," + + " \"kdf\":\"pbkdf2\"," + + " \"kdfparams\":{\"dklen\":32,\"c\":262144,\"prf\":\"hmac-sha256\",\"salt\":\"salt\"}," + + " \"mac\":\"mac\"" + + "}}"; + + WalletFile wf = new ObjectMapper().readValue(json, WalletFile.class); + assertNotNull(wf.getCrypto().getKdfparams()); + assertTrue(wf.getCrypto().getKdfparams() instanceof WalletFile.Aes128CtrKdfParams); + } + + @Test + public void testJsonDeserializeCryptoV1Field() throws Exception { + // Legacy files may use "Crypto" instead of "crypto" + String json = "{" + + "\"address\":\"TAddr\"," + + "\"version\":3," + + "\"Crypto\":{" + + " \"cipher\":\"aes-128-ctr\"," + + " \"kdf\":\"scrypt\"," + + " \"kdfparams\":{\"dklen\":32,\"n\":1,\"p\":1,\"r\":8,\"salt\":\"s\"}" + + "}}"; + + WalletFile wf = new ObjectMapper().readValue(json, WalletFile.class); + assertNotNull(wf.getCrypto()); + assertEquals("aes-128-ctr", wf.getCrypto().getCipher()); + } +} diff --git a/framework/src/test/java/org/tron/program/KeystoreFactoryDeprecationTest.java b/framework/src/test/java/org/tron/program/KeystoreFactoryDeprecationTest.java index bf13117f6c0..860980d21e5 100644 --- a/framework/src/test/java/org/tron/program/KeystoreFactoryDeprecationTest.java +++ b/framework/src/test/java/org/tron/program/KeystoreFactoryDeprecationTest.java @@ -2,29 +2,59 @@ import static org.junit.Assert.assertTrue; +import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.File; import java.io.InputStream; import java.io.PrintStream; +import org.junit.After; +import org.junit.Before; import org.junit.Test; +import org.tron.common.TestConstants; +import org.tron.core.config.args.Args; /** - * Verifies that --keystore-factory prints deprecation warning to stderr. + * Verifies the deprecated --keystore-factory CLI. */ public class KeystoreFactoryDeprecationTest { + private PrintStream originalOut; + private PrintStream originalErr; + private InputStream originalIn; + + @Before + public void setup() { + originalOut = System.out; + originalErr = System.err; + originalIn = System.in; + Args.setParam(new String[] {}, TestConstants.TEST_CONF); + } + + @After + public void teardown() throws Exception { + System.setOut(originalOut); + System.setErr(originalErr); + System.setIn(originalIn); + Args.clearParam(); + // Clean up Wallet dir + File wallet = new File("Wallet"); + if (wallet.exists()) { + if (wallet.isDirectory() && wallet.listFiles() != null) { + for (File f : wallet.listFiles()) { + f.delete(); + } + } + wallet.delete(); + } + } + @Test(timeout = 10000) public void testDeprecationWarningPrinted() throws Exception { - PrintStream originalErr = System.err; - InputStream originalIn = System.in; ByteArrayOutputStream errContent = new ByteArrayOutputStream(); System.setErr(new PrintStream(errContent)); - System.setIn(new java.io.ByteArrayInputStream("exit\n".getBytes())); - try { - KeystoreFactory.start(); - } finally { - System.setErr(originalErr); - System.setIn(originalIn); - } + System.setIn(new ByteArrayInputStream("exit\n".getBytes())); + + KeystoreFactory.start(); String errOutput = errContent.toString("UTF-8"); assertTrue("Should contain deprecation warning", @@ -32,4 +62,86 @@ public void testDeprecationWarningPrinted() throws Exception { assertTrue("Should point to Toolkit.jar", errOutput.contains("Toolkit.jar keystore")); } + + @Test(timeout = 10000) + public void testHelpCommand() throws Exception { + ByteArrayOutputStream outContent = new ByteArrayOutputStream(); + System.setOut(new PrintStream(outContent)); + System.setIn(new ByteArrayInputStream("help\nexit\n".getBytes())); + + KeystoreFactory.start(); + + String out = outContent.toString("UTF-8"); + assertTrue("Should show legacy commands", out.contains("GenKeystore")); + assertTrue("Should show ImportPrivateKey", out.contains("ImportPrivateKey")); + } + + @Test(timeout = 10000) + public void testInvalidCommand() throws Exception { + ByteArrayOutputStream outContent = new ByteArrayOutputStream(); + System.setOut(new PrintStream(outContent)); + System.setIn(new ByteArrayInputStream("badcommand\nexit\n".getBytes())); + + KeystoreFactory.start(); + + String out = outContent.toString("UTF-8"); + assertTrue("Should report invalid cmd", + out.contains("Invalid cmd: badcommand")); + } + + @Test(timeout = 10000) + public void testEmptyLineSkipped() throws Exception { + ByteArrayOutputStream outContent = new ByteArrayOutputStream(); + System.setOut(new PrintStream(outContent)); + System.setIn(new ByteArrayInputStream("\n\nexit\n".getBytes())); + + KeystoreFactory.start(); + + String out = outContent.toString("UTF-8"); + assertTrue("Should exit cleanly", out.contains("Exit")); + } + + @Test(timeout = 10000) + public void testQuitCommand() throws Exception { + ByteArrayOutputStream outContent = new ByteArrayOutputStream(); + System.setOut(new PrintStream(outContent)); + System.setIn(new ByteArrayInputStream("quit\n".getBytes())); + + KeystoreFactory.start(); + + String out = outContent.toString("UTF-8"); + assertTrue("Quit should terminate", out.contains("Exit")); + } + + @Test(timeout = 10000) + public void testGenKeystoreTriggersError() throws Exception { + // genkeystore reads password via a nested Scanner, which conflicts + // with the outer Scanner and throws "No line found". The error is + // caught and logged, and the REPL continues. + ByteArrayOutputStream outContent = new ByteArrayOutputStream(); + System.setOut(new PrintStream(outContent)); + System.setIn(new ByteArrayInputStream("genkeystore\nexit\n".getBytes())); + + KeystoreFactory.start(); + + String out = outContent.toString("UTF-8"); + assertTrue("genKeystore should prompt for password", + out.contains("Please input password")); + assertTrue("REPL should continue to exit", out.contains("Exit")); + } + + @Test(timeout = 10000) + public void testImportPrivateKeyTriggersPrompt() throws Exception { + // importprivatekey reads via nested Scanner — same limitation as above, + // but we at least hit the dispatch logic. + ByteArrayOutputStream outContent = new ByteArrayOutputStream(); + System.setOut(new PrintStream(outContent)); + System.setIn(new ByteArrayInputStream("importprivatekey\nexit\n".getBytes())); + + KeystoreFactory.start(); + + String out = outContent.toString("UTF-8"); + assertTrue("importprivatekey should prompt for key", + out.contains("Please input private key")); + } } From cdb038f51b285b9e68dfff0c99b9886e72715ebf Mon Sep 17 00:00:00 2001 From: Barbatos Date: Fri, 17 Apr 2026 14:47:42 +0800 Subject: [PATCH 26/41] ci: re-trigger build after transient apt mirror failure From 4e19b0b803c309c4edbb83e818eda3f65834544d Mon Sep 17 00:00:00 2001 From: Barbatos Date: Fri, 17 Apr 2026 15:41:49 +0800 Subject: [PATCH 27/41] ci: re-trigger after transient infrastructure failure From 368d10419bd873d75e7e0026da883ed5b3a5c27a Mon Sep 17 00:00:00 2001 From: Barbatos Date: Sat, 18 Apr 2026 11:21:21 +0800 Subject: [PATCH 28/41] fix(crypto): enforce keystore address consistency in Wallet.decrypt Prevents address-spoofing attacks where a crafted keystore declares one address in JSON but encrypts a different private key. After decryption, verify that the declared address matches the address derived from the decrypted key. Null/empty addresses are still accepted for compatibility with Ethereum-style keystores that omit the field. --- .../main/java/org/tron/keystore/Wallet.java | 17 +++- .../keystore/WalletAddressValidationTest.java | 93 +++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 crypto/src/test/java/org/tron/keystore/WalletAddressValidationTest.java diff --git a/crypto/src/main/java/org/tron/keystore/Wallet.java b/crypto/src/main/java/org/tron/keystore/Wallet.java index b5cb37a23ab..de1153595fa 100644 --- a/crypto/src/main/java/org/tron/keystore/Wallet.java +++ b/crypto/src/main/java/org/tron/keystore/Wallet.java @@ -211,7 +211,22 @@ public static SignInterface decrypt(String password, WalletFile walletFile, byte[] encryptKey = Arrays.copyOfRange(derivedKey, 0, 16); byte[] privateKey = performCipherOperation(Cipher.DECRYPT_MODE, iv, encryptKey, cipherText); - return SignUtils.fromPrivate(privateKey, ecKey); + SignInterface keyPair = SignUtils.fromPrivate(privateKey, ecKey); + + // Enforce address consistency: if the keystore declares an address, it MUST match + // the address derived from the decrypted private key. Prevents address spoofing + // where a crafted keystore displays one address but encrypts a different key. + String declared = walletFile.getAddress(); + if (declared != null && !declared.isEmpty()) { + String derived = StringUtil.encode58Check(keyPair.getAddress()); + if (!declared.equals(derived)) { + throw new CipherException( + "Keystore address mismatch: file declares " + declared + + " but private key derives " + derived); + } + } + + return keyPair; } static void validate(WalletFile walletFile) throws CipherException { diff --git a/crypto/src/test/java/org/tron/keystore/WalletAddressValidationTest.java b/crypto/src/test/java/org/tron/keystore/WalletAddressValidationTest.java new file mode 100644 index 00000000000..82008988b6e --- /dev/null +++ b/crypto/src/test/java/org/tron/keystore/WalletAddressValidationTest.java @@ -0,0 +1,93 @@ +package org.tron.keystore; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import org.junit.Test; +import org.tron.common.crypto.SignInterface; +import org.tron.common.crypto.SignUtils; +import org.tron.common.utils.Utils; +import org.tron.core.exception.CipherException; + +/** + * Verifies that Wallet.decrypt rejects keystores whose declared address + * does not match the address derived from the decrypted private key, + * preventing address-spoofing attacks. + */ +public class WalletAddressValidationTest { + + @Test + public void testDecryptAcceptsMatchingAddress() throws Exception { + String password = "test123456"; + SignInterface keyPair = SignUtils.getGeneratedRandomSign(Utils.getRandom(), true); + WalletFile walletFile = Wallet.createStandard(password, keyPair); + + // createStandard sets the correct derived address — should decrypt fine + SignInterface recovered = Wallet.decrypt(password, walletFile, true); + assertEquals("Private key must match", + org.tron.common.utils.ByteArray.toHexString(keyPair.getPrivateKey()), + org.tron.common.utils.ByteArray.toHexString(recovered.getPrivateKey())); + } + + @Test + public void testDecryptRejectsSpoofedAddress() throws Exception { + String password = "test123456"; + SignInterface keyPair = SignUtils.getGeneratedRandomSign(Utils.getRandom(), true); + WalletFile walletFile = Wallet.createStandard(password, keyPair); + + // Tamper with the address to simulate a spoofed keystore + walletFile.setAddress("TTamperedAddressXXXXXXXXXXXXXXXXXX"); + + try { + Wallet.decrypt(password, walletFile, true); + fail("Expected CipherException due to address mismatch"); + } catch (CipherException e) { + assertTrue("Error should mention address mismatch, got: " + e.getMessage(), + e.getMessage().contains("address mismatch")); + } + } + + @Test + public void testDecryptAllowsNullAddress() throws Exception { + // Ethereum-style keystores may not include the address field — should still decrypt + String password = "test123456"; + SignInterface keyPair = SignUtils.getGeneratedRandomSign(Utils.getRandom(), true); + WalletFile walletFile = Wallet.createStandard(password, keyPair); + walletFile.setAddress(null); + + SignInterface recovered = Wallet.decrypt(password, walletFile, true); + assertNotNull(recovered); + assertEquals(org.tron.common.utils.ByteArray.toHexString(keyPair.getPrivateKey()), + org.tron.common.utils.ByteArray.toHexString(recovered.getPrivateKey())); + } + + @Test + public void testDecryptAllowsEmptyAddress() throws Exception { + String password = "test123456"; + SignInterface keyPair = SignUtils.getGeneratedRandomSign(Utils.getRandom(), true); + WalletFile walletFile = Wallet.createStandard(password, keyPair); + walletFile.setAddress(""); + + // Empty-string address is treated as absent (no validation) + SignInterface recovered = Wallet.decrypt(password, walletFile, true); + assertNotNull(recovered); + } + + @Test + public void testDecryptRejectsSpoofedAddressSm2() throws Exception { + String password = "test123456"; + SignInterface keyPair = SignUtils.getGeneratedRandomSign(Utils.getRandom(), false); + WalletFile walletFile = Wallet.createStandard(password, keyPair); + + walletFile.setAddress("TSpoofedSm2Addr123456789XXXXXXXX"); + + try { + Wallet.decrypt(password, walletFile, false); + fail("Expected CipherException due to address mismatch on SM2"); + } catch (CipherException e) { + assertTrue(e.getMessage().contains("address mismatch")); + } + } +} From c383a83d30554e45a56b32d425b4cd617b628015 Mon Sep 17 00:00:00 2001 From: Barbatos Date: Sat, 18 Apr 2026 11:23:26 +0800 Subject: [PATCH 29/41] fix(plugins): prevent address spoofing in keystore update command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove newWalletFile.setAddress(walletFile.getAddress()) — createStandard already sets the correctly-derived address, and propagating the JSON address could carry a spoofed value forward - Use the derived address from newWalletFile for output messages instead of walletFile.getAddress(), keeping the output correct as a defense-in-depth measure even if upstream validation is weakened - Add tests for tampered-address rejection and derived-address output --- .../org/tron/plugins/KeystoreUpdate.java | 13 +++- .../org/tron/plugins/KeystoreUpdateTest.java | 74 +++++++++++++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java index 1df0bc89630..5f4d4dfbc8e 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java @@ -144,8 +144,10 @@ public Integer call() { WalletFile walletFile = MAPPER.readValue(keystoreFile, WalletFile.class); SignInterface keyPair = Wallet.decrypt(oldPassword, walletFile, ecKey); + // createStandard already sets the correctly-derived address. Do NOT override + // with walletFile.getAddress() — that would propagate a potentially spoofed + // address from the JSON. WalletFile newWalletFile = Wallet.createStandard(newPassword, keyPair); - newWalletFile.setAddress(walletFile.getAddress()); // Write to temp file first, then atomic rename to prevent corruption File tempFile = File.createTempFile("keystore-", ".tmp", keystoreFile.getParentFile()); @@ -161,13 +163,18 @@ public Integer call() { throw e; } + // Use the derived address from newWalletFile, not walletFile.getAddress(). + // Defense-in-depth: Wallet.decrypt already rejects spoofed addresses, but + // relying on the derived value keeps this code correct even if that check + // is ever weakened. + String verifiedAddress = newWalletFile.getAddress(); if (json) { KeystoreCliUtils.printJson(out, err, KeystoreCliUtils.jsonMap( - "address", walletFile.getAddress(), + "address", verifiedAddress, "file", keystoreFile.getName(), "status", "updated")); } else { - out.println("Password updated for: " + walletFile.getAddress()); + out.println("Password updated for: " + verifiedAddress); } return 0; } catch (CipherException e) { diff --git a/plugins/src/test/java/org/tron/plugins/KeystoreUpdateTest.java b/plugins/src/test/java/org/tron/plugins/KeystoreUpdateTest.java index 4b7d205e74e..b6eb66d1667 100644 --- a/plugins/src/test/java/org/tron/plugins/KeystoreUpdateTest.java +++ b/plugins/src/test/java/org/tron/plugins/KeystoreUpdateTest.java @@ -548,4 +548,78 @@ public void testUpdateSkipsInvalidVersionKeystores() throws Exception { assertTrue("Error should mention no keystore found", err.toString().contains("No keystore found")); } + + @Test + public void testUpdateRejectsTamperedAddressKeystore() throws Exception { + File dir = tempFolder.newFolder("keystore-tampered"); + String password = "test123456"; + + // Create a real keystore, then tamper with the address field to simulate + // a spoofed keystore that claims a different address than its encrypted key. + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + String fileName = WalletUtils.generateWalletFile(password, keyPair, dir, true); + File keystoreFile = new File(dir, fileName); + + String realAddress = Credentials.create(keyPair).getAddress(); + String spoofedAddress = "TSpoofedAddressXXXXXXXXXXXXXXXXXXXX"; + + com.fasterxml.jackson.databind.ObjectMapper mapper = + new com.fasterxml.jackson.databind.ObjectMapper() + .configure(com.fasterxml.jackson.databind.DeserializationFeature + .FAIL_ON_UNKNOWN_PROPERTIES, false); + org.tron.keystore.WalletFile wf = mapper.readValue(keystoreFile, + org.tron.keystore.WalletFile.class); + wf.setAddress(spoofedAddress); + mapper.writeValue(keystoreFile, wf); + + File pwFile = tempFolder.newFile("pw-tampered.txt"); + Files.write(pwFile.toPath(), + (password + "\nnewpass789").getBytes(StandardCharsets.UTF_8)); + + StringWriter err = new StringWriter(); + CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setErr(new PrintWriter(err)); + int exitCode = cmd.execute("keystore", "update", spoofedAddress, + "--keystore-dir", dir.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals("Should fail decryption on tampered address", 1, exitCode); + assertTrue("Error should mention address mismatch, got: " + err.toString(), + err.toString().contains("address mismatch")); + } + + @Test + public void testUpdatePreservesCorrectDerivedAddress() throws Exception { + // After update, the keystore's address field should be the derived address, + // not carried over from the original JSON (defense-in-depth against any + // residual spoofed address that somehow passed decryption). + File dir = tempFolder.newFolder("keystore-derived"); + String oldPassword = "oldpass123"; + String newPassword = "newpass456"; + + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + String fileName = WalletUtils.generateWalletFile(oldPassword, keyPair, dir, true); + String originalAddress = Credentials.create(keyPair).getAddress(); + + File pwFile = tempFolder.newFile("pw-derived.txt"); + Files.write(pwFile.toPath(), + (oldPassword + "\n" + newPassword).getBytes(StandardCharsets.UTF_8)); + + CommandLine cmd = new CommandLine(new Toolkit()); + int exitCode = cmd.execute("keystore", "update", originalAddress, + "--keystore-dir", dir.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals(0, exitCode); + + // Verify updated file has the derived address + com.fasterxml.jackson.databind.ObjectMapper mapper = + new com.fasterxml.jackson.databind.ObjectMapper(); + org.tron.keystore.WalletFile wf = mapper.readValue(new File(dir, fileName), + org.tron.keystore.WalletFile.class); + assertEquals("Updated keystore address must match derived address", + originalAddress, wf.getAddress()); + } } From 5a76ed87286d19edea2e58059f71d32dfd7a8db7 Mon Sep 17 00:00:00 2001 From: Barbatos Date: Sat, 18 Apr 2026 11:24:09 +0800 Subject: [PATCH 30/41] docs(plugins): document keystore list address trust model Clarify that 'keystore list' displays the declared address from each keystore's JSON without decrypting it. A tampered keystore may claim an address that does not correspond to its encrypted key; verification only happens at decryption time (e.g. 'keystore update'). --- plugins/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/README.md b/plugins/README.md index dc16b3ecf35..ab64bf8279a 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -193,6 +193,8 @@ List all keystore files in a directory. java -jar Toolkit.jar keystore list --keystore-dir /data/keystores # custom directory ``` +> **Note**: `list` displays the `address` field as declared in each keystore JSON without decrypting the file. A tampered keystore can claim an address that does not correspond to its encrypted private key. The address is only cryptographically verified at decryption time (e.g. by `update` or by tools that load the credentials). Only trust keystores from sources you control. + #### keystore update Change the password of a keystore file. From 070316cf4f081de1d095b628758d13d3630dbe2f Mon Sep 17 00:00:00 2001 From: Barbatos Date: Sat, 18 Apr 2026 20:35:06 +0800 Subject: [PATCH 31/41] refactor(crypto): centralize secure keystore file writing in WalletUtils Move the temp-file + atomic-rename + POSIX 0600 permissions pattern from plugins into WalletUtils.generateWalletFile and a new public WalletUtils.writeWalletFile method. Use Files.createTempFile with PosixFilePermissions.asFileAttribute to atomically create the temp file with owner-only permissions, eliminating the brief world-readable window present in the previous File.createTempFile + setOwnerOnly sequence. All callers benefit automatically, including framework/KeystoreFactory (which previously wrote 0644 files) and any third-party project that consumes the crypto artifact. Removes ~68 lines of duplicated plumbing from KeystoreCliUtils. - Windows/non-POSIX fallback is best-effort (DOS attributes) and documented as such in JavaDoc - Error path suppresses secondary IOException via addSuppressed so the original cause is preserved - OWNER_ONLY wrapped with Collections.unmodifiableSet for defense in depth - WalletUtilsWriteTest covers permissions, replacement, temp cleanup, parent-directory creation, and a failure-path test that forces move to fail and asserts no temp file remains - KeystoreUpdateTest adversarially pre-loosens perms to 0644 and verifies update narrows back to 0600 --- .../java/org/tron/keystore/WalletUtils.java | 74 +++++++- .../tron/keystore/WalletUtilsWriteTest.java | 169 ++++++++++++++++++ .../org/tron/plugins/KeystoreCliUtils.java | 68 ------- .../org/tron/plugins/KeystoreImport.java | 5 +- .../common/org/tron/plugins/KeystoreNew.java | 5 +- .../org/tron/plugins/KeystoreUpdate.java | 16 +- .../tron/plugins/KeystoreCliUtilsTest.java | 91 ---------- .../org/tron/plugins/KeystoreUpdateTest.java | 49 +++++ 8 files changed, 298 insertions(+), 179 deletions(-) create mode 100644 crypto/src/test/java/org/tron/keystore/WalletUtilsWriteTest.java diff --git a/crypto/src/main/java/org/tron/keystore/WalletUtils.java b/crypto/src/main/java/org/tron/keystore/WalletUtils.java index ad4f434005f..f9b04686d85 100644 --- a/crypto/src/main/java/org/tron/keystore/WalletUtils.java +++ b/crypto/src/main/java/org/tron/keystore/WalletUtils.java @@ -6,13 +6,22 @@ import java.io.Console; import java.io.File; import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; import java.security.InvalidAlgorithmParameterException; import java.security.NoSuchAlgorithmException; import java.security.NoSuchProviderException; import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; +import java.util.Collections; +import java.util.EnumSet; import java.util.Scanner; +import java.util.Set; import org.apache.commons.lang3.StringUtils; import org.tron.common.crypto.SignInterface; import org.tron.common.crypto.SignUtils; @@ -26,6 +35,10 @@ public class WalletUtils { private static final ObjectMapper objectMapper = new ObjectMapper(); + private static final Set OWNER_ONLY = + Collections.unmodifiableSet(EnumSet.of( + PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)); + static { objectMapper.configure(JsonParser.Feature.ALLOW_UNQUOTED_FIELD_NAMES, true); objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); @@ -69,12 +82,69 @@ public static String generateWalletFile( String fileName = getWalletFileName(walletFile); File destination = new File(destinationDirectory, fileName); - - objectMapper.writeValue(destination, walletFile); + writeWalletFile(walletFile, destination); return fileName; } + /** + * Write a WalletFile to the given destination path with owner-only (0600) + * permissions, using a temp file + atomic rename. + * + *

On POSIX filesystems, the temp file is created atomically with 0600 + * permissions via {@link Files#createTempFile(Path, String, String, + * java.nio.file.attribute.FileAttribute[])}, avoiding any window where the + * file is world-readable. + * + *

On non-POSIX filesystems (e.g. Windows) the fallback uses + * {@link File#setReadable(boolean, boolean)} / + * {@link File#setWritable(boolean, boolean)} which is best-effort — these + * methods manipulate only DOS-style attributes on Windows and may not update + * file ACLs. The sensitive keystore JSON is written only after the narrowing + * calls, so no confidential data is exposed during the window, but callers + * on Windows should not infer strict owner-only ACL enforcement from this. + * + * @param walletFile the keystore to serialize + * @param destination the final target file (existing file will be replaced) + */ + public static void writeWalletFile(WalletFile walletFile, File destination) + throws IOException { + Path dir = destination.getAbsoluteFile().getParentFile().toPath(); + Files.createDirectories(dir); + + Path tmp; + try { + tmp = Files.createTempFile(dir, "keystore-", ".tmp", + PosixFilePermissions.asFileAttribute(OWNER_ONLY)); + } catch (UnsupportedOperationException e) { + // Windows / non-POSIX fallback — best-effort narrowing only (see JavaDoc) + tmp = Files.createTempFile(dir, "keystore-", ".tmp"); + File tf = tmp.toFile(); + tf.setReadable(false, false); + tf.setReadable(true, true); + tf.setWritable(false, false); + tf.setWritable(true, true); + } + + try { + objectMapper.writeValue(tmp.toFile(), walletFile); + try { + Files.move(tmp, destination.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + Files.move(tmp, destination.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + } catch (Exception e) { + try { + Files.deleteIfExists(tmp); + } catch (IOException suppress) { + e.addSuppressed(suppress); + } + throw e; + } + } + public static Credentials loadCredentials(String password, File source, boolean ecKey) throws IOException, CipherException { WalletFile walletFile = objectMapper.readValue(source, WalletFile.class); diff --git a/crypto/src/test/java/org/tron/keystore/WalletUtilsWriteTest.java b/crypto/src/test/java/org/tron/keystore/WalletUtilsWriteTest.java new file mode 100644 index 00000000000..f67db5db20d --- /dev/null +++ b/crypto/src/test/java/org/tron/keystore/WalletUtilsWriteTest.java @@ -0,0 +1,169 @@ +package org.tron.keystore; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.attribute.PosixFilePermission; +import java.util.EnumSet; +import java.util.Set; +import org.junit.Assume; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.common.crypto.SignInterface; +import org.tron.common.crypto.SignUtils; +import org.tron.common.utils.Utils; + +/** + * Verifies that {@link WalletUtils#generateWalletFile} and + * {@link WalletUtils#writeWalletFile} produce keystore files with + * owner-only permissions (0600) atomically, leaving no temp files behind. + * + *

Tests use light scrypt (useFullScrypt=false) where possible because + * they validate filesystem behavior, not the KDF parameters. + */ +public class WalletUtilsWriteTest { + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + private static WalletFile lightWalletFile(String password) throws Exception { + SignInterface keyPair = SignUtils.getGeneratedRandomSign(Utils.getRandom(), true); + return Wallet.createLight(password, keyPair); + } + + @Test + public void testGenerateWalletFileCreatesOwnerOnlyFile() throws Exception { + Assume.assumeTrue("POSIX permissions test", + !System.getProperty("os.name").toLowerCase().contains("win")); + + File dir = tempFolder.newFolder("gen-perms"); + SignInterface keyPair = SignUtils.getGeneratedRandomSign(Utils.getRandom(), true); + + String fileName = WalletUtils.generateWalletFile("password123", keyPair, dir, false); + + File created = new File(dir, fileName); + assertTrue(created.exists()); + + Set perms = Files.getPosixFilePermissions(created.toPath()); + assertEquals("Keystore must have owner-only permissions (rw-------)", + EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE), + perms); + } + + @Test + public void testGenerateWalletFileLeavesNoTempFile() throws Exception { + File dir = tempFolder.newFolder("gen-no-temp"); + SignInterface keyPair = SignUtils.getGeneratedRandomSign(Utils.getRandom(), true); + + WalletUtils.generateWalletFile("password123", keyPair, dir, false); + + File[] tempFiles = dir.listFiles((d, name) -> name.startsWith("keystore-") + && name.endsWith(".tmp")); + assertNotNull(tempFiles); + assertEquals("No temp files should remain after generation", 0, tempFiles.length); + } + + @Test + public void testGenerateWalletFileLightScrypt() throws Exception { + File dir = tempFolder.newFolder("gen-light"); + SignInterface keyPair = SignUtils.getGeneratedRandomSign(Utils.getRandom(), true); + + String fileName = WalletUtils.generateWalletFile("password123", keyPair, dir, false); + assertNotNull(fileName); + assertTrue(fileName.endsWith(".json")); + assertTrue(new File(dir, fileName).exists()); + } + + @Test + public void testWriteWalletFileOwnerOnly() throws Exception { + Assume.assumeTrue("POSIX permissions test", + !System.getProperty("os.name").toLowerCase().contains("win")); + + File dir = tempFolder.newFolder("write-perms"); + WalletFile wf = lightWalletFile("password123"); + File destination = new File(dir, "out.json"); + + WalletUtils.writeWalletFile(wf, destination); + + assertTrue(destination.exists()); + Set perms = Files.getPosixFilePermissions(destination.toPath()); + assertEquals("Keystore must have owner-only permissions (rw-------)", + EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE), + perms); + } + + @Test + public void testWriteWalletFileReplacesExisting() throws Exception { + File dir = tempFolder.newFolder("write-replace"); + WalletFile wf1 = lightWalletFile("password123"); + WalletFile wf2 = lightWalletFile("password123"); + File destination = new File(dir, "out.json"); + + WalletUtils.writeWalletFile(wf1, destination); + WalletUtils.writeWalletFile(wf2, destination); + + assertTrue("Destination exists after replace", destination.exists()); + WalletFile reread = new com.fasterxml.jackson.databind.ObjectMapper() + .readValue(destination, WalletFile.class); + assertEquals("Replaced file should have wf2's address", + wf2.getAddress(), reread.getAddress()); + } + + @Test + public void testWriteWalletFileLeavesNoTempFile() throws Exception { + File dir = tempFolder.newFolder("write-no-temp"); + WalletFile wf = lightWalletFile("password123"); + File destination = new File(dir, "final.json"); + + WalletUtils.writeWalletFile(wf, destination); + + File[] tempFiles = dir.listFiles((d, name) -> name.startsWith("keystore-") + && name.endsWith(".tmp")); + assertNotNull(tempFiles); + assertEquals("No temp files should remain", 0, tempFiles.length); + } + + @Test + public void testWriteWalletFileCreatesParentDirectories() throws Exception { + File base = tempFolder.newFolder("write-nested"); + File destination = new File(base, "a/b/c/out.json"); + assertFalse("Parent dir does not exist yet", destination.getParentFile().exists()); + + WalletFile wf = lightWalletFile("password123"); + WalletUtils.writeWalletFile(wf, destination); + + assertTrue("Destination written", destination.exists()); + } + + @Test + public void testWriteWalletFileCleansUpTempOnFailure() throws Exception { + // Force failure by making the destination a directory — Files.move will fail + // because the source is a file. The temp file must be cleaned up. + File dir = tempFolder.newFolder("write-fail"); + File destinationAsDir = new File(dir, "blocking-dir"); + assertTrue("Setup: blocking dir created", destinationAsDir.mkdir()); + // Put a file inside so Files.move with REPLACE_EXISTING fails (non-empty dir). + assertTrue("Setup: block file", new File(destinationAsDir, "blocker").createNewFile()); + + WalletFile wf = lightWalletFile("password123"); + + try { + WalletUtils.writeWalletFile(wf, destinationAsDir); + fail("Expected IOException because destination is a non-empty directory"); + } catch (IOException expected) { + // Expected + } + + File[] tempFiles = dir.listFiles((d, name) -> name.startsWith("keystore-") + && name.endsWith(".tmp")); + assertNotNull(tempFiles); + assertEquals("Temp file must be cleaned up on failure", 0, tempFiles.length); + } +} diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java index 961f28eb552..a1d4f399587 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java @@ -8,16 +8,9 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.StandardCopyOption; -import java.nio.file.attribute.PosixFilePermission; import java.util.Arrays; -import java.util.EnumSet; import java.util.LinkedHashMap; import java.util.Map; -import java.util.Set; -import org.tron.common.crypto.SignInterface; -import org.tron.core.exception.CipherException; -import org.tron.keystore.Wallet; import org.tron.keystore.WalletFile; import org.tron.keystore.WalletUtils; @@ -26,63 +19,11 @@ */ final class KeystoreCliUtils { - private static final Set OWNER_ONLY = EnumSet.of( - PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE); - private static final long MAX_FILE_SIZE = 1024; private KeystoreCliUtils() { } - /** - * Generate a keystore file using temp-file + atomic-rename to avoid - * a TOCTOU window where the file is world-readable before permissions are set. - * - * @return the final keystore file name (not the full path) - */ - static String generateKeystoreFile(String password, SignInterface keyPair, - File destDir, boolean useFullScrypt, PrintWriter err) - throws CipherException, IOException { - - WalletFile walletFile; - if (useFullScrypt) { - walletFile = Wallet.createStandard(password, keyPair); - } else { - walletFile = Wallet.createLight(password, keyPair); - } - - String fileName = WalletUtils.getWalletFileName(walletFile); - File destination = new File(destDir, fileName); - - File tempFile = File.createTempFile("keystore-", ".tmp", destDir); - try { - setOwnerOnly(tempFile, err); - MAPPER.writeValue(tempFile, walletFile); - atomicMove(tempFile, destination); - } catch (Exception e) { - if (!tempFile.delete()) { - err.println("Warning: could not delete temp file: " + tempFile.getName()); - } - throw e; - } - - return fileName; - } - - /** - * Atomic move with fallback for filesystems that don't support it. - */ - static void atomicMove(File source, File target) throws IOException { - try { - Files.move(source.toPath(), target.toPath(), - StandardCopyOption.REPLACE_EXISTING, - StandardCopyOption.ATOMIC_MOVE); - } catch (java.nio.file.AtomicMoveNotSupportedException e) { - Files.move(source.toPath(), target.toPath(), - StandardCopyOption.REPLACE_EXISTING); - } - } - static String readPassword(File passwordFile, PrintWriter err) throws IOException { if (passwordFile != null) { if (!passwordFile.exists()) { @@ -228,13 +169,4 @@ static boolean isValidKeystoreFile(WalletFile wf) { && wf.getCrypto() != null && wf.getVersion() == 3; } - - static void setOwnerOnly(File file, PrintWriter err) { - try { - Files.setPosixFilePermissions(file.toPath(), OWNER_ONLY); - } catch (UnsupportedOperationException | IOException e) { - err.println("Warning: could not set file permissions on " + file.getName() - + ". Please manually restrict access to this file."); - } - } } diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java index 7b3589c11f6..4e3f53630f9 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java @@ -15,6 +15,7 @@ import org.tron.core.exception.CipherException; import org.tron.keystore.Credentials; import org.tron.keystore.WalletFile; +import org.tron.keystore.WalletUtils; import picocli.CommandLine.Command; import picocli.CommandLine.Model.CommandSpec; import picocli.CommandLine.Option; @@ -99,8 +100,8 @@ public Integer call() { + ". Use --force to import anyway."); return 1; } - String fileName = KeystoreCliUtils.generateKeystoreFile( - password, keyPair, keystoreDir, true, err); + String fileName = WalletUtils.generateWalletFile( + password, keyPair, keystoreDir, true); if (json) { KeystoreCliUtils.printJson(out, err, KeystoreCliUtils.jsonMap( "address", address, "file", fileName)); diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreNew.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreNew.java index c154d81d95d..39d2bdd3502 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreNew.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreNew.java @@ -8,6 +8,7 @@ import org.tron.common.utils.Utils; import org.tron.core.exception.CipherException; import org.tron.keystore.Credentials; +import org.tron.keystore.WalletUtils; import picocli.CommandLine.Command; import picocli.CommandLine.Model.CommandSpec; import picocli.CommandLine.Option; @@ -52,8 +53,8 @@ public Integer call() { boolean ecKey = !sm2; SignInterface keyPair = SignUtils.getGeneratedRandomSign(Utils.getRandom(), ecKey); - String fileName = KeystoreCliUtils.generateKeystoreFile( - password, keyPair, keystoreDir, true, err); + String fileName = WalletUtils.generateWalletFile( + password, keyPair, keystoreDir, true); String address = Credentials.create(keyPair).getAddress(); if (json) { diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java index 5f4d4dfbc8e..422b227b33c 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java @@ -148,20 +148,8 @@ public Integer call() { // with walletFile.getAddress() — that would propagate a potentially spoofed // address from the JSON. WalletFile newWalletFile = Wallet.createStandard(newPassword, keyPair); - // Write to temp file first, then atomic rename to prevent corruption - File tempFile = File.createTempFile("keystore-", ".tmp", - keystoreFile.getParentFile()); - try { - KeystoreCliUtils.setOwnerOnly(tempFile, err); - MAPPER.writeValue(tempFile, newWalletFile); - KeystoreCliUtils.atomicMove(tempFile, keystoreFile); - } catch (Exception e) { - if (!tempFile.delete()) { - err.println("Warning: could not delete temp file: " - + tempFile.getName()); - } - throw e; - } + // writeWalletFile does a secure temp-file + atomic rename internally. + WalletUtils.writeWalletFile(newWalletFile, keystoreFile); // Use the derived address from newWalletFile, not walletFile.getAddress(). // Defense-in-depth: Wallet.decrypt already rejects spoofed addresses, but diff --git a/plugins/src/test/java/org/tron/plugins/KeystoreCliUtilsTest.java b/plugins/src/test/java/org/tron/plugins/KeystoreCliUtilsTest.java index 72d0803665f..76b8e087978 100644 --- a/plugins/src/test/java/org/tron/plugins/KeystoreCliUtilsTest.java +++ b/plugins/src/test/java/org/tron/plugins/KeystoreCliUtilsTest.java @@ -11,13 +11,10 @@ import java.io.StringWriter; import java.nio.charset.StandardCharsets; import java.nio.file.Files; -import java.security.SecureRandom; import java.util.Map; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; -import org.tron.common.crypto.SignInterface; -import org.tron.common.crypto.SignUtils; import org.tron.keystore.WalletFile; public class KeystoreCliUtilsTest { @@ -259,92 +256,4 @@ public void testPrintSecurityTipsIncludesAddressAndFile() { assertTrue(s.contains("REMEMBER")); } - @Test - public void testAtomicMove() throws Exception { - File src = tempFolder.newFile("src.txt"); - Files.write(src.toPath(), "hello".getBytes(StandardCharsets.UTF_8)); - File target = new File(tempFolder.getRoot(), "target.txt"); - - KeystoreCliUtils.atomicMove(src, target); - assertFalse(src.exists()); - assertTrue(target.exists()); - assertEquals("hello", - new String(Files.readAllBytes(target.toPath()), StandardCharsets.UTF_8)); - } - - @Test - public void testAtomicMoveReplacesExisting() throws Exception { - File src = tempFolder.newFile("src2.txt"); - Files.write(src.toPath(), "new".getBytes(StandardCharsets.UTF_8)); - File target = tempFolder.newFile("target2.txt"); - Files.write(target.toPath(), "old".getBytes(StandardCharsets.UTF_8)); - - KeystoreCliUtils.atomicMove(src, target); - assertEquals("new", - new String(Files.readAllBytes(target.toPath()), StandardCharsets.UTF_8)); - } - - @Test - public void testGenerateKeystoreFileFullScrypt() throws Exception { - File dir = tempFolder.newFolder("gen-full"); - SignInterface keyPair = SignUtils.getGeneratedRandomSign( - SecureRandom.getInstance("NativePRNG"), true); - StringWriter err = new StringWriter(); - - String fileName = KeystoreCliUtils.generateKeystoreFile( - "password123", keyPair, dir, true, new PrintWriter(err)); - - assertNotNull(fileName); - assertTrue(fileName.endsWith(".json")); - File file = new File(dir, fileName); - assertTrue(file.exists()); - } - - @Test - public void testGenerateKeystoreFileLightScrypt() throws Exception { - File dir = tempFolder.newFolder("gen-light"); - SignInterface keyPair = SignUtils.getGeneratedRandomSign( - SecureRandom.getInstance("NativePRNG"), true); - StringWriter err = new StringWriter(); - - String fileName = KeystoreCliUtils.generateKeystoreFile( - "password123", keyPair, dir, false, new PrintWriter(err)); - - assertNotNull(fileName); - File file = new File(dir, fileName); - assertTrue(file.exists()); - } - - @Test - public void testGenerateKeystoreFileLeavesNoTempFile() throws Exception { - File dir = tempFolder.newFolder("gen-notemp"); - SignInterface keyPair = SignUtils.getGeneratedRandomSign( - SecureRandom.getInstance("NativePRNG"), true); - StringWriter err = new StringWriter(); - - KeystoreCliUtils.generateKeystoreFile( - "password123", keyPair, dir, false, new PrintWriter(err)); - - File[] tempFiles = dir.listFiles((d, name) -> name.startsWith("keystore-") - && name.endsWith(".tmp")); - assertNotNull(tempFiles); - assertEquals("No temp files should remain after generation", 0, tempFiles.length); - } - - @Test - public void testSetOwnerOnly() throws Exception { - String os = System.getProperty("os.name").toLowerCase(); - org.junit.Assume.assumeTrue("POSIX permissions test", !os.contains("win")); - - File f = tempFolder.newFile("perm-test.txt"); - StringWriter err = new StringWriter(); - KeystoreCliUtils.setOwnerOnly(f, new PrintWriter(err)); - - java.util.Set perms = - Files.getPosixFilePermissions(f.toPath()); - assertEquals(java.util.EnumSet.of( - java.nio.file.attribute.PosixFilePermission.OWNER_READ, - java.nio.file.attribute.PosixFilePermission.OWNER_WRITE), - perms); - } } diff --git a/plugins/src/test/java/org/tron/plugins/KeystoreUpdateTest.java b/plugins/src/test/java/org/tron/plugins/KeystoreUpdateTest.java index b6eb66d1667..d249369571c 100644 --- a/plugins/src/test/java/org/tron/plugins/KeystoreUpdateTest.java +++ b/plugins/src/test/java/org/tron/plugins/KeystoreUpdateTest.java @@ -622,4 +622,53 @@ public void testUpdatePreservesCorrectDerivedAddress() throws Exception { assertEquals("Updated keystore address must match derived address", originalAddress, wf.getAddress()); } + + @Test + public void testUpdateNarrowsLoosePermissionsTo0600() throws Exception { + // Adversarial test: pre-loosen the keystore to 0644, then verify that + // update writes the file back with 0600. This exercises the temp-file + // + atomic-rename path rather than merely preserving existing perms. + String os = System.getProperty("os.name").toLowerCase(); + org.junit.Assume.assumeTrue("POSIX permissions test, skip on Windows", + !os.contains("win")); + + File dir = tempFolder.newFolder("keystore-perms"); + String oldPassword = "oldpass123"; + String newPassword = "newpass456"; + + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + String fileName = WalletUtils.generateWalletFile(oldPassword, keyPair, dir, true); + Credentials creds = WalletUtils.loadCredentials(oldPassword, + new File(dir, fileName), true); + + // Deliberately loosen to 0644 before update + java.nio.file.Path keystorePath = new File(dir, fileName).toPath(); + java.nio.file.Files.setPosixFilePermissions(keystorePath, + java.util.EnumSet.of( + java.nio.file.attribute.PosixFilePermission.OWNER_READ, + java.nio.file.attribute.PosixFilePermission.OWNER_WRITE, + java.nio.file.attribute.PosixFilePermission.GROUP_READ, + java.nio.file.attribute.PosixFilePermission.OTHERS_READ)); + + File pwFile = tempFolder.newFile("pw-perms.txt"); + Files.write(pwFile.toPath(), + (oldPassword + "\n" + newPassword).getBytes(StandardCharsets.UTF_8)); + + CommandLine cmd = new CommandLine(new Toolkit()); + int exitCode = cmd.execute("keystore", "update", creds.getAddress(), + "--keystore-dir", dir.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals(0, exitCode); + + // Verify the updated keystore file is now owner-only (0600), not 0644 + java.util.Set perms = + java.nio.file.Files.getPosixFilePermissions(keystorePath); + assertEquals("Updated keystore must be narrowed to owner-only (rw-------)", + java.util.EnumSet.of( + java.nio.file.attribute.PosixFilePermission.OWNER_READ, + java.nio.file.attribute.PosixFilePermission.OWNER_WRITE), + perms); + } } From 40ec5cea8cdadb035cf1ab0819b389e48d4f6757 Mon Sep 17 00:00:00 2001 From: Barbatos Date: Sat, 18 Apr 2026 20:53:42 +0800 Subject: [PATCH 32/41] fix(plugins): reject symlinked password/key files to prevent file disclosure By default java.nio.file.Files.readAllBytes follows symbolic links, allowing an attacker who can plant files in a user-supplied path to redirect reads to arbitrary files (e.g. /etc/shadow, SSH private keys). The first ~1KB of the linked file would be misused as a password or private key. Introduces KeystoreCliUtils.readRegularFile that: - stats the path with LinkOption.NOFOLLOW_LINKS (lstat semantics) - rejects symlinks, directories, FIFOs and other non-regular files - opens the byte channel with LinkOption.NOFOLLOW_LINKS too, closing the TOCTOU window between stat and open - enforces a single size check via the lstat-returned attributes instead of a separate File.length() call All three call sites are migrated: - KeystoreCliUtils.readPassword (used by new/import) - KeystoreImport.readPrivateKey (key file) - KeystoreUpdate.call (password file for old+new passwords) Tests: - unit tests for readRegularFile covering success, missing file, too-large, symlink refused, directory refused, and empty file - end-to-end tests in KeystoreImportTest that provide a symlinked --key-file and --password-file and assert refusal --- .../org/tron/plugins/KeystoreCliUtils.java | 79 ++++++++++++++++-- .../org/tron/plugins/KeystoreImport.java | 6 +- .../org/tron/plugins/KeystoreUpdate.java | 12 +-- .../tron/plugins/KeystoreCliUtilsTest.java | 80 +++++++++++++++++++ .../org/tron/plugins/KeystoreImportTest.java | 62 ++++++++++++++ 5 files changed, 218 insertions(+), 21 deletions(-) diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java index a1d4f399587..9d031663a18 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java @@ -5,10 +5,18 @@ import java.io.File; import java.io.IOException; import java.io.PrintWriter; +import java.nio.ByteBuffer; +import java.nio.channels.SeekableByteChannel; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.NoSuchFileException; +import java.nio.file.OpenOption; import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.BasicFileAttributes; import java.util.Arrays; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.Map; import org.tron.keystore.WalletFile; @@ -24,18 +32,73 @@ final class KeystoreCliUtils { private KeystoreCliUtils() { } + /** + * Read a regular file safely without following symbolic links. + * + *

This prevents an attacker who can plant files in a user-supplied + * path from redirecting the read to an arbitrary file on disk (e.g. a + * symlink pointing at {@code /etc/shadow} or a user's SSH private key). + * Also rejects FIFOs, devices and other non-regular files. + * + * @param file the file to read + * @param maxSize maximum acceptable file size in bytes + * @param label human-readable label used in error messages + * @param err writer for diagnostic messages + * @return file bytes, or {@code null} if the file is missing, a symlink, + * not a regular file, or too large (err is written in each case) + */ + static byte[] readRegularFile(File file, long maxSize, String label, PrintWriter err) + throws IOException { + Path path = file.toPath(); + + BasicFileAttributes attrs; + try { + attrs = Files.readAttributes(path, BasicFileAttributes.class, + LinkOption.NOFOLLOW_LINKS); + } catch (NoSuchFileException e) { + err.println(label + " not found: " + file.getPath()); + return null; + } + + if (attrs.isSymbolicLink()) { + err.println("Refusing to follow symbolic link: " + file.getPath()); + return null; + } + if (!attrs.isRegularFile()) { + err.println("Not a regular file: " + file.getPath()); + return null; + } + if (attrs.size() > maxSize) { + err.println(label + " too large (max " + maxSize + " bytes): " + file.getPath()); + return null; + } + + int size = (int) attrs.size(); + java.util.Set openOptions = new HashSet<>(); + openOptions.add(StandardOpenOption.READ); + openOptions.add(LinkOption.NOFOLLOW_LINKS); + try (SeekableByteChannel ch = Files.newByteChannel(path, openOptions)) { + ByteBuffer buf = ByteBuffer.allocate(size); + while (buf.hasRemaining()) { + if (ch.read(buf) < 0) { + break; + } + } + if (buf.position() < size) { + byte[] actual = new byte[buf.position()]; + System.arraycopy(buf.array(), 0, actual, 0, buf.position()); + return actual; + } + return buf.array(); + } + } + static String readPassword(File passwordFile, PrintWriter err) throws IOException { if (passwordFile != null) { - if (!passwordFile.exists()) { - err.println("Password file not found: " + passwordFile.getPath() - + ". Omit --password-file for interactive input."); - return null; - } - if (passwordFile.length() > MAX_FILE_SIZE) { - err.println("Password file too large (max 1KB)."); + byte[] bytes = readRegularFile(passwordFile, MAX_FILE_SIZE, "Password file", err); + if (bytes == null) { return null; } - byte[] bytes = Files.readAllBytes(passwordFile.toPath()); try { String password = stripLineEndings( new String(bytes, StandardCharsets.UTF_8)); diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java index 4e3f53630f9..6832678e36a 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java @@ -5,7 +5,6 @@ import java.io.IOException; import java.io.PrintWriter; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; import java.util.Arrays; import java.util.concurrent.Callable; import org.apache.commons.lang3.StringUtils; @@ -122,11 +121,10 @@ public Integer call() { private String readPrivateKey(PrintWriter err) throws IOException { if (keyFile != null) { - if (keyFile.length() > 1024) { - err.println("Key file too large (max 1KB)."); + byte[] bytes = KeystoreCliUtils.readRegularFile(keyFile, 1024, "Key file", err); + if (bytes == null) { return null; } - byte[] bytes = Files.readAllBytes(keyFile.toPath()); try { return new String(bytes, StandardCharsets.UTF_8).trim(); } finally { diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java index 422b227b33c..5f57dfb6743 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java @@ -6,7 +6,6 @@ import java.io.IOException; import java.io.PrintWriter; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; import java.util.Arrays; import java.util.concurrent.Callable; import org.tron.common.crypto.SignInterface; @@ -66,16 +65,11 @@ public Integer call() { String newPassword; if (passwordFile != null) { - if (!passwordFile.exists()) { - err.println("Password file not found: " + passwordFile.getPath() - + ". Omit --password-file for interactive input."); + byte[] bytes = KeystoreCliUtils.readRegularFile( + passwordFile, 1024, "Password file", err); + if (bytes == null) { return 1; } - if (passwordFile.length() > 1024) { - err.println("Password file too large (max 1KB)."); - return 1; - } - byte[] bytes = Files.readAllBytes(passwordFile.toPath()); try { String content = new String(bytes, StandardCharsets.UTF_8); // Strip UTF-8 BOM if present (Windows Notepad) diff --git a/plugins/src/test/java/org/tron/plugins/KeystoreCliUtilsTest.java b/plugins/src/test/java/org/tron/plugins/KeystoreCliUtilsTest.java index 76b8e087978..756fb7c803d 100644 --- a/plugins/src/test/java/org/tron/plugins/KeystoreCliUtilsTest.java +++ b/plugins/src/test/java/org/tron/plugins/KeystoreCliUtilsTest.java @@ -256,4 +256,84 @@ public void testPrintSecurityTipsIncludesAddressAndFile() { assertTrue(s.contains("REMEMBER")); } + @Test + public void testReadRegularFileSuccess() throws Exception { + File f = tempFolder.newFile("regular.txt"); + Files.write(f.toPath(), "hello".getBytes(StandardCharsets.UTF_8)); + StringWriter err = new StringWriter(); + + byte[] bytes = KeystoreCliUtils.readRegularFile(f, 1024, "File", + new PrintWriter(err)); + assertNotNull(bytes); + assertEquals("hello", new String(bytes, StandardCharsets.UTF_8)); + } + + @Test + public void testReadRegularFileMissing() throws Exception { + File f = new File(tempFolder.getRoot(), "does-not-exist"); + StringWriter err = new StringWriter(); + + byte[] bytes = KeystoreCliUtils.readRegularFile(f, 1024, "Password file", + new PrintWriter(err)); + assertNull(bytes); + assertTrue("Expected 'not found' error, got: " + err.toString(), + err.toString().contains("Password file not found")); + } + + @Test + public void testReadRegularFileTooLarge() throws Exception { + File f = tempFolder.newFile("big.txt"); + byte[] big = new byte[2048]; + java.util.Arrays.fill(big, (byte) 'a'); + Files.write(f.toPath(), big); + StringWriter err = new StringWriter(); + + byte[] bytes = KeystoreCliUtils.readRegularFile(f, 1024, "Password file", + new PrintWriter(err)); + assertNull(bytes); + assertTrue("Expected 'too large', got: " + err.toString(), + err.toString().contains("too large")); + } + + @Test + public void testReadRegularFileRefusesSymlink() throws Exception { + org.junit.Assume.assumeTrue("Symlinks only tested on POSIX", + !System.getProperty("os.name").toLowerCase().contains("win")); + + File target = tempFolder.newFile("real-target.txt"); + Files.write(target.toPath(), "secret content".getBytes(StandardCharsets.UTF_8)); + File link = new File(tempFolder.getRoot(), "symlink.txt"); + Files.createSymbolicLink(link.toPath(), target.toPath()); + + StringWriter err = new StringWriter(); + byte[] bytes = KeystoreCliUtils.readRegularFile(link, 1024, "File", + new PrintWriter(err)); + + assertNull("Must refuse to read through symlink", bytes); + assertTrue("Expected symlink-refusal message, got: " + err.toString(), + err.toString().contains("Refusing to follow symbolic link")); + } + + @Test + public void testReadRegularFileRefusesDirectory() throws Exception { + File dir = tempFolder.newFolder("a-dir"); + StringWriter err = new StringWriter(); + + byte[] bytes = KeystoreCliUtils.readRegularFile(dir, 1024, "File", + new PrintWriter(err)); + assertNull(bytes); + assertTrue("Expected not-regular-file error, got: " + err.toString(), + err.toString().contains("Not a regular file")); + } + + @Test + public void testReadRegularFileEmptyFile() throws Exception { + File f = tempFolder.newFile("empty.txt"); + StringWriter err = new StringWriter(); + + byte[] bytes = KeystoreCliUtils.readRegularFile(f, 1024, "File", + new PrintWriter(err)); + assertNotNull(bytes); + assertEquals(0, bytes.length); + } } diff --git a/plugins/src/test/java/org/tron/plugins/KeystoreImportTest.java b/plugins/src/test/java/org/tron/plugins/KeystoreImportTest.java index 4ad6e650c42..94b1c7c21cb 100644 --- a/plugins/src/test/java/org/tron/plugins/KeystoreImportTest.java +++ b/plugins/src/test/java/org/tron/plugins/KeystoreImportTest.java @@ -374,6 +374,68 @@ public void testImportKeystoreFilePermissions() throws Exception { perms); } + @Test + public void testImportRefusesSymlinkKeyFile() throws Exception { + org.junit.Assume.assumeTrue("Symlinks only tested on POSIX", + !System.getProperty("os.name").toLowerCase().contains("win")); + + File dir = tempFolder.newFolder("keystore-symlink"); + // Create a real key file and a symlink pointing to it + File target = tempFolder.newFile("real.key"); + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + Files.write(target.toPath(), + ByteArray.toHexString(keyPair.getPrivateKey()).getBytes(StandardCharsets.UTF_8)); + + File symlink = new File(tempFolder.getRoot(), "symlink.key"); + Files.createSymbolicLink(symlink.toPath(), target.toPath()); + + File pwFile = tempFolder.newFile("pw-symlink.txt"); + Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + + java.io.StringWriter err = new java.io.StringWriter(); + CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setErr(new java.io.PrintWriter(err)); + int exitCode = cmd.execute("keystore", "import", + "--keystore-dir", dir.getAbsolutePath(), + "--key-file", symlink.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals("Must refuse symlinked key file", 1, exitCode); + assertTrue("Expected symlink-refusal error, got: " + err.toString(), + err.toString().contains("Refusing to follow symbolic link")); + } + + @Test + public void testImportRefusesSymlinkPasswordFile() throws Exception { + org.junit.Assume.assumeTrue("Symlinks only tested on POSIX", + !System.getProperty("os.name").toLowerCase().contains("win")); + + File dir = tempFolder.newFolder("keystore-pwsymlink"); + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + File keyFile = tempFolder.newFile("sym-pw.key"); + Files.write(keyFile.toPath(), + ByteArray.toHexString(keyPair.getPrivateKey()).getBytes(StandardCharsets.UTF_8)); + + File realPwFile = tempFolder.newFile("real-pw.txt"); + Files.write(realPwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + File pwSymlink = new File(tempFolder.getRoot(), "pw-symlink.txt"); + Files.createSymbolicLink(pwSymlink.toPath(), realPwFile.toPath()); + + java.io.StringWriter err = new java.io.StringWriter(); + CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setErr(new java.io.PrintWriter(err)); + int exitCode = cmd.execute("keystore", "import", + "--keystore-dir", dir.getAbsolutePath(), + "--key-file", keyFile.getAbsolutePath(), + "--password-file", pwSymlink.getAbsolutePath()); + + assertEquals("Must refuse symlinked password file", 1, exitCode); + assertTrue("Expected symlink-refusal error, got: " + err.toString(), + err.toString().contains("Refusing to follow symbolic link")); + } + @Test public void testImportDuplicateCheckSkipsInvalidVersion() throws Exception { File dir = tempFolder.newFolder("keystore-badver"); From e5bbc20bdb9cbe23256b19e049b65f0546c3cb58 Mon Sep 17 00:00:00 2001 From: Barbatos Date: Tue, 21 Apr 2026 17:31:25 +0800 Subject: [PATCH 33/41] fix(crypto): preserve whitespace in non-TTY password input WalletUtils.inputPassword() previously applied trim() + split("\\s+")[0] on the non-TTY (stdin) branch, silently truncating passphrases like "correct horse battery staple" to "correct" when piped via stdin. This produced keystores encrypted with only the first word, which would then fail to decrypt under new CLIs that correctly preserve the full password. - Replace trim() + split("\\s+")[0] with stripPasswordLine() that only strips the UTF-8 BOM and trailing line terminators, preserving internal whitespace - Expose stripPasswordLine as the canonical public helper and consolidate KeystoreCliUtils.stripLineEndings into it (plugins now delegates) - Fix a pre-existing Scanner lifecycle bug: inputPassword() used to allocate a fresh Scanner(System.in) on every call, so the second call in inputPassword2Twice() lost bytes the first Scanner had buffered. The Scanner is now lazily-cached and shared across calls, with a resetSharedStdinScanner() hook for tests - KeystoreFactory.importPrivateKey keeps trim()+split for private-key input (hex strings have no legitimate whitespace) New tests cover: internal whitespace preservation, tabs, CRLF, BOM, leading/trailing spaces, the inputPassword2Twice piped double-read path, and direct unit tests of stripPasswordLine (null, empty, only-BOM, only-terminators, BOM-then-terminator). --- .../java/org/tron/keystore/WalletUtils.java | 70 +++++++- .../WalletUtilsInputPasswordTest.java | 167 ++++++++++++++++++ .../org/tron/plugins/KeystoreCliUtils.java | 19 +- .../tron/plugins/KeystoreCliUtilsTest.java | 46 ----- 4 files changed, 232 insertions(+), 70 deletions(-) create mode 100644 crypto/src/test/java/org/tron/keystore/WalletUtilsInputPasswordTest.java diff --git a/crypto/src/main/java/org/tron/keystore/WalletUtils.java b/crypto/src/main/java/org/tron/keystore/WalletUtils.java index f9b04686d85..416cd283a42 100644 --- a/crypto/src/main/java/org/tron/keystore/WalletUtils.java +++ b/crypto/src/main/java/org/tron/keystore/WalletUtils.java @@ -186,6 +186,35 @@ public static String getMainnetKeyDirectory() { return String.format("%s%skeystore", getDefaultKeyDirectory(), File.separator); } + /** + * Strip trailing line terminators ({@code \n}/{@code \r}) and a leading + * UTF-8 BOM ({@code \uFEFF}) from a line of input. Unlike + * {@link String#trim()} this preserves internal whitespace, so passwords + * containing spaces (e.g. passphrases) survive intact. + * + *

Intended as the canonical helper for normalizing raw user-provided + * password/line input across both CLI console and file-driven paths. + * Returns {@code null} if the input is {@code null}. + */ + public static String stripPasswordLine(String s) { + if (s == null) { + return null; + } + if (s.length() > 0 && s.charAt(0) == '\uFEFF') { + s = s.substring(1); + } + int end = s.length(); + while (end > 0) { + char c = s.charAt(end - 1); + if (c == '\n' || c == '\r') { + end--; + } else { + break; + } + } + return s.substring(0, end); + } + public static boolean passwordValid(String password) { if (StringUtils.isEmpty(password)) { return false; @@ -197,20 +226,49 @@ public static boolean passwordValid(String password) { return true; } + /** + * Lazily-initialized Scanner shared across successive + * {@link #inputPassword()} calls on the non-TTY path so that + * {@link #inputPassword2Twice()} can read two lines in sequence + * without losing data. Each call to {@code new Scanner(System.in)} + * internally buffers bytes from the underlying {@link BufferedReader}; + * constructing a second Scanner after the first has been discarded + * drops any buffered bytes the first pulled from stdin, causing + * {@code NoSuchElementException}. + */ + private static Scanner sharedStdinScanner; + + /** + * Visible for testing: reset the cached Scanner so subsequent calls + * see a freshly rebound {@link System#in}. + */ + static synchronized void resetSharedStdinScanner() { + sharedStdinScanner = null; + } + + private static synchronized Scanner getSharedStdinScanner() { + if (sharedStdinScanner == null) { + sharedStdinScanner = new Scanner(System.in); + } + return sharedStdinScanner; + } + public static String inputPassword() { - Scanner in = null; String password; Console cons = System.console(); - if (cons == null) { - in = new Scanner(System.in); - } + Scanner in = cons == null ? getSharedStdinScanner() : null; while (true) { if (cons != null) { char[] pwd = cons.readPassword("password: "); password = String.valueOf(pwd); } else { - String input = in.nextLine().trim(); - password = input.split("\\s+")[0]; + // Preserve the full password including embedded whitespace. + // The previous implementation applied trim() + split("\\s+")[0] + // which silently truncated passwords like "correct horse battery + // staple" to "correct" when piped via stdin (e.g. echo ... | java). + // stripPasswordLine only removes the UTF-8 BOM and trailing line + // terminators — internal whitespace is part of the password. + password = stripPasswordLine(in.nextLine()); } if (passwordValid(password)) { return password; diff --git a/crypto/src/test/java/org/tron/keystore/WalletUtilsInputPasswordTest.java b/crypto/src/test/java/org/tron/keystore/WalletUtilsInputPasswordTest.java new file mode 100644 index 00000000000..64752b9ca49 --- /dev/null +++ b/crypto/src/test/java/org/tron/keystore/WalletUtilsInputPasswordTest.java @@ -0,0 +1,167 @@ +package org.tron.keystore; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** + * Verifies that {@link WalletUtils#inputPassword()} preserves the full + * password including internal whitespace on the non-TTY (stdin) path, + * and that {@link WalletUtils#stripPasswordLine(String)} handles all + * edge cases correctly. + * + *

Previously the non-TTY path applied {@code trim() + split("\\s+")[0]} + * which silently truncated passphrases like "correct horse battery staple" + * to "correct" when piped via stdin. This test locks in the fix. + */ +public class WalletUtilsInputPasswordTest { + + private InputStream originalIn; + + @Before + public void saveStdin() { + originalIn = System.in; + // Clear the cached Scanner so each test binds to its own System.in + WalletUtils.resetSharedStdinScanner(); + } + + @After + public void restoreStdin() { + System.setIn(originalIn); + WalletUtils.resetSharedStdinScanner(); + } + + // ---------- inputPassword() behavioral tests ---------- + + @Test(timeout = 5000) + public void testInputPasswordPreservesInternalWhitespace() { + System.setIn(new ByteArrayInputStream( + "correct horse battery staple\n".getBytes(StandardCharsets.UTF_8))); + + String pw = WalletUtils.inputPassword(); + + assertEquals("Password with internal whitespace must be preserved intact", + "correct horse battery staple", pw); + } + + @Test(timeout = 5000) + public void testInputPasswordPreservesTabs() { + System.setIn(new ByteArrayInputStream( + "pass\tw0rd\n".getBytes(StandardCharsets.UTF_8))); + + String pw = WalletUtils.inputPassword(); + + assertEquals("Internal tabs must be preserved", "pass\tw0rd", pw); + } + + @Test(timeout = 5000) + public void testInputPasswordStripsTrailingCr() { + // Windows line endings + System.setIn(new ByteArrayInputStream( + "password123\r\n".getBytes(StandardCharsets.UTF_8))); + + String pw = WalletUtils.inputPassword(); + + assertEquals("Trailing \\r must be stripped", "password123", pw); + } + + @Test(timeout = 5000) + public void testInputPasswordStripsBom() { + System.setIn(new ByteArrayInputStream( + "\uFEFFpassword123\n".getBytes(StandardCharsets.UTF_8))); + + String pw = WalletUtils.inputPassword(); + + assertEquals("UTF-8 BOM must be stripped from the start", "password123", pw); + } + + @Test(timeout = 5000) + public void testInputPasswordPreservesLeadingAndTrailingSpaces() { + // The legacy bug also called trim(); post-fix, spaces at the edges + // are part of the password. Callers that want to trim should do so + // themselves with full knowledge. + System.setIn(new ByteArrayInputStream( + " with spaces \n".getBytes(StandardCharsets.UTF_8))); + + String pw = WalletUtils.inputPassword(); + + assertEquals("Leading and trailing spaces are part of the password", + " with spaces ", pw); + } + + @Test(timeout = 10000) + public void testInputPassword2TwicePipedPreservesInternalWhitespace() { + // M1: verifies the double-read path (inputPassword2Twice → inputPassword() + // called twice) works correctly when both lines arrive on the same + // piped stdin. Guards against regressions from Scanner lifecycle issues + // where a newly-constructed Scanner could miss bytes buffered by an + // earlier Scanner on the same InputStream. + System.setIn(new ByteArrayInputStream( + ("correct horse battery staple\n" + + "correct horse battery staple\n").getBytes(StandardCharsets.UTF_8))); + + String pw = WalletUtils.inputPassword2Twice(); + + assertEquals("Full passphrase must survive the double-read path", + "correct horse battery staple", pw); + } + + // ---------- stripPasswordLine() direct unit tests (M3) ---------- + + @Test + public void testStripPasswordLineNull() { + assertNull(WalletUtils.stripPasswordLine(null)); + } + + @Test + public void testStripPasswordLineEmpty() { + assertEquals("", WalletUtils.stripPasswordLine("")); + } + + @Test + public void testStripPasswordLineOnlyBom() { + assertEquals("", WalletUtils.stripPasswordLine("\uFEFF")); + } + + @Test + public void testStripPasswordLineOnlyLineTerminators() { + assertEquals("", WalletUtils.stripPasswordLine("\r\n\r\n")); + } + + @Test + public void testStripPasswordLineBomThenTerminator() { + assertEquals("", WalletUtils.stripPasswordLine("\uFEFF\r\n")); + } + + @Test + public void testStripPasswordLineBomAndInternalWhitespace() { + assertEquals("with spaces", + WalletUtils.stripPasswordLine("\uFEFFwith spaces\r\n")); + } + + @Test + public void testStripPasswordLineNoChange() { + assertEquals("password", WalletUtils.stripPasswordLine("password")); + } + + @Test + public void testStripPasswordLineTrailingLf() { + assertEquals("password", WalletUtils.stripPasswordLine("password\n")); + } + + @Test + public void testStripPasswordLineTrailingCr() { + assertEquals("password", WalletUtils.stripPasswordLine("password\r")); + } + + @Test + public void testStripPasswordLineMultipleTrailing() { + assertEquals("password", WalletUtils.stripPasswordLine("password\r\n\r\n")); + } +} diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java index 9d031663a18..96ab4c95562 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java @@ -100,7 +100,7 @@ static String readPassword(File passwordFile, PrintWriter err) throws IOExceptio return null; } try { - String password = stripLineEndings( + String password = WalletUtils.stripPasswordLine( new String(bytes, StandardCharsets.UTF_8)); if (!WalletUtils.passwordValid(password)) { err.println("Invalid password: must be at least 6 characters."); @@ -180,23 +180,6 @@ static Map jsonMap(String... keyValues) { return map; } - static String stripLineEndings(String s) { - // Strip UTF-8 BOM if present (Windows Notepad adds this) - if (s.length() > 0 && s.charAt(0) == '\uFEFF') { - s = s.substring(1); - } - int end = s.length(); - while (end > 0) { - char c = s.charAt(end - 1); - if (c == '\n' || c == '\r') { - end--; - } else { - break; - } - } - return s.substring(0, end); - } - static boolean checkFileExists(File file, String label, PrintWriter err) { if (file != null && !file.exists()) { err.println(label + " not found: " + file.getPath()); diff --git a/plugins/src/test/java/org/tron/plugins/KeystoreCliUtilsTest.java b/plugins/src/test/java/org/tron/plugins/KeystoreCliUtilsTest.java index 756fb7c803d..2b235b923ab 100644 --- a/plugins/src/test/java/org/tron/plugins/KeystoreCliUtilsTest.java +++ b/plugins/src/test/java/org/tron/plugins/KeystoreCliUtilsTest.java @@ -22,52 +22,6 @@ public class KeystoreCliUtilsTest { @Rule public TemporaryFolder tempFolder = new TemporaryFolder(); - @Test - public void testStripLineEndingsNoChange() { - assertEquals("password", KeystoreCliUtils.stripLineEndings("password")); - } - - @Test - public void testStripLineEndingsTrailingLf() { - assertEquals("password", KeystoreCliUtils.stripLineEndings("password\n")); - } - - @Test - public void testStripLineEndingsTrailingCrLf() { - assertEquals("password", KeystoreCliUtils.stripLineEndings("password\r\n")); - } - - @Test - public void testStripLineEndingsTrailingCr() { - assertEquals("password", KeystoreCliUtils.stripLineEndings("password\r")); - } - - @Test - public void testStripLineEndingsMultipleTrailing() { - assertEquals("password", KeystoreCliUtils.stripLineEndings("password\r\n\r\n")); - } - - @Test - public void testStripLineEndingsBom() { - assertEquals("password", KeystoreCliUtils.stripLineEndings("\uFEFFpassword")); - } - - @Test - public void testStripLineEndingsBomAndTrailing() { - assertEquals("password", - KeystoreCliUtils.stripLineEndings("\uFEFFpassword\r\n")); - } - - @Test - public void testStripLineEndingsEmpty() { - assertEquals("", KeystoreCliUtils.stripLineEndings("")); - } - - @Test - public void testStripLineEndingsOnlyLineEndings() { - assertEquals("", KeystoreCliUtils.stripLineEndings("\r\n\r\n")); - } - @Test public void testJsonMapEven() { Map m = KeystoreCliUtils.jsonMap("a", "1", "b", "2"); From 4bcb850fd142acd9c9538b3f3f06d3790c647e73 Mon Sep 17 00:00:00 2001 From: Barbatos Date: Tue, 21 Apr 2026 17:31:46 +0800 Subject: [PATCH 34/41] feat(plugins): hint at legacy password-truncation on keystore update failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `keystore update` decryption fails and the provided old password contains whitespace, print a hint pointing users at the `--keystore-factory` non-TTY truncation bug: keystores created with `echo PASS | java -jar FullNode.jar --keystore-factory` were encrypted using only the first whitespace-separated word of the password. The hint is gated on whitespace in the supplied password so that the overwhelmingly common case (user simply mistyped) does not get noise about an unrelated legacy bug. Wording explicitly tells the user to re-run with just the first word as the current password and offers the full phrase as the new password. Two tests cover both branches of the gate (hint fires when password contains whitespace, suppressed otherwise). Deliberately not adding automatic fallback to split("\\s+")[0] during decryption — that would lower effective password entropy by giving an attacker a free first-token brute-force channel. Recovery stays explicit and user-driven. --- .../org/tron/plugins/KeystoreUpdate.java | 25 ++++++- .../org/tron/plugins/KeystoreUpdateTest.java | 65 +++++++++++++++++++ 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java index 5f57dfb6743..03d7f22854b 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java @@ -54,6 +54,10 @@ public class KeystoreUpdate implements Callable { public Integer call() { PrintWriter out = spec.commandLine().getOut(); PrintWriter err = spec.commandLine().getErr(); + // Hoisted out of the try so the legacy-truncation hint in the catch + // block can inspect whether the user-supplied password contained + // whitespace (which is the only case truncation can explain). + String oldPassword = null; try { File keystoreFile = findKeystoreByAddress(address, err); if (keystoreFile == null) { @@ -61,7 +65,6 @@ public Integer call() { return 1; } - String oldPassword; String newPassword; if (passwordFile != null) { @@ -83,8 +86,8 @@ public Integer call() { + " on separate lines."); return 1; } - oldPassword = KeystoreCliUtils.stripLineEndings(lines[0]); - newPassword = KeystoreCliUtils.stripLineEndings(lines[1]); + oldPassword = WalletUtils.stripPasswordLine(lines[0]); + newPassword = WalletUtils.stripPasswordLine(lines[1]); } finally { Arrays.fill(bytes, (byte) 0); } @@ -161,6 +164,22 @@ public Integer call() { return 0; } catch (CipherException e) { err.println("Decryption failed: " + e.getMessage()); + // Legacy-truncation hint: keystores created via + // `FullNode.jar --keystore-factory` in non-TTY mode (e.g. + // `echo PASS | java ...`) were encrypted with only the first + // whitespace-separated word of the password due to a bug in the + // legacy input path. The hint only fires if the provided password + // actually contains whitespace — otherwise truncation cannot be the + // cause of the decryption failure and the hint would be noise for + // the far more common "wrong password" case. + if (oldPassword != null && oldPassword.matches(".*\\s.*")) { + err.println("Tip: if this keystore was created with " + + "`FullNode.jar --keystore-factory` in non-TTY mode, the legacy " + + "code truncated the password at the first whitespace. " + + "Try re-running with only the first whitespace-separated word " + + "of your passphrase as the current password; you can then " + + "choose the full phrase as the new password."); + } return 1; } catch (Exception e) { err.println("Error: " + e.getMessage()); diff --git a/plugins/src/test/java/org/tron/plugins/KeystoreUpdateTest.java b/plugins/src/test/java/org/tron/plugins/KeystoreUpdateTest.java index d249369571c..f2b05ee9344 100644 --- a/plugins/src/test/java/org/tron/plugins/KeystoreUpdateTest.java +++ b/plugins/src/test/java/org/tron/plugins/KeystoreUpdateTest.java @@ -2,6 +2,7 @@ import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import com.fasterxml.jackson.databind.ObjectMapper; @@ -671,4 +672,68 @@ public void testUpdateNarrowsLoosePermissionsTo0600() throws Exception { java.nio.file.attribute.PosixFilePermission.OWNER_WRITE), perms); } + + @Test + public void testUpdateLegacyTipFiresWhenPasswordHasWhitespace() throws Exception { + // The legacy-truncation tip should fire when the entered old password + // contains whitespace and decryption fails — the scenario that actually + // matches the legacy bug. + File dir = tempFolder.newFolder("keystore-tip-ws"); + String realPassword = "realpass123"; + + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + String fileName = WalletUtils.generateWalletFile(realPassword, keyPair, dir, true); + Credentials creds = WalletUtils.loadCredentials(realPassword, + new File(dir, fileName), true); + + // Password with internal whitespace that is NOT the real password + File pwFile = tempFolder.newFile("pw-ws.txt"); + Files.write(pwFile.toPath(), + ("correct horse battery staple\nnewpass789").getBytes(StandardCharsets.UTF_8)); + + StringWriter err = new StringWriter(); + CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setErr(new PrintWriter(err)); + int exitCode = cmd.execute("keystore", "update", creds.getAddress(), + "--keystore-dir", dir.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals(1, exitCode); + assertTrue("Legacy-truncation tip should fire for whitespace password, got: " + + err.toString(), + err.toString().contains("first whitespace-separated word")); + } + + @Test + public void testUpdateLegacyTipSuppressedWhenPasswordHasNoWhitespace() throws Exception { + // For the common "wrong password" case (no whitespace), the legacy tip + // would be noise — it should be suppressed. + File dir = tempFolder.newFolder("keystore-tip-nows"); + String realPassword = "realpass123"; + + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + String fileName = WalletUtils.generateWalletFile(realPassword, keyPair, dir, true); + Credentials creds = WalletUtils.loadCredentials(realPassword, + new File(dir, fileName), true); + + // Wrong password with no whitespace + File pwFile = tempFolder.newFile("pw-nows.txt"); + Files.write(pwFile.toPath(), + ("wrongpassword\nnewpass789").getBytes(StandardCharsets.UTF_8)); + + StringWriter err = new StringWriter(); + CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setErr(new PrintWriter(err)); + int exitCode = cmd.execute("keystore", "update", creds.getAddress(), + "--keystore-dir", dir.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals(1, exitCode); + assertTrue("Decryption failure must still be reported", + err.toString().contains("Decryption failed")); + assertFalse("Legacy-truncation tip should NOT fire for whitespace-free password", + err.toString().contains("first whitespace-separated word")); + } } From f6811241ad442fcdd4210221140aae4763596f81 Mon Sep 17 00:00:00 2001 From: Barbatos Date: Tue, 21 Apr 2026 17:31:57 +0800 Subject: [PATCH 35/41] feat(framework): hint at legacy password-truncation on SR startup failure When WitnessInitializer fails to load the SR keystore due to a CipherException and the provided password contains whitespace, log a tip pointing the operator at the same legacy `--keystore-factory` non-TTY truncation bug handled in the Toolkit plugins CLI. Gives SR operators a breadcrumb when their node refuses to start after upgrading past the inputPassword() fix: their keystore may have been encrypted using only the first word of their password, and they can recover by using that first word temporarily, then resetting with Toolkit.jar keystore update. The tip only fires when the password has whitespace (truncation cannot explain a failure otherwise), keeping the common wrong-password case quiet. --- .../tron/core/config/args/WitnessInitializer.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java b/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java index 0ef242e2e01..c2ce2ba0046 100644 --- a/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java +++ b/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java @@ -86,6 +86,20 @@ public static LocalWitnesses initFromKeystore( privateKeys.add(prikey); } catch (IOException | CipherException e) { logger.error("Witness node start failed!"); + // Legacy-truncation hint: if this keystore was created with + // `FullNode.jar --keystore-factory` in non-TTY mode (e.g. + // `echo PASS | java ...`), the legacy code encrypted with only + // the first whitespace-separated word of the password. Emit the + // tip only when the entered password has internal whitespace — + // otherwise truncation cannot be the cause. + if (e instanceof CipherException && pwd != null && pwd.matches(".*\\s.*")) { + logger.error( + "Tip: keystores created via `FullNode.jar --keystore-factory` in " + + "non-TTY mode were encrypted with only the first " + + "whitespace-separated word of the password. Try restarting " + + "with only that first word as `-p`, then reset the password " + + "via `java -jar Toolkit.jar keystore update`."); + } throw new TronError(e, TronError.ErrCode.WITNESS_KEYSTORE_LOAD); } From 99159e266272892d05c984f49a9038064813582d Mon Sep 17 00:00:00 2001 From: Barbatos Date: Thu, 23 Apr 2026 00:04:55 +0800 Subject: [PATCH 36/41] test(crypto): move keystore tests to framework for jacoco coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit crypto/build.gradle's jacocoTestReport reads exec data from ../framework/build/jacoco only — so tests in crypto/src/test itself produce no signal in the crypto coverage report that the Coverage Gate job checks. WalletFilePojoTest was already placed in framework for this reason; this commit makes the other seven keystore tests consistent. Moves (pure git mv, no code changes needed — same package `org.tron.keystore`, all test deps available in framework): - CredentialsTest - CrossImplTest - WalletAddressValidationTest - WalletFileTest - WalletPropertyTest - WalletUtilsInputPasswordTest - WalletUtilsWriteTest After the move, crypto/src/test/java/org/tron/keystore/ is empty and all keystore test classes live in framework/src/test/java/org/tron/ keystore/ alongside WalletFilePojoTest. --- .../src/test/java/org/tron/keystore/CredentialsTest.java | 0 .../src/test/java/org/tron/keystore/CrossImplTest.java | 0 .../test/java/org/tron/keystore/WalletAddressValidationTest.java | 0 .../src/test/java/org/tron/keystore/WalletFileTest.java | 0 .../src/test/java/org/tron/keystore/WalletPropertyTest.java | 0 .../test/java/org/tron/keystore/WalletUtilsInputPasswordTest.java | 0 .../src/test/java/org/tron/keystore/WalletUtilsWriteTest.java | 0 7 files changed, 0 insertions(+), 0 deletions(-) rename {crypto => framework}/src/test/java/org/tron/keystore/CredentialsTest.java (100%) rename {crypto => framework}/src/test/java/org/tron/keystore/CrossImplTest.java (100%) rename {crypto => framework}/src/test/java/org/tron/keystore/WalletAddressValidationTest.java (100%) rename {crypto => framework}/src/test/java/org/tron/keystore/WalletFileTest.java (100%) rename {crypto => framework}/src/test/java/org/tron/keystore/WalletPropertyTest.java (100%) rename {crypto => framework}/src/test/java/org/tron/keystore/WalletUtilsInputPasswordTest.java (100%) rename {crypto => framework}/src/test/java/org/tron/keystore/WalletUtilsWriteTest.java (100%) diff --git a/crypto/src/test/java/org/tron/keystore/CredentialsTest.java b/framework/src/test/java/org/tron/keystore/CredentialsTest.java similarity index 100% rename from crypto/src/test/java/org/tron/keystore/CredentialsTest.java rename to framework/src/test/java/org/tron/keystore/CredentialsTest.java diff --git a/crypto/src/test/java/org/tron/keystore/CrossImplTest.java b/framework/src/test/java/org/tron/keystore/CrossImplTest.java similarity index 100% rename from crypto/src/test/java/org/tron/keystore/CrossImplTest.java rename to framework/src/test/java/org/tron/keystore/CrossImplTest.java diff --git a/crypto/src/test/java/org/tron/keystore/WalletAddressValidationTest.java b/framework/src/test/java/org/tron/keystore/WalletAddressValidationTest.java similarity index 100% rename from crypto/src/test/java/org/tron/keystore/WalletAddressValidationTest.java rename to framework/src/test/java/org/tron/keystore/WalletAddressValidationTest.java diff --git a/crypto/src/test/java/org/tron/keystore/WalletFileTest.java b/framework/src/test/java/org/tron/keystore/WalletFileTest.java similarity index 100% rename from crypto/src/test/java/org/tron/keystore/WalletFileTest.java rename to framework/src/test/java/org/tron/keystore/WalletFileTest.java diff --git a/crypto/src/test/java/org/tron/keystore/WalletPropertyTest.java b/framework/src/test/java/org/tron/keystore/WalletPropertyTest.java similarity index 100% rename from crypto/src/test/java/org/tron/keystore/WalletPropertyTest.java rename to framework/src/test/java/org/tron/keystore/WalletPropertyTest.java diff --git a/crypto/src/test/java/org/tron/keystore/WalletUtilsInputPasswordTest.java b/framework/src/test/java/org/tron/keystore/WalletUtilsInputPasswordTest.java similarity index 100% rename from crypto/src/test/java/org/tron/keystore/WalletUtilsInputPasswordTest.java rename to framework/src/test/java/org/tron/keystore/WalletUtilsInputPasswordTest.java diff --git a/crypto/src/test/java/org/tron/keystore/WalletUtilsWriteTest.java b/framework/src/test/java/org/tron/keystore/WalletUtilsWriteTest.java similarity index 100% rename from crypto/src/test/java/org/tron/keystore/WalletUtilsWriteTest.java rename to framework/src/test/java/org/tron/keystore/WalletUtilsWriteTest.java From 340669cb6b51754d25494a4bbb71890bb6c143d2 Mon Sep 17 00:00:00 2001 From: Barbatos Date: Thu, 23 Apr 2026 00:12:33 +0800 Subject: [PATCH 37/41] fix(plugins): reject symlinks in keystore directory scans Earlier commits hardened --password-file / --key-file reads against symlink-following, but the keystore-directory scans in list, import (duplicate check), and update (findKeystoreByAddress) still called MAPPER.readValue(file, ...) on every *.json entry without a symlink guard. In a hostile or group-writable keystore directory, a planted symlink could redirect deserialization to arbitrary files, and FIFOs or devices could block the scan indefinitely. - Add KeystoreCliUtils.isSafeRegularFile(file, err) helper that stats with LinkOption.NOFOLLOW_LINKS and rejects symbolic links, directories, FIFOs, and devices with a warning to err - Apply it before MAPPER.readValue in KeystoreList.call, KeystoreImport.findExistingKeystore, and KeystoreUpdate.findKeystoreByAddress - Add three end-to-end tests (one per command) that plant a .json symlink in the keystore dir and verify each command warns and continues without reading the symlink target --- .../org/tron/plugins/KeystoreCliUtils.java | 29 ++++++++++++++ .../org/tron/plugins/KeystoreImport.java | 3 ++ .../common/org/tron/plugins/KeystoreList.java | 3 ++ .../org/tron/plugins/KeystoreUpdate.java | 3 ++ .../org/tron/plugins/KeystoreImportTest.java | 37 ++++++++++++++++++ .../org/tron/plugins/KeystoreListTest.java | 39 +++++++++++++++++++ .../org/tron/plugins/KeystoreUpdateTest.java | 37 ++++++++++++++++++ 7 files changed, 151 insertions(+) diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java index 96ab4c95562..4126bb8dcb0 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java @@ -188,6 +188,35 @@ static boolean checkFileExists(File file, String label, PrintWriter err) { return true; } + /** + * Returns true iff {@code file} exists, is not a symbolic link, and is a + * regular file (not a directory, FIFO, or device). Used to filter keystore + * directory scans before {@code MAPPER.readValue(file, ...)} so a hostile + * or group-writable keystore directory cannot redirect reads to arbitrary + * files (e.g. {@code /etc/shadow}) or block on non-regular files + * (e.g. a FIFO) via planted entries. + * + *

Writes a warning to {@code err} when the entry is skipped. + */ + static boolean isSafeRegularFile(File file, PrintWriter err) { + try { + BasicFileAttributes attrs = Files.readAttributes(file.toPath(), + BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + if (attrs.isSymbolicLink()) { + err.println("Warning: skipping symbolic link: " + file.getName()); + return false; + } + if (!attrs.isRegularFile()) { + err.println("Warning: skipping non-regular file: " + file.getName()); + return false; + } + return true; + } catch (IOException e) { + err.println("Warning: skipping unreadable file: " + file.getName()); + return false; + } + } + static void printSecurityTips(PrintWriter out, String address, String fileName) { out.println(); out.println("Public address of the key: " + address); diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java index 6832678e36a..3b1effe0431 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java @@ -169,6 +169,9 @@ private String findExistingKeystore(File dir, String address, PrintWriter err) { com.fasterxml.jackson.databind.ObjectMapper mapper = KeystoreCliUtils.mapper(); for (File file : files) { + if (!KeystoreCliUtils.isSafeRegularFile(file, err)) { + continue; + } try { WalletFile wf = mapper.readValue(file, WalletFile.class); if (KeystoreCliUtils.isValidKeystoreFile(wf) diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreList.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreList.java index 52a82ba6527..214ecf0a642 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreList.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreList.java @@ -59,6 +59,9 @@ public Integer call() { List> entries = new ArrayList<>(); for (File file : files) { + if (!KeystoreCliUtils.isSafeRegularFile(file, err)) { + continue; + } try { WalletFile walletFile = MAPPER.readValue(file, WalletFile.class); if (!KeystoreCliUtils.isValidKeystoreFile(walletFile)) { diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java index 03d7f22854b..a27e1fd98b8 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java @@ -199,6 +199,9 @@ private File findKeystoreByAddress(String targetAddress, PrintWriter err) { } java.util.List matches = new java.util.ArrayList<>(); for (File file : files) { + if (!KeystoreCliUtils.isSafeRegularFile(file, err)) { + continue; + } try { WalletFile wf = MAPPER.readValue(file, WalletFile.class); if (KeystoreCliUtils.isValidKeystoreFile(wf) diff --git a/plugins/src/test/java/org/tron/plugins/KeystoreImportTest.java b/plugins/src/test/java/org/tron/plugins/KeystoreImportTest.java index 94b1c7c21cb..f4f816e5562 100644 --- a/plugins/src/test/java/org/tron/plugins/KeystoreImportTest.java +++ b/plugins/src/test/java/org/tron/plugins/KeystoreImportTest.java @@ -464,4 +464,41 @@ public void testImportDuplicateCheckSkipsInvalidVersion() throws Exception { assertEquals("Import should succeed — invalid-version file is not a real duplicate", 0, exitCode); } + + @Test + public void testImportDuplicateScanSkipsSymlinkedEntry() throws Exception { + org.junit.Assume.assumeTrue("Symlinks only tested on POSIX", + !System.getProperty("os.name").toLowerCase().contains("win")); + + File dir = tempFolder.newFolder("keystore-dup-symlink"); + + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + String privateKeyHex = ByteArray.toHexString(keyPair.getPrivateKey()); + + File target = tempFolder.newFile("outside.json"); + Files.write(target.toPath(), + "{\"not\":\"a keystore\"}".getBytes(StandardCharsets.UTF_8)); + File symlink = new File(dir, "evil.json"); + Files.createSymbolicLink(symlink.toPath(), target.toPath()); + + File keyFile = tempFolder.newFile("dup-sym.key"); + Files.write(keyFile.toPath(), + privateKeyHex.getBytes(StandardCharsets.UTF_8)); + File pwFile = tempFolder.newFile("pw-dup-sym.txt"); + Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + + java.io.StringWriter err = new java.io.StringWriter(); + CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setErr(new java.io.PrintWriter(err)); + int exitCode = cmd.execute("keystore", "import", + "--keystore-dir", dir.getAbsolutePath(), + "--key-file", keyFile.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals("Import should succeed with symlink present", 0, exitCode); + assertTrue("Duplicate scan must warn about the symlinked entry, got: " + + err.toString(), + err.toString().contains("Warning: skipping symbolic link: evil.json")); + } } diff --git a/plugins/src/test/java/org/tron/plugins/KeystoreListTest.java b/plugins/src/test/java/org/tron/plugins/KeystoreListTest.java index f76ec0be254..b029ddaf9f7 100644 --- a/plugins/src/test/java/org/tron/plugins/KeystoreListTest.java +++ b/plugins/src/test/java/org/tron/plugins/KeystoreListTest.java @@ -240,4 +240,43 @@ public void testListSkipsInvalidVersionKeystores() throws Exception { assertEquals("Should list only the valid v3 keystore, not v2 or no-crypto", 1, lines.length); } + + @Test + public void testListSkipsSymlinkedKeystoreFile() throws Exception { + org.junit.Assume.assumeTrue("Symlinks only tested on POSIX", + !System.getProperty("os.name").toLowerCase().contains("win")); + + File dir = tempFolder.newFolder("keystore-symlink-scan"); + String password = "test123456"; + + SignInterface key = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + WalletUtils.generateWalletFile(password, key, dir, false); + + // A JSON file elsewhere (simulates "target we should not be tricked + // into reading") — placed outside the keystore dir. + File target = tempFolder.newFile("outside.json"); + Files.write(target.toPath(), + "{\"secret\":\"should not appear in list output\"}" + .getBytes(StandardCharsets.UTF_8)); + + // Plant a .json symlink in the keystore dir + File symlink = new File(dir, "evil.json"); + Files.createSymbolicLink(symlink.toPath(), target.toPath()); + + StringWriter out = new StringWriter(); + StringWriter err = new StringWriter(); + CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setOut(new PrintWriter(out)); + cmd.setErr(new PrintWriter(err)); + int exitCode = cmd.execute("keystore", "list", + "--keystore-dir", dir.getAbsolutePath()); + + assertEquals(0, exitCode); + assertTrue("Should warn about symbolic link, got err: " + err.toString(), + err.toString().contains("Warning: skipping symbolic link: evil.json")); + String output = out.toString().trim(); + String[] lines = output.split("\\n"); + assertEquals("Should list only the real keystore", 1, lines.length); + } } diff --git a/plugins/src/test/java/org/tron/plugins/KeystoreUpdateTest.java b/plugins/src/test/java/org/tron/plugins/KeystoreUpdateTest.java index f2b05ee9344..067d4302170 100644 --- a/plugins/src/test/java/org/tron/plugins/KeystoreUpdateTest.java +++ b/plugins/src/test/java/org/tron/plugins/KeystoreUpdateTest.java @@ -736,4 +736,41 @@ public void testUpdateLegacyTipSuppressedWhenPasswordHasNoWhitespace() throws Ex assertFalse("Legacy-truncation tip should NOT fire for whitespace-free password", err.toString().contains("first whitespace-separated word")); } + + @Test + public void testUpdateScanSkipsSymlinkedEntry() throws Exception { + org.junit.Assume.assumeTrue("Symlinks only tested on POSIX", + !System.getProperty("os.name").toLowerCase().contains("win")); + + File dir = tempFolder.newFolder("keystore-update-symlink"); + String oldPassword = "oldpass123"; + String newPassword = "newpass456"; + + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + String fileName = WalletUtils.generateWalletFile(oldPassword, keyPair, dir, true); + Credentials creds = WalletUtils.loadCredentials(oldPassword, + new File(dir, fileName), true); + + File target = tempFolder.newFile("outside.json"); + Files.write(target.toPath(), + "{\"not\":\"a keystore\"}".getBytes(StandardCharsets.UTF_8)); + File symlink = new File(dir, "evil.json"); + Files.createSymbolicLink(symlink.toPath(), target.toPath()); + + File pwFile = tempFolder.newFile("pw-update-sym.txt"); + Files.write(pwFile.toPath(), + (oldPassword + "\n" + newPassword).getBytes(StandardCharsets.UTF_8)); + + StringWriter err = new StringWriter(); + CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setErr(new PrintWriter(err)); + int exitCode = cmd.execute("keystore", "update", creds.getAddress(), + "--keystore-dir", dir.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals("Update should succeed; symlinked entry must not break scan", 0, exitCode); + assertTrue("Scan must warn about the symlinked entry, got: " + err.toString(), + err.toString().contains("Warning: skipping symbolic link: evil.json")); + } } From a5ff900b34f032c385a919f85f7bc6ee6cd9ac28 Mon Sep 17 00:00:00 2001 From: Barbatos Date: Thu, 23 Apr 2026 07:52:47 +0800 Subject: [PATCH 38/41] fix(plugins): reject multi-line password files in keystore new/import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readPassword() only stripped trailing line terminators, so a password file containing interior newlines — e.g. the two-line format used by `keystore update` (old\nnew) — was silently accepted as a literal password like "old\nnew". The keystore was created successfully and passed length validation, but neither visible line alone could unlock it later. Easy to trigger by accidentally reusing an update password file with `new` or `import`. After stripPasswordLine, explicitly reject files whose contents still contain \n or \r, with an error that points at the update two-line format as the likely cause. `keystore update` has its own two-line parser elsewhere and is unaffected. Tests: - KeystoreNewTest.testNewKeystoreRejectsMultiLinePasswordFile - KeystoreImportTest.testImportRejectsMultiLinePasswordFile Both assert exit code 1, presence of the "multiple lines" error, and that no keystore file was created. --- .../org/tron/plugins/KeystoreCliUtils.java | 13 ++++++++ .../org/tron/plugins/KeystoreImportTest.java | 32 +++++++++++++++++++ .../org/tron/plugins/KeystoreNewTest.java | 27 ++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java index 4126bb8dcb0..3bceb87aa4c 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java @@ -102,6 +102,19 @@ static String readPassword(File passwordFile, PrintWriter err) throws IOExceptio try { String password = WalletUtils.stripPasswordLine( new String(bytes, StandardCharsets.UTF_8)); + // Reject multi-line password files. stripPasswordLine only trims + // trailing terminators; any remaining \n/\r means the file had + // interior line breaks. A common mistake is passing a two-line + // `keystore update` password file to `keystore new` / `import` — + // without this guard the literal "old\nnew" would silently become + // the password, and neither visible line alone would unlock the + // keystore later. + if (password.indexOf('\n') >= 0 || password.indexOf('\r') >= 0) { + err.println("Password file contains multiple lines; provide a " + + "single-line password (the `keystore update` two-line " + + "format is not accepted here)."); + return null; + } if (!WalletUtils.passwordValid(password)) { err.println("Invalid password: must be at least 6 characters."); return null; diff --git a/plugins/src/test/java/org/tron/plugins/KeystoreImportTest.java b/plugins/src/test/java/org/tron/plugins/KeystoreImportTest.java index f4f816e5562..3e718dfd143 100644 --- a/plugins/src/test/java/org/tron/plugins/KeystoreImportTest.java +++ b/plugins/src/test/java/org/tron/plugins/KeystoreImportTest.java @@ -501,4 +501,36 @@ public void testImportDuplicateScanSkipsSymlinkedEntry() throws Exception { + err.toString(), err.toString().contains("Warning: skipping symbolic link: evil.json")); } + + @Test + public void testImportRejectsMultiLinePasswordFile() throws Exception { + // Regression: a user might accidentally point --password-file at a + // `keystore update` two-line file; without the guard that literal + // "old\nnew" becomes the password. + File dir = tempFolder.newFolder("keystore-multi-pw"); + SignInterface keyPair = SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true); + String privateKeyHex = ByteArray.toHexString(keyPair.getPrivateKey()); + + File keyFile = tempFolder.newFile("multi.key"); + Files.write(keyFile.toPath(), privateKeyHex.getBytes(StandardCharsets.UTF_8)); + File pwFile = tempFolder.newFile("multi-pw.txt"); + Files.write(pwFile.toPath(), + "oldpass123\nnewpass456".getBytes(StandardCharsets.UTF_8)); + + java.io.StringWriter err = new java.io.StringWriter(); + CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setErr(new java.io.PrintWriter(err)); + int exitCode = cmd.execute("keystore", "import", + "--keystore-dir", dir.getAbsolutePath(), + "--key-file", keyFile.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals("Should reject multi-line password file", 1, exitCode); + assertTrue("Error must explain the multi-line rejection, got: " + err.toString(), + err.toString().contains("multiple lines")); + File[] files = dir.listFiles((d, name) -> name.endsWith(".json")); + assertTrue("No keystore should have been created", + files == null || files.length == 0); + } } diff --git a/plugins/src/test/java/org/tron/plugins/KeystoreNewTest.java b/plugins/src/test/java/org/tron/plugins/KeystoreNewTest.java index 94705cfc84c..0819103642e 100644 --- a/plugins/src/test/java/org/tron/plugins/KeystoreNewTest.java +++ b/plugins/src/test/java/org/tron/plugins/KeystoreNewTest.java @@ -277,4 +277,31 @@ public void testNewKeystoreFilePermissions() throws Exception { java.nio.file.attribute.PosixFilePermission.OWNER_WRITE), perms); } + + @Test + public void testNewKeystoreRejectsMultiLinePasswordFile() throws Exception { + // Regression: a user might accidentally point --password-file at a + // `keystore update` two-line file (old\nnew). Without the guard the + // literal "old\nnew" becomes the password and neither line alone can + // unlock it later. new/import must reject multi-line files. + File dir = tempFolder.newFolder("keystore-multi"); + File pwFile = tempFolder.newFile("multi-line.txt"); + Files.write(pwFile.toPath(), + "oldpass123\nnewpass456".getBytes(StandardCharsets.UTF_8)); + + StringWriter err = new StringWriter(); + CommandLine cmd = new CommandLine(new Toolkit()); + cmd.setErr(new PrintWriter(err)); + int exitCode = cmd.execute("keystore", "new", + "--keystore-dir", dir.getAbsolutePath(), + "--password-file", pwFile.getAbsolutePath()); + + assertEquals("Should reject multi-line password file", 1, exitCode); + assertTrue("Error must explain the multi-line rejection, got: " + err.toString(), + err.toString().contains("multiple lines")); + // No keystore created + File[] files = dir.listFiles((d, name) -> name.endsWith(".json")); + assertTrue("No keystore should have been created", + files == null || files.length == 0); + } } From 61c155ba719b260c4c87509b80448ff5d8b218e7 Mon Sep 17 00:00:00 2001 From: Barbatos Date: Thu, 23 Apr 2026 08:06:48 +0800 Subject: [PATCH 39/41] fix(crypto): reject JSON stubs missing cipher/KDF from keystore discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isValidKeystoreFile previously accepted any JSON with address, non-null crypto, and version == 3 — so a stub like {"address":"T...","version":3,"crypto":{}} passed discovery even though Wallet.validate would reject it later for missing cipher/KDF. That let keystore list show junk entries, keystore import refuse a real import as a duplicate, and keystore update report spurious multi-match / stub failures. - Extract a shared validationError(WalletFile) helper in Wallet that checks version, crypto != null, cipher in the supported set, and KDF in the supported set. Both Wallet.validate (throws CipherException with the specific error) and the new public Wallet.isValidKeystoreFile (returns boolean) use it, so the discovery predicate cannot drift from decrypt-time validation - KeystoreCliUtils.isValidKeystoreFile now delegates to Wallet.isValidKeystoreFile - Side-effect fix: Wallet.validate no longer NPEs when crypto == null Tests: existing KeystoreCliUtilsTest fixtures now build a supported Crypto (cipher + kdf). Added four new cases covering the stub scenario: empty crypto, unsupported cipher, unsupported KDF, and pbkdf2 acceptance. --- .../main/java/org/tron/keystore/Wallet.java | 50 +++++++++++++--- .../org/tron/plugins/KeystoreCliUtils.java | 10 ++-- .../tron/plugins/KeystoreCliUtilsTest.java | 59 ++++++++++++++++++- 3 files changed, 104 insertions(+), 15 deletions(-) diff --git a/crypto/src/main/java/org/tron/keystore/Wallet.java b/crypto/src/main/java/org/tron/keystore/Wallet.java index de1153595fa..d81ebb76d46 100644 --- a/crypto/src/main/java/org/tron/keystore/Wallet.java +++ b/crypto/src/main/java/org/tron/keystore/Wallet.java @@ -229,22 +229,54 @@ public static SignInterface decrypt(String password, WalletFile walletFile, return keyPair; } - static void validate(WalletFile walletFile) throws CipherException { - WalletFile.Crypto crypto = walletFile.getCrypto(); - + /** + * Returns a description of the first schema violation found in + * {@code walletFile}, or {@code null} if the file matches the supported + * V3 keystore shape (current version, known cipher, known KDF). + * + *

Shared by {@link #validate(WalletFile)} (which throws the message) + * and {@link #isValidKeystoreFile(WalletFile)} (which returns boolean + * for discovery-style filtering). + */ + private static String validationError(WalletFile walletFile) { if (walletFile.getVersion() != CURRENT_VERSION) { - throw new CipherException("Wallet version is not supported"); + return "Wallet version is not supported"; } - - if (!crypto.getCipher().equals(CIPHER)) { - throw new CipherException("Wallet cipher is not supported"); + WalletFile.Crypto crypto = walletFile.getCrypto(); + if (crypto == null) { + return "Missing crypto section"; + } + String cipher = crypto.getCipher(); + if (cipher == null || !cipher.equals(CIPHER)) { + return "Wallet cipher is not supported"; } + String kdf = crypto.getKdf(); + if (kdf == null || (!kdf.equals(AES_128_CTR) && !kdf.equals(SCRYPT))) { + return "KDF type is not supported"; + } + return null; + } - if (!crypto.getKdf().equals(AES_128_CTR) && !crypto.getKdf().equals(SCRYPT)) { - throw new CipherException("KDF type is not supported"); + static void validate(WalletFile walletFile) throws CipherException { + String error = validationError(walletFile); + if (error != null) { + throw new CipherException(error); } } + /** + * Returns {@code true} iff {@code walletFile} has the shape of a + * decryptable V3 keystore: non-null address, supported version, non-null + * crypto section with a supported cipher and KDF. Intended for + * discovery-style filtering (e.g. listing or duplicate detection) where + * we want to skip JSON stubs that would later fail {@link #validate}. + */ + public static boolean isValidKeystoreFile(WalletFile walletFile) { + return walletFile != null + && walletFile.getAddress() != null + && validationError(walletFile) == null; + } + public static byte[] generateRandomBytes(int size) { byte[] bytes = new byte[size]; new SecureRandom().nextBytes(bytes); diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java index 3bceb87aa4c..8c06622fa3a 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java @@ -250,11 +250,13 @@ static void printSecurityTips(PrintWriter out, String address, String fileName) } /** - * Check if a WalletFile represents a valid V3 keystore. + * Check if a WalletFile represents a decryptable V3 keystore. + * Delegates to {@link Wallet#isValidKeystoreFile(WalletFile)} so the + * discovery predicate stays in sync with decryption-time validation — + * a JSON stub with empty or unsupported cipher/KDF is rejected here + * rather than silently showing up as a "keystore" and failing later. */ static boolean isValidKeystoreFile(WalletFile wf) { - return wf.getAddress() != null - && wf.getCrypto() != null - && wf.getVersion() == 3; + return org.tron.keystore.Wallet.isValidKeystoreFile(wf); } } diff --git a/plugins/src/test/java/org/tron/plugins/KeystoreCliUtilsTest.java b/plugins/src/test/java/org/tron/plugins/KeystoreCliUtilsTest.java index 2b235b923ab..264e1cb4519 100644 --- a/plugins/src/test/java/org/tron/plugins/KeystoreCliUtilsTest.java +++ b/plugins/src/test/java/org/tron/plugins/KeystoreCliUtilsTest.java @@ -46,12 +46,19 @@ public void testJsonMapEmpty() { assertTrue(m.isEmpty()); } + private static WalletFile.Crypto supportedCrypto() { + WalletFile.Crypto crypto = new WalletFile.Crypto(); + crypto.setCipher("aes-128-ctr"); + crypto.setKdf("scrypt"); + return crypto; + } + @Test public void testIsValidKeystoreFileValid() { WalletFile wf = new WalletFile(); wf.setAddress("TAddr"); wf.setVersion(3); - wf.setCrypto(new WalletFile.Crypto()); + wf.setCrypto(supportedCrypto()); assertTrue(KeystoreCliUtils.isValidKeystoreFile(wf)); } @@ -59,7 +66,7 @@ public void testIsValidKeystoreFileValid() { public void testIsValidKeystoreFileNullAddress() { WalletFile wf = new WalletFile(); wf.setVersion(3); - wf.setCrypto(new WalletFile.Crypto()); + wf.setCrypto(supportedCrypto()); assertFalse(KeystoreCliUtils.isValidKeystoreFile(wf)); } @@ -76,10 +83,58 @@ public void testIsValidKeystoreFileWrongVersion() { WalletFile wf = new WalletFile(); wf.setAddress("TAddr"); wf.setVersion(2); + wf.setCrypto(supportedCrypto()); + assertFalse(KeystoreCliUtils.isValidKeystoreFile(wf)); + } + + @Test + public void testIsValidKeystoreFileRejectsEmptyCryptoStub() { + // {"address":"T...","version":3,"crypto":{}} — passes the old checks + // but Wallet.validate would later reject it. Discovery should skip it. + WalletFile wf = new WalletFile(); + wf.setAddress("TAddr"); + wf.setVersion(3); wf.setCrypto(new WalletFile.Crypto()); assertFalse(KeystoreCliUtils.isValidKeystoreFile(wf)); } + @Test + public void testIsValidKeystoreFileRejectsUnsupportedCipher() { + WalletFile wf = new WalletFile(); + wf.setAddress("TAddr"); + wf.setVersion(3); + WalletFile.Crypto crypto = new WalletFile.Crypto(); + crypto.setCipher("des"); + crypto.setKdf("scrypt"); + wf.setCrypto(crypto); + assertFalse(KeystoreCliUtils.isValidKeystoreFile(wf)); + } + + @Test + public void testIsValidKeystoreFileRejectsUnsupportedKdf() { + WalletFile wf = new WalletFile(); + wf.setAddress("TAddr"); + wf.setVersion(3); + WalletFile.Crypto crypto = new WalletFile.Crypto(); + crypto.setCipher("aes-128-ctr"); + crypto.setKdf("bcrypt"); + wf.setCrypto(crypto); + assertFalse(KeystoreCliUtils.isValidKeystoreFile(wf)); + } + + @Test + public void testIsValidKeystoreFileAcceptsPbkdf2Kdf() { + // pbkdf2 is the other supported KDF (used by some Ethereum keystores). + WalletFile wf = new WalletFile(); + wf.setAddress("TAddr"); + wf.setVersion(3); + WalletFile.Crypto crypto = new WalletFile.Crypto(); + crypto.setCipher("aes-128-ctr"); + crypto.setKdf("pbkdf2"); + wf.setCrypto(crypto); + assertTrue(KeystoreCliUtils.isValidKeystoreFile(wf)); + } + @Test public void testCheckFileExistsNull() { StringWriter err = new StringWriter(); From fc6ab9273ee98411f52b85a30e54aa2fd143b21c Mon Sep 17 00:00:00 2001 From: Barbatos Date: Thu, 23 Apr 2026 15:02:15 +0800 Subject: [PATCH 40/41] refactor(crypto): rename misleading Wallet.AES_128_CTR to PBKDF2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The constant Wallet.AES_128_CTR held the string "pbkdf2" — its name implied a cipher but its value is actually the KDF identifier used in the Web3 Secret Storage keystore "kdf" field. A future contributor renaming or re-valuing it could silently break pbkdf2 keystore support, which is hit by the new Wallet.validationError check and by the Jackson subtype registration in WalletFile. Rename to PBKDF2 to match the string value. The only three references (declaration, KDF check in validationError, JsonSubTypes name in WalletFile) all follow. Wire format is unchanged — the constant string is still "pbkdf2". The inner class `WalletFile.Aes128CtrKdfParams` keeps its name because changing a public class would be a binary-incompatible break for any third-party consumer of the crypto artifact. --- crypto/src/main/java/org/tron/keystore/Wallet.java | 9 +++++++-- crypto/src/main/java/org/tron/keystore/WalletFile.java | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/crypto/src/main/java/org/tron/keystore/Wallet.java b/crypto/src/main/java/org/tron/keystore/Wallet.java index d81ebb76d46..d63525b1e4d 100644 --- a/crypto/src/main/java/org/tron/keystore/Wallet.java +++ b/crypto/src/main/java/org/tron/keystore/Wallet.java @@ -47,7 +47,12 @@ */ public class Wallet { - protected static final String AES_128_CTR = "pbkdf2"; + // KDF identifiers used in the Web3 Secret Storage "kdf" field. + // The old name "AES_128_CTR" was misleading — the value is the PBKDF2 KDF + // identifier, not the cipher (CIPHER below). The inner class name + // `WalletFile.Aes128CtrKdfParams` is kept for wire-format/Jackson-subtype + // backward compatibility even though it also reflects the same history. + protected static final String PBKDF2 = "pbkdf2"; protected static final String SCRYPT = "scrypt"; private static final int N_LIGHT = 1 << 12; private static final int P_LIGHT = 6; @@ -251,7 +256,7 @@ private static String validationError(WalletFile walletFile) { return "Wallet cipher is not supported"; } String kdf = crypto.getKdf(); - if (kdf == null || (!kdf.equals(AES_128_CTR) && !kdf.equals(SCRYPT))) { + if (kdf == null || (!kdf.equals(PBKDF2) && !kdf.equals(SCRYPT))) { return "KDF type is not supported"; } return null; diff --git a/crypto/src/main/java/org/tron/keystore/WalletFile.java b/crypto/src/main/java/org/tron/keystore/WalletFile.java index 1f5135fefd3..97e538d1a8a 100644 --- a/crypto/src/main/java/org/tron/keystore/WalletFile.java +++ b/crypto/src/main/java/org/tron/keystore/WalletFile.java @@ -165,7 +165,7 @@ public KdfParams getKdfparams() { include = JsonTypeInfo.As.EXTERNAL_PROPERTY, property = "kdf") @JsonSubTypes({ - @JsonSubTypes.Type(value = Aes128CtrKdfParams.class, name = Wallet.AES_128_CTR), + @JsonSubTypes.Type(value = Aes128CtrKdfParams.class, name = Wallet.PBKDF2), @JsonSubTypes.Type(value = ScryptKdfParams.class, name = Wallet.SCRYPT) }) // To support my Ether Wallet keys uncomment this annotation & comment out the above From b89790c643002a117fbd5b365daac959a5b13fa3 Mon Sep 17 00:00:00 2001 From: Barbatos Date: Fri, 24 Apr 2026 23:58:05 +0800 Subject: [PATCH 41/41] refactor(crypto): remove unused web3j-era helpers from WalletUtils MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The following methods had no production callers after the keystore refactor — the only reference was a coverage-padding test at SupplementTest.testGet (no assertions, just "call these to bump the coverage number") plus internal self-references: - generateFullNewWalletFile(String, File, boolean) - generateLightNewWalletFile(String, File, boolean) - generateNewWalletFile(String, File, boolean, boolean) - getDefaultKeyDirectory() / getDefaultKeyDirectory(String) - getTestnetKeyDirectory() - getMainnetKeyDirectory() The three generate* wrappers were identical calls into the real generateWalletFile(String, SignInterface, File, boolean) (which is kept and actively used). The three getDirectory helpers returned Ethereum- style paths (~/Library/Ethereum, %APPDATA%/Ethereum, ~/.ethereum) — hard-coded from the web3j origin of this code, never applicable in a TRON context, and capable of misleading anyone who tried to use them. Also drops the corresponding five noop invocations from SupplementTest.testGet and its now-unused WalletUtils import (the static import of passwordValid elsewhere in the file is preserved — it's still exercised by testPasswordValid). No production behavior change. Reduces the crypto module's public API surface and keeps the keystore library focused on what TRON actually uses. --- .../java/org/tron/keystore/WalletUtils.java | 57 ------------------- .../java/org/tron/program/SupplementTest.java | 7 --- 2 files changed, 64 deletions(-) diff --git a/crypto/src/main/java/org/tron/keystore/WalletUtils.java b/crypto/src/main/java/org/tron/keystore/WalletUtils.java index 416cd283a42..153914b2efc 100644 --- a/crypto/src/main/java/org/tron/keystore/WalletUtils.java +++ b/crypto/src/main/java/org/tron/keystore/WalletUtils.java @@ -12,9 +12,6 @@ import java.nio.file.StandardCopyOption; import java.nio.file.attribute.PosixFilePermission; import java.nio.file.attribute.PosixFilePermissions; -import java.security.InvalidAlgorithmParameterException; -import java.security.NoSuchAlgorithmException; -import java.security.NoSuchProviderException; import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; @@ -24,8 +21,6 @@ import java.util.Set; import org.apache.commons.lang3.StringUtils; import org.tron.common.crypto.SignInterface; -import org.tron.common.crypto.SignUtils; -import org.tron.common.utils.Utils; import org.tron.core.exception.CipherException; /** @@ -44,31 +39,6 @@ public class WalletUtils { objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); } - public static String generateFullNewWalletFile(String password, File destinationDirectory, - boolean ecKey) - throws NoSuchAlgorithmException, NoSuchProviderException, - InvalidAlgorithmParameterException, CipherException, IOException { - - return generateNewWalletFile(password, destinationDirectory, true, ecKey); - } - - public static String generateLightNewWalletFile(String password, File destinationDirectory, - boolean ecKey) - throws NoSuchAlgorithmException, NoSuchProviderException, - InvalidAlgorithmParameterException, CipherException, IOException { - - return generateNewWalletFile(password, destinationDirectory, false, ecKey); - } - - public static String generateNewWalletFile( - String password, File destinationDirectory, boolean useFullScrypt, boolean ecKey) - throws CipherException, IOException, InvalidAlgorithmParameterException, - NoSuchAlgorithmException, NoSuchProviderException { - - SignInterface ecKeyPair = SignUtils.getGeneratedRandomSign(Utils.getRandom(), ecKey); - return generateWalletFile(password, ecKeyPair, destinationDirectory, useFullScrypt); - } - public static String generateWalletFile( String password, SignInterface ecKeyPair, File destinationDirectory, boolean useFullScrypt) throws CipherException, IOException { @@ -159,33 +129,6 @@ public static String getWalletFileName(WalletFile walletFile) { return now.format(format) + walletFile.getAddress() + ".json"; } - public static String getDefaultKeyDirectory() { - return getDefaultKeyDirectory(System.getProperty("os.name")); - } - - static String getDefaultKeyDirectory(String osName1) { - String osName = osName1.toLowerCase(); - - if (osName.startsWith("mac")) { - return String.format( - "%s%sLibrary%sEthereum", System.getProperty("user.home"), File.separator, - File.separator); - } else if (osName.startsWith("win")) { - return String.format("%s%sEthereum", System.getenv("APPDATA"), File.separator); - } else { - return String.format("%s%s.ethereum", System.getProperty("user.home"), File.separator); - } - } - - public static String getTestnetKeyDirectory() { - return String.format( - "%s%stestnet%skeystore", getDefaultKeyDirectory(), File.separator, File.separator); - } - - public static String getMainnetKeyDirectory() { - return String.format("%s%skeystore", getDefaultKeyDirectory(), File.separator); - } - /** * Strip trailing line terminators ({@code \n}/{@code \r}) and a leading * UTF-8 BOM ({@code \uFEFF}) from a line of input. Unlike diff --git a/framework/src/test/java/org/tron/program/SupplementTest.java b/framework/src/test/java/org/tron/program/SupplementTest.java index d5557614c46..483922cf8c5 100644 --- a/framework/src/test/java/org/tron/program/SupplementTest.java +++ b/framework/src/test/java/org/tron/program/SupplementTest.java @@ -27,7 +27,6 @@ import org.tron.core.config.args.Args; import org.tron.core.services.http.HttpSelfFormatFieldName; import org.tron.core.store.StorageRowStore; -import org.tron.keystore.WalletUtils; public class SupplementTest extends BaseTest { @@ -54,12 +53,6 @@ public void testGet() throws Exception { String p = dbPath + File.separator; dbBackupConfig.initArgs(true, p + "propPath", p + "bak1path/", p + "bak2path/", 1); - WalletUtils.generateFullNewWalletFile("123456", new File(dbPath), true); - WalletUtils.generateLightNewWalletFile("123456", new File(dbPath), true); - WalletUtils.getDefaultKeyDirectory(); - WalletUtils.getTestnetKeyDirectory(); - WalletUtils.getMainnetKeyDirectory(); - Value value = new Value(new byte[]{1}); value.asBytes(); value = new Value(1);