5-improve-encrypted-storage (#6)

Added the use of DEK's for encryption of secrets. Both the KEK's and DEK's are stored in a way that you can have multiple key of which one is active. But the others are still available for decrypting. This allows for implementing key rotation.

Co-authored-by: eelke <eelke@eelkeklein.nl>
Co-authored-by: Eelke76 <31384324+Eelke76@users.noreply.github.com>
Reviewed-on: #6
This commit is contained in:
eelke 2026-02-27 17:57:42 +00:00
parent 138f335af0
commit 07393f57fc
87 changed files with 1903 additions and 533 deletions

View file

@ -0,0 +1,65 @@
using System.Security.Cryptography;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Model;
using Microsoft.EntityFrameworkCore;
namespace IdentityShroud.Core.Services;
public class ClientService(
Db db,
IDataEncryptionService cryptor,
IClock clock) : IClientService
{
public async Task<Result<Client>> Create(Guid realmId, ClientCreateRequest request, CancellationToken ct = default)
{
Client client = new()
{
RealmId = realmId,
ClientId = request.ClientId,
Name = request.Name,
Description = request.Description,
SignatureAlgorithm = request.SignatureAlgorithm,
AllowClientCredentialsFlow = request.AllowClientCredentialsFlow ?? false,
CreatedAt = clock.UtcNow(),
};
if (client.AllowClientCredentialsFlow)
{
client.Secrets.Add(CreateSecret());
}
await db.AddAsync(client, ct);
await db.SaveChangesAsync(ct);
return client;
}
public async Task<Client?> GetByClientId(
Guid realmId,
string clientId,
CancellationToken ct = default)
{
return await db.Clients.FirstOrDefaultAsync(c => c.ClientId == clientId && c.RealmId == realmId, ct);
}
public async Task<Client?> FindById(
Guid realmId,
int id,
CancellationToken ct = default)
{
return await db.Clients.FirstOrDefaultAsync(c => c.Id == id && c.RealmId == realmId, ct);
}
private ClientSecret CreateSecret()
{
Span<byte> secret = stackalloc byte[24];
RandomNumberGenerator.Fill(secret);
return new ClientSecret()
{
CreatedAt = clock.UtcNow(),
Secret = cryptor.Encrypt(secret.ToArray()),
};
}
}

View file

@ -0,0 +1,11 @@
using IdentityShroud.Core.Contracts;
namespace IdentityShroud.Core.Services;
public class ClockService : IClock
{
public DateTime UtcNow()
{
return DateTime.UtcNow;
}
}

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(ReadOnlySpan<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(ReadOnlySpan<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,27 +0,0 @@
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Security;
namespace IdentityShroud.Core.Services;
/// <summary>
///
/// </summary>
public class EncryptionService : IEncryptionService
{
private readonly byte[] encryptionKey;
public EncryptionService(ISecretProvider secretProvider)
{
encryptionKey = Convert.FromBase64String(secretProvider.GetSecret("Master"));
}
public byte[] Encrypt(byte[] plain)
{
return AesGcmHelper.EncryptAesGcm(plain, encryptionKey);
}
public byte[] Decrypt(byte[] cipher)
{
return AesGcmHelper.DecryptAesGcm(cipher, encryptionKey);
}
}

View file

@ -1,12 +0,0 @@
using IdentityShroud.Core.Messages.Realm;
using IdentityShroud.Core.Model;
namespace IdentityShroud.Core.Services;
public interface IRealmService
{
Task<Realm?> FindBySlug(string slug, CancellationToken ct = default);
Task<Result<RealmCreateResponse>> Create(RealmCreateRequest request, CancellationToken ct = default);
Task LoadActiveKeys(Realm realm);
}

View file

@ -0,0 +1,46 @@
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Messages;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security.Keys;
namespace IdentityShroud.Core.Services;
public class KeyService(
IDekEncryptionService cryptor,
IKeyProviderFactory keyProviderFactory,
IClock clock) : IKeyService
{
public RealmKey CreateKey(KeyPolicy policy)
{
IKeyProvider provider = keyProviderFactory.CreateProvider(policy.KeyType);
var plainKey = provider.CreateKey(policy);
return CreateKey(policy.KeyType, plainKey);
}
public JsonWebKey? CreateJsonWebKey(RealmKey realmKey)
{
JsonWebKey jwk = new()
{
KeyId = realmKey.Id.ToString(),
KeyType = realmKey.KeyType,
Use = "sig",
};
IKeyProvider provider = keyProviderFactory.CreateProvider(realmKey.KeyType);
provider.SetJwkParameters(
cryptor.Decrypt(realmKey.Key),
jwk);
return jwk;
}
private RealmKey CreateKey(string keyType, byte[] plainKey) =>
new RealmKey()
{
Id = Guid.NewGuid(),
KeyType = keyType,
Key = cryptor.Encrypt(plainKey),
CreatedAt = clock.UtcNow(),
};
}

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

@ -1,8 +1,9 @@
using System.Security.Cryptography;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Helpers;
using IdentityShroud.Core.Messages.Realm;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security.Keys;
using IdentityShroud.Core.Security.Keys.Rsa;
using Microsoft.EntityFrameworkCore;
namespace IdentityShroud.Core.Services;
@ -11,8 +12,14 @@ public record RealmCreateResponse(Guid Id, string Slug, string Name);
public class RealmService(
Db db,
IEncryptionService encryptionService) : IRealmService
IKeyService keyService) : IRealmService
{
public async Task<Realm?> FindById(Guid id, CancellationToken ct = default)
{
return await db.Realms
.SingleOrDefaultAsync(r => r.Id == id, ct);
}
public async Task<Realm?> FindBySlug(string slug, CancellationToken ct = default)
{
return await db.Realms
@ -26,8 +33,9 @@ public class RealmService(
Id = request.Id ?? Guid.CreateVersion7(),
Slug = request.Slug ?? SlugHelper.GenerateSlug(request.Name),
Name = request.Name,
Keys = [ CreateKey() ],
};
realm.Keys.Add(keyService.CreateKey(GetKeyPolicy(realm)));
db.Add(realm);
await db.SaveChangesAsync(ct);
@ -36,25 +44,26 @@ public class RealmService(
realm.Id, realm.Slug, realm.Name);
}
/// <summary>
/// Place holder for getting policies from the realm and falling back to sane defaults when no policies have been set.
/// </summary>
/// <param name="_"></param>
/// <returns></returns>
private KeyPolicy GetKeyPolicy(Realm _) => new RsaKeyPolicy();
public async Task LoadActiveKeys(Realm realm)
{
await db.Entry(realm).Collection(r => r.Keys)
.Query()
.Where(k => k.DeactivatedAt == null)
.Where(k => k.RevokedAt == null)
.LoadAsync();
}
private Key CreateKey()
public async Task LoadDeks(Realm realm)
{
using RSA rsa = RSA.Create(2048);
Key key = new()
{
Priority = 10,
};
key.SetPrivateKey(encryptionService, rsa.ExportPkcs8PrivateKey());
return key;
await db.Entry(realm).Collection(r => r.Deks)
.Query()
.LoadAsync();
}
}