From 0f38952426c573f070dbafaf7cc1434248e91790 Mon Sep 17 00:00:00 2001 From: aharo Date: Wed, 7 Oct 2026 20:23:31 +0000 Subject: [PATCH 1/2] Save private key files with owner-only permissions KeyUtils.saveEcKey and KeyUtils.saveEcKeyAsHex wrote the private key with the default mode (usually 0644), so any local user could read it. Both now write the file with mode 0600 on file systems that support POSIX permissions. If the file already exists, its permissions are tightened before the key is written. On Windows the file keeps the default permissions. --- .../java/com/bitpay/sdk/util/KeyUtils.java | 55 +++++++++++++---- .../com/bitpay/sdk/util/KeyUtilsTest.java | 60 +++++++++++++++++++ 2 files changed, 104 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/bitpay/sdk/util/KeyUtils.java b/src/main/java/com/bitpay/sdk/util/KeyUtils.java index c2f160a8..e1794183 100644 --- a/src/main/java/com/bitpay/sdk/util/KeyUtils.java +++ b/src/main/java/com/bitpay/sdk/util/KeyUtils.java @@ -10,14 +10,17 @@ import java.io.BufferedReader; import java.io.File; import java.io.FileInputStream; -import java.io.FileOutputStream; import java.io.FileReader; import java.io.IOException; -import java.io.PrintWriter; import java.math.BigInteger; import java.net.URI; import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.Set; import org.bitcoinj.base.Base58; import org.bitcoinj.base.Sha256Hash; import org.bitcoinj.crypto.ECKey; @@ -174,10 +177,7 @@ public static void saveEcKey(ECKey ecKey) throws IOException { file = new File(KeyUtils.privateKey); } - FileOutputStream output = new FileOutputStream(file); - - output.write(bytes); - output.close(); + writeOwnerOnlyFile(file, bytes); } /** @@ -210,17 +210,16 @@ public static void saveEcKey( */ public static void saveEcKeyAsHex(ECKey ecKey) throws IOException { byte[] bytes = ecKey.toASN1(); - PrintWriter file; + File file; if (KeyUtils.privateKey == null) { - file = new PrintWriter(PrivateKeyFile); + file = new File(PrivateKeyFile); } else { - file = new PrintWriter(String.valueOf(KeyUtils.privateKey)); + file = new File(String.valueOf(KeyUtils.privateKey)); } String keyHex = bytesToHex(bytes); - file.println(keyHex); - file.close(); + writeOwnerOnlyFile(file, (keyHex + System.lineSeparator()).getBytes(StandardCharsets.UTF_8)); } /** @@ -345,4 +344,38 @@ public static String bytesToHex(byte[] bytes) { return new String(hexChars); } + + /** + * Writes a private key file so only its owner can read and write it. + * + *

On file systems with POSIX permissions, the file is created with mode 0600 (or set to 0600 + * if it already exists) before the key is written. On other file systems, such as Windows, + * the file keeps the default permissions.

+ * + * @param file the file to write + * @param content the file content + * @throws IOException the io exception + */ + private static void writeOwnerOnlyFile( + File file, + byte[] content + ) throws IOException { + Path path = file.toPath(); + boolean isPosix = path.getFileSystem().supportedFileAttributeViews().contains("posix"); + Set ownerOnly = PosixFilePermissions.fromString("rw-------"); + + if (isPosix) { + if (Files.exists(path)) { + Files.setPosixFilePermissions(path, ownerOnly); + } else { + Files.createFile(path, PosixFilePermissions.asFileAttribute(ownerOnly)); + } + } + + Files.write(path, content); + + if (isPosix) { + Files.setPosixFilePermissions(path, ownerOnly); + } + } } diff --git a/src/test/java/com/bitpay/sdk/util/KeyUtilsTest.java b/src/test/java/com/bitpay/sdk/util/KeyUtilsTest.java index 563ff4d1..b2cb2472 100644 --- a/src/test/java/com/bitpay/sdk/util/KeyUtilsTest.java +++ b/src/test/java/com/bitpay/sdk/util/KeyUtilsTest.java @@ -6,11 +6,20 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assumptions.assumeTrue; import com.bitpay.sdk.exceptions.BitPayGenericException; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; import org.bitcoinj.crypto.ECKey; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; public class KeyUtilsTest { @@ -55,4 +64,55 @@ public void it_should_convert_bytes_to_hex() { assertEquals(expectedHex, actualHex); } + + @Test + public void it_should_save_the_key_file_readable_only_by_its_owner(@TempDir Path dir) throws IOException { + assumePosix(); + File file = dir.resolve("bitpay_private_test.key").toFile(); + ECKey key = KeyUtils.createEcKey(); + + KeyUtils.privateKeyExists(file.getPath()); + KeyUtils.saveEcKey(key); + + assertEquals("rw-------", permissionsOf(file)); + assertEquals(key.getPrivateKeyAsHex(), KeyUtils.loadEcKey().getPrivateKeyAsHex()); + } + + @Test + public void it_should_tighten_an_existing_key_file(@TempDir Path dir) throws IOException { + assumePosix(); + File file = dir.resolve("bitpay_private_test.key").toFile(); + Files.write(file.toPath(), "old key".getBytes(StandardCharsets.UTF_8)); + Files.setPosixFilePermissions(file.toPath(), PosixFilePermissions.fromString("rw-r--r--")); + + KeyUtils.privateKeyExists(file.getPath()); + KeyUtils.saveEcKey(KeyUtils.createEcKey()); + + assertEquals("rw-------", permissionsOf(file)); + } + + @Test + public void it_should_save_the_hex_key_file_readable_only_by_its_owner(@TempDir Path dir) throws IOException { + assumePosix(); + File file = dir.resolve("bitpay_private_test.txt").toFile(); + ECKey key = KeyUtils.createEcKey(); + + KeyUtils.privateKeyExists(file.getPath()); + KeyUtils.saveEcKeyAsHex(key); + + assertEquals("rw-------", permissionsOf(file)); + assertEquals( + KeyUtils.loadEcKeyAsHex(key) + System.lineSeparator(), + new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8) + ); + } + + // POSIX permissions do not apply on Windows. + private static void assumePosix() { + assumeTrue(FileSystems.getDefault().supportedFileAttributeViews().contains("posix")); + } + + private static String permissionsOf(File file) throws IOException { + return PosixFilePermissions.toString(Files.getPosixFilePermissions(file.toPath())); + } } From 50cefaad6e5d9dc51e94079ef7fdcf87f2529a69 Mon Sep 17 00:00:00 2001 From: aharo Date: Wed, 7 Oct 2026 20:23:49 +0000 Subject: [PATCH 2/2] Save the setup config file with owner-only permissions The setup tool wrote BitPay.config.json with the default mode (usually 0644). The file holds the API tokens, and with the "plain text" option it also holds the private key, so any local user could read them. It is now written with mode 0600 on file systems that support POSIX permissions, the same way as the private key file. --- src/setup/java/BitPaySetup.java | 44 +++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/src/setup/java/BitPaySetup.java b/src/setup/java/BitPaySetup.java index a224dc69..23aeec66 100644 --- a/src/setup/java/BitPaySetup.java +++ b/src/setup/java/BitPaySetup.java @@ -16,9 +16,15 @@ import com.fasterxml.jackson.databind.ObjectWriter; import com.fasterxml.jackson.databind.node.ObjectNode; import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.nio.file.Paths; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; import java.util.HashMap; import java.util.Scanner; +import java.util.Set; import java.util.concurrent.atomic.AtomicReference; import org.bitcoinj.crypto.ECKey; @@ -125,9 +131,9 @@ public static void main(final String[] args) { final String jsonString = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(configurationFile); final ObjectWriter writer = mapper.writer(new DefaultPrettyPrinter()); - writer.writeValue( + writeOwnerOnlyFile( new File(Paths.get(".").toAbsolutePath().normalize() + "/output/BitPay.config.json"), - configurationFile); + writer.writeValueAsBytes(configurationFile)); System.out.println("In location:"); System.out.println(Paths.get(".").toAbsolutePath().normalize() + "/output/BitPay.config.json"); @@ -159,4 +165,38 @@ public static void main(final String[] args) { + "in the generated config file." ); } + + /** + * Writes the config file so only its owner can read and write it, because it holds the API tokens. + * + *

On file systems with POSIX permissions, the file is created with mode 0600 (or set to 0600 + * if it already exists) before it is written. On other file systems, such as Windows, the file + * keeps the default permissions.

+ * + * @param file the file to write + * @param content the file content + * @throws IOException the io exception + */ + private static void writeOwnerOnlyFile( + final File file, + final byte[] content + ) throws IOException { + final Path path = file.toPath(); + final boolean isPosix = path.getFileSystem().supportedFileAttributeViews().contains("posix"); + final Set ownerOnly = PosixFilePermissions.fromString("rw-------"); + + if (isPosix) { + if (Files.exists(path)) { + Files.setPosixFilePermissions(path, ownerOnly); + } else { + Files.createFile(path, PosixFilePermissions.asFileAttribute(ownerOnly)); + } + } + + Files.write(path, content); + + if (isPosix) { + Files.setPosixFilePermissions(path, ownerOnly); + } + } }