Encrypt realm data with dek which is encrypted with kek. The signing keys are also encrypted with the kek.

This commit is contained in:
eelke 2026-02-26 16:53:02 +01:00
parent 644b005f2a
commit 650fe99990
36 changed files with 399 additions and 129 deletions

View file

@ -7,7 +7,7 @@ namespace IdentityShroud.Core.Services;
public class ClientService(
Db db,
IEncryptionService cryptor,
IDataEncryptionService cryptor,
IClock clock) : IClientService
{
public async Task<Result<Client>> Create(Guid realmId, ClientCreateRequest request, CancellationToken ct = default)
@ -52,12 +52,13 @@ public class ClientService(
private ClientSecret CreateSecret()
{
byte[] secret = RandomNumberGenerator.GetBytes(24);
Span<byte> secret = stackalloc byte[24];
RandomNumberGenerator.Fill(secret);
return new ClientSecret()
{
CreatedAt = clock.UtcNow(),
Secret = cryptor.Encrypt(secret),
Secret = cryptor.Encrypt(secret.ToArray()),
};
}

View file

@ -0,0 +1,41 @@
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security;
namespace IdentityShroud.Core.Services;
public class DataEncryptionService(
IRealmContext realmContext,
IDekEncryptionService dekCryptor) : IDataEncryptionService
{
// 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 IList<RealmDek>? _deks = null;
private IList<RealmDek> GetDeks()
{
if (_deks is null)
_deks = realmContext.GetDeks().Result;
return _deks;
}
private RealmDek GetActiveDek() => GetDeks().Single(d => d.Active);
private RealmDek GetKey(DekId id) => GetDeks().Single(d => d.Id == id);
public byte[] Decrypt(EncryptedValue input)
{
var dek = GetKey(input.DekId);
var key = dekCryptor.Decrypt(dek.KeyData);
return Encryption.Decrypt(input.Value, key);
}
public EncryptedValue Encrypt(ReadOnlyMemory<byte> plain)
{
var dek = GetActiveDek();
var key = dekCryptor.Decrypt(dek.KeyData);
byte[] cipher = Encryption.Encrypt(plain, key);
return new (dek.Id, cipher);
}
}

View file

@ -0,0 +1,38 @@
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Security;
namespace IdentityShroud.Core.Services;
/// <summary>
///
/// </summary>
public class DekEncryptionService : IDekEncryptionService
{
// 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 KeyEncryptionKey[] _encryptionKeys;
private KeyEncryptionKey ActiveKey => _encryptionKeys.Single(k => k.Active);
private KeyEncryptionKey GetKey(KekId keyId) => _encryptionKeys.Single(k => k.Id == keyId);
public DekEncryptionService(ISecretProvider secretProvider)
{
_encryptionKeys = secretProvider.GetKeys("master");
// if (_encryptionKey.Length != 32) // 256bit key
// throw new Exception("Key must be 256bits (32 bytes) for AES256GCM.");
}
public EncryptedDek Encrypt(ReadOnlyMemory<byte> plaintext)
{
var encryptionKey = ActiveKey;
byte[] cipher = Encryption.Encrypt(plaintext, encryptionKey.Key);
return new (encryptionKey.Id, cipher);
}
public byte[] Decrypt(EncryptedDek input)
{
var encryptionKey = GetKey(input.KekId);
return Encryption.Decrypt(input.Value, encryptionKey.Key);
}
}

View file

@ -1,92 +0,0 @@
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) // 256bit key
// throw new Exception("Key must be 256bits (32 bytes) for AES256GCM.");
}
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;
}
}

View file

@ -6,7 +6,7 @@ using IdentityShroud.Core.Security.Keys;
namespace IdentityShroud.Core.Services;
public class KeyService(
IEncryptionService cryptor,
IDekEncryptionService cryptor,
IKeyProviderFactory keyProviderFactory,
IClock clock) : IKeyService
{

View file

@ -0,0 +1,26 @@
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Model;
using Microsoft.AspNetCore.Http;
namespace IdentityShroud.Core.Services;
public class RealmContext(
IHttpContextAccessor accessor,
IRealmService realmService) : IRealmContext
{
public Realm GetRealm()
{
return (Realm)accessor.HttpContext.Items["RealmEntity"];
}
public async Task<IList<RealmDek>> GetDeks(CancellationToken ct = default)
{
Realm realm = GetRealm();
if (realm.Deks.Count == 0)
{
await realmService.LoadDeks(realm);
}
return realm.Deks;
}
}

View file

@ -58,6 +58,12 @@ public class RealmService(
.Query()
.Where(k => k.RevokedAt == null)
.LoadAsync();
}
public async Task LoadDeks(Realm realm)
{
await db.Entry(realm).Collection(r => r.Deks)
.Query()
.LoadAsync();
}
}