diff --git a/BitPay/KeyUtils.cs b/BitPay/KeyUtils.cs index b4b1054..1efe3e3 100644 --- a/BitPay/KeyUtils.cs +++ b/BitPay/KeyUtils.cs @@ -90,7 +90,7 @@ public static async Task SaveEcKey(EcKey ecKey) { Directory.CreateDirectory(Path.GetDirectoryName(PrivateKeyFile)!); } - using (var fs = new FileStream(PrivateKeyFile!, FileMode.Create, FileAccess.Write)) + using (var fs = CreateOwnerOnlyFile(PrivateKeyFile!)) { #pragma warning disable CA1835 await fs.WriteAsync(bytes, 0, bytes.Length).ConfigureAwait(false); @@ -327,5 +327,36 @@ private static string GetKeyStringFromFile(string filename) return line; } } + + /// + /// Creates (or overwrites) a file that only its owner can read and write. + /// + /// + /// On Linux and macOS the file gets mode 0600. An existing file is changed to 0600 before it is + /// overwritten, because the create mode only applies to new files. On Windows, and on .NET + /// Framework, the file keeps the permissions it inherits from its folder. + /// + private static FileStream CreateOwnerOnlyFile(string path) + { +#if NET7_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + { + const UnixFileMode ownerOnly = UnixFileMode.UserRead | UnixFileMode.UserWrite; + + if (File.Exists(path)) + { + File.SetUnixFileMode(path, ownerOnly); + } + + return new FileStream(path, new FileStreamOptions + { + Mode = FileMode.Create, + Access = FileAccess.Write, + UnixCreateMode = ownerOnly, + }); + } +#endif + return new FileStream(path, FileMode.Create, FileAccess.Write); + } } } \ No newline at end of file diff --git a/BitPaySetup/Program.cs b/BitPaySetup/Program.cs index 95d8287..659aecc 100644 --- a/BitPaySetup/Program.cs +++ b/BitPaySetup/Program.cs @@ -828,7 +828,7 @@ private static void GenerateConfFile(string newConfFilePath = @"BitPay.config.js } // serialize JSON directly to a new config file - using (var file = File.CreateText(newConfFilePath)) + using (var file = new StreamWriter(CreateOwnerOnlyFile(newConfFilePath))) { var serializer = new JsonSerializer {Formatting = Formatting.Indented}; serializer.Serialize(file, appConfig); @@ -836,5 +836,37 @@ private static void GenerateConfFile(string newConfFilePath = @"BitPay.config.js confFilePath = newConfFilePath; } + + /// + /// Creates (or overwrites) a file that only its owner can read and write. The config file + /// holds the API tokens, and with the "plain text" option it also holds the private key. + /// + /// + /// On Linux and macOS the file gets mode 0600. An existing file is changed to 0600 before it is + /// overwritten, because the create mode only applies to new files. On Windows, and on .NET + /// Framework, the file keeps the permissions it inherits from its folder. + /// + private static FileStream CreateOwnerOnlyFile(string path) + { +#if NET7_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + { + const UnixFileMode ownerOnly = UnixFileMode.UserRead | UnixFileMode.UserWrite; + + if (File.Exists(path)) + { + File.SetUnixFileMode(path, ownerOnly); + } + + return new FileStream(path, new FileStreamOptions + { + Mode = FileMode.Create, + Access = FileAccess.Write, + UnixCreateMode = ownerOnly, + }); + } +#endif + return new FileStream(path, FileMode.Create, FileAccess.Write); + } } } \ No newline at end of file diff --git a/BitPayUnitTest/ClientTest.cs b/BitPayUnitTest/ClientTest.cs index 27c56e6..761a5d7 100644 --- a/BitPayUnitTest/ClientTest.cs +++ b/BitPayUnitTest/ClientTest.cs @@ -22,6 +22,7 @@ namespace BitPayUnitTest { + [Collection(KeyUtilsTest.StaticStateCollection)] public class ClientTest { const string Identity = "someIdentity"; diff --git a/BitPayUnitTest/KeyUtilsTest.cs b/BitPayUnitTest/KeyUtilsTest.cs new file mode 100644 index 0000000..1ab1d0c --- /dev/null +++ b/BitPayUnitTest/KeyUtilsTest.cs @@ -0,0 +1,69 @@ +// Copyright (c) 2019 BitPay. +// All rights reserved. + +using BitPay; + +namespace BitPayUnitTest +{ + // KeyUtils keeps the key path in a static field, and ClientTest sets it too. + // Tests in the same collection never run in parallel. + [Collection(StaticStateCollection)] + public class KeyUtilsTest : IDisposable + { + public const string StaticStateCollection = "KeyUtils static state"; + + private readonly string _directory; + + public KeyUtilsTest() + { + _directory = Path.Combine(Path.GetTempPath(), "bitpay-keyutils-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_directory); + } + + public void Dispose() + { + Directory.Delete(_directory, true); + GC.SuppressFinalize(this); + } + +#if NET7_0_OR_GREATER + private const UnixFileMode OwnerOnly = UnixFileMode.UserRead | UnixFileMode.UserWrite; + + [Fact] + public async Task it_should_save_the_key_file_readable_only_by_its_owner() + { + if (OperatingSystem.IsWindows()) + { + return; // POSIX permissions do not apply on Windows. + } + + var path = Path.Combine(_directory, "bitpay_private_test.key"); + var key = KeyUtils.CreateEcKey(); + + KeyUtils.PrivateKeyExists(path); + await KeyUtils.SaveEcKey(key); + + Assert.Equal(OwnerOnly, File.GetUnixFileMode(path)); + Assert.Equal(key.ToAsn1(), KeyUtils.LoadEcKey().ToAsn1()); + } + + [Fact] + public async Task it_should_tighten_an_existing_key_file() + { + if (OperatingSystem.IsWindows()) + { + return; // POSIX permissions do not apply on Windows. + } + + var path = Path.Combine(_directory, "bitpay_private_test.key"); + File.WriteAllText(path, "old key"); + File.SetUnixFileMode(path, OwnerOnly | UnixFileMode.GroupRead | UnixFileMode.OtherRead); + + KeyUtils.PrivateKeyExists(path); + await KeyUtils.SaveEcKey(KeyUtils.CreateEcKey()); + + Assert.Equal(OwnerOnly, File.GetUnixFileMode(path)); + } +#endif + } +}