The EncryptionService now loads a set of keys and uses the active one to encrypt and selects key based on keyid during decryption. Introduced EncryptedValue to hold keyId and encrypted data. (There are no intermeddiate keys yet)
92 lines
No EOL
3.5 KiB
C#
92 lines
No EOL
3.5 KiB
C#
using System.Security.Cryptography;
|
||
using IdentityShroud.Core.Contracts;
|
||
|
||
namespace IdentityShroud.Core.Services;
|
||
|
||
/// <summary>
|
||
///
|
||
/// </summary>
|
||
public class EncryptionService : IEncryptionService
|
||
{
|
||
private record struct AlgVersion(int NonceSize, int TagSize);
|
||
|
||
private AlgVersion[] _versions =
|
||
[
|
||
new(0, 0), // version 0 does not realy exist
|
||
new (12, 16), // version 1
|
||
];
|
||
|
||
// Note this array is expected to have one item in it most of the during key rotation it will have two
|
||
// until it is ensured the old key can safely be removed. More then two will work but is not really expected.
|
||
private readonly EncryptionKey[] _encryptionKeys;
|
||
|
||
private EncryptionKey ActiveKey => _encryptionKeys.Single(k => k.Active);
|
||
private EncryptionKey GetKey(string keyId) => _encryptionKeys.Single(k => k.Id == keyId);
|
||
|
||
public EncryptionService(ISecretProvider secretProvider)
|
||
{
|
||
_encryptionKeys = secretProvider.GetKeys("master");
|
||
// if (_encryptionKey.Length != 32) // 256‑bit key
|
||
// throw new Exception("Key must be 256 bits (32 bytes) for AES‑256‑GCM.");
|
||
}
|
||
|
||
public EncryptedValue Encrypt(ReadOnlyMemory<byte> plaintext)
|
||
{
|
||
const int versionNumber = 1;
|
||
AlgVersion versionParams = _versions[versionNumber];
|
||
|
||
int resultSize = 1 + versionParams.NonceSize + versionParams.TagSize + plaintext.Length;
|
||
// allocate buffer for complete response
|
||
var result = new byte[resultSize];
|
||
|
||
result[0] = (byte)versionNumber;
|
||
|
||
// make the spans that point to the parts of the result where their data is located
|
||
var nonce = result.AsSpan(1, versionParams.NonceSize);
|
||
var tag = result.AsSpan(1 + versionParams.NonceSize, versionParams.TagSize);
|
||
var cipher = result.AsSpan(1 + versionParams.NonceSize + versionParams.TagSize);
|
||
|
||
// use the spans to place the data directly in its place
|
||
RandomNumberGenerator.Fill(nonce);
|
||
var encryptionKey = ActiveKey;
|
||
using var aes = new AesGcm(encryptionKey.Key, versionParams.TagSize);
|
||
aes.Encrypt(nonce, plaintext.Span, cipher, tag);
|
||
|
||
return new (encryptionKey.Id, result);
|
||
}
|
||
|
||
public byte[] Decrypt(EncryptedValue input)
|
||
{
|
||
var encryptionKey = GetKey(input.KeyId);
|
||
|
||
var payload = input.Value.AsSpan();
|
||
int versionNumber = (int)payload[0];
|
||
if (versionNumber != 1)
|
||
throw new ArgumentException("Invalid payload");
|
||
|
||
AlgVersion versionParams = _versions[versionNumber];
|
||
|
||
|
||
if (payload.Length < 1 + versionParams.NonceSize + versionParams.TagSize)
|
||
throw new ArgumentException("Payload is too short to contain nonce, ciphertext, and tag.", nameof(payload));
|
||
|
||
ReadOnlySpan<byte> nonce = payload.Slice(1, versionParams.NonceSize);
|
||
ReadOnlySpan<byte> tag = payload.Slice(1 + versionParams.NonceSize, versionParams.TagSize);
|
||
ReadOnlySpan<byte> cipher = payload.Slice(1 + versionParams.NonceSize + versionParams.TagSize);
|
||
|
||
byte[] plaintext = new byte[cipher.Length];
|
||
|
||
using var aes = new AesGcm(encryptionKey.Key, versionParams.TagSize);
|
||
try
|
||
{
|
||
aes.Decrypt(nonce, cipher, tag, plaintext);
|
||
}
|
||
catch (CryptographicException ex)
|
||
{
|
||
// Tag verification failed → tampering or wrong key/nonce.
|
||
throw new InvalidOperationException("Decryption failed – authentication tag mismatch.", ex);
|
||
}
|
||
|
||
return plaintext;
|
||
}
|
||
} |