Support rotation of master key.

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)
This commit is contained in:
eelke 2026-02-24 06:32:58 +01:00
parent 4201d0240d
commit 644b005f2a
19 changed files with 259 additions and 72 deletions

View file

@ -1,6 +1,5 @@
using System.Security.Cryptography;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Security;
namespace IdentityShroud.Core.Services;
@ -17,16 +16,21 @@ public class EncryptionService : IEncryptionService
new (12, 16), // version 1
];
private readonly byte[] _encryptionKey;
// 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)
{
_encryptionKey = Convert.FromBase64String(secretProvider.GetSecret("Master"));
if (_encryptionKey.Length != 32) // 256bit key
throw new Exception("Key must be 256bits (32 bytes) for AES256GCM.");
_encryptionKeys = secretProvider.GetKeys("master");
// if (_encryptionKey.Length != 32) // 256bit key
// throw new Exception("Key must be 256bits (32 bytes) for AES256GCM.");
}
public byte[] Encrypt(ReadOnlyMemory<byte> plaintext)
public EncryptedValue Encrypt(ReadOnlyMemory<byte> plaintext)
{
const int versionNumber = 1;
AlgVersion versionParams = _versions[versionNumber];
@ -44,26 +48,21 @@ public class EncryptionService : IEncryptionService
// use the spans to place the data directly in its place
RandomNumberGenerator.Fill(nonce);
using var aes = new AesGcm(_encryptionKey, versionParams.TagSize);
var encryptionKey = ActiveKey;
using var aes = new AesGcm(encryptionKey.Key, versionParams.TagSize);
aes.Encrypt(nonce, plaintext.Span, cipher, tag);
return result;
return new (encryptionKey.Id, result);
}
public byte[] Decrypt(ReadOnlyMemory<byte> input)
public byte[] Decrypt(EncryptedValue input)
{
// ----------------------------------------------------------------
// 1⃣ Extract the three components.
// ----------------------------------------------------------------
// AesGcm.NonceByteSizes.MaxSize = 12 bytes (standard GCM nonce length)
// AesGcm.TagByteSizes.MaxSize = 16 bytes (128bit authentication tag)
//int nonceSize = AesGcm.NonceByteSizes.MaxSize; // 12
//int tagSize = AesGcm.TagByteSizes.MaxSize; // 16
var payload = input.Span;
var encryptionKey = GetKey(input.KeyId);
var payload = input.Value.AsSpan();
int versionNumber = (int)payload[0];
if (versionNumber != 1)
throw new ArgumentException("Invalid payloag");
throw new ArgumentException("Invalid payload");
AlgVersion versionParams = _versions[versionNumber];
@ -77,7 +76,7 @@ public class EncryptionService : IEncryptionService
byte[] plaintext = new byte[cipher.Length];
using var aes = new AesGcm(_encryptionKey, versionParams.TagSize);
using var aes = new AesGcm(encryptionKey.Key, versionParams.TagSize);
try
{
aes.Decrypt(nonce, cipher, tag, plaintext);