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,179 @@
using System.Net;
using System.Net.Http.Json;
using IdentityShroud.Core;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Tests.Fixtures;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace IdentityShroud.Api.Tests.Apis;
public class ClientApiTests : IClassFixture<ApplicationFactory>
{
private readonly ApplicationFactory _factory;
public ClientApiTests(ApplicationFactory factory)
{
_factory = factory;
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<Db>();
if (!db.Database.EnsureCreated())
{
db.Database.ExecuteSqlRaw("TRUNCATE realm CASCADE;");
}
}
[Theory]
[InlineData(null, false, "ClientId")]
[InlineData("", false, "ClientId")]
[InlineData("my-client", true, "")]
public async Task Create_Validation(string? clientId, bool succeeds, string fieldName)
{
// setup
Realm realm = await CreateRealmAsync("test-realm", "Test Realm");
var client = _factory.CreateClient();
// act
var response = await client.PostAsync(
$"/api/v1/realms/{realm.Id}/clients",
JsonContent.Create(new { ClientId = clientId }),
TestContext.Current.CancellationToken);
#if DEBUG
string contents = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
#endif
if (succeeds)
{
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
}
else
{
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var problemDetails =
await response.Content.ReadFromJsonAsync<ValidationProblemDetails>(
TestContext.Current.CancellationToken);
Assert.Contains(problemDetails!.Errors, e => e.Key == fieldName);
}
}
[Fact]
public async Task Create_Success_ReturnsCreatedWithLocation()
{
// setup
Realm realm = await CreateRealmAsync("create-realm", "Create Realm");
var client = _factory.CreateClient();
// act
var response = await client.PostAsync(
$"/api/v1/realms/{realm.Id}/clients",
JsonContent.Create(new { ClientId = "new-client", Name = "New Client" }),
TestContext.Current.CancellationToken);
#if DEBUG
string contents = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
#endif
// verify
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<ClientCreateReponse>(
TestContext.Current.CancellationToken);
Assert.NotNull(body);
Assert.Equal("new-client", body.ClientId);
Assert.True(body.Id > 0);
}
[Fact]
public async Task Create_UnknownRealm_ReturnsNotFound()
{
var client = _factory.CreateClient();
var response = await client.PostAsync(
$"/api/v1/realms/{Guid.NewGuid()}/clients",
JsonContent.Create(new { ClientId = "some-client" }),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task Get_Success()
{
// setup
Realm realm = await CreateRealmAsync("get-realm", "Get Realm");
Client dbClient = await CreateClientAsync(realm, "get-client", "Get Client");
var httpClient = _factory.CreateClient();
// act
var response = await httpClient.GetAsync(
$"/api/v1/realms/{realm.Id}/clients/{dbClient.Id}",
TestContext.Current.CancellationToken);
#if DEBUG
string contents = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
#endif
// verify
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<ClientRepresentation>(
TestContext.Current.CancellationToken);
Assert.NotNull(body);
Assert.Equal(dbClient.Id, body.Id);
Assert.Equal("get-client", body.ClientId);
Assert.Equal("Get Client", body.Name);
Assert.Equal(realm.Id, body.RealmId);
}
[Fact]
public async Task Get_UnknownClient_ReturnsNotFound()
{
// setup
Realm realm = await CreateRealmAsync("notfound-realm", "NotFound Realm");
var httpClient = _factory.CreateClient();
// act
var response = await httpClient.GetAsync(
$"/api/v1/realms/{realm.Id}/clients/99999",
TestContext.Current.CancellationToken);
// verify
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
private async Task<Realm> CreateRealmAsync(string slug, string name)
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<Db>();
var realm = new Realm { Slug = slug, Name = name };
db.Realms.Add(realm);
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
return realm;
}
private async Task<Client> CreateClientAsync(Realm realm, string clientId, string? name = null)
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<Db>();
var client = new Client
{
RealmId = realm.Id,
ClientId = clientId,
Name = name,
CreatedAt = DateTime.UtcNow,
};
db.Clients.Add(client);
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
return client;
}
}

View file

@ -44,7 +44,9 @@ public class RealmApisTests : IClassFixture<ApplicationFactory>
var client = _factory.CreateClient();
Guid? inputId = id is null ? (Guid?)null : new Guid(id);
var response = await client.PostAsync("/realms", JsonContent.Create(new
// act
var response = await client.PostAsync("/api/v1/realms", JsonContent.Create(new
{
Id = inputId,
Slug = slug,
@ -88,16 +90,21 @@ public class RealmApisTests : IClassFixture<ApplicationFactory>
// act
var client = _factory.CreateClient();
var response = await client.GetAsync("/realms/foo/.well-known/openid-configuration",
var response = await client.GetAsync("auth/realms/foo/.well-known/openid-configuration",
TestContext.Current.CancellationToken);
// verify
#if DEBUG
string contents = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
#endif
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<JsonObject>(TestContext.Current.CancellationToken);
Assert.NotNull(result);
JsonObjectAssert.Equal("http://localhost/realms/foo/openid-connect/auth", result, "authorization_endpoint");
JsonObjectAssert.Equal("http://localhost/realms/foo", result, "issuer");
JsonObjectAssert.Equal("http://localhost/realms/foo/openid-connect/token", result, "token_endpoint");
JsonObjectAssert.Equal("http://localhost/realms/foo/openid-connect/jwks", result, "jwks_uri");
JsonObjectAssert.Equal("http://localhost/auth/realms/foo/openid-connect/auth", result, "authorization_endpoint");
JsonObjectAssert.Equal("http://localhost/auth/realms/foo", result, "issuer");
JsonObjectAssert.Equal("http://localhost/auth/realms/foo/openid-connect/token", result, "token_endpoint");
JsonObjectAssert.Equal("http://localhost/auth/realms/foo/openid-connect/jwks", result, "jwks_uri");
}
[Theory]
@ -107,7 +114,7 @@ public class RealmApisTests : IClassFixture<ApplicationFactory>
{
// act
var client = _factory.CreateClient();
var response = await client.GetAsync("/realms/bar/.well-known/openid-configuration",
var response = await client.GetAsync($"/realms/{slug}/.well-known/openid-configuration",
TestContext.Current.CancellationToken);
// verify
@ -118,34 +125,35 @@ public class RealmApisTests : IClassFixture<ApplicationFactory>
public async Task GetJwks()
{
// setup
IEncryptionService encryptionService = _factory.Services.GetRequiredService<IEncryptionService>();
IDekEncryptionService dekEncryptionService = _factory.Services.GetRequiredService<IDekEncryptionService>();
using var rsa = RSA.Create(2048);
RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false);
Key key = new()
RealmKey realmKey = new()
{
Id = Guid.NewGuid(),
KeyType = "RSA",
Key = dekEncryptionService.Encrypt(rsa.ExportPkcs8PrivateKey()),
CreatedAt = DateTime.UtcNow,
};
key.SetPrivateKey(encryptionService, rsa.ExportPkcs8PrivateKey());
await ScopedContextAsync(async db =>
{
db.Realms.Add(new Realm() { Slug = "foo", Name = "Foo", Keys = [ key ]});
db.Realms.Add(new Realm() { Slug = "foo", Name = "Foo", Keys = [ realmKey ]});
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
});
// act
var client = _factory.CreateClient();
var response = await client.GetAsync("/realms/foo/openid-connect/jwks",
var response = await client.GetAsync("/auth/realms/foo/openid-connect/jwks",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
JsonObject? payload = await response.Content.ReadFromJsonAsync<JsonObject>(TestContext.Current.CancellationToken);
Assert.NotNull(payload);
JsonObjectAssert.Equal(key.Id.ToString(), payload, "keys[0].kid");
JsonObjectAssert.Equal(realmKey.Id.ToString(), payload, "keys[0].kid");
JsonObjectAssert.Equal(WebEncoders.Base64UrlEncode(parameters.Modulus!), payload, "keys[0].n");
JsonObjectAssert.Equal(WebEncoders.Base64UrlEncode(parameters.Exponent!), payload, "keys[0].e");
}

View file

@ -1,11 +1,6 @@
using IdentityShroud.Core.Services;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.VisualStudio.TestPlatform.TestHost;
using Npgsql;
using Testcontainers.PostgreSql;
namespace IdentityShroud.Core.Tests.Fixtures;
@ -33,7 +28,10 @@ public class ApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
new Dictionary<string, string?>
{
["Db:ConnectionString"] = _postgresqlServer.GetConnectionString(),
["Encryption:Master"] = "GVd07qW0frRX9quPX/X62L88BeRR7+IzgRJHtG7ZzHw=",
["secrets:master:0:Id"] = "94970f27-3d88-4223-9940-7dd57548f5b5",
["secrets:master:0:Active"] = "true",
["secrets:master:0:Algorithm"] = "AES",
["secrets:master:0:Key"] = "GVd07qW0frRX9quPX/X62L88BeRR7+IzgRJHtG7ZzHw=",
});
});

View file

@ -1,41 +0,0 @@
using System.Security.Cryptography;
using IdentityShroud.Api.Mappers;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Messages;
using IdentityShroud.Core.Model;
using IdentityShroud.TestUtils.Substitutes;
using Microsoft.AspNetCore.WebUtilities;
namespace IdentityShroud.Api.Tests.Mappers;
public class KeyMapperTests
{
private readonly IEncryptionService _encryptionService = EncryptionServiceSubstitute.CreatePassthrough();
[Fact]
public void Test()
{
// Setup
using RSA rsa = RSA.Create(2048);
RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false);
Key key = new()
{
Id = new("60bb79cf-4bac-4521-87f2-ac87cc15541f"),
CreatedAt = DateTime.UtcNow,
Priority = 10,
};
key.SetPrivateKey(_encryptionService, rsa.ExportPkcs8PrivateKey());
// Act
KeyMapper mapper = new(_encryptionService);
JsonWebKey jwk = mapper.KeyToJsonWebKey(key);
Assert.Equal("RSA", jwk.KeyType);
Assert.Equal(key.Id.ToString(), jwk.KeyId);
Assert.Equal("sig", jwk.Use);
Assert.Equal(parameters.Exponent, WebEncoders.Base64UrlDecode(jwk.Exponent));
Assert.Equal(parameters.Modulus, WebEncoders.Base64UrlDecode(jwk.Modulus));
}
}

View file

@ -0,0 +1,46 @@
using System.Buffers.Text;
using System.Security.Cryptography;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security;
using IdentityShroud.Core.Security.Keys;
using IdentityShroud.Core.Services;
using IdentityShroud.TestUtils.Substitutes;
namespace IdentityShroud.Api.Tests.Mappers;
public class KeyServiceTests
{
private readonly NullDekEncryptionService _dekEncryptionService = new();
[Fact]
public void Test()
{
// Setup
using RSA rsa = RSA.Create(2048);
RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false);
DekId kid = DekId.NewId();
RealmKey realmKey = new()
{
Id = new("60bb79cf-4bac-4521-87f2-ac87cc15541f"),
KeyType = "RSA",
Key = new(_dekEncryptionService.KeyId, rsa.ExportPkcs8PrivateKey()),
CreatedAt = DateTime.UtcNow,
Priority = 10,
};
// Act
KeyService sut = new(_dekEncryptionService, new KeyProviderFactory(), new ClockService());
var jwk = sut.CreateJsonWebKey(realmKey);
Assert.NotNull(jwk);
Assert.Equal("RSA", jwk.KeyType);
Assert.Equal(realmKey.Id.ToString(), jwk.KeyId);
Assert.Equal("sig", jwk.Use);
Assert.Equal(parameters.Exponent, Base64Url.DecodeFromChars(jwk.Exponent));
Assert.Equal(parameters.Modulus, Base64Url.DecodeFromChars(jwk.Modulus));
}
}