Skip to content

Commit 15c8e2b

Browse files
committed
Added RSA API for creating and loading private/public key pairs
1 parent fa7cc9b commit 15c8e2b

File tree

4 files changed

+284
-3
lines changed

4 files changed

+284
-3
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
using System;
2+
using System.IO;
3+
using System.Text;
4+
using FluentAssertions;
5+
using Microsoft.VisualStudio.TestTools.UnitTesting;
6+
7+
namespace NitroxModel.Security;
8+
9+
[TestClass]
10+
public class AsymCryptTests
11+
{
12+
private string pemFile;
13+
14+
[TestInitialize]
15+
public void Setup()
16+
{
17+
pemFile = Path.ChangeExtension(Path.GetTempFileName(), "key");
18+
AsymCrypt.CreateKey(pemFile, 1024); // Using lower key size so tests aren't so slow..
19+
}
20+
21+
[TestMethod]
22+
public void Encrypt_CanDecryptEncryptedInput()
23+
{
24+
byte[] input = "Hello, encrypted world!"u8.ToArray();
25+
Console.Write("Input: ");
26+
Console.WriteLine(Encoding.UTF8.GetString(input));
27+
28+
byte[] encrypted = AsymCrypt.Encrypt(pemFile, input);
29+
encrypted.Should().NotBeEquivalentTo(input);
30+
31+
Console.Write("Encrypted: ");
32+
Console.WriteLine(BitConverter.ToString(encrypted).Replace("-", ""));
33+
AsymCrypt.Decrypt(pemFile, encrypted).Should().BeEquivalentTo(input);
34+
}
35+
36+
[TestMethod]
37+
public void GetPublicPemFromPrivatePem_CanDerivePublicKeyFromPrivatePem()
38+
{
39+
// Store public key in PEM file.
40+
string publicPem = AsymCrypt.GetPublicPemFromPrivateKeyFile(pemFile);
41+
publicPem.Should().NotBeEmpty();
42+
Console.WriteLine("Public PEM:");
43+
Console.Write(publicPem);
44+
string publicPemFile = Path.ChangeExtension(Path.GetTempFileName(), "pem");
45+
File.WriteAllText(publicPemFile, publicPem);
46+
47+
try
48+
{
49+
// Encrypt something with the new PEM file.
50+
byte[] input = "Encrypted with public key that was derived from private key"u8.ToArray();
51+
byte[] encrypted = AsymCrypt.Encrypt(publicPemFile, input);
52+
encrypted.Should().NotBeEmpty();
53+
54+
// Decrypt with private key (supposedly known by other party).
55+
string decrypted = Encoding.UTF8.GetString(AsymCrypt.Decrypt(pemFile, encrypted));
56+
decrypted.Should().BeEquivalentTo(Encoding.UTF8.GetString(input));
57+
Console.WriteLine("Decrypted:");
58+
Console.Write(decrypted);
59+
}
60+
finally
61+
{
62+
File.Delete(publicPemFile);
63+
}
64+
}
65+
66+
[TestCleanup]
67+
public void Cleanup()
68+
{
69+
File.Delete(pemFile);
70+
}
71+
}

NitroxModel/NitroxModel.csproj

+1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
<PackageReference Include="System.IO.FileSystem.AccessControl" Version="5.0.0" />
2828
<PackageReference Include="System.Numerics.Vectors" Version="4.5.0" />
2929
<PackageReference Include="System.Runtime.CompilerServices.Unsafe" Version="6.0.0" />
30+
<PackageReference Include="BouncyCastle.Cryptography" Version="2.2.1" />
3031
</ItemGroup>
3132

3233
<ItemGroup>

NitroxModel/Packets/Packet.cs

+3-3
Original file line numberDiff line numberDiff line change
@@ -104,17 +104,17 @@ public override string ToString()
104104
}
105105

106106
toStringBuilder.Clear();
107-
toStringBuilder.Append($"[{packetType.Name}: ");
107+
toStringBuilder.Append('[').Append(packetType.Name).Append(": ");
108108
foreach (PropertyInfo property in properties)
109109
{
110110
object propertyValue = property.GetValue(this);
111111
if (propertyValue is IList propertyList)
112112
{
113-
toStringBuilder.Append($"{property.Name}: {propertyList.Count}, ");
113+
toStringBuilder.Append(property.Name).Append(": ").Append(propertyList.Count).Append(", ");
114114
}
115115
else
116116
{
117-
toStringBuilder.Append($"{property.Name}: {propertyValue}, ");
117+
toStringBuilder.Append(property.Name).Append(": ").Append(propertyValue).Append(", ");
118118
}
119119
}
120120

NitroxModel/Security/AsymCrypt.cs

+209
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
using System;
2+
using System.CodeDom.Compiler;
3+
using System.Collections.Concurrent;
4+
using System.IO;
5+
using Org.BouncyCastle.Crypto;
6+
using Org.BouncyCastle.Crypto.Digests;
7+
using Org.BouncyCastle.Crypto.Encodings;
8+
using Org.BouncyCastle.Crypto.Engines;
9+
using Org.BouncyCastle.Crypto.Generators;
10+
using Org.BouncyCastle.Crypto.Parameters;
11+
using Org.BouncyCastle.Math;
12+
using Org.BouncyCastle.OpenSsl;
13+
14+
namespace NitroxModel.Security;
15+
16+
/// <summary>
17+
/// API wrapper for BouncyCastle's asymmetric encryption algorithms. Uses PEM files to store public/private key pairs
18+
/// that can then be used to encrypt, decrypt and sign data.
19+
/// </summary>
20+
public static class AsymCrypt
21+
{
22+
private static readonly ConcurrentDictionary<string, PemUser> pemCache = new();
23+
24+
/// <summary>
25+
/// Creates a new private key file for asymmetric encryption. The public key can be derived from a private key file.
26+
/// </summary>
27+
/// <param name="keyFile">The file name for the key file.</param>
28+
/// <param name="keySize">The length of the private key. Should be sufficiently large to ensure security.</param>
29+
public static void CreateKey(string keyFile, int keySize = 4096)
30+
{
31+
RsaKeyGenerationParameters keyGenParams = new(new BigInteger("65537"), new(), keySize, 64);
32+
RsaKeyPairGenerator rsaKeyGen = new();
33+
rsaKeyGen.Init(keyGenParams);
34+
AsymmetricCipherKeyPair rsaKeyPair = rsaKeyGen.GenerateKeyPair();
35+
36+
using StreamWriter keyStream = new(keyFile);
37+
using IndentedTextWriter textWriter = new(keyStream);
38+
using PemWriter writer = new(textWriter);
39+
writer.WriteObject(new Pkcs8Generator(rsaKeyPair.Private));
40+
writer.Writer.Flush();
41+
}
42+
43+
/// <summary>
44+
/// Gets the public key information from a private key file.
45+
/// </summary>
46+
/// <param name="keyFile">The key file with a private key.</param>
47+
/// <returns>
48+
/// A PEM file formatted public key. Returns null if the file does not exist or the PEM file doesn't have a
49+
/// private key.
50+
/// </returns>
51+
public static string GetPublicPemFromPrivateKeyFile(string keyFile)
52+
{
53+
if (!LoadPemFile(keyFile, out PemUser pem))
54+
{
55+
return null;
56+
}
57+
58+
using StringWriter publicPemContent = new();
59+
using IndentedTextWriter textWriter = new(publicPemContent);
60+
using PemWriter writer = new(textWriter);
61+
writer.WriteObject(pem.PublicKey);
62+
writer.Writer.Flush();
63+
64+
return publicPemContent.ToString();
65+
}
66+
67+
/// <summary>
68+
/// Signs the data with the private key. This allows receivers of this data, those with the matching
69+
/// public key, to ensure it came from a known source.
70+
/// </summary>
71+
/// <remarks>
72+
/// Important: this isn't secure (anyone with the public key can decrypt). Only to be used for signing.
73+
/// </remarks>
74+
/// <param name="keyFile">The key file that contains a private key to sign the data with.</param>
75+
/// <param name="data">The data to sign.</param>
76+
public static void Sign(string keyFile, byte[] data)
77+
{
78+
throw new NotImplementedException();
79+
}
80+
81+
/// <summary>
82+
/// Decrypts the signed data to verify it is from the expected source.
83+
/// </summary>
84+
public static bool Verify(string pemFile, byte[] data)
85+
{
86+
throw new NotImplementedException();
87+
}
88+
89+
/// <summary>
90+
/// Encrypts the data with the public key. Only those with the matching private key can decrypt this
91+
/// data, making it secure.
92+
/// </summary>
93+
/// <param name="pemFile">The PEM file containing the public key to encrypt the data with.</param>
94+
/// <param name="data">The data to encrypt.</param>
95+
/// <exception cref="FileNotFoundException">Thrown when the PEM file does not exist.</exception>
96+
public static byte[] Encrypt(string pemFile, byte[] data)
97+
{
98+
if (!LoadPemFile(pemFile, out PemUser pem))
99+
{
100+
throw new FileNotFoundException("PEM file not found.", pemFile);
101+
}
102+
103+
return pem.Encoder.Value.ProcessBlock(data, 0, data.Length);
104+
}
105+
106+
/// <summary>
107+
/// Decrypts the data with the private key. The data should have been encrypted with the matching
108+
/// public key.
109+
/// </summary>
110+
/// <param name="keyFile">The key file containing the private key to decrypt the data with.</param>
111+
/// <param name="data">The data to decrypt.</param>
112+
public static byte[] Decrypt(string keyFile, byte[] data)
113+
{
114+
if (!LoadPemFile(keyFile, out PemUser pem))
115+
{
116+
throw new FileNotFoundException("Key file not found.", keyFile);
117+
}
118+
if (!pem.CanDecrypt)
119+
{
120+
throw new Exception($"The key file '{keyFile}' does not contain a private key, decryption is unavailable.");
121+
}
122+
123+
return pem.Decoder.Value.ProcessBlock(data, 0, data.Length);
124+
}
125+
126+
public static void Invalidate()
127+
{
128+
pemCache.Clear();
129+
}
130+
131+
/// <summary>
132+
/// Loads a file that can either contain a private key (usually .key) or public key (usually .pem).
133+
/// Multi-content PEM data is not supported.
134+
/// </summary>
135+
private static bool LoadPemFile(string pemFile, out PemUser pemUser)
136+
{
137+
pemFile = Path.GetFullPath(pemFile);
138+
if (!pemCache.ContainsKey(pemFile))
139+
{
140+
if (!File.Exists(pemFile))
141+
{
142+
pemUser = default;
143+
return false;
144+
}
145+
146+
pemUser = pemCache[pemFile] = new PemUser(pemFile);
147+
return true;
148+
}
149+
150+
pemUser = pemCache[pemFile];
151+
return true;
152+
}
153+
154+
private readonly record struct PemUser
155+
{
156+
public Lazy<OaepEncoding> Encoder { get; }
157+
public Lazy<OaepEncoding> Decoder { get; }
158+
159+
public bool CanDecrypt => Decoder != null;
160+
161+
public RsaPrivateCrtKeyParameters PrivateKey { get; }
162+
public RsaKeyParameters PublicKey { get; }
163+
164+
public PemUser(string pemFile)
165+
{
166+
string pemContent = File.ReadAllText(pemFile);
167+
if (string.IsNullOrWhiteSpace(pemContent))
168+
{
169+
throw new Exception($"Invalid PEM data in file '{pemFile}'");
170+
}
171+
172+
using StringReader stringReader = new(pemContent);
173+
using PemReader reader = new(stringReader);
174+
object pemObject = reader.ReadObject();
175+
switch (pemObject)
176+
{
177+
case RsaPrivateCrtKeyParameters privatePem:
178+
PrivateKey = privatePem;
179+
PublicKey = new(false, privatePem.Modulus, privatePem.PublicExponent);
180+
break;
181+
case RsaKeyParameters publicPem:
182+
PublicKey = publicPem;
183+
break;
184+
default:
185+
throw new NotSupportedException($"Unsupported PEM file '{pemFile}'");
186+
}
187+
188+
RsaKeyParameters publicKey = PublicKey;
189+
Encoder = new Lazy<OaepEncoding>(() =>
190+
{
191+
OaepEncoding encoder = CreateEncoder();
192+
encoder.Init(true, publicKey);
193+
return encoder;
194+
});
195+
RsaKeyParameters privateKey = PrivateKey;
196+
Decoder = new Lazy<OaepEncoding>(() =>
197+
{
198+
OaepEncoding encoder = CreateEncoder();
199+
encoder.Init(false, privateKey);
200+
return encoder;
201+
});
202+
}
203+
204+
private static OaepEncoding CreateEncoder()
205+
{
206+
return new(new RsaEngine(), new Sha256Digest(), new Sha256Digest(), null);
207+
}
208+
}
209+
}

0 commit comments

Comments
 (0)