feat(infrastructure): implement RsaKeyService with tests

This commit is contained in:
Joseph Doherty
2026-01-03 08:16:13 -05:00
parent 4e1523b0c0
commit 97ef3e4345
2 changed files with 146 additions and 0 deletions
@@ -0,0 +1,50 @@
// NEW/src/JdeScoping.Infrastructure/Security/RsaKeyService.cs
using System.Security.Cryptography;
using JdeScoping.Core.Interfaces;
namespace JdeScoping.Infrastructure.Security;
/// <summary>
/// RSA key service that auto-generates and persists keys.
/// </summary>
public class RsaKeyService : IRsaKeyService, IDisposable
{
private readonly RSA _rsa;
/// <summary>
/// Creates a new RSA key service.
/// </summary>
/// <param name="keyFilePath">Path to persist the private key</param>
public RsaKeyService(string keyFilePath)
{
_rsa = RSA.Create(2048);
if (File.Exists(keyFilePath))
{
var keyBytes = File.ReadAllBytes(keyFilePath);
_rsa.ImportRSAPrivateKey(keyBytes, out _);
}
else
{
var privateKey = _rsa.ExportRSAPrivateKey();
var directory = Path.GetDirectoryName(keyFilePath);
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
File.WriteAllBytes(keyFilePath, privateKey);
}
}
/// <inheritdoc />
public string GetPublicKeyPem()
=> _rsa.ExportSubjectPublicKeyInfoPem();
/// <inheritdoc />
public byte[] Decrypt(byte[] ciphertext)
=> _rsa.Decrypt(ciphertext, RSAEncryptionPadding.OaepSHA256);
public void Dispose()
{
_rsa.Dispose();
GC.SuppressFinalize(this);
}
}
@@ -0,0 +1,96 @@
// NEW/tests/JdeScoping.Infrastructure.Tests/Security/RsaKeyServiceTests.cs
using JdeScoping.Core.Interfaces;
using JdeScoping.Infrastructure.Security;
using Shouldly;
namespace JdeScoping.Infrastructure.Tests.Security;
public class RsaKeyServiceTests : IDisposable
{
private readonly string _testKeyPath;
public RsaKeyServiceTests()
{
_testKeyPath = Path.Combine(Path.GetTempPath(), $"test-rsa-key-{Guid.NewGuid()}.bin");
}
public void Dispose()
{
if (File.Exists(_testKeyPath))
File.Delete(_testKeyPath);
}
[Fact]
public void GetPublicKeyPem_ReturnsValidPemFormat()
{
// Arrange
var service = new RsaKeyService(_testKeyPath);
// Act
var pem = service.GetPublicKeyPem();
// Assert
pem.ShouldStartWith("-----BEGIN PUBLIC KEY-----");
pem.ShouldEndWith("-----END PUBLIC KEY-----");
}
[Fact]
public void Constructor_WhenKeyFileMissing_GeneratesAndPersistsKey()
{
// Arrange - ensure file doesn't exist
File.Exists(_testKeyPath).ShouldBeFalse();
// Act
_ = new RsaKeyService(_testKeyPath);
// Assert
File.Exists(_testKeyPath).ShouldBeTrue();
new FileInfo(_testKeyPath).Length.ShouldBeGreaterThan(0);
}
[Fact]
public void Constructor_WhenKeyFileExists_LoadsSameKey()
{
// Arrange - create service to generate key
var service1 = new RsaKeyService(_testKeyPath);
var publicKey1 = service1.GetPublicKeyPem();
// Act - create new service instance
var service2 = new RsaKeyService(_testKeyPath);
var publicKey2 = service2.GetPublicKeyPem();
// Assert - same key loaded
publicKey2.ShouldBe(publicKey1);
}
[Fact]
public void Decrypt_WithValidCiphertext_ReturnsPlaintext()
{
// Arrange
var service = new RsaKeyService(_testKeyPath);
var plaintext = "Hello, World!"u8.ToArray();
// Encrypt using public key (simulating what client does)
using var rsa = System.Security.Cryptography.RSA.Create();
rsa.ImportFromPem(service.GetPublicKeyPem());
var ciphertext = rsa.Encrypt(plaintext, System.Security.Cryptography.RSAEncryptionPadding.OaepSHA256);
// Act
var decrypted = service.Decrypt(ciphertext);
// Assert
decrypted.ShouldBe(plaintext);
}
[Fact]
public void Decrypt_WithInvalidCiphertext_ThrowsCryptographicException()
{
// Arrange
var service = new RsaKeyService(_testKeyPath);
var invalidCiphertext = new byte[] { 1, 2, 3, 4, 5 };
// Act & Assert
Should.Throw<System.Security.Cryptography.CryptographicException>(
() => service.Decrypt(invalidCiphertext));
}
}