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,73 @@
using FluentResults;
using IdentityShroud.Api.Mappers;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Model;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
namespace IdentityShroud.Api;
public record ClientCreateReponse(int Id, string ClientId);
/// <summary>
/// The part of the api below realms/{slug}/clients
/// </summary>
public static class ClientApi
{
public const string ClientGetRouteName = "ClientGet";
public static void MapEndpoints(this IEndpointRouteBuilder erp)
{
RouteGroupBuilder clientsGroup = erp.MapGroup("clients");
clientsGroup.MapPost("", ClientCreate)
.Validate<ClientCreateRequest>()
.WithName("ClientCreate")
.Produces(StatusCodes.Status201Created);
var clientIdGroup = clientsGroup.MapGroup("{clientId}")
.AddEndpointFilter<ClientIdValidationFilter>();
clientIdGroup.MapGet("", ClientGet)
.WithName(ClientGetRouteName);
}
private static Ok<ClientRepresentation> ClientGet(
Guid realmId,
int clientId,
HttpContext context)
{
Client client = (Client)context.Items["ClientEntity"]!;
return TypedResults.Ok(new ClientMapper().ToDto(client));
}
private static async Task<Results<CreatedAtRoute<ClientCreateReponse>, InternalServerError>>
ClientCreate(
Guid realmId,
ClientCreateRequest request,
[FromServices] IClientService service,
HttpContext context,
CancellationToken cancellationToken)
{
Realm realm = context.GetValidatedRealm();
Result<Client> result = await service.Create(realm.Id, request, cancellationToken);
if (result.IsFailed)
{
throw new NotImplementedException();
}
Client client = result.Value;
return TypedResults.CreatedAtRoute(
new ClientCreateReponse(client.Id, client.ClientId),
ClientGetRouteName,
new RouteValueDictionary()
{
["realmId"] = realm.Id,
["clientId"] = client.Id,
});
}
}

View file

@ -1,41 +0,0 @@
using System.Text.Json.Serialization;
namespace IdentityShroud.Core.Messages;
// https://www.rfc-editor.org/rfc/rfc7517.html
public class JsonWebKey
{
[JsonPropertyName("kty")]
public string KeyType { get; set; } = "RSA";
// Common values sig(nature) enc(ryption)
[JsonPropertyName("use")]
public string? Use { get; set; } = "sig"; // "sig" for signature, "enc" for encryption
// Per standard this field is optional, commented out for now as it seems not
// have any good use in an identity server. Anyone validating tokens should use
// the algorithm specified in the header of the token.
// [JsonPropertyName("alg")]
// public string? Algorithm { get; set; } = "RS256";
[JsonPropertyName("kid")]
public required string KeyId { get; set; }
// RSA Public Key Components
[JsonPropertyName("n")]
public required string Modulus { get; set; }
[JsonPropertyName("e")]
public required string Exponent { get; set; }
// Optional fields
[JsonPropertyName("x5c")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<string>? X509CertificateChain { get; set; }
[JsonPropertyName("x5t")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? X509CertificateThumbprint { get; set; }
}

View file

@ -1,9 +0,0 @@
using System.Text.Json.Serialization;
namespace IdentityShroud.Core.Messages;
public class JsonWebKeySet
{
[JsonPropertyName("keys")]
public List<JsonWebKey> Keys { get; set; } = new List<JsonWebKey>();
}

View file

@ -0,0 +1,16 @@
namespace IdentityShroud.Api;
public record ClientRepresentation
{
public int Id { get; set; }
public Guid RealmId { get; set; }
public required string ClientId { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public string? SignatureAlgorithm { get; set; }
public bool AllowClientCredentialsFlow { get; set; } = false;
public required DateTime CreatedAt { get; set; }
}

View file

@ -0,0 +1,15 @@
namespace IdentityShroud.Api;
public static class EndpointRouteBuilderExtensions
{
public static RouteHandlerBuilder Validate<TDto>(this RouteHandlerBuilder builder) where TDto : class
=> builder.AddEndpointFilter<ValidateFilter<TDto>>();
public static void MapApis(this IEndpointRouteBuilder erp)
{
RealmApi.MapRealmEndpoints(erp);
OpenIdEndpoints.MapEndpoints(erp);
}
}

View file

@ -0,0 +1,21 @@
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Model;
namespace IdentityShroud.Api;
public class ClientIdValidationFilter(IClientService clientService) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
Guid realmId = context.Arguments.OfType<Guid>().First();
int id = context.Arguments.OfType<int>().First();
Client? client = await clientService.FindById(realmId, id, context.HttpContext.RequestAborted);
if (client is null)
{
return Results.NotFound();
}
context.HttpContext.Items["ClientEntity"] = client;
return await next(context);
}
}

View file

@ -0,0 +1,20 @@
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Model;
namespace IdentityShroud.Api;
public class RealmIdValidationFilter(IRealmService realmService) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
Guid id = context.Arguments.OfType<Guid>().First();
Realm? realm = await realmService.FindById(id, context.HttpContext.RequestAborted);
if (realm is null)
{
return Results.NotFound();
}
context.HttpContext.Items["RealmEntity"] = realm;
return await next(context);
}
}

View file

@ -1,5 +1,5 @@
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Services;
namespace IdentityShroud.Api;
@ -9,12 +9,13 @@ namespace IdentityShroud.Api;
/// consistently.
/// </summary>
/// <param name="realmService"></param>
public class SlugValidationFilter(IRealmService realmService) : IEndpointFilter
public class RealmSlugValidationFilter(IRealmService realmService) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
string slug = context.Arguments.OfType<string>().First();
Realm? realm = await realmService.FindBySlug(slug);
string realmSlug = context.Arguments.OfType<string>().FirstOrDefault()
?? throw new InvalidOperationException("Expected argument missing, ensure you include path parameters in your handlers signature even when you don't use them");
Realm? realm = await realmService.FindBySlug(realmSlug, context.HttpContext.RequestAborted);
if (realm is null)
{
return Results.NotFound();

View file

@ -0,0 +1,11 @@
using IdentityShroud.Core.Model;
using Riok.Mapperly.Abstractions;
namespace IdentityShroud.Api.Mappers;
[Mapper]
public partial class ClientMapper
{
[MapperIgnoreSource(nameof(Client.Secrets))]
public partial ClientRepresentation ToDto(Client client);
}

View file

@ -1,34 +1,22 @@
using System.Security.Cryptography;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Messages;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security;
using Microsoft.AspNetCore.WebUtilities;
namespace IdentityShroud.Api.Mappers;
public class KeyMapper(IEncryptionService encryptionService)
public class KeyMapper(IKeyService keyService)
{
public JsonWebKey KeyToJsonWebKey(Key key)
public JsonWebKeySet KeyListToJsonWebKeySet(IEnumerable<RealmKey> keys)
{
using var rsa = RsaHelper.LoadFromPkcs8(key.GetPrivateKey(encryptionService));
RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false);
return new JsonWebKey()
JsonWebKeySet wks = new();
foreach (var k in keys)
{
KeyType = rsa.SignatureAlgorithm,
KeyId = key.Id.ToString(),
Use = "sig",
Exponent = WebEncoders.Base64UrlEncode(parameters.Exponent!),
Modulus = WebEncoders.Base64UrlEncode(parameters.Modulus!),
};
}
public JsonWebKeySet KeyListToJsonWebKeySet(IEnumerable<Key> keys)
{
return new JsonWebKeySet()
{
Keys = keys.Select(e => KeyToJsonWebKey(e)).ToList(),
};
var wk = keyService.CreateJsonWebKey(k);
if (wk is {})
{
wks.Keys.Add(wk);
}
}
return wks;
}
}

View file

@ -0,0 +1,72 @@
using IdentityShroud.Api.Mappers;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Messages;
using IdentityShroud.Core.Model;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
namespace IdentityShroud.Api;
public static class OpenIdEndpoints
{
// openid: auth/realms/{realmSlug}/.well-known/openid-configuration
// openid: auth/realms/{realmSlug}/openid-connect/(auth|token|jwks)
public static void MapEndpoints(this IEndpointRouteBuilder erp)
{
var realmsGroup = erp.MapGroup("/auth/realms");
var realmSlugGroup = realmsGroup.MapGroup("{realmSlug}")
.AddEndpointFilter<RealmSlugValidationFilter>();
realmSlugGroup.MapGet(".well-known/openid-configuration", GetOpenIdConfiguration);
var openidConnect = realmSlugGroup.MapGroup("openid-connect");
openidConnect.MapPost("auth", OpenIdConnectAuth);
openidConnect.MapPost("token", OpenIdConnectToken);
openidConnect.MapGet("jwks", OpenIdConnectJwks);
}
private static async Task<JsonHttpResult<OpenIdConfiguration>> GetOpenIdConfiguration(
string realmSlug,
[FromServices]IRealmService realmService,
HttpContext context)
{
Realm realm = context.GetValidatedRealm();
var s = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}";
var searchString = $"realms/{realmSlug}";
int index = s.IndexOf(searchString, StringComparison.OrdinalIgnoreCase);
string baseUri = s.Substring(0, index + searchString.Length);
return TypedResults.Json(new OpenIdConfiguration()
{
AuthorizationEndpoint = baseUri + "/openid-connect/auth",
TokenEndpoint = baseUri + "/openid-connect/token",
Issuer = baseUri,
JwksUri = baseUri + "/openid-connect/jwks",
}, AppJsonSerializerContext.Default.OpenIdConfiguration);
}
private static async Task<Results<Ok<JsonWebKeySet>, BadRequest>> OpenIdConnectJwks(
string realmSlug,
[FromServices]IRealmService realmService,
[FromServices]KeyMapper keyMapper,
HttpContext context)
{
Realm realm = context.GetValidatedRealm();
await realmService.LoadActiveKeys(realm);
return TypedResults.Ok(keyMapper.KeyListToJsonWebKeySet(realm.Keys));
}
private static Task OpenIdConnectToken(HttpContext context)
{
throw new NotImplementedException();
}
private static Task OpenIdConnectAuth(HttpContext context)
{
throw new NotImplementedException();
}
}

View file

@ -1,7 +1,4 @@
using FluentResults;
using IdentityShroud.Api.Mappers;
using IdentityShroud.Api.Validation;
using IdentityShroud.Core.Messages;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Messages.Realm;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Services;
@ -15,26 +12,28 @@ public static class HttpContextExtensions
public static Realm GetValidatedRealm(this HttpContext context) => (Realm)context.Items["RealmEntity"]!;
}
// api: api/v1/realms/{realmId}/....
// api: api/v1/realms/{realmId}/clients/{clientId}
public static class RealmApi
{
public static void MapRealmEndpoints(this IEndpointRouteBuilder app)
public static void MapRealmEndpoints(IEndpointRouteBuilder erp)
{
var realmsGroup = app.MapGroup("/realms");
var realmsGroup = erp.MapGroup("/api/v1/realms");
realmsGroup.MapPost("", RealmCreate)
.Validate<RealmCreateRequest>()
.WithName("Create Realm")
.Produces(StatusCodes.Status201Created);
var realmSlugGroup = realmsGroup.MapGroup("{slug}")
.AddEndpointFilter<SlugValidationFilter>();
realmSlugGroup.MapGet(".well-known/openid-configuration", GetOpenIdConfiguration);
var realmIdGroup = realmsGroup.MapGroup("{realmId}")
.AddEndpointFilter<RealmIdValidationFilter>();
ClientApi.MapEndpoints(realmIdGroup);
var openidConnect = realmSlugGroup.MapGroup("openid-connect");
openidConnect.MapPost("auth", OpenIdConnectAuth);
openidConnect.MapPost("token", OpenIdConnectToken);
openidConnect.MapGet("jwks", OpenIdConnectJwks);
}
private static async Task<Results<Created<RealmCreateResponse>, InternalServerError>>
@ -47,46 +46,4 @@ public static class RealmApi
// TODO make helper to convert failure response to a proper HTTP result.
return TypedResults.InternalServerError();
}
private static async Task<Results<Ok<JsonWebKeySet>, BadRequest>> OpenIdConnectJwks(
string slug,
[FromServices]IRealmService realmService,
[FromServices]KeyMapper keyMapper,
HttpContext context)
{
Realm realm = context.GetValidatedRealm();
await realmService.LoadActiveKeys(realm);
return TypedResults.Ok(keyMapper.KeyListToJsonWebKeySet(realm.Keys));
}
private static Task OpenIdConnectToken(HttpContext context)
{
throw new NotImplementedException();
}
private static Task OpenIdConnectAuth(HttpContext context)
{
throw new NotImplementedException();
}
private static async Task<JsonHttpResult<OpenIdConfiguration>> GetOpenIdConfiguration(
string slug,
[FromServices]IRealmService realmService,
HttpContext context)
{
Realm realm = context.GetValidatedRealm();
var s = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}";
var searchString = $"realms/{slug}";
int index = s.IndexOf(searchString, StringComparison.OrdinalIgnoreCase);
string baseUri = s.Substring(0, index + searchString.Length);
return TypedResults.Json(new OpenIdConfiguration()
{
AuthorizationEndpoint = baseUri + "/openid-connect/auth",
TokenEndpoint = baseUri + "/openid-connect/token",
Issuer = baseUri,
JwksUri = baseUri + "/openid-connect/jwks",
}, AppJsonSerializerContext.Default.OpenIdConfiguration);
}
}

View file

@ -0,0 +1,22 @@
using FluentValidation;
using IdentityShroud.Core.Contracts;
namespace IdentityShroud.Api;
public class ClientCreateRequestValidator : AbstractValidator<ClientCreateRequest>
{
// most of standard ascii minus the control characters and space
private const string ClientIdPattern = "^[\x21-\x7E]+";
private string[] AllowedAlgorithms = [ "RS256", "ES256" ];
public ClientCreateRequestValidator()
{
RuleFor(e => e.ClientId).NotEmpty().MaximumLength(40).Matches(ClientIdPattern);
RuleFor(e => e.Name).MaximumLength(80);
RuleFor(e => e.Description).MaximumLength(2048);
RuleFor(e => e.SignatureAlgorithm)
.Must(v => v is null || AllowedAlgorithms.Contains(v))
.WithMessage($"SignatureAlgorithm must be one of {string.Join(", ", AllowedAlgorithms)} or null");
}
}

View file

@ -1,7 +1,7 @@
using FluentValidation;
using IdentityShroud.Core.Messages.Realm;
namespace IdentityShroud.Api.Validation;
namespace IdentityShroud.Api;
public class RealmCreateRequestValidator : AbstractValidator<RealmCreateRequest>
{

View file

@ -1,6 +1,6 @@
using FluentValidation;
namespace IdentityShroud.Api.Validation;
namespace IdentityShroud.Api;
public class ValidateFilter<T> : IEndpointFilter where T : class
{

View file

@ -1,7 +1,6 @@
using System.Text.Json.Serialization;
using IdentityShroud.Core.Messages;
using IdentityShroud.Core.Messages.Realm;
using Microsoft.Extensions.Diagnostics.HealthChecks;
[JsonSerializable(typeof(OpenIdConfiguration))]
[JsonSerializable(typeof(RealmCreateRequest))]

View file

@ -17,7 +17,7 @@
<ItemGroup>
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0"/>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.2" />
<PackageReference Include="Riok.Mapperly" Version="4.3.1" />
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Expressions" Version="5.0.0" />

View file

@ -1,3 +1,5 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=apis_005Cdto/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=apis_005Cfilters/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=apis_005Cfilters/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=apis_005Cvalidation/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=validation/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

View file

@ -1,10 +1,10 @@
using FluentValidation;
using IdentityShroud.Api;
using IdentityShroud.Api.Mappers;
using IdentityShroud.Api.Validation;
using IdentityShroud.Core;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Security;
using IdentityShroud.Core.Security.Keys;
using IdentityShroud.Core.Services;
using Serilog;
using Serilog.Formatting.Json;
@ -36,13 +36,21 @@ void ConfigureBuilder(WebApplicationBuilder builder)
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
services.AddOpenApi();
services.AddScoped<Db>();
services.AddScoped<IClientService, ClientService>();
services.AddSingleton<IClock, ClockService>();
services.AddSingleton<IDekEncryptionService, DekEncryptionService>();
services.AddScoped<IDataEncryptionService, DataEncryptionService>();
services.AddScoped<IRealmContext, RealmContext>();
services.AddScoped<IKeyProviderFactory, KeyProviderFactory>();
services.AddScoped<IKeyService, KeyService>();
services.AddScoped<IRealmService, RealmService>();
services.AddOptions<DbConfiguration>().Bind(configuration.GetSection("db"));
services.AddSingleton<ISecretProvider, ConfigurationSecretProvider>();
services.AddSingleton<KeyMapper>();
services.AddSingleton<IEncryptionService, EncryptionService>();
services.AddScoped<KeyMapper>();
services.AddScoped<IRealmContext, RealmContext>();
services.AddValidatorsFromAssemblyContaining<RealmCreateRequestValidator>();
services.AddValidatorsFromAssemblyContaining<RealmCreateRequestValidator>();
services.AddHttpContextAccessor();
builder.Host.UseSerilog((context, services, configuration) => configuration
.Enrich.FromLogContext()
@ -57,7 +65,8 @@ void ConfigureApplication(WebApplication app)
app.MapOpenApi();
}
app.UseSerilogRequestLogging();
app.MapRealmEndpoints();
app.MapApis();
// app.UseRouting();
// app.MapControllers();
}

View file

@ -1,7 +0,0 @@
namespace IdentityShroud.Api.Validation;
public static class EndpointRouteBuilderExtensions
{
public static RouteHandlerBuilder Validate<TDto>(this RouteHandlerBuilder builder) where TDto : class
=> builder.AddEndpointFilter<ValidateFilter<TDto>>();
}