Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add optional packet encryption #2030

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions Nitrox.Test/Model/Security/AsymCryptTests.cs
Original file line number Diff line number Diff line change
@@ -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 keyFile;

[TestInitialize]
public void Setup()
{
keyFile = Path.ChangeExtension(Path.GetTempFileName(), "key");
AsymCrypt.CreateKey(keyFile, 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(keyFile, input);
encrypted.Should().NotBeEquivalentTo(input);

Console.Write("Encrypted: ");
Console.WriteLine(BitConverter.ToString(encrypted).Replace("-", ""));
AsymCrypt.Decrypt(keyFile, encrypted).Should().BeEquivalentTo(input);
}

[TestMethod]
public void GetPublicPemFromPrivatePem_CanDerivePublicKeyFromPrivatePem()
{
// Store public key in PEM file.
string publicPem = AsymCrypt.GetPublicPemFromPrivateKeyFile(keyFile);
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(keyFile, encrypted));
decrypted.Should().BeEquivalentTo(Encoding.UTF8.GetString(input));
Console.WriteLine("Decrypted:");
Console.Write(decrypted);
}
finally
{
File.Delete(publicPemFile);
}
}

[TestCleanup]
public void Cleanup()
{
File.Delete(keyFile);
}
}
43 changes: 43 additions & 0 deletions Nitrox.Test/Model/Security/SymCryptTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using FluentAssertions;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace NitroxModel.Security;

[TestClass]
public class SymCryptTests
{
private static readonly string password = "TotallySecurePasswordThatNoOneWillEverGuessTM";

[TestMethod]
public void Encrypt_CanDecryptBackToSameValue()
{
byte[] plain = "Hello, encrypted world!"u8.ToArray();
byte[] encrypted = SymCrypt.Encrypt(password, plain);

encrypted.Should().NotBeEmpty();
encrypted.Should().NotBeEquivalentTo(plain);

SymCrypt.TryDecrypt(password, encrypted, out byte[] decrypted).Should().BeTrue();
decrypted.Should().NotBeEquivalentTo(encrypted);
decrypted.Should().BeEquivalentTo(plain);
}

[TestMethod]
public void Encrypt_CanNotDecryptWithDifferentPassword()
{
string correctPassword = "the first password";
byte[] plain = "A message that should be encrypted!"u8.ToArray();
byte[] encrypted = SymCrypt.Encrypt(correctPassword, plain);

encrypted.Should().NotBeEmpty();
encrypted.Should().NotBeEquivalentTo(plain);

SymCrypt.TryDecrypt("second ps", encrypted, out byte[] wrongDecrypt).Should().BeFalse();
wrongDecrypt.Should().NotBeEquivalentTo(encrypted);
wrongDecrypt.Should().NotBeEquivalentTo(plain);

SymCrypt.TryDecrypt(correctPassword, encrypted, out byte[] correctDecrypt).Should().BeTrue();
correctDecrypt.Should().NotBeEquivalentTo(wrongDecrypt);
correctDecrypt.Should().BeEquivalentTo(plain);
}
}
1 change: 1 addition & 0 deletions NitroxModel/NitroxModel.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
<PackageReference Include="System.IO.FileSystem.AccessControl" Version="5.0.0" />
<PackageReference Include="System.Numerics.Vectors" Version="4.5.0" />
<PackageReference Include="System.Runtime.CompilerServices.Unsafe" Version="6.0.0" />
<PackageReference Include="BouncyCastle.Cryptography" Version="2.2.1" />
</ItemGroup>

<ItemGroup>
Expand Down
6 changes: 3 additions & 3 deletions NitroxModel/Packets/Packet.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(", ");
}
}

Expand Down
209 changes: 209 additions & 0 deletions NitroxModel/Security/AsymCrypt.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
public static class AsymCrypt
{
private static readonly ConcurrentDictionary<string, PemUser> pemCache = new();

/// <summary>
/// Creates a new private key file for asymmetric encryption. The public key can be derived from a private key file.
/// </summary>
/// <param name="keyFile">The file name for the key file.</param>
/// <param name="keySize">The length of the private key. Should be sufficiently large to ensure security.</param>
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();
}

/// <summary>
/// Gets the public key information from a private key file.
/// </summary>
/// <param name="keyFile">The key file with a private key.</param>
/// <returns>
/// A PEM file formatted public key. Returns null if the file does not exist or the PEM file doesn't have a
/// private key.
/// </returns>
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();
}

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Important: this isn't secure (anyone with the public key can decrypt). Only to be used for signing.
/// </remarks>
/// <param name="keyFile">The key file that contains a private key to sign the data with.</param>
/// <param name="data">The data to sign.</param>
public static void Sign(string keyFile, byte[] data)
{
throw new NotImplementedException();
}

/// <summary>
/// Decrypts the signed data to verify it is from the expected source.
/// </summary>
public static bool Verify(string pemFile, byte[] data)
{
throw new NotImplementedException();
}

/// <summary>
/// Encrypts the data with the public key. Only those with the matching private key can decrypt this
/// data, making it secure.
/// </summary>
/// <param name="pemFile">The PEM file containing the public key to encrypt the data with.</param>
/// <param name="data">The data to encrypt.</param>
/// <exception cref="FileNotFoundException">Thrown when the PEM file does not exist.</exception>
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);
}

/// <summary>
/// Decrypts the data with the private key. The data should have been encrypted with the matching
/// public key.
/// </summary>
/// <param name="keyFile">The key file containing the private key to decrypt the data with.</param>
/// <param name="data">The data to decrypt.</param>
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();
}

/// <summary>
/// Loads a file that can either contain a private key (usually .key) or public key (usually .pem).
/// Multi-content PEM data is not supported.
/// </summary>
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<OaepEncoding> Encoder { get; }
public Lazy<OaepEncoding> 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>(() =>
{
OaepEncoding encoder = CreateEncoder();
encoder.Init(true, publicKey);
return encoder;
});
RsaKeyParameters privateKey = PrivateKey;
Decoder = new Lazy<OaepEncoding>(() =>
{
OaepEncoding encoder = CreateEncoder();
encoder.Init(false, privateKey);
return encoder;
});
}

private static OaepEncoding CreateEncoder()
{
return new(new RsaEngine(), new Sha256Digest(), new Sha256Digest(), null);
}
}
}
Loading