From 7a1ab56a7923a12993464491627cdc12a437a007 Mon Sep 17 00:00:00 2001 From: Barbatos Date: Thu, 2 Apr 2026 15:30:05 +0800 Subject: [PATCH 01/19] 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 | 73 +++++++++++++++++++ .../org/tron/keystore/WalletFileTest.java | 0 .../core/config/args/WitnessInitializer.java | 3 +- .../org/tron/program/KeystoreFactory.java | 8 +- .../config/args/WitnessInitializerTest.java | 3 +- .../org/tron/keystore/CredentialsTest.java | 48 ------------ .../org/tron/keystroe/CredentialsTest.java | 33 --------- .../java/org/tron/program/SupplementTest.java | 4 +- plugins/build.gradle | 1 + 13 files changed, 99 insertions(+), 101 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%) create mode 100644 crypto/src/test/java/org/tron/keystore/CredentialsTest.java rename {framework => crypto}/src/test/java/org/tron/keystore/WalletFileTest.java (100%) delete mode 100644 framework/src/test/java/org/tron/keystore/CredentialsTest.java delete mode 100644 framework/src/test/java/org/tron/keystroe/CredentialsTest.java 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/crypto/src/test/java/org/tron/keystore/CredentialsTest.java b/crypto/src/test/java/org/tron/keystore/CredentialsTest.java new file mode 100644 index 00000000000..9caaff827a6 --- /dev/null +++ b/crypto/src/test/java/org/tron/keystore/CredentialsTest.java @@ -0,0 +1,73 @@ +package org.tron.keystore; + +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import lombok.extern.slf4j.Slf4j; +import org.junit.Assert; +import org.junit.Test; +import org.mockito.Mockito; +import org.tron.common.crypto.SignInterface; +import org.tron.common.crypto.SignUtils; +import org.tron.common.crypto.sm2.SM2; +import org.tron.common.utils.ByteUtil; + +@Slf4j +public class CredentialsTest { + + @Test + public void testCreate() throws NoSuchAlgorithmException { + Credentials credentials = Credentials.create(SignUtils.getGeneratedRandomSign( + SecureRandom.getInstance("NativePRNG"), true)); + Assert.assertTrue("Credentials address create failed!", + credentials.getAddress() != null && !credentials.getAddress().isEmpty()); + Assert.assertNotNull("Credentials cryptoEngine create failed", + credentials.getSignInterface()); + } + + @Test + public void testCreateFromSM2() { + try { + Credentials.create(SM2.fromNodeId(ByteUtil.hexToBytes("fffffffffff" + + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + + "fffffffffffffffffffffffffffffffffffffff"))); + } catch (Exception 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.assertFalse("Credentials instance should be not equal!", + credentials1.equals(credentials2)); + Assert.assertFalse("Credentials instance hashcode should be not equal!", + credentials1.hashCode() == credentials2.hashCode()); + } + + @Test + public void testEqualityWithMocks() { + 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); + // si and si2 are different mock objects, so credentials are not equal + Assert.assertFalse(aCredential.equals(aCredential2)); + Credentials aCredential3 = Credentials.create(si3); + Assert.assertFalse(aCredential.equals(aCredential3)); + } +} 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/keystore/CredentialsTest.java b/framework/src/test/java/org/tron/keystore/CredentialsTest.java deleted file mode 100644 index 3fe2ce02b63..00000000000 --- a/framework/src/test/java/org/tron/keystore/CredentialsTest.java +++ /dev/null @@ -1,48 +0,0 @@ -package org.tron.keystore; - -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; -import junit.framework.TestCase; -import lombok.extern.slf4j.Slf4j; -import org.junit.Test; -import org.springframework.util.Assert; -import org.tron.common.crypto.SignUtils; -import org.tron.common.crypto.sm2.SM2; -import org.tron.common.utils.ByteUtil; - -@Slf4j -public class CredentialsTest extends TestCase { - - @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"); - } - - @Test - public void testCreateFromSM2() { - try { - Credentials.create(SM2.fromNodeId(ByteUtil.hexToBytes("fffffffffff" - + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" - + "fffffffffffffffffffffffffffffffffffffff"))); - } catch (Exception e) { - Assert.isInstanceOf(IllegalArgumentException.class, e); - } - } - - @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!"); - } - -} \ 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)); - } -} 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 1e5bf8c73b5f1efafafb90bfb221f7669b000643 Mon Sep 17 00:00:00 2001 From: Barbatos Date: Thu, 2 Apr 2026 15:30:38 +0800 Subject: [PATCH 02/19] 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 1492a490dce64cd371dc4adf3ce983ec9d3363a4 Mon Sep 17 00:00:00 2001 From: Barbatos Date: Thu, 2 Apr 2026 15:51:58 +0800 Subject: [PATCH 03/19] 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 +- .../org/tron/keystore/CredentialsTest.java | 7 +- .../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 +++++++++++++++ 18 files changed, 1272 insertions(+), 193 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/CredentialsTest.java b/crypto/src/test/java/org/tron/keystore/CredentialsTest.java index 9caaff827a6..7d90679f0d9 100644 --- a/crypto/src/test/java/org/tron/keystore/CredentialsTest.java +++ b/crypto/src/test/java/org/tron/keystore/CredentialsTest.java @@ -30,8 +30,9 @@ public void testCreateFromSM2() { Credentials.create(SM2.fromNodeId(ByteUtil.hexToBytes("fffffffffff" + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + "fffffffffffffffffffffffffffffffffffffff"))); - } catch (Exception e) { - Assert.assertTrue(e instanceof IllegalArgumentException); + Assert.fail("Expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + // Expected } } @@ -43,8 +44,6 @@ public void testEquals() throws NoSuchAlgorithmException { SecureRandom.getInstance("NativePRNG"), true)); Assert.assertFalse("Credentials instance should be not equal!", credentials1.equals(credentials2)); - Assert.assertFalse("Credentials instance hashcode should be not equal!", - credentials1.hashCode() == credentials2.hashCode()); } @Test 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 6c9fab2104d83a793552af45b2970dacd3dc521b Mon Sep 17 00:00:00 2001 From: Barbatos Date: Fri, 3 Apr 2026 17:05:33 +0800 Subject: [PATCH 04/19] 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 d7f7c6b06bdb9b136f6d4e9072e00d45b45bf0fa Mon Sep 17 00:00:00 2001 From: Barbatos Date: Sun, 5 Apr 2026 10:52:23 +0800 Subject: [PATCH 05/19] 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 ba232f4a2a13d85f5e120763df56e5e58512622b Mon Sep 17 00:00:00 2001 From: Barbatos Date: Thu, 16 Apr 2026 23:31:52 +0800 Subject: [PATCH 06/19] 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 88df0ff91639338a38f3a2f20aa20ccb4ff08bbc Mon Sep 17 00:00:00 2001 From: Barbatos Date: Thu, 16 Apr 2026 23:34:14 +0800 Subject: [PATCH 07/19] 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 e61cbb2419ce607deb5f1732a846b9f593cbce41 Mon Sep 17 00:00:00 2001 From: Barbatos Date: Thu, 16 Apr 2026 23:36:35 +0800 Subject: [PATCH 08/19] 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 1f27bcaaa34b74f316effd002d04f032219c6754 Mon Sep 17 00:00:00 2001 From: Barbatos Date: Fri, 17 Apr 2026 07:18:35 +0800 Subject: [PATCH 09/19] 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 309dc23ee9c50ad2f8a400ba3c5766e9fb6b297d Mon Sep 17 00:00:00 2001 From: Barbatos Date: Fri, 17 Apr 2026 08:12:42 +0800 Subject: [PATCH 10/19] 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 e5c49e70de8a6f7799631e19135c6d6738682774 Mon Sep 17 00:00:00 2001 From: Barbatos Date: Fri, 17 Apr 2026 08:54:13 +0800 Subject: [PATCH 11/19] 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 ad68bd74d56308d908dc317a9de3f273da78e498 Mon Sep 17 00:00:00 2001 From: Barbatos Date: Fri, 17 Apr 2026 13:17:11 +0800 Subject: [PATCH 12/19] 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 2ba80c3257928e60eb0539dd6898b72cf83f6070 Mon Sep 17 00:00:00 2001 From: Barbatos Date: Fri, 17 Apr 2026 14:47:42 +0800 Subject: [PATCH 13/19] ci: re-trigger build after transient apt mirror failure From 8ffbbb3c71531ab4bd89e8f1ff57a8f4e7152ad3 Mon Sep 17 00:00:00 2001 From: Barbatos Date: Fri, 17 Apr 2026 15:41:49 +0800 Subject: [PATCH 14/19] ci: re-trigger after transient infrastructure failure From 63bf39716e1f3f8943dcacf965f434ad929c16d1 Mon Sep 17 00:00:00 2001 From: Barbatos Date: Sat, 18 Apr 2026 11:21:21 +0800 Subject: [PATCH 15/19] 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 51350e8af396ab8bb9003390850f935c90592d7e Mon Sep 17 00:00:00 2001 From: Barbatos Date: Sat, 18 Apr 2026 11:23:26 +0800 Subject: [PATCH 16/19] 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 f519a91d49a380153a4a51e7b3e9524a9618ba9e Mon Sep 17 00:00:00 2001 From: Barbatos Date: Sat, 18 Apr 2026 11:24:09 +0800 Subject: [PATCH 17/19] 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 6b66b402f6b2ce2c04001485072b20ead2db6e54 Mon Sep 17 00:00:00 2001 From: Barbatos Date: Sat, 18 Apr 2026 20:35:06 +0800 Subject: [PATCH 18/19] 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 ba01b3b3e4adaeada45f5595737f32cd88d4719e Mon Sep 17 00:00:00 2001 From: Barbatos Date: Sat, 18 Apr 2026 20:53:42 +0800 Subject: [PATCH 19/19] 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");