diff --git a/Nitrox.Test/Model/Security/AsymCryptTests.cs b/Nitrox.Test/Model/Security/AsymCryptTests.cs new file mode 100644 index 0000000000..ec3e29d7b6 --- /dev/null +++ b/Nitrox.Test/Model/Security/AsymCryptTests.cs @@ -0,0 +1,71 @@ +using System; +using System.IO; +using System.Text; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace NitroxModel.Security; + +[TestClass] +public class AsymCryptTests +{ + private string pemFile; + + [TestInitialize] + public void Setup() + { + pemFile = Path.ChangeExtension(Path.GetTempFileName(), "key"); + AsymCrypt.CreateKey(pemFile, 1024); // Using lower key size so tests aren't so slow.. + } + + [TestMethod] + public void Encrypt_CanDecryptEncryptedInput() + { + byte[] input = "Hello, encrypted world!"u8.ToArray(); + Console.Write("Input: "); + Console.WriteLine(Encoding.UTF8.GetString(input)); + + byte[] encrypted = AsymCrypt.Encrypt(pemFile, input); + encrypted.Should().NotBeEquivalentTo(input); + + Console.Write("Encrypted: "); + Console.WriteLine(BitConverter.ToString(encrypted).Replace("-", "")); + AsymCrypt.Decrypt(pemFile, encrypted).Should().BeEquivalentTo(input); + } + + [TestMethod] + public void GetPublicPemFromPrivatePem_CanDerivePublicKeyFromPrivatePem() + { + // Store public key in PEM file. + string publicPem = AsymCrypt.GetPublicPemFromPrivateKeyFile(pemFile); + publicPem.Should().NotBeEmpty(); + Console.WriteLine("Public PEM:"); + Console.Write(publicPem); + string publicPemFile = Path.ChangeExtension(Path.GetTempFileName(), "pem"); + File.WriteAllText(publicPemFile, publicPem); + + try + { + // Encrypt something with the new PEM file. + byte[] input = "Encrypted with public key that was derived from private key"u8.ToArray(); + byte[] encrypted = AsymCrypt.Encrypt(publicPemFile, input); + encrypted.Should().NotBeEmpty(); + + // Decrypt with private key (supposedly known by other party). + string decrypted = Encoding.UTF8.GetString(AsymCrypt.Decrypt(pemFile, encrypted)); + decrypted.Should().BeEquivalentTo(Encoding.UTF8.GetString(input)); + Console.WriteLine("Decrypted:"); + Console.Write(decrypted); + } + finally + { + File.Delete(publicPemFile); + } + } + + [TestCleanup] + public void Cleanup() + { + File.Delete(pemFile); + } +} diff --git a/NitroxModel/NitroxModel.csproj b/NitroxModel/NitroxModel.csproj index 47e216adf9..d7d28d15b0 100644 --- a/NitroxModel/NitroxModel.csproj +++ b/NitroxModel/NitroxModel.csproj @@ -27,6 +27,7 @@ + diff --git a/NitroxModel/Packets/Packet.cs b/NitroxModel/Packets/Packet.cs index b49142aa3f..a25cf64c62 100644 --- a/NitroxModel/Packets/Packet.cs +++ b/NitroxModel/Packets/Packet.cs @@ -104,17 +104,17 @@ public override string ToString() } toStringBuilder.Clear(); - toStringBuilder.Append($"[{packetType.Name}: "); + toStringBuilder.Append('[').Append(packetType.Name).Append(": "); foreach (PropertyInfo property in properties) { object propertyValue = property.GetValue(this); if (propertyValue is IList propertyList) { - toStringBuilder.Append($"{property.Name}: {propertyList.Count}, "); + toStringBuilder.Append(property.Name).Append(": ").Append(propertyList.Count).Append(", "); } else { - toStringBuilder.Append($"{property.Name}: {propertyValue}, "); + toStringBuilder.Append(property.Name).Append(": ").Append(propertyValue).Append(", "); } } diff --git a/NitroxModel/Security/AsymCrypt.cs b/NitroxModel/Security/AsymCrypt.cs new file mode 100644 index 0000000000..f18416dcd8 --- /dev/null +++ b/NitroxModel/Security/AsymCrypt.cs @@ -0,0 +1,209 @@ +using System; +using System.CodeDom.Compiler; +using System.Collections.Concurrent; +using System.IO; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Digests; +using Org.BouncyCastle.Crypto.Encodings; +using Org.BouncyCastle.Crypto.Engines; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Math; +using Org.BouncyCastle.OpenSsl; + +namespace NitroxModel.Security; + +/// +/// API wrapper for BouncyCastle's asymmetric encryption algorithms. Uses PEM files to store public/private key pairs +/// that can then be used to encrypt, decrypt and sign data. +/// +public static class AsymCrypt +{ + private static readonly ConcurrentDictionary pemCache = new(); + + /// + /// Creates a new private key file for asymmetric encryption. The public key can be derived from a private key file. + /// + /// The file name for the key file. + /// The length of the private key. Should be sufficiently large to ensure security. + public static void CreateKey(string keyFile, int keySize = 4096) + { + RsaKeyGenerationParameters keyGenParams = new(new BigInteger("65537"), new(), keySize, 64); + RsaKeyPairGenerator rsaKeyGen = new(); + rsaKeyGen.Init(keyGenParams); + AsymmetricCipherKeyPair rsaKeyPair = rsaKeyGen.GenerateKeyPair(); + + using StreamWriter keyStream = new(keyFile); + using IndentedTextWriter textWriter = new(keyStream); + using PemWriter writer = new(textWriter); + writer.WriteObject(new Pkcs8Generator(rsaKeyPair.Private)); + writer.Writer.Flush(); + } + + /// + /// Gets the public key information from a PEM file containing the private key. + /// + /// The key file with a private key. + /// + /// A PEM file formatted public key. Returns null if the file does not exist or the PEM file doesn't have a + /// private key. + /// + public static string GetPublicPemFromPrivateKeyFile(string keyFile) + { + if (!LoadPemFile(keyFile, out PemUser pem)) + { + return null; + } + + using StringWriter publicPemContent = new(); + using IndentedTextWriter textWriter = new(publicPemContent); + using PemWriter writer = new(textWriter); + writer.WriteObject(pem.PublicKey); + writer.Writer.Flush(); + + return publicPemContent.ToString(); + } + + /// + /// Signs the data with the private key in the PEM file. This allows receivers of this data, those with the matching + /// public key, to ensure it came from a known source. + /// + /// + /// Important: this isn't secure (anyone with the public key can decrypt). Only to be used for signing. + /// + /// The key file that contains a private key to sign the data with. + /// The data to sign. + public static void Sign(string keyFile, byte[] data) + { + throw new NotImplementedException(); + } + + /// + /// Decrypts the signed data to verify it is from the expected source. + /// + public static bool Verify(string pemFile, byte[] data) + { + throw new NotImplementedException(); + } + + /// + /// Encrypts the data with the public key in the PEM file. Only those with the matching private key can decrypt this + /// data, making it secure. + /// + /// The PEM file containing the public key to encrypt the data with. + /// The data to encrypt. + /// Thrown when the PEM file does not exist. + public static byte[] Encrypt(string pemFile, byte[] data) + { + if (!LoadPemFile(pemFile, out PemUser pem)) + { + throw new FileNotFoundException("PEM file not found.", pemFile); + } + + return pem.Encoder.Value.ProcessBlock(data, 0, data.Length); + } + + /// + /// Decrypts the data with the private key in the key file. The data should have been encrypted with the matching + /// public key. + /// + /// The key file containing the private key to decrypt the data with. + /// The data to decrypt. + public static byte[] Decrypt(string keyFile, byte[] data) + { + if (!LoadPemFile(keyFile, out PemUser pem)) + { + throw new FileNotFoundException("Key file not found.", keyFile); + } + if (!pem.CanDecrypt) + { + throw new Exception($"The key file '{keyFile}' does not contain a private key, decryption is unavailable."); + } + + return pem.Decoder.Value.ProcessBlock(data, 0, data.Length); + } + + public static void Invalidate() + { + pemCache.Clear(); + } + + /// + /// Loads a PEM file that can either contain a private or public key. + /// Multi-content PEM data is not supported. + /// + private static bool LoadPemFile(string pemFile, out PemUser pemUser) + { + pemFile = Path.GetFullPath(pemFile); + if (!pemCache.ContainsKey(pemFile)) + { + if (!File.Exists(pemFile)) + { + pemUser = default; + return false; + } + + pemUser = pemCache[pemFile] = new PemUser(pemFile); + return true; + } + + pemUser = pemCache[pemFile]; + return true; + } + + private readonly record struct PemUser + { + public Lazy Encoder { get; } + public Lazy Decoder { get; } + + public bool CanDecrypt => Decoder != null; + + public RsaPrivateCrtKeyParameters PrivateKey { get; } + public RsaKeyParameters PublicKey { get; } + + public PemUser(string pemFile) + { + string pemContent = File.ReadAllText(pemFile); + if (string.IsNullOrWhiteSpace(pemContent)) + { + throw new Exception($"Invalid PEM data in file '{pemFile}'"); + } + + using StringReader stringReader = new(pemContent); + using PemReader reader = new(stringReader); + object pemObject = reader.ReadObject(); + switch (pemObject) + { + case RsaPrivateCrtKeyParameters privatePem: + PrivateKey = privatePem; + PublicKey = new(false, privatePem.Modulus, privatePem.PublicExponent); + break; + case RsaKeyParameters publicPem: + PublicKey = publicPem; + break; + default: + throw new NotSupportedException($"Unsupported PEM file '{pemFile}'"); + } + + RsaKeyParameters publicKey = PublicKey; + Encoder = new Lazy(() => + { + OaepEncoding encoder = CreateEncoder(); + encoder.Init(true, publicKey); + return encoder; + }); + RsaKeyParameters privateKey = PrivateKey; + Decoder = new Lazy(() => + { + OaepEncoding encoder = CreateEncoder(); + encoder.Init(false, privateKey); + return encoder; + }); + } + + private static OaepEncoding CreateEncoder() + { + return new(new RsaEngine(), new Sha256Digest(), new Sha256Digest(), null); + } + } +}