diff --git a/Nitrox.Test/Model/Security/AsymCryptTests.cs b/Nitrox.Test/Model/Security/AsymCryptTests.cs
new file mode 100644
index 000000000..ec3e29d7b
--- /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 47e216adf..d7d28d15b 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 b49142aa3..a25cf64c6 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 000000000..c5f979707
--- /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 private key file.
+ ///
+ /// 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. 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. 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. 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 file that can either contain a private key (usually .key) or public key (usually .pem).
+ /// 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);
+ }
+ }
+}