Skip to content

Commit

Permalink
Added RSA API for creating and loading private/public key pairs
Browse files Browse the repository at this point in the history
  • Loading branch information
Measurity committed Apr 27, 2023
1 parent fa7cc9b commit 43c26fe
Show file tree
Hide file tree
Showing 4 changed files with 284 additions and 3 deletions.
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 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);
}
}
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 PEM file containing the private key.
/// </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 in the PEM file. 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 in the PEM file. 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 in the key file. 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 PEM file that can either contain a private or public key.
/// 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);
}
}
}

0 comments on commit 43c26fe

Please sign in to comment.