diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9c3af93f787..f818cc5ad65 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -34,6 +34,9 @@ jobs: - name: Checkout repository uses: actions/checkout@v5 + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@v6 + # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v4 diff --git a/.github/workflows/integration-test-single-node.yml b/.github/workflows/integration-test-single-node.yml index 3c56843c799..3ce0cc248a3 100644 --- a/.github/workflows/integration-test-single-node.yml +++ b/.github/workflows/integration-test-single-node.yml @@ -25,6 +25,9 @@ jobs: - name: Checkout java-tron uses: actions/checkout@v5 + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@v6 + - name: Set up JDK 8 uses: actions/setup-java@v5 with: diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index c7a5a6f4160..e76c6993b1e 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -36,6 +36,9 @@ jobs: steps: - uses: actions/checkout@v5 + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@v6 + - name: Set up JDK ${{ matrix.java }} uses: actions/setup-java@v5 with: @@ -72,6 +75,9 @@ jobs: steps: - uses: actions/checkout@v5 + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@v6 + - name: Set up JDK 17 uses: actions/setup-java@v5 with: @@ -126,6 +132,9 @@ jobs: - name: Checkout code uses: actions/checkout@v5 + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@v6 + - name: Check Java version run: java -version @@ -183,6 +192,9 @@ jobs: - name: Checkout code uses: actions/checkout@v5 + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@v6 + - name: Check Java version run: java -version @@ -249,6 +261,9 @@ jobs: with: ref: ${{ github.event.pull_request.base.sha }} + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@v6 + - name: Cache Gradle packages uses: actions/cache@v5 with: diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 6a7337ce310..014fe5d104f 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -110,6 +110,9 @@ jobs: steps: - uses: actions/checkout@v5 + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@v6 + - name: Set up Python uses: actions/setup-python@v5 with: diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index d67e2418bf6..c98747dadc8 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -54,6 +54,9 @@ jobs: with: ref: ${{ github.sha }} + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@v6 + - name: Cache Gradle packages uses: actions/cache@v5 with: @@ -121,6 +124,9 @@ jobs: with: ref: ${{ github.sha }} + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@v6 + - name: Set up Temurin 17 uses: actions/setup-java@v5 with: diff --git a/README.md b/README.md index 721468efdd3..0d79c36d7a8 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,9 @@ Fill in the private key of your SR account into the `localwitness` list in the c ] ``` + +> **Note**: Plain private keys in `localwitness` (and the `-p/--private-key` CLI flag) are deprecated and insecure — they are readable by anyone with access to the config file, process list, or shell history. Use the encrypted [`localwitnesskeystore`](https://tronprotocol.github.io/documentation-en/using_javatron/toolkit/) instead. + Check [Starting a Block Production Node](https://tronprotocol.github.io/documentation-en/using_javatron/installing_javatron/#starting-a-block-production-node) for more details. You could also test the process by connecting to a testnet or setting up a private network. diff --git a/common/src/main/java/org/tron/core/config/args/NodeConfig.java b/common/src/main/java/org/tron/core/config/args/NodeConfig.java index 56d5cd7cca3..3e60378d86a 100644 --- a/common/src/main/java/org/tron/core/config/args/NodeConfig.java +++ b/common/src/main/java/org/tron/core/config/args/NodeConfig.java @@ -221,7 +221,7 @@ public static class RpcConfig { private int maxConcurrentCallsPerConnection = DEFAULT_MAX_CONCURRENT_CALLS_PER_CONNECTION; private int flowControlWindow = 1048576; - private long maxConnectionIdleInMillis = 0; + private long maxConnectionIdleInMillis = 60000L; private long maxConnectionAgeInMillis = 0; private int maxMessageSize = 4194304; private int maxHeaderListSize = 8192; diff --git a/common/src/main/resources/reference.conf b/common/src/main/resources/reference.conf index a425d9c0f39..49caf28b3af 100644 --- a/common/src/main/resources/reference.conf +++ b/common/src/main/resources/reference.conf @@ -288,7 +288,7 @@ node { flowControlWindow = 1048576 # Connection idle timeout (ms). Connections idle longer than this are gracefully terminated. 0 = no limit - maxConnectionIdleInMillis = 0 + maxConnectionIdleInMillis = 60000 # Connection max age (ms). 0 = no limit maxConnectionAgeInMillis = 0 diff --git a/common/src/test/java/org/tron/core/config/args/NodeConfigTest.java b/common/src/test/java/org/tron/core/config/args/NodeConfigTest.java index dac9dfdbdd6..76589b4a82b 100644 --- a/common/src/test/java/org/tron/core/config/args/NodeConfigTest.java +++ b/common/src/test/java/org/tron/core/config/args/NodeConfigTest.java @@ -108,7 +108,9 @@ public void testRpcDefaultsFromReference() { assertEquals(NodeConfig.RpcConfig.DEFAULT_MAX_CONCURRENT_CALLS_PER_CONNECTION, rpc.getMaxConcurrentCallsPerConnection()); assertEquals(1048576, rpc.getFlowControlWindow()); - assertEquals(9223372036854775807L, rpc.getMaxConnectionIdleInMillis()); + // reference.conf recommends 60000 for idle; age stays 0 in + // reference.conf, which postProcess maps to Long.MAX_VALUE. + assertEquals(60000L, rpc.getMaxConnectionIdleInMillis()); assertEquals(9223372036854775807L, rpc.getMaxConnectionAgeInMillis()); assertEquals(4194304, rpc.getMaxMessageSize()); assertEquals(8192, rpc.getMaxHeaderListSize()); @@ -149,6 +151,17 @@ public void testRpcNegativeConcurrentCallsRejected() { "node.rpc.maxConcurrentCallsPerConnection must be non-negative, got: -1")); } + @Test + public void testRpcExplicitZeroIdleAndAgeMeansUnlimited() { + // Legacy semantic preserved: an explicit 0 for idle/age means unlimited + // (Long.MAX_VALUE). reference.conf recommends 60000 for idle instead. + Config config = withRef( + "node { rpc { maxConnectionIdleInMillis = 0, maxConnectionAgeInMillis = 0 } }"); + NodeConfig nc = NodeConfig.fromConfig(config); + assertEquals(Long.MAX_VALUE, nc.getRpc().getMaxConnectionIdleInMillis()); + assertEquals(Long.MAX_VALUE, nc.getRpc().getMaxConnectionAgeInMillis()); + } + @Test public void testRpcRstDefaultsMatchReference() { NodeConfig.RpcConfig rpc = new NodeConfig.RpcConfig(); diff --git a/crypto/src/main/java/org/tron/keystore/Wallet.java b/crypto/src/main/java/org/tron/keystore/Wallet.java index d0051764490..6baa24a3cf7 100644 --- a/crypto/src/main/java/org/tron/keystore/Wallet.java +++ b/crypto/src/main/java/org/tron/keystore/Wallet.java @@ -63,9 +63,32 @@ public class Wallet { private static final int CURRENT_VERSION = 3; private static final String CIPHER = "aes-128-ctr"; + // Upper bounds for untrusted keystore KDF cost parameters. A hostile + // keystore file may declare arbitrary KDF strengths: extreme scrypt + // parameters make Bouncy Castle either allocate unbounded memory + // (n*r*128 bytes — e.g. n=2^22, r=8 requests 4 GiB and dies with OOM) + // or compute for hours, and out-of-range values can crash the JVM with + // NegativeArraySizeException / ArithmeticException inside SCrypt.generate. + // All values here are rejected by validationError() before any KDF runs. + private static final int SCRYPT_N_MIN = 1 << 12; + private static final int SCRYPT_N_MAX = 1 << 20; + private static final int SCRYPT_R_MAX = 8; + private static final int SCRYPT_P_MAX = 8; + private static final long SCRYPT_MEMORY_MAX_BYTES = 1L << 30; // 128 * r * n cap = 1 GiB + private static final int KDF_DKLEN_MAX = 128; + private static final int PBKDF2_C_MAX = 1 << 20; + public static WalletFile create(String password, SignInterface sign, int n, int p) throws CipherException { + // Keep create() symmetric with validate()/decrypt(): reject KDF cost + // parameters that the decryption path would refuse, so create() can + // never produce a wallet file that only this class rejects. + String paramError = scryptKdfParamsError(DKLEN, n, R, p); + if (paramError != null) { + throw new CipherException(paramError); + } + byte[] salt = generateRandomBytes(32); byte[] derivedKey = generateDerivedScryptKey(password.getBytes(UTF_8), salt, n, R, p, DKLEN); @@ -194,7 +217,16 @@ public static SignInterface decrypt(String password, WalletFile walletFile) int p = scryptKdfParams.getP(); int r = scryptKdfParams.getR(); byte[] salt = ByteArray.fromHexString(scryptKdfParams.getSalt()); - derivedKey = generateDerivedScryptKey(password.getBytes(UTF_8), salt, n, r, p, dklen); + try { + derivedKey = generateDerivedScryptKey(password.getBytes(UTF_8), salt, n, r, p, dklen); + } catch (RuntimeException e) { + // Defense-in-depth: any residual KDF engine failure (e.g. an + // IllegalArgumentException from Bouncy Castle) must surface as a + // clean CipherException, never as a raw RuntimeException stack. + // Errors (OutOfMemoryError etc.) are deliberately not caught — + // they remain fatal. + throw new CipherException("Scrypt key derivation failed", e); + } } else if (kdfParams instanceof WalletFile.Aes128CtrKdfParams) { WalletFile.Aes128CtrKdfParams aes128CtrKdfParams = (WalletFile.Aes128CtrKdfParams) crypto.getKdfparams(); @@ -202,7 +234,11 @@ public static SignInterface decrypt(String password, WalletFile walletFile) String prf = aes128CtrKdfParams.getPrf(); byte[] salt = ByteArray.fromHexString(aes128CtrKdfParams.getSalt()); - derivedKey = generateAes128CtrDerivedKey(password.getBytes(UTF_8), salt, c, prf); + try { + derivedKey = generateAes128CtrDerivedKey(password.getBytes(UTF_8), salt, c, prf); + } catch (RuntimeException e) { + throw new CipherException("Pbkdf2 key derivation failed", e); + } } else { throw new CipherException("Unable to deserialize params: " + crypto.getKdf()); } @@ -254,11 +290,15 @@ public static SignInterface decrypt(String password, WalletFile walletFile) /** * 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). + * V3 keystore shape (current version, known cipher, known KDF, and KDF + * cost parameters within safe bounds). * - *

Shared by {@link #validate(WalletFile)} (which throws the message) - * and {@link #isValidKeystoreFile(WalletFile)} (which returns boolean - * for discovery-style filtering). + *

KDF cost bounds are enforced here — the single chokepoint shared by + * {@link #validate(WalletFile)} (which throws the message) and + * {@link #isValidKeystoreFile(WalletFile)} (which returns boolean for + * discovery-style filtering) — so untrusted keystore files can never + * drive the KDF into unbounded memory allocation, multi-hour CPU + * exhaustion, or engine-level crashes before {@link #decrypt} runs. */ private static String validationError(WalletFile walletFile) { if (walletFile.getVersion() != CURRENT_VERSION) { @@ -276,6 +316,58 @@ private static String validationError(WalletFile walletFile) { if (kdf == null || (!kdf.equals(PBKDF2) && !kdf.equals(SCRYPT))) { return "KDF type is not supported"; } + WalletFile.KdfParams kdfParams = crypto.getKdfparams(); + if (kdfParams == null) { + // Structural stubs (e.g. tooling fixtures) may omit kdfparams entirely; + // there are no cost parameters to bound. Real keystores always carry them. + return null; + } + if (SCRYPT.equals(kdf)) { + if (!(kdfParams instanceof WalletFile.ScryptKdfParams)) { + return "Scrypt KDF params are missing or malformed"; + } + WalletFile.ScryptKdfParams params = (WalletFile.ScryptKdfParams) kdfParams; + return scryptKdfParamsError(params.getDklen(), params.getN(), params.getR(), params.getP()); + } + if (!(kdfParams instanceof WalletFile.Aes128CtrKdfParams)) { + return "Pbkdf2 KDF params are missing or malformed"; + } + WalletFile.Aes128CtrKdfParams params = (WalletFile.Aes128CtrKdfParams) kdfParams; + return pbkdf2KdfParamsError(params.getDklen(), params.getC()); + } + + private static String scryptKdfParamsError(int dklen, int n, int r, int p) { + // dklen must yield at least 32 bytes: generateMac copies derivedKey[16..32) + // and AES-128 needs bytes [0..16); smaller keys throw raw ArrayIndexOutOfBoundsException. + if (dklen < 32 || dklen > KDF_DKLEN_MAX) { + return "Scrypt dklen is out of range [32, " + KDF_DKLEN_MAX + "]: " + dklen; + } + if (n < SCRYPT_N_MIN || n > SCRYPT_N_MAX || (n & (n - 1)) != 0) { + return "Scrypt n must be a power of 2 in [" + SCRYPT_N_MIN + ", " + SCRYPT_N_MAX + + "]: " + n; + } + if (r < 1 || r > SCRYPT_R_MAX) { + return "Scrypt r is out of range [1, " + SCRYPT_R_MAX + "]: " + r; + } + if (p < 1 || p > SCRYPT_P_MAX) { + return "Scrypt p is out of range [1, " + SCRYPT_P_MAX + "]: " + p; + } + // Compute in long arithmetic: hostile r * n must not overflow int. + long memoryBytes = 128L * r * n; + if (memoryBytes > SCRYPT_MEMORY_MAX_BYTES) { + return "Scrypt memory requirement 128*r*n=" + memoryBytes + + " exceeds the " + SCRYPT_MEMORY_MAX_BYTES + " byte limit"; + } + return null; + } + + private static String pbkdf2KdfParamsError(int dklen, int c) { + if (dklen < 1 || dklen > KDF_DKLEN_MAX) { + return "Pbkdf2 dklen is out of range [1, " + KDF_DKLEN_MAX + "]: " + dklen; + } + if (c < 1 || c > PBKDF2_C_MAX) { + return "Pbkdf2 iteration count c is out of range [1, " + PBKDF2_C_MAX + "]: " + c; + } return null; } diff --git a/crypto/src/main/java/org/tron/keystore/WalletUtils.java b/crypto/src/main/java/org/tron/keystore/WalletUtils.java index 765eb26559f..7c15ab8207b 100644 --- a/crypto/src/main/java/org/tron/keystore/WalletUtils.java +++ b/crypto/src/main/java/org/tron/keystore/WalletUtils.java @@ -19,12 +19,14 @@ import java.time.format.DateTimeFormatter; import java.util.Collections; import java.util.EnumSet; +import java.util.NoSuchElementException; import java.util.Scanner; import java.util.Set; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.tron.common.crypto.SignInterface; import org.tron.core.exception.CipherException; +import org.tron.core.exception.TronError; /** * Utility functions for working with Wallet files. @@ -230,6 +232,14 @@ public static String inputPassword() { while (true) { if (cons != null) { char[] pwd = cons.readPassword("password: "); + // Console EOF (e.g. Ctrl+D) returns null; fail fast instead of + // crashing with a raw NullPointerException. + if (pwd == null) { + throw new TronError( + "password input closed (EOF) while reading from the console; " + + "restart with --password or provide an interactive TTY", + TronError.ErrCode.WITNESS_KEYSTORE_LOAD); + } password = String.valueOf(pwd); } else { // Preserve the full password including embedded whitespace. @@ -238,7 +248,18 @@ public static String inputPassword() { // 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()); + String line; + try { + line = in.nextLine(); + } catch (NoSuchElementException e) { + // Piped stdin reached EOF before a password line was available; + // fail fast instead of leaking a raw NoSuchElementException. + throw new TronError( + "password input closed (EOF): piped stdin provided no password " + + "line; restart with --password or provide an interactive TTY", + e, TronError.ErrCode.WITNESS_KEYSTORE_LOAD); + } + password = stripPasswordLine(line); } if (passwordValid(password)) { return password; diff --git a/docs/configuration.md b/docs/configuration.md index 910a50b5907..28baf408375 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -127,7 +127,7 @@ node { # RST_STREAM counting window in seconds. 0 uses the default of 5. secondsPerWindow = 5 # Idle connection timeout (ms). 0 = no limit. - maxConnectionIdleInMillis = 0 + maxConnectionIdleInMillis = 60000 # Minimum active connections required before broadcasting transactions. minEffectiveConnection = 1 } @@ -180,12 +180,13 @@ storage.properties = [ ### Block Production (Super Representatives) ```hocon -# Plain private key (use localwitnesskeystore for production) +# DEPRECATED: plain private key, insecure (anyone reading the config file +# sees the keys). Use localwitnesskeystore for production. localwitness = [ "your-private-key-hex" ] -# Recommended: keystore file +# Recommended: encrypted keystore file # localwitnesskeystore = [ # "/path/to/localwitnesskeystore.json" # ] diff --git a/framework/src/main/java/org/tron/core/config/args/Args.java b/framework/src/main/java/org/tron/core/config/args/Args.java index 77d6fcde7f1..4742b5d4140 100644 --- a/framework/src/main/java/org/tron/core/config/args/Args.java +++ b/framework/src/main/java/org/tron/core/config/args/Args.java @@ -900,6 +900,9 @@ private static void initLocalWitnesses(Config config, CLIParameter cmd) { // path 1: CLI --private-key if (StringUtils.isNotBlank(cmd.privateKey)) { + logger.warn("Passing the witness private key via -p/--private-key is deprecated and " + + "insecure: the key is visible in the process list and shell history. Use " + + "localwitnesskeystore in the config file instead."); localWitnesses = WitnessInitializer.initFromCLIPrivateKey( cmd.privateKey, cmd.witnessAddress); return; @@ -909,6 +912,8 @@ private static void initLocalWitnesses(Config config, CLIParameter cmd) { // path 2: config localwitness (private key list) if (!lwConfig.getPrivateKeys().isEmpty()) { + logger.warn("Storing plain witness private keys in 'localwitness' is insecure: any " + + "reader of the config file sees the keys. Use localwitnesskeystore instead."); localWitnesses = WitnessInitializer.initFromCFGPrivateKey( lwConfig.getPrivateKeys(), lwConfig.getAccountAddress()); return; diff --git a/framework/src/main/java/org/tron/core/config/args/CLIParameter.java b/framework/src/main/java/org/tron/core/config/args/CLIParameter.java index ab522a66d57..74742707254 100644 --- a/framework/src/main/java/org/tron/core/config/args/CLIParameter.java +++ b/framework/src/main/java/org/tron/core/config/args/CLIParameter.java @@ -39,6 +39,7 @@ public class CLIParameter { @Parameter(names = {"-w", "--witness"}, description = "Is witness node") public boolean witness; + @Deprecated @Parameter(names = {"-p", "--private-key"}, description = "Witness private key") public String privateKey; diff --git a/framework/src/test/java/org/tron/keystore/WalletDecryptBoundsTest.java b/framework/src/test/java/org/tron/keystore/WalletDecryptBoundsTest.java new file mode 100644 index 00000000000..aab7b640b9d --- /dev/null +++ b/framework/src/test/java/org/tron/keystore/WalletDecryptBoundsTest.java @@ -0,0 +1,308 @@ +package org.tron.keystore; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +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 untrusted keystore KDF cost parameters are bounded before + * any key-derivation work happens. A hostile keystore previously drove + * Bouncy Castle into {@code NegativeArraySizeException} (huge scrypt n), + * a ~4 GiB allocation / OOM (n=2^22, r=8), {@code ArithmeticException} + * (r*n overflow), or a multi-hour pbkdf2 CPU hang. All of these must now + * be rejected by {@link Wallet#validate(WalletFile)} (which is called at + * the top of {@link Wallet#decrypt}), never reaching the KDF engine. + * + *

All rejection tests are fast by construction: validation performs no + * KDF execution, and no test ever invokes decrypt with scrypt parameters + * that pass the bounds checks (except the deliberately corrupted + * low-cost keystore used for the CipherException wrap test). + */ +public class WalletDecryptBoundsTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + private static final String SCRYPT_TEMPLATE = + "{\"version\":3,\"address\":\"addr\",\"id\":\"test\",\"crypto\":{" + + "\"cipher\":\"aes-128-ctr\",\"ciphertext\":\"00\"," + + "\"cipherparams\":{\"iv\":\"00\"},\"mac\":\"00\",\"kdf\":\"scrypt\"," + + "\"kdfparams\":{\"dklen\":%d,\"n\":%d,\"r\":%d,\"p\":%d,\"salt\":\"00\"}}}"; + + private static final String PBKDF2_TEMPLATE = + "{\"version\":3,\"address\":\"addr\",\"id\":\"test\",\"crypto\":{" + + "\"cipher\":\"aes-128-ctr\",\"ciphertext\":\"00\"," + + "\"cipherparams\":{\"iv\":\"00\"},\"mac\":\"00\",\"kdf\":\"pbkdf2\"," + + "\"kdfparams\":{\"dklen\":%d,\"c\":%d,\"prf\":\"hmac-sha256\",\"salt\":\"00\"}}}"; + + private static WalletFile parseKeystore(String json) throws Exception { + return OBJECT_MAPPER.readValue(json, WalletFile.class); + } + + private static CipherException validateFailure(String json) throws Exception { + WalletFile walletFile = parseKeystore(json); + return assertThrows(CipherException.class, () -> Wallet.validate(walletFile)); + } + + // ---------- hostile scrypt n ---------- + + @Test + public void testValidateRejectsScryptNNegativeArrayCase() throws Exception { + // n=2^23 previously crashed inside SCrypt.generate with + // NegativeArraySizeException; must be rejected before the KDF runs. + CipherException err = validateFailure(String.format(SCRYPT_TEMPLATE, + 32, 1 << 23, 8, 1)); + assertTrue(err.getMessage(), err.getMessage().contains("n")); + } + + @Test + public void testValidateRejectsScryptNOomCase() throws Exception { + // n=2^22, r=8 requests 128*r*n = 4 GiB and previously died with OOM; + // validation must reject it (fast, no allocation). + long start = System.nanoTime(); + CipherException err = validateFailure(String.format(SCRYPT_TEMPLATE, + 32, 1 << 22, 8, 1)); + assertTrue(err.getMessage(), err.getMessage().contains("n")); + // Rejection is pure validation: must be far cheaper than any KDF run. + assertTrue("rejection must happen before allocation", + System.nanoTime() - start < 5_000_000_000L); + } + + @Test + public void testValidateRejectsScryptNBelowFloor() throws Exception { + CipherException err = validateFailure(String.format(SCRYPT_TEMPLATE, + 32, 1 << 11, 8, 1)); + assertTrue(err.getMessage(), err.getMessage().contains("Scrypt n")); + } + + @Test + public void testValidateRejectsScryptNAboveCap() throws Exception { + CipherException err = validateFailure(String.format(SCRYPT_TEMPLATE, + 32, 1 << 21, 8, 1)); + assertTrue(err.getMessage(), err.getMessage().contains("Scrypt n")); + } + + @Test + public void testValidateRejectsScryptNNotPowerOfTwo() throws Exception { + CipherException err = validateFailure(String.format(SCRYPT_TEMPLATE, + 32, 12345, 8, 1)); + assertTrue(err.getMessage(), err.getMessage().contains("Scrypt n")); + } + + // ---------- hostile scrypt r / p / dklen ---------- + + @Test + public void testValidateRejectsScryptRArithmeticCase() throws Exception { + // r=2^24 with n*r overflow previously crashed inside + // SCrypt.generate with ArithmeticException. + CipherException err = validateFailure(String.format(SCRYPT_TEMPLATE, + 32, 1 << 13, 1 << 24, 1)); + assertTrue(err.getMessage(), err.getMessage().contains("Scrypt r")); + } + + @Test + public void testValidateRejectsScryptPAboveCap() throws Exception { + CipherException err = validateFailure(String.format(SCRYPT_TEMPLATE, + 32, 1 << 13, 8, 9)); + assertTrue(err.getMessage(), err.getMessage().contains("Scrypt p")); + } + + @Test + public void testValidateRejectsOversizedDklen() throws Exception { + CipherException err = validateFailure(String.format(SCRYPT_TEMPLATE, + 4096, 1 << 13, 8, 1)); + assertTrue(err.getMessage(), err.getMessage().contains("Scrypt dklen")); + } + + @Test + public void testValidateRejectsScryptDklenBelowMacFloor() throws Exception { + // dklen=16 passes the old < 1 sanity floor but is too short for + // generateMac (needs derivedKey[16..32)) — validation must reject it + // before any KDF runs, otherwise a raw ArrayIndexOutOfBoundsException + // escapes decrypt(). + CipherException err = validateFailure(String.format(SCRYPT_TEMPLATE, + 16, 1 << 12, 1, 1)); + assertTrue(err.getMessage(), err.getMessage().contains("Scrypt dklen")); + assertTrue(err.getMessage(), err.getMessage().contains("[32")); + } + + // ---------- missing kdfparams ---------- + // + // Note: a keystore JSON that omits "kdfparams" entirely fails at + // deserialization (Jackson external type id), which is already a safe + // rejection. The validation chokepoint itself is exercised here via + // programmatically built WalletFile objects with a null kdfparams. + + private static WalletFile walletWithKdfparams( + String kdf, WalletFile.KdfParams kdfParams) { + WalletFile walletFile = new WalletFile(); + walletFile.setVersion(3); + walletFile.setAddress("addr"); + WalletFile.Crypto crypto = new WalletFile.Crypto(); + crypto.setCipher("aes-128-ctr"); + crypto.setCiphertext("00"); + WalletFile.CipherParams cipherParams = new WalletFile.CipherParams(); + cipherParams.setIv("00"); + crypto.setCipherparams(cipherParams); + crypto.setMac("00"); + crypto.setKdf(kdf); + crypto.setKdfparams(kdfParams); + walletFile.setCrypto(crypto); + return walletFile; + } + + @Test + public void testValidateToleratesMissingScryptKdfparams() { + // Structural stubs without kdfparams are tolerated (KeystoreCliUtilsTest + // fixture contract); decrypt() still fails safely because the + // ClassCastException (RuntimeException) is wrapped into CipherException. + WalletFile walletFile = walletWithKdfparams("scrypt", null); + try { + Wallet.validate(walletFile); + } catch (CipherException e) { + fail("missing kdfparams must be tolerated, got: " + e.getMessage()); + } + assertThrows(CipherException.class, + () -> Wallet.decrypt("password123", walletFile)); + } + + @Test + public void testValidateToleratesMissingPbkdf2Kdfparams() { + WalletFile walletFile = walletWithKdfparams("pbkdf2", null); + try { + Wallet.validate(walletFile); + } catch (CipherException e) { + fail("missing kdfparams must be tolerated, got: " + e.getMessage()); + } + assertThrows(CipherException.class, + () -> Wallet.decrypt("password123", walletFile)); + } + + // ---------- hostile pbkdf2 ---------- + + @Test + public void testValidateRejectsPbkdf2MaxIterations() throws Exception { + // c=2147483647 is a multi-hour CPU hang; must be rejected up front. + CipherException err = validateFailure(String.format(PBKDF2_TEMPLATE, + 32, Integer.MAX_VALUE)); + assertTrue(err.getMessage(), err.getMessage().contains("Pbkdf2 iteration count")); + } + + @Test + public void testValidateRejectsPbkdf2OversizedDklen() throws Exception { + CipherException err = validateFailure(String.format(PBKDF2_TEMPLATE, + 4096, 262144)); + assertTrue(err.getMessage(), err.getMessage().contains("Pbkdf2 dklen")); + } + + // ---------- behavior freeze: legitimate keystores still pass ---------- + + @Test + public void testValidateAcceptsStandardDefaults() throws Exception { + // Wallet.createStandard: n=2^18, r=8, p=1, dklen=32. + WalletFile standard = parseKeystore(String.format(SCRYPT_TEMPLATE, + 32, 1 << 18, 8, 1)); + Wallet.validate(standard); + assertTrue(Wallet.isValidKeystoreFile(standard)); + + // Wallet.createLight: n=2^12, r=8, p=6, dklen=32. + WalletFile light = parseKeystore(String.format(SCRYPT_TEMPLATE, + 32, 1 << 12, 8, 6)); + Wallet.validate(light); + assertTrue(Wallet.isValidKeystoreFile(light)); + } + + @Test + public void testValidateAcceptsUpperBoundaryParams() throws Exception { + // n=2^20, r=8, p=1 sits exactly at the cap (128*r*n = 1 GiB) and is a + // sane (if heavy) generator choice: validation must accept it. The + // test only asserts validation — the actual scrypt run is never + // executed here. + WalletFile boundary = parseKeystore(String.format(SCRYPT_TEMPLATE, + 128, 1 << 20, 8, 1)); + Wallet.validate(boundary); + assertTrue(Wallet.isValidKeystoreFile(boundary)); + } + + @Test + public void testValidateAcceptsPbkdf2Defaults() throws Exception { + WalletFile walletFile = parseKeystore(String.format(PBKDF2_TEMPLATE, + 32, 262144)); + Wallet.validate(walletFile); + assertTrue(Wallet.isValidKeystoreFile(walletFile)); + } + + // ---------- decrypt wrap: in-bounds but corrupted keystore ---------- + + @Test + public void testDecryptCorruptedKeystoreThrowsCipherException() throws Exception { + // In-bounds low-cost scrypt params (fast), but tampered MAC: + // decrypt must fail with a clean CipherException, never leak a raw + // RuntimeException stack. + String json = String.format(SCRYPT_TEMPLATE, 32, 1 << 12, 1, 1) + .replace("\"mac\":\"00\"", + "\"mac\":\"0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20\""); + WalletFile walletFile = parseKeystore(json); + Wallet.validate(walletFile); + + CipherException err = assertThrows(CipherException.class, + () -> Wallet.decrypt("password123", walletFile)); + assertEquals("Invalid password provided", err.getMessage()); + } + + @Test + public void testDecryptWrapsScryptRuntimeException() throws Exception { + // r=1 with n=131072 passes every validation bound (n is a power of 2 in + // [2^12, 2^20], r=1, p=1, 128*r*n = 16 MiB, dklen=32), but Bouncy + // Castle's SCrypt.generate itself rejects r==1 with n >= 65536 and throws + // a raw IllegalArgumentException. decrypt() must wrap that runtime KDF + // engine failure into a clean CipherException, preserving the cause. + WalletFile walletFile = parseKeystore(String.format(SCRYPT_TEMPLATE, + 32, 1 << 17, 1, 1)); + Wallet.validate(walletFile); + + CipherException err = assertThrows(CipherException.class, + () -> Wallet.decrypt("password123", walletFile)); + assertEquals("Scrypt key derivation failed", err.getMessage()); + assertTrue("cause must be the SCrypt IllegalArgumentException", + err.getCause() instanceof IllegalArgumentException); + } + + // ---------- create(): KDF params validated symmetric with decrypt() ---------- + + @Test + public void testCreateRejectsOutOfBoundsKdfParams() { + // p=16 exceeds the SCRYPT_P_MAX=8 bound that validate()/decrypt() enforce, + // so create(n=2^18, p=16) would previously produce a wallet file that only + // this class would later refuse to decrypt. Must throw before any KDF run. + SignInterface keyPair = SignUtils.getGeneratedRandomSign(Utils.getRandom()); + + CipherException err = assertThrows(CipherException.class, + () -> Wallet.create("password123", keyPair, 1 << 18, 16)); + assertTrue(err.getMessage(), err.getMessage().contains("Scrypt p")); + } + + @Test(timeout = 60000) + public void testCreateAcceptsInBoundsKdfParams() throws Exception { + // n=2^12 (4096), p=1 is well within the enforced bounds; create must + // succeed and the resulting file must pass validate() and decrypt(). + SignInterface keyPair = SignUtils.getGeneratedRandomSign(Utils.getRandom()); + byte[] originalKey = keyPair.getPrivateKey(); + + WalletFile walletFile = Wallet.create("password123", keyPair, 1 << 12, 1); + Wallet.validate(walletFile); + + SignInterface recovered = Wallet.decrypt("password123", walletFile); + assertArrayEquals(originalKey, recovered.getPrivateKey()); + } +} diff --git a/framework/src/test/java/org/tron/keystore/WalletUtilsInputPasswordTest.java b/framework/src/test/java/org/tron/keystore/WalletUtilsInputPasswordTest.java index ed1bc32b974..bca7f8a2caf 100644 --- a/framework/src/test/java/org/tron/keystore/WalletUtilsInputPasswordTest.java +++ b/framework/src/test/java/org/tron/keystore/WalletUtilsInputPasswordTest.java @@ -2,6 +2,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; import java.io.ByteArrayInputStream; import java.io.InputStream; @@ -9,6 +10,7 @@ import org.junit.After; import org.junit.Before; import org.junit.Test; +import org.tron.core.exception.TronError; /** * Verifies that {@link WalletUtils#inputPassword()} preserves the full @@ -95,6 +97,26 @@ public void testInputPasswordPreservesLeadingAndTrailingSpaces() { " with spaces ", pw); } + // ---------- EOF fail-fast tests ---------- + + @Test(timeout = 5000) + public void testInputPasswordPipedStdinEofThrowsTronError() { + // Non-TTY branch: Scanner.nextLine() throws NoSuchElementException at + // EOF; it must be translated into a fail-fast TronError. + // (The TTY branch's Console.readPassword() null-on-EOF check cannot be + // exercised under JUnit, where System.console() is always null and + // java.io.Console is final — covered by code review only.) + System.setIn(new ByteArrayInputStream(new byte[0])); + + TronError err = assertThrows(TronError.class, WalletUtils::inputPassword); + + assertEquals("Piped stdin EOF must map to the keystore-load error code", + TronError.ErrCode.WITNESS_KEYSTORE_LOAD, err.getErrCode()); + } + + // Note: the M1 double-read test (inputPassword2Twice) was dropped in + // the 4.8.3 rebase - upstream #6978 removed inputPassword2Twice() + // along with its only caller KeystoreFactory. // ---------- stripPasswordLine() direct unit tests (M3) ---------- @Test diff --git a/plugins/README.md b/plugins/README.md index 6866dba33a8..600e876353a 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -275,3 +275,5 @@ When using `--password-file` with `update`, the file must contain exactly two li - `--force`: For `keystore import`, allow importing a private key whose address already has a keystore in the directory (creates an additional file). - `--json`: Output in JSON format for scripting. - `-h | --help`: Provide the help info. + +> **Note:** `--password-file` and `--key-file` must be owner-only (`chmod 600`). Files readable or writable by group/other are rejected — create them with a restrictive umask or run `chmod 600 ` before use. diff --git a/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java b/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java index 6959a7f8177..9d7f3c5a92e 100644 --- a/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java +++ b/plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java @@ -15,7 +15,11 @@ import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.PosixFileAttributeView; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; import java.util.Arrays; +import java.util.EnumSet; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.Map; @@ -81,6 +85,20 @@ static byte[] readRegularFile(File file, long maxSize, String label, PrintWriter return null; } + // Refuse secret files readable or writable by group/other. Only owner + // bits are allowed; filesystems without POSIX attributes are skipped. + PosixFileAttributeView posixView = Files.getFileAttributeView( + file.toPath(), PosixFileAttributeView.class, LinkOption.NOFOLLOW_LINKS); + if (posixView != null + && !EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE) + .containsAll(posixView.readAttributes().permissions())) { + err.println(label + " must be owner-only (no group/other access), got " + + PosixFilePermissions.toString(posixView.readAttributes().permissions()) + + ": " + file.getPath()); + return null; + } + int size = (int) attrs.size(); java.util.Set openOptions = new HashSet<>(); openOptions.add(StandardOpenOption.READ); diff --git a/plugins/src/test/java/org/tron/plugins/KeystoreCliUtilsTest.java b/plugins/src/test/java/org/tron/plugins/KeystoreCliUtilsTest.java index 29782af91e2..6507ec28f86 100644 --- a/plugins/src/test/java/org/tron/plugins/KeystoreCliUtilsTest.java +++ b/plugins/src/test/java/org/tron/plugins/KeystoreCliUtilsTest.java @@ -7,6 +7,7 @@ import static org.junit.Assert.assertTrue; import java.io.File; +import java.io.IOException; import java.io.PrintWriter; import java.io.StringWriter; import java.nio.charset.StandardCharsets; @@ -165,6 +166,7 @@ public void testCheckFileExistsPresent() throws Exception { public void testReadPasswordFromFile() throws Exception { File pwFile = tempFolder.newFile("pw.txt"); Files.write(pwFile.toPath(), "goodpassword".getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); StringWriter err = new StringWriter(); String pw = KeystoreCliUtils.readPassword(pwFile, new PrintWriter(err)); assertEquals("goodpassword", pw); @@ -174,6 +176,7 @@ public void testReadPasswordFromFile() throws Exception { public void testReadPasswordFromFileWithLineEndings() throws Exception { File pwFile = tempFolder.newFile("pw-crlf.txt"); Files.write(pwFile.toPath(), "goodpassword\r\n".getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); StringWriter err = new StringWriter(); String pw = KeystoreCliUtils.readPassword(pwFile, new PrintWriter(err)); assertEquals("goodpassword", pw); @@ -184,6 +187,7 @@ public void testReadPasswordFromFileWithBom() throws Exception { File pwFile = tempFolder.newFile("pw-bom.txt"); Files.write(pwFile.toPath(), "\uFEFFgoodpassword".getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); StringWriter err = new StringWriter(); String pw = KeystoreCliUtils.readPassword(pwFile, new PrintWriter(err)); assertEquals("goodpassword", pw); @@ -205,6 +209,7 @@ public void testReadPasswordFileTooLarge() throws Exception { public void testReadPasswordFileShort() throws Exception { File pwFile = tempFolder.newFile("pw-short.txt"); Files.write(pwFile.toPath(), "abc".getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); StringWriter err = new StringWriter(); String pw = KeystoreCliUtils.readPassword(pwFile, new PrintWriter(err)); assertNull(pw); @@ -270,6 +275,7 @@ public void testPrintSecurityTipsIncludesAddressAndFile() { public void testReadRegularFileSuccess() throws Exception { File f = tempFolder.newFile("regular.txt"); Files.write(f.toPath(), "hello".getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(f); StringWriter err = new StringWriter(); byte[] bytes = KeystoreCliUtils.readRegularFile(f, 1024, "File", @@ -339,6 +345,7 @@ public void testReadRegularFileRefusesDirectory() throws Exception { @Test public void testReadRegularFileEmptyFile() throws Exception { File f = tempFolder.newFile("empty.txt"); + makeOwnerOnly(f); StringWriter err = new StringWriter(); byte[] bytes = KeystoreCliUtils.readRegularFile(f, 1024, "File", @@ -346,4 +353,43 @@ public void testReadRegularFileEmptyFile() throws Exception { assertNotNull(bytes); assertEquals(0, bytes.length); } + + private static void makeOwnerOnly(File f) throws IOException { + if (Files.getFileAttributeView(f.toPath(), + java.nio.file.attribute.PosixFileAttributeView.class) == null) { + return; // non-POSIX FS: skip chmod, mirroring KeystoreCliUtils runtime behavior + } + Files.setPosixFilePermissions(f.toPath(), + java.nio.file.attribute.PosixFilePermissions.fromString("rw-------")); + } + + @Test + public void testReadRegularFileRejectsGroupReadable() throws Exception { + File f = tempFolder.newFile("group-readable.txt"); + Files.write(f.toPath(), "hello".getBytes(StandardCharsets.UTF_8)); + org.junit.Assume.assumeTrue("POSIX permissions test, skip on Windows", + Files.getFileAttributeView(f.toPath(), + java.nio.file.attribute.PosixFileAttributeView.class) != null); + makeOwnerOnly(f); + Files.setPosixFilePermissions(f.toPath(), + java.nio.file.attribute.PosixFilePermissions.fromString("rw-r-----")); + StringWriter err = new StringWriter(); + byte[] bytes = KeystoreCliUtils.readRegularFile(f, 1024, + "Password file", new PrintWriter(err)); + assertNull(bytes); + assertTrue("expected owner-only error, got: " + err, + err.toString().contains("owner-only")); + } + + @Test + public void testReadRegularFileAcceptsOwnerOnly() throws Exception { + File f = tempFolder.newFile("owner-only.txt"); + Files.write(f.toPath(), "hello".getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(f); + StringWriter err = new StringWriter(); + byte[] bytes = KeystoreCliUtils.readRegularFile(f, 1024, + "Password file", new PrintWriter(err)); + assertNotNull(bytes); + assertEquals("hello", new String(bytes, StandardCharsets.UTF_8)); + } } diff --git a/plugins/src/test/java/org/tron/plugins/KeystoreImportTest.java b/plugins/src/test/java/org/tron/plugins/KeystoreImportTest.java index cbb3484d260..4d998d7a263 100644 --- a/plugins/src/test/java/org/tron/plugins/KeystoreImportTest.java +++ b/plugins/src/test/java/org/tron/plugins/KeystoreImportTest.java @@ -6,6 +6,7 @@ import static org.junit.Assert.assertTrue; import java.io.File; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.security.SecureRandom; @@ -37,9 +38,11 @@ public void testImportWithKeyFileAndPasswordFile() throws Exception { File keyFile = tempFolder.newFile("private.key"); Files.write(keyFile.toPath(), privateKeyHex.getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(keyFile); File pwFile = tempFolder.newFile("password.txt"); Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); CommandLine cmd = new CommandLine(new Toolkit()); int exitCode = cmd.execute("keystore", "import", @@ -65,9 +68,11 @@ public void testImportInvalidKeyTooShort() throws Exception { File dir = tempFolder.newFolder("keystore-bad"); File keyFile = tempFolder.newFile("bad.key"); Files.write(keyFile.toPath(), "abcdef1234".getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(keyFile); File pwFile = tempFolder.newFile("pw.txt"); Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); CommandLine cmd = new CommandLine(new Toolkit()); int exitCode = cmd.execute("keystore", "import", @@ -85,9 +90,11 @@ public void testImportInvalidKeyNonHex() throws Exception { Files.write(keyFile.toPath(), "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" .getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(keyFile); File pwFile = tempFolder.newFile("pw.txt"); Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); CommandLine cmd = new CommandLine(new Toolkit()); int exitCode = cmd.execute("keystore", "import", @@ -103,6 +110,7 @@ public void testImportNoTtyNoKeyFile() throws Exception { File dir = tempFolder.newFolder("keystore-notty"); File pwFile = tempFolder.newFile("pw2.txt"); Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); // No --key-file and System.console() is null in CI CommandLine cmd = new CommandLine(new Toolkit()); @@ -124,8 +132,10 @@ public void testImportKeyFileWithWhitespace() throws Exception { File keyFile = tempFolder.newFile("ws.key"); Files.write(keyFile.toPath(), (" " + privateKeyHex + " \n\n").getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(keyFile); File pwFile = tempFolder.newFile("pw-ws.txt"); Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); CommandLine cmd = new CommandLine(new Toolkit()); int exitCode = cmd.execute("keystore", "import", @@ -152,8 +162,10 @@ public void testImportDuplicateAddressBlocked() throws Exception { File keyFile = tempFolder.newFile("dup.key"); Files.write(keyFile.toPath(), privateKeyHex.getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(keyFile); File pwFile = tempFolder.newFile("pw-dup.txt"); Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); // First import succeeds CommandLine cmd1 = new CommandLine(new Toolkit()); @@ -188,8 +200,10 @@ public void testImportDuplicateAddressWithForce() throws Exception { File keyFile = tempFolder.newFile("force.key"); Files.write(keyFile.toPath(), privateKeyHex.getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(keyFile); File pwFile = tempFolder.newFile("pw-force.txt"); Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); // First import CommandLine cmd1 = new CommandLine(new Toolkit()); @@ -216,6 +230,7 @@ public void testImportKeyFileNotFound() throws Exception { File dir = tempFolder.newFolder("keystore-nokey"); File pwFile = tempFolder.newFile("pw-nokey.txt"); Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); CommandLine cmd = new CommandLine(new Toolkit()); int exitCode = cmd.execute("keystore", "import", @@ -237,8 +252,10 @@ public void testImportWith0xPrefix() throws Exception { File keyFile = tempFolder.newFile("0x.key"); Files.write(keyFile.toPath(), ("0x" + privateKeyHex).getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(keyFile); File pwFile = tempFolder.newFile("pw-0x.txt"); Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); CommandLine cmd = new CommandLine(new Toolkit()); int exitCode = cmd.execute("keystore", "import", @@ -264,8 +281,10 @@ public void testImportWith0XUppercasePrefix() throws Exception { File keyFile = tempFolder.newFile("0X.key"); Files.write(keyFile.toPath(), ("0X" + privateKeyHex).getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(keyFile); File pwFile = tempFolder.newFile("pw-0X.txt"); Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); CommandLine cmd = new CommandLine(new Toolkit()); int exitCode = cmd.execute("keystore", "import", @@ -289,8 +308,10 @@ public void testImportWarnsOnCorruptedFile() throws Exception { File keyFile = tempFolder.newFile("warn.key"); Files.write(keyFile.toPath(), privateKeyHex.getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(keyFile); File pwFile = tempFolder.newFile("pw-warn.txt"); Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); java.io.StringWriter out = new java.io.StringWriter(); java.io.StringWriter err = new java.io.StringWriter(); @@ -321,8 +342,10 @@ public void testImportKeystoreFilePermissions() throws Exception { File keyFile = tempFolder.newFile("perm.key"); Files.write(keyFile.toPath(), privateKeyHex.getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(keyFile); File pwFile = tempFolder.newFile("pw-perm.txt"); Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); CommandLine cmd = new CommandLine(new Toolkit()); int exitCode = cmd.execute("keystore", "import", @@ -362,6 +385,7 @@ public void testImportRefusesSymlinkKeyFile() throws Exception { File pwFile = tempFolder.newFile("pw-symlink.txt"); Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); java.io.StringWriter err = new java.io.StringWriter(); CommandLine cmd = new CommandLine(new Toolkit()); @@ -387,9 +411,11 @@ public void testImportRefusesSymlinkPasswordFile() throws Exception { File keyFile = tempFolder.newFile("sym-pw.key"); Files.write(keyFile.toPath(), ByteArray.toHexString(keyPair.getPrivateKey()).getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(keyFile); File realPwFile = tempFolder.newFile("real-pw.txt"); Files.write(realPwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(realPwFile); File pwSymlink = new File(tempFolder.getRoot(), "pw-symlink.txt"); Files.createSymbolicLink(pwSymlink.toPath(), realPwFile.toPath()); @@ -422,8 +448,10 @@ public void testImportDuplicateCheckSkipsInvalidVersion() throws Exception { File keyFile = tempFolder.newFile("ver.key"); Files.write(keyFile.toPath(), privateKeyHex.getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(keyFile); File pwFile = tempFolder.newFile("pw-ver.txt"); Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); CommandLine cmd = new CommandLine(new Toolkit()); int exitCode = cmd.execute("keystore", "import", @@ -455,8 +483,10 @@ public void testImportDuplicateScanSkipsSymlinkedEntry() throws Exception { File keyFile = tempFolder.newFile("dup-sym.key"); Files.write(keyFile.toPath(), privateKeyHex.getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(keyFile); File pwFile = tempFolder.newFile("pw-dup-sym.txt"); Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); java.io.StringWriter err = new java.io.StringWriter(); CommandLine cmd = new CommandLine(new Toolkit()); @@ -484,9 +514,11 @@ public void testImportRejectsMultiLinePasswordFile() throws Exception { File keyFile = tempFolder.newFile("multi.key"); Files.write(keyFile.toPath(), privateKeyHex.getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(keyFile); File pwFile = tempFolder.newFile("multi-pw.txt"); Files.write(pwFile.toPath(), "oldpass123\nnewpass456".getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); java.io.StringWriter err = new java.io.StringWriter(); CommandLine cmd = new CommandLine(new Toolkit()); @@ -503,4 +535,13 @@ public void testImportRejectsMultiLinePasswordFile() throws Exception { assertTrue("No keystore should have been created", files == null || files.length == 0); } + + private static void makeOwnerOnly(File f) throws IOException { + if (Files.getFileAttributeView(f.toPath(), + java.nio.file.attribute.PosixFileAttributeView.class) == null) { + return; // non-POSIX FS: skip chmod, mirroring KeystoreCliUtils runtime behavior + } + Files.setPosixFilePermissions(f.toPath(), + java.nio.file.attribute.PosixFilePermissions.fromString("rw-------")); + } } diff --git a/plugins/src/test/java/org/tron/plugins/KeystoreNewTest.java b/plugins/src/test/java/org/tron/plugins/KeystoreNewTest.java index 0ce5b3dd3f3..3d71b667ba4 100644 --- a/plugins/src/test/java/org/tron/plugins/KeystoreNewTest.java +++ b/plugins/src/test/java/org/tron/plugins/KeystoreNewTest.java @@ -5,6 +5,7 @@ import static org.junit.Assert.assertTrue; import java.io.File; +import java.io.IOException; import java.io.PrintWriter; import java.io.StringWriter; import java.nio.charset.StandardCharsets; @@ -36,6 +37,7 @@ public void testNewKeystoreWithPasswordFile() throws Exception { File dir = tempFolder.newFolder("keystore"); File pwFile = tempFolder.newFile("password.txt"); Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); StringWriter out = new StringWriter(); StringWriter err = new StringWriter(); @@ -64,6 +66,7 @@ public void testNewKeystoreJsonOutput() throws Exception { File dir = tempFolder.newFolder("keystore-json"); File pwFile = tempFolder.newFile("password-json.txt"); Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); StringWriter out = new StringWriter(); StringWriter err = new StringWriter(); @@ -88,6 +91,7 @@ public void testNewKeystoreInvalidPassword() throws Exception { File dir = tempFolder.newFolder("keystore-bad"); File pwFile = tempFolder.newFile("short.txt"); Files.write(pwFile.toPath(), "abc".getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); StringWriter err = new StringWriter(); CommandLine cmd = new CommandLine(new Toolkit()); @@ -106,6 +110,7 @@ public void testNewKeystoreCustomDir() throws Exception { File dir = new File(tempFolder.getRoot(), "custom/nested/dir"); File pwFile = tempFolder.newFile("pw.txt"); Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); CommandLine cmd = new CommandLine(new Toolkit()); int exitCode = cmd.execute("keystore", "new", @@ -137,6 +142,7 @@ public void testNewKeystoreEmptyPassword() throws Exception { File dir = tempFolder.newFolder("keystore-empty"); File pwFile = tempFolder.newFile("empty.txt"); Files.write(pwFile.toPath(), "".getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); StringWriter err = new StringWriter(); CommandLine cmd = new CommandLine(new Toolkit()); @@ -156,6 +162,7 @@ public void testNewKeystoreSpecialCharPassword() throws Exception { File pwFile = tempFolder.newFile("pw-special.txt"); String password = "p@$$w0rd!#%^&*()_+-=[]{}"; Files.write(pwFile.toPath(), password.getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); CommandLine cmd = new CommandLine(new Toolkit()); int exitCode = cmd.execute("keystore", "new", @@ -189,6 +196,7 @@ public void testNewKeystoreDirIsFile() throws Exception { File notADir = tempFolder.newFile("not-a-dir"); File pwFile = tempFolder.newFile("pw-dir.txt"); Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); CommandLine cmd = new CommandLine(new Toolkit()); int exitCode = cmd.execute("keystore", "new", @@ -205,6 +213,7 @@ public void testNewKeystorePasswordFileTooLarge() throws Exception { byte[] bigContent = new byte[1025]; java.util.Arrays.fill(bigContent, (byte) 'a'); Files.write(pwFile.toPath(), bigContent); + makeOwnerOnly(pwFile); StringWriter err = new StringWriter(); CommandLine cmd = new CommandLine(new Toolkit()); @@ -224,6 +233,7 @@ public void testNewKeystorePasswordFileWithBom() throws Exception { File pwFile = tempFolder.newFile("bom.txt"); Files.write(pwFile.toPath(), ("\uFEFF" + "test123456").getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); CommandLine cmd = new CommandLine(new Toolkit()); int exitCode = cmd.execute("keystore", "new", @@ -245,6 +255,7 @@ public void testNewKeystoreFilePermissions() throws Exception { File dir = tempFolder.newFolder("keystore-perms"); File pwFile = tempFolder.newFile("pw-perms.txt"); Files.write(pwFile.toPath(), "test123456".getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); CommandLine cmd = new CommandLine(new Toolkit()); int exitCode = cmd.execute("keystore", "new", @@ -275,6 +286,7 @@ public void testNewKeystoreRejectsMultiLinePasswordFile() throws Exception { File pwFile = tempFolder.newFile("multi-line.txt"); Files.write(pwFile.toPath(), "oldpass123\nnewpass456".getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); StringWriter err = new StringWriter(); CommandLine cmd = new CommandLine(new Toolkit()); @@ -291,4 +303,13 @@ public void testNewKeystoreRejectsMultiLinePasswordFile() throws Exception { assertTrue("No keystore should have been created", files == null || files.length == 0); } + + private static void makeOwnerOnly(File f) throws IOException { + if (Files.getFileAttributeView(f.toPath(), + java.nio.file.attribute.PosixFileAttributeView.class) == null) { + return; // non-POSIX FS: skip chmod, mirroring KeystoreCliUtils runtime behavior + } + Files.setPosixFilePermissions(f.toPath(), + java.nio.file.attribute.PosixFilePermissions.fromString("rw-------")); + } } diff --git a/plugins/src/test/java/org/tron/plugins/KeystoreUpdateTest.java b/plugins/src/test/java/org/tron/plugins/KeystoreUpdateTest.java index f5b6e952735..c750ec8575e 100644 --- a/plugins/src/test/java/org/tron/plugins/KeystoreUpdateTest.java +++ b/plugins/src/test/java/org/tron/plugins/KeystoreUpdateTest.java @@ -7,6 +7,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import java.io.File; +import java.io.IOException; import java.io.PrintWriter; import java.io.StringWriter; import java.nio.charset.StandardCharsets; @@ -48,6 +49,7 @@ public void testUpdatePassword() throws Exception { File pwFile = tempFolder.newFile("passwords.txt"); Files.write(pwFile.toPath(), (oldPassword + "\n" + newPassword).getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); CommandLine cmd = new CommandLine(new Toolkit()); int exitCode = cmd.execute("keystore", "update", address, @@ -84,6 +86,7 @@ public void testUpdateWrongOldPassword() throws Exception { File pwFile = tempFolder.newFile("wrong.txt"); Files.write(pwFile.toPath(), ("wrongpass1\nnewpass456").getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); StringWriter err = new StringWriter(); CommandLine cmd = new CommandLine(new Toolkit()); @@ -114,6 +117,7 @@ public void testUpdateNonExistentAddress() throws Exception { File pwFile = tempFolder.newFile("pw.txt"); Files.write(pwFile.toPath(), ("test123456\nnewpass789").getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); StringWriter err = new StringWriter(); CommandLine cmd = new CommandLine(new Toolkit()); @@ -142,6 +146,7 @@ public void testUpdateNewPasswordTooShort() throws Exception { File pwFile = tempFolder.newFile("shortpw.txt"); Files.write(pwFile.toPath(), (password + "\nabc").getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); StringWriter err = new StringWriter(); CommandLine cmd = new CommandLine(new Toolkit()); @@ -171,6 +176,7 @@ public void testUpdateWithWindowsLineEndings() throws Exception { File pwFile = tempFolder.newFile("crlf.txt"); Files.write(pwFile.toPath(), (oldPassword + "\r\n" + newPassword + "\r\n").getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); CommandLine cmd = new CommandLine(new Toolkit()); int exitCode = cmd.execute("keystore", "update", creds.getAddress(), @@ -200,6 +206,7 @@ public void testUpdateJsonOutput() throws Exception { File pwFile = tempFolder.newFile("pw-json.txt"); Files.write(pwFile.toPath(), (oldPassword + "\n" + newPassword).getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); StringWriter out = new StringWriter(); StringWriter err = new StringWriter(); @@ -238,6 +245,7 @@ public void testUpdateWarnsOnCorruptedFile() throws Exception { File pwFile = tempFolder.newFile("pw-corrupt.txt"); Files.write(pwFile.toPath(), (password + "\nnewpass789").getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); StringWriter out = new StringWriter(); StringWriter err = new StringWriter(); @@ -261,11 +269,13 @@ public void testUpdatePasswordFileOnlyOneLine() throws Exception { SignInterface keyPair = SignUtils.getGeneratedRandomSign( SecureRandom.getInstance("NativePRNG")); String fileName = WalletUtils.generateWalletFile(password, keyPair, dir, true); - Credentials creds = WalletUtils.loadCredentials(password, new File(dir, fileName)); + Credentials creds = WalletUtils.loadCredentials(password, + new File(dir, fileName)); File pwFile = tempFolder.newFile("oneline.txt"); Files.write(pwFile.toPath(), "onlyoldpassword".getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); StringWriter err = new StringWriter(); CommandLine cmd = new CommandLine(new Toolkit()); @@ -292,7 +302,8 @@ public void testUpdatePasswordFileThreeLines() throws Exception { SecureRandom.getInstance("NativePRNG")); byte[] originalKey = keyPair.getPrivateKey(); String fileName = WalletUtils.generateWalletFile(oldPassword, keyPair, dir, true); - Credentials creds = WalletUtils.loadCredentials(oldPassword, new File(dir, fileName)); + Credentials creds = WalletUtils.loadCredentials(oldPassword, + new File(dir, fileName)); // Snapshot the keystore bytes so we can verify the file is untouched. byte[] beforeBytes = Files.readAllBytes(new File(dir, fileName).toPath()); @@ -300,6 +311,7 @@ public void testUpdatePasswordFileThreeLines() throws Exception { File pwFile = tempFolder.newFile("threeline.txt"); Files.write(pwFile.toPath(), (oldPassword + "\nnewpass456\nnewpass456").getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); StringWriter err = new StringWriter(); CommandLine cmd = new CommandLine(new Toolkit()); @@ -318,7 +330,8 @@ public void testUpdatePasswordFileThreeLines() throws Exception { beforeBytes, afterBytes); // Verify: original password still decrypts the keystore - Credentials unchanged = WalletUtils.loadCredentials(oldPassword, new File(dir, fileName)); + Credentials unchanged = WalletUtils.loadCredentials(oldPassword, + new File(dir, fileName)); assertArrayEquals("Original key must still be recoverable with old password", originalKey, unchanged.getSignInterface().getPrivateKey()); } @@ -331,7 +344,8 @@ public void testUpdateNoTtyNoPasswordFile() throws Exception { SignInterface keyPair = SignUtils.getGeneratedRandomSign( SecureRandom.getInstance("NativePRNG")); String fileName = WalletUtils.generateWalletFile(password, keyPair, dir, true); - Credentials creds = WalletUtils.loadCredentials(password, new File(dir, fileName)); + Credentials creds = WalletUtils.loadCredentials(password, + new File(dir, fileName)); StringWriter err = new StringWriter(); CommandLine cmd = new CommandLine(new Toolkit()); @@ -352,7 +366,8 @@ public void testUpdatePasswordFileNotFound() throws Exception { SignInterface keyPair = SignUtils.getGeneratedRandomSign( SecureRandom.getInstance("NativePRNG")); String fileName = WalletUtils.generateWalletFile(password, keyPair, dir, true); - Credentials creds = WalletUtils.loadCredentials(password, new File(dir, fileName)); + Credentials creds = WalletUtils.loadCredentials(password, + new File(dir, fileName)); StringWriter err = new StringWriter(); CommandLine cmd = new CommandLine(new Toolkit()); @@ -384,6 +399,7 @@ public void testUpdateMultipleKeystoresSameAddress() throws Exception { File pwFile = tempFolder.newFile("pw-multi.txt"); Files.write(pwFile.toPath(), (password + "\nnewpass789").getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); StringWriter err = new StringWriter(); CommandLine cmd = new CommandLine(new Toolkit()); @@ -407,13 +423,15 @@ public void testUpdatePasswordFileTooLarge() throws Exception { SignInterface keyPair = SignUtils.getGeneratedRandomSign( SecureRandom.getInstance("NativePRNG")); String fileName = WalletUtils.generateWalletFile(password, keyPair, dir, true); - Credentials creds = WalletUtils.loadCredentials(password, new File(dir, fileName)); + Credentials creds = WalletUtils.loadCredentials(password, + new File(dir, fileName)); // Create a password file > 1KB File pwFile = tempFolder.newFile("bigpw.txt"); byte[] bigContent = new byte[1025]; java.util.Arrays.fill(bigContent, (byte) 'a'); Files.write(pwFile.toPath(), bigContent); + makeOwnerOnly(pwFile); StringWriter err = new StringWriter(); CommandLine cmd = new CommandLine(new Toolkit()); @@ -437,12 +455,14 @@ public void testUpdatePasswordFileWithBom() throws Exception { SecureRandom.getInstance("NativePRNG")); byte[] originalKey = keyPair.getPrivateKey(); String fileName = WalletUtils.generateWalletFile(oldPassword, keyPair, dir, true); - Credentials creds = WalletUtils.loadCredentials(oldPassword, new File(dir, fileName)); + Credentials creds = WalletUtils.loadCredentials(oldPassword, + new File(dir, fileName)); // Password file with UTF-8 BOM File pwFile = tempFolder.newFile("bom.txt"); Files.write(pwFile.toPath(), ("\uFEFF" + oldPassword + "\n" + newPassword).getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); CommandLine cmd = new CommandLine(new Toolkit()); int exitCode = cmd.execute("keystore", "update", creds.getAddress(), @@ -451,7 +471,8 @@ public void testUpdatePasswordFileWithBom() throws Exception { assertEquals("Update with BOM password file should succeed", 0, exitCode); - Credentials updated = WalletUtils.loadCredentials(newPassword, new File(dir, fileName)); + Credentials updated = WalletUtils.loadCredentials(newPassword, + new File(dir, fileName)); assertArrayEquals("Key must survive update with BOM password file", originalKey, updated.getSignInterface().getPrivateKey()); } @@ -463,6 +484,7 @@ public void testUpdateNonExistentKeystoreDir() throws Exception { File pwFile = tempFolder.newFile("pw-nodir.txt"); Files.write(pwFile.toPath(), ("oldpass123\nnewpass456").getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); StringWriter err = new StringWriter(); CommandLine cmd = new CommandLine(new Toolkit()); @@ -483,6 +505,7 @@ public void testUpdateKeystoreDirIsFile() throws Exception { File pwFile = tempFolder.newFile("pw-notdir.txt"); Files.write(pwFile.toPath(), ("oldpass123\nnewpass456").getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); StringWriter err = new StringWriter(); CommandLine cmd = new CommandLine(new Toolkit()); @@ -506,12 +529,14 @@ public void testUpdateWithOldMacLineEndings() throws Exception { SecureRandom.getInstance("NativePRNG")); byte[] originalKey = keyPair.getPrivateKey(); String fileName = WalletUtils.generateWalletFile(oldPassword, keyPair, dir, true); - Credentials creds = WalletUtils.loadCredentials(oldPassword, new File(dir, fileName)); + Credentials creds = WalletUtils.loadCredentials(oldPassword, + new File(dir, fileName)); // Password file with old Mac line endings (\r only) File pwFile = tempFolder.newFile("cr.txt"); Files.write(pwFile.toPath(), (oldPassword + "\r" + newPassword + "\r").getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); CommandLine cmd = new CommandLine(new Toolkit()); int exitCode = cmd.execute("keystore", "update", creds.getAddress(), @@ -520,7 +545,8 @@ public void testUpdateWithOldMacLineEndings() throws Exception { assertEquals("Update with old Mac CR line endings should succeed", 0, exitCode); - Credentials updated = WalletUtils.loadCredentials(newPassword, new File(dir, fileName)); + Credentials updated = WalletUtils.loadCredentials(newPassword, + new File(dir, fileName)); assertArrayEquals("Key must survive update with CR passwords", originalKey, updated.getSignInterface().getPrivateKey()); } @@ -543,6 +569,7 @@ public void testUpdateSkipsInvalidVersionKeystores() throws Exception { File pwFile = tempFolder.newFile("pw-badver.txt"); Files.write(pwFile.toPath(), (password + "\nnewpass789").getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); StringWriter err = new StringWriter(); CommandLine cmd = new CommandLine(new Toolkit()); @@ -583,6 +610,7 @@ public void testUpdateRejectsTamperedAddressKeystore() throws Exception { File pwFile = tempFolder.newFile("pw-tampered.txt"); Files.write(pwFile.toPath(), (password + "\nnewpass789").getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); StringWriter err = new StringWriter(); CommandLine cmd = new CommandLine(new Toolkit()); @@ -613,6 +641,7 @@ public void testUpdatePreservesCorrectDerivedAddress() throws Exception { File pwFile = tempFolder.newFile("pw-derived.txt"); Files.write(pwFile.toPath(), (oldPassword + "\n" + newPassword).getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); CommandLine cmd = new CommandLine(new Toolkit()); int exitCode = cmd.execute("keystore", "update", originalAddress, @@ -646,7 +675,8 @@ public void testUpdateNarrowsLoosePermissionsTo0600() throws Exception { SignInterface keyPair = SignUtils.getGeneratedRandomSign( SecureRandom.getInstance("NativePRNG")); String fileName = WalletUtils.generateWalletFile(oldPassword, keyPair, dir, true); - Credentials creds = WalletUtils.loadCredentials(oldPassword, new File(dir, fileName)); + Credentials creds = WalletUtils.loadCredentials(oldPassword, + new File(dir, fileName)); // Deliberately loosen to 0644 before update java.nio.file.Path keystorePath = new File(dir, fileName).toPath(); @@ -660,6 +690,7 @@ public void testUpdateNarrowsLoosePermissionsTo0600() throws Exception { File pwFile = tempFolder.newFile("pw-perms.txt"); Files.write(pwFile.toPath(), (oldPassword + "\n" + newPassword).getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); CommandLine cmd = new CommandLine(new Toolkit()); int exitCode = cmd.execute("keystore", "update", creds.getAddress(), @@ -689,12 +720,14 @@ public void testUpdateLegacyTipFiresWhenPasswordHasWhitespace() throws Exception SignInterface keyPair = SignUtils.getGeneratedRandomSign( SecureRandom.getInstance("NativePRNG")); String fileName = WalletUtils.generateWalletFile(realPassword, keyPair, dir, true); - Credentials creds = WalletUtils.loadCredentials(realPassword, new File(dir, fileName)); + Credentials creds = WalletUtils.loadCredentials(realPassword, + new File(dir, fileName)); // Password with internal whitespace that is NOT the real password File pwFile = tempFolder.newFile("pw-ws.txt"); Files.write(pwFile.toPath(), ("correct horse battery staple\nnewpass789").getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); StringWriter err = new StringWriter(); CommandLine cmd = new CommandLine(new Toolkit()); @@ -719,12 +752,14 @@ public void testUpdateLegacyTipSuppressedWhenPasswordHasNoWhitespace() throws Ex SignInterface keyPair = SignUtils.getGeneratedRandomSign( SecureRandom.getInstance("NativePRNG")); String fileName = WalletUtils.generateWalletFile(realPassword, keyPair, dir, true); - Credentials creds = WalletUtils.loadCredentials(realPassword, new File(dir, fileName)); + Credentials creds = WalletUtils.loadCredentials(realPassword, + new File(dir, fileName)); // Wrong password with no whitespace File pwFile = tempFolder.newFile("pw-nows.txt"); Files.write(pwFile.toPath(), ("wrongpassword\nnewpass789").getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); StringWriter err = new StringWriter(); CommandLine cmd = new CommandLine(new Toolkit()); @@ -752,7 +787,8 @@ public void testUpdateScanSkipsSymlinkedEntry() throws Exception { SignInterface keyPair = SignUtils.getGeneratedRandomSign( SecureRandom.getInstance("NativePRNG")); String fileName = WalletUtils.generateWalletFile(oldPassword, keyPair, dir, true); - Credentials creds = WalletUtils.loadCredentials(oldPassword, new File(dir, fileName)); + Credentials creds = WalletUtils.loadCredentials(oldPassword, + new File(dir, fileName)); File target = tempFolder.newFile("outside.json"); Files.write(target.toPath(), @@ -763,6 +799,7 @@ public void testUpdateScanSkipsSymlinkedEntry() throws Exception { File pwFile = tempFolder.newFile("pw-update-sym.txt"); Files.write(pwFile.toPath(), (oldPassword + "\n" + newPassword).getBytes(StandardCharsets.UTF_8)); + makeOwnerOnly(pwFile); StringWriter err = new StringWriter(); CommandLine cmd = new CommandLine(new Toolkit()); @@ -775,4 +812,13 @@ public void testUpdateScanSkipsSymlinkedEntry() throws Exception { assertTrue("Scan must warn about the symlinked entry, got: " + err.toString(), err.toString().contains("Warning: skipping symbolic link: evil.json")); } + + private static void makeOwnerOnly(File f) throws IOException { + if (Files.getFileAttributeView(f.toPath(), + java.nio.file.attribute.PosixFileAttributeView.class) == null) { + return; // non-POSIX FS: skip chmod, mirroring KeystoreCliUtils runtime behavior + } + Files.setPosixFilePermissions(f.toPath(), + java.nio.file.attribute.PosixFilePermissions.fromString("rw-------")); + } }