Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7a1ab56
refactor(crypto): extract keystore library from framework module
Apr 2, 2026
1e5bf8c
feat(plugins): add keystore new and import commands to Toolkit
Apr 2, 2026
1492a49
feat(plugins): add keystore list and update commands, deprecate --key…
Apr 2, 2026
6c9fab2
fix(plugins): handle duplicate-address keystores and add import warning
Apr 3, 2026
d7f7c6b
fix(plugins): block duplicate import, improve error messages and secu…
Apr 5, 2026
ba232f4
style(plugins): use picocli output streams and address review findings
Apr 16, 2026
88df0ff
fix(plugins): secure keystore file creation and improve robustness
Apr 16, 2026
e61cbb2
test(plugins): improve keystore test coverage and assertions
Apr 16, 2026
1f27bca
fix(plugins): unify keystore validation and fix inconsistent error me…
Apr 16, 2026
309dc23
test(plugins): improve coverage for keystore validation and edge cases
Apr 17, 2026
e5c49e7
test(plugins): add direct unit tests for KeystoreCliUtils
Apr 17, 2026
ad68bd7
test(framework): expand coverage for WalletFile POJO and KeystoreFactory
Apr 17, 2026
2ba80c3
ci: re-trigger build after transient apt mirror failure
Apr 17, 2026
8ffbbb3
ci: re-trigger after transient infrastructure failure
Apr 17, 2026
63bf397
fix(crypto): enforce keystore address consistency in Wallet.decrypt
Apr 18, 2026
51350e8
fix(plugins): prevent address spoofing in keystore update command
Apr 18, 2026
f519a91
docs(plugins): document keystore list address trust model
Apr 18, 2026
6b66b40
refactor(crypto): centralize secure keystore file writing in WalletUtils
Apr 18, 2026
ba01b3b
fix(plugins): reject symlinked password/key files to prevent file dis…
Apr 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -168,8 +167,8 @@ private static byte[] generateMac(byte[] derivedKey, byte[] cipherText) {
return Hash.sha3(result);
}

public static SignInterface decrypt(String password, WalletFile walletFile)
throws CipherException {
public static SignInterface decrypt(String password, WalletFile walletFile,
boolean ecKey) throws CipherException {

validate(walletFile);

Expand Down Expand Up @@ -205,14 +204,29 @@ 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);
}
}

return keyPair;
}

static void validate(WalletFile walletFile) throws CipherException {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,26 @@
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.config.args.Args;
import org.tron.core.exception.CipherException;

/**
Expand All @@ -27,32 +35,37 @@ public class WalletUtils {

private static final ObjectMapper objectMapper = new ObjectMapper();

private static final Set<PosixFilePermission> 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)
public static String generateFullNewWalletFile(String password, File destinationDirectory,
boolean ecKey)
throws NoSuchAlgorithmException, NoSuchProviderException,
InvalidAlgorithmParameterException, CipherException, IOException {

return generateNewWalletFile(password, destinationDirectory, true);
return generateNewWalletFile(password, destinationDirectory, true, ecKey);
}

public static String generateLightNewWalletFile(String password, File destinationDirectory)
public static String generateLightNewWalletFile(String password, File destinationDirectory,
boolean ecKey)
throws NoSuchAlgorithmException, NoSuchProviderException,
InvalidAlgorithmParameterException, CipherException, IOException {

return generateNewWalletFile(password, destinationDirectory, false);
return generateNewWalletFile(password, destinationDirectory, false, ecKey);
}

public static String generateNewWalletFile(
String password, File destinationDirectory, boolean useFullScrypt)
String password, File destinationDirectory, boolean useFullScrypt, boolean ecKey)
throws CipherException, IOException, InvalidAlgorithmParameterException,
NoSuchAlgorithmException, NoSuchProviderException {

SignInterface ecKeyPair = SignUtils.getGeneratedRandomSign(Utils.getRandom(),
Args.getInstance().isECKeyCryptoEngine());
SignInterface ecKeyPair = SignUtils.getGeneratedRandomSign(Utils.getRandom(), ecKey);
return generateWalletFile(password, ecKeyPair, destinationDirectory, useFullScrypt);
}

Expand All @@ -69,19 +82,76 @@ public static String generateWalletFile(

String fileName = getWalletFileName(walletFile);
File destination = new File(destinationDirectory, fileName);

objectMapper.writeValue(destination, walletFile);
writeWalletFile(walletFile, destination);

return fileName;
}

public static Credentials loadCredentials(String password, File source)
/**
* Write a WalletFile to the given destination path with owner-only (0600)
* permissions, using a temp file + atomic rename.
*
* <p>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.
*
* <p>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));
return Credentials.create(Wallet.decrypt(password, walletFile, ecKey));
}

private static String getWalletFileName(WalletFile walletFile) {
public static String getWalletFileName(WalletFile walletFile) {
DateTimeFormatter format = DateTimeFormatter.ofPattern(
"'UTC--'yyyy-MM-dd'T'HH-mm-ss.nVV'--'");
ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);
Expand Down
72 changes: 72 additions & 0 deletions crypto/src/test/java/org/tron/keystore/CredentialsTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package org.tron.keystore;

import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import lombok.extern.slf4j.Slf4j;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.Mockito;
import org.tron.common.crypto.SignInterface;
import org.tron.common.crypto.SignUtils;
import org.tron.common.crypto.sm2.SM2;
import org.tron.common.utils.ByteUtil;

@Slf4j
public class CredentialsTest {

@Test
public void testCreate() throws NoSuchAlgorithmException {
Credentials credentials = Credentials.create(SignUtils.getGeneratedRandomSign(
SecureRandom.getInstance("NativePRNG"), true));
Assert.assertTrue("Credentials address create failed!",
credentials.getAddress() != null && !credentials.getAddress().isEmpty());
Assert.assertNotNull("Credentials cryptoEngine create failed",
credentials.getSignInterface());
}

@Test
public void testCreateFromSM2() {
try {
Credentials.create(SM2.fromNodeId(ByteUtil.hexToBytes("fffffffffff"
+ "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
+ "fffffffffffffffffffffffffffffffffffffff")));
Assert.fail("Expected IllegalArgumentException");
} catch (IllegalArgumentException e) {
// Expected
}
}

@Test
public void testEquals() throws NoSuchAlgorithmException {
Credentials credentials1 = Credentials.create(SignUtils.getGeneratedRandomSign(
SecureRandom.getInstance("NativePRNG"), true));
Credentials credentials2 = Credentials.create(SignUtils.getGeneratedRandomSign(
SecureRandom.getInstance("NativePRNG"), true));
Assert.assertFalse("Credentials instance should be not equal!",
credentials1.equals(credentials2));
}

@Test
public void testEqualityWithMocks() {
Object aObject = new Object();
SignInterface si = Mockito.mock(SignInterface.class);
SignInterface si2 = Mockito.mock(SignInterface.class);
SignInterface si3 = Mockito.mock(SignInterface.class);
byte[] address = "TQhZ7W1RudxFdzJMw6FvMnujPxrS6sFfmj".getBytes();
byte[] address2 = "TNCmcTdyrYKMtmE1KU2itzeCX76jGm5Not".getBytes();
Mockito.when(si.getAddress()).thenReturn(address);
Mockito.when(si2.getAddress()).thenReturn(address);
Mockito.when(si3.getAddress()).thenReturn(address2);
Credentials aCredential = Credentials.create(si);
Assert.assertFalse(aObject.equals(aCredential));
Assert.assertFalse(aCredential.equals(aObject));
Assert.assertFalse(aCredential.equals(null));
Credentials anotherCredential = Credentials.create(si);
Assert.assertTrue(aCredential.equals(anotherCredential));
Credentials aCredential2 = Credentials.create(si2);
// si and si2 are different mock objects, so credentials are not equal
Assert.assertFalse(aCredential.equals(aCredential2));
Credentials aCredential3 = Credentials.create(si3);
Assert.assertFalse(aCredential.equals(aCredential3));
}
}
Loading
Loading