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 74%
rename from framework/src/main/java/org/tron/keystore/Wallet.java
rename to crypto/src/main/java/org/tron/keystore/Wallet.java
index d38b1c74984..d63525b1e4d 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;
/**
@@ -48,7 +47,12 @@
*/
public class Wallet {
- protected static final String AES_128_CTR = "pbkdf2";
+ // KDF identifiers used in the Web3 Secret Storage "kdf" field.
+ // The old name "AES_128_CTR" was misleading — the value is the PBKDF2 KDF
+ // identifier, not the cipher (CIPHER below). The inner class name
+ // `WalletFile.Aes128CtrKdfParams` is kept for wire-format/Jackson-subtype
+ // backward compatibility even though it also reflects the same history.
+ protected static final String PBKDF2 = "pbkdf2";
protected static final String SCRYPT = "scrypt";
private static final int N_LIGHT = 1 << 12;
private static final int P_LIGHT = 6;
@@ -168,8 +172,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);
@@ -205,32 +209,79 @@ 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");
}
byte[] encryptKey = Arrays.copyOfRange(derivedKey, 0, 16);
byte[] privateKey = performCipherOperation(Cipher.DECRYPT_MODE, iv, encryptKey, cipherText);
- return SignUtils.fromPrivate(privateKey, Args.getInstance().isECKeyCryptoEngine());
- }
+ 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);
+ }
+ }
- static void validate(WalletFile walletFile) throws CipherException {
- WalletFile.Crypto crypto = walletFile.getCrypto();
+ return keyPair;
+ }
+ /**
+ * Returns a description of the first schema violation found in
+ * {@code walletFile}, or {@code null} if the file matches the supported
+ * V3 keystore shape (current version, known cipher, known KDF).
+ *
+ *
Shared by {@link #validate(WalletFile)} (which throws the message)
+ * and {@link #isValidKeystoreFile(WalletFile)} (which returns boolean
+ * for discovery-style filtering).
+ */
+ private static String validationError(WalletFile walletFile) {
if (walletFile.getVersion() != CURRENT_VERSION) {
- throw new CipherException("Wallet version is not supported");
+ return "Wallet version is not supported";
}
-
- if (!crypto.getCipher().equals(CIPHER)) {
- throw new CipherException("Wallet cipher is not supported");
+ WalletFile.Crypto crypto = walletFile.getCrypto();
+ if (crypto == null) {
+ return "Missing crypto section";
+ }
+ String cipher = crypto.getCipher();
+ if (cipher == null || !cipher.equals(CIPHER)) {
+ return "Wallet cipher is not supported";
}
+ String kdf = crypto.getKdf();
+ if (kdf == null || (!kdf.equals(PBKDF2) && !kdf.equals(SCRYPT))) {
+ return "KDF type is not supported";
+ }
+ return null;
+ }
- if (!crypto.getKdf().equals(AES_128_CTR) && !crypto.getKdf().equals(SCRYPT)) {
- throw new CipherException("KDF type is not supported");
+ static void validate(WalletFile walletFile) throws CipherException {
+ String error = validationError(walletFile);
+ if (error != null) {
+ throw new CipherException(error);
}
}
+ /**
+ * Returns {@code true} iff {@code walletFile} has the shape of a
+ * decryptable V3 keystore: non-null address, supported version, non-null
+ * crypto section with a supported cipher and KDF. Intended for
+ * discovery-style filtering (e.g. listing or duplicate detection) where
+ * we want to skip JSON stubs that would later fail {@link #validate}.
+ */
+ public static boolean isValidKeystoreFile(WalletFile walletFile) {
+ return walletFile != null
+ && walletFile.getAddress() != null
+ && validationError(walletFile) == null;
+ }
+
public static byte[] generateRandomBytes(int size) {
byte[] bytes = new byte[size];
new SecureRandom().nextBytes(bytes);
diff --git a/framework/src/main/java/org/tron/keystore/WalletFile.java b/crypto/src/main/java/org/tron/keystore/WalletFile.java
similarity index 99%
rename from framework/src/main/java/org/tron/keystore/WalletFile.java
rename to crypto/src/main/java/org/tron/keystore/WalletFile.java
index 1f5135fefd3..97e538d1a8a 100644
--- a/framework/src/main/java/org/tron/keystore/WalletFile.java
+++ b/crypto/src/main/java/org/tron/keystore/WalletFile.java
@@ -165,7 +165,7 @@ public KdfParams getKdfparams() {
include = JsonTypeInfo.As.EXTERNAL_PROPERTY,
property = "kdf")
@JsonSubTypes({
- @JsonSubTypes.Type(value = Aes128CtrKdfParams.class, name = Wallet.AES_128_CTR),
+ @JsonSubTypes.Type(value = Aes128CtrKdfParams.class, name = Wallet.PBKDF2),
@JsonSubTypes.Type(value = ScryptKdfParams.class, name = Wallet.SCRYPT)
})
// To support my Ether Wallet keys uncomment this annotation & comment out the above
diff --git a/crypto/src/main/java/org/tron/keystore/WalletUtils.java b/crypto/src/main/java/org/tron/keystore/WalletUtils.java
new file mode 100644
index 00000000000..416cd283a42
--- /dev/null
+++ b/crypto/src/main/java/org/tron/keystore/WalletUtils.java
@@ -0,0 +1,294 @@
+package org.tron.keystore;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+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;
+import org.tron.common.utils.Utils;
+import org.tron.core.exception.CipherException;
+
+/**
+ * Utility functions for working with Wallet files.
+ */
+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);
+ }
+
+ public static String generateFullNewWalletFile(String password, File destinationDirectory,
+ boolean ecKey)
+ throws NoSuchAlgorithmException, NoSuchProviderException,
+ InvalidAlgorithmParameterException, CipherException, IOException {
+
+ return generateNewWalletFile(password, destinationDirectory, true, ecKey);
+ }
+
+ public static String generateLightNewWalletFile(String password, File destinationDirectory,
+ boolean ecKey)
+ throws NoSuchAlgorithmException, NoSuchProviderException,
+ InvalidAlgorithmParameterException, CipherException, IOException {
+
+ return generateNewWalletFile(password, destinationDirectory, false, ecKey);
+ }
+
+ public static String generateNewWalletFile(
+ String password, File destinationDirectory, boolean useFullScrypt, boolean ecKey)
+ throws CipherException, IOException, InvalidAlgorithmParameterException,
+ NoSuchAlgorithmException, NoSuchProviderException {
+
+ SignInterface ecKeyPair = SignUtils.getGeneratedRandomSign(Utils.getRandom(), ecKey);
+ return generateWalletFile(password, ecKeyPair, destinationDirectory, useFullScrypt);
+ }
+
+ public static String generateWalletFile(
+ String password, SignInterface ecKeyPair, File destinationDirectory, boolean useFullScrypt)
+ throws CipherException, IOException {
+
+ WalletFile walletFile;
+ if (useFullScrypt) {
+ walletFile = Wallet.createStandard(password, ecKeyPair);
+ } else {
+ walletFile = Wallet.createLight(password, ecKeyPair);
+ }
+
+ String fileName = getWalletFileName(walletFile);
+ File destination = new File(destinationDirectory, fileName);
+ 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);
+ return Credentials.create(Wallet.decrypt(password, walletFile, ecKey));
+ }
+
+ 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);
+
+ return now.format(format) + walletFile.getAddress() + ".json";
+ }
+
+ public static String getDefaultKeyDirectory() {
+ return getDefaultKeyDirectory(System.getProperty("os.name"));
+ }
+
+ static String getDefaultKeyDirectory(String osName1) {
+ String osName = osName1.toLowerCase();
+
+ if (osName.startsWith("mac")) {
+ return String.format(
+ "%s%sLibrary%sEthereum", System.getProperty("user.home"), File.separator,
+ File.separator);
+ } else if (osName.startsWith("win")) {
+ return String.format("%s%sEthereum", System.getenv("APPDATA"), File.separator);
+ } else {
+ return String.format("%s%s.ethereum", System.getProperty("user.home"), File.separator);
+ }
+ }
+
+ public static String getTestnetKeyDirectory() {
+ return String.format(
+ "%s%stestnet%skeystore", getDefaultKeyDirectory(), File.separator, File.separator);
+ }
+
+ public static String getMainnetKeyDirectory() {
+ return String.format("%s%skeystore", getDefaultKeyDirectory(), File.separator);
+ }
+
+ /**
+ * Strip trailing line terminators ({@code \n}/{@code \r}) and a leading
+ * UTF-8 BOM ({@code \uFEFF}) from a line of input. Unlike
+ * {@link String#trim()} this preserves internal whitespace, so passwords
+ * containing spaces (e.g. passphrases) survive intact.
+ *
+ *
Intended as the canonical helper for normalizing raw user-provided
+ * password/line input across both CLI console and file-driven paths.
+ * Returns {@code null} if the input is {@code null}.
+ */
+ public static String stripPasswordLine(String s) {
+ if (s == null) {
+ return null;
+ }
+ if (s.length() > 0 && s.charAt(0) == '\uFEFF') {
+ s = s.substring(1);
+ }
+ int end = s.length();
+ while (end > 0) {
+ char c = s.charAt(end - 1);
+ if (c == '\n' || c == '\r') {
+ end--;
+ } else {
+ break;
+ }
+ }
+ return s.substring(0, end);
+ }
+
+ public static boolean passwordValid(String password) {
+ if (StringUtils.isEmpty(password)) {
+ return false;
+ }
+ if (password.length() < 6) {
+ return false;
+ }
+ //Other rule;
+ return true;
+ }
+
+ /**
+ * Lazily-initialized Scanner shared across successive
+ * {@link #inputPassword()} calls on the non-TTY path so that
+ * {@link #inputPassword2Twice()} can read two lines in sequence
+ * without losing data. Each call to {@code new Scanner(System.in)}
+ * internally buffers bytes from the underlying {@link BufferedReader};
+ * constructing a second Scanner after the first has been discarded
+ * drops any buffered bytes the first pulled from stdin, causing
+ * {@code NoSuchElementException}.
+ */
+ private static Scanner sharedStdinScanner;
+
+ /**
+ * Visible for testing: reset the cached Scanner so subsequent calls
+ * see a freshly rebound {@link System#in}.
+ */
+ static synchronized void resetSharedStdinScanner() {
+ sharedStdinScanner = null;
+ }
+
+ private static synchronized Scanner getSharedStdinScanner() {
+ if (sharedStdinScanner == null) {
+ sharedStdinScanner = new Scanner(System.in);
+ }
+ return sharedStdinScanner;
+ }
+
+ public static String inputPassword() {
+ String password;
+ Console cons = System.console();
+ Scanner in = cons == null ? getSharedStdinScanner() : null;
+ while (true) {
+ if (cons != null) {
+ char[] pwd = cons.readPassword("password: ");
+ password = String.valueOf(pwd);
+ } else {
+ // Preserve the full password including embedded whitespace.
+ // The previous implementation applied trim() + split("\\s+")[0]
+ // which silently truncated passwords like "correct horse battery
+ // staple" to "correct" when piped via stdin (e.g. echo ... | java).
+ // stripPasswordLine only removes the UTF-8 BOM and trailing line
+ // terminators — internal whitespace is part of the password.
+ password = stripPasswordLine(in.nextLine());
+ }
+ if (passwordValid(password)) {
+ return password;
+ }
+ System.out.println("Invalid password, please input again.");
+ }
+ }
+
+ public static String inputPassword2Twice() {
+ String password0;
+ while (true) {
+ System.out.println("Please input password.");
+ password0 = inputPassword();
+ System.out.println("Please input password again.");
+ String password1 = inputPassword();
+ if (password0.equals(password1)) {
+ break;
+ }
+ System.out.println("Two passwords do not match, please input again.");
+ }
+ return password0;
+ }
+}
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..c2ce2ba0046 100644
--- a/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java
+++ b/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java
@@ -79,12 +79,27 @@ 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);
} catch (IOException | CipherException e) {
logger.error("Witness node start failed!");
+ // Legacy-truncation hint: if this keystore was created with
+ // `FullNode.jar --keystore-factory` in non-TTY mode (e.g.
+ // `echo PASS | java ...`), the legacy code encrypted with only
+ // the first whitespace-separated word of the password. Emit the
+ // tip only when the entered password has internal whitespace —
+ // otherwise truncation cannot be the cause.
+ if (e instanceof CipherException && pwd != null && pwd.matches(".*\\s.*")) {
+ logger.error(
+ "Tip: keystores created via `FullNode.jar --keystore-factory` in "
+ + "non-TTY mode were encrypted with only the first "
+ + "whitespace-separated word of the password. Try restarting "
+ + "with only that first word as `-p`, then reset the password "
+ + "via `java -jar Toolkit.jar keystore update`.");
+ }
throw new TronError(e, TronError.ErrCode.WITNESS_KEYSTORE_LOAD);
}
diff --git a/framework/src/main/java/org/tron/keystore/WalletUtils.java b/framework/src/main/java/org/tron/keystore/WalletUtils.java
deleted file mode 100644
index 8bcc68cbab0..00000000000
--- a/framework/src/main/java/org/tron/keystore/WalletUtils.java
+++ /dev/null
@@ -1,166 +0,0 @@
-package org.tron.keystore;
-
-import com.fasterxml.jackson.core.JsonParser;
-import com.fasterxml.jackson.databind.DeserializationFeature;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import java.io.Console;
-import java.io.File;
-import java.io.IOException;
-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.Scanner;
-import org.apache.commons.lang3.StringUtils;
-import org.tron.common.crypto.SignInterface;
-import org.tron.common.crypto.SignUtils;
-import org.tron.common.utils.Utils;
-import org.tron.core.config.args.Args;
-import org.tron.core.exception.CipherException;
-
-/**
- * Utility functions for working with Wallet files.
- */
-public class WalletUtils {
-
- private static final ObjectMapper objectMapper = new ObjectMapper();
-
- static {
- objectMapper.configure(JsonParser.Feature.ALLOW_UNQUOTED_FIELD_NAMES, true);
- objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
- }
-
- public static String generateFullNewWalletFile(String password, File destinationDirectory)
- throws NoSuchAlgorithmException, NoSuchProviderException,
- InvalidAlgorithmParameterException, CipherException, IOException {
-
- return generateNewWalletFile(password, destinationDirectory, true);
- }
-
- public static String generateLightNewWalletFile(String password, File destinationDirectory)
- throws NoSuchAlgorithmException, NoSuchProviderException,
- InvalidAlgorithmParameterException, CipherException, IOException {
-
- return generateNewWalletFile(password, destinationDirectory, false);
- }
-
- public static String generateNewWalletFile(
- String password, File destinationDirectory, boolean useFullScrypt)
- throws CipherException, IOException, InvalidAlgorithmParameterException,
- NoSuchAlgorithmException, NoSuchProviderException {
-
- SignInterface ecKeyPair = SignUtils.getGeneratedRandomSign(Utils.getRandom(),
- Args.getInstance().isECKeyCryptoEngine());
- return generateWalletFile(password, ecKeyPair, destinationDirectory, useFullScrypt);
- }
-
- public static String generateWalletFile(
- String password, SignInterface ecKeyPair, File destinationDirectory, boolean useFullScrypt)
- throws CipherException, IOException {
-
- WalletFile walletFile;
- if (useFullScrypt) {
- walletFile = Wallet.createStandard(password, ecKeyPair);
- } else {
- walletFile = Wallet.createLight(password, ecKeyPair);
- }
-
- String fileName = getWalletFileName(walletFile);
- File destination = new File(destinationDirectory, fileName);
-
- objectMapper.writeValue(destination, walletFile);
-
- return fileName;
- }
-
- public static Credentials loadCredentials(String password, File source)
- throws IOException, CipherException {
- WalletFile walletFile = objectMapper.readValue(source, WalletFile.class);
- return Credentials.create(Wallet.decrypt(password, walletFile));
- }
-
- private static String getWalletFileName(WalletFile walletFile) {
- DateTimeFormatter format = DateTimeFormatter.ofPattern(
- "'UTC--'yyyy-MM-dd'T'HH-mm-ss.nVV'--'");
- ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);
-
- return now.format(format) + walletFile.getAddress() + ".json";
- }
-
- public static String getDefaultKeyDirectory() {
- return getDefaultKeyDirectory(System.getProperty("os.name"));
- }
-
- static String getDefaultKeyDirectory(String osName1) {
- String osName = osName1.toLowerCase();
-
- if (osName.startsWith("mac")) {
- return String.format(
- "%s%sLibrary%sEthereum", System.getProperty("user.home"), File.separator,
- File.separator);
- } else if (osName.startsWith("win")) {
- return String.format("%s%sEthereum", System.getenv("APPDATA"), File.separator);
- } else {
- return String.format("%s%s.ethereum", System.getProperty("user.home"), File.separator);
- }
- }
-
- public static String getTestnetKeyDirectory() {
- return String.format(
- "%s%stestnet%skeystore", getDefaultKeyDirectory(), File.separator, File.separator);
- }
-
- public static String getMainnetKeyDirectory() {
- return String.format("%s%skeystore", getDefaultKeyDirectory(), File.separator);
- }
-
- public static boolean passwordValid(String password) {
- if (StringUtils.isEmpty(password)) {
- return false;
- }
- if (password.length() < 6) {
- return false;
- }
- //Other rule;
- return true;
- }
-
- public static String inputPassword() {
- Scanner in = null;
- String password;
- Console cons = System.console();
- if (cons == null) {
- in = new Scanner(System.in);
- }
- while (true) {
- if (cons != null) {
- char[] pwd = cons.readPassword("password: ");
- password = String.valueOf(pwd);
- } else {
- String input = in.nextLine().trim();
- password = input.split("\\s+")[0];
- }
- if (passwordValid(password)) {
- return password;
- }
- System.out.println("Invalid password, please input again.");
- }
- }
-
- public static String inputPassword2Twice() {
- String password0;
- while (true) {
- System.out.println("Please input password.");
- password0 = inputPassword();
- System.out.println("Please input password again.");
- String password1 = inputPassword();
- if (password0.equals(password1)) {
- break;
- }
- System.out.println("Two passwords do not match, please input again.");
- }
- return password0;
- }
-}
diff --git a/framework/src/main/java/org/tron/program/KeystoreFactory.java b/framework/src/main/java/org/tron/program/KeystoreFactory.java
index 8199d7e9076..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,15 +66,16 @@ 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);
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());
}
@@ -84,22 +94,25 @@ 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);
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());
}
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/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
index 8aabd887bb0..df1b4440e08 100644
--- a/framework/src/test/java/org/tron/keystore/CredentialsTest.java
+++ b/framework/src/test/java/org/tron/keystore/CredentialsTest.java
@@ -77,5 +77,4 @@ public void testEqualsWithAddressAndCryptoEngine() {
Assert.assertNotEquals(credential, sameAddressDifferentEngineCredential);
Assert.assertFalse(credential.equals(differentCredential));
}
-
}
diff --git a/framework/src/test/java/org/tron/keystore/CrossImplTest.java b/framework/src/test/java/org/tron/keystore/CrossImplTest.java
new file mode 100644
index 00000000000..6b00c57c1f9
--- /dev/null
+++ b/framework/src/test/java/org/tron/keystore/CrossImplTest.java
@@ -0,0 +1,165 @@
+package org.tron.keystore;
+
+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;
+
+/**
+ * Format compatibility tests.
+ *
+ * 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 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";
+
+ 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("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 testLightScryptFormatCompatibility() throws Exception {
+ SignInterface keyPair = SignUtils.getGeneratedRandomSign(Utils.getRandom(), true);
+ byte[] originalKey = keyPair.getPrivateKey();
+ String password = "lightCompat456";
+
+ 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("Key must survive light scrypt file roundtrip",
+ 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();
+
+ 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/framework/src/test/java/org/tron/keystore/WalletAddressValidationTest.java b/framework/src/test/java/org/tron/keystore/WalletAddressValidationTest.java
new file mode 100644
index 00000000000..82008988b6e
--- /dev/null
+++ b/framework/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"));
+ }
+ }
+}
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/keystore/WalletPropertyTest.java b/framework/src/test/java/org/tron/keystore/WalletPropertyTest.java
new file mode 100644
index 00000000000..3028d2a7799
--- /dev/null
+++ b/framework/src/test/java/org/tron/keystore/WalletPropertyTest.java
@@ -0,0 +1,77 @@
+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(timeout = 120000)
+ public void encryptDecryptRoundtripStandard() throws Exception {
+ // 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();
+
+ 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/framework/src/test/java/org/tron/keystore/WalletUtilsInputPasswordTest.java b/framework/src/test/java/org/tron/keystore/WalletUtilsInputPasswordTest.java
new file mode 100644
index 00000000000..64752b9ca49
--- /dev/null
+++ b/framework/src/test/java/org/tron/keystore/WalletUtilsInputPasswordTest.java
@@ -0,0 +1,167 @@
+package org.tron.keystore;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Verifies that {@link WalletUtils#inputPassword()} preserves the full
+ * password including internal whitespace on the non-TTY (stdin) path,
+ * and that {@link WalletUtils#stripPasswordLine(String)} handles all
+ * edge cases correctly.
+ *
+ *
Previously the non-TTY path applied {@code trim() + split("\\s+")[0]}
+ * which silently truncated passphrases like "correct horse battery staple"
+ * to "correct" when piped via stdin. This test locks in the fix.
+ */
+public class WalletUtilsInputPasswordTest {
+
+ private InputStream originalIn;
+
+ @Before
+ public void saveStdin() {
+ originalIn = System.in;
+ // Clear the cached Scanner so each test binds to its own System.in
+ WalletUtils.resetSharedStdinScanner();
+ }
+
+ @After
+ public void restoreStdin() {
+ System.setIn(originalIn);
+ WalletUtils.resetSharedStdinScanner();
+ }
+
+ // ---------- inputPassword() behavioral tests ----------
+
+ @Test(timeout = 5000)
+ public void testInputPasswordPreservesInternalWhitespace() {
+ System.setIn(new ByteArrayInputStream(
+ "correct horse battery staple\n".getBytes(StandardCharsets.UTF_8)));
+
+ String pw = WalletUtils.inputPassword();
+
+ assertEquals("Password with internal whitespace must be preserved intact",
+ "correct horse battery staple", pw);
+ }
+
+ @Test(timeout = 5000)
+ public void testInputPasswordPreservesTabs() {
+ System.setIn(new ByteArrayInputStream(
+ "pass\tw0rd\n".getBytes(StandardCharsets.UTF_8)));
+
+ String pw = WalletUtils.inputPassword();
+
+ assertEquals("Internal tabs must be preserved", "pass\tw0rd", pw);
+ }
+
+ @Test(timeout = 5000)
+ public void testInputPasswordStripsTrailingCr() {
+ // Windows line endings
+ System.setIn(new ByteArrayInputStream(
+ "password123\r\n".getBytes(StandardCharsets.UTF_8)));
+
+ String pw = WalletUtils.inputPassword();
+
+ assertEquals("Trailing \\r must be stripped", "password123", pw);
+ }
+
+ @Test(timeout = 5000)
+ public void testInputPasswordStripsBom() {
+ System.setIn(new ByteArrayInputStream(
+ "\uFEFFpassword123\n".getBytes(StandardCharsets.UTF_8)));
+
+ String pw = WalletUtils.inputPassword();
+
+ assertEquals("UTF-8 BOM must be stripped from the start", "password123", pw);
+ }
+
+ @Test(timeout = 5000)
+ public void testInputPasswordPreservesLeadingAndTrailingSpaces() {
+ // The legacy bug also called trim(); post-fix, spaces at the edges
+ // are part of the password. Callers that want to trim should do so
+ // themselves with full knowledge.
+ System.setIn(new ByteArrayInputStream(
+ " with spaces \n".getBytes(StandardCharsets.UTF_8)));
+
+ String pw = WalletUtils.inputPassword();
+
+ assertEquals("Leading and trailing spaces are part of the password",
+ " with spaces ", pw);
+ }
+
+ @Test(timeout = 10000)
+ public void testInputPassword2TwicePipedPreservesInternalWhitespace() {
+ // M1: verifies the double-read path (inputPassword2Twice → inputPassword()
+ // called twice) works correctly when both lines arrive on the same
+ // piped stdin. Guards against regressions from Scanner lifecycle issues
+ // where a newly-constructed Scanner could miss bytes buffered by an
+ // earlier Scanner on the same InputStream.
+ System.setIn(new ByteArrayInputStream(
+ ("correct horse battery staple\n"
+ + "correct horse battery staple\n").getBytes(StandardCharsets.UTF_8)));
+
+ String pw = WalletUtils.inputPassword2Twice();
+
+ assertEquals("Full passphrase must survive the double-read path",
+ "correct horse battery staple", pw);
+ }
+
+ // ---------- stripPasswordLine() direct unit tests (M3) ----------
+
+ @Test
+ public void testStripPasswordLineNull() {
+ assertNull(WalletUtils.stripPasswordLine(null));
+ }
+
+ @Test
+ public void testStripPasswordLineEmpty() {
+ assertEquals("", WalletUtils.stripPasswordLine(""));
+ }
+
+ @Test
+ public void testStripPasswordLineOnlyBom() {
+ assertEquals("", WalletUtils.stripPasswordLine("\uFEFF"));
+ }
+
+ @Test
+ public void testStripPasswordLineOnlyLineTerminators() {
+ assertEquals("", WalletUtils.stripPasswordLine("\r\n\r\n"));
+ }
+
+ @Test
+ public void testStripPasswordLineBomThenTerminator() {
+ assertEquals("", WalletUtils.stripPasswordLine("\uFEFF\r\n"));
+ }
+
+ @Test
+ public void testStripPasswordLineBomAndInternalWhitespace() {
+ assertEquals("with spaces",
+ WalletUtils.stripPasswordLine("\uFEFFwith spaces\r\n"));
+ }
+
+ @Test
+ public void testStripPasswordLineNoChange() {
+ assertEquals("password", WalletUtils.stripPasswordLine("password"));
+ }
+
+ @Test
+ public void testStripPasswordLineTrailingLf() {
+ assertEquals("password", WalletUtils.stripPasswordLine("password\n"));
+ }
+
+ @Test
+ public void testStripPasswordLineTrailingCr() {
+ assertEquals("password", WalletUtils.stripPasswordLine("password\r"));
+ }
+
+ @Test
+ public void testStripPasswordLineMultipleTrailing() {
+ assertEquals("password", WalletUtils.stripPasswordLine("password\r\n\r\n"));
+ }
+}
diff --git a/framework/src/test/java/org/tron/keystore/WalletUtilsWriteTest.java b/framework/src/test/java/org/tron/keystore/WalletUtilsWriteTest.java
new file mode 100644
index 00000000000..f67db5db20d
--- /dev/null
+++ b/framework/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/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..860980d21e5
--- /dev/null
+++ b/framework/src/test/java/org/tron/program/KeystoreFactoryDeprecationTest.java
@@ -0,0 +1,147 @@
+package org.tron.program;
+
+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 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 {
+ ByteArrayOutputStream errContent = new ByteArrayOutputStream();
+ System.setErr(new PrintStream(errContent));
+ System.setIn(new ByteArrayInputStream("exit\n".getBytes()));
+
+ KeystoreFactory.start();
+
+ 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"));
+ }
+
+ @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"));
+ }
+}
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/README.md b/plugins/README.md
index db25811882f..ab64bf8279a 100644
--- a/plugins/README.md
+++ b/plugins/README.md
@@ -143,3 +143,74 @@ 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
+```
+
+> **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.
+
+```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/build.gradle b/plugins/build.gradle
index 85dcdd2342d..2e358a884a3 100644
--- a/plugins/build.gradle
+++ b/plugins/build.gradle
@@ -34,6 +34,12 @@ dependencies {
implementation fileTree(dir: 'libs', include: '*.jar')
testImplementation project(":framework")
testImplementation project(":framework").sourceSets.test.output
+ 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
new file mode 100644
index 00000000000..6929bb406ea
--- /dev/null
+++ b/plugins/src/main/java/common/org/tron/plugins/Keystore.java
@@ -0,0 +1,19 @@
+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 account keys.",
+ subcommands = {CommandLine.HelpCommand.class,
+ KeystoreNew.class,
+ KeystoreImport.class,
+ KeystoreList.class,
+ KeystoreUpdate.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/KeystoreCliUtils.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java
new file mode 100644
index 00000000000..8c06622fa3a
--- /dev/null
+++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java
@@ -0,0 +1,262 @@
+package org.tron.plugins;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.Console;
+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;
+import org.tron.keystore.WalletUtils;
+
+/**
+ * Shared utilities for keystore CLI commands.
+ */
+final class KeystoreCliUtils {
+
+ private static final long MAX_FILE_SIZE = 1024;
+
+ 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) {
+ byte[] bytes = readRegularFile(passwordFile, MAX_FILE_SIZE, "Password file", err);
+ if (bytes == null) {
+ return null;
+ }
+ try {
+ String password = WalletUtils.stripPasswordLine(
+ new String(bytes, StandardCharsets.UTF_8));
+ // Reject multi-line password files. stripPasswordLine only trims
+ // trailing terminators; any remaining \n/\r means the file had
+ // interior line breaks. A common mistake is passing a two-line
+ // `keystore update` password file to `keystore new` / `import` —
+ // without this guard the literal "old\nnew" would silently become
+ // the password, and neither visible line alone would unlock the
+ // keystore later.
+ if (password.indexOf('\n') >= 0 || password.indexOf('\r') >= 0) {
+ err.println("Password file contains multiple lines; provide a "
+ + "single-line password (the `keystore update` two-line "
+ + "format is not accepted here).");
+ return null;
+ }
+ if (!WalletUtils.passwordValid(password)) {
+ err.println("Invalid password: must be at least 6 characters.");
+ return null;
+ }
+ return password;
+ } finally {
+ Arrays.fill(bytes, (byte) 0);
+ }
+ }
+
+ Console console = System.console();
+ if (console == null) {
+ err.println("No interactive terminal available. "
+ + "Use --password-file to provide password.");
+ return null;
+ }
+
+ char[] pwd1 = console.readPassword("Enter password: ");
+ if (pwd1 == null) {
+ err.println("Password input cancelled.");
+ return null;
+ }
+ char[] pwd2 = console.readPassword("Confirm password: ");
+ if (pwd2 == null) {
+ Arrays.fill(pwd1, '\0');
+ err.println("Password input cancelled.");
+ return null;
+ }
+ try {
+ if (!Arrays.equals(pwd1, pwd2)) {
+ err.println("Passwords do not match.");
+ return null;
+ }
+ String password = new String(pwd1);
+ if (!WalletUtils.passwordValid(password)) {
+ 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(PrintWriter out, PrintWriter err, Map fields) {
+ try {
+ out.println(MAPPER.writeValueAsString(fields));
+ } catch (Exception e) {
+ 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 boolean checkFileExists(File file, String label, PrintWriter err) {
+ if (file != null && !file.exists()) {
+ err.println(label + " not found: " + file.getPath());
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Returns true iff {@code file} exists, is not a symbolic link, and is a
+ * regular file (not a directory, FIFO, or device). Used to filter keystore
+ * directory scans before {@code MAPPER.readValue(file, ...)} so a hostile
+ * or group-writable keystore directory cannot redirect reads to arbitrary
+ * files (e.g. {@code /etc/shadow}) or block on non-regular files
+ * (e.g. a FIFO) via planted entries.
+ *
+ * Writes a warning to {@code err} when the entry is skipped.
+ */
+ static boolean isSafeRegularFile(File file, PrintWriter err) {
+ try {
+ BasicFileAttributes attrs = Files.readAttributes(file.toPath(),
+ BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
+ if (attrs.isSymbolicLink()) {
+ err.println("Warning: skipping symbolic link: " + file.getName());
+ return false;
+ }
+ if (!attrs.isRegularFile()) {
+ err.println("Warning: skipping non-regular file: " + file.getName());
+ return false;
+ }
+ return true;
+ } catch (IOException e) {
+ err.println("Warning: skipping unreadable file: " + file.getName());
+ return false;
+ }
+ }
+
+ static void printSecurityTips(PrintWriter out, String address, String fileName) {
+ out.println();
+ out.println("Public address of the key: " + address);
+ 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.");
+ out.println(
+ "- You must NEVER share the secret key with anyone!"
+ + " The key controls access to your funds!");
+ out.println(
+ "- You must BACKUP your key file!"
+ + " Without the key, it's impossible to access account funds!");
+ out.println(
+ "- You must REMEMBER your password!"
+ + " Without the password, it's impossible to decrypt the key!");
+ }
+
+ /**
+ * Check if a WalletFile represents a decryptable V3 keystore.
+ * Delegates to {@link Wallet#isValidKeystoreFile(WalletFile)} so the
+ * discovery predicate stays in sync with decryption-time validation —
+ * a JSON stub with empty or unsupported cipher/KDF is rejected here
+ * rather than silently showing up as a "keystore" and failing later.
+ */
+ static boolean isValidKeystoreFile(WalletFile wf) {
+ return org.tron.keystore.Wallet.isValidKeystoreFile(wf);
+ }
+}
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..3b1effe0431
--- /dev/null
+++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java
@@ -0,0 +1,187 @@
+package org.tron.plugins;
+
+import java.io.Console;
+import java.io.File;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+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.WalletFile;
+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")
+ 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;
+
+ @Option(names = {"--sm2"},
+ 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() {
+ PrintWriter out = spec.commandLine().getOut();
+ PrintWriter err = spec.commandLine().getErr();
+ try {
+ if (!KeystoreCliUtils.checkFileExists(keyFile, "Key file", err)) {
+ return 1;
+ }
+ KeystoreCliUtils.ensureDirectory(keystoreDir);
+
+ String privateKey = readPrivateKey(err);
+ if (privateKey == null) {
+ return 1;
+ }
+
+ if (privateKey.startsWith("0x") || privateKey.startsWith("0X")) {
+ privateKey = privateKey.substring(2);
+ }
+ if (!isValidPrivateKey(privateKey)) {
+ err.println("Invalid private key: must be 64 hex characters.");
+ return 1;
+ }
+
+ String password = KeystoreCliUtils.readPassword(passwordFile, err);
+ if (password == null) {
+ return 1;
+ }
+
+ boolean ecKey = !sm2;
+ SignInterface keyPair;
+ try {
+ keyPair = SignUtils.fromPrivate(
+ ByteArray.fromHexString(privateKey), ecKey);
+ } catch (Exception e) {
+ 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, 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);
+ if (json) {
+ KeystoreCliUtils.printJson(out, err, KeystoreCliUtils.jsonMap(
+ "address", address, "file", fileName));
+ } else {
+ out.println("Imported keystore successfully");
+ KeystoreCliUtils.printSecurityTips(out, address,
+ new File(keystoreDir, fileName).getPath());
+ }
+ return 0;
+ } catch (CipherException e) {
+ err.println("Encryption error: " + e.getMessage());
+ return 1;
+ } catch (Exception e) {
+ err.println("Error: " + e.getMessage());
+ return 1;
+ }
+ }
+
+ private String readPrivateKey(PrintWriter err) throws IOException {
+ if (keyFile != null) {
+ byte[] bytes = KeystoreCliUtils.readRegularFile(keyFile, 1024, "Key file", err);
+ if (bytes == null) {
+ return null;
+ }
+ try {
+ return new String(bytes, StandardCharsets.UTF_8).trim();
+ } finally {
+ Arrays.fill(bytes, (byte) 0);
+ }
+ }
+
+ Console console = System.console();
+ if (console == null) {
+ 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) {
+ err.println("Input cancelled.");
+ return null;
+ }
+ try {
+ return new String(key);
+ } finally {
+ Arrays.fill(key, '\0');
+ }
+ }
+
+ private static final java.util.regex.Pattern HEX_PATTERN =
+ java.util.regex.Pattern.compile("[0-9a-fA-F]{64}");
+
+ private boolean isValidPrivateKey(String key) {
+ return !StringUtils.isEmpty(key) && HEX_PATTERN.matcher(key).matches();
+ }
+
+ private String findExistingKeystore(File dir, String address, PrintWriter err) {
+ if (!dir.exists() || !dir.isDirectory()) {
+ return null;
+ }
+ File[] files = dir.listFiles((d, name) -> name.endsWith(".json"));
+ if (files == null) {
+ return null;
+ }
+ com.fasterxml.jackson.databind.ObjectMapper mapper =
+ KeystoreCliUtils.mapper();
+ for (File file : files) {
+ if (!KeystoreCliUtils.isSafeRegularFile(file, err)) {
+ continue;
+ }
+ try {
+ WalletFile wf = mapper.readValue(file, WalletFile.class);
+ if (KeystoreCliUtils.isValidKeystoreFile(wf)
+ && address.equals(wf.getAddress())) {
+ return file.getName();
+ }
+ } catch (Exception e) {
+ 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
new file mode 100644
index 00000000000..214ecf0a642
--- /dev/null
+++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreList.java
@@ -0,0 +1,109 @@
+package org.tron.plugins;
+
+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;
+import java.util.Map;
+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,
+ description = "List all keystore files in a directory.")
+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")
+ private File keystoreDir;
+
+ @Option(names = {"--json"},
+ description = "Output in JSON format")
+ private boolean json;
+
+ @Override
+ public Integer call() {
+ PrintWriter out = spec.commandLine().getOut();
+ PrintWriter err = spec.commandLine().getErr();
+
+ if (!keystoreDir.exists() || !keystoreDir.isDirectory()) {
+ if (json) {
+ return printEmptyJson(out, err);
+ } else {
+ 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(out, err);
+ } else {
+ out.println("No keystores found in: " + keystoreDir.getAbsolutePath());
+ }
+ return 0;
+ }
+
+ List