From eb872a4f449dfcd1629adb3a781853eec1c3fb8e Mon Sep 17 00:00:00 2001 From: eelke Date: Fri, 20 Feb 2026 17:35:38 +0100 Subject: [PATCH 01/22] WIP making ClientCreate endpoint --- .../Apis/RealmApisTests.cs | 17 +++--- .../Mappers/KeyMapperTests.cs | 8 +-- IdentityShroud.Api/Apis/ClientApi.cs | 55 +++++++++++++++++++ IdentityShroud.Api/Apis/DTO/JsonWebKey.cs | 50 ++++++++++++++--- .../Apis/Filters/SlugValidationFilter.cs | 1 + IdentityShroud.Api/Apis/Mappers/KeyMapper.cs | 44 ++++++++++----- IdentityShroud.Api/Apis/RealmApi.cs | 11 ++-- .../IdentityShroud.Api.csproj.DotSettings | 3 +- IdentityShroud.Api/Program.cs | 1 - .../EndpointRouteBuilderExtensions.cs | 2 +- .../Validation/RealmCreateRequestValidator.cs | 2 +- .../Validation/ValidateFilter.cs | 2 +- .../Model/{KeyTests.cs => RealmKeyTests.cs} | 18 +++--- .../Contracts/IClientService.cs | 25 +++++++++ IdentityShroud.Core/Contracts/IClock.cs | 6 ++ .../Contracts/IKeyProvisioningService.cs | 8 +++ .../{Services => Contracts}/IRealmService.cs | 3 +- IdentityShroud.Core/Db.cs | 3 +- IdentityShroud.Core/Model/Client.cs | 23 +++++++- IdentityShroud.Core/Model/ClientSecret.cs | 15 +++++ IdentityShroud.Core/Model/Key.cs | 45 --------------- IdentityShroud.Core/Model/Realm.cs | 2 +- IdentityShroud.Core/Model/RealmKey.cs | 22 ++++++++ IdentityShroud.Core/Services/ClientService.cs | 54 ++++++++++++++++++ IdentityShroud.Core/Services/ClockService.cs | 11 ++++ .../Services/KeyProvisioningService.cs | 30 ++++++++++ IdentityShroud.Core/Services/RealmService.cs | 19 +------ IdentityShroud.sln.DotSettings.user | 6 +- 28 files changed, 365 insertions(+), 121 deletions(-) create mode 100644 IdentityShroud.Api/Apis/ClientApi.cs rename IdentityShroud.Core.Tests/Model/{KeyTests.cs => RealmKeyTests.cs} (69%) create mode 100644 IdentityShroud.Core/Contracts/IClientService.cs create mode 100644 IdentityShroud.Core/Contracts/IClock.cs create mode 100644 IdentityShroud.Core/Contracts/IKeyProvisioningService.cs rename IdentityShroud.Core/{Services => Contracts}/IRealmService.cs (81%) create mode 100644 IdentityShroud.Core/Model/ClientSecret.cs delete mode 100644 IdentityShroud.Core/Model/Key.cs create mode 100644 IdentityShroud.Core/Model/RealmKey.cs create mode 100644 IdentityShroud.Core/Services/ClientService.cs create mode 100644 IdentityShroud.Core/Services/ClockService.cs create mode 100644 IdentityShroud.Core/Services/KeyProvisioningService.cs diff --git a/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs b/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs index 350149b..31c1e9d 100644 --- a/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs +++ b/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs @@ -122,17 +122,16 @@ public class RealmApisTests : IClassFixture using var rsa = RSA.Create(2048); RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false); - - Key key = new() - { - Id = Guid.NewGuid(), - CreatedAt = DateTime.UtcNow, - }; - key.SetPrivateKey(encryptionService, rsa.ExportPkcs8PrivateKey()); + + RealmKey realmKey = new( + Guid.NewGuid(), + "RSA", + encryptionService.Encrypt(rsa.ExportPkcs8PrivateKey()), + DateTime.UtcNow); 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); }); @@ -145,7 +144,7 @@ public class RealmApisTests : IClassFixture JsonObject? payload = await response.Content.ReadFromJsonAsync(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"); } diff --git a/IdentityShroud.Api.Tests/Mappers/KeyMapperTests.cs b/IdentityShroud.Api.Tests/Mappers/KeyMapperTests.cs index 6c57971..9cd88e0 100644 --- a/IdentityShroud.Api.Tests/Mappers/KeyMapperTests.cs +++ b/IdentityShroud.Api.Tests/Mappers/KeyMapperTests.cs @@ -20,20 +20,20 @@ public class KeyMapperTests RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false); - Key key = new() + RealmKey realmKey = new() { Id = new("60bb79cf-4bac-4521-87f2-ac87cc15541f"), CreatedAt = DateTime.UtcNow, Priority = 10, }; - key.SetPrivateKey(_encryptionService, rsa.ExportPkcs8PrivateKey()); + realmKey.SetPrivateKey(_encryptionService, rsa.ExportPkcs8PrivateKey()); // Act KeyMapper mapper = new(_encryptionService); - JsonWebKey jwk = mapper.KeyToJsonWebKey(key); + JsonWebKey jwk = mapper.KeyToJsonWebKey(realmKey); Assert.Equal("RSA", jwk.KeyType); - Assert.Equal(key.Id.ToString(), jwk.KeyId); + Assert.Equal(realmKey.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)); diff --git a/IdentityShroud.Api/Apis/ClientApi.cs b/IdentityShroud.Api/Apis/ClientApi.cs new file mode 100644 index 0000000..86d965f --- /dev/null +++ b/IdentityShroud.Api/Apis/ClientApi.cs @@ -0,0 +1,55 @@ +using FluentResults; +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Messages.Realm; +using IdentityShroud.Core.Model; +using IdentityShroud.Core.Services; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; + +namespace IdentityShroud.Api; + + +public record ClientCreateReponse(int Id, string ClientId); + +/// +/// The part of the api below realms/{slug}/clients +/// +public static class ClientApi +{ + public const string ClientGetRouteName = "ClientGet"; + + public static void MapEndpoints(this IEndpointRouteBuilder erp) + { + erp.MapPost("", ClientCreate) + .Validate() + .WithName("ClientCreate") + .Produces(StatusCodes.Status201Created); + erp.MapGet("{clientId}", ClientGet) + .WithName(ClientGetRouteName); + } + + private static Task ClientGet(HttpContext context) + { + throw new NotImplementedException(); + } + + private static async Task, InternalServerError>> + ClientCreate( + ClientCreateRequest request, + [FromServices] IClientService service, + HttpContext context, + CancellationToken cancellationToken) + { + Realm realm = context.GetValidatedRealm(); + Result result = await service.Create(realm.Id, request, cancellationToken); + + // Should i have two set of paths? one for actual REST and one for openid + // openid: auth/realms/{realmSlug}/.well-known/openid-configuration + // openid: auth/realms/{realmSlug}/openid-connect/(auth|token|jwks) + // api: api/v1/realms/{realmId}/.... + // api: api/v1/realms/{realmId}/clients/{clientId} + + //return Results.CreatedAtRoute(ClientGetRouteName, [ "realmSlug" = realmId!?]) + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/DTO/JsonWebKey.cs b/IdentityShroud.Api/Apis/DTO/JsonWebKey.cs index e46107f..ea4d7d5 100644 --- a/IdentityShroud.Api/Apis/DTO/JsonWebKey.cs +++ b/IdentityShroud.Api/Apis/DTO/JsonWebKey.cs @@ -1,3 +1,6 @@ +using System.Buffers; +using System.Buffers.Text; +using System.Text.Json; using System.Text.Json.Serialization; namespace IdentityShroud.Core.Messages; @@ -25,17 +28,46 @@ public class JsonWebKey // RSA Public Key Components [JsonPropertyName("n")] - public required string Modulus { get; set; } + public string? Modulus { get; set; } [JsonPropertyName("e")] - public required string Exponent { get; set; } + public string? Exponent { get; set; } + + // ECdsa + public string? Curve { get; set; } + [JsonConverter(typeof(Base64UrlConverter))] + public byte[]? X { get; set; } + [JsonConverter(typeof(Base64UrlConverter))] + public byte[]? Y { get; set; } // Optional fields - [JsonPropertyName("x5c")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public List? X509CertificateChain { get; set; } - - [JsonPropertyName("x5t")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? X509CertificateThumbprint { get; set; } + // [JsonPropertyName("x5c")] + // [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + // public List? X509CertificateChain { get; set; } + // + // [JsonPropertyName("x5t")] + // [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + // public string? X509CertificateThumbprint { get; set; } +} + +public class Base64UrlConverter : JsonConverter +{ + public override byte[] Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + // GetValueSpan gives you the raw UTF-8 bytes of the JSON string value + if (reader.HasValueSequence) + { + var valueSequence = reader.ValueSequence.ToArray(); + return Base64Url.DecodeFromUtf8(valueSequence); + } + return Base64Url.DecodeFromUtf8(reader.ValueSpan); + } + + public override void Write(Utf8JsonWriter writer, byte[] value, JsonSerializerOptions options) + { + int encodedLength = Base64Url.GetEncodedLength(value.Length); + Span buffer = encodedLength <= 256 ? stackalloc byte[encodedLength] : new byte[encodedLength]; + Base64Url.EncodeToUtf8(value, buffer); + writer.WriteStringValue(buffer); + } } \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/Filters/SlugValidationFilter.cs b/IdentityShroud.Api/Apis/Filters/SlugValidationFilter.cs index 5bc699e..b7efc2b 100644 --- a/IdentityShroud.Api/Apis/Filters/SlugValidationFilter.cs +++ b/IdentityShroud.Api/Apis/Filters/SlugValidationFilter.cs @@ -1,3 +1,4 @@ +using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Model; using IdentityShroud.Core.Services; diff --git a/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs b/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs index 00f5d7b..94d37e7 100644 --- a/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs +++ b/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs @@ -9,26 +9,44 @@ namespace IdentityShroud.Api.Mappers; public class KeyMapper(IEncryptionService encryptionService) { - public JsonWebKey KeyToJsonWebKey(Key key) + public JsonWebKey? KeyToJsonWebKey(RealmKey realmKey) { - using var rsa = RsaHelper.LoadFromPkcs8(key.GetPrivateKey(encryptionService)); - RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false); - - return new JsonWebKey() + + JsonWebKey result = new() { - KeyType = rsa.SignatureAlgorithm, - KeyId = key.Id.ToString(), + KeyId = realmKey.Id.ToString(), Use = "sig", - Exponent = WebEncoders.Base64UrlEncode(parameters.Exponent!), - Modulus = WebEncoders.Base64UrlEncode(parameters.Modulus!), }; + switch (realmKey.KeyType) + { + case "RSA": + using (var rsa = RsaHelper.LoadFromPkcs8(realmKey.GetPrivateKey(encryptionService))) + { + RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false); + result.KeyType = rsa.SignatureAlgorithm; + result.Exponent = WebEncoders.Base64UrlEncode(parameters.Exponent!); + result.Modulus = WebEncoders.Base64UrlEncode(parameters.Modulus!); + } + break; + + default: + return null; + } + + return result; } - public JsonWebKeySet KeyListToJsonWebKeySet(IEnumerable keys) + public JsonWebKeySet KeyListToJsonWebKeySet(IEnumerable keys) { - return new JsonWebKeySet() + JsonWebKeySet wks = new(); + foreach (var k in keys) { - Keys = keys.Select(e => KeyToJsonWebKey(e)).ToList(), - }; + var wk = KeyToJsonWebKey(k); + if (wk is {}) + { + wks.Keys.Add(wk); + } + } + return wks; } } \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/RealmApi.cs b/IdentityShroud.Api/Apis/RealmApi.cs index d5e439b..47b0549 100644 --- a/IdentityShroud.Api/Apis/RealmApi.cs +++ b/IdentityShroud.Api/Apis/RealmApi.cs @@ -1,6 +1,6 @@ using FluentResults; using IdentityShroud.Api.Mappers; -using IdentityShroud.Api.Validation; +using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Messages; using IdentityShroud.Core.Messages.Realm; using IdentityShroud.Core.Model; @@ -19,18 +19,21 @@ public static class HttpContextExtensions public static class RealmApi { - public static void MapRealmEndpoints(this IEndpointRouteBuilder app) + public static void MapRealmEndpoints(this IEndpointRouteBuilder erp) { - var realmsGroup = app.MapGroup("/realms"); + var realmsGroup = erp.MapGroup("/realms"); realmsGroup.MapPost("", RealmCreate) .Validate() .WithName("Create Realm") .Produces(StatusCodes.Status201Created); - var realmSlugGroup = realmsGroup.MapGroup("{slug}") + var realmSlugGroup = realmsGroup.MapGroup("{realmSlug}") .AddEndpointFilter(); realmSlugGroup.MapGet(".well-known/openid-configuration", GetOpenIdConfiguration); + RouteGroupBuilder clientsGroup = realmSlugGroup.MapGroup("clients"); + + var openidConnect = realmSlugGroup.MapGroup("openid-connect"); openidConnect.MapPost("auth", OpenIdConnectAuth); openidConnect.MapPost("token", OpenIdConnectToken); diff --git a/IdentityShroud.Api/IdentityShroud.Api.csproj.DotSettings b/IdentityShroud.Api/IdentityShroud.Api.csproj.DotSettings index bd2aa2d..c053b70 100644 --- a/IdentityShroud.Api/IdentityShroud.Api.csproj.DotSettings +++ b/IdentityShroud.Api/IdentityShroud.Api.csproj.DotSettings @@ -1,3 +1,4 @@  True - True \ No newline at end of file + True + True \ No newline at end of file diff --git a/IdentityShroud.Api/Program.cs b/IdentityShroud.Api/Program.cs index 66a7554..bb35f98 100644 --- a/IdentityShroud.Api/Program.cs +++ b/IdentityShroud.Api/Program.cs @@ -1,7 +1,6 @@ using FluentValidation; using IdentityShroud.Api; using IdentityShroud.Api.Mappers; -using IdentityShroud.Api.Validation; using IdentityShroud.Core; using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Security; diff --git a/IdentityShroud.Api/Validation/EndpointRouteBuilderExtensions.cs b/IdentityShroud.Api/Validation/EndpointRouteBuilderExtensions.cs index e67f787..e6952be 100644 --- a/IdentityShroud.Api/Validation/EndpointRouteBuilderExtensions.cs +++ b/IdentityShroud.Api/Validation/EndpointRouteBuilderExtensions.cs @@ -1,4 +1,4 @@ -namespace IdentityShroud.Api.Validation; +namespace IdentityShroud.Api; public static class EndpointRouteBuilderExtensions { diff --git a/IdentityShroud.Api/Validation/RealmCreateRequestValidator.cs b/IdentityShroud.Api/Validation/RealmCreateRequestValidator.cs index 8daa0a9..3e3a20a 100644 --- a/IdentityShroud.Api/Validation/RealmCreateRequestValidator.cs +++ b/IdentityShroud.Api/Validation/RealmCreateRequestValidator.cs @@ -1,7 +1,7 @@ using FluentValidation; using IdentityShroud.Core.Messages.Realm; -namespace IdentityShroud.Api.Validation; +namespace IdentityShroud.Api; public class RealmCreateRequestValidator : AbstractValidator { diff --git a/IdentityShroud.Api/Validation/ValidateFilter.cs b/IdentityShroud.Api/Validation/ValidateFilter.cs index fbebd9d..d621441 100644 --- a/IdentityShroud.Api/Validation/ValidateFilter.cs +++ b/IdentityShroud.Api/Validation/ValidateFilter.cs @@ -1,6 +1,6 @@ using FluentValidation; -namespace IdentityShroud.Api.Validation; +namespace IdentityShroud.Api; public class ValidateFilter : IEndpointFilter where T : class { diff --git a/IdentityShroud.Core.Tests/Model/KeyTests.cs b/IdentityShroud.Core.Tests/Model/RealmKeyTests.cs similarity index 69% rename from IdentityShroud.Core.Tests/Model/KeyTests.cs rename to IdentityShroud.Core.Tests/Model/RealmKeyTests.cs index e7e9b45..77969d8 100644 --- a/IdentityShroud.Core.Tests/Model/KeyTests.cs +++ b/IdentityShroud.Core.Tests/Model/RealmKeyTests.cs @@ -3,7 +3,7 @@ using IdentityShroud.Core.Model; namespace IdentityShroud.Core.Tests.Model; -public class KeyTests +public class RealmKeyTests { [Fact] public void SetNewKey() @@ -16,12 +16,12 @@ public class KeyTests .Encrypt(Arg.Any()) .Returns(x => encryptedPrivateKey); - Key key = new(); - key.SetPrivateKey(encryptionService, privateKey); + RealmKey realmKey = new(); + realmKey.SetPrivateKey(encryptionService, privateKey); // should be able to return original without calling decrypt - Assert.Equal(privateKey, key.GetPrivateKey(encryptionService)); - Assert.Equal(encryptedPrivateKey, key.PrivateKeyEncrypted); + Assert.Equal(privateKey, realmKey.GetPrivateKey(encryptionService)); + Assert.Equal(encryptedPrivateKey, realmKey.PrivateKeyEncrypted); encryptionService.Received(1).Encrypt(privateKey); encryptionService.DidNotReceive().Decrypt(Arg.Any()); @@ -38,12 +38,12 @@ public class KeyTests .Decrypt(encryptedPrivateKey) .Returns(x => privateKey); - Key key = new(); - key.PrivateKeyEncrypted = encryptedPrivateKey; + RealmKey realmKey = new(); + realmKey.PrivateKeyEncrypted = encryptedPrivateKey; // should be able to return original without calling decrypt - Assert.Equal(privateKey, key.GetPrivateKey(encryptionService)); - Assert.Equal(encryptedPrivateKey, key.PrivateKeyEncrypted); + Assert.Equal(privateKey, realmKey.GetPrivateKey(encryptionService)); + Assert.Equal(encryptedPrivateKey, realmKey.PrivateKeyEncrypted); encryptionService.Received(1).Decrypt(encryptedPrivateKey); } diff --git a/IdentityShroud.Core/Contracts/IClientService.cs b/IdentityShroud.Core/Contracts/IClientService.cs new file mode 100644 index 0000000..5c2295b --- /dev/null +++ b/IdentityShroud.Core/Contracts/IClientService.cs @@ -0,0 +1,25 @@ +using IdentityShroud.Core.Model; + +namespace IdentityShroud.Core.Contracts; + +//public record CreateClientRequest(Guid RealmId, string ClientId, string? Description); + +public class ClientCreateRequest +{ + public string ClientId { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string? SignatureAlgorithm { get; set; } + public bool? AllowClientCredentialsFlow { get; set; } +} + + +public interface IClientService +{ + Task> Create( + Guid realmId, + ClientCreateRequest request, + CancellationToken ct = default); + + Task GetByClientId(string clientId, CancellationToken ct = default); +} \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/IClock.cs b/IdentityShroud.Core/Contracts/IClock.cs new file mode 100644 index 0000000..4ba7766 --- /dev/null +++ b/IdentityShroud.Core/Contracts/IClock.cs @@ -0,0 +1,6 @@ +namespace IdentityShroud.Core.Contracts; + +public interface IClock +{ + DateTime UtcNow(); +} \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/IKeyProvisioningService.cs b/IdentityShroud.Core/Contracts/IKeyProvisioningService.cs new file mode 100644 index 0000000..396d765 --- /dev/null +++ b/IdentityShroud.Core/Contracts/IKeyProvisioningService.cs @@ -0,0 +1,8 @@ +using IdentityShroud.Core.Model; + +namespace IdentityShroud.Core.Contracts; + +public interface IKeyProvisioningService +{ + RealmKey CreateRsaKey(int keySize = 2048); +} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/IRealmService.cs b/IdentityShroud.Core/Contracts/IRealmService.cs similarity index 81% rename from IdentityShroud.Core/Services/IRealmService.cs rename to IdentityShroud.Core/Contracts/IRealmService.cs index 4ce1da4..9940466 100644 --- a/IdentityShroud.Core/Services/IRealmService.cs +++ b/IdentityShroud.Core/Contracts/IRealmService.cs @@ -1,7 +1,8 @@ using IdentityShroud.Core.Messages.Realm; using IdentityShroud.Core.Model; +using IdentityShroud.Core.Services; -namespace IdentityShroud.Core.Services; +namespace IdentityShroud.Core.Contracts; public interface IRealmService { diff --git a/IdentityShroud.Core/Db.cs b/IdentityShroud.Core/Db.cs index b476787..cd7a493 100644 --- a/IdentityShroud.Core/Db.cs +++ b/IdentityShroud.Core/Db.cs @@ -16,8 +16,9 @@ public class Db( ILoggerFactory? loggerFactory) : DbContext { + public virtual DbSet Clients { get; set; } public virtual DbSet Realms { get; set; } - public virtual DbSet Keys { get; set; } + public virtual DbSet Keys { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { diff --git a/IdentityShroud.Core/Model/Client.cs b/IdentityShroud.Core/Model/Client.cs index d412632..43f2f1a 100644 --- a/IdentityShroud.Core/Model/Client.cs +++ b/IdentityShroud.Core/Model/Client.cs @@ -1,11 +1,30 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; using IdentityShroud.Core.Security; +using Microsoft.EntityFrameworkCore; namespace IdentityShroud.Core.Model; +[Table("client")] +[Index(nameof(ClientId), IsUnique = true)] public class Client { + [Key] public Guid Id { get; set; } - public string Name { get; set; } + public Guid RealmId { get; set; } + [MaxLength(40)] + public required string ClientId { get; set; } + [MaxLength(80)] + public string? Name { get; set; } + [MaxLength(2048)] + public string? Description { get; set; } - public string? SignatureAlgorithm { get; set; } = JsonWebAlgorithm.RS256; + [MaxLength(20)] + public string? SignatureAlgorithm { get; set; } + + public bool AllowClientCredentialsFlow { get; set; } = false; + + public required DateTime CreatedAt { get; set; } + + public List Secrets { get; set; } = []; } \ No newline at end of file diff --git a/IdentityShroud.Core/Model/ClientSecret.cs b/IdentityShroud.Core/Model/ClientSecret.cs new file mode 100644 index 0000000..bd57d37 --- /dev/null +++ b/IdentityShroud.Core/Model/ClientSecret.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace IdentityShroud.Core.Model; + +[Table("client_secret")] +public class ClientSecret +{ + [Key] + public int Id { get; set; } + public Guid ClientId { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? RevokedAt { get; set; } + public required byte[] SecretEncrypted { get; set; } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Model/Key.cs b/IdentityShroud.Core/Model/Key.cs deleted file mode 100644 index ee09d31..0000000 --- a/IdentityShroud.Core/Model/Key.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.ComponentModel.DataAnnotations.Schema; -using IdentityShroud.Core.Contracts; - -namespace IdentityShroud.Core.Model; - - -[Table("key")] -public class Key -{ - private byte[] _privateKeyDecrypted = []; - - public Guid Id { get; set; } - - public DateTime CreatedAt { get; set; } - public DateTime? DeactivatedAt { get; set; } - - /// - /// Key with highest priority will be used. While there is not really a use case for this I know some users - /// are more comfortable replacing keys by using priority then directly deactivating the old key. - /// - public int Priority { get; set; } = 10; - - public byte[] PrivateKeyEncrypted - { - get; - set - { - field = value; - _privateKeyDecrypted = []; - } - } = []; - - public byte[] GetPrivateKey(IEncryptionService encryptionService) - { - if (_privateKeyDecrypted.Length == 0 && PrivateKeyEncrypted.Length > 0) - _privateKeyDecrypted = encryptionService.Decrypt(PrivateKeyEncrypted); - return _privateKeyDecrypted; - } - - public void SetPrivateKey(IEncryptionService encryptionService, byte[] privateKey) - { - PrivateKeyEncrypted = encryptionService.Encrypt(privateKey); - _privateKeyDecrypted = privateKey; - } -} \ No newline at end of file diff --git a/IdentityShroud.Core/Model/Realm.cs b/IdentityShroud.Core/Model/Realm.cs index 35c76e8..c02fc38 100644 --- a/IdentityShroud.Core/Model/Realm.cs +++ b/IdentityShroud.Core/Model/Realm.cs @@ -20,7 +20,7 @@ public class Realm public string Name { get; set; } = ""; public List Clients { get; init; } = []; - public List Keys { get; init; } = []; + public List Keys { get; init; } = []; /// /// Can be overriden per client diff --git a/IdentityShroud.Core/Model/RealmKey.cs b/IdentityShroud.Core/Model/RealmKey.cs new file mode 100644 index 0000000..14c7c9c --- /dev/null +++ b/IdentityShroud.Core/Model/RealmKey.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace IdentityShroud.Core.Model; + + +[Table("realm_key")] +public record RealmKey(Guid Id, string KeyType, byte[] KeyDataEncrypted, DateTime CreatedAt) +{ + public Guid Id { get; private set; } = Id; + public string KeyType { get; private set; } = KeyType; + public byte[] KeyDataEncrypted { get; private set; } = KeyDataEncrypted; + public DateTime CreatedAt { get; private set; } = CreatedAt; + public DateTime? RevokedAt { get; set; } + + /// + /// Key with highest priority will be used. While there is not really a use case for this I know some users + /// are more comfortable replacing keys by using priority then directly deactivating the old key. + /// + public int Priority { get; set; } = 10; + + +} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/ClientService.cs b/IdentityShroud.Core/Services/ClientService.cs new file mode 100644 index 0000000..37a8391 --- /dev/null +++ b/IdentityShroud.Core/Services/ClientService.cs @@ -0,0 +1,54 @@ +using System.Security.Cryptography; +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Model; +using Microsoft.EntityFrameworkCore; + +namespace IdentityShroud.Core.Services; + +public class ClientService( + Db db, + IEncryptionService cryptor, + IClock clock) : IClientService +{ + public async Task> Create(Guid realmId, ClientCreateRequest request, CancellationToken ct = default) + { + Client client = new() + { + Id = Guid.NewGuid(), + 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 GetByClientId(string clientId, CancellationToken ct = default) + { + return await db.Clients.FirstOrDefaultAsync(c => c.ClientId == clientId, ct); + } + + private ClientSecret CreateSecret() + { + byte[] secret = RandomNumberGenerator.GetBytes(24); + + return new ClientSecret() + { + CreatedAt = clock.UtcNow(), + SecretEncrypted = cryptor.Encrypt(secret), + }; + + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/ClockService.cs b/IdentityShroud.Core/Services/ClockService.cs new file mode 100644 index 0000000..26eb3dd --- /dev/null +++ b/IdentityShroud.Core/Services/ClockService.cs @@ -0,0 +1,11 @@ +using IdentityShroud.Core.Contracts; + +namespace IdentityShroud.Core.Services; + +public class ClockService : IClock +{ + public DateTime UtcNow() + { + return DateTime.UtcNow; + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/KeyProvisioningService.cs b/IdentityShroud.Core/Services/KeyProvisioningService.cs new file mode 100644 index 0000000..313e894 --- /dev/null +++ b/IdentityShroud.Core/Services/KeyProvisioningService.cs @@ -0,0 +1,30 @@ +using System.Security.Cryptography; +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Model; + +namespace IdentityShroud.Core.Services; + +public class KeyProvisioningService( + IEncryptionService encryptionService, + IClock clock) : IKeyProvisioningService +{ + public RealmKey CreateRsaKey(int keySize = 2048) + { + using var rsa = RSA.Create(keySize); + return CreateKey("RSA", rsa.ExportPkcs8PrivateKey()); + } + + private RealmKey CreateKey(string keyType, byte[] keyData) => + new RealmKey( + Guid.NewGuid(), + keyType, + encryptionService.Encrypt(keyData), + clock.UtcNow()); + + // public byte[] GetPrivateKey(IEncryptionService encryptionService) + // { + // if (_privateKeyDecrypted.Length == 0 && PrivateKeyEncrypted.Length > 0) + // _privateKeyDecrypted = encryptionService.Decrypt(PrivateKeyEncrypted); + // return _privateKeyDecrypted; + // } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/RealmService.cs b/IdentityShroud.Core/Services/RealmService.cs index 57c4cf2..206d38e 100644 --- a/IdentityShroud.Core/Services/RealmService.cs +++ b/IdentityShroud.Core/Services/RealmService.cs @@ -11,7 +11,7 @@ public record RealmCreateResponse(Guid Id, string Slug, string Name); public class RealmService( Db db, - IEncryptionService encryptionService) : IRealmService + IKeyProvisioningService keyProvisioningService) : IRealmService { public async Task FindBySlug(string slug, CancellationToken ct = default) { @@ -26,7 +26,7 @@ public class RealmService( Id = request.Id ?? Guid.CreateVersion7(), Slug = request.Slug ?? SlugHelper.GenerateSlug(request.Name), Name = request.Name, - Keys = [ CreateKey() ], + Keys = [ keyProvisioningService.CreateRsaKey() ], }; db.Add(realm); @@ -40,21 +40,8 @@ public class RealmService( { await db.Entry(realm).Collection(r => r.Keys) .Query() - .Where(k => k.DeactivatedAt == null) + .Where(k => k.RevokedAt == null) .LoadAsync(); } - - private Key CreateKey() - { - using RSA rsa = RSA.Create(2048); - - Key key = new() - { - Priority = 10, - }; - key.SetPrivateKey(encryptionService, rsa.ExportPkcs8PrivateKey()); - - return key; - } } \ No newline at end of file diff --git a/IdentityShroud.sln.DotSettings.user b/IdentityShroud.sln.DotSettings.user index a850ec0..26df40e 100644 --- a/IdentityShroud.sln.DotSettings.user +++ b/IdentityShroud.sln.DotSettings.user @@ -13,11 +13,13 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded + ForceIncluded /home/eelke/.cache/JetBrains/Rider2025.3/resharper-host/temp/Rider/vAny/CoverageData/_IdentityShroud.-1277985570/Snapshot/snapshot.utdcvr /home/eelke/.dotnet/dotnet /home/eelke/.dotnet/sdk/10.0.102/MSBuild.dll - <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution #3" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <SessionState ContinuousTestingMode="0" Name="All tests from Solution #3" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> </SessionState> @@ -25,7 +27,7 @@ <SessionState ContinuousTestingMode="0" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> </SessionState> - <SessionState ContinuousTestingMode="0" Name="All tests from Solution #2" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution #2" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> </SessionState> From 0c6f22704986a2dcc95bf5afd718ab232f457a72 Mon Sep 17 00:00:00 2001 From: eelke Date: Sat, 21 Feb 2026 20:15:46 +0100 Subject: [PATCH 02/22] Reworked code around signing keys have key details much more isolated from the other parts of the program. --- .../Apis/RealmApisTests.cs | 21 ++++-- .../Mappers/KeyMapperTests.cs | 42 +++-------- .../Mappers/KeyServiceTests.cs | 43 +++++++++++ IdentityShroud.Api/Apis/ClientApi.cs | 36 +++++++--- .../Apis/EndpointRouteBuilderExtensions.cs | 15 ++++ .../Apis/Filters/ClientIdValidationFilter.cs | 20 ++++++ .../Apis/Filters/RealmIdValidationFilter.cs | 20 ++++++ ...Filter.cs => RealmSlugValidationFilter.cs} | 7 +- IdentityShroud.Api/Apis/Mappers/KeyMapper.cs | 31 +------- IdentityShroud.Api/Apis/OpenIdEndpoints.cs | 72 +++++++++++++++++++ IdentityShroud.Api/Apis/RealmApi.cs | 64 +++-------------- .../Validation/RealmCreateRequestValidator.cs | 0 .../{ => Apis}/Validation/ValidateFilter.cs | 0 .../IdentityShroud.Api.csproj.DotSettings | 1 + IdentityShroud.Api/Program.cs | 12 +++- .../EndpointRouteBuilderExtensions.cs | 7 -- .../IdentityShroud.Core.Tests.csproj | 4 ++ .../Model/RealmKeyTests.cs | 51 ------------- .../Services/RealmServiceTests.cs | 60 ++++++++++------ .../Contracts/IClientService.cs | 3 +- .../Contracts/IEncryptionService.cs | 2 +- .../Contracts/IKeyProvisioningService.cs | 8 --- IdentityShroud.Core/Contracts/IKeyService.cs | 12 ++++ .../Contracts/IRealmService.cs | 1 + .../DTO/JsonWebKey.cs | 0 .../DTO/JsonWebKeySet.cs | 0 .../IdentityShroud.Core.csproj | 1 + IdentityShroud.Core/Model/Client.cs | 2 +- IdentityShroud.Core/Security/AesGcmHelper.cs | 9 ++- .../Security/Keys/IKeyProvider.cs | 20 ++++++ .../Security/Keys/IKeyProviderFactory.cs | 7 ++ .../Security/Keys/KeyProviderFactory.cs | 17 +++++ .../Security/Keys/Rsa/RsaProvider.cs | 37 ++++++++++ IdentityShroud.Core/Services/ClientService.cs | 6 +- .../Services/EncryptionService.cs | 2 +- .../Services/KeyProvisioningService.cs | 30 -------- IdentityShroud.Core/Services/KeyService.cs | 52 ++++++++++++++ IdentityShroud.Core/Services/RealmService.cs | 21 +++++- .../EncryptionServiceSubstitute.cs | 4 +- IdentityShroud.sln.DotSettings.user | 15 ++-- 40 files changed, 474 insertions(+), 281 deletions(-) create mode 100644 IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs create mode 100644 IdentityShroud.Api/Apis/EndpointRouteBuilderExtensions.cs create mode 100644 IdentityShroud.Api/Apis/Filters/ClientIdValidationFilter.cs create mode 100644 IdentityShroud.Api/Apis/Filters/RealmIdValidationFilter.cs rename IdentityShroud.Api/Apis/Filters/{SlugValidationFilter.cs => RealmSlugValidationFilter.cs} (63%) create mode 100644 IdentityShroud.Api/Apis/OpenIdEndpoints.cs rename IdentityShroud.Api/{ => Apis}/Validation/RealmCreateRequestValidator.cs (100%) rename IdentityShroud.Api/{ => Apis}/Validation/ValidateFilter.cs (100%) delete mode 100644 IdentityShroud.Api/Validation/EndpointRouteBuilderExtensions.cs delete mode 100644 IdentityShroud.Core.Tests/Model/RealmKeyTests.cs delete mode 100644 IdentityShroud.Core/Contracts/IKeyProvisioningService.cs create mode 100644 IdentityShroud.Core/Contracts/IKeyService.cs rename {IdentityShroud.Api/Apis => IdentityShroud.Core}/DTO/JsonWebKey.cs (100%) rename {IdentityShroud.Api/Apis => IdentityShroud.Core}/DTO/JsonWebKeySet.cs (100%) create mode 100644 IdentityShroud.Core/Security/Keys/IKeyProvider.cs create mode 100644 IdentityShroud.Core/Security/Keys/IKeyProviderFactory.cs create mode 100644 IdentityShroud.Core/Security/Keys/KeyProviderFactory.cs create mode 100644 IdentityShroud.Core/Security/Keys/Rsa/RsaProvider.cs delete mode 100644 IdentityShroud.Core/Services/KeyProvisioningService.cs create mode 100644 IdentityShroud.Core/Services/KeyService.cs diff --git a/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs b/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs index 31c1e9d..8d08a27 100644 --- a/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs +++ b/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs @@ -44,7 +44,9 @@ public class RealmApisTests : IClassFixture 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 // 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(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] @@ -137,7 +144,7 @@ public class RealmApisTests : IClassFixture // 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); diff --git a/IdentityShroud.Api.Tests/Mappers/KeyMapperTests.cs b/IdentityShroud.Api.Tests/Mappers/KeyMapperTests.cs index 9cd88e0..767337e 100644 --- a/IdentityShroud.Api.Tests/Mappers/KeyMapperTests.cs +++ b/IdentityShroud.Api.Tests/Mappers/KeyMapperTests.cs @@ -1,41 +1,17 @@ -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); - - RealmKey realmKey = new() - { - Id = new("60bb79cf-4bac-4521-87f2-ac87cc15541f"), - CreatedAt = DateTime.UtcNow, - Priority = 10, - }; - realmKey.SetPrivateKey(_encryptionService, rsa.ExportPkcs8PrivateKey()); - - // Act - KeyMapper mapper = new(_encryptionService); - JsonWebKey jwk = mapper.KeyToJsonWebKey(realmKey); - - Assert.Equal("RSA", jwk.KeyType); - Assert.Equal(realmKey.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)); - } -} \ No newline at end of file +// public class KeyMapperTests +// { +// private readonly IEncryptionService _encryptionService = EncryptionServiceSubstitute.CreatePassthrough(); +// +// [Fact] +// public void Test() +// { +// } +// } \ No newline at end of file diff --git a/IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs b/IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs new file mode 100644 index 0000000..196b15d --- /dev/null +++ b/IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs @@ -0,0 +1,43 @@ +using System.Buffers.Text; +using System.Security.Cryptography; +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Model; +using IdentityShroud.Core.Security.Keys; +using IdentityShroud.Core.Services; +using IdentityShroud.TestUtils.Substitutes; + +namespace IdentityShroud.Api.Tests.Mappers; + +public class KeyServiceTests +{ + private readonly IEncryptionService _encryptionService = EncryptionServiceSubstitute.CreatePassthrough(); + //private readonly IKeyProviderFactory _keyProviderFactory = Substitute.For(); + + [Fact] + public void Test() + { + // Setup + using RSA rsa = RSA.Create(2048); + + RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false); + + RealmKey realmKey = new( + new("60bb79cf-4bac-4521-87f2-ac87cc15541f"), + "RSA", + rsa.ExportPkcs8PrivateKey(), + DateTime.UtcNow) + { + Priority = 10, + }; + + // Act + KeyService sut = new(_encryptionService, new KeyProviderFactory(), new ClockService()); + var jwk = sut.CreateJsonWebKey(realmKey); + + 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)); + } +} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/ClientApi.cs b/IdentityShroud.Api/Apis/ClientApi.cs index 86d965f..fd3e804 100644 --- a/IdentityShroud.Api/Apis/ClientApi.cs +++ b/IdentityShroud.Api/Apis/ClientApi.cs @@ -20,11 +20,17 @@ public static class ClientApi public static void MapEndpoints(this IEndpointRouteBuilder erp) { - erp.MapPost("", ClientCreate) + RouteGroupBuilder clientsGroup = erp.MapGroup("clients"); + + clientsGroup.MapPost("", ClientCreate) .Validate() .WithName("ClientCreate") .Produces(StatusCodes.Status201Created); - erp.MapGet("{clientId}", ClientGet) + + var clientIdGroup = clientsGroup.MapGroup("{clientId}") + .AddEndpointFilter(); + + clientIdGroup.MapGet("", ClientGet) .WithName(ClientGetRouteName); } @@ -33,7 +39,7 @@ public static class ClientApi throw new NotImplementedException(); } - private static async Task, InternalServerError>> + private static async Task, InternalServerError>> ClientCreate( ClientCreateRequest request, [FromServices] IClientService service, @@ -42,14 +48,22 @@ public static class ClientApi { Realm realm = context.GetValidatedRealm(); Result result = await service.Create(realm.Id, request, cancellationToken); - - // Should i have two set of paths? one for actual REST and one for openid - // openid: auth/realms/{realmSlug}/.well-known/openid-configuration - // openid: auth/realms/{realmSlug}/openid-connect/(auth|token|jwks) - // api: api/v1/realms/{realmId}/.... - // api: api/v1/realms/{realmId}/clients/{clientId} - - //return Results.CreatedAtRoute(ClientGetRouteName, [ "realmSlug" = realmId!?]) + + 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, + }); throw new NotImplementedException(); } } \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/EndpointRouteBuilderExtensions.cs b/IdentityShroud.Api/Apis/EndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000..3c47b48 --- /dev/null +++ b/IdentityShroud.Api/Apis/EndpointRouteBuilderExtensions.cs @@ -0,0 +1,15 @@ +namespace IdentityShroud.Api; + +public static class EndpointRouteBuilderExtensions +{ + public static RouteHandlerBuilder Validate(this RouteHandlerBuilder builder) where TDto : class + => builder.AddEndpointFilter>(); + + public static void MapApis(this IEndpointRouteBuilder erp) + { + RealmApi.MapRealmEndpoints(erp); + + OpenIdEndpoints.MapEndpoints(erp); + } + +} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/Filters/ClientIdValidationFilter.cs b/IdentityShroud.Api/Apis/Filters/ClientIdValidationFilter.cs new file mode 100644 index 0000000..8030153 --- /dev/null +++ b/IdentityShroud.Api/Apis/Filters/ClientIdValidationFilter.cs @@ -0,0 +1,20 @@ +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Model; + +namespace IdentityShroud.Api; + +public class ClientIdValidationFilter(IClientService clientService) : IEndpointFilter +{ + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + int id = context.Arguments.OfType().First(); + Client? client = await clientService.FindById(id, context.HttpContext.RequestAborted); + if (client is null) + { + return Results.NotFound(); + } + context.HttpContext.Items["ClientEntity"] = client; + + return await next(context); + } +} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/Filters/RealmIdValidationFilter.cs b/IdentityShroud.Api/Apis/Filters/RealmIdValidationFilter.cs new file mode 100644 index 0000000..97a1bb9 --- /dev/null +++ b/IdentityShroud.Api/Apis/Filters/RealmIdValidationFilter.cs @@ -0,0 +1,20 @@ +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Model; + +namespace IdentityShroud.Api; + +public class RealmIdValidationFilter(IRealmService realmService) : IEndpointFilter +{ + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + Guid id = context.Arguments.OfType().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); + } +} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/Filters/SlugValidationFilter.cs b/IdentityShroud.Api/Apis/Filters/RealmSlugValidationFilter.cs similarity index 63% rename from IdentityShroud.Api/Apis/Filters/SlugValidationFilter.cs rename to IdentityShroud.Api/Apis/Filters/RealmSlugValidationFilter.cs index b7efc2b..862b599 100644 --- a/IdentityShroud.Api/Apis/Filters/SlugValidationFilter.cs +++ b/IdentityShroud.Api/Apis/Filters/RealmSlugValidationFilter.cs @@ -10,12 +10,13 @@ namespace IdentityShroud.Api; /// consistently. /// /// -public class SlugValidationFilter(IRealmService realmService) : IEndpointFilter +public class RealmSlugValidationFilter(IRealmService realmService) : IEndpointFilter { public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) { - string slug = context.Arguments.OfType().First(); - Realm? realm = await realmService.FindBySlug(slug); + string realmSlug = context.Arguments.OfType().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(); diff --git a/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs b/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs index 94d37e7..36bd200 100644 --- a/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs +++ b/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs @@ -7,41 +7,14 @@ using Microsoft.AspNetCore.WebUtilities; namespace IdentityShroud.Api.Mappers; -public class KeyMapper(IEncryptionService encryptionService) +public class KeyMapper(IKeyService keyService) { - public JsonWebKey? KeyToJsonWebKey(RealmKey realmKey) - { - - JsonWebKey result = new() - { - KeyId = realmKey.Id.ToString(), - Use = "sig", - }; - switch (realmKey.KeyType) - { - case "RSA": - using (var rsa = RsaHelper.LoadFromPkcs8(realmKey.GetPrivateKey(encryptionService))) - { - RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false); - result.KeyType = rsa.SignatureAlgorithm; - result.Exponent = WebEncoders.Base64UrlEncode(parameters.Exponent!); - result.Modulus = WebEncoders.Base64UrlEncode(parameters.Modulus!); - } - break; - - default: - return null; - } - - return result; - } - public JsonWebKeySet KeyListToJsonWebKeySet(IEnumerable keys) { JsonWebKeySet wks = new(); foreach (var k in keys) { - var wk = KeyToJsonWebKey(k); + var wk = keyService.CreateJsonWebKey(k); if (wk is {}) { wks.Keys.Add(wk); diff --git a/IdentityShroud.Api/Apis/OpenIdEndpoints.cs b/IdentityShroud.Api/Apis/OpenIdEndpoints.cs new file mode 100644 index 0000000..6565413 --- /dev/null +++ b/IdentityShroud.Api/Apis/OpenIdEndpoints.cs @@ -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(); + 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> 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, 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(); + } + +} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/RealmApi.cs b/IdentityShroud.Api/Apis/RealmApi.cs index 47b0549..88a5179 100644 --- a/IdentityShroud.Api/Apis/RealmApi.cs +++ b/IdentityShroud.Api/Apis/RealmApi.cs @@ -1,7 +1,4 @@ -using FluentResults; -using IdentityShroud.Api.Mappers; using IdentityShroud.Core.Contracts; -using IdentityShroud.Core.Messages; using IdentityShroud.Core.Messages.Realm; using IdentityShroud.Core.Model; using IdentityShroud.Core.Services; @@ -15,29 +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 erp) + public static void MapRealmEndpoints(IEndpointRouteBuilder erp) { - var realmsGroup = erp.MapGroup("/realms"); + var realmsGroup = erp.MapGroup("/api/v1/realms"); realmsGroup.MapPost("", RealmCreate) .Validate() .WithName("Create Realm") .Produces(StatusCodes.Status201Created); - var realmSlugGroup = realmsGroup.MapGroup("{realmSlug}") - .AddEndpointFilter(); - realmSlugGroup.MapGet(".well-known/openid-configuration", GetOpenIdConfiguration); + var realmIdGroup = realmsGroup.MapGroup("{realmId}") + .AddEndpointFilter(); - RouteGroupBuilder clientsGroup = realmSlugGroup.MapGroup("clients"); + 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, InternalServerError>> @@ -50,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, 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> 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); - } } \ No newline at end of file diff --git a/IdentityShroud.Api/Validation/RealmCreateRequestValidator.cs b/IdentityShroud.Api/Apis/Validation/RealmCreateRequestValidator.cs similarity index 100% rename from IdentityShroud.Api/Validation/RealmCreateRequestValidator.cs rename to IdentityShroud.Api/Apis/Validation/RealmCreateRequestValidator.cs diff --git a/IdentityShroud.Api/Validation/ValidateFilter.cs b/IdentityShroud.Api/Apis/Validation/ValidateFilter.cs similarity index 100% rename from IdentityShroud.Api/Validation/ValidateFilter.cs rename to IdentityShroud.Api/Apis/Validation/ValidateFilter.cs diff --git a/IdentityShroud.Api/IdentityShroud.Api.csproj.DotSettings b/IdentityShroud.Api/IdentityShroud.Api.csproj.DotSettings index c053b70..c9c4f6a 100644 --- a/IdentityShroud.Api/IdentityShroud.Api.csproj.DotSettings +++ b/IdentityShroud.Api/IdentityShroud.Api.csproj.DotSettings @@ -1,4 +1,5 @@  True True + True True \ No newline at end of file diff --git a/IdentityShroud.Api/Program.cs b/IdentityShroud.Api/Program.cs index bb35f98..0a145c2 100644 --- a/IdentityShroud.Api/Program.cs +++ b/IdentityShroud.Api/Program.cs @@ -4,6 +4,7 @@ using IdentityShroud.Api.Mappers; 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; @@ -35,11 +36,15 @@ void ConfigureBuilder(WebApplicationBuilder builder) // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi services.AddOpenApi(); services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddOptions().Bind(configuration.GetSection("db")); services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); + services.AddScoped(); services.AddValidatorsFromAssemblyContaining(); @@ -56,7 +61,8 @@ void ConfigureApplication(WebApplication app) app.MapOpenApi(); } app.UseSerilogRequestLogging(); - app.MapRealmEndpoints(); + app.MapApis(); + // app.UseRouting(); // app.MapControllers(); } diff --git a/IdentityShroud.Api/Validation/EndpointRouteBuilderExtensions.cs b/IdentityShroud.Api/Validation/EndpointRouteBuilderExtensions.cs deleted file mode 100644 index e6952be..0000000 --- a/IdentityShroud.Api/Validation/EndpointRouteBuilderExtensions.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace IdentityShroud.Api; - -public static class EndpointRouteBuilderExtensions -{ - public static RouteHandlerBuilder Validate(this RouteHandlerBuilder builder) where TDto : class - => builder.AddEndpointFilter>(); -} \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj b/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj index 8af08c1..40c87d5 100644 --- a/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj +++ b/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj @@ -30,4 +30,8 @@ + + + + \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/Model/RealmKeyTests.cs b/IdentityShroud.Core.Tests/Model/RealmKeyTests.cs deleted file mode 100644 index 77969d8..0000000 --- a/IdentityShroud.Core.Tests/Model/RealmKeyTests.cs +++ /dev/null @@ -1,51 +0,0 @@ -using IdentityShroud.Core.Contracts; -using IdentityShroud.Core.Model; - -namespace IdentityShroud.Core.Tests.Model; - -public class RealmKeyTests -{ - [Fact] - public void SetNewKey() - { - byte[] privateKey = [5, 6, 7, 8]; - byte[] encryptedPrivateKey = [1, 2, 3, 4]; - - var encryptionService = Substitute.For(); - encryptionService - .Encrypt(Arg.Any()) - .Returns(x => encryptedPrivateKey); - - RealmKey realmKey = new(); - realmKey.SetPrivateKey(encryptionService, privateKey); - - // should be able to return original without calling decrypt - Assert.Equal(privateKey, realmKey.GetPrivateKey(encryptionService)); - Assert.Equal(encryptedPrivateKey, realmKey.PrivateKeyEncrypted); - - encryptionService.Received(1).Encrypt(privateKey); - encryptionService.DidNotReceive().Decrypt(Arg.Any()); - } - - [Fact] - public void GetDecryptedKey() - { - byte[] privateKey = [5, 6, 7, 8]; - byte[] encryptedPrivateKey = [1, 2, 3, 4]; - - var encryptionService = Substitute.For(); - encryptionService - .Decrypt(encryptedPrivateKey) - .Returns(x => privateKey); - - RealmKey realmKey = new(); - realmKey.PrivateKeyEncrypted = encryptedPrivateKey; - - // should be able to return original without calling decrypt - Assert.Equal(privateKey, realmKey.GetPrivateKey(encryptionService)); - Assert.Equal(encryptedPrivateKey, realmKey.PrivateKeyEncrypted); - - encryptionService.Received(1).Decrypt(encryptedPrivateKey); - } - -} \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs b/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs index 5b830ea..d6aec76 100644 --- a/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs +++ b/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs @@ -1,4 +1,6 @@ using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Model; +using IdentityShroud.Core.Security.Keys; using IdentityShroud.Core.Services; using IdentityShroud.Core.Tests.Fixtures; using IdentityShroud.TestUtils.Substitutes; @@ -9,7 +11,7 @@ namespace IdentityShroud.Core.Tests.Services; public class RealmServiceTests : IClassFixture { private readonly DbFixture _dbFixture; - private readonly IEncryptionService _encryptionService = EncryptionServiceSubstitute.CreatePassthrough(); + private readonly IKeyService _keyService = Substitute.For(); public RealmServiceTests(DbFixture dbFixture) { @@ -34,25 +36,37 @@ public class RealmServiceTests : IClassFixture if (idString is not null) realmId = new(idString); - using Db db = _dbFixture.CreateDbContext(); - RealmService sut = new(db, _encryptionService); - // Act - - var response = await sut.Create( - new(realmId, "slug", "New realm"), - TestContext.Current.CancellationToken); - - // Verify - RealmCreateResponse val = ResultAssert.Success(response); - if (realmId.HasValue) - Assert.Equal(realmId, val.Id); - else - Assert.NotEqual(Guid.Empty, val.Id); - - Assert.Equal("slug", val.Slug); - Assert.Equal("New realm", val.Name); - - // TODO verify data has been stored! + RealmCreateResponse? val; + await using (var db = _dbFixture.CreateDbContext()) + { + _keyService.CreateKey(Arg.Any()) + .Returns(new RealmKey(Guid.NewGuid(), "TST", [21], DateTime.UtcNow)); + // Act + RealmService sut = new(db, _keyService); + var response = await sut.Create( + new(realmId, "slug", "New realm"), + TestContext.Current.CancellationToken); + + // Verify + val = ResultAssert.Success(response); + if (realmId.HasValue) + Assert.Equal(realmId, val.Id); + else + Assert.NotEqual(Guid.Empty, val.Id); + + Assert.Equal("slug", val.Slug); + Assert.Equal("New realm", val.Name); + + _keyService.Received().CreateKey(Arg.Any()); + } + + await using (var db = _dbFixture.CreateDbContext()) + { + var dbRecord = await db.Realms + .Include(e => e.Keys) + .SingleAsync(e => e.Id == val.Id, TestContext.Current.CancellationToken); + Assert.Equal("TST", dbRecord.Keys[0].KeyType); + } } [Theory] @@ -60,7 +74,7 @@ public class RealmServiceTests : IClassFixture [InlineData("foo", "Foo")] public async Task FindBySlug(string slug, string? name) { - using (var setupContext = _dbFixture.CreateDbContext()) + await using (var setupContext = _dbFixture.CreateDbContext()) { setupContext.Realms.Add(new() { @@ -76,8 +90,8 @@ public class RealmServiceTests : IClassFixture await setupContext.SaveChangesAsync(TestContext.Current.CancellationToken); } - using Db actContext = _dbFixture.CreateDbContext(); - RealmService sut = new(actContext, _encryptionService); + await using var actContext = _dbFixture.CreateDbContext(); + RealmService sut = new(actContext, _keyService); // Act var result = await sut.FindBySlug(slug, TestContext.Current.CancellationToken); diff --git a/IdentityShroud.Core/Contracts/IClientService.cs b/IdentityShroud.Core/Contracts/IClientService.cs index 5c2295b..15c0eba 100644 --- a/IdentityShroud.Core/Contracts/IClientService.cs +++ b/IdentityShroud.Core/Contracts/IClientService.cs @@ -6,7 +6,7 @@ namespace IdentityShroud.Core.Contracts; public class ClientCreateRequest { - public string ClientId { get; set; } + public required string ClientId { get; set; } public string? Name { get; set; } public string? Description { get; set; } public string? SignatureAlgorithm { get; set; } @@ -22,4 +22,5 @@ public interface IClientService CancellationToken ct = default); Task GetByClientId(string clientId, CancellationToken ct = default); + Task FindById(int id, CancellationToken ct = default); } \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/IEncryptionService.cs b/IdentityShroud.Core/Contracts/IEncryptionService.cs index f85487d..a737732 100644 --- a/IdentityShroud.Core/Contracts/IEncryptionService.cs +++ b/IdentityShroud.Core/Contracts/IEncryptionService.cs @@ -3,5 +3,5 @@ namespace IdentityShroud.Core.Contracts; public interface IEncryptionService { byte[] Encrypt(byte[] plain); - byte[] Decrypt(byte[] cipher); + byte[] Decrypt(ReadOnlyMemory cipher); } \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/IKeyProvisioningService.cs b/IdentityShroud.Core/Contracts/IKeyProvisioningService.cs deleted file mode 100644 index 396d765..0000000 --- a/IdentityShroud.Core/Contracts/IKeyProvisioningService.cs +++ /dev/null @@ -1,8 +0,0 @@ -using IdentityShroud.Core.Model; - -namespace IdentityShroud.Core.Contracts; - -public interface IKeyProvisioningService -{ - RealmKey CreateRsaKey(int keySize = 2048); -} \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/IKeyService.cs b/IdentityShroud.Core/Contracts/IKeyService.cs new file mode 100644 index 0000000..4f6b5f7 --- /dev/null +++ b/IdentityShroud.Core/Contracts/IKeyService.cs @@ -0,0 +1,12 @@ +using IdentityShroud.Core.Messages; +using IdentityShroud.Core.Model; +using IdentityShroud.Core.Security.Keys; + +namespace IdentityShroud.Core.Contracts; + +public interface IKeyService +{ + RealmKey CreateKey(KeyPolicy policy); + + JsonWebKey? CreateJsonWebKey(RealmKey realmKey); +} \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/IRealmService.cs b/IdentityShroud.Core/Contracts/IRealmService.cs index 9940466..b740aa5 100644 --- a/IdentityShroud.Core/Contracts/IRealmService.cs +++ b/IdentityShroud.Core/Contracts/IRealmService.cs @@ -6,6 +6,7 @@ namespace IdentityShroud.Core.Contracts; public interface IRealmService { + Task FindById(Guid id, CancellationToken ct = default); Task FindBySlug(string slug, CancellationToken ct = default); Task> Create(RealmCreateRequest request, CancellationToken ct = default); diff --git a/IdentityShroud.Api/Apis/DTO/JsonWebKey.cs b/IdentityShroud.Core/DTO/JsonWebKey.cs similarity index 100% rename from IdentityShroud.Api/Apis/DTO/JsonWebKey.cs rename to IdentityShroud.Core/DTO/JsonWebKey.cs diff --git a/IdentityShroud.Api/Apis/DTO/JsonWebKeySet.cs b/IdentityShroud.Core/DTO/JsonWebKeySet.cs similarity index 100% rename from IdentityShroud.Api/Apis/DTO/JsonWebKeySet.cs rename to IdentityShroud.Core/DTO/JsonWebKeySet.cs diff --git a/IdentityShroud.Core/IdentityShroud.Core.csproj b/IdentityShroud.Core/IdentityShroud.Core.csproj index a87c996..d9d6809 100644 --- a/IdentityShroud.Core/IdentityShroud.Core.csproj +++ b/IdentityShroud.Core/IdentityShroud.Core.csproj @@ -11,6 +11,7 @@ + diff --git a/IdentityShroud.Core/Model/Client.cs b/IdentityShroud.Core/Model/Client.cs index 43f2f1a..a8c9e29 100644 --- a/IdentityShroud.Core/Model/Client.cs +++ b/IdentityShroud.Core/Model/Client.cs @@ -10,7 +10,7 @@ namespace IdentityShroud.Core.Model; public class Client { [Key] - public Guid Id { get; set; } + public int Id { get; set; } public Guid RealmId { get; set; } [MaxLength(40)] public required string ClientId { get; set; } diff --git a/IdentityShroud.Core/Security/AesGcmHelper.cs b/IdentityShroud.Core/Security/AesGcmHelper.cs index 62abf6a..bfa5809 100644 --- a/IdentityShroud.Core/Security/AesGcmHelper.cs +++ b/IdentityShroud.Core/Security/AesGcmHelper.cs @@ -31,9 +31,8 @@ public static class AesGcmHelper // • payload – byte[] containing nonce‖ciphertext‖tag // • returns – the original plaintext bytes // -------------------------------------------------------------------- - public static byte[] DecryptAesGcm(byte[] payload, byte[] key) + public static byte[] DecryptAesGcm(ReadOnlyMemory payload, byte[] key) { - if (payload == null) throw new ArgumentNullException(nameof(payload)); if (key == null) throw new ArgumentNullException(nameof(key)); if (key.Length != 32) // 256‑bit key throw new ArgumentException("Key must be 256 bits (32 bytes) for AES‑256‑GCM.", nameof(key)); @@ -49,9 +48,9 @@ public static class AesGcmHelper if (payload.Length < nonceSize + tagSize) throw new ArgumentException("Payload is too short to contain nonce, ciphertext, and tag.", nameof(payload)); - ReadOnlySpan nonce = new(payload, 0, nonceSize); - ReadOnlySpan ciphertext = new(payload, nonceSize, payload.Length - nonceSize - tagSize); - ReadOnlySpan tag = new(payload, payload.Length - tagSize, tagSize); + ReadOnlySpan nonce = payload.Span[..nonceSize]; + ReadOnlySpan ciphertext = payload.Span.Slice(nonceSize, payload.Length - nonceSize - tagSize); + ReadOnlySpan tag = payload.Span.Slice(payload.Length - tagSize, tagSize); byte[] plaintext = new byte[ciphertext.Length]; diff --git a/IdentityShroud.Core/Security/Keys/IKeyProvider.cs b/IdentityShroud.Core/Security/Keys/IKeyProvider.cs new file mode 100644 index 0000000..ec095b5 --- /dev/null +++ b/IdentityShroud.Core/Security/Keys/IKeyProvider.cs @@ -0,0 +1,20 @@ +using IdentityShroud.Core.Messages; +using IdentityShroud.Core.Model; + +namespace IdentityShroud.Core.Security.Keys; + +public abstract class KeyPolicy +{ + public abstract string KeyType { get; } +} + + +public interface IKeyProvider +{ + byte[] CreateKey(KeyPolicy policy); + + void SetJwkParameters(byte[] key, JsonWebKey jwk); +} + + + diff --git a/IdentityShroud.Core/Security/Keys/IKeyProviderFactory.cs b/IdentityShroud.Core/Security/Keys/IKeyProviderFactory.cs new file mode 100644 index 0000000..485e6e5 --- /dev/null +++ b/IdentityShroud.Core/Security/Keys/IKeyProviderFactory.cs @@ -0,0 +1,7 @@ +namespace IdentityShroud.Core.Security.Keys; + + +public interface IKeyProviderFactory +{ + public IKeyProvider CreateProvider(string keyType); +} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Keys/KeyProviderFactory.cs b/IdentityShroud.Core/Security/Keys/KeyProviderFactory.cs new file mode 100644 index 0000000..a1c3472 --- /dev/null +++ b/IdentityShroud.Core/Security/Keys/KeyProviderFactory.cs @@ -0,0 +1,17 @@ +using IdentityShroud.Core.Security.Keys.Rsa; + +namespace IdentityShroud.Core.Security.Keys; + +public class KeyProviderFactory : IKeyProviderFactory +{ + public IKeyProvider CreateProvider(string keyType) + { + switch (keyType) + { + case "RSA": + return new RsaProvider(); + default: + throw new NotImplementedException(); + } + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Keys/Rsa/RsaProvider.cs b/IdentityShroud.Core/Security/Keys/Rsa/RsaProvider.cs new file mode 100644 index 0000000..a5bcee8 --- /dev/null +++ b/IdentityShroud.Core/Security/Keys/Rsa/RsaProvider.cs @@ -0,0 +1,37 @@ +using System.Buffers.Text; +using System.Security.Cryptography; +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Messages; +using IdentityShroud.Core.Model; + +namespace IdentityShroud.Core.Security.Keys.Rsa; + +public class RsaKeyPolicy : KeyPolicy +{ + public override string KeyType => "RSA"; + public int KeySize { get; } = 2048; +} + +public class RsaProvider : IKeyProvider +{ + public byte[] CreateKey(KeyPolicy policy) + { + if (policy is RsaKeyPolicy p) + { + using var rsa = RSA.Create(p.KeySize); + return rsa.ExportPkcs8PrivateKey(); + } + + throw new ArgumentException("Incorrect policy type", nameof(policy)); + } + + public void SetJwkParameters(byte[] key, JsonWebKey jwk) + { + using var rsa = RSA.Create(); + rsa.ImportPkcs8PrivateKey(key, out _); + var parameters = rsa.ExportParameters(includePrivateParameters: false); + + jwk.Exponent = Base64Url.EncodeToString(parameters.Exponent); + jwk.Modulus = Base64Url.EncodeToString(parameters.Modulus); + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/ClientService.cs b/IdentityShroud.Core/Services/ClientService.cs index 37a8391..2e556d4 100644 --- a/IdentityShroud.Core/Services/ClientService.cs +++ b/IdentityShroud.Core/Services/ClientService.cs @@ -14,7 +14,6 @@ public class ClientService( { Client client = new() { - Id = Guid.NewGuid(), RealmId = realmId, ClientId = request.ClientId, Name = request.Name, @@ -40,6 +39,11 @@ public class ClientService( return await db.Clients.FirstOrDefaultAsync(c => c.ClientId == clientId, ct); } + public async Task FindById(int id, CancellationToken ct = default) + { + return await db.Clients.FirstOrDefaultAsync(c => c.Id == id, ct); + } + private ClientSecret CreateSecret() { byte[] secret = RandomNumberGenerator.GetBytes(24); diff --git a/IdentityShroud.Core/Services/EncryptionService.cs b/IdentityShroud.Core/Services/EncryptionService.cs index 24cdd18..a4455e0 100644 --- a/IdentityShroud.Core/Services/EncryptionService.cs +++ b/IdentityShroud.Core/Services/EncryptionService.cs @@ -20,7 +20,7 @@ public class EncryptionService : IEncryptionService return AesGcmHelper.EncryptAesGcm(plain, encryptionKey); } - public byte[] Decrypt(byte[] cipher) + public byte[] Decrypt(ReadOnlyMemory cipher) { return AesGcmHelper.DecryptAesGcm(cipher, encryptionKey); } diff --git a/IdentityShroud.Core/Services/KeyProvisioningService.cs b/IdentityShroud.Core/Services/KeyProvisioningService.cs deleted file mode 100644 index 313e894..0000000 --- a/IdentityShroud.Core/Services/KeyProvisioningService.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Security.Cryptography; -using IdentityShroud.Core.Contracts; -using IdentityShroud.Core.Model; - -namespace IdentityShroud.Core.Services; - -public class KeyProvisioningService( - IEncryptionService encryptionService, - IClock clock) : IKeyProvisioningService -{ - public RealmKey CreateRsaKey(int keySize = 2048) - { - using var rsa = RSA.Create(keySize); - return CreateKey("RSA", rsa.ExportPkcs8PrivateKey()); - } - - private RealmKey CreateKey(string keyType, byte[] keyData) => - new RealmKey( - Guid.NewGuid(), - keyType, - encryptionService.Encrypt(keyData), - clock.UtcNow()); - - // public byte[] GetPrivateKey(IEncryptionService encryptionService) - // { - // if (_privateKeyDecrypted.Length == 0 && PrivateKeyEncrypted.Length > 0) - // _privateKeyDecrypted = encryptionService.Decrypt(PrivateKeyEncrypted); - // return _privateKeyDecrypted; - // } -} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/KeyService.cs b/IdentityShroud.Core/Services/KeyService.cs new file mode 100644 index 0000000..440dff9 --- /dev/null +++ b/IdentityShroud.Core/Services/KeyService.cs @@ -0,0 +1,52 @@ +using System.Security.Cryptography; +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Messages; +using IdentityShroud.Core.Model; +using IdentityShroud.Core.Security.Keys; + +namespace IdentityShroud.Core.Services; + +public class KeyService( + IEncryptionService 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.KeyDataEncrypted), + jwk); + + return jwk; + } + + private RealmKey CreateKey(string keyType, byte[] plainKey) => + new RealmKey( + Guid.NewGuid(), + keyType, + cryptor.Encrypt(plainKey), + clock.UtcNow()); + + // public byte[] GetPrivateKey(IEncryptionService encryptionService) + // { + // if (_privateKeyDecrypted.Length == 0 && PrivateKeyEncrypted.Length > 0) + // _privateKeyDecrypted = encryptionService.Decrypt(PrivateKeyEncrypted); + // return _privateKeyDecrypted; + // } +} diff --git a/IdentityShroud.Core/Services/RealmService.cs b/IdentityShroud.Core/Services/RealmService.cs index 206d38e..5385658 100644 --- a/IdentityShroud.Core/Services/RealmService.cs +++ b/IdentityShroud.Core/Services/RealmService.cs @@ -3,6 +3,8 @@ 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 +13,14 @@ public record RealmCreateResponse(Guid Id, string Slug, string Name); public class RealmService( Db db, - IKeyProvisioningService keyProvisioningService) : IRealmService + IKeyService keyService) : IRealmService { + public async Task FindById(Guid id, CancellationToken ct = default) + { + return await db.Realms + .SingleOrDefaultAsync(r => r.Id == id, ct); + } + public async Task FindBySlug(string slug, CancellationToken ct = default) { return await db.Realms @@ -26,8 +34,9 @@ public class RealmService( Id = request.Id ?? Guid.CreateVersion7(), Slug = request.Slug ?? SlugHelper.GenerateSlug(request.Name), Name = request.Name, - Keys = [ keyProvisioningService.CreateRsaKey() ], }; + + realm.Keys.Add(keyService.CreateKey(GetKeyPolicy(realm))); db.Add(realm); await db.SaveChangesAsync(ct); @@ -36,6 +45,14 @@ public class RealmService( realm.Id, realm.Slug, realm.Name); } + /// + /// Place holder for getting policies from the realm and falling back to sane defaults when no policies have been set. + /// + /// + /// + private KeyPolicy GetKeyPolicy(Realm _) => new RsaKeyPolicy(); + + public async Task LoadActiveKeys(Realm realm) { await db.Entry(realm).Collection(r => r.Keys) diff --git a/IdentityShroud.TestUtils/Substitutes/EncryptionServiceSubstitute.cs b/IdentityShroud.TestUtils/Substitutes/EncryptionServiceSubstitute.cs index bb26ee9..5a81240 100644 --- a/IdentityShroud.TestUtils/Substitutes/EncryptionServiceSubstitute.cs +++ b/IdentityShroud.TestUtils/Substitutes/EncryptionServiceSubstitute.cs @@ -11,8 +11,8 @@ public static class EncryptionServiceSubstitute .Encrypt(Arg.Any()) .Returns(x => x.ArgAt(0)); encryptionService - .Decrypt(Arg.Any()) - .Returns(x => x.ArgAt(0)); + .Decrypt(Arg.Any>()) + .Returns(x => x.ArgAt>(0).ToArray()); return encryptionService; } } \ No newline at end of file diff --git a/IdentityShroud.sln.DotSettings.user b/IdentityShroud.sln.DotSettings.user index 26df40e..9992676 100644 --- a/IdentityShroud.sln.DotSettings.user +++ b/IdentityShroud.sln.DotSettings.user @@ -5,31 +5,32 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded /home/eelke/.cache/JetBrains/Rider2025.3/resharper-host/temp/Rider/vAny/CoverageData/_IdentityShroud.-1277985570/Snapshot/snapshot.utdcvr /home/eelke/.dotnet/dotnet /home/eelke/.dotnet/sdk/10.0.102/MSBuild.dll - <SessionState ContinuousTestingMode="0" Name="All tests from Solution #3" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> </SessionState> - <SessionState ContinuousTestingMode="0" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <Solution /> -</SessionState> - <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution #2" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <Solution /> -</SessionState> + + + + \ No newline at end of file From cd2ec646fd98fe0fd1d27a5e2013fe3a36b52bc7 Mon Sep 17 00:00:00 2001 From: eelke Date: Sun, 22 Feb 2026 08:21:54 +0100 Subject: [PATCH 03/22] Add some tests --- .../Services/ClientServiceTests.cs | 154 ++++++++++++++++++ .../Services/RealmServiceTests.cs | 39 ++++- IdentityShroud.sln.DotSettings.user | 5 +- 3 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 IdentityShroud.Core.Tests/Services/ClientServiceTests.cs diff --git a/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs b/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs new file mode 100644 index 0000000..cb2e772 --- /dev/null +++ b/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs @@ -0,0 +1,154 @@ +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Model; +using IdentityShroud.Core.Services; +using IdentityShroud.Core.Tests.Fixtures; +using IdentityShroud.TestUtils.Substitutes; +using Microsoft.EntityFrameworkCore; + +namespace IdentityShroud.Core.Tests.Services; + +public class ClientServiceTests : IClassFixture +{ + private readonly DbFixture _dbFixture; + private readonly IEncryptionService _encryptionService = EncryptionServiceSubstitute.CreatePassthrough(); + private readonly IClock _clock = Substitute.For(); + private readonly Guid _realmId = new("a1b2c3d4-0000-0000-0000-000000000001"); + + public ClientServiceTests(DbFixture dbFixture) + { + _dbFixture = dbFixture; + using Db db = dbFixture.CreateDbContext(); + if (!db.Database.EnsureCreated()) + TruncateTables(db); + EnsureRealm(db); + } + + private void TruncateTables(Db db) + { + db.Database.ExecuteSqlRaw("TRUNCATE client CASCADE;"); + db.Database.ExecuteSqlRaw("TRUNCATE realm CASCADE;"); + } + + private void EnsureRealm(Db db) + { + if (!db.Realms.Any(r => r.Id == _realmId)) + { + db.Realms.Add(new() { Id = _realmId, Slug = "test-realm", Name = "Test Realm" }); + db.SaveChanges(); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Create(bool allowClientCredentialsFlow) + { + // Setup + DateTime now = DateTime.UtcNow; + _clock.UtcNow().Returns(now); + + Client val; + await using (var db = _dbFixture.CreateDbContext()) + { + // Act + ClientService sut = new(db, _encryptionService, _clock); + var response = await sut.Create( + _realmId, + new ClientCreateRequest + { + ClientId = "test-client", + Name = "Test Client", + Description = "A test client", + AllowClientCredentialsFlow = allowClientCredentialsFlow, + }, + TestContext.Current.CancellationToken); + + // Verify + val = ResultAssert.Success(response); + Assert.Equal(_realmId, val.RealmId); + Assert.Equal("test-client", val.ClientId); + Assert.Equal("Test Client", val.Name); + Assert.Equal("A test client", val.Description); + Assert.Equal(allowClientCredentialsFlow, val.AllowClientCredentialsFlow); + Assert.Equal(now, val.CreatedAt); + } + + await using (var db = _dbFixture.CreateDbContext()) + { + var dbRecord = await db.Clients + .Include(e => e.Secrets) + .SingleAsync(e => e.Id == val.Id, TestContext.Current.CancellationToken); + + if (allowClientCredentialsFlow) + Assert.Single(dbRecord.Secrets); + else + Assert.Empty(dbRecord.Secrets); + } + } + + [Theory] + [InlineData("existing-client", true)] + [InlineData("missing-client", false)] + public async Task GetByClientId(string clientId, bool shouldFind) + { + // Setup + _clock.UtcNow().Returns(DateTime.UtcNow); + await using (var setupContext = _dbFixture.CreateDbContext()) + { + setupContext.Clients.Add(new() + { + RealmId = _realmId, + ClientId = "existing-client", + CreatedAt = DateTime.UtcNow, + }); + + await setupContext.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + await using var actContext = _dbFixture.CreateDbContext(); + // Act + ClientService sut = new(actContext, _encryptionService, _clock); + Client? result = await sut.GetByClientId(clientId, TestContext.Current.CancellationToken); + + // Verify + if (shouldFind) + Assert.NotNull(result); + else + Assert.Null(result); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task FindById(bool shouldFind) + { + // Setup + _clock.UtcNow().Returns(DateTime.UtcNow); + int existingId; + await using (var setupContext = _dbFixture.CreateDbContext()) + { + Client client = new() + { + RealmId = _realmId, + ClientId = "find-by-id-client", + CreatedAt = DateTime.UtcNow, + }; + setupContext.Clients.Add(client); + await setupContext.SaveChangesAsync(TestContext.Current.CancellationToken); + existingId = client.Id; + } + + int searchId = shouldFind ? existingId : existingId + 9999; + + await using var actContext = _dbFixture.CreateDbContext(); + // Act + ClientService sut = new(actContext, _encryptionService, _clock); + Client? result = await sut.FindById(searchId, TestContext.Current.CancellationToken); + + // Verify + if (shouldFind) + Assert.NotNull(result); + else + Assert.Null(result); + } +} diff --git a/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs b/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs index d6aec76..60764bc 100644 --- a/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs +++ b/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs @@ -91,10 +91,47 @@ public class RealmServiceTests : IClassFixture } await using var actContext = _dbFixture.CreateDbContext(); - RealmService sut = new(actContext, _keyService); // Act + RealmService sut = new(actContext, _keyService); var result = await sut.FindBySlug(slug, TestContext.Current.CancellationToken); + // Verify Assert.Equal(name, result?.Name); } + + [Theory] + [InlineData("b0423bba-2411-497b-a5b6-c5adf404b862", true)] + [InlineData("65ac9dba-6d43-4fa4-b57f-133ed639fbcb", false)] + public async Task FindById(string idString, bool shouldFind) + { + Guid id = new(idString); + await using (var setupContext = _dbFixture.CreateDbContext()) + { + setupContext.Realms.Add(new() + { + Id = new("b0423bba-2411-497b-a5b6-c5adf404b862"), + Slug = "foo", + Name = "Foo", + }); + setupContext.Realms.Add(new() + { + Id = new("d4ffc7d0-7b2c-4f02-82b9-a74610435b0d"), + Slug = "bar", + Name = "Bar", + }); + + await setupContext.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + await using var actContext = _dbFixture.CreateDbContext(); + // Act + RealmService sut = new(actContext, _keyService); + Realm? result = await sut.FindById(id, TestContext.Current.CancellationToken); + + // Verify + if (shouldFind) + Assert.NotNull(result); + else + Assert.Null(result); + } } \ No newline at end of file diff --git a/IdentityShroud.sln.DotSettings.user b/IdentityShroud.sln.DotSettings.user index 9992676..e39022f 100644 --- a/IdentityShroud.sln.DotSettings.user +++ b/IdentityShroud.sln.DotSettings.user @@ -22,9 +22,12 @@ /home/eelke/.cache/JetBrains/Rider2025.3/resharper-host/temp/Rider/vAny/CoverageData/_IdentityShroud.-1277985570/Snapshot/snapshot.utdcvr /home/eelke/.dotnet/dotnet /home/eelke/.dotnet/sdk/10.0.102/MSBuild.dll - <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <SessionState ContinuousTestingMode="0" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> </SessionState> + <SessionState ContinuousTestingMode="0" IsActive="True" Name="Junie Session" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <ProjectFile>DC887623-8680-4D3B-B23A-D54F7DA91891/d:Services/f:ClientServiceTests.cs</ProjectFile> +</SessionState> From 3d73a9914c5ff5b9a139277cad57c72865a2c4d2 Mon Sep 17 00:00:00 2001 From: eelke Date: Sun, 22 Feb 2026 09:27:48 +0100 Subject: [PATCH 04/22] Tests voor client api and service --- .../Apis/ClientApiTests.cs | 179 ++++++++++++++++++ IdentityShroud.Api/Apis/ClientApi.cs | 14 +- .../Apis/Dto/ClientRepresentation.cs | 16 ++ .../Apis/Filters/ClientIdValidationFilter.cs | 3 +- .../Apis/Mappers/ClientMapper.cs | 11 ++ .../ClientCreateRequestValidator.cs | 22 +++ IdentityShroud.Api/IdentityShroud.Api.csproj | 1 + .../Services/ClientServiceTests.cs | 4 +- .../Contracts/IClientService.cs | 16 +- .../DTO/Client/ClientCreateRequest.cs | 10 + IdentityShroud.Core/Services/ClientService.cs | 14 +- IdentityShroud.sln.DotSettings.user | 7 +- 12 files changed, 267 insertions(+), 30 deletions(-) create mode 100644 IdentityShroud.Api.Tests/Apis/ClientApiTests.cs create mode 100644 IdentityShroud.Api/Apis/Dto/ClientRepresentation.cs create mode 100644 IdentityShroud.Api/Apis/Mappers/ClientMapper.cs create mode 100644 IdentityShroud.Api/Apis/Validation/ClientCreateRequestValidator.cs create mode 100644 IdentityShroud.Core/DTO/Client/ClientCreateRequest.cs diff --git a/IdentityShroud.Api.Tests/Apis/ClientApiTests.cs b/IdentityShroud.Api.Tests/Apis/ClientApiTests.cs new file mode 100644 index 0000000..db984f1 --- /dev/null +++ b/IdentityShroud.Api.Tests/Apis/ClientApiTests.cs @@ -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 +{ + private readonly ApplicationFactory _factory; + + public ClientApiTests(ApplicationFactory factory) + { + _factory = factory; + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + 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( + 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( + 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( + 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 CreateRealmAsync(string slug, string name) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var realm = new Realm { Slug = slug, Name = name }; + db.Realms.Add(realm); + await db.SaveChangesAsync(TestContext.Current.CancellationToken); + return realm; + } + + private async Task CreateClientAsync(Realm realm, string clientId, string? name = null) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + 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; + } +} diff --git a/IdentityShroud.Api/Apis/ClientApi.cs b/IdentityShroud.Api/Apis/ClientApi.cs index fd3e804..e595e34 100644 --- a/IdentityShroud.Api/Apis/ClientApi.cs +++ b/IdentityShroud.Api/Apis/ClientApi.cs @@ -1,14 +1,14 @@ using FluentResults; +using IdentityShroud.Api.Mappers; using IdentityShroud.Core.Contracts; -using IdentityShroud.Core.Messages.Realm; using IdentityShroud.Core.Model; -using IdentityShroud.Core.Services; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; namespace IdentityShroud.Api; + public record ClientCreateReponse(int Id, string ClientId); /// @@ -34,13 +34,18 @@ public static class ClientApi .WithName(ClientGetRouteName); } - private static Task ClientGet(HttpContext context) + private static Ok ClientGet( + Guid realmId, + int clientId, + HttpContext context) { - throw new NotImplementedException(); + Client client = (Client)context.Items["ClientEntity"]!; + return TypedResults.Ok(new ClientMapper().ToDto(client)); } private static async Task, InternalServerError>> ClientCreate( + Guid realmId, ClientCreateRequest request, [FromServices] IClientService service, HttpContext context, @@ -64,6 +69,5 @@ public static class ClientApi ["realmId"] = realm.Id, ["clientId"] = client.Id, }); - throw new NotImplementedException(); } } \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/Dto/ClientRepresentation.cs b/IdentityShroud.Api/Apis/Dto/ClientRepresentation.cs new file mode 100644 index 0000000..80b5f13 --- /dev/null +++ b/IdentityShroud.Api/Apis/Dto/ClientRepresentation.cs @@ -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; } +} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/Filters/ClientIdValidationFilter.cs b/IdentityShroud.Api/Apis/Filters/ClientIdValidationFilter.cs index 8030153..771be81 100644 --- a/IdentityShroud.Api/Apis/Filters/ClientIdValidationFilter.cs +++ b/IdentityShroud.Api/Apis/Filters/ClientIdValidationFilter.cs @@ -7,8 +7,9 @@ public class ClientIdValidationFilter(IClientService clientService) : IEndpointF { public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) { + Guid realmId = context.Arguments.OfType().First(); int id = context.Arguments.OfType().First(); - Client? client = await clientService.FindById(id, context.HttpContext.RequestAborted); + Client? client = await clientService.FindById(realmId, id, context.HttpContext.RequestAborted); if (client is null) { return Results.NotFound(); diff --git a/IdentityShroud.Api/Apis/Mappers/ClientMapper.cs b/IdentityShroud.Api/Apis/Mappers/ClientMapper.cs new file mode 100644 index 0000000..8e58717 --- /dev/null +++ b/IdentityShroud.Api/Apis/Mappers/ClientMapper.cs @@ -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); +} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/Validation/ClientCreateRequestValidator.cs b/IdentityShroud.Api/Apis/Validation/ClientCreateRequestValidator.cs new file mode 100644 index 0000000..7666b36 --- /dev/null +++ b/IdentityShroud.Api/Apis/Validation/ClientCreateRequestValidator.cs @@ -0,0 +1,22 @@ +using FluentValidation; +using IdentityShroud.Core.Contracts; + +namespace IdentityShroud.Api; + +public class ClientCreateRequestValidator : AbstractValidator +{ + // 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"); + } +} \ No newline at end of file diff --git a/IdentityShroud.Api/IdentityShroud.Api.csproj b/IdentityShroud.Api/IdentityShroud.Api.csproj index 72b4639..860fbeb 100644 --- a/IdentityShroud.Api/IdentityShroud.Api.csproj +++ b/IdentityShroud.Api/IdentityShroud.Api.csproj @@ -18,6 +18,7 @@ + diff --git a/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs b/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs index cb2e772..30bb3b6 100644 --- a/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs +++ b/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs @@ -108,7 +108,7 @@ public class ClientServiceTests : IClassFixture await using var actContext = _dbFixture.CreateDbContext(); // Act ClientService sut = new(actContext, _encryptionService, _clock); - Client? result = await sut.GetByClientId(clientId, TestContext.Current.CancellationToken); + Client? result = await sut.GetByClientId(_realmId, clientId, TestContext.Current.CancellationToken); // Verify if (shouldFind) @@ -143,7 +143,7 @@ public class ClientServiceTests : IClassFixture await using var actContext = _dbFixture.CreateDbContext(); // Act ClientService sut = new(actContext, _encryptionService, _clock); - Client? result = await sut.FindById(searchId, TestContext.Current.CancellationToken); + Client? result = await sut.FindById(_realmId, searchId, TestContext.Current.CancellationToken); // Verify if (shouldFind) diff --git a/IdentityShroud.Core/Contracts/IClientService.cs b/IdentityShroud.Core/Contracts/IClientService.cs index 15c0eba..20e270c 100644 --- a/IdentityShroud.Core/Contracts/IClientService.cs +++ b/IdentityShroud.Core/Contracts/IClientService.cs @@ -2,18 +2,6 @@ using IdentityShroud.Core.Model; namespace IdentityShroud.Core.Contracts; -//public record CreateClientRequest(Guid RealmId, string ClientId, string? Description); - -public class ClientCreateRequest -{ - 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; } -} - - public interface IClientService { Task> Create( @@ -21,6 +9,6 @@ public interface IClientService ClientCreateRequest request, CancellationToken ct = default); - Task GetByClientId(string clientId, CancellationToken ct = default); - Task FindById(int id, CancellationToken ct = default); + Task GetByClientId(Guid realmId, string clientId, CancellationToken ct = default); + Task FindById(Guid realmId, int id, CancellationToken ct = default); } \ No newline at end of file diff --git a/IdentityShroud.Core/DTO/Client/ClientCreateRequest.cs b/IdentityShroud.Core/DTO/Client/ClientCreateRequest.cs new file mode 100644 index 0000000..a162131 --- /dev/null +++ b/IdentityShroud.Core/DTO/Client/ClientCreateRequest.cs @@ -0,0 +1,10 @@ +namespace IdentityShroud.Core.Contracts; + +public class ClientCreateRequest +{ + 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; } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/ClientService.cs b/IdentityShroud.Core/Services/ClientService.cs index 2e556d4..ed85daf 100644 --- a/IdentityShroud.Core/Services/ClientService.cs +++ b/IdentityShroud.Core/Services/ClientService.cs @@ -34,14 +34,20 @@ public class ClientService( return client; } - public async Task GetByClientId(string clientId, CancellationToken ct = default) + public async Task GetByClientId( + Guid realmId, + string clientId, + CancellationToken ct = default) { - return await db.Clients.FirstOrDefaultAsync(c => c.ClientId == clientId, ct); + return await db.Clients.FirstOrDefaultAsync(c => c.ClientId == clientId && c.RealmId == realmId, ct); } - public async Task FindById(int id, CancellationToken ct = default) + public async Task FindById( + Guid realmId, + int id, + CancellationToken ct = default) { - return await db.Clients.FirstOrDefaultAsync(c => c.Id == id, ct); + return await db.Clients.FirstOrDefaultAsync(c => c.Id == id && c.RealmId == realmId, ct); } private ClientSecret CreateSecret() diff --git a/IdentityShroud.sln.DotSettings.user b/IdentityShroud.sln.DotSettings.user index e39022f..01fd911 100644 --- a/IdentityShroud.sln.DotSettings.user +++ b/IdentityShroud.sln.DotSettings.user @@ -22,12 +22,11 @@ /home/eelke/.cache/JetBrains/Rider2025.3/resharper-host/temp/Rider/vAny/CoverageData/_IdentityShroud.-1277985570/Snapshot/snapshot.utdcvr /home/eelke/.dotnet/dotnet /home/eelke/.dotnet/sdk/10.0.102/MSBuild.dll - <SessionState ContinuousTestingMode="0" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> </SessionState> - <SessionState ContinuousTestingMode="0" IsActive="True" Name="Junie Session" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <ProjectFile>DC887623-8680-4D3B-B23A-D54F7DA91891/d:Services/f:ClientServiceTests.cs</ProjectFile> -</SessionState> + + From e0f6f3f8a9c31b5f05d30dcd920fb3ad0c84da85 Mon Sep 17 00:00:00 2001 From: eelke Date: Sun, 22 Feb 2026 09:28:05 +0100 Subject: [PATCH 05/22] Cleanup --- .../Fixtures/ApplicationFactory.cs | 5 ----- .../Mappers/KeyMapperTests.cs | 17 ----------------- .../Apis/Filters/RealmSlugValidationFilter.cs | 1 - IdentityShroud.Api/Apis/Mappers/KeyMapper.cs | 3 --- IdentityShroud.Api/AppJsonSerializerContext.cs | 1 - IdentityShroud.Core.Tests/Fixtures/DbFixture.cs | 3 +-- .../Services/RealmServiceTests.cs | 1 - IdentityShroud.Core.Tests/UnitTest1.cs | 1 - IdentityShroud.Core/Helpers/SlugHelper.cs | 1 - IdentityShroud.Core/Model/Client.cs | 1 - IdentityShroud.Core/Model/Realm.cs | 1 - .../Security/JsonWebAlgorithm.cs | 2 -- .../Security/Keys/IKeyProvider.cs | 1 - .../Security/Keys/Rsa/RsaProvider.cs | 2 -- IdentityShroud.Core/Services/KeyService.cs | 1 - IdentityShroud.Core/Services/RealmService.cs | 1 - .../Asserts/JsonObjectAssert.cs | 1 - .../Asserts/ResultAssert.cs | 1 - 18 files changed, 1 insertion(+), 43 deletions(-) delete mode 100644 IdentityShroud.Api.Tests/Mappers/KeyMapperTests.cs diff --git a/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs b/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs index 6f4c461..42fd91c 100644 --- a/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs +++ b/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs @@ -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; diff --git a/IdentityShroud.Api.Tests/Mappers/KeyMapperTests.cs b/IdentityShroud.Api.Tests/Mappers/KeyMapperTests.cs deleted file mode 100644 index 767337e..0000000 --- a/IdentityShroud.Api.Tests/Mappers/KeyMapperTests.cs +++ /dev/null @@ -1,17 +0,0 @@ -using IdentityShroud.Api.Mappers; -using IdentityShroud.Core.Contracts; -using IdentityShroud.Core.Messages; -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() -// { -// } -// } \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/Filters/RealmSlugValidationFilter.cs b/IdentityShroud.Api/Apis/Filters/RealmSlugValidationFilter.cs index 862b599..75338e1 100644 --- a/IdentityShroud.Api/Apis/Filters/RealmSlugValidationFilter.cs +++ b/IdentityShroud.Api/Apis/Filters/RealmSlugValidationFilter.cs @@ -1,6 +1,5 @@ using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Model; -using IdentityShroud.Core.Services; namespace IdentityShroud.Api; diff --git a/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs b/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs index 36bd200..7155208 100644 --- a/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs +++ b/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs @@ -1,9 +1,6 @@ -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; diff --git a/IdentityShroud.Api/AppJsonSerializerContext.cs b/IdentityShroud.Api/AppJsonSerializerContext.cs index 9b075ce..e7d90da 100644 --- a/IdentityShroud.Api/AppJsonSerializerContext.cs +++ b/IdentityShroud.Api/AppJsonSerializerContext.cs @@ -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))] diff --git a/IdentityShroud.Core.Tests/Fixtures/DbFixture.cs b/IdentityShroud.Core.Tests/Fixtures/DbFixture.cs index 85c2fbe..844d4ca 100644 --- a/IdentityShroud.Core.Tests/Fixtures/DbFixture.cs +++ b/IdentityShroud.Core.Tests/Fixtures/DbFixture.cs @@ -1,5 +1,4 @@ -using DotNet.Testcontainers.Containers; -using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Npgsql; using Testcontainers.PostgreSql; diff --git a/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs b/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs index 60764bc..acbc3bf 100644 --- a/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs +++ b/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs @@ -3,7 +3,6 @@ using IdentityShroud.Core.Model; using IdentityShroud.Core.Security.Keys; using IdentityShroud.Core.Services; using IdentityShroud.Core.Tests.Fixtures; -using IdentityShroud.TestUtils.Substitutes; using Microsoft.EntityFrameworkCore; namespace IdentityShroud.Core.Tests.Services; diff --git a/IdentityShroud.Core.Tests/UnitTest1.cs b/IdentityShroud.Core.Tests/UnitTest1.cs index 2d28047..e2b5a05 100644 --- a/IdentityShroud.Core.Tests/UnitTest1.cs +++ b/IdentityShroud.Core.Tests/UnitTest1.cs @@ -2,7 +2,6 @@ using System.Text; using System.Text.Json; using IdentityShroud.Core.DTO; -using IdentityShroud.Core.Messages; using Microsoft.AspNetCore.WebUtilities; namespace IdentityShroud.Core.Tests; diff --git a/IdentityShroud.Core/Helpers/SlugHelper.cs b/IdentityShroud.Core/Helpers/SlugHelper.cs index beef894..51aa0c3 100644 --- a/IdentityShroud.Core/Helpers/SlugHelper.cs +++ b/IdentityShroud.Core/Helpers/SlugHelper.cs @@ -1,4 +1,3 @@ -using System; using System.Globalization; using System.Security.Cryptography; using System.Text; diff --git a/IdentityShroud.Core/Model/Client.cs b/IdentityShroud.Core/Model/Client.cs index a8c9e29..5df6c1a 100644 --- a/IdentityShroud.Core/Model/Client.cs +++ b/IdentityShroud.Core/Model/Client.cs @@ -1,6 +1,5 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using IdentityShroud.Core.Security; using Microsoft.EntityFrameworkCore; namespace IdentityShroud.Core.Model; diff --git a/IdentityShroud.Core/Model/Realm.cs b/IdentityShroud.Core/Model/Realm.cs index c02fc38..7fcd10c 100644 --- a/IdentityShroud.Core/Model/Realm.cs +++ b/IdentityShroud.Core/Model/Realm.cs @@ -1,7 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using IdentityShroud.Core.Security; -using Microsoft.EntityFrameworkCore; namespace IdentityShroud.Core.Model; diff --git a/IdentityShroud.Core/Security/JsonWebAlgorithm.cs b/IdentityShroud.Core/Security/JsonWebAlgorithm.cs index cbdcf05..dc9bc28 100644 --- a/IdentityShroud.Core/Security/JsonWebAlgorithm.cs +++ b/IdentityShroud.Core/Security/JsonWebAlgorithm.cs @@ -1,5 +1,3 @@ -using System.Security.Cryptography; - namespace IdentityShroud.Core.Security; public static class JsonWebAlgorithm diff --git a/IdentityShroud.Core/Security/Keys/IKeyProvider.cs b/IdentityShroud.Core/Security/Keys/IKeyProvider.cs index ec095b5..8e32309 100644 --- a/IdentityShroud.Core/Security/Keys/IKeyProvider.cs +++ b/IdentityShroud.Core/Security/Keys/IKeyProvider.cs @@ -1,5 +1,4 @@ using IdentityShroud.Core.Messages; -using IdentityShroud.Core.Model; namespace IdentityShroud.Core.Security.Keys; diff --git a/IdentityShroud.Core/Security/Keys/Rsa/RsaProvider.cs b/IdentityShroud.Core/Security/Keys/Rsa/RsaProvider.cs index a5bcee8..daf2b7f 100644 --- a/IdentityShroud.Core/Security/Keys/Rsa/RsaProvider.cs +++ b/IdentityShroud.Core/Security/Keys/Rsa/RsaProvider.cs @@ -1,8 +1,6 @@ using System.Buffers.Text; using System.Security.Cryptography; -using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Messages; -using IdentityShroud.Core.Model; namespace IdentityShroud.Core.Security.Keys.Rsa; diff --git a/IdentityShroud.Core/Services/KeyService.cs b/IdentityShroud.Core/Services/KeyService.cs index 440dff9..6c5e828 100644 --- a/IdentityShroud.Core/Services/KeyService.cs +++ b/IdentityShroud.Core/Services/KeyService.cs @@ -1,4 +1,3 @@ -using System.Security.Cryptography; using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Messages; using IdentityShroud.Core.Model; diff --git a/IdentityShroud.Core/Services/RealmService.cs b/IdentityShroud.Core/Services/RealmService.cs index 5385658..f8e7185 100644 --- a/IdentityShroud.Core/Services/RealmService.cs +++ b/IdentityShroud.Core/Services/RealmService.cs @@ -1,4 +1,3 @@ -using System.Security.Cryptography; using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Helpers; using IdentityShroud.Core.Messages.Realm; diff --git a/IdentityShroud.TestUtils/Asserts/JsonObjectAssert.cs b/IdentityShroud.TestUtils/Asserts/JsonObjectAssert.cs index 3352bc6..016f358 100644 --- a/IdentityShroud.TestUtils/Asserts/JsonObjectAssert.cs +++ b/IdentityShroud.TestUtils/Asserts/JsonObjectAssert.cs @@ -1,6 +1,5 @@ using System.Text.Json.Nodes; using System.Text.RegularExpressions; -using Xunit; namespace IdentityShroud.TestUtils.Asserts; diff --git a/IdentityShroud.TestUtils/Asserts/ResultAssert.cs b/IdentityShroud.TestUtils/Asserts/ResultAssert.cs index 28a0b11..ff00c06 100644 --- a/IdentityShroud.TestUtils/Asserts/ResultAssert.cs +++ b/IdentityShroud.TestUtils/Asserts/ResultAssert.cs @@ -1,5 +1,4 @@ using FluentResults; -using Xunit; namespace IdentityShroud.Core.Tests; From 72dbc5acbf63de7c98bf9dcc05841b6fd56dd46b Mon Sep 17 00:00:00 2001 From: eelke Date: Sun, 22 Feb 2026 09:39:43 +0100 Subject: [PATCH 06/22] Add github job to run tests --- .github/workflows/ci.yml | 70 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d51bc52 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,70 @@ +name: CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} + restore-keys: | + ${{ runner.os }}-nuget- + + - name: Cache Docker image (postgres) + id: docker-cache + uses: actions/cache@v4 + with: + path: /tmp/docker-postgres.tar + key: ${{ runner.os }}-docker-postgres-18.1 + + - name: Load cached postgres image or pull + run: | + if [ -f /tmp/docker-postgres.tar ]; then + docker load -i /tmp/docker-postgres.tar + else + docker pull postgres:18.1 + docker save postgres:18.1 -o /tmp/docker-postgres.tar + fi + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --no-restore --configuration Release + + - name: Test with coverage + run: | + dotnet test --no-build --configuration Release \ + --collect:"XPlat Code Coverage" \ + --results-directory ./coverage \ + -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + directory: ./coverage + fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} + + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: ./coverage/**/coverage.cobertura.xml + retention-days: 7 From 4b493ee28d817dbd3827452215663e5410e77ced Mon Sep 17 00:00:00 2001 From: eelke Date: Sun, 22 Feb 2026 09:46:09 +0100 Subject: [PATCH 07/22] Fix library reference --- IdentityShroud.Core/IdentityShroud.Core.csproj | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/IdentityShroud.Core/IdentityShroud.Core.csproj b/IdentityShroud.Core/IdentityShroud.Core.csproj index d9d6809..1e7e8d0 100644 --- a/IdentityShroud.Core/IdentityShroud.Core.csproj +++ b/IdentityShroud.Core/IdentityShroud.Core.csproj @@ -13,6 +13,7 @@ + @@ -20,10 +21,4 @@ - - - ..\..\..\.nuget\packages\microsoft.aspnetcore.webutilities\10.0.2\lib\net10.0\Microsoft.AspNetCore.WebUtilities.dll - - - From c2a21843535e36d8b440c0db5c6e7a69139742c7 Mon Sep 17 00:00:00 2001 From: eelke Date: Sun, 22 Feb 2026 09:49:10 +0100 Subject: [PATCH 08/22] Another reference fix --- IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj b/IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj index 0b8cba9..4b68445 100644 --- a/IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj +++ b/IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj @@ -10,6 +10,7 @@ + @@ -21,10 +22,4 @@ - - - ..\..\..\.nuget\packages\nsubstitute\5.3.0\lib\net6.0\NSubstitute.dll - - - From 21b53ff5b32b0dee96fa14760f82436b995d5d76 Mon Sep 17 00:00:00 2001 From: eelke Date: Sun, 22 Feb 2026 09:58:20 +0100 Subject: [PATCH 09/22] Fix injection of encryption secret --- IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs b/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs index 42fd91c..2a2ae76 100644 --- a/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs +++ b/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs @@ -28,7 +28,7 @@ public class ApplicationFactory : WebApplicationFactory, IAsyncLifetime new Dictionary { ["Db:ConnectionString"] = _postgresqlServer.GetConnectionString(), - ["Encryption:Master"] = "GVd07qW0frRX9quPX/X62L88BeRR7+IzgRJHtG7ZzHw=", + ["secrets:Master"] = "GVd07qW0frRX9quPX/X62L88BeRR7+IzgRJHtG7ZzHw=", }); }); From ac08956339830efaa8fa0607ad93949bca0f9480 Mon Sep 17 00:00:00 2001 From: eelke Date: Sun, 22 Feb 2026 11:30:54 +0100 Subject: [PATCH 10/22] No codecov (AI was over eager) just show the numbers in github. --- .github/workflows/ci.yml | 11 ++++++----- README.md | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) create mode 100644 README.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d51bc52..f2ed668 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,12 +55,13 @@ jobs: --results-directory ./coverage \ -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura - - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v5 + - name: Code Coverage Report + uses: irongut/CodeCoverageSummary@v1.3.0 with: - directory: ./coverage - fail_ci_if_error: false - token: ${{ secrets.CODECOV_TOKEN }} + filename: coverage/**/coverage.cobertura.xml + badge: true + format: markdown + output: both - name: Upload coverage artifact uses: actions/upload-artifact@v4 diff --git a/README.md b/README.md new file mode 100644 index 0000000..f8839d9 --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +# IdentityShroud + +![Build Status](https://github.com/Eelke76/IdentityShroud/actions/workflows/ci.yml/badge.svg) +![Code Coverage](https://img.shields.io/badge/Code%20Coverage-0%25-critical) + +IdentityShroud is a .NET project for identity management and protection. + +## Build and Test + +```bash +dotnet restore +dotnet build +dotnet test +``` + +## Coverage + +Coverage reports are generated automatically in CI and displayed in pull request comments. From 4201d0240d85bbd2a85e1f338c9e069a3c04e80a Mon Sep 17 00:00:00 2001 From: eelke Date: Sun, 22 Feb 2026 19:11:17 +0100 Subject: [PATCH 11/22] Improve the binary storage format of encrypted secrets. Move the related code from AesGcmHelper into the EncryptionService. --- .../IdentityShroud.Core.Tests.csproj | 4 - .../JwtSignatureGeneratorTests.cs | 4 +- .../Security/AesGcmHelperTests.cs | 21 ----- .../Services/EncryptionServiceTests.cs | 38 +++++++-- IdentityShroud.Core.Tests/UnitTest1.cs | 4 +- .../Contracts/IEncryptionService.cs | 2 +- IdentityShroud.Core/Security/AesGcmHelper.cs | 70 ----------------- IdentityShroud.Core/Security/RsaHelper.cs | 16 ---- .../Services/EncryptionService.cs | 78 +++++++++++++++++-- IdentityShroud.sln.DotSettings.user | 1 + README.md | 3 - 11 files changed, 110 insertions(+), 131 deletions(-) delete mode 100644 IdentityShroud.Core.Tests/Security/AesGcmHelperTests.cs delete mode 100644 IdentityShroud.Core/Security/AesGcmHelper.cs delete mode 100644 IdentityShroud.Core/Security/RsaHelper.cs diff --git a/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj b/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj index 40c87d5..8af08c1 100644 --- a/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj +++ b/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj @@ -30,8 +30,4 @@ - - - - \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/JwtSignatureGeneratorTests.cs b/IdentityShroud.Core.Tests/JwtSignatureGeneratorTests.cs index 0fb0a42..bf4d0a6 100644 --- a/IdentityShroud.Core.Tests/JwtSignatureGeneratorTests.cs +++ b/IdentityShroud.Core.Tests/JwtSignatureGeneratorTests.cs @@ -72,8 +72,8 @@ public class JwtSignatureGeneratorTests var rsa = RSA.Create(); var parameters = new RSAParameters { - Modulus = WebEncoders.Base64UrlDecode(jwk.Modulus), - Exponent = WebEncoders.Base64UrlDecode(jwk.Exponent) + Modulus = WebEncoders.Base64UrlDecode(jwk.Modulus!), + Exponent = WebEncoders.Base64UrlDecode(jwk.Exponent!) }; rsa.ImportParameters(parameters); diff --git a/IdentityShroud.Core.Tests/Security/AesGcmHelperTests.cs b/IdentityShroud.Core.Tests/Security/AesGcmHelperTests.cs deleted file mode 100644 index 6392676..0000000 --- a/IdentityShroud.Core.Tests/Security/AesGcmHelperTests.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Security.Cryptography; -using System.Text; -using IdentityShroud.Core.Security; - -namespace IdentityShroud.Core.Tests.Security; - -public class AesGcmHelperTests -{ - [Fact] - public void EncryptDecryptCycleWorks() - { - string input = "Hello, world!"; - - var encryptionKey = RandomNumberGenerator.GetBytes(32); - - var cypher = AesGcmHelper.EncryptAesGcm(Encoding.UTF8.GetBytes(input), encryptionKey); - var output = AesGcmHelper.DecryptAesGcm(cypher, encryptionKey); - - Assert.Equal(input, Encoding.UTF8.GetString(output)); - } -} \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/Services/EncryptionServiceTests.cs b/IdentityShroud.Core.Tests/Services/EncryptionServiceTests.cs index b855732..68ab90d 100644 --- a/IdentityShroud.Core.Tests/Services/EncryptionServiceTests.cs +++ b/IdentityShroud.Core.Tests/Services/EncryptionServiceTests.cs @@ -1,3 +1,4 @@ +using System.Buffers.Text; using System.Security.Cryptography; using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Services; @@ -9,18 +10,43 @@ public class EncryptionServiceTests [Fact] public void RoundtripWorks() { + // Note this code will tend to only test the latest verion. + // setup - string key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); var secretProvider = Substitute.For(); - secretProvider.GetSecret("Master").Returns(key); + secretProvider.GetSecret("Master").Returns("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); - EncryptionService sut = new(secretProvider); - byte[] input = RandomNumberGenerator.GetBytes(16); + ReadOnlySpan input = "Hello, World!"u8; // act - var cipher = sut.Encrypt(input); - var result = sut.Decrypt(cipher); + EncryptionService sut = new(secretProvider); + byte[] cipher = sut.Encrypt(input.ToArray()); + byte[] result = sut.Decrypt(cipher); + // verify Assert.Equal(input, result); } + + [Fact] + public void DecodeV1_Success() + { + // When introducing a new version we need version specific tests to + // make sure decoding of legacy data still works. + + // setup + Span cipher = + [ + 1, 198, 55, 58, 56, 110, 238, 59, 158, 214, 85, 241, 26, 44, 140, 229, 128, 111, 167, 154, 160, 177, 152, + 193, 74, 4, 235, 82, 207, 87, 32, 10, 239, 4, 246, 25, 21, 249, 25, 59, 160, 101 + ]; + var secretProvider = Substitute.For(); + secretProvider.GetSecret("Master").Returns("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); + + // act + EncryptionService sut = new(secretProvider); + byte[] result = sut.Decrypt(cipher.ToArray()); + + // verify + Assert.Equal("Hello, World!"u8, result); + } } \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/UnitTest1.cs b/IdentityShroud.Core.Tests/UnitTest1.cs index e2b5a05..7a12bc4 100644 --- a/IdentityShroud.Core.Tests/UnitTest1.cs +++ b/IdentityShroud.Core.Tests/UnitTest1.cs @@ -66,9 +66,9 @@ public static class JwtReader return new JsonWebToken() { Header = JsonSerializer.Deserialize( - Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(jwt, 0, firstDot))), + Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(jwt, 0, firstDot)))!, Payload = JsonSerializer.Deserialize( - Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(jwt, firstDot + 1, secondDot - (firstDot + 1)))), + Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(jwt, firstDot + 1, secondDot - (firstDot + 1))))!, Signature = WebEncoders.Base64UrlDecode(jwt, secondDot + 1, jwt.Length - (secondDot + 1)) }; } diff --git a/IdentityShroud.Core/Contracts/IEncryptionService.cs b/IdentityShroud.Core/Contracts/IEncryptionService.cs index a737732..388304b 100644 --- a/IdentityShroud.Core/Contracts/IEncryptionService.cs +++ b/IdentityShroud.Core/Contracts/IEncryptionService.cs @@ -2,6 +2,6 @@ namespace IdentityShroud.Core.Contracts; public interface IEncryptionService { - byte[] Encrypt(byte[] plain); + byte[] Encrypt(ReadOnlyMemory plain); byte[] Decrypt(ReadOnlyMemory cipher); } \ No newline at end of file diff --git a/IdentityShroud.Core/Security/AesGcmHelper.cs b/IdentityShroud.Core/Security/AesGcmHelper.cs deleted file mode 100644 index bfa5809..0000000 --- a/IdentityShroud.Core/Security/AesGcmHelper.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System.Security.Cryptography; - -namespace IdentityShroud.Core.Security; - -public static class AesGcmHelper -{ - - public static byte[] EncryptAesGcm(byte[] plaintext, byte[] key) - { - int tagSize = AesGcm.TagByteSizes.MaxSize; - using var aes = new AesGcm(key, tagSize); - - Span nonce = stackalloc byte[AesGcm.NonceByteSizes.MaxSize]; - RandomNumberGenerator.Fill(nonce); - Span ciphertext = stackalloc byte[plaintext.Length]; - Span tag = stackalloc byte[tagSize]; - - aes.Encrypt(nonce, plaintext, ciphertext, tag); - - // Return concatenated nonce|ciphertext|tag - var result = new byte[nonce.Length + ciphertext.Length + tag.Length]; - nonce.CopyTo(result.AsSpan(0, nonce.Length)); - ciphertext.CopyTo(result.AsSpan(nonce.Length, ciphertext.Length)); - tag.CopyTo(result.AsSpan(nonce.Length + ciphertext.Length, tag.Length)); - return result; - } - - // -------------------------------------------------------------------- - // DecryptAesGcm - // • key – 32‑byte (256‑bit) secret key (same key used for encryption) - // • payload – byte[] containing nonce‖ciphertext‖tag - // • returns – the original plaintext bytes - // -------------------------------------------------------------------- - public static byte[] DecryptAesGcm(ReadOnlyMemory payload, byte[] key) - { - if (key == null) throw new ArgumentNullException(nameof(key)); - if (key.Length != 32) // 256‑bit key - throw new ArgumentException("Key must be 256 bits (32 bytes) for AES‑256‑GCM.", nameof(key)); - - // ---------------------------------------------------------------- - // 1️⃣ Extract the three components. - // ---------------------------------------------------------------- - // AesGcm.NonceByteSizes.MaxSize = 12 bytes (standard GCM nonce length) - // AesGcm.TagByteSizes.MaxSize = 16 bytes (128‑bit authentication tag) - int nonceSize = AesGcm.NonceByteSizes.MaxSize; // 12 - int tagSize = AesGcm.TagByteSizes.MaxSize; // 16 - - if (payload.Length < nonceSize + tagSize) - throw new ArgumentException("Payload is too short to contain nonce, ciphertext, and tag.", nameof(payload)); - - ReadOnlySpan nonce = payload.Span[..nonceSize]; - ReadOnlySpan ciphertext = payload.Span.Slice(nonceSize, payload.Length - nonceSize - tagSize); - ReadOnlySpan tag = payload.Span.Slice(payload.Length - tagSize, tagSize); - - byte[] plaintext = new byte[ciphertext.Length]; - - using var aes = new AesGcm(key, tagSize); - try - { - aes.Decrypt(nonce, ciphertext, tag, plaintext); - } - catch (CryptographicException ex) - { - // Tag verification failed → tampering or wrong key/nonce. - throw new InvalidOperationException("Decryption failed – authentication tag mismatch.", ex); - } - - return plaintext; - } -} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/RsaHelper.cs b/IdentityShroud.Core/Security/RsaHelper.cs deleted file mode 100644 index ab49ebd..0000000 --- a/IdentityShroud.Core/Security/RsaHelper.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Security.Cryptography; - -namespace IdentityShroud.Core.Security; - -public static class RsaHelper -{ - /// - /// Load RSA private key from PKCS#8 format - /// - public static RSA LoadFromPkcs8(byte[] pkcs8Key) - { - var rsa = RSA.Create(); - rsa.ImportPkcs8PrivateKey(pkcs8Key, out _); - return rsa; - } -} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/EncryptionService.cs b/IdentityShroud.Core/Services/EncryptionService.cs index a4455e0..8aa5bed 100644 --- a/IdentityShroud.Core/Services/EncryptionService.cs +++ b/IdentityShroud.Core/Services/EncryptionService.cs @@ -1,3 +1,4 @@ +using System.Security.Cryptography; using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Security; @@ -8,20 +9,85 @@ namespace IdentityShroud.Core.Services; /// public class EncryptionService : IEncryptionService { - private readonly byte[] encryptionKey; + 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 + ]; + + private readonly byte[] _encryptionKey; public EncryptionService(ISecretProvider secretProvider) { - encryptionKey = Convert.FromBase64String(secretProvider.GetSecret("Master")); + _encryptionKey = Convert.FromBase64String(secretProvider.GetSecret("Master")); + if (_encryptionKey.Length != 32) // 256‑bit key + throw new Exception("Key must be 256 bits (32 bytes) for AES‑256‑GCM."); } - public byte[] Encrypt(byte[] plain) + public byte[] Encrypt(ReadOnlyMemory plaintext) { - return AesGcmHelper.EncryptAesGcm(plain, encryptionKey); + 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); + using var aes = new AesGcm(_encryptionKey, versionParams.TagSize); + aes.Encrypt(nonce, plaintext.Span, cipher, tag); + + return result; } - public byte[] Decrypt(ReadOnlyMemory cipher) + public byte[] Decrypt(ReadOnlyMemory input) { - return AesGcmHelper.DecryptAesGcm(cipher, encryptionKey); + + // ---------------------------------------------------------------- + // 1️⃣ Extract the three components. + // ---------------------------------------------------------------- + // AesGcm.NonceByteSizes.MaxSize = 12 bytes (standard GCM nonce length) + // AesGcm.TagByteSizes.MaxSize = 16 bytes (128‑bit authentication tag) + //int nonceSize = AesGcm.NonceByteSizes.MaxSize; // 12 + //int tagSize = AesGcm.TagByteSizes.MaxSize; // 16 + var payload = input.Span; + int versionNumber = (int)payload[0]; + if (versionNumber != 1) + throw new ArgumentException("Invalid payloag"); + + 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 nonce = payload.Slice(1, versionParams.NonceSize); + ReadOnlySpan tag = payload.Slice(1 + versionParams.NonceSize, versionParams.TagSize); + ReadOnlySpan cipher = payload.Slice(1 + versionParams.NonceSize + versionParams.TagSize); + + byte[] plaintext = new byte[cipher.Length]; + + using var aes = new AesGcm(_encryptionKey, 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; } } \ No newline at end of file diff --git a/IdentityShroud.sln.DotSettings.user b/IdentityShroud.sln.DotSettings.user index 01fd911..d90a7ba 100644 --- a/IdentityShroud.sln.DotSettings.user +++ b/IdentityShroud.sln.DotSettings.user @@ -14,6 +14,7 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded diff --git a/README.md b/README.md index f8839d9..fa9605a 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,5 @@ # IdentityShroud -![Build Status](https://github.com/Eelke76/IdentityShroud/actions/workflows/ci.yml/badge.svg) -![Code Coverage](https://img.shields.io/badge/Code%20Coverage-0%25-critical) - IdentityShroud is a .NET project for identity management and protection. ## Build and Test From 644b005f2a7faf5a094a99289232ead68cf87a23 Mon Sep 17 00:00:00 2001 From: eelke Date: Tue, 24 Feb 2026 06:32:58 +0100 Subject: [PATCH 12/22] 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) --- .../Apis/RealmApisTests.cs | 16 +-- .../Fixtures/ApplicationFactory.cs | 5 +- .../Mappers/KeyServiceTests.cs | 13 ++- IdentityShroud.Api/IdentityShroud.Api.csproj | 1 - .../ConfigurationSecretProviderTests.cs | 61 ++++++++++ .../Services/EncryptionServiceTests.cs | 110 ++++++++++++++++-- .../Services/RealmServiceTests.cs | 8 +- .../Contracts/IEncryptionService.cs | 4 +- .../Contracts/ISecretProvider.cs | 6 + IdentityShroud.Core/Model/ClientSecret.cs | 3 +- IdentityShroud.Core/Model/RealmKey.cs | 14 ++- .../Security/ConfigurationSecretProvider.cs | 5 + .../Security/EncryptedValue.cs | 6 + IdentityShroud.Core/Security/EncryptionKey.cs | 4 + IdentityShroud.Core/Services/ClientService.cs | 2 +- .../Services/EncryptionService.cs | 39 +++---- IdentityShroud.Core/Services/KeyService.cs | 21 ++-- .../EncryptionServiceSubstitute.cs | 8 +- IdentityShroud.sln.DotSettings.user | 5 +- 19 files changed, 259 insertions(+), 72 deletions(-) create mode 100644 IdentityShroud.Core.Tests/Security/ConfigurationSecretProviderTests.cs create mode 100644 IdentityShroud.Core/Security/EncryptedValue.cs create mode 100644 IdentityShroud.Core/Security/EncryptionKey.cs diff --git a/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs b/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs index 8d08a27..a91ea62 100644 --- a/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs +++ b/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs @@ -114,7 +114,7 @@ public class RealmApisTests : IClassFixture { // 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 @@ -130,18 +130,20 @@ public class RealmApisTests : IClassFixture using var rsa = RSA.Create(2048); RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false); - RealmKey realmKey = new( - Guid.NewGuid(), - "RSA", - encryptionService.Encrypt(rsa.ExportPkcs8PrivateKey()), - DateTime.UtcNow); + RealmKey realmKey = new() + { + Id = Guid.NewGuid(), + KeyType = "RSA", + Key = encryptionService.Encrypt(rsa.ExportPkcs8PrivateKey()), + CreatedAt = DateTime.UtcNow, + }; await ScopedContextAsync(async db => { 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("/auth/realms/foo/openid-connect/jwks", diff --git a/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs b/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs index 2a2ae76..2a2be31 100644 --- a/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs +++ b/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs @@ -28,7 +28,10 @@ public class ApplicationFactory : WebApplicationFactory, IAsyncLifetime new Dictionary { ["Db:ConnectionString"] = _postgresqlServer.GetConnectionString(), - ["secrets:Master"] = "GVd07qW0frRX9quPX/X62L88BeRR7+IzgRJHtG7ZzHw=", + ["secrets:master:0:Id"] = "key1", + ["secrets:master:0:Active"] = "true", + ["secrets:master:0:Algorithm"] = "AES", + ["secrets:master:0:Key"] = "GVd07qW0frRX9quPX/X62L88BeRR7+IzgRJHtG7ZzHw=", }); }); diff --git a/IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs b/IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs index 196b15d..0df74a3 100644 --- a/IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs +++ b/IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs @@ -21,12 +21,12 @@ public class KeyServiceTests RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false); - RealmKey realmKey = new( - new("60bb79cf-4bac-4521-87f2-ac87cc15541f"), - "RSA", - rsa.ExportPkcs8PrivateKey(), - DateTime.UtcNow) + RealmKey realmKey = new() { + Id = new("60bb79cf-4bac-4521-87f2-ac87cc15541f"), + KeyType = "RSA", + Key = new("", rsa.ExportPkcs8PrivateKey()), + CreatedAt = DateTime.UtcNow, Priority = 10, }; @@ -34,10 +34,11 @@ public class KeyServiceTests KeyService sut = new(_encryptionService, 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)); } -} \ No newline at end of file +} diff --git a/IdentityShroud.Api/IdentityShroud.Api.csproj b/IdentityShroud.Api/IdentityShroud.Api.csproj index 860fbeb..31f88b2 100644 --- a/IdentityShroud.Api/IdentityShroud.Api.csproj +++ b/IdentityShroud.Api/IdentityShroud.Api.csproj @@ -17,7 +17,6 @@ - diff --git a/IdentityShroud.Core.Tests/Security/ConfigurationSecretProviderTests.cs b/IdentityShroud.Core.Tests/Security/ConfigurationSecretProviderTests.cs new file mode 100644 index 0000000..180732b --- /dev/null +++ b/IdentityShroud.Core.Tests/Security/ConfigurationSecretProviderTests.cs @@ -0,0 +1,61 @@ +using System.Text; +using IdentityShroud.Core.Security; +using Microsoft.Extensions.Configuration; + +namespace IdentityShroud.Core.Tests.Security; + +public class ConfigurationSecretProviderTests +{ + private static IConfiguration BuildConfigFromJson(string json) + { + // Convert the JSON string into a stream that the config builder can read. + var jsonBytes = Encoding.UTF8.GetBytes(json); + using var stream = new MemoryStream(jsonBytes); + + // Build the configuration just like the real app does, but from the stream. + var config = new ConfigurationBuilder() + .AddJsonStream(stream) // <-- reads from the in‑memory JSON + .Build(); + + return config; + } + + [Fact] + public void Test() + { + string jsonConfig = """ + { + "secrets": { + "master": [ + { + "Id": "first", + "Active": true, + "Algorithm": "AES", + "Key": "yoQ4W7EaNjo7s3FBYkWo5BLyX1BnLyWd7BlSaDIrkzo=" + }, + { + "Id": "second", + "Active": false, + "Algorithm": "AES", + "Key": "YSWK6vTJXCJOGLpCo+TtZ6anKNzvA1VT2xXLHbmq4M0=" + } + ] + } + } + """; + + + ConfigurationSecretProvider sut = new(BuildConfigFromJson(jsonConfig)); + + var keys = sut.GetKeys("master"); + + Assert.Equal(2, keys.Length); + var active = keys.Single(k => k.Active); + Assert.Equal("first", active.Id); + Assert.Equal("AES", active.Algorithm); + Assert.Equal(Convert.FromBase64String("yoQ4W7EaNjo7s3FBYkWo5BLyX1BnLyWd7BlSaDIrkzo="), active.Key); + + var inactive = keys.Single(k => !k.Active); + Assert.Equal("second", inactive.Id); + } +} \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/Services/EncryptionServiceTests.cs b/IdentityShroud.Core.Tests/Services/EncryptionServiceTests.cs index 68ab90d..7a7be2c 100644 --- a/IdentityShroud.Core.Tests/Services/EncryptionServiceTests.cs +++ b/IdentityShroud.Core.Tests/Services/EncryptionServiceTests.cs @@ -1,5 +1,3 @@ -using System.Buffers.Text; -using System.Security.Cryptography; using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Services; @@ -11,16 +9,22 @@ public class EncryptionServiceTests public void RoundtripWorks() { // Note this code will tend to only test the latest verion. - + // setup + byte[] keyValue = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); var secretProvider = Substitute.For(); - secretProvider.GetSecret("Master").Returns("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); + EncryptionKey[] keys = + [ + new EncryptionKey("1", true, "AES", keyValue) + ]; + secretProvider.GetKeys("master").Returns(keys); + ReadOnlySpan input = "Hello, World!"u8; // act EncryptionService sut = new(secretProvider); - byte[] cipher = sut.Encrypt(input.ToArray()); + EncryptedValue cipher = sut.Encrypt(input.ToArray()); byte[] result = sut.Decrypt(cipher); // verify @@ -34,19 +38,109 @@ public class EncryptionServiceTests // make sure decoding of legacy data still works. // setup - Span cipher = + byte[] cipher = [ 1, 198, 55, 58, 56, 110, 238, 59, 158, 214, 85, 241, 26, 44, 140, 229, 128, 111, 167, 154, 160, 177, 152, 193, 74, 4, 235, 82, 207, 87, 32, 10, 239, 4, 246, 25, 21, 249, 25, 59, 160, 101 ]; + EncryptedValue secret = new("kid", cipher); + + byte[] keyValue = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); var secretProvider = Substitute.For(); - secretProvider.GetSecret("Master").Returns("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); + EncryptionKey[] keys = + [ + new EncryptionKey("kid", true, "AES", keyValue) + ]; + secretProvider.GetKeys("master").Returns(keys); // act EncryptionService sut = new(secretProvider); - byte[] result = sut.Decrypt(cipher.ToArray()); + byte[] result = sut.Decrypt(secret); // verify Assert.Equal("Hello, World!"u8, result); } + + [Fact] + public void DetectsCorruptInput() + { + // When introducing a new version we need version specific tests to + // make sure decoding of legacy data still works. + + // setup + byte[] cipher = // NOTE INCORRECT CIPHER DO NOT USE IN OTHER TESTS + [ + 1, 198, 55, 58, 56, 110, 238, 59, 158, 214, 85, 241, 26, 44, 140, 229, 128, 111, 167, 154, 160, 177, 152, + 193, 75, 4, 235, 82, 207, 87, 32, 10, 239, 4, 246, 25, 21, 249, 25, 59, 160, 101 + ]; + EncryptedValue secret = new("kid", cipher); + + byte[] keyValue = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); + var secretProvider = Substitute.For(); + EncryptionKey[] keys = + [ + new EncryptionKey("kid", true, "AES", keyValue) + ]; + secretProvider.GetKeys("master").Returns(keys); + + // act + EncryptionService sut = new(secretProvider); + Assert.Throws( + () => sut.Decrypt(secret), + ex => ex.Message.Contains("Decryption failed") ? null : "Expected Decryption failed in message"); + } + + [Fact] + public void DecodeSelectsRightKey() + { + // The key is marked inactive also it is the second key + + // setup + byte[] cipher = + [ + 1, 198, 55, 58, 56, 110, 238, 59, 158, 214, 85, 241, 26, 44, 140, 229, 128, 111, 167, 154, 160, 177, 152, + 193, 74, 4, 235, 82, 207, 87, 32, 10, 239, 4, 246, 25, 21, 249, 25, 59, 160, 101 + ]; + EncryptedValue secret = new("1", cipher); + + byte[] keyValue1 = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); + byte[] keyValue2 = Convert.FromBase64String("Dat1RwRvuLX3wdKMMP4NwHdBl8tJJsKfp01qikyo8aw="); + var secretProvider = Substitute.For(); + EncryptionKey[] keys = + [ + new EncryptionKey("2", true, "AES", keyValue2), + new EncryptionKey("1", false, "AES", keyValue1), + ]; + secretProvider.GetKeys("master").Returns(keys); + + // act + EncryptionService sut = new(secretProvider); + byte[] result = sut.Decrypt(secret); + + // verify + Assert.Equal("Hello, World!"u8, result); + } + + [Fact] + public void EncryptionUsesActiveKey() + { + // setup + byte[] keyValue1 = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); + byte[] keyValue2 = Convert.FromBase64String("Dat1RwRvuLX3wdKMMP4NwHdBl8tJJsKfp01qikyo8aw="); + var secretProvider = Substitute.For(); + EncryptionKey[] keys = + [ + new EncryptionKey("1", false, "AES", keyValue1), + new EncryptionKey("2", true, "AES", keyValue2), + ]; + secretProvider.GetKeys("master").Returns(keys); + + ReadOnlySpan input = "Hello, World!"u8; + // act + EncryptionService sut = new(secretProvider); + EncryptedValue cipher = sut.Encrypt(input.ToArray()); + + // Verify + Assert.Equal("2", cipher.KeyId); + } } \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs b/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs index acbc3bf..ea34ca8 100644 --- a/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs +++ b/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs @@ -39,7 +39,13 @@ public class RealmServiceTests : IClassFixture await using (var db = _dbFixture.CreateDbContext()) { _keyService.CreateKey(Arg.Any()) - .Returns(new RealmKey(Guid.NewGuid(), "TST", [21], DateTime.UtcNow)); + .Returns(new RealmKey() + { + Id = Guid.NewGuid(), + KeyType = "TST", + Key = new("kid", [21]), + CreatedAt = DateTime.UtcNow + }); // Act RealmService sut = new(db, _keyService); var response = await sut.Create( diff --git a/IdentityShroud.Core/Contracts/IEncryptionService.cs b/IdentityShroud.Core/Contracts/IEncryptionService.cs index 388304b..2fa7e9c 100644 --- a/IdentityShroud.Core/Contracts/IEncryptionService.cs +++ b/IdentityShroud.Core/Contracts/IEncryptionService.cs @@ -2,6 +2,6 @@ namespace IdentityShroud.Core.Contracts; public interface IEncryptionService { - byte[] Encrypt(ReadOnlyMemory plain); - byte[] Decrypt(ReadOnlyMemory cipher); + EncryptedValue Encrypt(ReadOnlyMemory plain); + byte[] Decrypt(EncryptedValue input); } \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/ISecretProvider.cs b/IdentityShroud.Core/Contracts/ISecretProvider.cs index 2a8e9e6..a586fe7 100644 --- a/IdentityShroud.Core/Contracts/ISecretProvider.cs +++ b/IdentityShroud.Core/Contracts/ISecretProvider.cs @@ -3,4 +3,10 @@ namespace IdentityShroud.Core.Contracts; public interface ISecretProvider { string GetSecret(string name); + + /// + /// Should return one active key, might return inactive keys. + /// + /// + EncryptionKey[] GetKeys(string name); } diff --git a/IdentityShroud.Core/Model/ClientSecret.cs b/IdentityShroud.Core/Model/ClientSecret.cs index bd57d37..0b0122d 100644 --- a/IdentityShroud.Core/Model/ClientSecret.cs +++ b/IdentityShroud.Core/Model/ClientSecret.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using IdentityShroud.Core.Contracts; namespace IdentityShroud.Core.Model; @@ -11,5 +12,5 @@ public class ClientSecret public Guid ClientId { get; set; } public DateTime CreatedAt { get; set; } public DateTime? RevokedAt { get; set; } - public required byte[] SecretEncrypted { get; set; } + public required EncryptedValue Secret { get; set; } } \ No newline at end of file diff --git a/IdentityShroud.Core/Model/RealmKey.cs b/IdentityShroud.Core/Model/RealmKey.cs index 14c7c9c..038f853 100644 --- a/IdentityShroud.Core/Model/RealmKey.cs +++ b/IdentityShroud.Core/Model/RealmKey.cs @@ -1,15 +1,19 @@ using System.ComponentModel.DataAnnotations.Schema; +using IdentityShroud.Core.Contracts; +using Microsoft.EntityFrameworkCore; namespace IdentityShroud.Core.Model; [Table("realm_key")] -public record RealmKey(Guid Id, string KeyType, byte[] KeyDataEncrypted, DateTime CreatedAt) +public record RealmKey { - public Guid Id { get; private set; } = Id; - public string KeyType { get; private set; } = KeyType; - public byte[] KeyDataEncrypted { get; private set; } = KeyDataEncrypted; - public DateTime CreatedAt { get; private set; } = CreatedAt; + public required Guid Id { get; init; } + public required string KeyType { get; init; } + + + public required EncryptedValue Key { get; init; } + public required DateTime CreatedAt { get; init; } public DateTime? RevokedAt { get; set; } /// diff --git a/IdentityShroud.Core/Security/ConfigurationSecretProvider.cs b/IdentityShroud.Core/Security/ConfigurationSecretProvider.cs index ab77ef1..dd616b1 100644 --- a/IdentityShroud.Core/Security/ConfigurationSecretProvider.cs +++ b/IdentityShroud.Core/Security/ConfigurationSecretProvider.cs @@ -14,4 +14,9 @@ public class ConfigurationSecretProvider(IConfiguration configuration) : ISecret { return secrets.GetValue(name) ?? ""; } + + public EncryptionKey[] GetKeys(string name) + { + return secrets.GetSection(name).Get() ?? []; + } } \ No newline at end of file diff --git a/IdentityShroud.Core/Security/EncryptedValue.cs b/IdentityShroud.Core/Security/EncryptedValue.cs new file mode 100644 index 0000000..655ab13 --- /dev/null +++ b/IdentityShroud.Core/Security/EncryptedValue.cs @@ -0,0 +1,6 @@ +using Microsoft.EntityFrameworkCore; + +namespace IdentityShroud.Core.Contracts; + +[Owned] +public record EncryptedValue(string KeyId, byte[] Value); \ No newline at end of file diff --git a/IdentityShroud.Core/Security/EncryptionKey.cs b/IdentityShroud.Core/Security/EncryptionKey.cs new file mode 100644 index 0000000..2e857a1 --- /dev/null +++ b/IdentityShroud.Core/Security/EncryptionKey.cs @@ -0,0 +1,4 @@ +namespace IdentityShroud.Core.Contracts; + +// Contains an encryption key and associated relevant data +public record EncryptionKey(string Id, bool Active, string Algorithm, byte[] Key); \ No newline at end of file diff --git a/IdentityShroud.Core/Services/ClientService.cs b/IdentityShroud.Core/Services/ClientService.cs index ed85daf..e6b5c32 100644 --- a/IdentityShroud.Core/Services/ClientService.cs +++ b/IdentityShroud.Core/Services/ClientService.cs @@ -57,7 +57,7 @@ public class ClientService( return new ClientSecret() { CreatedAt = clock.UtcNow(), - SecretEncrypted = cryptor.Encrypt(secret), + Secret = cryptor.Encrypt(secret), }; } diff --git a/IdentityShroud.Core/Services/EncryptionService.cs b/IdentityShroud.Core/Services/EncryptionService.cs index 8aa5bed..a6b39c0 100644 --- a/IdentityShroud.Core/Services/EncryptionService.cs +++ b/IdentityShroud.Core/Services/EncryptionService.cs @@ -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) // 256‑bit key - throw new Exception("Key must be 256 bits (32 bytes) for AES‑256‑GCM."); + _encryptionKeys = secretProvider.GetKeys("master"); + // if (_encryptionKey.Length != 32) // 256‑bit key + // throw new Exception("Key must be 256 bits (32 bytes) for AES‑256‑GCM."); } - public byte[] Encrypt(ReadOnlyMemory plaintext) + public EncryptedValue Encrypt(ReadOnlyMemory 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 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 (128‑bit 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); diff --git a/IdentityShroud.Core/Services/KeyService.cs b/IdentityShroud.Core/Services/KeyService.cs index 6c5e828..16af5a4 100644 --- a/IdentityShroud.Core/Services/KeyService.cs +++ b/IdentityShroud.Core/Services/KeyService.cs @@ -29,23 +29,18 @@ public class KeyService( IKeyProvider provider = keyProviderFactory.CreateProvider(realmKey.KeyType); provider.SetJwkParameters( - cryptor.Decrypt(realmKey.KeyDataEncrypted), + cryptor.Decrypt(realmKey.Key), jwk); return jwk; } private RealmKey CreateKey(string keyType, byte[] plainKey) => - new RealmKey( - Guid.NewGuid(), - keyType, - cryptor.Encrypt(plainKey), - clock.UtcNow()); - - // public byte[] GetPrivateKey(IEncryptionService encryptionService) - // { - // if (_privateKeyDecrypted.Length == 0 && PrivateKeyEncrypted.Length > 0) - // _privateKeyDecrypted = encryptionService.Decrypt(PrivateKeyEncrypted); - // return _privateKeyDecrypted; - // } + new RealmKey() + { + Id = Guid.NewGuid(), + KeyType = keyType, + Key = cryptor.Encrypt(plainKey), + CreatedAt = clock.UtcNow(), + }; } diff --git a/IdentityShroud.TestUtils/Substitutes/EncryptionServiceSubstitute.cs b/IdentityShroud.TestUtils/Substitutes/EncryptionServiceSubstitute.cs index 5a81240..36045ae 100644 --- a/IdentityShroud.TestUtils/Substitutes/EncryptionServiceSubstitute.cs +++ b/IdentityShroud.TestUtils/Substitutes/EncryptionServiceSubstitute.cs @@ -8,11 +8,11 @@ public static class EncryptionServiceSubstitute { var encryptionService = Substitute.For(); encryptionService - .Encrypt(Arg.Any()) - .Returns(x => x.ArgAt(0)); + .Encrypt(Arg.Any>()) + .Returns(x => new EncryptedValue("kid", x.ArgAt>(0).ToArray())); encryptionService - .Decrypt(Arg.Any>()) - .Returns(x => x.ArgAt>(0).ToArray()); + .Decrypt(Arg.Any()) + .Returns(x => x.ArgAt(0).Value); return encryptionService; } } \ No newline at end of file diff --git a/IdentityShroud.sln.DotSettings.user b/IdentityShroud.sln.DotSettings.user index d90a7ba..795f362 100644 --- a/IdentityShroud.sln.DotSettings.user +++ b/IdentityShroud.sln.DotSettings.user @@ -20,10 +20,10 @@ ForceIncluded ForceIncluded ForceIncluded - /home/eelke/.cache/JetBrains/Rider2025.3/resharper-host/temp/Rider/vAny/CoverageData/_IdentityShroud.-1277985570/Snapshot/snapshot.utdcvr + /home/eelke/.dotnet/dotnet /home/eelke/.dotnet/sdk/10.0.102/MSBuild.dll - <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> </SessionState> @@ -36,4 +36,5 @@ + \ No newline at end of file From 650fe99990cd63a104cdc834cb033208a6e21d96 Mon Sep 17 00:00:00 2001 From: eelke Date: Thu, 26 Feb 2026 16:53:02 +0100 Subject: [PATCH 13/22] Encrypt realm data with dek which is encrypted with kek. The signing keys are also encrypted with the kek. --- .../Apis/RealmApisTests.cs | 4 +- .../Fixtures/ApplicationFactory.cs | 2 +- .../Mappers/KeyServiceTests.cs | 11 ++- IdentityShroud.Api/Program.cs | 8 +- .../ConfigurationSecretProviderTests.cs | 10 ++- .../Services/ClientServiceTests.cs | 14 +++- ...eTests.cs => DekEncryptionServiceTests.cs} | 79 +++++++------------ .../Services/EncryptionTests.cs | 30 +++++++ .../Services/RealmServiceTests.cs | 3 +- IdentityShroud.Core.Tests/UnitTest1.cs | 10 ++- ...onService.cs => IDataEncryptionService.cs} | 4 +- .../Contracts/IDekEncryptionService.cs | 11 +++ .../Contracts/IRealmContext.cs | 9 +++ .../Contracts/IRealmService.cs | 1 + .../Contracts/ISecretProvider.cs | 4 +- IdentityShroud.Core/Db.cs | 38 ++++++++- .../IdentityShroud.Core.csproj | 1 + IdentityShroud.Core/Model/ClientSecret.cs | 1 + IdentityShroud.Core/Model/Realm.cs | 13 ++- IdentityShroud.Core/Model/RealmKey.cs | 3 +- .../Security/ConfigurationSecretProvider.cs | 4 +- IdentityShroud.Core/Security/DekId.cs | 6 ++ IdentityShroud.Core/Security/EncryptedDek.cs | 6 ++ .../Security/EncryptedValue.cs | 6 +- .../Encryption.cs} | 50 ++++-------- IdentityShroud.Core/Security/EncryptionKey.cs | 4 - IdentityShroud.Core/Security/KekId.cs | 41 ++++++++++ .../Security/KeyEncryptionKey.cs | 10 +++ IdentityShroud.Core/Services/ClientService.cs | 7 +- .../Services/DataEncryptionService.cs | 41 ++++++++++ .../Services/DekEncryptionService.cs | 38 +++++++++ IdentityShroud.Core/Services/KeyService.cs | 2 +- IdentityShroud.Core/Services/RealmContext.cs | 26 ++++++ IdentityShroud.Core/Services/RealmService.cs | 8 +- .../EncryptionServiceSubstitute.cs | 13 +-- IdentityShroud.sln.DotSettings.user | 10 ++- 36 files changed, 399 insertions(+), 129 deletions(-) rename IdentityShroud.Core.Tests/Services/{EncryptionServiceTests.cs => DekEncryptionServiceTests.cs} (60%) create mode 100644 IdentityShroud.Core.Tests/Services/EncryptionTests.cs rename IdentityShroud.Core/Contracts/{IEncryptionService.cs => IDataEncryptionService.cs} (65%) create mode 100644 IdentityShroud.Core/Contracts/IDekEncryptionService.cs create mode 100644 IdentityShroud.Core/Contracts/IRealmContext.cs create mode 100644 IdentityShroud.Core/Security/DekId.cs create mode 100644 IdentityShroud.Core/Security/EncryptedDek.cs rename IdentityShroud.Core/{Services/EncryptionService.cs => Security/Encryption.cs} (55%) delete mode 100644 IdentityShroud.Core/Security/EncryptionKey.cs create mode 100644 IdentityShroud.Core/Security/KekId.cs create mode 100644 IdentityShroud.Core/Security/KeyEncryptionKey.cs create mode 100644 IdentityShroud.Core/Services/DataEncryptionService.cs create mode 100644 IdentityShroud.Core/Services/DekEncryptionService.cs create mode 100644 IdentityShroud.Core/Services/RealmContext.cs diff --git a/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs b/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs index a91ea62..ecc46c0 100644 --- a/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs +++ b/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs @@ -125,7 +125,7 @@ public class RealmApisTests : IClassFixture public async Task GetJwks() { // setup - IEncryptionService encryptionService = _factory.Services.GetRequiredService(); + IDekEncryptionService dekEncryptionService = _factory.Services.GetRequiredService(); using var rsa = RSA.Create(2048); RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false); @@ -134,7 +134,7 @@ public class RealmApisTests : IClassFixture { Id = Guid.NewGuid(), KeyType = "RSA", - Key = encryptionService.Encrypt(rsa.ExportPkcs8PrivateKey()), + Key = dekEncryptionService.Encrypt(rsa.ExportPkcs8PrivateKey()), CreatedAt = DateTime.UtcNow, }; diff --git a/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs b/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs index 2a2be31..9846559 100644 --- a/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs +++ b/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs @@ -28,7 +28,7 @@ public class ApplicationFactory : WebApplicationFactory, IAsyncLifetime new Dictionary { ["Db:ConnectionString"] = _postgresqlServer.GetConnectionString(), - ["secrets:master:0:Id"] = "key1", + ["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=", diff --git a/IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs b/IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs index 0df74a3..b6350cf 100644 --- a/IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs +++ b/IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs @@ -2,6 +2,7 @@ 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; @@ -10,7 +11,9 @@ namespace IdentityShroud.Api.Tests.Mappers; public class KeyServiceTests { - private readonly IEncryptionService _encryptionService = EncryptionServiceSubstitute.CreatePassthrough(); + private readonly IDekEncryptionService _dekEncryptionService = EncryptionServiceSubstitute.CreatePassthrough(); + + //private readonly IDataEncryptionService _dataEncryptionService = Substitute.For(); //private readonly IKeyProviderFactory _keyProviderFactory = Substitute.For(); [Fact] @@ -20,18 +23,20 @@ public class KeyServiceTests 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("", rsa.ExportPkcs8PrivateKey()), + Key = new(EncryptionServiceSubstitute.KeyId, rsa.ExportPkcs8PrivateKey()), CreatedAt = DateTime.UtcNow, Priority = 10, }; // Act - KeyService sut = new(_encryptionService, new KeyProviderFactory(), new ClockService()); + KeyService sut = new(_dekEncryptionService, new KeyProviderFactory(), new ClockService()); var jwk = sut.CreateJsonWebKey(realmKey); Assert.NotNull(jwk); diff --git a/IdentityShroud.Api/Program.cs b/IdentityShroud.Api/Program.cs index 0a145c2..29f6736 100644 --- a/IdentityShroud.Api/Program.cs +++ b/IdentityShroud.Api/Program.cs @@ -38,15 +38,19 @@ void ConfigureBuilder(WebApplicationBuilder builder) services.AddScoped(); services.AddScoped(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddOptions().Bind(configuration.GetSection("db")); services.AddSingleton(); services.AddScoped(); + services.AddScoped(); - services.AddValidatorsFromAssemblyContaining(); + services.AddValidatorsFromAssemblyContaining(); + services.AddHttpContextAccessor(); builder.Host.UseSerilog((context, services, configuration) => configuration .Enrich.FromLogContext() diff --git a/IdentityShroud.Core.Tests/Security/ConfigurationSecretProviderTests.cs b/IdentityShroud.Core.Tests/Security/ConfigurationSecretProviderTests.cs index 180732b..01851a4 100644 --- a/IdentityShroud.Core.Tests/Security/ConfigurationSecretProviderTests.cs +++ b/IdentityShroud.Core.Tests/Security/ConfigurationSecretProviderTests.cs @@ -28,13 +28,13 @@ public class ConfigurationSecretProviderTests "secrets": { "master": [ { - "Id": "first", + "Id": "5676d159-5495-4945-aa84-59ee694aa8a2", "Active": true, "Algorithm": "AES", "Key": "yoQ4W7EaNjo7s3FBYkWo5BLyX1BnLyWd7BlSaDIrkzo=" }, { - "Id": "second", + "Id": "b82489e7-a05a-4d64-b9a5-58d2f2c0dc39", "Active": false, "Algorithm": "AES", "Key": "YSWK6vTJXCJOGLpCo+TtZ6anKNzvA1VT2xXLHbmq4M0=" @@ -47,15 +47,17 @@ public class ConfigurationSecretProviderTests ConfigurationSecretProvider sut = new(BuildConfigFromJson(jsonConfig)); + // act var keys = sut.GetKeys("master"); + // verify Assert.Equal(2, keys.Length); var active = keys.Single(k => k.Active); - Assert.Equal("first", active.Id); + Assert.Equal(new Guid("5676d159-5495-4945-aa84-59ee694aa8a2"), active.Id.Id); Assert.Equal("AES", active.Algorithm); Assert.Equal(Convert.FromBase64String("yoQ4W7EaNjo7s3FBYkWo5BLyX1BnLyWd7BlSaDIrkzo="), active.Key); var inactive = keys.Single(k => !k.Active); - Assert.Equal("second", inactive.Id); + Assert.Equal(new Guid("b82489e7-a05a-4d64-b9a5-58d2f2c0dc39"), inactive.Id.Id); } } \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs b/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs index 30bb3b6..5b08563 100644 --- a/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs +++ b/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs @@ -1,5 +1,6 @@ using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Model; +using IdentityShroud.Core.Security; using IdentityShroud.Core.Services; using IdentityShroud.Core.Tests.Fixtures; using IdentityShroud.TestUtils.Substitutes; @@ -10,12 +11,17 @@ namespace IdentityShroud.Core.Tests.Services; public class ClientServiceTests : IClassFixture { private readonly DbFixture _dbFixture; - private readonly IEncryptionService _encryptionService = EncryptionServiceSubstitute.CreatePassthrough(); + //private readonly IDekEncryptionService _dekEncryptionService = EncryptionServiceSubstitute.CreatePassthrough(); + private readonly IDataEncryptionService _dataEncryptionService = Substitute.For(); + private readonly IClock _clock = Substitute.For(); private readonly Guid _realmId = new("a1b2c3d4-0000-0000-0000-000000000001"); public ClientServiceTests(DbFixture dbFixture) { + _dataEncryptionService.Encrypt(Arg.Any>()) + .Returns(x => new EncryptedValue(DekId.NewId(), x.ArgAt>(0).ToArray())); + _dbFixture = dbFixture; using Db db = dbFixture.CreateDbContext(); if (!db.Database.EnsureCreated()) @@ -51,7 +57,7 @@ public class ClientServiceTests : IClassFixture await using (var db = _dbFixture.CreateDbContext()) { // Act - ClientService sut = new(db, _encryptionService, _clock); + ClientService sut = new(db, _dataEncryptionService, _clock); var response = await sut.Create( _realmId, new ClientCreateRequest @@ -107,7 +113,7 @@ public class ClientServiceTests : IClassFixture await using var actContext = _dbFixture.CreateDbContext(); // Act - ClientService sut = new(actContext, _encryptionService, _clock); + ClientService sut = new(actContext, _dataEncryptionService, _clock); Client? result = await sut.GetByClientId(_realmId, clientId, TestContext.Current.CancellationToken); // Verify @@ -142,7 +148,7 @@ public class ClientServiceTests : IClassFixture await using var actContext = _dbFixture.CreateDbContext(); // Act - ClientService sut = new(actContext, _encryptionService, _clock); + ClientService sut = new(actContext, _dataEncryptionService, _clock); Client? result = await sut.FindById(_realmId, searchId, TestContext.Current.CancellationToken); // Verify diff --git a/IdentityShroud.Core.Tests/Services/EncryptionServiceTests.cs b/IdentityShroud.Core.Tests/Services/DekEncryptionServiceTests.cs similarity index 60% rename from IdentityShroud.Core.Tests/Services/EncryptionServiceTests.cs rename to IdentityShroud.Core.Tests/Services/DekEncryptionServiceTests.cs index 7a7be2c..fc4a45f 100644 --- a/IdentityShroud.Core.Tests/Services/EncryptionServiceTests.cs +++ b/IdentityShroud.Core.Tests/Services/DekEncryptionServiceTests.cs @@ -1,9 +1,10 @@ using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Security; using IdentityShroud.Core.Services; namespace IdentityShroud.Core.Tests.Services; -public class EncryptionServiceTests +public class DekEncryptionServiceTests { [Fact] public void RoundtripWorks() @@ -13,9 +14,9 @@ public class EncryptionServiceTests // setup byte[] keyValue = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); var secretProvider = Substitute.For(); - EncryptionKey[] keys = + KeyEncryptionKey[] keys = [ - new EncryptionKey("1", true, "AES", keyValue) + new KeyEncryptionKey(KekId.NewId(), true, "AES", keyValue) ]; secretProvider.GetKeys("master").Returns(keys); @@ -23,68 +24,38 @@ public class EncryptionServiceTests ReadOnlySpan input = "Hello, World!"u8; // act - EncryptionService sut = new(secretProvider); - EncryptedValue cipher = sut.Encrypt(input.ToArray()); + DekEncryptionService sut = new(secretProvider); + EncryptedDek cipher = sut.Encrypt(input.ToArray()); byte[] result = sut.Decrypt(cipher); // verify Assert.Equal(input, result); } - [Fact] - public void DecodeV1_Success() - { - // When introducing a new version we need version specific tests to - // make sure decoding of legacy data still works. - - // setup - byte[] cipher = - [ - 1, 198, 55, 58, 56, 110, 238, 59, 158, 214, 85, 241, 26, 44, 140, 229, 128, 111, 167, 154, 160, 177, 152, - 193, 74, 4, 235, 82, 207, 87, 32, 10, 239, 4, 246, 25, 21, 249, 25, 59, 160, 101 - ]; - EncryptedValue secret = new("kid", cipher); - - byte[] keyValue = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); - var secretProvider = Substitute.For(); - EncryptionKey[] keys = - [ - new EncryptionKey("kid", true, "AES", keyValue) - ]; - secretProvider.GetKeys("master").Returns(keys); - - // act - EncryptionService sut = new(secretProvider); - byte[] result = sut.Decrypt(secret); - - // verify - Assert.Equal("Hello, World!"u8, result); - } - [Fact] public void DetectsCorruptInput() { // When introducing a new version we need version specific tests to // make sure decoding of legacy data still works. - + KekId kid = KekId.NewId(); // setup byte[] cipher = // NOTE INCORRECT CIPHER DO NOT USE IN OTHER TESTS [ 1, 198, 55, 58, 56, 110, 238, 59, 158, 214, 85, 241, 26, 44, 140, 229, 128, 111, 167, 154, 160, 177, 152, 193, 75, 4, 235, 82, 207, 87, 32, 10, 239, 4, 246, 25, 21, 249, 25, 59, 160, 101 ]; - EncryptedValue secret = new("kid", cipher); + EncryptedDek secret = new(kid, cipher); byte[] keyValue = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); var secretProvider = Substitute.For(); - EncryptionKey[] keys = + KeyEncryptionKey[] keys = [ - new EncryptionKey("kid", true, "AES", keyValue) + new KeyEncryptionKey(kid, true, "AES", keyValue) ]; secretProvider.GetKeys("master").Returns(keys); // act - EncryptionService sut = new(secretProvider); + DekEncryptionService sut = new(secretProvider); Assert.Throws( () => sut.Decrypt(secret), ex => ex.Message.Contains("Decryption failed") ? null : "Expected Decryption failed in message"); @@ -96,25 +67,28 @@ public class EncryptionServiceTests // The key is marked inactive also it is the second key // setup + KekId kid1 = KekId.NewId(); + KekId kid2 = KekId.NewId(); + byte[] cipher = [ 1, 198, 55, 58, 56, 110, 238, 59, 158, 214, 85, 241, 26, 44, 140, 229, 128, 111, 167, 154, 160, 177, 152, 193, 74, 4, 235, 82, 207, 87, 32, 10, 239, 4, 246, 25, 21, 249, 25, 59, 160, 101 ]; - EncryptedValue secret = new("1", cipher); + EncryptedDek secret = new(kid1, cipher); byte[] keyValue1 = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); byte[] keyValue2 = Convert.FromBase64String("Dat1RwRvuLX3wdKMMP4NwHdBl8tJJsKfp01qikyo8aw="); var secretProvider = Substitute.For(); - EncryptionKey[] keys = + KeyEncryptionKey[] keys = [ - new EncryptionKey("2", true, "AES", keyValue2), - new EncryptionKey("1", false, "AES", keyValue1), + new KeyEncryptionKey(kid2, true, "AES", keyValue2), + new KeyEncryptionKey(kid1, false, "AES", keyValue1), ]; secretProvider.GetKeys("master").Returns(keys); // act - EncryptionService sut = new(secretProvider); + DekEncryptionService sut = new(secretProvider); byte[] result = sut.Decrypt(secret); // verify @@ -125,22 +99,25 @@ public class EncryptionServiceTests public void EncryptionUsesActiveKey() { // setup + KekId kid1 = KekId.NewId(); + KekId kid2 = KekId.NewId(); + byte[] keyValue1 = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); byte[] keyValue2 = Convert.FromBase64String("Dat1RwRvuLX3wdKMMP4NwHdBl8tJJsKfp01qikyo8aw="); var secretProvider = Substitute.For(); - EncryptionKey[] keys = + KeyEncryptionKey[] keys = [ - new EncryptionKey("1", false, "AES", keyValue1), - new EncryptionKey("2", true, "AES", keyValue2), + new KeyEncryptionKey(kid1, false, "AES", keyValue1), + new KeyEncryptionKey(kid2, true, "AES", keyValue2), ]; secretProvider.GetKeys("master").Returns(keys); ReadOnlySpan input = "Hello, World!"u8; // act - EncryptionService sut = new(secretProvider); - EncryptedValue cipher = sut.Encrypt(input.ToArray()); + DekEncryptionService sut = new(secretProvider); + EncryptedDek cipher = sut.Encrypt(input.ToArray()); // Verify - Assert.Equal("2", cipher.KeyId); + Assert.Equal(kid2, cipher.KekId); } } \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/Services/EncryptionTests.cs b/IdentityShroud.Core.Tests/Services/EncryptionTests.cs new file mode 100644 index 0000000..2dfbb52 --- /dev/null +++ b/IdentityShroud.Core.Tests/Services/EncryptionTests.cs @@ -0,0 +1,30 @@ +using IdentityShroud.Core.Security; +using IdentityShroud.Core.Services; + +namespace IdentityShroud.Core.Tests.Services; + +public class EncryptionTests +{ + [Fact] + public void DecodeV1_Success() + { + // When introducing a new version we need version specific tests to + // make sure decoding of legacy data still works. + + // setup + byte[] cipher = + [ + 1, 198, 55, 58, 56, 110, 238, 59, 158, 214, 85, 241, 26, 44, 140, 229, 128, 111, 167, 154, 160, 177, 152, + 193, 74, 4, 235, 82, 207, 87, 32, 10, 239, 4, 246, 25, 21, 249, 25, 59, 160, 101 + ]; + byte[] keyValue = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); + + // act + byte[] result = Encryption.Decrypt(cipher, keyValue); + + // verify + Assert.Equal("Hello, World!"u8, result); + } + + +} \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs b/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs index ea34ca8..fda233e 100644 --- a/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs +++ b/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs @@ -1,5 +1,6 @@ using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Model; +using IdentityShroud.Core.Security; using IdentityShroud.Core.Security.Keys; using IdentityShroud.Core.Services; using IdentityShroud.Core.Tests.Fixtures; @@ -43,7 +44,7 @@ public class RealmServiceTests : IClassFixture { Id = Guid.NewGuid(), KeyType = "TST", - Key = new("kid", [21]), + Key = new(KekId.NewId(), [21]), CreatedAt = DateTime.UtcNow }); // Act diff --git a/IdentityShroud.Core.Tests/UnitTest1.cs b/IdentityShroud.Core.Tests/UnitTest1.cs index 7a12bc4..7506fd0 100644 --- a/IdentityShroud.Core.Tests/UnitTest1.cs +++ b/IdentityShroud.Core.Tests/UnitTest1.cs @@ -35,7 +35,6 @@ public class UnitTest1 // Option 3: Generate a new key for testing rsa.KeySize = 2048; - // Your already encoded header and payload string header = "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJybVZ3TU5rM0o1WHlmMWhyS3NVbEVYN1BNUm42dlZKY0h3U3FYMUVQRnFJIn0"; string payload = "eyJleHAiOjE3Njk5MzY5MDksImlhdCI6MTc2OTkzNjYwOSwianRpIjoiMjNiZDJmNjktODdhYi00YmM2LWE0MWQtZGZkNzkxNDc4ZDM0IiwiaXNzIjoiaHR0cHM6Ly9pYW0ua2Fzc2FjbG91ZC5ubC9hdXRoL3JlYWxtcy9tcGx1c2thc3NhIiwiYXVkIjpbImthc3NhLW1hbmFnZW1lbnQtc2VydmljZSIsImFwYWNoZTItaW50cmFuZXQtYXV0aCIsImFjY291bnQiXSwic3ViIjoiMDkzY2NmMTUtYzRhOS00YWI0LTk3MWYtZDVhMDIyMzZkODVhIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoibXBvYmFja2VuZCIsInNpZCI6IjI2NmUyNjJiLTU5NjMtNDUyZi04ZTI3LWIwZTkzMjBkNTZkNiIsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJkZWZhdWx0LXJvbGVzLW1wbHVza2Fzc2EiLCJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIiwiZGVhbGVyLW1lZGV3ZXJrZXItcm9sZSIsIm1wbHVza2Fzc2EtbWVkZXdlcmtlci1yb2xlIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsiYXBhY2hlMi1pbnRyYW5ldC1hdXRoIjp7InJvbGVzIjpbImludHJhbmV0IiwicmVsZWFzZW5vdGVzX3dyaXRlIl19LCJrYXNzYS1tYW5hZ2VtZW50LXNlcnZpY2UiOnsicm9sZXMiOlsicG9zYWNjb3VudF9wYXNzd29yZHJlc2V0IiwiZHJhZnRfbGljZW5zZV93cml0ZSIsImxpY2Vuc2VfcmVhZCIsImtub3dsZWRnZUl0ZW1fcmVhZCIsIm1haWxpbmdfcmVhZCIsIm1wbHVzYXBpX3JlYWQiLCJkYXRhYmFzZV91c2VyX3dyaXRlIiwiZW52aXJvbm1lbnRfd3JpdGUiLCJna3NfYXV0aGNvZGVfcmVhZCIsImVtcGxveWVlX3JlYWQiLCJkYXRhYmFzZV91c2VyX3JlYWQiLCJhcGlhY2NvdW50X3Bhc3N3b3JkcmVzZXQiLCJtcGx1c2FwaV93cml0ZSIsImVudmlyb25tZW50X3JlYWQiLCJrbm93bGVkZ2VJdGVtX3dyaXRlIiwiZGF0YWJhc2VfdXNlcl9wYXNzd29yZF9yZWFkIiwibGljZW5zZV93cml0ZSIsImN1c3RvbWVyX3dyaXRlIiwiZGVhbGVyX3JlYWQiLCJlbXBsb3llZV93cml0ZSIsImRhdGFiYXNlX2NvbmZpZ3VyYXRpb25fd3JpdGUiLCJyZWxhdGlvbnNfcmVhZCIsImRhdGFiYXNlX3VzZXJfcGFzc3dvcmRfbXBsdXNfZW5jcnlwdGVkX3JlYWQiLCJkcmFmdF9saWNlbnNlX3JlYWQiLCJkYXRhYmFzZV9jb25maWd1cmF0aW9uX3JlYWQiXX0sImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoia21zIGVtYWlsIHByb2ZpbGUiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZGVhbGVySWQiOjEsIm5hbWUiOiJFZWxrZSBLbGVpbiIsInByZWZlcnJlZF91c2VybmFtZSI6ImVlbGtlQGJvbHQubmwiLCJsb2NhbGUiOiJlbiIsImdpdmVuX25hbWUiOiJFZWxrZSIsImZhbWlseV9uYW1lIjoiS2xlaW4iLCJlbWFpbCI6ImVlbGtlQGJvbHQubmwiLCJlbXBsb3llZU51bWJlciI6NTR9"; @@ -51,6 +50,15 @@ public class UnitTest1 // Or generate complete JWT // string completeJwt = JwtSignatureGenerator.GenerateCompleteJwt(header, payload, rsa); // Console.WriteLine($"Complete JWT: {completeJwt}"); + + rsa.ExportRSAPublicKey(); // PKCS#1 + } + + using (ECDsa dsa = ECDsa.Create()) + { + dsa.ExportPkcs8PrivateKey(); + + dsa.ExportSubjectPublicKeyInfo(); // x509 } } } diff --git a/IdentityShroud.Core/Contracts/IEncryptionService.cs b/IdentityShroud.Core/Contracts/IDataEncryptionService.cs similarity index 65% rename from IdentityShroud.Core/Contracts/IEncryptionService.cs rename to IdentityShroud.Core/Contracts/IDataEncryptionService.cs index 2fa7e9c..55eafe2 100644 --- a/IdentityShroud.Core/Contracts/IEncryptionService.cs +++ b/IdentityShroud.Core/Contracts/IDataEncryptionService.cs @@ -1,6 +1,8 @@ +using IdentityShroud.Core.Security; + namespace IdentityShroud.Core.Contracts; -public interface IEncryptionService +public interface IDataEncryptionService { EncryptedValue Encrypt(ReadOnlyMemory plain); byte[] Decrypt(EncryptedValue input); diff --git a/IdentityShroud.Core/Contracts/IDekEncryptionService.cs b/IdentityShroud.Core/Contracts/IDekEncryptionService.cs new file mode 100644 index 0000000..45e9b3f --- /dev/null +++ b/IdentityShroud.Core/Contracts/IDekEncryptionService.cs @@ -0,0 +1,11 @@ +using IdentityShroud.Core.Security; + +namespace IdentityShroud.Core.Contracts; + + + +public interface IDekEncryptionService +{ + EncryptedDek Encrypt(ReadOnlyMemory plain); + byte[] Decrypt(EncryptedDek input); +} \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/IRealmContext.cs b/IdentityShroud.Core/Contracts/IRealmContext.cs new file mode 100644 index 0000000..c757a02 --- /dev/null +++ b/IdentityShroud.Core/Contracts/IRealmContext.cs @@ -0,0 +1,9 @@ +using IdentityShroud.Core.Model; + +namespace IdentityShroud.Core.Contracts; + +public interface IRealmContext +{ + public Realm GetRealm(); + Task> GetDeks(CancellationToken ct = default); +} \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/IRealmService.cs b/IdentityShroud.Core/Contracts/IRealmService.cs index b740aa5..4598b97 100644 --- a/IdentityShroud.Core/Contracts/IRealmService.cs +++ b/IdentityShroud.Core/Contracts/IRealmService.cs @@ -11,4 +11,5 @@ public interface IRealmService Task> Create(RealmCreateRequest request, CancellationToken ct = default); Task LoadActiveKeys(Realm realm); + Task LoadDeks(Realm realm); } \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/ISecretProvider.cs b/IdentityShroud.Core/Contracts/ISecretProvider.cs index a586fe7..4d4182e 100644 --- a/IdentityShroud.Core/Contracts/ISecretProvider.cs +++ b/IdentityShroud.Core/Contracts/ISecretProvider.cs @@ -1,3 +1,5 @@ +using IdentityShroud.Core.Security; + namespace IdentityShroud.Core.Contracts; public interface ISecretProvider @@ -8,5 +10,5 @@ public interface ISecretProvider /// Should return one active key, might return inactive keys. /// /// - EncryptionKey[] GetKeys(string name); + KeyEncryptionKey[] GetKeys(string name); } diff --git a/IdentityShroud.Core/Db.cs b/IdentityShroud.Core/Db.cs index cd7a493..a37136c 100644 --- a/IdentityShroud.Core/Db.cs +++ b/IdentityShroud.Core/Db.cs @@ -1,5 +1,7 @@ using IdentityShroud.Core.Model; +using IdentityShroud.Core.Security; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -19,7 +21,41 @@ public class Db( public virtual DbSet Clients { get; set; } public virtual DbSet Realms { get; set; } public virtual DbSet Keys { get; set; } - + public virtual DbSet Deks { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var dekIdConverter = new ValueConverter( + id => id.Id, + guid => new DekId(guid)); + + var kekIdConverter = new ValueConverter( + id => id.Id, + guid => new KekId(guid)); + + modelBuilder.Entity() + .Property(d => d.Id) + .HasConversion(dekIdConverter); + + modelBuilder.Entity() + .OwnsOne(d => d.KeyData, keyData => + { + keyData.Property(k => k.KekId).HasConversion(kekIdConverter); + }); + + modelBuilder.Entity() + .OwnsOne(k => k.Key, key => + { + key.Property(k => k.KekId).HasConversion(kekIdConverter); + }); + + modelBuilder.Entity() + .OwnsOne(c => c.Secret, secret => + { + secret.Property(s => s.DekId).HasConversion(dekIdConverter); + }); + } + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseNpgsql(""); diff --git a/IdentityShroud.Core/IdentityShroud.Core.csproj b/IdentityShroud.Core/IdentityShroud.Core.csproj index 1e7e8d0..9dd3e34 100644 --- a/IdentityShroud.Core/IdentityShroud.Core.csproj +++ b/IdentityShroud.Core/IdentityShroud.Core.csproj @@ -12,6 +12,7 @@ + diff --git a/IdentityShroud.Core/Model/ClientSecret.cs b/IdentityShroud.Core/Model/ClientSecret.cs index 0b0122d..52d25cc 100644 --- a/IdentityShroud.Core/Model/ClientSecret.cs +++ b/IdentityShroud.Core/Model/ClientSecret.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Security; namespace IdentityShroud.Core.Model; diff --git a/IdentityShroud.Core/Model/Realm.cs b/IdentityShroud.Core/Model/Realm.cs index 7fcd10c..f3e087a 100644 --- a/IdentityShroud.Core/Model/Realm.cs +++ b/IdentityShroud.Core/Model/Realm.cs @@ -21,9 +21,20 @@ public class Realm public List Keys { get; init; } = []; + public List Deks { get; init; } = []; + /// /// Can be overriden per client /// public string DefaultSignatureAlgorithm { get; set; } = JsonWebAlgorithm.RS256; - +} + +[Table("realm_dek")] +public record RealmDek +{ + public required DekId Id { get; init; } + public required bool Active { get; init; } + public required string Algorithm { get; init; } + public required EncryptedDek KeyData { get; init; } + public required Guid RealmId { get; init; } } diff --git a/IdentityShroud.Core/Model/RealmKey.cs b/IdentityShroud.Core/Model/RealmKey.cs index 038f853..3fcf2d1 100644 --- a/IdentityShroud.Core/Model/RealmKey.cs +++ b/IdentityShroud.Core/Model/RealmKey.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations.Schema; using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Security; using Microsoft.EntityFrameworkCore; namespace IdentityShroud.Core.Model; @@ -12,7 +13,7 @@ public record RealmKey public required string KeyType { get; init; } - public required EncryptedValue Key { get; init; } + public required EncryptedDek Key { get; init; } public required DateTime CreatedAt { get; init; } public DateTime? RevokedAt { get; set; } diff --git a/IdentityShroud.Core/Security/ConfigurationSecretProvider.cs b/IdentityShroud.Core/Security/ConfigurationSecretProvider.cs index dd616b1..9355c0b 100644 --- a/IdentityShroud.Core/Security/ConfigurationSecretProvider.cs +++ b/IdentityShroud.Core/Security/ConfigurationSecretProvider.cs @@ -15,8 +15,8 @@ public class ConfigurationSecretProvider(IConfiguration configuration) : ISecret return secrets.GetValue(name) ?? ""; } - public EncryptionKey[] GetKeys(string name) + public KeyEncryptionKey[] GetKeys(string name) { - return secrets.GetSection(name).Get() ?? []; + return secrets.GetSection(name).Get() ?? []; } } \ No newline at end of file diff --git a/IdentityShroud.Core/Security/DekId.cs b/IdentityShroud.Core/Security/DekId.cs new file mode 100644 index 0000000..276178e --- /dev/null +++ b/IdentityShroud.Core/Security/DekId.cs @@ -0,0 +1,6 @@ +namespace IdentityShroud.Core.Security; + +public record struct DekId(Guid Id) +{ + public static DekId NewId() => new(Guid.NewGuid()); +} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/EncryptedDek.cs b/IdentityShroud.Core/Security/EncryptedDek.cs new file mode 100644 index 0000000..377a2f6 --- /dev/null +++ b/IdentityShroud.Core/Security/EncryptedDek.cs @@ -0,0 +1,6 @@ +using Microsoft.EntityFrameworkCore; + +namespace IdentityShroud.Core.Security; + +[Owned] +public record EncryptedDek(KekId KekId, byte[] Value); \ No newline at end of file diff --git a/IdentityShroud.Core/Security/EncryptedValue.cs b/IdentityShroud.Core/Security/EncryptedValue.cs index 655ab13..173c295 100644 --- a/IdentityShroud.Core/Security/EncryptedValue.cs +++ b/IdentityShroud.Core/Security/EncryptedValue.cs @@ -1,6 +1,8 @@ using Microsoft.EntityFrameworkCore; -namespace IdentityShroud.Core.Contracts; +namespace IdentityShroud.Core.Security; [Owned] -public record EncryptedValue(string KeyId, byte[] Value); \ No newline at end of file +public record EncryptedValue(DekId DekId, byte[] Value); + + diff --git a/IdentityShroud.Core/Services/EncryptionService.cs b/IdentityShroud.Core/Security/Encryption.cs similarity index 55% rename from IdentityShroud.Core/Services/EncryptionService.cs rename to IdentityShroud.Core/Security/Encryption.cs index a6b39c0..a80a273 100644 --- a/IdentityShroud.Core/Services/EncryptionService.cs +++ b/IdentityShroud.Core/Security/Encryption.cs @@ -1,36 +1,18 @@ using System.Security.Cryptography; -using IdentityShroud.Core.Contracts; -namespace IdentityShroud.Core.Services; +namespace IdentityShroud.Core.Security; -/// -/// -/// -public class EncryptionService : IEncryptionService +public static class Encryption { - private record struct AlgVersion(int NonceSize, int TagSize); + private record struct AlgVersion(int Version, int NonceSize, int TagSize); - private AlgVersion[] _versions = + private static AlgVersion[] _versions = [ - new(0, 0), // version 0 does not realy exist - new (12, 16), // version 1 + new(0, 0, 0), // version 0 does not realy exist + new(1, 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) // 256‑bit key - // throw new Exception("Key must be 256 bits (32 bytes) for AES‑256‑GCM."); - } - - public EncryptedValue Encrypt(ReadOnlyMemory plaintext) + public static byte[] Encrypt(ReadOnlyMemory plaintext, ReadOnlySpan key) { const int versionNumber = 1; AlgVersion versionParams = _versions[versionNumber]; @@ -39,7 +21,7 @@ public class EncryptionService : IEncryptionService // allocate buffer for complete response var result = new byte[resultSize]; - result[0] = (byte)versionNumber; + result[0] = (byte)versionParams.Version; // make the spans that point to the parts of the result where their data is located var nonce = result.AsSpan(1, versionParams.NonceSize); @@ -48,18 +30,14 @@ public class EncryptionService : IEncryptionService // 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); + using var aes = new AesGcm(key, versionParams.TagSize); aes.Encrypt(nonce, plaintext.Span, cipher, tag); - - return new (encryptionKey.Id, result); + return result; } - - public byte[] Decrypt(EncryptedValue input) + + public static byte[] Decrypt(ReadOnlyMemory input, ReadOnlySpan key) { - var encryptionKey = GetKey(input.KeyId); - - var payload = input.Value.AsSpan(); + var payload = input.Span; int versionNumber = (int)payload[0]; if (versionNumber != 1) throw new ArgumentException("Invalid payload"); @@ -76,7 +54,7 @@ public class EncryptionService : IEncryptionService byte[] plaintext = new byte[cipher.Length]; - using var aes = new AesGcm(encryptionKey.Key, versionParams.TagSize); + using var aes = new AesGcm(key, versionParams.TagSize); try { aes.Decrypt(nonce, cipher, tag, plaintext); diff --git a/IdentityShroud.Core/Security/EncryptionKey.cs b/IdentityShroud.Core/Security/EncryptionKey.cs deleted file mode 100644 index 2e857a1..0000000 --- a/IdentityShroud.Core/Security/EncryptionKey.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace IdentityShroud.Core.Contracts; - -// Contains an encryption key and associated relevant data -public record EncryptionKey(string Id, bool Active, string Algorithm, byte[] Key); \ No newline at end of file diff --git a/IdentityShroud.Core/Security/KekId.cs b/IdentityShroud.Core/Security/KekId.cs new file mode 100644 index 0000000..c794078 --- /dev/null +++ b/IdentityShroud.Core/Security/KekId.cs @@ -0,0 +1,41 @@ +using System.ComponentModel; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace IdentityShroud.Core.Security; + +[JsonConverter(typeof(KekIdJsonConverter))] +[TypeConverter(typeof(KekIdTypeConverter))] +public readonly record struct KekId +{ + public Guid Id { get; } + + public KekId(Guid id) + { + Id = id; + } + + public static KekId NewId() + { + return new KekId(Guid.NewGuid()); + } +} + +public class KekIdJsonConverter : JsonConverter +{ + public override KekId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => new KekId(reader.GetGuid()); + + public override void Write(Utf8JsonWriter writer, KekId value, JsonSerializerOptions options) + => writer.WriteStringValue(value.Id); +} + +public class KekIdTypeConverter : TypeConverter +{ + public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) + => sourceType == typeof(string) || base.CanConvertFrom(context, sourceType); + + public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) + => value is string s ? new KekId(Guid.Parse(s)) : base.ConvertFrom(context, culture, value); +} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/KeyEncryptionKey.cs b/IdentityShroud.Core/Security/KeyEncryptionKey.cs new file mode 100644 index 0000000..35f7917 --- /dev/null +++ b/IdentityShroud.Core/Security/KeyEncryptionKey.cs @@ -0,0 +1,10 @@ +namespace IdentityShroud.Core.Security; + +/// +/// Contains a KEK and associated relevant data. This structure +/// +/// +/// +/// +/// +public record KeyEncryptionKey(KekId Id, bool Active, string Algorithm, byte[] Key); diff --git a/IdentityShroud.Core/Services/ClientService.cs b/IdentityShroud.Core/Services/ClientService.cs index e6b5c32..0887ccd 100644 --- a/IdentityShroud.Core/Services/ClientService.cs +++ b/IdentityShroud.Core/Services/ClientService.cs @@ -7,7 +7,7 @@ namespace IdentityShroud.Core.Services; public class ClientService( Db db, - IEncryptionService cryptor, + IDataEncryptionService cryptor, IClock clock) : IClientService { public async Task> Create(Guid realmId, ClientCreateRequest request, CancellationToken ct = default) @@ -52,12 +52,13 @@ public class ClientService( private ClientSecret CreateSecret() { - byte[] secret = RandomNumberGenerator.GetBytes(24); + Span secret = stackalloc byte[24]; + RandomNumberGenerator.Fill(secret); return new ClientSecret() { CreatedAt = clock.UtcNow(), - Secret = cryptor.Encrypt(secret), + Secret = cryptor.Encrypt(secret.ToArray()), }; } diff --git a/IdentityShroud.Core/Services/DataEncryptionService.cs b/IdentityShroud.Core/Services/DataEncryptionService.cs new file mode 100644 index 0000000..603f833 --- /dev/null +++ b/IdentityShroud.Core/Services/DataEncryptionService.cs @@ -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? _deks = null; + + private IList 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 plain) + { + var dek = GetActiveDek(); + var key = dekCryptor.Decrypt(dek.KeyData); + byte[] cipher = Encryption.Encrypt(plain, key); + return new (dek.Id, cipher); + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/DekEncryptionService.cs b/IdentityShroud.Core/Services/DekEncryptionService.cs new file mode 100644 index 0000000..c147662 --- /dev/null +++ b/IdentityShroud.Core/Services/DekEncryptionService.cs @@ -0,0 +1,38 @@ +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Security; + +namespace IdentityShroud.Core.Services; + +/// +/// +/// +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) // 256‑bit key + // throw new Exception("Key must be 256 bits (32 bytes) for AES‑256‑GCM."); + } + + public EncryptedDek Encrypt(ReadOnlyMemory 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); + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/KeyService.cs b/IdentityShroud.Core/Services/KeyService.cs index 16af5a4..a2ce9dc 100644 --- a/IdentityShroud.Core/Services/KeyService.cs +++ b/IdentityShroud.Core/Services/KeyService.cs @@ -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 { diff --git a/IdentityShroud.Core/Services/RealmContext.cs b/IdentityShroud.Core/Services/RealmContext.cs new file mode 100644 index 0000000..7daa399 --- /dev/null +++ b/IdentityShroud.Core/Services/RealmContext.cs @@ -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> GetDeks(CancellationToken ct = default) + { + Realm realm = GetRealm(); + if (realm.Deks.Count == 0) + { + await realmService.LoadDeks(realm); + } + + return realm.Deks; + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/RealmService.cs b/IdentityShroud.Core/Services/RealmService.cs index f8e7185..949c9fe 100644 --- a/IdentityShroud.Core/Services/RealmService.cs +++ b/IdentityShroud.Core/Services/RealmService.cs @@ -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(); } } \ No newline at end of file diff --git a/IdentityShroud.TestUtils/Substitutes/EncryptionServiceSubstitute.cs b/IdentityShroud.TestUtils/Substitutes/EncryptionServiceSubstitute.cs index 36045ae..009629e 100644 --- a/IdentityShroud.TestUtils/Substitutes/EncryptionServiceSubstitute.cs +++ b/IdentityShroud.TestUtils/Substitutes/EncryptionServiceSubstitute.cs @@ -1,18 +1,21 @@ using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Security; namespace IdentityShroud.TestUtils.Substitutes; public static class EncryptionServiceSubstitute { - public static IEncryptionService CreatePassthrough() + public static KekId KeyId { get; } = KekId.NewId(); + + public static IDekEncryptionService CreatePassthrough() { - var encryptionService = Substitute.For(); + var encryptionService = Substitute.For(); encryptionService .Encrypt(Arg.Any>()) - .Returns(x => new EncryptedValue("kid", x.ArgAt>(0).ToArray())); + .Returns(x => new EncryptedDek(KeyId, x.ArgAt>(0).ToArray())); encryptionService - .Decrypt(Arg.Any()) - .Returns(x => x.ArgAt(0).Value); + .Decrypt(Arg.Any()) + .Returns(x => x.ArgAt(0).Value); return encryptionService; } } \ No newline at end of file diff --git a/IdentityShroud.sln.DotSettings.user b/IdentityShroud.sln.DotSettings.user index 795f362..88c8f46 100644 --- a/IdentityShroud.sln.DotSettings.user +++ b/IdentityShroud.sln.DotSettings.user @@ -2,8 +2,10 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded @@ -20,10 +22,11 @@ ForceIncluded ForceIncluded ForceIncluded + /home/eelke/.cache/JetBrains/Rider2025.3/resharper-host/temp/Rider/vAny/CoverageData/_IdentityShroud.-1277985570/Snapshot/snapshot.utdcvr /home/eelke/.dotnet/dotnet /home/eelke/.dotnet/sdk/10.0.102/MSBuild.dll - <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> </SessionState> @@ -37,4 +40,9 @@ + + + + + \ No newline at end of file From ccc00d8e80e385dce9a528ff2099e8a3140a4ea8 Mon Sep 17 00:00:00 2001 From: eelke Date: Thu, 26 Feb 2026 20:39:48 +0100 Subject: [PATCH 14/22] Pass Span instead of Memory --- .../Mappers/KeyServiceTests.cs | 7 ++----- .../Services/ClientServiceTests.cs | 7 +------ .../Contracts/IDataEncryptionService.cs | 2 +- .../Contracts/IDekEncryptionService.cs | 2 +- IdentityShroud.Core/Security/Encryption.cs | 4 ++-- .../Services/DataEncryptionService.cs | 2 +- .../Services/DekEncryptionService.cs | 2 +- .../EncryptionServiceSubstitute.cs | 21 ------------------- .../Substitutes/NullDataEncryptionService.cs | 18 ++++++++++++++++ .../Substitutes/NullDekEncryptionService.cs | 18 ++++++++++++++++ 10 files changed, 45 insertions(+), 38 deletions(-) delete mode 100644 IdentityShroud.TestUtils/Substitutes/EncryptionServiceSubstitute.cs create mode 100644 IdentityShroud.TestUtils/Substitutes/NullDataEncryptionService.cs create mode 100644 IdentityShroud.TestUtils/Substitutes/NullDekEncryptionService.cs diff --git a/IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs b/IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs index b6350cf..f423f54 100644 --- a/IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs +++ b/IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs @@ -11,10 +11,7 @@ namespace IdentityShroud.Api.Tests.Mappers; public class KeyServiceTests { - private readonly IDekEncryptionService _dekEncryptionService = EncryptionServiceSubstitute.CreatePassthrough(); - - //private readonly IDataEncryptionService _dataEncryptionService = Substitute.For(); - //private readonly IKeyProviderFactory _keyProviderFactory = Substitute.For(); + private readonly NullDekEncryptionService _dekEncryptionService = new(); [Fact] public void Test() @@ -30,7 +27,7 @@ public class KeyServiceTests { Id = new("60bb79cf-4bac-4521-87f2-ac87cc15541f"), KeyType = "RSA", - Key = new(EncryptionServiceSubstitute.KeyId, rsa.ExportPkcs8PrivateKey()), + Key = new(_dekEncryptionService.KeyId, rsa.ExportPkcs8PrivateKey()), CreatedAt = DateTime.UtcNow, Priority = 10, }; diff --git a/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs b/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs index 5b08563..d0269e6 100644 --- a/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs +++ b/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs @@ -1,6 +1,5 @@ using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Model; -using IdentityShroud.Core.Security; using IdentityShroud.Core.Services; using IdentityShroud.Core.Tests.Fixtures; using IdentityShroud.TestUtils.Substitutes; @@ -11,17 +10,13 @@ namespace IdentityShroud.Core.Tests.Services; public class ClientServiceTests : IClassFixture { private readonly DbFixture _dbFixture; - //private readonly IDekEncryptionService _dekEncryptionService = EncryptionServiceSubstitute.CreatePassthrough(); - private readonly IDataEncryptionService _dataEncryptionService = Substitute.For(); + private readonly NullDataEncryptionService _dataEncryptionService = new(); private readonly IClock _clock = Substitute.For(); private readonly Guid _realmId = new("a1b2c3d4-0000-0000-0000-000000000001"); public ClientServiceTests(DbFixture dbFixture) { - _dataEncryptionService.Encrypt(Arg.Any>()) - .Returns(x => new EncryptedValue(DekId.NewId(), x.ArgAt>(0).ToArray())); - _dbFixture = dbFixture; using Db db = dbFixture.CreateDbContext(); if (!db.Database.EnsureCreated()) diff --git a/IdentityShroud.Core/Contracts/IDataEncryptionService.cs b/IdentityShroud.Core/Contracts/IDataEncryptionService.cs index 55eafe2..2810aaa 100644 --- a/IdentityShroud.Core/Contracts/IDataEncryptionService.cs +++ b/IdentityShroud.Core/Contracts/IDataEncryptionService.cs @@ -4,6 +4,6 @@ namespace IdentityShroud.Core.Contracts; public interface IDataEncryptionService { - EncryptedValue Encrypt(ReadOnlyMemory plain); + EncryptedValue Encrypt(ReadOnlySpan plain); byte[] Decrypt(EncryptedValue input); } \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/IDekEncryptionService.cs b/IdentityShroud.Core/Contracts/IDekEncryptionService.cs index 45e9b3f..3032040 100644 --- a/IdentityShroud.Core/Contracts/IDekEncryptionService.cs +++ b/IdentityShroud.Core/Contracts/IDekEncryptionService.cs @@ -6,6 +6,6 @@ namespace IdentityShroud.Core.Contracts; public interface IDekEncryptionService { - EncryptedDek Encrypt(ReadOnlyMemory plain); + EncryptedDek Encrypt(ReadOnlySpan plain); byte[] Decrypt(EncryptedDek input); } \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Encryption.cs b/IdentityShroud.Core/Security/Encryption.cs index a80a273..47344c1 100644 --- a/IdentityShroud.Core/Security/Encryption.cs +++ b/IdentityShroud.Core/Security/Encryption.cs @@ -12,7 +12,7 @@ public static class Encryption new(1, 12, 16), // version 1 ]; - public static byte[] Encrypt(ReadOnlyMemory plaintext, ReadOnlySpan key) + public static byte[] Encrypt(ReadOnlySpan plaintext, ReadOnlySpan key) { const int versionNumber = 1; AlgVersion versionParams = _versions[versionNumber]; @@ -31,7 +31,7 @@ public static class Encryption // use the spans to place the data directly in its place RandomNumberGenerator.Fill(nonce); using var aes = new AesGcm(key, versionParams.TagSize); - aes.Encrypt(nonce, plaintext.Span, cipher, tag); + aes.Encrypt(nonce, plaintext, cipher, tag); return result; } diff --git a/IdentityShroud.Core/Services/DataEncryptionService.cs b/IdentityShroud.Core/Services/DataEncryptionService.cs index 603f833..a06cbae 100644 --- a/IdentityShroud.Core/Services/DataEncryptionService.cs +++ b/IdentityShroud.Core/Services/DataEncryptionService.cs @@ -31,7 +31,7 @@ public class DataEncryptionService( return Encryption.Decrypt(input.Value, key); } - public EncryptedValue Encrypt(ReadOnlyMemory plain) + public EncryptedValue Encrypt(ReadOnlySpan plain) { var dek = GetActiveDek(); var key = dekCryptor.Decrypt(dek.KeyData); diff --git a/IdentityShroud.Core/Services/DekEncryptionService.cs b/IdentityShroud.Core/Services/DekEncryptionService.cs index c147662..add9267 100644 --- a/IdentityShroud.Core/Services/DekEncryptionService.cs +++ b/IdentityShroud.Core/Services/DekEncryptionService.cs @@ -22,7 +22,7 @@ public class DekEncryptionService : IDekEncryptionService // throw new Exception("Key must be 256 bits (32 bytes) for AES‑256‑GCM."); } - public EncryptedDek Encrypt(ReadOnlyMemory plaintext) + public EncryptedDek Encrypt(ReadOnlySpan plaintext) { var encryptionKey = ActiveKey; byte[] cipher = Encryption.Encrypt(plaintext, encryptionKey.Key); diff --git a/IdentityShroud.TestUtils/Substitutes/EncryptionServiceSubstitute.cs b/IdentityShroud.TestUtils/Substitutes/EncryptionServiceSubstitute.cs deleted file mode 100644 index 009629e..0000000 --- a/IdentityShroud.TestUtils/Substitutes/EncryptionServiceSubstitute.cs +++ /dev/null @@ -1,21 +0,0 @@ -using IdentityShroud.Core.Contracts; -using IdentityShroud.Core.Security; - -namespace IdentityShroud.TestUtils.Substitutes; - -public static class EncryptionServiceSubstitute -{ - public static KekId KeyId { get; } = KekId.NewId(); - - public static IDekEncryptionService CreatePassthrough() - { - var encryptionService = Substitute.For(); - encryptionService - .Encrypt(Arg.Any>()) - .Returns(x => new EncryptedDek(KeyId, x.ArgAt>(0).ToArray())); - encryptionService - .Decrypt(Arg.Any()) - .Returns(x => x.ArgAt(0).Value); - return encryptionService; - } -} \ No newline at end of file diff --git a/IdentityShroud.TestUtils/Substitutes/NullDataEncryptionService.cs b/IdentityShroud.TestUtils/Substitutes/NullDataEncryptionService.cs new file mode 100644 index 0000000..4e97bfc --- /dev/null +++ b/IdentityShroud.TestUtils/Substitutes/NullDataEncryptionService.cs @@ -0,0 +1,18 @@ +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Security; + +namespace IdentityShroud.TestUtils.Substitutes; + +public class NullDataEncryptionService : IDataEncryptionService +{ + public DekId KeyId { get; } = DekId.NewId(); + public EncryptedValue Encrypt(ReadOnlySpan plain) + { + return new(KeyId, plain.ToArray()); + } + + public byte[] Decrypt(EncryptedValue input) + { + return input.Value; + } +} \ No newline at end of file diff --git a/IdentityShroud.TestUtils/Substitutes/NullDekEncryptionService.cs b/IdentityShroud.TestUtils/Substitutes/NullDekEncryptionService.cs new file mode 100644 index 0000000..879f932 --- /dev/null +++ b/IdentityShroud.TestUtils/Substitutes/NullDekEncryptionService.cs @@ -0,0 +1,18 @@ +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Security; + +namespace IdentityShroud.TestUtils.Substitutes; + +public class NullDekEncryptionService : IDekEncryptionService +{ + public KekId KeyId { get; } = KekId.NewId(); + public EncryptedDek Encrypt(ReadOnlySpan plain) + { + return new(KeyId, plain.ToArray()); + } + + public byte[] Decrypt(EncryptedDek input) + { + return input.Value; + } +} \ No newline at end of file From 1cd7fb659a4dbb7266e5e192f0f5367ee1ab5450 Mon Sep 17 00:00:00 2001 From: eelke Date: Fri, 27 Feb 2026 18:50:28 +0100 Subject: [PATCH 15/22] Improve test coverage --- .../Helpers/Base64UrlConverterTests.cs | 36 +++++++++++ .../Services/DataEncryptionServiceTests.cs | 64 +++++++++++++++++++ IdentityShroud.Core/DTO/JsonWebKey.cs | 26 +------- .../Helpers/Base64UrlConverter.cs | 28 ++++++++ IdentityShroud.Core/Model/Realm.cs | 2 +- 5 files changed, 130 insertions(+), 26 deletions(-) create mode 100644 IdentityShroud.Core.Tests/Helpers/Base64UrlConverterTests.cs create mode 100644 IdentityShroud.Core.Tests/Services/DataEncryptionServiceTests.cs create mode 100644 IdentityShroud.Core/Helpers/Base64UrlConverter.cs diff --git a/IdentityShroud.Core.Tests/Helpers/Base64UrlConverterTests.cs b/IdentityShroud.Core.Tests/Helpers/Base64UrlConverterTests.cs new file mode 100644 index 0000000..923a865 --- /dev/null +++ b/IdentityShroud.Core.Tests/Helpers/Base64UrlConverterTests.cs @@ -0,0 +1,36 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using IdentityShroud.Core.Helpers; + +namespace IdentityShroud.Core.Tests.Helpers; + +public class Base64UrlConverterTests +{ + internal class Data + { + [JsonConverter(typeof(Base64UrlConverter))] + public byte[]? X { get; set; } + } + + [Fact] + public void Serialize() + { + Data d = new() { X = ">>>???"u8.ToArray() }; + string s = JsonSerializer.Serialize(d); + + Assert.Contains("\"Pj4-Pz8_\"", s); + } + + [Fact] + public void Deerialize() + { + var jsonstring = """ + { "X": "Pj4-Pz8_" } + """; + var d = JsonSerializer.Deserialize(jsonstring); + + Assert.Equal(">>>???", Encoding.UTF8.GetString(d.X)); + } + +} \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/Services/DataEncryptionServiceTests.cs b/IdentityShroud.Core.Tests/Services/DataEncryptionServiceTests.cs new file mode 100644 index 0000000..4f88e48 --- /dev/null +++ b/IdentityShroud.Core.Tests/Services/DataEncryptionServiceTests.cs @@ -0,0 +1,64 @@ +using System.Security.Cryptography; +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Model; +using IdentityShroud.Core.Security; +using IdentityShroud.Core.Services; +using IdentityShroud.TestUtils.Substitutes; + +namespace IdentityShroud.Core.Tests.Services; + +public class DataEncryptionServiceTests +{ + private readonly IRealmContext _realmContext = Substitute.For(); + private readonly IDekEncryptionService _dekCryptor = new NullDekEncryptionService();// Substitute.For(); + + private readonly DekId _activeDekId = DekId.NewId(); + private readonly DekId _secondDekId = DekId.NewId(); + private DataEncryptionService CreateSut() + => new(_realmContext, _dekCryptor); + + [Fact] + public void Encrypt_UsesActiveKey() + { + _realmContext.GetDeks(Arg.Any()).Returns([ + CreateRealmDek(_secondDekId, false), + CreateRealmDek(_activeDekId, true), + ]); + + var cipher = CreateSut().Encrypt("Hello"u8); + + Assert.Equal(_activeDekId, cipher.DekId); + } + + [Fact] + public void Decrypt_UsesCorrectKey() + { + var first = CreateRealmDek(_activeDekId, true); + _realmContext.GetDeks(Arg.Any()).Returns([ first ]); + + var sut = CreateSut(); + var cipher = sut.Encrypt("Hello"u8); + + // Deactivate original key + first.Active = false; + // Make new active + var second = CreateRealmDek(_secondDekId, true); + // Return both + _realmContext.GetDeks(Arg.Any()).Returns([ first, second ]); + + + var decoded = sut.Decrypt(cipher); + + Assert.Equal("Hello"u8, decoded); + } + + private RealmDek CreateRealmDek(DekId id, bool active) + => new() + { + Id = id, + Active = active, + Algorithm = "AES", + KeyData = new(KekId.NewId(), RandomNumberGenerator.GetBytes(32)), + RealmId = default, + }; +} \ No newline at end of file diff --git a/IdentityShroud.Core/DTO/JsonWebKey.cs b/IdentityShroud.Core/DTO/JsonWebKey.cs index ea4d7d5..4f16955 100644 --- a/IdentityShroud.Core/DTO/JsonWebKey.cs +++ b/IdentityShroud.Core/DTO/JsonWebKey.cs @@ -1,7 +1,5 @@ -using System.Buffers; -using System.Buffers.Text; -using System.Text.Json; using System.Text.Json.Serialization; +using IdentityShroud.Core.Helpers; namespace IdentityShroud.Core.Messages; @@ -48,26 +46,4 @@ public class JsonWebKey // [JsonPropertyName("x5t")] // [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] // public string? X509CertificateThumbprint { get; set; } -} - -public class Base64UrlConverter : JsonConverter -{ - public override byte[] Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - // GetValueSpan gives you the raw UTF-8 bytes of the JSON string value - if (reader.HasValueSequence) - { - var valueSequence = reader.ValueSequence.ToArray(); - return Base64Url.DecodeFromUtf8(valueSequence); - } - return Base64Url.DecodeFromUtf8(reader.ValueSpan); - } - - public override void Write(Utf8JsonWriter writer, byte[] value, JsonSerializerOptions options) - { - int encodedLength = Base64Url.GetEncodedLength(value.Length); - Span buffer = encodedLength <= 256 ? stackalloc byte[encodedLength] : new byte[encodedLength]; - Base64Url.EncodeToUtf8(value, buffer); - writer.WriteStringValue(buffer); - } } \ No newline at end of file diff --git a/IdentityShroud.Core/Helpers/Base64UrlConverter.cs b/IdentityShroud.Core/Helpers/Base64UrlConverter.cs new file mode 100644 index 0000000..77f05f2 --- /dev/null +++ b/IdentityShroud.Core/Helpers/Base64UrlConverter.cs @@ -0,0 +1,28 @@ +using System.Buffers; +using System.Buffers.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace IdentityShroud.Core.Helpers; + +public class Base64UrlConverter : JsonConverter +{ + public override byte[] Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + // GetValueSpan gives you the raw UTF-8 bytes of the JSON string value + if (reader.HasValueSequence) + { + var valueSequence = reader.ValueSequence.ToArray(); + return Base64Url.DecodeFromUtf8(valueSequence); + } + return Base64Url.DecodeFromUtf8(reader.ValueSpan); + } + + public override void Write(Utf8JsonWriter writer, byte[] value, JsonSerializerOptions options) + { + int encodedLength = Base64Url.GetEncodedLength(value.Length); + Span buffer = encodedLength <= 256 ? stackalloc byte[encodedLength] : new byte[encodedLength]; + Base64Url.EncodeToUtf8(value, buffer); + writer.WriteStringValue(buffer); + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Model/Realm.cs b/IdentityShroud.Core/Model/Realm.cs index f3e087a..bbe9631 100644 --- a/IdentityShroud.Core/Model/Realm.cs +++ b/IdentityShroud.Core/Model/Realm.cs @@ -33,7 +33,7 @@ public class Realm public record RealmDek { public required DekId Id { get; init; } - public required bool Active { get; init; } + public required bool Active { get; set; } public required string Algorithm { get; init; } public required EncryptedDek KeyData { get; init; } public required Guid RealmId { get; init; } From d8f6024afd8e616cf11255bfe4c21d5ec0d18b0d Mon Sep 17 00:00:00 2001 From: eelke Date: Fri, 27 Feb 2026 18:54:01 +0100 Subject: [PATCH 16/22] Cleanup --- .github/workflows/ci.yml | 71 ---------------------------------------- README.md | 11 ------- 2 files changed, 82 deletions(-) delete mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index f2ed668..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: CI - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -jobs: - test: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '10.0.x' - - - name: Cache NuGet packages - uses: actions/cache@v4 - with: - path: ~/.nuget/packages - key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} - restore-keys: | - ${{ runner.os }}-nuget- - - - name: Cache Docker image (postgres) - id: docker-cache - uses: actions/cache@v4 - with: - path: /tmp/docker-postgres.tar - key: ${{ runner.os }}-docker-postgres-18.1 - - - name: Load cached postgres image or pull - run: | - if [ -f /tmp/docker-postgres.tar ]; then - docker load -i /tmp/docker-postgres.tar - else - docker pull postgres:18.1 - docker save postgres:18.1 -o /tmp/docker-postgres.tar - fi - - - name: Restore dependencies - run: dotnet restore - - - name: Build - run: dotnet build --no-restore --configuration Release - - - name: Test with coverage - run: | - dotnet test --no-build --configuration Release \ - --collect:"XPlat Code Coverage" \ - --results-directory ./coverage \ - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura - - - name: Code Coverage Report - uses: irongut/CodeCoverageSummary@v1.3.0 - with: - filename: coverage/**/coverage.cobertura.xml - badge: true - format: markdown - output: both - - - name: Upload coverage artifact - uses: actions/upload-artifact@v4 - with: - name: coverage-report - path: ./coverage/**/coverage.cobertura.xml - retention-days: 7 diff --git a/README.md b/README.md index fa9605a..8bd5aa3 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,3 @@ IdentityShroud is a .NET project for identity management and protection. -## Build and Test - -```bash -dotnet restore -dotnet build -dotnet test -``` - -## Coverage - -Coverage reports are generated automatically in CI and displayed in pull request comments. From 07393f57fc7b0baa78b1ae54dcac70a674ba1db7 Mon Sep 17 00:00:00 2001 From: eelke Date: Fri, 27 Feb 2026 17:57:42 +0000 Subject: [PATCH 17/22] 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 Co-authored-by: Eelke76 <31384324+Eelke76@users.noreply.github.com> Reviewed-on: https://code.eelkeklein.nl/eelke/IdentityShroud/pulls/6 --- .../Apis/ClientApiTests.cs | 179 ++++++++++++++++++ .../Apis/RealmApisTests.cs | 38 ++-- .../Fixtures/ApplicationFactory.cs | 10 +- .../Mappers/KeyMapperTests.cs | 41 ---- .../Mappers/KeyServiceTests.cs | 46 +++++ IdentityShroud.Api/Apis/ClientApi.cs | 73 +++++++ .../Apis/Dto/ClientRepresentation.cs | 16 ++ .../Apis/EndpointRouteBuilderExtensions.cs | 15 ++ .../Apis/Filters/ClientIdValidationFilter.cs | 21 ++ .../Apis/Filters/RealmIdValidationFilter.cs | 20 ++ ...Filter.cs => RealmSlugValidationFilter.cs} | 9 +- .../Apis/Mappers/ClientMapper.cs | 11 ++ IdentityShroud.Api/Apis/Mappers/KeyMapper.cs | 34 ++-- IdentityShroud.Api/Apis/OpenIdEndpoints.cs | 72 +++++++ IdentityShroud.Api/Apis/RealmApi.cs | 67 ++----- .../ClientCreateRequestValidator.cs | 22 +++ .../Validation/RealmCreateRequestValidator.cs | 2 +- .../{ => Apis}/Validation/ValidateFilter.cs | 2 +- .../AppJsonSerializerContext.cs | 1 - IdentityShroud.Api/IdentityShroud.Api.csproj | 2 +- .../IdentityShroud.Api.csproj.DotSettings | 4 +- IdentityShroud.Api/Program.cs | 19 +- .../EndpointRouteBuilderExtensions.cs | 7 - .../Fixtures/DbFixture.cs | 3 +- .../Helpers/Base64UrlConverterTests.cs | 36 ++++ .../JwtSignatureGeneratorTests.cs | 4 +- IdentityShroud.Core.Tests/Model/KeyTests.cs | 51 ----- .../Security/AesGcmHelperTests.cs | 21 -- .../ConfigurationSecretProviderTests.cs | 63 ++++++ .../Services/ClientServiceTests.cs | 155 +++++++++++++++ .../Services/DataEncryptionServiceTests.cs | 64 +++++++ .../Services/DekEncryptionServiceTests.cs | 123 ++++++++++++ .../Services/EncryptionServiceTests.cs | 26 --- .../Services/EncryptionTests.cs | 30 +++ .../Services/RealmServiceTests.cs | 105 +++++++--- IdentityShroud.Core.Tests/UnitTest1.cs | 15 +- .../Contracts/IClientService.cs | 14 ++ IdentityShroud.Core/Contracts/IClock.cs | 6 + .../Contracts/IDataEncryptionService.cs | 9 + .../Contracts/IDekEncryptionService.cs | 11 ++ .../Contracts/IEncryptionService.cs | 7 - IdentityShroud.Core/Contracts/IKeyService.cs | 12 ++ .../Contracts/IRealmContext.cs | 9 + .../{Services => Contracts}/IRealmService.cs | 5 +- .../Contracts/ISecretProvider.cs | 8 + .../DTO/Client/ClientCreateRequest.cs | 10 + .../DTO/JsonWebKey.cs | 26 ++- .../DTO/JsonWebKeySet.cs | 0 IdentityShroud.Core/Db.cs | 41 +++- .../Helpers/Base64UrlConverter.cs | 28 +++ IdentityShroud.Core/Helpers/SlugHelper.cs | 1 - .../IdentityShroud.Core.csproj | 9 +- IdentityShroud.Core/Model/Client.cs | 26 ++- IdentityShroud.Core/Model/ClientSecret.cs | 17 ++ IdentityShroud.Core/Model/Key.cs | 45 ----- IdentityShroud.Core/Model/Realm.cs | 16 +- IdentityShroud.Core/Model/RealmKey.cs | 27 +++ IdentityShroud.Core/Security/AesGcmHelper.cs | 71 ------- .../Security/ConfigurationSecretProvider.cs | 5 + IdentityShroud.Core/Security/DekId.cs | 6 + IdentityShroud.Core/Security/EncryptedDek.cs | 6 + .../Security/EncryptedValue.cs | 8 + IdentityShroud.Core/Security/Encryption.cs | 70 +++++++ .../Security/JsonWebAlgorithm.cs | 2 - IdentityShroud.Core/Security/KekId.cs | 41 ++++ .../Security/KeyEncryptionKey.cs | 10 + .../Security/Keys/IKeyProvider.cs | 19 ++ .../Security/Keys/IKeyProviderFactory.cs | 7 + .../Security/Keys/KeyProviderFactory.cs | 17 ++ .../Security/Keys/Rsa/RsaProvider.cs | 35 ++++ IdentityShroud.Core/Security/RsaHelper.cs | 16 -- IdentityShroud.Core/Services/ClientService.cs | 65 +++++++ IdentityShroud.Core/Services/ClockService.cs | 11 ++ .../Services/DataEncryptionService.cs | 41 ++++ .../Services/DekEncryptionService.cs | 38 ++++ .../Services/EncryptionService.cs | 27 --- IdentityShroud.Core/Services/KeyService.cs | 46 +++++ IdentityShroud.Core/Services/RealmContext.cs | 26 +++ IdentityShroud.Core/Services/RealmService.cs | 39 ++-- .../Asserts/JsonObjectAssert.cs | 1 - .../Asserts/ResultAssert.cs | 1 - .../IdentityShroud.TestUtils.csproj | 7 +- .../EncryptionServiceSubstitute.cs | 18 -- .../Substitutes/NullDataEncryptionService.cs | 18 ++ .../Substitutes/NullDekEncryptionService.cs | 18 ++ IdentityShroud.sln.DotSettings.user | 29 ++- README.md | 4 + 87 files changed, 1903 insertions(+), 533 deletions(-) create mode 100644 IdentityShroud.Api.Tests/Apis/ClientApiTests.cs delete mode 100644 IdentityShroud.Api.Tests/Mappers/KeyMapperTests.cs create mode 100644 IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs create mode 100644 IdentityShroud.Api/Apis/ClientApi.cs create mode 100644 IdentityShroud.Api/Apis/Dto/ClientRepresentation.cs create mode 100644 IdentityShroud.Api/Apis/EndpointRouteBuilderExtensions.cs create mode 100644 IdentityShroud.Api/Apis/Filters/ClientIdValidationFilter.cs create mode 100644 IdentityShroud.Api/Apis/Filters/RealmIdValidationFilter.cs rename IdentityShroud.Api/Apis/Filters/{SlugValidationFilter.cs => RealmSlugValidationFilter.cs} (58%) create mode 100644 IdentityShroud.Api/Apis/Mappers/ClientMapper.cs create mode 100644 IdentityShroud.Api/Apis/OpenIdEndpoints.cs create mode 100644 IdentityShroud.Api/Apis/Validation/ClientCreateRequestValidator.cs rename IdentityShroud.Api/{ => Apis}/Validation/RealmCreateRequestValidator.cs (92%) rename IdentityShroud.Api/{ => Apis}/Validation/ValidateFilter.cs (96%) delete mode 100644 IdentityShroud.Api/Validation/EndpointRouteBuilderExtensions.cs create mode 100644 IdentityShroud.Core.Tests/Helpers/Base64UrlConverterTests.cs delete mode 100644 IdentityShroud.Core.Tests/Model/KeyTests.cs delete mode 100644 IdentityShroud.Core.Tests/Security/AesGcmHelperTests.cs create mode 100644 IdentityShroud.Core.Tests/Security/ConfigurationSecretProviderTests.cs create mode 100644 IdentityShroud.Core.Tests/Services/ClientServiceTests.cs create mode 100644 IdentityShroud.Core.Tests/Services/DataEncryptionServiceTests.cs create mode 100644 IdentityShroud.Core.Tests/Services/DekEncryptionServiceTests.cs delete mode 100644 IdentityShroud.Core.Tests/Services/EncryptionServiceTests.cs create mode 100644 IdentityShroud.Core.Tests/Services/EncryptionTests.cs create mode 100644 IdentityShroud.Core/Contracts/IClientService.cs create mode 100644 IdentityShroud.Core/Contracts/IClock.cs create mode 100644 IdentityShroud.Core/Contracts/IDataEncryptionService.cs create mode 100644 IdentityShroud.Core/Contracts/IDekEncryptionService.cs delete mode 100644 IdentityShroud.Core/Contracts/IEncryptionService.cs create mode 100644 IdentityShroud.Core/Contracts/IKeyService.cs create mode 100644 IdentityShroud.Core/Contracts/IRealmContext.cs rename IdentityShroud.Core/{Services => Contracts}/IRealmService.cs (65%) create mode 100644 IdentityShroud.Core/DTO/Client/ClientCreateRequest.cs rename {IdentityShroud.Api/Apis => IdentityShroud.Core}/DTO/JsonWebKey.cs (58%) rename {IdentityShroud.Api/Apis => IdentityShroud.Core}/DTO/JsonWebKeySet.cs (100%) create mode 100644 IdentityShroud.Core/Helpers/Base64UrlConverter.cs create mode 100644 IdentityShroud.Core/Model/ClientSecret.cs delete mode 100644 IdentityShroud.Core/Model/Key.cs create mode 100644 IdentityShroud.Core/Model/RealmKey.cs delete mode 100644 IdentityShroud.Core/Security/AesGcmHelper.cs create mode 100644 IdentityShroud.Core/Security/DekId.cs create mode 100644 IdentityShroud.Core/Security/EncryptedDek.cs create mode 100644 IdentityShroud.Core/Security/EncryptedValue.cs create mode 100644 IdentityShroud.Core/Security/Encryption.cs create mode 100644 IdentityShroud.Core/Security/KekId.cs create mode 100644 IdentityShroud.Core/Security/KeyEncryptionKey.cs create mode 100644 IdentityShroud.Core/Security/Keys/IKeyProvider.cs create mode 100644 IdentityShroud.Core/Security/Keys/IKeyProviderFactory.cs create mode 100644 IdentityShroud.Core/Security/Keys/KeyProviderFactory.cs create mode 100644 IdentityShroud.Core/Security/Keys/Rsa/RsaProvider.cs delete mode 100644 IdentityShroud.Core/Security/RsaHelper.cs create mode 100644 IdentityShroud.Core/Services/ClientService.cs create mode 100644 IdentityShroud.Core/Services/ClockService.cs create mode 100644 IdentityShroud.Core/Services/DataEncryptionService.cs create mode 100644 IdentityShroud.Core/Services/DekEncryptionService.cs delete mode 100644 IdentityShroud.Core/Services/EncryptionService.cs create mode 100644 IdentityShroud.Core/Services/KeyService.cs create mode 100644 IdentityShroud.Core/Services/RealmContext.cs delete mode 100644 IdentityShroud.TestUtils/Substitutes/EncryptionServiceSubstitute.cs create mode 100644 IdentityShroud.TestUtils/Substitutes/NullDataEncryptionService.cs create mode 100644 IdentityShroud.TestUtils/Substitutes/NullDekEncryptionService.cs create mode 100644 README.md diff --git a/IdentityShroud.Api.Tests/Apis/ClientApiTests.cs b/IdentityShroud.Api.Tests/Apis/ClientApiTests.cs new file mode 100644 index 0000000..db984f1 --- /dev/null +++ b/IdentityShroud.Api.Tests/Apis/ClientApiTests.cs @@ -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 +{ + private readonly ApplicationFactory _factory; + + public ClientApiTests(ApplicationFactory factory) + { + _factory = factory; + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + 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( + 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( + 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( + 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 CreateRealmAsync(string slug, string name) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var realm = new Realm { Slug = slug, Name = name }; + db.Realms.Add(realm); + await db.SaveChangesAsync(TestContext.Current.CancellationToken); + return realm; + } + + private async Task CreateClientAsync(Realm realm, string clientId, string? name = null) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + 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; + } +} diff --git a/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs b/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs index 350149b..ecc46c0 100644 --- a/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs +++ b/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs @@ -44,7 +44,9 @@ public class RealmApisTests : IClassFixture 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 // 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(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 { // 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 public async Task GetJwks() { // setup - IEncryptionService encryptionService = _factory.Services.GetRequiredService(); + IDekEncryptionService dekEncryptionService = _factory.Services.GetRequiredService(); 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(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"); } diff --git a/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs b/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs index 6f4c461..9846559 100644 --- a/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs +++ b/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs @@ -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, IAsyncLifetime new Dictionary { ["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=", }); }); diff --git a/IdentityShroud.Api.Tests/Mappers/KeyMapperTests.cs b/IdentityShroud.Api.Tests/Mappers/KeyMapperTests.cs deleted file mode 100644 index 6c57971..0000000 --- a/IdentityShroud.Api.Tests/Mappers/KeyMapperTests.cs +++ /dev/null @@ -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)); - } -} \ No newline at end of file diff --git a/IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs b/IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs new file mode 100644 index 0000000..f423f54 --- /dev/null +++ b/IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs @@ -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)); + } +} diff --git a/IdentityShroud.Api/Apis/ClientApi.cs b/IdentityShroud.Api/Apis/ClientApi.cs new file mode 100644 index 0000000..e595e34 --- /dev/null +++ b/IdentityShroud.Api/Apis/ClientApi.cs @@ -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); + +/// +/// The part of the api below realms/{slug}/clients +/// +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() + .WithName("ClientCreate") + .Produces(StatusCodes.Status201Created); + + var clientIdGroup = clientsGroup.MapGroup("{clientId}") + .AddEndpointFilter(); + + clientIdGroup.MapGet("", ClientGet) + .WithName(ClientGetRouteName); + } + + private static Ok ClientGet( + Guid realmId, + int clientId, + HttpContext context) + { + Client client = (Client)context.Items["ClientEntity"]!; + return TypedResults.Ok(new ClientMapper().ToDto(client)); + } + + private static async Task, InternalServerError>> + ClientCreate( + Guid realmId, + ClientCreateRequest request, + [FromServices] IClientService service, + HttpContext context, + CancellationToken cancellationToken) + { + Realm realm = context.GetValidatedRealm(); + Result 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, + }); + } +} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/Dto/ClientRepresentation.cs b/IdentityShroud.Api/Apis/Dto/ClientRepresentation.cs new file mode 100644 index 0000000..80b5f13 --- /dev/null +++ b/IdentityShroud.Api/Apis/Dto/ClientRepresentation.cs @@ -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; } +} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/EndpointRouteBuilderExtensions.cs b/IdentityShroud.Api/Apis/EndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000..3c47b48 --- /dev/null +++ b/IdentityShroud.Api/Apis/EndpointRouteBuilderExtensions.cs @@ -0,0 +1,15 @@ +namespace IdentityShroud.Api; + +public static class EndpointRouteBuilderExtensions +{ + public static RouteHandlerBuilder Validate(this RouteHandlerBuilder builder) where TDto : class + => builder.AddEndpointFilter>(); + + public static void MapApis(this IEndpointRouteBuilder erp) + { + RealmApi.MapRealmEndpoints(erp); + + OpenIdEndpoints.MapEndpoints(erp); + } + +} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/Filters/ClientIdValidationFilter.cs b/IdentityShroud.Api/Apis/Filters/ClientIdValidationFilter.cs new file mode 100644 index 0000000..771be81 --- /dev/null +++ b/IdentityShroud.Api/Apis/Filters/ClientIdValidationFilter.cs @@ -0,0 +1,21 @@ +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Model; + +namespace IdentityShroud.Api; + +public class ClientIdValidationFilter(IClientService clientService) : IEndpointFilter +{ + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + Guid realmId = context.Arguments.OfType().First(); + int id = context.Arguments.OfType().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); + } +} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/Filters/RealmIdValidationFilter.cs b/IdentityShroud.Api/Apis/Filters/RealmIdValidationFilter.cs new file mode 100644 index 0000000..97a1bb9 --- /dev/null +++ b/IdentityShroud.Api/Apis/Filters/RealmIdValidationFilter.cs @@ -0,0 +1,20 @@ +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Model; + +namespace IdentityShroud.Api; + +public class RealmIdValidationFilter(IRealmService realmService) : IEndpointFilter +{ + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + Guid id = context.Arguments.OfType().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); + } +} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/Filters/SlugValidationFilter.cs b/IdentityShroud.Api/Apis/Filters/RealmSlugValidationFilter.cs similarity index 58% rename from IdentityShroud.Api/Apis/Filters/SlugValidationFilter.cs rename to IdentityShroud.Api/Apis/Filters/RealmSlugValidationFilter.cs index 5bc699e..75338e1 100644 --- a/IdentityShroud.Api/Apis/Filters/SlugValidationFilter.cs +++ b/IdentityShroud.Api/Apis/Filters/RealmSlugValidationFilter.cs @@ -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. /// /// -public class SlugValidationFilter(IRealmService realmService) : IEndpointFilter +public class RealmSlugValidationFilter(IRealmService realmService) : IEndpointFilter { public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) { - string slug = context.Arguments.OfType().First(); - Realm? realm = await realmService.FindBySlug(slug); + string realmSlug = context.Arguments.OfType().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(); diff --git a/IdentityShroud.Api/Apis/Mappers/ClientMapper.cs b/IdentityShroud.Api/Apis/Mappers/ClientMapper.cs new file mode 100644 index 0000000..8e58717 --- /dev/null +++ b/IdentityShroud.Api/Apis/Mappers/ClientMapper.cs @@ -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); +} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs b/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs index 00f5d7b..7155208 100644 --- a/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs +++ b/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs @@ -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 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 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; } } \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/OpenIdEndpoints.cs b/IdentityShroud.Api/Apis/OpenIdEndpoints.cs new file mode 100644 index 0000000..6565413 --- /dev/null +++ b/IdentityShroud.Api/Apis/OpenIdEndpoints.cs @@ -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(); + 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> 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, 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(); + } + +} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/RealmApi.cs b/IdentityShroud.Api/Apis/RealmApi.cs index d5e439b..88a5179 100644 --- a/IdentityShroud.Api/Apis/RealmApi.cs +++ b/IdentityShroud.Api/Apis/RealmApi.cs @@ -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() .WithName("Create Realm") .Produces(StatusCodes.Status201Created); - var realmSlugGroup = realmsGroup.MapGroup("{slug}") - .AddEndpointFilter(); - realmSlugGroup.MapGet(".well-known/openid-configuration", GetOpenIdConfiguration); + var realmIdGroup = realmsGroup.MapGroup("{realmId}") + .AddEndpointFilter(); + + 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, 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, 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> 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); - } } \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/Validation/ClientCreateRequestValidator.cs b/IdentityShroud.Api/Apis/Validation/ClientCreateRequestValidator.cs new file mode 100644 index 0000000..7666b36 --- /dev/null +++ b/IdentityShroud.Api/Apis/Validation/ClientCreateRequestValidator.cs @@ -0,0 +1,22 @@ +using FluentValidation; +using IdentityShroud.Core.Contracts; + +namespace IdentityShroud.Api; + +public class ClientCreateRequestValidator : AbstractValidator +{ + // 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"); + } +} \ No newline at end of file diff --git a/IdentityShroud.Api/Validation/RealmCreateRequestValidator.cs b/IdentityShroud.Api/Apis/Validation/RealmCreateRequestValidator.cs similarity index 92% rename from IdentityShroud.Api/Validation/RealmCreateRequestValidator.cs rename to IdentityShroud.Api/Apis/Validation/RealmCreateRequestValidator.cs index 8daa0a9..3e3a20a 100644 --- a/IdentityShroud.Api/Validation/RealmCreateRequestValidator.cs +++ b/IdentityShroud.Api/Apis/Validation/RealmCreateRequestValidator.cs @@ -1,7 +1,7 @@ using FluentValidation; using IdentityShroud.Core.Messages.Realm; -namespace IdentityShroud.Api.Validation; +namespace IdentityShroud.Api; public class RealmCreateRequestValidator : AbstractValidator { diff --git a/IdentityShroud.Api/Validation/ValidateFilter.cs b/IdentityShroud.Api/Apis/Validation/ValidateFilter.cs similarity index 96% rename from IdentityShroud.Api/Validation/ValidateFilter.cs rename to IdentityShroud.Api/Apis/Validation/ValidateFilter.cs index fbebd9d..d621441 100644 --- a/IdentityShroud.Api/Validation/ValidateFilter.cs +++ b/IdentityShroud.Api/Apis/Validation/ValidateFilter.cs @@ -1,6 +1,6 @@ using FluentValidation; -namespace IdentityShroud.Api.Validation; +namespace IdentityShroud.Api; public class ValidateFilter : IEndpointFilter where T : class { diff --git a/IdentityShroud.Api/AppJsonSerializerContext.cs b/IdentityShroud.Api/AppJsonSerializerContext.cs index 9b075ce..e7d90da 100644 --- a/IdentityShroud.Api/AppJsonSerializerContext.cs +++ b/IdentityShroud.Api/AppJsonSerializerContext.cs @@ -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))] diff --git a/IdentityShroud.Api/IdentityShroud.Api.csproj b/IdentityShroud.Api/IdentityShroud.Api.csproj index 72b4639..31f88b2 100644 --- a/IdentityShroud.Api/IdentityShroud.Api.csproj +++ b/IdentityShroud.Api/IdentityShroud.Api.csproj @@ -17,7 +17,7 @@ - + diff --git a/IdentityShroud.Api/IdentityShroud.Api.csproj.DotSettings b/IdentityShroud.Api/IdentityShroud.Api.csproj.DotSettings index bd2aa2d..c9c4f6a 100644 --- a/IdentityShroud.Api/IdentityShroud.Api.csproj.DotSettings +++ b/IdentityShroud.Api/IdentityShroud.Api.csproj.DotSettings @@ -1,3 +1,5 @@  True - True \ No newline at end of file + True + True + True \ No newline at end of file diff --git a/IdentityShroud.Api/Program.cs b/IdentityShroud.Api/Program.cs index 66a7554..29f6736 100644 --- a/IdentityShroud.Api/Program.cs +++ b/IdentityShroud.Api/Program.cs @@ -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(); + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddOptions().Bind(configuration.GetSection("db")); services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); - services.AddValidatorsFromAssemblyContaining(); + services.AddValidatorsFromAssemblyContaining(); + 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(); } diff --git a/IdentityShroud.Api/Validation/EndpointRouteBuilderExtensions.cs b/IdentityShroud.Api/Validation/EndpointRouteBuilderExtensions.cs deleted file mode 100644 index e67f787..0000000 --- a/IdentityShroud.Api/Validation/EndpointRouteBuilderExtensions.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace IdentityShroud.Api.Validation; - -public static class EndpointRouteBuilderExtensions -{ - public static RouteHandlerBuilder Validate(this RouteHandlerBuilder builder) where TDto : class - => builder.AddEndpointFilter>(); -} \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/Fixtures/DbFixture.cs b/IdentityShroud.Core.Tests/Fixtures/DbFixture.cs index 85c2fbe..844d4ca 100644 --- a/IdentityShroud.Core.Tests/Fixtures/DbFixture.cs +++ b/IdentityShroud.Core.Tests/Fixtures/DbFixture.cs @@ -1,5 +1,4 @@ -using DotNet.Testcontainers.Containers; -using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Npgsql; using Testcontainers.PostgreSql; diff --git a/IdentityShroud.Core.Tests/Helpers/Base64UrlConverterTests.cs b/IdentityShroud.Core.Tests/Helpers/Base64UrlConverterTests.cs new file mode 100644 index 0000000..923a865 --- /dev/null +++ b/IdentityShroud.Core.Tests/Helpers/Base64UrlConverterTests.cs @@ -0,0 +1,36 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using IdentityShroud.Core.Helpers; + +namespace IdentityShroud.Core.Tests.Helpers; + +public class Base64UrlConverterTests +{ + internal class Data + { + [JsonConverter(typeof(Base64UrlConverter))] + public byte[]? X { get; set; } + } + + [Fact] + public void Serialize() + { + Data d = new() { X = ">>>???"u8.ToArray() }; + string s = JsonSerializer.Serialize(d); + + Assert.Contains("\"Pj4-Pz8_\"", s); + } + + [Fact] + public void Deerialize() + { + var jsonstring = """ + { "X": "Pj4-Pz8_" } + """; + var d = JsonSerializer.Deserialize(jsonstring); + + Assert.Equal(">>>???", Encoding.UTF8.GetString(d.X)); + } + +} \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/JwtSignatureGeneratorTests.cs b/IdentityShroud.Core.Tests/JwtSignatureGeneratorTests.cs index 0fb0a42..bf4d0a6 100644 --- a/IdentityShroud.Core.Tests/JwtSignatureGeneratorTests.cs +++ b/IdentityShroud.Core.Tests/JwtSignatureGeneratorTests.cs @@ -72,8 +72,8 @@ public class JwtSignatureGeneratorTests var rsa = RSA.Create(); var parameters = new RSAParameters { - Modulus = WebEncoders.Base64UrlDecode(jwk.Modulus), - Exponent = WebEncoders.Base64UrlDecode(jwk.Exponent) + Modulus = WebEncoders.Base64UrlDecode(jwk.Modulus!), + Exponent = WebEncoders.Base64UrlDecode(jwk.Exponent!) }; rsa.ImportParameters(parameters); diff --git a/IdentityShroud.Core.Tests/Model/KeyTests.cs b/IdentityShroud.Core.Tests/Model/KeyTests.cs deleted file mode 100644 index e7e9b45..0000000 --- a/IdentityShroud.Core.Tests/Model/KeyTests.cs +++ /dev/null @@ -1,51 +0,0 @@ -using IdentityShroud.Core.Contracts; -using IdentityShroud.Core.Model; - -namespace IdentityShroud.Core.Tests.Model; - -public class KeyTests -{ - [Fact] - public void SetNewKey() - { - byte[] privateKey = [5, 6, 7, 8]; - byte[] encryptedPrivateKey = [1, 2, 3, 4]; - - var encryptionService = Substitute.For(); - encryptionService - .Encrypt(Arg.Any()) - .Returns(x => encryptedPrivateKey); - - Key key = new(); - key.SetPrivateKey(encryptionService, privateKey); - - // should be able to return original without calling decrypt - Assert.Equal(privateKey, key.GetPrivateKey(encryptionService)); - Assert.Equal(encryptedPrivateKey, key.PrivateKeyEncrypted); - - encryptionService.Received(1).Encrypt(privateKey); - encryptionService.DidNotReceive().Decrypt(Arg.Any()); - } - - [Fact] - public void GetDecryptedKey() - { - byte[] privateKey = [5, 6, 7, 8]; - byte[] encryptedPrivateKey = [1, 2, 3, 4]; - - var encryptionService = Substitute.For(); - encryptionService - .Decrypt(encryptedPrivateKey) - .Returns(x => privateKey); - - Key key = new(); - key.PrivateKeyEncrypted = encryptedPrivateKey; - - // should be able to return original without calling decrypt - Assert.Equal(privateKey, key.GetPrivateKey(encryptionService)); - Assert.Equal(encryptedPrivateKey, key.PrivateKeyEncrypted); - - encryptionService.Received(1).Decrypt(encryptedPrivateKey); - } - -} \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/Security/AesGcmHelperTests.cs b/IdentityShroud.Core.Tests/Security/AesGcmHelperTests.cs deleted file mode 100644 index 6392676..0000000 --- a/IdentityShroud.Core.Tests/Security/AesGcmHelperTests.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Security.Cryptography; -using System.Text; -using IdentityShroud.Core.Security; - -namespace IdentityShroud.Core.Tests.Security; - -public class AesGcmHelperTests -{ - [Fact] - public void EncryptDecryptCycleWorks() - { - string input = "Hello, world!"; - - var encryptionKey = RandomNumberGenerator.GetBytes(32); - - var cypher = AesGcmHelper.EncryptAesGcm(Encoding.UTF8.GetBytes(input), encryptionKey); - var output = AesGcmHelper.DecryptAesGcm(cypher, encryptionKey); - - Assert.Equal(input, Encoding.UTF8.GetString(output)); - } -} \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/Security/ConfigurationSecretProviderTests.cs b/IdentityShroud.Core.Tests/Security/ConfigurationSecretProviderTests.cs new file mode 100644 index 0000000..01851a4 --- /dev/null +++ b/IdentityShroud.Core.Tests/Security/ConfigurationSecretProviderTests.cs @@ -0,0 +1,63 @@ +using System.Text; +using IdentityShroud.Core.Security; +using Microsoft.Extensions.Configuration; + +namespace IdentityShroud.Core.Tests.Security; + +public class ConfigurationSecretProviderTests +{ + private static IConfiguration BuildConfigFromJson(string json) + { + // Convert the JSON string into a stream that the config builder can read. + var jsonBytes = Encoding.UTF8.GetBytes(json); + using var stream = new MemoryStream(jsonBytes); + + // Build the configuration just like the real app does, but from the stream. + var config = new ConfigurationBuilder() + .AddJsonStream(stream) // <-- reads from the in‑memory JSON + .Build(); + + return config; + } + + [Fact] + public void Test() + { + string jsonConfig = """ + { + "secrets": { + "master": [ + { + "Id": "5676d159-5495-4945-aa84-59ee694aa8a2", + "Active": true, + "Algorithm": "AES", + "Key": "yoQ4W7EaNjo7s3FBYkWo5BLyX1BnLyWd7BlSaDIrkzo=" + }, + { + "Id": "b82489e7-a05a-4d64-b9a5-58d2f2c0dc39", + "Active": false, + "Algorithm": "AES", + "Key": "YSWK6vTJXCJOGLpCo+TtZ6anKNzvA1VT2xXLHbmq4M0=" + } + ] + } + } + """; + + + ConfigurationSecretProvider sut = new(BuildConfigFromJson(jsonConfig)); + + // act + var keys = sut.GetKeys("master"); + + // verify + Assert.Equal(2, keys.Length); + var active = keys.Single(k => k.Active); + Assert.Equal(new Guid("5676d159-5495-4945-aa84-59ee694aa8a2"), active.Id.Id); + Assert.Equal("AES", active.Algorithm); + Assert.Equal(Convert.FromBase64String("yoQ4W7EaNjo7s3FBYkWo5BLyX1BnLyWd7BlSaDIrkzo="), active.Key); + + var inactive = keys.Single(k => !k.Active); + Assert.Equal(new Guid("b82489e7-a05a-4d64-b9a5-58d2f2c0dc39"), inactive.Id.Id); + } +} \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs b/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs new file mode 100644 index 0000000..d0269e6 --- /dev/null +++ b/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs @@ -0,0 +1,155 @@ +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Model; +using IdentityShroud.Core.Services; +using IdentityShroud.Core.Tests.Fixtures; +using IdentityShroud.TestUtils.Substitutes; +using Microsoft.EntityFrameworkCore; + +namespace IdentityShroud.Core.Tests.Services; + +public class ClientServiceTests : IClassFixture +{ + private readonly DbFixture _dbFixture; + private readonly NullDataEncryptionService _dataEncryptionService = new(); + + private readonly IClock _clock = Substitute.For(); + private readonly Guid _realmId = new("a1b2c3d4-0000-0000-0000-000000000001"); + + public ClientServiceTests(DbFixture dbFixture) + { + _dbFixture = dbFixture; + using Db db = dbFixture.CreateDbContext(); + if (!db.Database.EnsureCreated()) + TruncateTables(db); + EnsureRealm(db); + } + + private void TruncateTables(Db db) + { + db.Database.ExecuteSqlRaw("TRUNCATE client CASCADE;"); + db.Database.ExecuteSqlRaw("TRUNCATE realm CASCADE;"); + } + + private void EnsureRealm(Db db) + { + if (!db.Realms.Any(r => r.Id == _realmId)) + { + db.Realms.Add(new() { Id = _realmId, Slug = "test-realm", Name = "Test Realm" }); + db.SaveChanges(); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Create(bool allowClientCredentialsFlow) + { + // Setup + DateTime now = DateTime.UtcNow; + _clock.UtcNow().Returns(now); + + Client val; + await using (var db = _dbFixture.CreateDbContext()) + { + // Act + ClientService sut = new(db, _dataEncryptionService, _clock); + var response = await sut.Create( + _realmId, + new ClientCreateRequest + { + ClientId = "test-client", + Name = "Test Client", + Description = "A test client", + AllowClientCredentialsFlow = allowClientCredentialsFlow, + }, + TestContext.Current.CancellationToken); + + // Verify + val = ResultAssert.Success(response); + Assert.Equal(_realmId, val.RealmId); + Assert.Equal("test-client", val.ClientId); + Assert.Equal("Test Client", val.Name); + Assert.Equal("A test client", val.Description); + Assert.Equal(allowClientCredentialsFlow, val.AllowClientCredentialsFlow); + Assert.Equal(now, val.CreatedAt); + } + + await using (var db = _dbFixture.CreateDbContext()) + { + var dbRecord = await db.Clients + .Include(e => e.Secrets) + .SingleAsync(e => e.Id == val.Id, TestContext.Current.CancellationToken); + + if (allowClientCredentialsFlow) + Assert.Single(dbRecord.Secrets); + else + Assert.Empty(dbRecord.Secrets); + } + } + + [Theory] + [InlineData("existing-client", true)] + [InlineData("missing-client", false)] + public async Task GetByClientId(string clientId, bool shouldFind) + { + // Setup + _clock.UtcNow().Returns(DateTime.UtcNow); + await using (var setupContext = _dbFixture.CreateDbContext()) + { + setupContext.Clients.Add(new() + { + RealmId = _realmId, + ClientId = "existing-client", + CreatedAt = DateTime.UtcNow, + }); + + await setupContext.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + await using var actContext = _dbFixture.CreateDbContext(); + // Act + ClientService sut = new(actContext, _dataEncryptionService, _clock); + Client? result = await sut.GetByClientId(_realmId, clientId, TestContext.Current.CancellationToken); + + // Verify + if (shouldFind) + Assert.NotNull(result); + else + Assert.Null(result); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task FindById(bool shouldFind) + { + // Setup + _clock.UtcNow().Returns(DateTime.UtcNow); + int existingId; + await using (var setupContext = _dbFixture.CreateDbContext()) + { + Client client = new() + { + RealmId = _realmId, + ClientId = "find-by-id-client", + CreatedAt = DateTime.UtcNow, + }; + setupContext.Clients.Add(client); + await setupContext.SaveChangesAsync(TestContext.Current.CancellationToken); + existingId = client.Id; + } + + int searchId = shouldFind ? existingId : existingId + 9999; + + await using var actContext = _dbFixture.CreateDbContext(); + // Act + ClientService sut = new(actContext, _dataEncryptionService, _clock); + Client? result = await sut.FindById(_realmId, searchId, TestContext.Current.CancellationToken); + + // Verify + if (shouldFind) + Assert.NotNull(result); + else + Assert.Null(result); + } +} diff --git a/IdentityShroud.Core.Tests/Services/DataEncryptionServiceTests.cs b/IdentityShroud.Core.Tests/Services/DataEncryptionServiceTests.cs new file mode 100644 index 0000000..4f88e48 --- /dev/null +++ b/IdentityShroud.Core.Tests/Services/DataEncryptionServiceTests.cs @@ -0,0 +1,64 @@ +using System.Security.Cryptography; +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Model; +using IdentityShroud.Core.Security; +using IdentityShroud.Core.Services; +using IdentityShroud.TestUtils.Substitutes; + +namespace IdentityShroud.Core.Tests.Services; + +public class DataEncryptionServiceTests +{ + private readonly IRealmContext _realmContext = Substitute.For(); + private readonly IDekEncryptionService _dekCryptor = new NullDekEncryptionService();// Substitute.For(); + + private readonly DekId _activeDekId = DekId.NewId(); + private readonly DekId _secondDekId = DekId.NewId(); + private DataEncryptionService CreateSut() + => new(_realmContext, _dekCryptor); + + [Fact] + public void Encrypt_UsesActiveKey() + { + _realmContext.GetDeks(Arg.Any()).Returns([ + CreateRealmDek(_secondDekId, false), + CreateRealmDek(_activeDekId, true), + ]); + + var cipher = CreateSut().Encrypt("Hello"u8); + + Assert.Equal(_activeDekId, cipher.DekId); + } + + [Fact] + public void Decrypt_UsesCorrectKey() + { + var first = CreateRealmDek(_activeDekId, true); + _realmContext.GetDeks(Arg.Any()).Returns([ first ]); + + var sut = CreateSut(); + var cipher = sut.Encrypt("Hello"u8); + + // Deactivate original key + first.Active = false; + // Make new active + var second = CreateRealmDek(_secondDekId, true); + // Return both + _realmContext.GetDeks(Arg.Any()).Returns([ first, second ]); + + + var decoded = sut.Decrypt(cipher); + + Assert.Equal("Hello"u8, decoded); + } + + private RealmDek CreateRealmDek(DekId id, bool active) + => new() + { + Id = id, + Active = active, + Algorithm = "AES", + KeyData = new(KekId.NewId(), RandomNumberGenerator.GetBytes(32)), + RealmId = default, + }; +} \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/Services/DekEncryptionServiceTests.cs b/IdentityShroud.Core.Tests/Services/DekEncryptionServiceTests.cs new file mode 100644 index 0000000..fc4a45f --- /dev/null +++ b/IdentityShroud.Core.Tests/Services/DekEncryptionServiceTests.cs @@ -0,0 +1,123 @@ +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Security; +using IdentityShroud.Core.Services; + +namespace IdentityShroud.Core.Tests.Services; + +public class DekEncryptionServiceTests +{ + [Fact] + public void RoundtripWorks() + { + // Note this code will tend to only test the latest verion. + + // setup + byte[] keyValue = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); + var secretProvider = Substitute.For(); + KeyEncryptionKey[] keys = + [ + new KeyEncryptionKey(KekId.NewId(), true, "AES", keyValue) + ]; + secretProvider.GetKeys("master").Returns(keys); + + + ReadOnlySpan input = "Hello, World!"u8; + + // act + DekEncryptionService sut = new(secretProvider); + EncryptedDek cipher = sut.Encrypt(input.ToArray()); + byte[] result = sut.Decrypt(cipher); + + // verify + Assert.Equal(input, result); + } + + [Fact] + public void DetectsCorruptInput() + { + // When introducing a new version we need version specific tests to + // make sure decoding of legacy data still works. + KekId kid = KekId.NewId(); + // setup + byte[] cipher = // NOTE INCORRECT CIPHER DO NOT USE IN OTHER TESTS + [ + 1, 198, 55, 58, 56, 110, 238, 59, 158, 214, 85, 241, 26, 44, 140, 229, 128, 111, 167, 154, 160, 177, 152, + 193, 75, 4, 235, 82, 207, 87, 32, 10, 239, 4, 246, 25, 21, 249, 25, 59, 160, 101 + ]; + EncryptedDek secret = new(kid, cipher); + + byte[] keyValue = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); + var secretProvider = Substitute.For(); + KeyEncryptionKey[] keys = + [ + new KeyEncryptionKey(kid, true, "AES", keyValue) + ]; + secretProvider.GetKeys("master").Returns(keys); + + // act + DekEncryptionService sut = new(secretProvider); + Assert.Throws( + () => sut.Decrypt(secret), + ex => ex.Message.Contains("Decryption failed") ? null : "Expected Decryption failed in message"); + } + + [Fact] + public void DecodeSelectsRightKey() + { + // The key is marked inactive also it is the second key + + // setup + KekId kid1 = KekId.NewId(); + KekId kid2 = KekId.NewId(); + + byte[] cipher = + [ + 1, 198, 55, 58, 56, 110, 238, 59, 158, 214, 85, 241, 26, 44, 140, 229, 128, 111, 167, 154, 160, 177, 152, + 193, 74, 4, 235, 82, 207, 87, 32, 10, 239, 4, 246, 25, 21, 249, 25, 59, 160, 101 + ]; + EncryptedDek secret = new(kid1, cipher); + + byte[] keyValue1 = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); + byte[] keyValue2 = Convert.FromBase64String("Dat1RwRvuLX3wdKMMP4NwHdBl8tJJsKfp01qikyo8aw="); + var secretProvider = Substitute.For(); + KeyEncryptionKey[] keys = + [ + new KeyEncryptionKey(kid2, true, "AES", keyValue2), + new KeyEncryptionKey(kid1, false, "AES", keyValue1), + ]; + secretProvider.GetKeys("master").Returns(keys); + + // act + DekEncryptionService sut = new(secretProvider); + byte[] result = sut.Decrypt(secret); + + // verify + Assert.Equal("Hello, World!"u8, result); + } + + [Fact] + public void EncryptionUsesActiveKey() + { + // setup + KekId kid1 = KekId.NewId(); + KekId kid2 = KekId.NewId(); + + byte[] keyValue1 = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); + byte[] keyValue2 = Convert.FromBase64String("Dat1RwRvuLX3wdKMMP4NwHdBl8tJJsKfp01qikyo8aw="); + var secretProvider = Substitute.For(); + KeyEncryptionKey[] keys = + [ + new KeyEncryptionKey(kid1, false, "AES", keyValue1), + new KeyEncryptionKey(kid2, true, "AES", keyValue2), + ]; + secretProvider.GetKeys("master").Returns(keys); + + ReadOnlySpan input = "Hello, World!"u8; + // act + DekEncryptionService sut = new(secretProvider); + EncryptedDek cipher = sut.Encrypt(input.ToArray()); + + // Verify + Assert.Equal(kid2, cipher.KekId); + } +} \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/Services/EncryptionServiceTests.cs b/IdentityShroud.Core.Tests/Services/EncryptionServiceTests.cs deleted file mode 100644 index b855732..0000000 --- a/IdentityShroud.Core.Tests/Services/EncryptionServiceTests.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Security.Cryptography; -using IdentityShroud.Core.Contracts; -using IdentityShroud.Core.Services; - -namespace IdentityShroud.Core.Tests.Services; - -public class EncryptionServiceTests -{ - [Fact] - public void RoundtripWorks() - { - // setup - string key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); - var secretProvider = Substitute.For(); - secretProvider.GetSecret("Master").Returns(key); - - EncryptionService sut = new(secretProvider); - byte[] input = RandomNumberGenerator.GetBytes(16); - - // act - var cipher = sut.Encrypt(input); - var result = sut.Decrypt(cipher); - - Assert.Equal(input, result); - } -} \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/Services/EncryptionTests.cs b/IdentityShroud.Core.Tests/Services/EncryptionTests.cs new file mode 100644 index 0000000..2dfbb52 --- /dev/null +++ b/IdentityShroud.Core.Tests/Services/EncryptionTests.cs @@ -0,0 +1,30 @@ +using IdentityShroud.Core.Security; +using IdentityShroud.Core.Services; + +namespace IdentityShroud.Core.Tests.Services; + +public class EncryptionTests +{ + [Fact] + public void DecodeV1_Success() + { + // When introducing a new version we need version specific tests to + // make sure decoding of legacy data still works. + + // setup + byte[] cipher = + [ + 1, 198, 55, 58, 56, 110, 238, 59, 158, 214, 85, 241, 26, 44, 140, 229, 128, 111, 167, 154, 160, 177, 152, + 193, 74, 4, 235, 82, 207, 87, 32, 10, 239, 4, 246, 25, 21, 249, 25, 59, 160, 101 + ]; + byte[] keyValue = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); + + // act + byte[] result = Encryption.Decrypt(cipher, keyValue); + + // verify + Assert.Equal("Hello, World!"u8, result); + } + + +} \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs b/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs index 5b830ea..fda233e 100644 --- a/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs +++ b/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs @@ -1,7 +1,9 @@ using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Model; +using IdentityShroud.Core.Security; +using IdentityShroud.Core.Security.Keys; using IdentityShroud.Core.Services; using IdentityShroud.Core.Tests.Fixtures; -using IdentityShroud.TestUtils.Substitutes; using Microsoft.EntityFrameworkCore; namespace IdentityShroud.Core.Tests.Services; @@ -9,7 +11,7 @@ namespace IdentityShroud.Core.Tests.Services; public class RealmServiceTests : IClassFixture { private readonly DbFixture _dbFixture; - private readonly IEncryptionService _encryptionService = EncryptionServiceSubstitute.CreatePassthrough(); + private readonly IKeyService _keyService = Substitute.For(); public RealmServiceTests(DbFixture dbFixture) { @@ -34,25 +36,43 @@ public class RealmServiceTests : IClassFixture if (idString is not null) realmId = new(idString); - using Db db = _dbFixture.CreateDbContext(); - RealmService sut = new(db, _encryptionService); - // Act - - var response = await sut.Create( - new(realmId, "slug", "New realm"), - TestContext.Current.CancellationToken); - - // Verify - RealmCreateResponse val = ResultAssert.Success(response); - if (realmId.HasValue) - Assert.Equal(realmId, val.Id); - else - Assert.NotEqual(Guid.Empty, val.Id); - - Assert.Equal("slug", val.Slug); - Assert.Equal("New realm", val.Name); - - // TODO verify data has been stored! + RealmCreateResponse? val; + await using (var db = _dbFixture.CreateDbContext()) + { + _keyService.CreateKey(Arg.Any()) + .Returns(new RealmKey() + { + Id = Guid.NewGuid(), + KeyType = "TST", + Key = new(KekId.NewId(), [21]), + CreatedAt = DateTime.UtcNow + }); + // Act + RealmService sut = new(db, _keyService); + var response = await sut.Create( + new(realmId, "slug", "New realm"), + TestContext.Current.CancellationToken); + + // Verify + val = ResultAssert.Success(response); + if (realmId.HasValue) + Assert.Equal(realmId, val.Id); + else + Assert.NotEqual(Guid.Empty, val.Id); + + Assert.Equal("slug", val.Slug); + Assert.Equal("New realm", val.Name); + + _keyService.Received().CreateKey(Arg.Any()); + } + + await using (var db = _dbFixture.CreateDbContext()) + { + var dbRecord = await db.Realms + .Include(e => e.Keys) + .SingleAsync(e => e.Id == val.Id, TestContext.Current.CancellationToken); + Assert.Equal("TST", dbRecord.Keys[0].KeyType); + } } [Theory] @@ -60,7 +80,7 @@ public class RealmServiceTests : IClassFixture [InlineData("foo", "Foo")] public async Task FindBySlug(string slug, string? name) { - using (var setupContext = _dbFixture.CreateDbContext()) + await using (var setupContext = _dbFixture.CreateDbContext()) { setupContext.Realms.Add(new() { @@ -76,11 +96,48 @@ public class RealmServiceTests : IClassFixture await setupContext.SaveChangesAsync(TestContext.Current.CancellationToken); } - using Db actContext = _dbFixture.CreateDbContext(); - RealmService sut = new(actContext, _encryptionService); + await using var actContext = _dbFixture.CreateDbContext(); // Act + RealmService sut = new(actContext, _keyService); var result = await sut.FindBySlug(slug, TestContext.Current.CancellationToken); + // Verify Assert.Equal(name, result?.Name); } + + [Theory] + [InlineData("b0423bba-2411-497b-a5b6-c5adf404b862", true)] + [InlineData("65ac9dba-6d43-4fa4-b57f-133ed639fbcb", false)] + public async Task FindById(string idString, bool shouldFind) + { + Guid id = new(idString); + await using (var setupContext = _dbFixture.CreateDbContext()) + { + setupContext.Realms.Add(new() + { + Id = new("b0423bba-2411-497b-a5b6-c5adf404b862"), + Slug = "foo", + Name = "Foo", + }); + setupContext.Realms.Add(new() + { + Id = new("d4ffc7d0-7b2c-4f02-82b9-a74610435b0d"), + Slug = "bar", + Name = "Bar", + }); + + await setupContext.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + await using var actContext = _dbFixture.CreateDbContext(); + // Act + RealmService sut = new(actContext, _keyService); + Realm? result = await sut.FindById(id, TestContext.Current.CancellationToken); + + // Verify + if (shouldFind) + Assert.NotNull(result); + else + Assert.Null(result); + } } \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/UnitTest1.cs b/IdentityShroud.Core.Tests/UnitTest1.cs index 2d28047..7506fd0 100644 --- a/IdentityShroud.Core.Tests/UnitTest1.cs +++ b/IdentityShroud.Core.Tests/UnitTest1.cs @@ -2,7 +2,6 @@ using System.Text; using System.Text.Json; using IdentityShroud.Core.DTO; -using IdentityShroud.Core.Messages; using Microsoft.AspNetCore.WebUtilities; namespace IdentityShroud.Core.Tests; @@ -36,7 +35,6 @@ public class UnitTest1 // Option 3: Generate a new key for testing rsa.KeySize = 2048; - // Your already encoded header and payload string header = "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJybVZ3TU5rM0o1WHlmMWhyS3NVbEVYN1BNUm42dlZKY0h3U3FYMUVQRnFJIn0"; string payload = "eyJleHAiOjE3Njk5MzY5MDksImlhdCI6MTc2OTkzNjYwOSwianRpIjoiMjNiZDJmNjktODdhYi00YmM2LWE0MWQtZGZkNzkxNDc4ZDM0IiwiaXNzIjoiaHR0cHM6Ly9pYW0ua2Fzc2FjbG91ZC5ubC9hdXRoL3JlYWxtcy9tcGx1c2thc3NhIiwiYXVkIjpbImthc3NhLW1hbmFnZW1lbnQtc2VydmljZSIsImFwYWNoZTItaW50cmFuZXQtYXV0aCIsImFjY291bnQiXSwic3ViIjoiMDkzY2NmMTUtYzRhOS00YWI0LTk3MWYtZDVhMDIyMzZkODVhIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoibXBvYmFja2VuZCIsInNpZCI6IjI2NmUyNjJiLTU5NjMtNDUyZi04ZTI3LWIwZTkzMjBkNTZkNiIsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJkZWZhdWx0LXJvbGVzLW1wbHVza2Fzc2EiLCJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIiwiZGVhbGVyLW1lZGV3ZXJrZXItcm9sZSIsIm1wbHVza2Fzc2EtbWVkZXdlcmtlci1yb2xlIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsiYXBhY2hlMi1pbnRyYW5ldC1hdXRoIjp7InJvbGVzIjpbImludHJhbmV0IiwicmVsZWFzZW5vdGVzX3dyaXRlIl19LCJrYXNzYS1tYW5hZ2VtZW50LXNlcnZpY2UiOnsicm9sZXMiOlsicG9zYWNjb3VudF9wYXNzd29yZHJlc2V0IiwiZHJhZnRfbGljZW5zZV93cml0ZSIsImxpY2Vuc2VfcmVhZCIsImtub3dsZWRnZUl0ZW1fcmVhZCIsIm1haWxpbmdfcmVhZCIsIm1wbHVzYXBpX3JlYWQiLCJkYXRhYmFzZV91c2VyX3dyaXRlIiwiZW52aXJvbm1lbnRfd3JpdGUiLCJna3NfYXV0aGNvZGVfcmVhZCIsImVtcGxveWVlX3JlYWQiLCJkYXRhYmFzZV91c2VyX3JlYWQiLCJhcGlhY2NvdW50X3Bhc3N3b3JkcmVzZXQiLCJtcGx1c2FwaV93cml0ZSIsImVudmlyb25tZW50X3JlYWQiLCJrbm93bGVkZ2VJdGVtX3dyaXRlIiwiZGF0YWJhc2VfdXNlcl9wYXNzd29yZF9yZWFkIiwibGljZW5zZV93cml0ZSIsImN1c3RvbWVyX3dyaXRlIiwiZGVhbGVyX3JlYWQiLCJlbXBsb3llZV93cml0ZSIsImRhdGFiYXNlX2NvbmZpZ3VyYXRpb25fd3JpdGUiLCJyZWxhdGlvbnNfcmVhZCIsImRhdGFiYXNlX3VzZXJfcGFzc3dvcmRfbXBsdXNfZW5jcnlwdGVkX3JlYWQiLCJkcmFmdF9saWNlbnNlX3JlYWQiLCJkYXRhYmFzZV9jb25maWd1cmF0aW9uX3JlYWQiXX0sImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoia21zIGVtYWlsIHByb2ZpbGUiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZGVhbGVySWQiOjEsIm5hbWUiOiJFZWxrZSBLbGVpbiIsInByZWZlcnJlZF91c2VybmFtZSI6ImVlbGtlQGJvbHQubmwiLCJsb2NhbGUiOiJlbiIsImdpdmVuX25hbWUiOiJFZWxrZSIsImZhbWlseV9uYW1lIjoiS2xlaW4iLCJlbWFpbCI6ImVlbGtlQGJvbHQubmwiLCJlbXBsb3llZU51bWJlciI6NTR9"; @@ -52,6 +50,15 @@ public class UnitTest1 // Or generate complete JWT // string completeJwt = JwtSignatureGenerator.GenerateCompleteJwt(header, payload, rsa); // Console.WriteLine($"Complete JWT: {completeJwt}"); + + rsa.ExportRSAPublicKey(); // PKCS#1 + } + + using (ECDsa dsa = ECDsa.Create()) + { + dsa.ExportPkcs8PrivateKey(); + + dsa.ExportSubjectPublicKeyInfo(); // x509 } } } @@ -67,9 +74,9 @@ public static class JwtReader return new JsonWebToken() { Header = JsonSerializer.Deserialize( - Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(jwt, 0, firstDot))), + Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(jwt, 0, firstDot)))!, Payload = JsonSerializer.Deserialize( - Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(jwt, firstDot + 1, secondDot - (firstDot + 1)))), + Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(jwt, firstDot + 1, secondDot - (firstDot + 1))))!, Signature = WebEncoders.Base64UrlDecode(jwt, secondDot + 1, jwt.Length - (secondDot + 1)) }; } diff --git a/IdentityShroud.Core/Contracts/IClientService.cs b/IdentityShroud.Core/Contracts/IClientService.cs new file mode 100644 index 0000000..20e270c --- /dev/null +++ b/IdentityShroud.Core/Contracts/IClientService.cs @@ -0,0 +1,14 @@ +using IdentityShroud.Core.Model; + +namespace IdentityShroud.Core.Contracts; + +public interface IClientService +{ + Task> Create( + Guid realmId, + ClientCreateRequest request, + CancellationToken ct = default); + + Task GetByClientId(Guid realmId, string clientId, CancellationToken ct = default); + Task FindById(Guid realmId, int id, CancellationToken ct = default); +} \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/IClock.cs b/IdentityShroud.Core/Contracts/IClock.cs new file mode 100644 index 0000000..4ba7766 --- /dev/null +++ b/IdentityShroud.Core/Contracts/IClock.cs @@ -0,0 +1,6 @@ +namespace IdentityShroud.Core.Contracts; + +public interface IClock +{ + DateTime UtcNow(); +} \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/IDataEncryptionService.cs b/IdentityShroud.Core/Contracts/IDataEncryptionService.cs new file mode 100644 index 0000000..2810aaa --- /dev/null +++ b/IdentityShroud.Core/Contracts/IDataEncryptionService.cs @@ -0,0 +1,9 @@ +using IdentityShroud.Core.Security; + +namespace IdentityShroud.Core.Contracts; + +public interface IDataEncryptionService +{ + EncryptedValue Encrypt(ReadOnlySpan plain); + byte[] Decrypt(EncryptedValue input); +} \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/IDekEncryptionService.cs b/IdentityShroud.Core/Contracts/IDekEncryptionService.cs new file mode 100644 index 0000000..3032040 --- /dev/null +++ b/IdentityShroud.Core/Contracts/IDekEncryptionService.cs @@ -0,0 +1,11 @@ +using IdentityShroud.Core.Security; + +namespace IdentityShroud.Core.Contracts; + + + +public interface IDekEncryptionService +{ + EncryptedDek Encrypt(ReadOnlySpan plain); + byte[] Decrypt(EncryptedDek input); +} \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/IEncryptionService.cs b/IdentityShroud.Core/Contracts/IEncryptionService.cs deleted file mode 100644 index f85487d..0000000 --- a/IdentityShroud.Core/Contracts/IEncryptionService.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace IdentityShroud.Core.Contracts; - -public interface IEncryptionService -{ - byte[] Encrypt(byte[] plain); - byte[] Decrypt(byte[] cipher); -} \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/IKeyService.cs b/IdentityShroud.Core/Contracts/IKeyService.cs new file mode 100644 index 0000000..4f6b5f7 --- /dev/null +++ b/IdentityShroud.Core/Contracts/IKeyService.cs @@ -0,0 +1,12 @@ +using IdentityShroud.Core.Messages; +using IdentityShroud.Core.Model; +using IdentityShroud.Core.Security.Keys; + +namespace IdentityShroud.Core.Contracts; + +public interface IKeyService +{ + RealmKey CreateKey(KeyPolicy policy); + + JsonWebKey? CreateJsonWebKey(RealmKey realmKey); +} \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/IRealmContext.cs b/IdentityShroud.Core/Contracts/IRealmContext.cs new file mode 100644 index 0000000..c757a02 --- /dev/null +++ b/IdentityShroud.Core/Contracts/IRealmContext.cs @@ -0,0 +1,9 @@ +using IdentityShroud.Core.Model; + +namespace IdentityShroud.Core.Contracts; + +public interface IRealmContext +{ + public Realm GetRealm(); + Task> GetDeks(CancellationToken ct = default); +} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/IRealmService.cs b/IdentityShroud.Core/Contracts/IRealmService.cs similarity index 65% rename from IdentityShroud.Core/Services/IRealmService.cs rename to IdentityShroud.Core/Contracts/IRealmService.cs index 4ce1da4..4598b97 100644 --- a/IdentityShroud.Core/Services/IRealmService.cs +++ b/IdentityShroud.Core/Contracts/IRealmService.cs @@ -1,12 +1,15 @@ using IdentityShroud.Core.Messages.Realm; using IdentityShroud.Core.Model; +using IdentityShroud.Core.Services; -namespace IdentityShroud.Core.Services; +namespace IdentityShroud.Core.Contracts; public interface IRealmService { + Task FindById(Guid id, CancellationToken ct = default); Task FindBySlug(string slug, CancellationToken ct = default); Task> Create(RealmCreateRequest request, CancellationToken ct = default); Task LoadActiveKeys(Realm realm); + Task LoadDeks(Realm realm); } \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/ISecretProvider.cs b/IdentityShroud.Core/Contracts/ISecretProvider.cs index 2a8e9e6..4d4182e 100644 --- a/IdentityShroud.Core/Contracts/ISecretProvider.cs +++ b/IdentityShroud.Core/Contracts/ISecretProvider.cs @@ -1,6 +1,14 @@ +using IdentityShroud.Core.Security; + namespace IdentityShroud.Core.Contracts; public interface ISecretProvider { string GetSecret(string name); + + /// + /// Should return one active key, might return inactive keys. + /// + /// + KeyEncryptionKey[] GetKeys(string name); } diff --git a/IdentityShroud.Core/DTO/Client/ClientCreateRequest.cs b/IdentityShroud.Core/DTO/Client/ClientCreateRequest.cs new file mode 100644 index 0000000..a162131 --- /dev/null +++ b/IdentityShroud.Core/DTO/Client/ClientCreateRequest.cs @@ -0,0 +1,10 @@ +namespace IdentityShroud.Core.Contracts; + +public class ClientCreateRequest +{ + 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; } +} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/DTO/JsonWebKey.cs b/IdentityShroud.Core/DTO/JsonWebKey.cs similarity index 58% rename from IdentityShroud.Api/Apis/DTO/JsonWebKey.cs rename to IdentityShroud.Core/DTO/JsonWebKey.cs index e46107f..4f16955 100644 --- a/IdentityShroud.Api/Apis/DTO/JsonWebKey.cs +++ b/IdentityShroud.Core/DTO/JsonWebKey.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using IdentityShroud.Core.Helpers; namespace IdentityShroud.Core.Messages; @@ -25,17 +26,24 @@ public class JsonWebKey // RSA Public Key Components [JsonPropertyName("n")] - public required string Modulus { get; set; } + public string? Modulus { get; set; } [JsonPropertyName("e")] - public required string Exponent { get; set; } + public string? Exponent { get; set; } + + // ECdsa + public string? Curve { get; set; } + [JsonConverter(typeof(Base64UrlConverter))] + public byte[]? X { get; set; } + [JsonConverter(typeof(Base64UrlConverter))] + public byte[]? Y { get; set; } // Optional fields - [JsonPropertyName("x5c")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public List? X509CertificateChain { get; set; } - - [JsonPropertyName("x5t")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? X509CertificateThumbprint { get; set; } + // [JsonPropertyName("x5c")] + // [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + // public List? X509CertificateChain { get; set; } + // + // [JsonPropertyName("x5t")] + // [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + // public string? X509CertificateThumbprint { get; set; } } \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/DTO/JsonWebKeySet.cs b/IdentityShroud.Core/DTO/JsonWebKeySet.cs similarity index 100% rename from IdentityShroud.Api/Apis/DTO/JsonWebKeySet.cs rename to IdentityShroud.Core/DTO/JsonWebKeySet.cs diff --git a/IdentityShroud.Core/Db.cs b/IdentityShroud.Core/Db.cs index b476787..a37136c 100644 --- a/IdentityShroud.Core/Db.cs +++ b/IdentityShroud.Core/Db.cs @@ -1,5 +1,7 @@ using IdentityShroud.Core.Model; +using IdentityShroud.Core.Security; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -16,9 +18,44 @@ public class Db( ILoggerFactory? loggerFactory) : DbContext { + public virtual DbSet Clients { get; set; } public virtual DbSet Realms { get; set; } - public virtual DbSet Keys { get; set; } - + public virtual DbSet Keys { get; set; } + public virtual DbSet Deks { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var dekIdConverter = new ValueConverter( + id => id.Id, + guid => new DekId(guid)); + + var kekIdConverter = new ValueConverter( + id => id.Id, + guid => new KekId(guid)); + + modelBuilder.Entity() + .Property(d => d.Id) + .HasConversion(dekIdConverter); + + modelBuilder.Entity() + .OwnsOne(d => d.KeyData, keyData => + { + keyData.Property(k => k.KekId).HasConversion(kekIdConverter); + }); + + modelBuilder.Entity() + .OwnsOne(k => k.Key, key => + { + key.Property(k => k.KekId).HasConversion(kekIdConverter); + }); + + modelBuilder.Entity() + .OwnsOne(c => c.Secret, secret => + { + secret.Property(s => s.DekId).HasConversion(dekIdConverter); + }); + } + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseNpgsql(""); diff --git a/IdentityShroud.Core/Helpers/Base64UrlConverter.cs b/IdentityShroud.Core/Helpers/Base64UrlConverter.cs new file mode 100644 index 0000000..77f05f2 --- /dev/null +++ b/IdentityShroud.Core/Helpers/Base64UrlConverter.cs @@ -0,0 +1,28 @@ +using System.Buffers; +using System.Buffers.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace IdentityShroud.Core.Helpers; + +public class Base64UrlConverter : JsonConverter +{ + public override byte[] Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + // GetValueSpan gives you the raw UTF-8 bytes of the JSON string value + if (reader.HasValueSequence) + { + var valueSequence = reader.ValueSequence.ToArray(); + return Base64Url.DecodeFromUtf8(valueSequence); + } + return Base64Url.DecodeFromUtf8(reader.ValueSpan); + } + + public override void Write(Utf8JsonWriter writer, byte[] value, JsonSerializerOptions options) + { + int encodedLength = Base64Url.GetEncodedLength(value.Length); + Span buffer = encodedLength <= 256 ? stackalloc byte[encodedLength] : new byte[encodedLength]; + Base64Url.EncodeToUtf8(value, buffer); + writer.WriteStringValue(buffer); + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Helpers/SlugHelper.cs b/IdentityShroud.Core/Helpers/SlugHelper.cs index beef894..51aa0c3 100644 --- a/IdentityShroud.Core/Helpers/SlugHelper.cs +++ b/IdentityShroud.Core/Helpers/SlugHelper.cs @@ -1,4 +1,3 @@ -using System; using System.Globalization; using System.Security.Cryptography; using System.Text; diff --git a/IdentityShroud.Core/IdentityShroud.Core.csproj b/IdentityShroud.Core/IdentityShroud.Core.csproj index a87c996..9dd3e34 100644 --- a/IdentityShroud.Core/IdentityShroud.Core.csproj +++ b/IdentityShroud.Core/IdentityShroud.Core.csproj @@ -11,7 +11,10 @@ + + + @@ -19,10 +22,4 @@ - - - ..\..\..\.nuget\packages\microsoft.aspnetcore.webutilities\10.0.2\lib\net10.0\Microsoft.AspNetCore.WebUtilities.dll - - - diff --git a/IdentityShroud.Core/Model/Client.cs b/IdentityShroud.Core/Model/Client.cs index d412632..5df6c1a 100644 --- a/IdentityShroud.Core/Model/Client.cs +++ b/IdentityShroud.Core/Model/Client.cs @@ -1,11 +1,29 @@ -using IdentityShroud.Core.Security; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; namespace IdentityShroud.Core.Model; +[Table("client")] +[Index(nameof(ClientId), IsUnique = true)] public class Client { - public Guid Id { get; set; } - public string Name { get; set; } + [Key] + public int Id { get; set; } + public Guid RealmId { get; set; } + [MaxLength(40)] + public required string ClientId { get; set; } + [MaxLength(80)] + public string? Name { get; set; } + [MaxLength(2048)] + public string? Description { get; set; } - public string? SignatureAlgorithm { get; set; } = JsonWebAlgorithm.RS256; + [MaxLength(20)] + public string? SignatureAlgorithm { get; set; } + + public bool AllowClientCredentialsFlow { get; set; } = false; + + public required DateTime CreatedAt { get; set; } + + public List Secrets { get; set; } = []; } \ No newline at end of file diff --git a/IdentityShroud.Core/Model/ClientSecret.cs b/IdentityShroud.Core/Model/ClientSecret.cs new file mode 100644 index 0000000..52d25cc --- /dev/null +++ b/IdentityShroud.Core/Model/ClientSecret.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Security; + +namespace IdentityShroud.Core.Model; + +[Table("client_secret")] +public class ClientSecret +{ + [Key] + public int Id { get; set; } + public Guid ClientId { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? RevokedAt { get; set; } + public required EncryptedValue Secret { get; set; } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Model/Key.cs b/IdentityShroud.Core/Model/Key.cs deleted file mode 100644 index ee09d31..0000000 --- a/IdentityShroud.Core/Model/Key.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.ComponentModel.DataAnnotations.Schema; -using IdentityShroud.Core.Contracts; - -namespace IdentityShroud.Core.Model; - - -[Table("key")] -public class Key -{ - private byte[] _privateKeyDecrypted = []; - - public Guid Id { get; set; } - - public DateTime CreatedAt { get; set; } - public DateTime? DeactivatedAt { get; set; } - - /// - /// Key with highest priority will be used. While there is not really a use case for this I know some users - /// are more comfortable replacing keys by using priority then directly deactivating the old key. - /// - public int Priority { get; set; } = 10; - - public byte[] PrivateKeyEncrypted - { - get; - set - { - field = value; - _privateKeyDecrypted = []; - } - } = []; - - public byte[] GetPrivateKey(IEncryptionService encryptionService) - { - if (_privateKeyDecrypted.Length == 0 && PrivateKeyEncrypted.Length > 0) - _privateKeyDecrypted = encryptionService.Decrypt(PrivateKeyEncrypted); - return _privateKeyDecrypted; - } - - public void SetPrivateKey(IEncryptionService encryptionService, byte[] privateKey) - { - PrivateKeyEncrypted = encryptionService.Encrypt(privateKey); - _privateKeyDecrypted = privateKey; - } -} \ No newline at end of file diff --git a/IdentityShroud.Core/Model/Realm.cs b/IdentityShroud.Core/Model/Realm.cs index 35c76e8..bbe9631 100644 --- a/IdentityShroud.Core/Model/Realm.cs +++ b/IdentityShroud.Core/Model/Realm.cs @@ -1,7 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using IdentityShroud.Core.Security; -using Microsoft.EntityFrameworkCore; namespace IdentityShroud.Core.Model; @@ -20,11 +19,22 @@ public class Realm public string Name { get; set; } = ""; public List Clients { get; init; } = []; - public List Keys { get; init; } = []; + public List Keys { get; init; } = []; + + public List Deks { get; init; } = []; /// /// Can be overriden per client /// public string DefaultSignatureAlgorithm { get; set; } = JsonWebAlgorithm.RS256; - +} + +[Table("realm_dek")] +public record RealmDek +{ + public required DekId Id { get; init; } + public required bool Active { get; set; } + public required string Algorithm { get; init; } + public required EncryptedDek KeyData { get; init; } + public required Guid RealmId { get; init; } } diff --git a/IdentityShroud.Core/Model/RealmKey.cs b/IdentityShroud.Core/Model/RealmKey.cs new file mode 100644 index 0000000..3fcf2d1 --- /dev/null +++ b/IdentityShroud.Core/Model/RealmKey.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations.Schema; +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Security; +using Microsoft.EntityFrameworkCore; + +namespace IdentityShroud.Core.Model; + + +[Table("realm_key")] +public record RealmKey +{ + public required Guid Id { get; init; } + public required string KeyType { get; init; } + + + public required EncryptedDek Key { get; init; } + public required DateTime CreatedAt { get; init; } + public DateTime? RevokedAt { get; set; } + + /// + /// Key with highest priority will be used. While there is not really a use case for this I know some users + /// are more comfortable replacing keys by using priority then directly deactivating the old key. + /// + public int Priority { get; set; } = 10; + + +} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/AesGcmHelper.cs b/IdentityShroud.Core/Security/AesGcmHelper.cs deleted file mode 100644 index 62abf6a..0000000 --- a/IdentityShroud.Core/Security/AesGcmHelper.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System.Security.Cryptography; - -namespace IdentityShroud.Core.Security; - -public static class AesGcmHelper -{ - - public static byte[] EncryptAesGcm(byte[] plaintext, byte[] key) - { - int tagSize = AesGcm.TagByteSizes.MaxSize; - using var aes = new AesGcm(key, tagSize); - - Span nonce = stackalloc byte[AesGcm.NonceByteSizes.MaxSize]; - RandomNumberGenerator.Fill(nonce); - Span ciphertext = stackalloc byte[plaintext.Length]; - Span tag = stackalloc byte[tagSize]; - - aes.Encrypt(nonce, plaintext, ciphertext, tag); - - // Return concatenated nonce|ciphertext|tag - var result = new byte[nonce.Length + ciphertext.Length + tag.Length]; - nonce.CopyTo(result.AsSpan(0, nonce.Length)); - ciphertext.CopyTo(result.AsSpan(nonce.Length, ciphertext.Length)); - tag.CopyTo(result.AsSpan(nonce.Length + ciphertext.Length, tag.Length)); - return result; - } - - // -------------------------------------------------------------------- - // DecryptAesGcm - // • key – 32‑byte (256‑bit) secret key (same key used for encryption) - // • payload – byte[] containing nonce‖ciphertext‖tag - // • returns – the original plaintext bytes - // -------------------------------------------------------------------- - public static byte[] DecryptAesGcm(byte[] payload, byte[] key) - { - if (payload == null) throw new ArgumentNullException(nameof(payload)); - if (key == null) throw new ArgumentNullException(nameof(key)); - if (key.Length != 32) // 256‑bit key - throw new ArgumentException("Key must be 256 bits (32 bytes) for AES‑256‑GCM.", nameof(key)); - - // ---------------------------------------------------------------- - // 1️⃣ Extract the three components. - // ---------------------------------------------------------------- - // AesGcm.NonceByteSizes.MaxSize = 12 bytes (standard GCM nonce length) - // AesGcm.TagByteSizes.MaxSize = 16 bytes (128‑bit authentication tag) - int nonceSize = AesGcm.NonceByteSizes.MaxSize; // 12 - int tagSize = AesGcm.TagByteSizes.MaxSize; // 16 - - if (payload.Length < nonceSize + tagSize) - throw new ArgumentException("Payload is too short to contain nonce, ciphertext, and tag.", nameof(payload)); - - ReadOnlySpan nonce = new(payload, 0, nonceSize); - ReadOnlySpan ciphertext = new(payload, nonceSize, payload.Length - nonceSize - tagSize); - ReadOnlySpan tag = new(payload, payload.Length - tagSize, tagSize); - - byte[] plaintext = new byte[ciphertext.Length]; - - using var aes = new AesGcm(key, tagSize); - try - { - aes.Decrypt(nonce, ciphertext, tag, plaintext); - } - catch (CryptographicException ex) - { - // Tag verification failed → tampering or wrong key/nonce. - throw new InvalidOperationException("Decryption failed – authentication tag mismatch.", ex); - } - - return plaintext; - } -} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/ConfigurationSecretProvider.cs b/IdentityShroud.Core/Security/ConfigurationSecretProvider.cs index ab77ef1..9355c0b 100644 --- a/IdentityShroud.Core/Security/ConfigurationSecretProvider.cs +++ b/IdentityShroud.Core/Security/ConfigurationSecretProvider.cs @@ -14,4 +14,9 @@ public class ConfigurationSecretProvider(IConfiguration configuration) : ISecret { return secrets.GetValue(name) ?? ""; } + + public KeyEncryptionKey[] GetKeys(string name) + { + return secrets.GetSection(name).Get() ?? []; + } } \ No newline at end of file diff --git a/IdentityShroud.Core/Security/DekId.cs b/IdentityShroud.Core/Security/DekId.cs new file mode 100644 index 0000000..276178e --- /dev/null +++ b/IdentityShroud.Core/Security/DekId.cs @@ -0,0 +1,6 @@ +namespace IdentityShroud.Core.Security; + +public record struct DekId(Guid Id) +{ + public static DekId NewId() => new(Guid.NewGuid()); +} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/EncryptedDek.cs b/IdentityShroud.Core/Security/EncryptedDek.cs new file mode 100644 index 0000000..377a2f6 --- /dev/null +++ b/IdentityShroud.Core/Security/EncryptedDek.cs @@ -0,0 +1,6 @@ +using Microsoft.EntityFrameworkCore; + +namespace IdentityShroud.Core.Security; + +[Owned] +public record EncryptedDek(KekId KekId, byte[] Value); \ No newline at end of file diff --git a/IdentityShroud.Core/Security/EncryptedValue.cs b/IdentityShroud.Core/Security/EncryptedValue.cs new file mode 100644 index 0000000..173c295 --- /dev/null +++ b/IdentityShroud.Core/Security/EncryptedValue.cs @@ -0,0 +1,8 @@ +using Microsoft.EntityFrameworkCore; + +namespace IdentityShroud.Core.Security; + +[Owned] +public record EncryptedValue(DekId DekId, byte[] Value); + + diff --git a/IdentityShroud.Core/Security/Encryption.cs b/IdentityShroud.Core/Security/Encryption.cs new file mode 100644 index 0000000..47344c1 --- /dev/null +++ b/IdentityShroud.Core/Security/Encryption.cs @@ -0,0 +1,70 @@ +using System.Security.Cryptography; + +namespace IdentityShroud.Core.Security; + +public static class Encryption +{ + private record struct AlgVersion(int Version, int NonceSize, int TagSize); + + private static AlgVersion[] _versions = + [ + new(0, 0, 0), // version 0 does not realy exist + new(1, 12, 16), // version 1 + ]; + + public static byte[] Encrypt(ReadOnlySpan plaintext, ReadOnlySpan key) + { + 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)versionParams.Version; + + // 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); + using var aes = new AesGcm(key, versionParams.TagSize); + aes.Encrypt(nonce, plaintext, cipher, tag); + return result; + } + + public static byte[] Decrypt(ReadOnlyMemory input, ReadOnlySpan key) + { + var payload = input.Span; + 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 nonce = payload.Slice(1, versionParams.NonceSize); + ReadOnlySpan tag = payload.Slice(1 + versionParams.NonceSize, versionParams.TagSize); + ReadOnlySpan cipher = payload.Slice(1 + versionParams.NonceSize + versionParams.TagSize); + + byte[] plaintext = new byte[cipher.Length]; + + using var aes = new AesGcm(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; + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/JsonWebAlgorithm.cs b/IdentityShroud.Core/Security/JsonWebAlgorithm.cs index cbdcf05..dc9bc28 100644 --- a/IdentityShroud.Core/Security/JsonWebAlgorithm.cs +++ b/IdentityShroud.Core/Security/JsonWebAlgorithm.cs @@ -1,5 +1,3 @@ -using System.Security.Cryptography; - namespace IdentityShroud.Core.Security; public static class JsonWebAlgorithm diff --git a/IdentityShroud.Core/Security/KekId.cs b/IdentityShroud.Core/Security/KekId.cs new file mode 100644 index 0000000..c794078 --- /dev/null +++ b/IdentityShroud.Core/Security/KekId.cs @@ -0,0 +1,41 @@ +using System.ComponentModel; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace IdentityShroud.Core.Security; + +[JsonConverter(typeof(KekIdJsonConverter))] +[TypeConverter(typeof(KekIdTypeConverter))] +public readonly record struct KekId +{ + public Guid Id { get; } + + public KekId(Guid id) + { + Id = id; + } + + public static KekId NewId() + { + return new KekId(Guid.NewGuid()); + } +} + +public class KekIdJsonConverter : JsonConverter +{ + public override KekId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => new KekId(reader.GetGuid()); + + public override void Write(Utf8JsonWriter writer, KekId value, JsonSerializerOptions options) + => writer.WriteStringValue(value.Id); +} + +public class KekIdTypeConverter : TypeConverter +{ + public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) + => sourceType == typeof(string) || base.CanConvertFrom(context, sourceType); + + public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) + => value is string s ? new KekId(Guid.Parse(s)) : base.ConvertFrom(context, culture, value); +} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/KeyEncryptionKey.cs b/IdentityShroud.Core/Security/KeyEncryptionKey.cs new file mode 100644 index 0000000..35f7917 --- /dev/null +++ b/IdentityShroud.Core/Security/KeyEncryptionKey.cs @@ -0,0 +1,10 @@ +namespace IdentityShroud.Core.Security; + +/// +/// Contains a KEK and associated relevant data. This structure +/// +/// +/// +/// +/// +public record KeyEncryptionKey(KekId Id, bool Active, string Algorithm, byte[] Key); diff --git a/IdentityShroud.Core/Security/Keys/IKeyProvider.cs b/IdentityShroud.Core/Security/Keys/IKeyProvider.cs new file mode 100644 index 0000000..8e32309 --- /dev/null +++ b/IdentityShroud.Core/Security/Keys/IKeyProvider.cs @@ -0,0 +1,19 @@ +using IdentityShroud.Core.Messages; + +namespace IdentityShroud.Core.Security.Keys; + +public abstract class KeyPolicy +{ + public abstract string KeyType { get; } +} + + +public interface IKeyProvider +{ + byte[] CreateKey(KeyPolicy policy); + + void SetJwkParameters(byte[] key, JsonWebKey jwk); +} + + + diff --git a/IdentityShroud.Core/Security/Keys/IKeyProviderFactory.cs b/IdentityShroud.Core/Security/Keys/IKeyProviderFactory.cs new file mode 100644 index 0000000..485e6e5 --- /dev/null +++ b/IdentityShroud.Core/Security/Keys/IKeyProviderFactory.cs @@ -0,0 +1,7 @@ +namespace IdentityShroud.Core.Security.Keys; + + +public interface IKeyProviderFactory +{ + public IKeyProvider CreateProvider(string keyType); +} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Keys/KeyProviderFactory.cs b/IdentityShroud.Core/Security/Keys/KeyProviderFactory.cs new file mode 100644 index 0000000..a1c3472 --- /dev/null +++ b/IdentityShroud.Core/Security/Keys/KeyProviderFactory.cs @@ -0,0 +1,17 @@ +using IdentityShroud.Core.Security.Keys.Rsa; + +namespace IdentityShroud.Core.Security.Keys; + +public class KeyProviderFactory : IKeyProviderFactory +{ + public IKeyProvider CreateProvider(string keyType) + { + switch (keyType) + { + case "RSA": + return new RsaProvider(); + default: + throw new NotImplementedException(); + } + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Keys/Rsa/RsaProvider.cs b/IdentityShroud.Core/Security/Keys/Rsa/RsaProvider.cs new file mode 100644 index 0000000..daf2b7f --- /dev/null +++ b/IdentityShroud.Core/Security/Keys/Rsa/RsaProvider.cs @@ -0,0 +1,35 @@ +using System.Buffers.Text; +using System.Security.Cryptography; +using IdentityShroud.Core.Messages; + +namespace IdentityShroud.Core.Security.Keys.Rsa; + +public class RsaKeyPolicy : KeyPolicy +{ + public override string KeyType => "RSA"; + public int KeySize { get; } = 2048; +} + +public class RsaProvider : IKeyProvider +{ + public byte[] CreateKey(KeyPolicy policy) + { + if (policy is RsaKeyPolicy p) + { + using var rsa = RSA.Create(p.KeySize); + return rsa.ExportPkcs8PrivateKey(); + } + + throw new ArgumentException("Incorrect policy type", nameof(policy)); + } + + public void SetJwkParameters(byte[] key, JsonWebKey jwk) + { + using var rsa = RSA.Create(); + rsa.ImportPkcs8PrivateKey(key, out _); + var parameters = rsa.ExportParameters(includePrivateParameters: false); + + jwk.Exponent = Base64Url.EncodeToString(parameters.Exponent); + jwk.Modulus = Base64Url.EncodeToString(parameters.Modulus); + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/RsaHelper.cs b/IdentityShroud.Core/Security/RsaHelper.cs deleted file mode 100644 index ab49ebd..0000000 --- a/IdentityShroud.Core/Security/RsaHelper.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Security.Cryptography; - -namespace IdentityShroud.Core.Security; - -public static class RsaHelper -{ - /// - /// Load RSA private key from PKCS#8 format - /// - public static RSA LoadFromPkcs8(byte[] pkcs8Key) - { - var rsa = RSA.Create(); - rsa.ImportPkcs8PrivateKey(pkcs8Key, out _); - return rsa; - } -} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/ClientService.cs b/IdentityShroud.Core/Services/ClientService.cs new file mode 100644 index 0000000..0887ccd --- /dev/null +++ b/IdentityShroud.Core/Services/ClientService.cs @@ -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> 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 GetByClientId( + Guid realmId, + string clientId, + CancellationToken ct = default) + { + return await db.Clients.FirstOrDefaultAsync(c => c.ClientId == clientId && c.RealmId == realmId, ct); + } + + public async Task 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 secret = stackalloc byte[24]; + RandomNumberGenerator.Fill(secret); + + return new ClientSecret() + { + CreatedAt = clock.UtcNow(), + Secret = cryptor.Encrypt(secret.ToArray()), + }; + + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/ClockService.cs b/IdentityShroud.Core/Services/ClockService.cs new file mode 100644 index 0000000..26eb3dd --- /dev/null +++ b/IdentityShroud.Core/Services/ClockService.cs @@ -0,0 +1,11 @@ +using IdentityShroud.Core.Contracts; + +namespace IdentityShroud.Core.Services; + +public class ClockService : IClock +{ + public DateTime UtcNow() + { + return DateTime.UtcNow; + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/DataEncryptionService.cs b/IdentityShroud.Core/Services/DataEncryptionService.cs new file mode 100644 index 0000000..a06cbae --- /dev/null +++ b/IdentityShroud.Core/Services/DataEncryptionService.cs @@ -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? _deks = null; + + private IList 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 plain) + { + var dek = GetActiveDek(); + var key = dekCryptor.Decrypt(dek.KeyData); + byte[] cipher = Encryption.Encrypt(plain, key); + return new (dek.Id, cipher); + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/DekEncryptionService.cs b/IdentityShroud.Core/Services/DekEncryptionService.cs new file mode 100644 index 0000000..add9267 --- /dev/null +++ b/IdentityShroud.Core/Services/DekEncryptionService.cs @@ -0,0 +1,38 @@ +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Security; + +namespace IdentityShroud.Core.Services; + +/// +/// +/// +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) // 256‑bit key + // throw new Exception("Key must be 256 bits (32 bytes) for AES‑256‑GCM."); + } + + public EncryptedDek Encrypt(ReadOnlySpan 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); + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/EncryptionService.cs b/IdentityShroud.Core/Services/EncryptionService.cs deleted file mode 100644 index 24cdd18..0000000 --- a/IdentityShroud.Core/Services/EncryptionService.cs +++ /dev/null @@ -1,27 +0,0 @@ -using IdentityShroud.Core.Contracts; -using IdentityShroud.Core.Security; - -namespace IdentityShroud.Core.Services; - -/// -/// -/// -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); - } -} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/KeyService.cs b/IdentityShroud.Core/Services/KeyService.cs new file mode 100644 index 0000000..a2ce9dc --- /dev/null +++ b/IdentityShroud.Core/Services/KeyService.cs @@ -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(), + }; +} diff --git a/IdentityShroud.Core/Services/RealmContext.cs b/IdentityShroud.Core/Services/RealmContext.cs new file mode 100644 index 0000000..7daa399 --- /dev/null +++ b/IdentityShroud.Core/Services/RealmContext.cs @@ -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> GetDeks(CancellationToken ct = default) + { + Realm realm = GetRealm(); + if (realm.Deks.Count == 0) + { + await realmService.LoadDeks(realm); + } + + return realm.Deks; + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/RealmService.cs b/IdentityShroud.Core/Services/RealmService.cs index 57c4cf2..949c9fe 100644 --- a/IdentityShroud.Core/Services/RealmService.cs +++ b/IdentityShroud.Core/Services/RealmService.cs @@ -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 FindById(Guid id, CancellationToken ct = default) + { + return await db.Realms + .SingleOrDefaultAsync(r => r.Id == id, ct); + } + public async Task 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); } + /// + /// Place holder for getting policies from the realm and falling back to sane defaults when no policies have been set. + /// + /// + /// + 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(); } } \ No newline at end of file diff --git a/IdentityShroud.TestUtils/Asserts/JsonObjectAssert.cs b/IdentityShroud.TestUtils/Asserts/JsonObjectAssert.cs index 3352bc6..016f358 100644 --- a/IdentityShroud.TestUtils/Asserts/JsonObjectAssert.cs +++ b/IdentityShroud.TestUtils/Asserts/JsonObjectAssert.cs @@ -1,6 +1,5 @@ using System.Text.Json.Nodes; using System.Text.RegularExpressions; -using Xunit; namespace IdentityShroud.TestUtils.Asserts; diff --git a/IdentityShroud.TestUtils/Asserts/ResultAssert.cs b/IdentityShroud.TestUtils/Asserts/ResultAssert.cs index 28a0b11..ff00c06 100644 --- a/IdentityShroud.TestUtils/Asserts/ResultAssert.cs +++ b/IdentityShroud.TestUtils/Asserts/ResultAssert.cs @@ -1,5 +1,4 @@ using FluentResults; -using Xunit; namespace IdentityShroud.Core.Tests; diff --git a/IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj b/IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj index 0b8cba9..4b68445 100644 --- a/IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj +++ b/IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj @@ -10,6 +10,7 @@ + @@ -21,10 +22,4 @@ - - - ..\..\..\.nuget\packages\nsubstitute\5.3.0\lib\net6.0\NSubstitute.dll - - - diff --git a/IdentityShroud.TestUtils/Substitutes/EncryptionServiceSubstitute.cs b/IdentityShroud.TestUtils/Substitutes/EncryptionServiceSubstitute.cs deleted file mode 100644 index bb26ee9..0000000 --- a/IdentityShroud.TestUtils/Substitutes/EncryptionServiceSubstitute.cs +++ /dev/null @@ -1,18 +0,0 @@ -using IdentityShroud.Core.Contracts; - -namespace IdentityShroud.TestUtils.Substitutes; - -public static class EncryptionServiceSubstitute -{ - public static IEncryptionService CreatePassthrough() - { - var encryptionService = Substitute.For(); - encryptionService - .Encrypt(Arg.Any()) - .Returns(x => x.ArgAt(0)); - encryptionService - .Decrypt(Arg.Any()) - .Returns(x => x.ArgAt(0)); - return encryptionService; - } -} \ No newline at end of file diff --git a/IdentityShroud.TestUtils/Substitutes/NullDataEncryptionService.cs b/IdentityShroud.TestUtils/Substitutes/NullDataEncryptionService.cs new file mode 100644 index 0000000..4e97bfc --- /dev/null +++ b/IdentityShroud.TestUtils/Substitutes/NullDataEncryptionService.cs @@ -0,0 +1,18 @@ +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Security; + +namespace IdentityShroud.TestUtils.Substitutes; + +public class NullDataEncryptionService : IDataEncryptionService +{ + public DekId KeyId { get; } = DekId.NewId(); + public EncryptedValue Encrypt(ReadOnlySpan plain) + { + return new(KeyId, plain.ToArray()); + } + + public byte[] Decrypt(EncryptedValue input) + { + return input.Value; + } +} \ No newline at end of file diff --git a/IdentityShroud.TestUtils/Substitutes/NullDekEncryptionService.cs b/IdentityShroud.TestUtils/Substitutes/NullDekEncryptionService.cs new file mode 100644 index 0000000..879f932 --- /dev/null +++ b/IdentityShroud.TestUtils/Substitutes/NullDekEncryptionService.cs @@ -0,0 +1,18 @@ +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Security; + +namespace IdentityShroud.TestUtils.Substitutes; + +public class NullDekEncryptionService : IDekEncryptionService +{ + public KekId KeyId { get; } = KekId.NewId(); + public EncryptedDek Encrypt(ReadOnlySpan plain) + { + return new(KeyId, plain.ToArray()); + } + + public byte[] Decrypt(EncryptedDek input) + { + return input.Value; + } +} \ No newline at end of file diff --git a/IdentityShroud.sln.DotSettings.user b/IdentityShroud.sln.DotSettings.user index a850ec0..88c8f46 100644 --- a/IdentityShroud.sln.DotSettings.user +++ b/IdentityShroud.sln.DotSettings.user @@ -2,32 +2,47 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded + ForceIncluded /home/eelke/.cache/JetBrains/Rider2025.3/resharper-host/temp/Rider/vAny/CoverageData/_IdentityShroud.-1277985570/Snapshot/snapshot.utdcvr + /home/eelke/.dotnet/dotnet /home/eelke/.dotnet/sdk/10.0.102/MSBuild.dll - <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution #3" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> </SessionState> - <SessionState ContinuousTestingMode="0" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <Solution /> -</SessionState> - <SessionState ContinuousTestingMode="0" Name="All tests from Solution #2" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <Solution /> -</SessionState> + + + + + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8bd5aa3 --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ +# IdentityShroud + +IdentityShroud is a .NET project for identity management and protection. + From 1a8c63808a020aea860c26750be39d3560c6cc93 Mon Sep 17 00:00:00 2001 From: eelke Date: Sat, 28 Feb 2026 08:18:30 +0100 Subject: [PATCH 18/22] Switch Owned value types to ComplexProperty which is better fit. This requires fluent configuration. Also made some conversion registration context wide. --- .../EFCore/Converters/DekIdConverter.cs | 12 +++++ .../EFCore/Converters/KekIdConverter.cs | 12 +++++ IdentityShroud.Core/{ => EFCore}/Db.cs | 47 +++++-------------- .../IdentityShroud.Core.csproj.DotSettings | 2 + IdentityShroud.Core/Model/ClientSecret.cs | 14 +++++- IdentityShroud.Core/Model/Realm.cs | 15 ++---- IdentityShroud.Core/Model/RealmDek.cs | 24 ++++++++++ IdentityShroud.Core/Model/RealmKey.cs | 16 +++++-- IdentityShroud.Core/Security/EncryptedDek.cs | 1 - .../Security/EncryptedValue.cs | 1 - IdentityShroud.sln.DotSettings.user | 3 ++ 11 files changed, 93 insertions(+), 54 deletions(-) create mode 100644 IdentityShroud.Core/EFCore/Converters/DekIdConverter.cs create mode 100644 IdentityShroud.Core/EFCore/Converters/KekIdConverter.cs rename IdentityShroud.Core/{ => EFCore}/Db.cs (59%) create mode 100644 IdentityShroud.Core/IdentityShroud.Core.csproj.DotSettings create mode 100644 IdentityShroud.Core/Model/RealmDek.cs diff --git a/IdentityShroud.Core/EFCore/Converters/DekIdConverter.cs b/IdentityShroud.Core/EFCore/Converters/DekIdConverter.cs new file mode 100644 index 0000000..0a957ae --- /dev/null +++ b/IdentityShroud.Core/EFCore/Converters/DekIdConverter.cs @@ -0,0 +1,12 @@ +using IdentityShroud.Core.Security; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace IdentityShroud.Core; + +public class DekIdConverter : ValueConverter +{ + public DekIdConverter() + : base(id => id.Id, guid => new DekId(guid)) + { + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/EFCore/Converters/KekIdConverter.cs b/IdentityShroud.Core/EFCore/Converters/KekIdConverter.cs new file mode 100644 index 0000000..87c0de9 --- /dev/null +++ b/IdentityShroud.Core/EFCore/Converters/KekIdConverter.cs @@ -0,0 +1,12 @@ +using IdentityShroud.Core.Security; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace IdentityShroud.Core; + +public class KekIdConverter : ValueConverter +{ + public KekIdConverter() + : base(id => id.Id, guid => new KekId(guid)) + { + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Db.cs b/IdentityShroud.Core/EFCore/Db.cs similarity index 59% rename from IdentityShroud.Core/Db.cs rename to IdentityShroud.Core/EFCore/Db.cs index a37136c..90ebea7 100644 --- a/IdentityShroud.Core/Db.cs +++ b/IdentityShroud.Core/EFCore/Db.cs @@ -1,7 +1,7 @@ +using System.Linq.Expressions; using IdentityShroud.Core.Model; using IdentityShroud.Core.Security; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -23,39 +23,6 @@ public class Db( public virtual DbSet Keys { get; set; } public virtual DbSet Deks { get; set; } - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - var dekIdConverter = new ValueConverter( - id => id.Id, - guid => new DekId(guid)); - - var kekIdConverter = new ValueConverter( - id => id.Id, - guid => new KekId(guid)); - - modelBuilder.Entity() - .Property(d => d.Id) - .HasConversion(dekIdConverter); - - modelBuilder.Entity() - .OwnsOne(d => d.KeyData, keyData => - { - keyData.Property(k => k.KekId).HasConversion(kekIdConverter); - }); - - modelBuilder.Entity() - .OwnsOne(k => k.Key, key => - { - key.Property(k => k.KekId).HasConversion(kekIdConverter); - }); - - modelBuilder.Entity() - .OwnsOne(c => c.Secret, secret => - { - secret.Property(s => s.DekId).HasConversion(dekIdConverter); - }); - } - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseNpgsql(""); @@ -71,6 +38,18 @@ public class Db( { optionsBuilder.UseLoggerFactory(loggerFactory); } + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfigurationsFromAssembly(typeof(Db).Assembly); + } + + protected override void ConfigureConventions(ModelConfigurationBuilder b) + { + base.ConfigureConventions(b); + b.Properties().HaveConversion(); + b.Properties().HaveConversion(); } } \ No newline at end of file diff --git a/IdentityShroud.Core/IdentityShroud.Core.csproj.DotSettings b/IdentityShroud.Core/IdentityShroud.Core.csproj.DotSettings new file mode 100644 index 0000000..f42aea1 --- /dev/null +++ b/IdentityShroud.Core/IdentityShroud.Core.csproj.DotSettings @@ -0,0 +1,2 @@ + + True \ No newline at end of file diff --git a/IdentityShroud.Core/Model/ClientSecret.cs b/IdentityShroud.Core/Model/ClientSecret.cs index 52d25cc..d17e24d 100644 --- a/IdentityShroud.Core/Model/ClientSecret.cs +++ b/IdentityShroud.Core/Model/ClientSecret.cs @@ -2,6 +2,8 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Security; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace IdentityShroud.Core.Model; @@ -13,5 +15,15 @@ public class ClientSecret public Guid ClientId { get; set; } public DateTime CreatedAt { get; set; } public DateTime? RevokedAt { get; set; } - public required EncryptedValue Secret { get; set; } + public EncryptedValue? Secret { get; set; } +} + +public class ClientSecretConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder b) + { + b.ToTable("client_secret"); + b.HasKey(e => e.Id); + b.ComplexProperty(e => e.Secret); + } } \ No newline at end of file diff --git a/IdentityShroud.Core/Model/Realm.cs b/IdentityShroud.Core/Model/Realm.cs index bbe9631..8f6737a 100644 --- a/IdentityShroud.Core/Model/Realm.cs +++ b/IdentityShroud.Core/Model/Realm.cs @@ -1,13 +1,14 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using IdentityShroud.Core.Security; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace IdentityShroud.Core.Model; [Table("realm")] public class Realm { - public Guid Id { get; set; } /// /// Note this is part of the url we should encourage users to keep it short but we do not want to limit them too much @@ -27,14 +28,4 @@ public class Realm /// Can be overriden per client /// public string DefaultSignatureAlgorithm { get; set; } = JsonWebAlgorithm.RS256; -} - -[Table("realm_dek")] -public record RealmDek -{ - public required DekId Id { get; init; } - public required bool Active { get; set; } - public required string Algorithm { get; init; } - public required EncryptedDek KeyData { get; init; } - public required Guid RealmId { get; init; } -} +} \ No newline at end of file diff --git a/IdentityShroud.Core/Model/RealmDek.cs b/IdentityShroud.Core/Model/RealmDek.cs new file mode 100644 index 0000000..ed78b14 --- /dev/null +++ b/IdentityShroud.Core/Model/RealmDek.cs @@ -0,0 +1,24 @@ +using IdentityShroud.Core.Security; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace IdentityShroud.Core.Model; + +public record RealmDek +{ + public required DekId Id { get; init; } + public required bool Active { get; set; } + public required string Algorithm { get; init; } + public required EncryptedDek KeyData { get; init; } + public required Guid RealmId { get; init; } +} + +public class RealmDekConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder b) + { + b.ToTable("realm_dek"); + b.HasKey(e => e.Id); + b.ComplexProperty(e => e.KeyData, e => e.IsRequired()); + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Model/RealmKey.cs b/IdentityShroud.Core/Model/RealmKey.cs index 3fcf2d1..4613be8 100644 --- a/IdentityShroud.Core/Model/RealmKey.cs +++ b/IdentityShroud.Core/Model/RealmKey.cs @@ -2,17 +2,14 @@ using System.ComponentModel.DataAnnotations.Schema; using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Security; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace IdentityShroud.Core.Model; - -[Table("realm_key")] public record RealmKey { public required Guid Id { get; init; } public required string KeyType { get; init; } - - public required EncryptedDek Key { get; init; } public required DateTime CreatedAt { get; init; } public DateTime? RevokedAt { get; set; } @@ -22,6 +19,15 @@ public record RealmKey /// are more comfortable replacing keys by using priority then directly deactivating the old key. /// public int Priority { get; set; } = 10; - +} +public class RealmKeyConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder b) + { + b.ToTable("realm_key"); + b.HasKey(e => e.Id); + + b.ComplexProperty(e => e.Key, e => e.IsRequired()); + } } \ No newline at end of file diff --git a/IdentityShroud.Core/Security/EncryptedDek.cs b/IdentityShroud.Core/Security/EncryptedDek.cs index 377a2f6..002c1c9 100644 --- a/IdentityShroud.Core/Security/EncryptedDek.cs +++ b/IdentityShroud.Core/Security/EncryptedDek.cs @@ -2,5 +2,4 @@ using Microsoft.EntityFrameworkCore; namespace IdentityShroud.Core.Security; -[Owned] public record EncryptedDek(KekId KekId, byte[] Value); \ No newline at end of file diff --git a/IdentityShroud.Core/Security/EncryptedValue.cs b/IdentityShroud.Core/Security/EncryptedValue.cs index 173c295..37619fa 100644 --- a/IdentityShroud.Core/Security/EncryptedValue.cs +++ b/IdentityShroud.Core/Security/EncryptedValue.cs @@ -2,7 +2,6 @@ using Microsoft.EntityFrameworkCore; namespace IdentityShroud.Core.Security; -[Owned] public record EncryptedValue(DekId DekId, byte[] Value); diff --git a/IdentityShroud.sln.DotSettings.user b/IdentityShroud.sln.DotSettings.user index 88c8f46..540f7bf 100644 --- a/IdentityShroud.sln.DotSettings.user +++ b/IdentityShroud.sln.DotSettings.user @@ -26,6 +26,9 @@ /home/eelke/.dotnet/dotnet /home/eelke/.dotnet/sdk/10.0.102/MSBuild.dll + <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <Solution /> +</SessionState> <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> </SessionState> From 8782ef39c680d19cf166ad31fd218b113ca7e283 Mon Sep 17 00:00:00 2001 From: eelke Date: Mon, 16 Mar 2026 19:15:04 +0100 Subject: [PATCH 19/22] Still working on getting client credential flow complete, most of the request works but still working on generating the JWT. --- .../Apis/ClientApiTests.cs | 88 +++++++++---- .../Apis/OpenIdApiTests.cs | 123 ++++++++++++++++++ .../Apis/RealmApisTests.cs | 66 ++++++---- .../HeaderHelpersTests.cs | 20 +++ .../Mappers/KeyServiceTests.cs | 46 ------- IdentityShroud.Api/Apis/ClientApi.cs | 50 +++++-- .../Apis/Dto/ClientRepresentation.cs | 3 + IdentityShroud.Api/Apis/Dto/ErrorDto.cs | 3 + .../Apis/Dto/RealmRepresentation.cs | 6 + .../Apis/Dto/TokenRequestBody.cs | 21 +++ .../Apis/EndpointRouteBuilderExtensions.cs | 7 +- .../{Validation => Filters}/ValidateFilter.cs | 0 .../Apis/Helpers/HeaderHelpers.cs | 50 +++++++ .../ISResults/ISUnauthorizedHttpResult.cs | 38 ++++++ IdentityShroud.Api/Apis/Mappers/KeyMapper.cs | 20 ++- IdentityShroud.Api/Apis/OpenIdEndpoints.cs | 76 ++++++++++- IdentityShroud.Api/Apis/RealmApi.cs | 49 +++++-- .../ClientCreateRequestValidator.cs | 10 +- .../AppJsonSerializerContext.cs | 7 +- IdentityShroud.Api/GlobalExceptionHandler.cs | 24 ++++ IdentityShroud.Api/Program.cs | 8 +- .../Fixtures/DbFixture.cs | 3 +- .../IdentityShroud.Core.Tests.csproj | 1 - .../JwtSignatureGeneratorTests.cs | 3 +- .../Services/ClientServiceTests.cs | 62 +++++++-- .../Services/DataEncryptionServiceTests.cs | 24 ++-- .../Services/EncryptionTests.cs | 1 - .../Services/RealmServiceTests.cs | 39 +++--- IdentityShroud.Core.Tests/UnitTest1.cs | 11 +- .../Contracts/IDataEncryptionService.cs | 17 ++- IdentityShroud.Core/Contracts/IKeyService.cs | 8 +- .../Contracts/IRealmService.cs | 3 +- .../DTO/Client/ClientCreateRequest.cs | 16 +-- IdentityShroud.Core/DTO/JsonWebKey.cs | 3 +- IdentityShroud.Core/DTO/OpenId/GrantTypes.cs | 9 ++ .../DTO/OpenId/TokenResponse.cs | 19 +++ .../DTO/Realm/RealmCreateRequest.cs | 2 +- .../EFCore/Converters/DekIdConverter.cs | 10 +- .../Converters/DictionaryToJsonConverter.cs | 14 ++ .../Converters/JwtSigAlgNameConverter.cs | 5 + .../EFCore/Converters/KekIdConverter.cs | 2 +- .../EFCore/Converters/KeyTypeConverter.cs | 6 + .../Converters/RealmSigningKeyIdConverter.cs | 13 ++ IdentityShroud.Core/EFCore/Db.cs | 10 +- .../IdentityShroud.Core.csproj | 2 +- IdentityShroud.Core/Model/Client.cs | 10 +- IdentityShroud.Core/Model/ClientSecret.cs | 4 +- IdentityShroud.Core/Model/Realm.cs | 14 +- IdentityShroud.Core/Model/RealmDek.cs | 9 +- .../Model/{RealmKey.cs => RealmSigningKey.cs} | 19 +-- .../Model/RealmSigningKeyId.cs | 24 ++++ IdentityShroud.Core/Security/DekId.cs | 4 +- IdentityShroud.Core/Security/EncryptedDek.cs | 2 - .../Security/EncryptedValue.cs | 2 - IdentityShroud.Core/Security/Encryption.cs | 2 +- .../Security/JsonWebAlgorithm.cs | 6 - .../Security/Jwt/IJwtSignatureProvider.cs | 26 ++++ .../Security/Jwt/JwtSigAlgName.cs | 23 ++++ .../Security/Jwt/JwtSignatureGenerator.cs | 100 ++++++++++++++ .../Security/Jwt/RsaJwtSignatureProvider.cs | 88 +++++++++++++ .../Security/JwtSignatureGenerator.cs | 38 ------ .../Security/Keys/Aes/AesKeyPolicy.cs | 10 ++ .../Security/Keys/Aes/AesProvider.cs | 19 +++ .../Security/Keys/IKeyProvider.cs | 23 +++- .../Security/Keys/IKeyProviderFactory.cs | 2 +- .../Security/Keys/KeyProviderFactory.cs | 7 +- IdentityShroud.Core/Security/Keys/KeyType.cs | 21 +++ .../Security/Keys/Rsa/RsaKeyPolicy.cs | 10 ++ .../Security/Keys/Rsa/RsaProvider.cs | 29 ++--- IdentityShroud.Core/Services/ClientService.cs | 27 +++- .../Services/DataEncryptionService.cs | 38 ++---- IdentityShroud.Core/Services/KeyService.cs | 38 +----- .../Services/OpenId/TokenService.cs | 30 +++++ IdentityShroud.Core/Services/RealmContext.cs | 4 +- IdentityShroud.Core/Services/RealmService.cs | 53 ++++++-- .../DesignTimeDbFactory.cs | 2 +- .../Asserts/JsonObjectAssert.cs | 2 + .../Substitutes/NullDataEncryptionService.cs | 5 +- IdentityShroud.sln | 6 +- IdentityShroud.sln.DotSettings.user | 50 ++++++- 80 files changed, 1331 insertions(+), 414 deletions(-) create mode 100644 IdentityShroud.Api.Tests/Apis/OpenIdApiTests.cs create mode 100644 IdentityShroud.Api.Tests/HeaderHelpersTests.cs delete mode 100644 IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs create mode 100644 IdentityShroud.Api/Apis/Dto/ErrorDto.cs create mode 100644 IdentityShroud.Api/Apis/Dto/RealmRepresentation.cs create mode 100644 IdentityShroud.Api/Apis/Dto/TokenRequestBody.cs rename IdentityShroud.Api/Apis/{Validation => Filters}/ValidateFilter.cs (100%) create mode 100644 IdentityShroud.Api/Apis/Helpers/HeaderHelpers.cs create mode 100644 IdentityShroud.Api/Apis/ISResults/ISUnauthorizedHttpResult.cs create mode 100644 IdentityShroud.Api/GlobalExceptionHandler.cs create mode 100644 IdentityShroud.Core/DTO/OpenId/GrantTypes.cs create mode 100644 IdentityShroud.Core/DTO/OpenId/TokenResponse.cs create mode 100644 IdentityShroud.Core/EFCore/Converters/DictionaryToJsonConverter.cs create mode 100644 IdentityShroud.Core/EFCore/Converters/JwtSigAlgNameConverter.cs create mode 100644 IdentityShroud.Core/EFCore/Converters/KeyTypeConverter.cs create mode 100644 IdentityShroud.Core/EFCore/Converters/RealmSigningKeyIdConverter.cs rename IdentityShroud.Core/Model/{RealmKey.cs => RealmSigningKey.cs} (66%) create mode 100644 IdentityShroud.Core/Model/RealmSigningKeyId.cs delete mode 100644 IdentityShroud.Core/Security/JsonWebAlgorithm.cs create mode 100644 IdentityShroud.Core/Security/Jwt/IJwtSignatureProvider.cs create mode 100644 IdentityShroud.Core/Security/Jwt/JwtSigAlgName.cs create mode 100644 IdentityShroud.Core/Security/Jwt/JwtSignatureGenerator.cs create mode 100644 IdentityShroud.Core/Security/Jwt/RsaJwtSignatureProvider.cs delete mode 100644 IdentityShroud.Core/Security/JwtSignatureGenerator.cs create mode 100644 IdentityShroud.Core/Security/Keys/Aes/AesKeyPolicy.cs create mode 100644 IdentityShroud.Core/Security/Keys/Aes/AesProvider.cs create mode 100644 IdentityShroud.Core/Security/Keys/KeyType.cs create mode 100644 IdentityShroud.Core/Security/Keys/Rsa/RsaKeyPolicy.cs create mode 100644 IdentityShroud.Core/Services/OpenId/TokenService.cs diff --git a/IdentityShroud.Api.Tests/Apis/ClientApiTests.cs b/IdentityShroud.Api.Tests/Apis/ClientApiTests.cs index db984f1..7133bd7 100644 --- a/IdentityShroud.Api.Tests/Apis/ClientApiTests.cs +++ b/IdentityShroud.Api.Tests/Apis/ClientApiTests.cs @@ -1,16 +1,26 @@ using System.Net; using System.Net.Http.Json; -using IdentityShroud.Core; +using System.Text; +using System.Text.Json; +using FluentResults; +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.EFCore; using IdentityShroud.Core.Model; +using IdentityShroud.Core.Tests; using IdentityShroud.Core.Tests.Fixtures; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Shouldly; namespace IdentityShroud.Api.Tests.Apis; public class ClientApiTests : IClassFixture { + private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web) + { + TypeInfoResolver = AppJsonSerializerContext.Default, + }; private readonly ApplicationFactory _factory; public ClientApiTests(ApplicationFactory factory) @@ -32,7 +42,7 @@ public class ClientApiTests : IClassFixture public async Task Create_Validation(string? clientId, bool succeeds, string fieldName) { // setup - Realm realm = await CreateRealmAsync("test-realm", "Test Realm"); + var realm = await CreateRealmAsync("test-realm", "Test Realm"); var client = _factory.CreateClient(); @@ -64,32 +74,56 @@ public class ClientApiTests : IClassFixture [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 + var body = await DoCreateRequest(""" + { + "clientId": "new-client", + "name": "New Client" + } + """); // verify - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - - var body = await response.Content.ReadFromJsonAsync( - TestContext.Current.CancellationToken); - Assert.NotNull(body); Assert.Equal("new-client", body.ClientId); Assert.True(body.Id > 0); } + [Fact] + public async Task Create_Success_CreatesSecret() + { + // act + var body = await DoCreateRequest(""" + { + "clientId": "new-client", + "name": "New Client", + "confidential": true, + "generateSecret": true + } + """); + + // verify + body.ShouldNotBeNull(); + body.Secret.ShouldNotBeNullOrWhiteSpace(); + } + + private async Task DoCreateRequest( + string request) + { + var realm = await CreateRealmAsync("create-realm", "Create Realm"); + + var client = _factory.CreateClient(); + var response = await client.PostAsync( + $"/api/v1/realms/{realm.Id}/clients", + //JsonContent.Create(request), + new StringContent(request, Encoding.UTF8, "application/json"), + TestContext.Current.CancellationToken); + + string contents = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.True(HttpStatusCode.Created == response.StatusCode, contents); + + return JsonSerializer.Deserialize(contents, _jsonOptions); + } + [Fact] public async Task Create_UnknownRealm_ReturnsNotFound() { @@ -107,7 +141,7 @@ public class ClientApiTests : IClassFixture public async Task Get_Success() { // setup - Realm realm = await CreateRealmAsync("get-realm", "Get Realm"); + var realm = await CreateRealmAsync("get-realm", "Get Realm"); Client dbClient = await CreateClientAsync(realm, "get-client", "Get Client"); var httpClient = _factory.CreateClient(); @@ -138,7 +172,7 @@ public class ClientApiTests : IClassFixture public async Task Get_UnknownClient_ReturnsNotFound() { // setup - Realm realm = await CreateRealmAsync("notfound-realm", "NotFound Realm"); + var realm = await CreateRealmAsync("notfound-realm", "NotFound Realm"); var httpClient = _factory.CreateClient(); @@ -154,11 +188,11 @@ public class ClientApiTests : IClassFixture private async Task CreateRealmAsync(string slug, string name) { using var scope = _factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var realm = new Realm { Slug = slug, Name = name }; - db.Realms.Add(realm); - await db.SaveChangesAsync(TestContext.Current.CancellationToken); - return realm; + var realmService = scope.ServiceProvider.GetRequiredService(); + Result result = await realmService.Create( + new(null, slug, name), + TestContext.Current.CancellationToken); + return ResultAssert.Success(result); } private async Task CreateClientAsync(Realm realm, string clientId, string? name = null) diff --git a/IdentityShroud.Api.Tests/Apis/OpenIdApiTests.cs b/IdentityShroud.Api.Tests/Apis/OpenIdApiTests.cs new file mode 100644 index 0000000..93d241a --- /dev/null +++ b/IdentityShroud.Api.Tests/Apis/OpenIdApiTests.cs @@ -0,0 +1,123 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using IdentityShroud.Api.Apis; +using IdentityShroud.Core.EFCore; +using IdentityShroud.Core.Tests.Fixtures; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; + +namespace IdentityShroud.Api.Tests.Apis; + +public class OpenIdApiTests : IClassFixture +{ + private readonly ApplicationFactory _factory; + + public OpenIdApiTests(ApplicationFactory factory) + { + _factory = factory; + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + if (!db.Database.EnsureCreated()) + { + db.Database.ExecuteSqlRaw("TRUNCATE realm CASCADE;"); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ClientCredentialsFlow(bool useAuthenticationHeader) + { + var client = _factory.CreateClient(); + + var createRealmResponse = await client.PostAsync("/api/v1/realms", JsonContent.Create(new + { + Slug = "foo", + Name = "Test'", + }), + TestContext.Current.CancellationToken); + + createRealmResponse.StatusCode.ShouldBe(HttpStatusCode.Created); + + var realm = await createRealmResponse.Content.ReadFromJsonAsync( + cancellationToken: TestContext.Current.CancellationToken); + realm.ShouldNotBeNull(); + realm.Id.ShouldNotBe(Guid.Empty); + + var createClientResponse = await client.PostAsync( + $"/api/v1/realms/{realm.Id}/clients", + JsonContent.Create(new + { + ClientId = "myclient", + Name = "New Client", + Confidential = true, + AllowClientCredentialsFlow = true, + GenerateSecret = true, + }), + TestContext.Current.CancellationToken); + + createClientResponse.StatusCode.ShouldBe(HttpStatusCode.Created); + + // Act + const string clientId = "myclient"; + + var data = new[] + { + new KeyValuePair("client_id", clientId), + new KeyValuePair("client_secret", "secret"), + new KeyValuePair("response_type", "token"), + new KeyValuePair("grant_type", "client_credentials"), + }; + + if (useAuthenticationHeader) + { + // client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("basic", + // Convert.ToBase64String($"{clientId}:{clientSecret}")) + } + + var content = new FormUrlEncodedContent(data); + var response = await client.PostAsync( + "/auth/realms/foo/openid-connect/token", + content, + TestContext.Current.CancellationToken); + + // Verify + // var responseJson = await response.Content.ReadAsStringAsync( + // TestContext.Current.CancellationToken); + // Console.WriteLine($"Response: {responseJson}"); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + + // Cache-Control: no-store + response.Headers.CacheControl.ShouldNotBeNull() + .NoStore.ShouldBe(true); + // Pragma: no-cache + response.Headers.Pragma.ShouldNotBeNull() + .ShouldContain(new NameValueHeaderValue("no-cache")); + + var payload = await response.Content.ReadFromJsonAsync(); + payload.ShouldNotBeNull(); + Assert.Multiple( + () => payload.AccessToken.ShouldNotBeNull(), + () => payload.TokenType.ShouldBe("bearer"), + () => payload.ExpiresIn.ShouldBe(3600)); + + // - refresh_token OPTIONAL + // - scope OPTIONAL when identical to request otherwise REQUIRED + } + + internal class TokenResponse + { + [JsonPropertyName("access_token")] + public string? AccessToken { get; set; } + [JsonPropertyName("token_type")] + public string? TokenType { get; set; } + [JsonPropertyName("expires_in")] + public int? ExpiresIn { get; set; } + } + +} \ No newline at end of file diff --git a/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs b/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs index ecc46c0..7d3d49e 100644 --- a/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs +++ b/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs @@ -1,14 +1,13 @@ +using System.Buffers.Text; using System.Net; using System.Net.Http.Json; using System.Security.Cryptography; using System.Text.Json.Nodes; -using IdentityShroud.Core; -using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.EFCore; using IdentityShroud.Core.Model; using IdentityShroud.Core.Tests.Fixtures; using IdentityShroud.TestUtils.Asserts; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.WebUtilities; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -124,28 +123,16 @@ public class RealmApisTests : IClassFixture [Fact] public async Task GetJwks() { - // setup - IDekEncryptionService dekEncryptionService = _factory.Services.GetRequiredService(); - - using var rsa = RSA.Create(2048); - RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false); - - RealmKey realmKey = new() - { - Id = Guid.NewGuid(), - KeyType = "RSA", - Key = dekEncryptionService.Encrypt(rsa.ExportPkcs8PrivateKey()), - CreatedAt = DateTime.UtcNow, - }; - - await ScopedContextAsync(async db => - { - db.Realms.Add(new Realm() { Slug = "foo", Name = "Foo", Keys = [ realmKey ]}); - await db.SaveChangesAsync(TestContext.Current.CancellationToken); - }); - - // act var client = _factory.CreateClient(); + var createResponse = await client.PostAsync("/api/v1/realms", JsonContent.Create(new + { + Slug = "foo", + Name = "Test'", + }), + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); + + // act var response = await client.GetAsync("/auth/realms/foo/openid-connect/jwks", TestContext.Current.CancellationToken); @@ -153,9 +140,16 @@ public class RealmApisTests : IClassFixture JsonObject? payload = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); Assert.NotNull(payload); - 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"); + string? kid = JsonObjectAssert.NavigateToPath(payload, "keys[0].kid")?.AsValue().ToString(); + Assert.NotNull(kid); + Assert.True(kid.Length >= 16); + + //if (JsonObjectAssert.NavigateToPath(payload, "keys[0].kty")?.AsValue().ToString() == "RSA") + + JsonObjectAssert.Equal("RSA", payload, "keys[0].kty"); + string? n = payload["keys"]?[0]?["n"]?.AsValue().ToString(); + string? e = payload["keys"]?[0]?["e"]?.AsValue().ToString(); + AssertRsaParams(n, e); } private async Task ScopedContextAsync( @@ -166,4 +160,22 @@ public class RealmApisTests : IClassFixture var db = scope.ServiceProvider.GetRequiredService(); await action(db); } + + private static void AssertRsaParams(string? n, string? e) + { + Assert.NotNull(n); + Assert.NotNull(e); + + var rsa = RSA.Create(); + rsa.ImportParameters(new RSAParameters + { + Modulus = Base64Url.DecodeFromChars(n), + Exponent = Base64Url.DecodeFromChars(e) + }); + + // If n and e are complete nonsense, this will throw + var encrypted = rsa.Encrypt(new byte[] { 1, 2, 3 }, RSAEncryptionPadding.OaepSHA256); + Assert.NotNull(encrypted); + Assert.NotEmpty(encrypted); + } } \ No newline at end of file diff --git a/IdentityShroud.Api.Tests/HeaderHelpersTests.cs b/IdentityShroud.Api.Tests/HeaderHelpersTests.cs new file mode 100644 index 0000000..c08303a --- /dev/null +++ b/IdentityShroud.Api.Tests/HeaderHelpersTests.cs @@ -0,0 +1,20 @@ +using IdentityShroud.Api.Helpers; + +namespace IdentityShroud.Api.Tests; + +public class HeaderHelpersTests +{ + [Theory] + [InlineData("Basic dXNlcjpzZWNyZXQ=", true, "user", "secret")] + [InlineData("baSIC dXNlcjpzZWNyZXQ=", true, "user", "secret")] + [InlineData("Basic dXNlcnNlY3JldA==", false, null, null)] // no colon to seperate user and password + [InlineData("Bearer dXNlcjpzZWNyZXQ=", false, null, null)] + public void TryDecodeBasicAuth(string input, bool expectedResult, string? expectedUser, string? expectedPassword) + { + var result = HeaderHelpers.TryDecodeBasicAuth(input, out string? user, out string? password); + + Assert.Equal(expectedResult, result); + Assert.Equal(expectedUser, user); + Assert.Equal(expectedPassword, password); + } +} \ No newline at end of file diff --git a/IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs b/IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs deleted file mode 100644 index f423f54..0000000 --- a/IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs +++ /dev/null @@ -1,46 +0,0 @@ -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)); - } -} diff --git a/IdentityShroud.Api/Apis/ClientApi.cs b/IdentityShroud.Api/Apis/ClientApi.cs index e595e34..05b4aa7 100644 --- a/IdentityShroud.Api/Apis/ClientApi.cs +++ b/IdentityShroud.Api/Apis/ClientApi.cs @@ -5,11 +5,7 @@ using IdentityShroud.Core.Model; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; -namespace IdentityShroud.Api; - - - -public record ClientCreateReponse(int Id, string ClientId); +namespace IdentityShroud.Api.Apis; /// /// The part of the api below realms/{slug}/clients @@ -20,12 +16,12 @@ public static class ClientApi public static void MapEndpoints(this IEndpointRouteBuilder erp) { - RouteGroupBuilder clientsGroup = erp.MapGroup("clients"); - + RouteGroupBuilder clientsGroup = erp.MapGroup("clients"); + clientsGroup.MapPost("", ClientCreate) - .Validate() - .WithName("ClientCreate") - .Produces(StatusCodes.Status201Created); + .Produces(StatusCodes.Status201Created) + .Validate() + .WithName("ClientCreate"); var clientIdGroup = clientsGroup.MapGroup("{clientId}") .AddEndpointFilter(); @@ -43,11 +39,12 @@ public static class ClientApi return TypedResults.Ok(new ClientMapper().ToDto(client)); } - private static async Task, InternalServerError>> + private static async Task, InternalServerError>> ClientCreate( Guid realmId, ClientCreateRequest request, [FromServices] IClientService service, + [FromServices] IDataEncryptionService cryptor, HttpContext context, CancellationToken cancellationToken) { @@ -60,9 +57,12 @@ public static class ClientApi } Client client = result.Value; - + ClientRepresentation clientRepresentation = new ClientMapper().ToDto(client); + var secret = SelectBestSecret(client.Secrets); + if (secret is {} s) + clientRepresentation.Secret = cryptor.DecryptUtf8ToString(realm.DataEncryptionKeys, s.Secret); return TypedResults.CreatedAtRoute( - new ClientCreateReponse(client.Id, client.ClientId), + clientRepresentation, ClientGetRouteName, new RouteValueDictionary() { @@ -70,4 +70,28 @@ public static class ClientApi ["clientId"] = client.Id, }); } + + private static ClientSecret? SelectBestSecret(List clientSecrets) + { + ClientSecret? result = null; + + foreach (var cs in clientSecrets) + { + if (cs.RevokedAt is null && (!cs.Expires.HasValue || cs.Expires.Value > DateTime.UtcNow)) + { + if (result is null) + { + result = cs; + } + else + { + int d = (cs.Expires ?? DateTime.MaxValue).CompareTo(result.Expires ?? DateTime.MaxValue); + if (d > 0 || (d == 0 && cs.CreatedAt > result.CreatedAt)) + result = cs; + } + } + } + + return result; + } } \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/Dto/ClientRepresentation.cs b/IdentityShroud.Api/Apis/Dto/ClientRepresentation.cs index 80b5f13..d5e2853 100644 --- a/IdentityShroud.Api/Apis/Dto/ClientRepresentation.cs +++ b/IdentityShroud.Api/Apis/Dto/ClientRepresentation.cs @@ -10,7 +10,10 @@ public record ClientRepresentation public string? SignatureAlgorithm { get; set; } + public bool Confidential { get; set; } public bool AllowClientCredentialsFlow { get; set; } = false; public required DateTime CreatedAt { get; set; } + + public string? Secret { get; set; } } \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/Dto/ErrorDto.cs b/IdentityShroud.Api/Apis/Dto/ErrorDto.cs new file mode 100644 index 0000000..655d4c4 --- /dev/null +++ b/IdentityShroud.Api/Apis/Dto/ErrorDto.cs @@ -0,0 +1,3 @@ +namespace IdentityShroud.Api.Apis; + +public record ErrorDto(string Error); diff --git a/IdentityShroud.Api/Apis/Dto/RealmRepresentation.cs b/IdentityShroud.Api/Apis/Dto/RealmRepresentation.cs new file mode 100644 index 0000000..29f6ca5 --- /dev/null +++ b/IdentityShroud.Api/Apis/Dto/RealmRepresentation.cs @@ -0,0 +1,6 @@ +namespace IdentityShroud.Api.Apis; + +public record RealmRepresentation( + Guid Id, + string Slug, + string Name); \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/Dto/TokenRequestBody.cs b/IdentityShroud.Api/Apis/Dto/TokenRequestBody.cs new file mode 100644 index 0000000..88672d6 --- /dev/null +++ b/IdentityShroud.Api/Apis/Dto/TokenRequestBody.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace IdentityShroud.Core.DTO.OpenId; + +public class TokenRequestBody +{ + [JsonPropertyName("grant_type")] + public GrantTypes GrantType { get; init; } + + /// + /// In most cases required but not when basic auth header is used + /// + [JsonPropertyName("client_id")] + public string? ClientId { get; init; } = ""; + + [JsonPropertyName("client_secret")] + public string? ClientSecret { get; init; } + + [JsonPropertyName("scope")] + public string? Scope { get; init; } +} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/EndpointRouteBuilderExtensions.cs b/IdentityShroud.Api/Apis/EndpointRouteBuilderExtensions.cs index 3c47b48..5e2590f 100644 --- a/IdentityShroud.Api/Apis/EndpointRouteBuilderExtensions.cs +++ b/IdentityShroud.Api/Apis/EndpointRouteBuilderExtensions.cs @@ -2,9 +2,10 @@ namespace IdentityShroud.Api; public static class EndpointRouteBuilderExtensions { - public static RouteHandlerBuilder Validate(this RouteHandlerBuilder builder) where TDto : class - => builder.AddEndpointFilter>(); - + public static IEndpointConventionBuilder Validate(this IEndpointConventionBuilder builder) + where TDto : class + => builder.AddEndpointFilter>(); + public static void MapApis(this IEndpointRouteBuilder erp) { RealmApi.MapRealmEndpoints(erp); diff --git a/IdentityShroud.Api/Apis/Validation/ValidateFilter.cs b/IdentityShroud.Api/Apis/Filters/ValidateFilter.cs similarity index 100% rename from IdentityShroud.Api/Apis/Validation/ValidateFilter.cs rename to IdentityShroud.Api/Apis/Filters/ValidateFilter.cs diff --git a/IdentityShroud.Api/Apis/Helpers/HeaderHelpers.cs b/IdentityShroud.Api/Apis/Helpers/HeaderHelpers.cs new file mode 100644 index 0000000..35f7f30 --- /dev/null +++ b/IdentityShroud.Api/Apis/Helpers/HeaderHelpers.cs @@ -0,0 +1,50 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text; +using Microsoft.Extensions.Primitives; + +namespace IdentityShroud.Api.Helpers; + +public static class HeaderHelpers +{ + public static bool TryGetBasicAuth( + HttpContext context, + [NotNullWhen(true)] out string? user, + [NotNullWhen(true)] out string? password) + { + var headers = context?.Request.Headers; + if (headers is not null) + { + if (headers.TryGetValue("Authorization", out StringValues s)) + return TryDecodeBasicAuth(s.ToString(), out user, out password); + } + + user = password = null; + return false; + } + + public static bool TryDecodeBasicAuth( + string authorizationHeader, + [NotNullWhen(true)] out string? user, + [NotNullWhen(true)] out string? password) + { + if (authorizationHeader.StartsWith("basic ", StringComparison.OrdinalIgnoreCase)) + { + ReadOnlySpan val = authorizationHeader.AsSpan(6); // basic + space + Span b = new byte[(val.Length * 6 / 8) + 1]; + if (Convert.TryFromBase64Chars(val, b, out int written)) + { + int sepIdx = b.IndexOf((byte)':'); + if (sepIdx > 0 && sepIdx < written - 1) + { + user = Encoding.UTF8.GetString(b.Slice(0, sepIdx)); + password = Encoding.UTF8.GetString(b.Slice(sepIdx + 1, written - (sepIdx + 1))); + return true; + } + } + } + + user = password = null; + return false; + } + +} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/ISResults/ISUnauthorizedHttpResult.cs b/IdentityShroud.Api/Apis/ISResults/ISUnauthorizedHttpResult.cs new file mode 100644 index 0000000..f7722cb --- /dev/null +++ b/IdentityShroud.Api/Apis/ISResults/ISUnauthorizedHttpResult.cs @@ -0,0 +1,38 @@ +namespace IdentityShroud.Api.Apis.ISResults; + +public class ISUnauthorizedHttpResult : IResult, IStatusCodeHttpResult +{ + private readonly List _wwwAuthenticateValues; + /// + /// Initializes a new instance of the class. + /// + internal ISUnauthorizedHttpResult(List wwwAuthenticateValues) + { + _wwwAuthenticateValues = wwwAuthenticateValues; + } + + /// + /// Gets the HTTP status code: + /// + public int StatusCode => StatusCodes.Status401Unauthorized; + + int? IStatusCodeHttpResult.StatusCode => StatusCode; + + /// + public Task ExecuteAsync(HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(httpContext); + + // Creating the logger with a string to preserve the category after the refactoring. + // var loggerFactory = httpContext.RequestServices.GetRequiredService(); + // var logger = loggerFactory.CreateLogger("IdentityShroud.Api.Results.ISUnauthorizedResult"); + // HttpResultsHelper.Log.WritingResultAsStatusCode(logger, StatusCode); + + + httpContext.Response.Headers.WWWAuthenticate = new(_wwwAuthenticateValues.ToArray()); + + httpContext.Response.StatusCode = StatusCode; + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs b/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs index 7155208..e37798b 100644 --- a/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs +++ b/IdentityShroud.Api/Apis/Mappers/KeyMapper.cs @@ -1,20 +1,28 @@ -using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Messages; using IdentityShroud.Core.Model; +using IdentityShroud.Core.Security.Keys; namespace IdentityShroud.Api.Mappers; -public class KeyMapper(IKeyService keyService) +public class KeyMapper(IKeyProviderFactory keyProviderFactory) { - public JsonWebKeySet KeyListToJsonWebKeySet(IEnumerable keys) + public JsonWebKeySet KeyListToJsonWebKeySet(IEnumerable keys) { JsonWebKeySet wks = new(); foreach (var k in keys) { - var wk = keyService.CreateJsonWebKey(k); - if (wk is {}) + IKeyProvider provider = keyProviderFactory.CreateProvider(k.KeyType); + if (provider.IsPublic) { - wks.Keys.Add(wk); + JsonWebKey jwk = new() + { + KeyId = k.Id.ToString(), + KeyType = k.KeyType, + Use = "sig", + }; + + provider.SetJwkParameters(k.PublicKeyParameters!, jwk); + wks.Keys.Add(jwk); } } return wks; diff --git a/IdentityShroud.Api/Apis/OpenIdEndpoints.cs b/IdentityShroud.Api/Apis/OpenIdEndpoints.cs index 6565413..053be93 100644 --- a/IdentityShroud.Api/Apis/OpenIdEndpoints.cs +++ b/IdentityShroud.Api/Apis/OpenIdEndpoints.cs @@ -1,7 +1,11 @@ +using IdentityShroud.Api.Apis; +using IdentityShroud.Api.Apis.ISResults; +using IdentityShroud.Api.Helpers; using IdentityShroud.Api.Mappers; using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Messages; using IdentityShroud.Core.Model; +using IdentityShroud.Core.Services.OpenId; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; @@ -11,8 +15,6 @@ 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"); @@ -56,17 +58,79 @@ public static class OpenIdEndpoints { Realm realm = context.GetValidatedRealm(); await realmService.LoadActiveKeys(realm); - return TypedResults.Ok(keyMapper.KeyListToJsonWebKeySet(realm.Keys)); + return TypedResults.Ok(keyMapper.KeyListToJsonWebKeySet(realm.TokenSigningKeys)); } - private static Task OpenIdConnectToken(HttpContext context) + private static async Task, + BadRequest, + ISUnauthorizedHttpResult + >> OpenIdConnectToken( + string realmSlug, + [FromServices] IClientService clientService, + HttpContext context, + CancellationToken ct) { - throw new NotImplementedException(); + IFormCollection form = await context.Request.ReadFormAsync(); + + string grantType = form["grant_type"].ToString(); + string clientId = form["client_id"].ToString(); + string scope = form["scope"].ToString(); + + if (grantType == "client_credentials") + { + string? clientSecret = null; + bool withAuthHeader = false; + if (HeaderHelpers.TryGetBasicAuth(context, out string? user, out string? password)) + { + withAuthHeader = true; + clientId = user; + clientSecret = password; + } + clientSecret ??= form["client_secret"].ToString(); + + if (string.IsNullOrEmpty(clientId) || + string.IsNullOrEmpty(clientSecret)) + { + return CreateBadRequest("invalid_request"); + } + + Realm realm = context.GetValidatedRealm(); + Client? client = await clientService.GetByClientId(realm.Id, clientId, ct); + if (client is null) + { + if (withAuthHeader) + { + return new ISUnauthorizedHttpResult([$"Basic realm=\"{realm.Slug}\""]); + } + return CreateBadRequest("invalid_client"); + } + + if (!client.AllowClientCredentialsFlow) + return CreateBadRequest("unauthorized_client"); + + } + else + return CreateBadRequest("unsupported_grant_type"); + + context.Response.Headers.CacheControl = "no-store"; + context.Response.Headers.Pragma = "no-cache"; + + return TypedResults.Ok(new TokenResponse() + { + AccessToken = "token", + TokenType = "bearer", + ExpiresIn = 3600, + }); } + private static BadRequest CreateBadRequest(string error) => + TypedResults.BadRequest(new ErrorDto(error)); + + + private static Task OpenIdConnectAuth(HttpContext context) { throw new NotImplementedException(); } - } \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/RealmApi.cs b/IdentityShroud.Api/Apis/RealmApi.cs index 88a5179..ed78cef 100644 --- a/IdentityShroud.Api/Apis/RealmApi.cs +++ b/IdentityShroud.Api/Apis/RealmApi.cs @@ -1,7 +1,7 @@ +using IdentityShroud.Api.Apis; using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Messages.Realm; using IdentityShroud.Core.Model; -using IdentityShroud.Core.Services; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; @@ -19,31 +19,56 @@ public static class HttpContextExtensions public static class RealmApi { + public const string GetRealmRoute = "Get Realm"; + public const string CreateRealmRoute = "Create Realm"; + public static void MapRealmEndpoints(IEndpointRouteBuilder erp) { var realmsGroup = erp.MapGroup("/api/v1/realms"); + realmsGroup.MapPost("", RealmCreate) - .Validate() - .WithName("Create Realm") - .Produces(StatusCodes.Status201Created); + .Produces(StatusCodes.Status201Created) + .Validate() + .WithName(CreateRealmRoute); + var realmIdGroup = realmsGroup.MapGroup("{realmId}") .AddEndpointFilter(); - ClientApi.MapEndpoints(realmIdGroup); - - + realmIdGroup.MapGet("", RealmGet) + .WithName(GetRealmRoute); + ClientApi.MapEndpoints(realmIdGroup); } - - private static async Task, InternalServerError>> + + private static Ok RealmGet( + Guid realmId, + HttpContext context) + { + Realm realm = context.GetValidatedRealm(); + return TypedResults.Ok(MapToRepresentation(realm)); + } + + private static async Task, InternalServerError>> RealmCreate(RealmCreateRequest request, [FromServices] IRealmService service) { var response = await service.Create(request); if (response.IsSuccess) - return TypedResults.Created($"/realms/{response.Value.Slug}", response.Value); - + { + var realm = response.Value; + return TypedResults.CreatedAtRoute( + MapToRepresentation(realm), + GetRealmRoute, + new { realmId = realm.Id }); + } + // TODO make helper to convert failure response to a proper HTTP result. return TypedResults.InternalServerError(); } -} \ No newline at end of file + + private static RealmRepresentation MapToRepresentation(Realm realm) + => new(realm.Id, realm.Slug, realm.Name); +} + + + diff --git a/IdentityShroud.Api/Apis/Validation/ClientCreateRequestValidator.cs b/IdentityShroud.Api/Apis/Validation/ClientCreateRequestValidator.cs index 7666b36..aef7c47 100644 --- a/IdentityShroud.Api/Apis/Validation/ClientCreateRequestValidator.cs +++ b/IdentityShroud.Api/Apis/Validation/ClientCreateRequestValidator.cs @@ -6,9 +6,9 @@ namespace IdentityShroud.Api; public class ClientCreateRequestValidator : AbstractValidator { // most of standard ascii minus the control characters and space - private const string ClientIdPattern = "^[\x21-\x7E]+"; + private const string ClientIdPattern = "^[a-zA-Z0-9_-]+"; - private string[] AllowedAlgorithms = [ "RS256", "ES256" ]; + private readonly string[] _allowedAlgorithms = [ "RS256", "ES256" ]; public ClientCreateRequestValidator() { @@ -16,7 +16,9 @@ public class ClientCreateRequestValidator : AbstractValidator 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"); + .Must(v => v is null || _allowedAlgorithms.Contains(v)) + .WithMessage($"SignatureAlgorithm must be one of {string.Join(", ", _allowedAlgorithms)} or null"); + RuleFor(e => e.AllowClientCredentialsFlow).Must(v => v is not true).When(e => e.Confidential is not true); + RuleFor(e => e.GenerateSecret).Must(v => v is not true).When(e => e.Confidential is not true); } } \ No newline at end of file diff --git a/IdentityShroud.Api/AppJsonSerializerContext.cs b/IdentityShroud.Api/AppJsonSerializerContext.cs index e7d90da..5733ac3 100644 --- a/IdentityShroud.Api/AppJsonSerializerContext.cs +++ b/IdentityShroud.Api/AppJsonSerializerContext.cs @@ -1,9 +1,14 @@ using System.Text.Json.Serialization; +using IdentityShroud.Api.Apis; using IdentityShroud.Core.Messages; using IdentityShroud.Core.Messages.Realm; +namespace IdentityShroud.Api; + +[JsonSerializable(typeof(ClientRepresentation))] +[JsonSerializable(typeof(ErrorDto))] [JsonSerializable(typeof(OpenIdConfiguration))] [JsonSerializable(typeof(RealmCreateRequest))] -internal partial class AppJsonSerializerContext : JsonSerializerContext +public partial class AppJsonSerializerContext : JsonSerializerContext { } \ No newline at end of file diff --git a/IdentityShroud.Api/GlobalExceptionHandler.cs b/IdentityShroud.Api/GlobalExceptionHandler.cs new file mode 100644 index 0000000..7729674 --- /dev/null +++ b/IdentityShroud.Api/GlobalExceptionHandler.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Diagnostics; + +namespace IdentityShroud.Api; + +public class GlobalExceptionHandler : IExceptionHandler +{ + private readonly ILogger _logger; + + public GlobalExceptionHandler(ILogger logger) + => _logger = logger; + + public async ValueTask TryHandleAsync( + HttpContext httpContext, + Exception exception, + CancellationToken cancellationToken) + { + _logger.LogError(exception, "Exception type: {Type}, Message: {Message}", + exception.GetType().Name, exception.Message); + + // Return false to let other handlers or the default handle it + // Return true to mark it as handled + return false; + } +} \ No newline at end of file diff --git a/IdentityShroud.Api/Program.cs b/IdentityShroud.Api/Program.cs index 29f6736..b2a31e8 100644 --- a/IdentityShroud.Api/Program.cs +++ b/IdentityShroud.Api/Program.cs @@ -1,8 +1,8 @@ using FluentValidation; using IdentityShroud.Api; using IdentityShroud.Api.Mappers; -using IdentityShroud.Core; using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.EFCore; using IdentityShroud.Core.Security; using IdentityShroud.Core.Security.Keys; using IdentityShroud.Core.Services; @@ -30,7 +30,7 @@ void ConfigureBuilder(WebApplicationBuilder builder) //services.AddControllers(); services.ConfigureHttpJsonOptions(options => { - options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default); + options.SerializerOptions.TypeInfoResolverChain.Insert(0, IdentityShroud.Api.AppJsonSerializerContext.Default); }); // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi @@ -52,6 +52,9 @@ void ConfigureBuilder(WebApplicationBuilder builder) services.AddValidatorsFromAssemblyContaining(); services.AddHttpContextAccessor(); + services.AddExceptionHandler(); + services.AddProblemDetails(); + builder.Host.UseSerilog((context, services, configuration) => configuration .Enrich.FromLogContext() //.Enrich.With() @@ -60,6 +63,7 @@ void ConfigureBuilder(WebApplicationBuilder builder) void ConfigureApplication(WebApplication app) { + app.UseExceptionHandler(); if (app.Environment.IsDevelopment()) { app.MapOpenApi(); diff --git a/IdentityShroud.Core.Tests/Fixtures/DbFixture.cs b/IdentityShroud.Core.Tests/Fixtures/DbFixture.cs index 844d4ca..1df6559 100644 --- a/IdentityShroud.Core.Tests/Fixtures/DbFixture.cs +++ b/IdentityShroud.Core.Tests/Fixtures/DbFixture.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.Logging.Abstractions; +using IdentityShroud.Core.EFCore; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Npgsql; using Testcontainers.PostgreSql; diff --git a/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj b/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj index 8af08c1..1d98db0 100644 --- a/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj +++ b/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj @@ -9,7 +9,6 @@ - diff --git a/IdentityShroud.Core.Tests/JwtSignatureGeneratorTests.cs b/IdentityShroud.Core.Tests/JwtSignatureGeneratorTests.cs index bf4d0a6..4563ea4 100644 --- a/IdentityShroud.Core.Tests/JwtSignatureGeneratorTests.cs +++ b/IdentityShroud.Core.Tests/JwtSignatureGeneratorTests.cs @@ -48,8 +48,7 @@ public class JwtSignatureGeneratorTests } ] } - """; - + """; JsonWebKeySet keySet = JsonSerializer.Deserialize(keycloakKeySet)!; using RSA publicKey = LoadFromJwk(keySet.Keys[0]); diff --git a/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs b/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs index d0269e6..a0690c9 100644 --- a/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs +++ b/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs @@ -1,5 +1,9 @@ +using IdentityShroud.Api; using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.EFCore; using IdentityShroud.Core.Model; +using IdentityShroud.Core.Security; +using IdentityShroud.Core.Security.Keys; using IdentityShroud.Core.Services; using IdentityShroud.Core.Tests.Fixtures; using IdentityShroud.TestUtils.Substitutes; @@ -7,6 +11,29 @@ using Microsoft.EntityFrameworkCore; namespace IdentityShroud.Core.Tests.Services; +public static class RealmDekBuilder +{ + public static RealmDek DefaultActive() => + new() + { + Id = DekId.NewId(), + Active = true, + Algorithm = KeyType.AES, + KeyData = new EncryptedDek(KekId.NewId(), + [ + 0 + ]) + }; +} + +public static class ClientCreateRequestBuilder +{ + public static ClientCreateRequest Default() => new( + "test-client", + "Test Client", + "A test client"); +} + public class ClientServiceTests : IClassFixture { private readonly DbFixture _dbFixture; @@ -34,15 +61,28 @@ public class ClientServiceTests : IClassFixture { if (!db.Realms.Any(r => r.Id == _realmId)) { - db.Realms.Add(new() { Id = _realmId, Slug = "test-realm", Name = "Test Realm" }); + db.Realms.Add(new() + { + Id = _realmId, + Slug = "test-realm", + Name = "Test Realm", + DataEncryptionKeys = [ RealmDekBuilder.DefaultActive(), ], + }); + db.SaveChanges(); } } + private ClientService CreateSut(Db db) => new(db, + _dataEncryptionService, + new ClientCreateRequestValidator(), + _clock); + + [Theory] [InlineData(false)] [InlineData(true)] - public async Task Create(bool allowClientCredentialsFlow) + public async Task Create(bool withSecret) { // Setup DateTime now = DateTime.UtcNow; @@ -52,15 +92,13 @@ public class ClientServiceTests : IClassFixture await using (var db = _dbFixture.CreateDbContext()) { // Act - ClientService sut = new(db, _dataEncryptionService, _clock); + ClientService sut = CreateSut(db); var response = await sut.Create( _realmId, - new ClientCreateRequest + ClientCreateRequestBuilder.Default() with { - ClientId = "test-client", - Name = "Test Client", - Description = "A test client", - AllowClientCredentialsFlow = allowClientCredentialsFlow, + Confidential = withSecret, + GenerateSecret = withSecret, }, TestContext.Current.CancellationToken); @@ -70,7 +108,7 @@ public class ClientServiceTests : IClassFixture Assert.Equal("test-client", val.ClientId); Assert.Equal("Test Client", val.Name); Assert.Equal("A test client", val.Description); - Assert.Equal(allowClientCredentialsFlow, val.AllowClientCredentialsFlow); + Assert.Equal(withSecret, val.Confidential); Assert.Equal(now, val.CreatedAt); } @@ -80,7 +118,7 @@ public class ClientServiceTests : IClassFixture .Include(e => e.Secrets) .SingleAsync(e => e.Id == val.Id, TestContext.Current.CancellationToken); - if (allowClientCredentialsFlow) + if (withSecret) Assert.Single(dbRecord.Secrets); else Assert.Empty(dbRecord.Secrets); @@ -108,7 +146,7 @@ public class ClientServiceTests : IClassFixture await using var actContext = _dbFixture.CreateDbContext(); // Act - ClientService sut = new(actContext, _dataEncryptionService, _clock); + ClientService sut = CreateSut(actContext); Client? result = await sut.GetByClientId(_realmId, clientId, TestContext.Current.CancellationToken); // Verify @@ -143,7 +181,7 @@ public class ClientServiceTests : IClassFixture await using var actContext = _dbFixture.CreateDbContext(); // Act - ClientService sut = new(actContext, _dataEncryptionService, _clock); + ClientService sut = CreateSut(actContext); Client? result = await sut.FindById(_realmId, searchId, TestContext.Current.CancellationToken); // Verify diff --git a/IdentityShroud.Core.Tests/Services/DataEncryptionServiceTests.cs b/IdentityShroud.Core.Tests/Services/DataEncryptionServiceTests.cs index 4f88e48..a61e7e0 100644 --- a/IdentityShroud.Core.Tests/Services/DataEncryptionServiceTests.cs +++ b/IdentityShroud.Core.Tests/Services/DataEncryptionServiceTests.cs @@ -2,6 +2,7 @@ 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; @@ -9,23 +10,20 @@ namespace IdentityShroud.Core.Tests.Services; public class DataEncryptionServiceTests { - private readonly IRealmContext _realmContext = Substitute.For(); +// private readonly IRealmContext _realmContext = Substitute.For(); private readonly IDekEncryptionService _dekCryptor = new NullDekEncryptionService();// Substitute.For(); private readonly DekId _activeDekId = DekId.NewId(); private readonly DekId _secondDekId = DekId.NewId(); private DataEncryptionService CreateSut() - => new(_realmContext, _dekCryptor); + => new(_dekCryptor); [Fact] public void Encrypt_UsesActiveKey() { - _realmContext.GetDeks(Arg.Any()).Returns([ - CreateRealmDek(_secondDekId, false), - CreateRealmDek(_activeDekId, true), - ]); - - var cipher = CreateSut().Encrypt("Hello"u8); + var dek = CreateRealmDek(_activeDekId, true); + + var cipher = CreateSut().Encrypt(dek, "Hello"u8); Assert.Equal(_activeDekId, cipher.DekId); } @@ -34,20 +32,18 @@ public class DataEncryptionServiceTests public void Decrypt_UsesCorrectKey() { var first = CreateRealmDek(_activeDekId, true); - _realmContext.GetDeks(Arg.Any()).Returns([ first ]); var sut = CreateSut(); - var cipher = sut.Encrypt("Hello"u8); + var cipher = sut.Encrypt(first, "Hello"u8); // Deactivate original key first.Active = false; // Make new active var second = CreateRealmDek(_secondDekId, true); // Return both - _realmContext.GetDeks(Arg.Any()).Returns([ first, second ]); + RealmDek[] list = [ first, second ]; - - var decoded = sut.Decrypt(cipher); + var decoded = sut.Decrypt(list, cipher); Assert.Equal("Hello"u8, decoded); } @@ -57,7 +53,7 @@ public class DataEncryptionServiceTests { Id = id, Active = active, - Algorithm = "AES", + Algorithm = KeyType.AES, KeyData = new(KekId.NewId(), RandomNumberGenerator.GetBytes(32)), RealmId = default, }; diff --git a/IdentityShroud.Core.Tests/Services/EncryptionTests.cs b/IdentityShroud.Core.Tests/Services/EncryptionTests.cs index 2dfbb52..f040b84 100644 --- a/IdentityShroud.Core.Tests/Services/EncryptionTests.cs +++ b/IdentityShroud.Core.Tests/Services/EncryptionTests.cs @@ -1,5 +1,4 @@ using IdentityShroud.Core.Security; -using IdentityShroud.Core.Services; namespace IdentityShroud.Core.Tests.Services; diff --git a/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs b/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs index fda233e..70d6d11 100644 --- a/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs +++ b/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs @@ -1,10 +1,13 @@ +using FluentResults; using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.EFCore; using IdentityShroud.Core.Model; -using IdentityShroud.Core.Security; using IdentityShroud.Core.Security.Keys; using IdentityShroud.Core.Services; using IdentityShroud.Core.Tests.Fixtures; +using IdentityShroud.TestUtils.Substitutes; using Microsoft.EntityFrameworkCore; +using Shouldly; namespace IdentityShroud.Core.Tests.Services; @@ -12,6 +15,7 @@ public class RealmServiceTests : IClassFixture { private readonly DbFixture _dbFixture; private readonly IKeyService _keyService = Substitute.For(); + private readonly IDekEncryptionService _dekCryptor = new NullDekEncryptionService(); public RealmServiceTests(DbFixture dbFixture) { @@ -25,6 +29,9 @@ public class RealmServiceTests : IClassFixture { db.Database.ExecuteSqlRaw("TRUNCATE realm CASCADE;"); } + + private RealmService CreateSut(Db db) => new(db, _keyService, _dekCryptor, new ClockService()); + [Theory] [InlineData(null)] @@ -36,20 +43,14 @@ public class RealmServiceTests : IClassFixture if (idString is not null) realmId = new(idString); - RealmCreateResponse? val; + Realm? val; await using (var db = _dbFixture.CreateDbContext()) { _keyService.CreateKey(Arg.Any()) - .Returns(new RealmKey() - { - Id = Guid.NewGuid(), - KeyType = "TST", - Key = new(KekId.NewId(), [21]), - CreatedAt = DateTime.UtcNow - }); + .Returns(new CreateKeyResponse(KeyType.AES, new KeyData([21]))); // Act - RealmService sut = new(db, _keyService); - var response = await sut.Create( + RealmService sut = CreateSut(db); + Result response = await sut.Create( new(realmId, "slug", "New realm"), TestContext.Current.CancellationToken); @@ -60,8 +61,12 @@ public class RealmServiceTests : IClassFixture else Assert.NotEqual(Guid.Empty, val.Id); - Assert.Equal("slug", val.Slug); - Assert.Equal("New realm", val.Name); + Assert.Multiple( + () => val.Slug.ShouldBe("slug"), + () => val.Name.ShouldBe("New realm"), + () => val.DataEncryptionKeys.ShouldContain(d => d.Active), + () => val.TokenSigningKeys.ShouldContain(d => !d.RevokedAt.HasValue) + ); _keyService.Received().CreateKey(Arg.Any()); } @@ -69,9 +74,9 @@ public class RealmServiceTests : IClassFixture await using (var db = _dbFixture.CreateDbContext()) { var dbRecord = await db.Realms - .Include(e => e.Keys) + .Include(e => e.TokenSigningKeys) .SingleAsync(e => e.Id == val.Id, TestContext.Current.CancellationToken); - Assert.Equal("TST", dbRecord.Keys[0].KeyType); + Assert.Equal(KeyType.AES, dbRecord.TokenSigningKeys[0].KeyType); } } @@ -98,7 +103,7 @@ public class RealmServiceTests : IClassFixture await using var actContext = _dbFixture.CreateDbContext(); // Act - RealmService sut = new(actContext, _keyService); + RealmService sut = CreateSut(actContext); var result = await sut.FindBySlug(slug, TestContext.Current.CancellationToken); // Verify @@ -131,7 +136,7 @@ public class RealmServiceTests : IClassFixture await using var actContext = _dbFixture.CreateDbContext(); // Act - RealmService sut = new(actContext, _keyService); + RealmService sut = CreateSut(actContext); Realm? result = await sut.FindById(id, TestContext.Current.CancellationToken); // Verify diff --git a/IdentityShroud.Core.Tests/UnitTest1.cs b/IdentityShroud.Core.Tests/UnitTest1.cs index 7506fd0..7cfc961 100644 --- a/IdentityShroud.Core.Tests/UnitTest1.cs +++ b/IdentityShroud.Core.Tests/UnitTest1.cs @@ -1,8 +1,7 @@ -using System.Security.Cryptography; -using System.Text; +using System.Buffers.Text; +using System.Security.Cryptography; using System.Text.Json; using IdentityShroud.Core.DTO; -using Microsoft.AspNetCore.WebUtilities; namespace IdentityShroud.Core.Tests; @@ -74,10 +73,10 @@ public static class JwtReader return new JsonWebToken() { Header = JsonSerializer.Deserialize( - Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(jwt, 0, firstDot)))!, + Base64Url.DecodeFromChars(jwt.AsSpan().Slice(0, firstDot)))!, Payload = JsonSerializer.Deserialize( - Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(jwt, firstDot + 1, secondDot - (firstDot + 1))))!, - Signature = WebEncoders.Base64UrlDecode(jwt, secondDot + 1, jwt.Length - (secondDot + 1)) + Base64Url.DecodeFromChars(jwt.AsSpan().Slice(firstDot + 1, secondDot - (firstDot + 1))))!, + Signature = Base64Url.DecodeFromChars(jwt.AsSpan().Slice(secondDot + 1, jwt.Length - (secondDot + 1))), }; } } diff --git a/IdentityShroud.Core/Contracts/IDataEncryptionService.cs b/IdentityShroud.Core/Contracts/IDataEncryptionService.cs index 2810aaa..1a89862 100644 --- a/IdentityShroud.Core/Contracts/IDataEncryptionService.cs +++ b/IdentityShroud.Core/Contracts/IDataEncryptionService.cs @@ -1,9 +1,22 @@ +using System.Text; +using IdentityShroud.Core.Model; using IdentityShroud.Core.Security; namespace IdentityShroud.Core.Contracts; public interface IDataEncryptionService { - EncryptedValue Encrypt(ReadOnlySpan plain); - byte[] Decrypt(EncryptedValue input); + EncryptedValue Encrypt(RealmDek dek, ReadOnlySpan plain); + byte[] Decrypt(IReadOnlyList deks, EncryptedValue input); +} + +public static class DataEncryptionServiceExtensions +{ + public static string DecryptUtf8ToString( + this IDataEncryptionService des, + IReadOnlyList deks, + EncryptedValue input) + { + return Encoding.UTF8.GetString(des.Decrypt(deks, input)); + } } \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/IKeyService.cs b/IdentityShroud.Core/Contracts/IKeyService.cs index 4f6b5f7..08a5bf6 100644 --- a/IdentityShroud.Core/Contracts/IKeyService.cs +++ b/IdentityShroud.Core/Contracts/IKeyService.cs @@ -1,12 +1,10 @@ -using IdentityShroud.Core.Messages; -using IdentityShroud.Core.Model; using IdentityShroud.Core.Security.Keys; namespace IdentityShroud.Core.Contracts; +public record CreateKeyResponse(KeyType KeyType, KeyData Key); + public interface IKeyService { - RealmKey CreateKey(KeyPolicy policy); - - JsonWebKey? CreateJsonWebKey(RealmKey realmKey); + CreateKeyResponse CreateKey(KeyPolicy policy); } \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/IRealmService.cs b/IdentityShroud.Core/Contracts/IRealmService.cs index 4598b97..1724e6c 100644 --- a/IdentityShroud.Core/Contracts/IRealmService.cs +++ b/IdentityShroud.Core/Contracts/IRealmService.cs @@ -1,6 +1,5 @@ using IdentityShroud.Core.Messages.Realm; using IdentityShroud.Core.Model; -using IdentityShroud.Core.Services; namespace IdentityShroud.Core.Contracts; @@ -9,7 +8,7 @@ public interface IRealmService Task FindById(Guid id, CancellationToken ct = default); Task FindBySlug(string slug, CancellationToken ct = default); - Task> Create(RealmCreateRequest request, CancellationToken ct = default); + Task> Create(RealmCreateRequest request, CancellationToken ct = default); Task LoadActiveKeys(Realm realm); Task LoadDeks(Realm realm); } \ No newline at end of file diff --git a/IdentityShroud.Core/DTO/Client/ClientCreateRequest.cs b/IdentityShroud.Core/DTO/Client/ClientCreateRequest.cs index a162131..f1c3b40 100644 --- a/IdentityShroud.Core/DTO/Client/ClientCreateRequest.cs +++ b/IdentityShroud.Core/DTO/Client/ClientCreateRequest.cs @@ -1,10 +1,10 @@ namespace IdentityShroud.Core.Contracts; -public class ClientCreateRequest -{ - 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; } -} \ No newline at end of file +public record ClientCreateRequest( + string ClientId, + string? Name = null, + string? Description = null, + string? SignatureAlgorithm = null, + bool Confidential = false, + bool AllowClientCredentialsFlow = false, + bool GenerateSecret = false); \ No newline at end of file diff --git a/IdentityShroud.Core/DTO/JsonWebKey.cs b/IdentityShroud.Core/DTO/JsonWebKey.cs index 4f16955..afc9367 100644 --- a/IdentityShroud.Core/DTO/JsonWebKey.cs +++ b/IdentityShroud.Core/DTO/JsonWebKey.cs @@ -1,5 +1,6 @@ using System.Text.Json.Serialization; using IdentityShroud.Core.Helpers; +using IdentityShroud.Core.Security.Keys; namespace IdentityShroud.Core.Messages; @@ -9,7 +10,7 @@ namespace IdentityShroud.Core.Messages; public class JsonWebKey { [JsonPropertyName("kty")] - public string KeyType { get; set; } = "RSA"; + public required KeyType KeyType { get; set; } // Common values sig(nature) enc(ryption) [JsonPropertyName("use")] diff --git a/IdentityShroud.Core/DTO/OpenId/GrantTypes.cs b/IdentityShroud.Core/DTO/OpenId/GrantTypes.cs new file mode 100644 index 0000000..e764e24 --- /dev/null +++ b/IdentityShroud.Core/DTO/OpenId/GrantTypes.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace IdentityShroud.Core.DTO.OpenId; + +public enum GrantTypes +{ + [JsonStringEnumMemberName("client_credentials")] + ClientCredentials +} \ No newline at end of file diff --git a/IdentityShroud.Core/DTO/OpenId/TokenResponse.cs b/IdentityShroud.Core/DTO/OpenId/TokenResponse.cs new file mode 100644 index 0000000..23d9718 --- /dev/null +++ b/IdentityShroud.Core/DTO/OpenId/TokenResponse.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace IdentityShroud.Core.Services.OpenId; + +public class TokenResponse +{ + [JsonPropertyName("access_token")] + public required string AccessToken { get; set; } + + [JsonPropertyName("token_type")] + public required string TokenType { get; set; } + + [JsonPropertyName("expires_in")] + public int? ExpiresIn { get; set; } + + [JsonPropertyName("refresh_token")] + public string? RefreshToken { get; set; } + +} \ No newline at end of file diff --git a/IdentityShroud.Core/DTO/Realm/RealmCreateRequest.cs b/IdentityShroud.Core/DTO/Realm/RealmCreateRequest.cs index fab91aa..143c75b 100644 --- a/IdentityShroud.Core/DTO/Realm/RealmCreateRequest.cs +++ b/IdentityShroud.Core/DTO/Realm/RealmCreateRequest.cs @@ -1,3 +1,3 @@ namespace IdentityShroud.Core.Messages.Realm; -public record RealmCreateRequest(Guid? Id, string? Slug, string Name); \ No newline at end of file +public record RealmCreateRequest(Guid? Id = null, string? Slug = null, string? Name = null); \ No newline at end of file diff --git a/IdentityShroud.Core/EFCore/Converters/DekIdConverter.cs b/IdentityShroud.Core/EFCore/Converters/DekIdConverter.cs index 0a957ae..df12fc2 100644 --- a/IdentityShroud.Core/EFCore/Converters/DekIdConverter.cs +++ b/IdentityShroud.Core/EFCore/Converters/DekIdConverter.cs @@ -1,12 +1,6 @@ using IdentityShroud.Core.Security; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -namespace IdentityShroud.Core; +namespace IdentityShroud.Core.EFCore; -public class DekIdConverter : ValueConverter -{ - public DekIdConverter() - : base(id => id.Id, guid => new DekId(guid)) - { - } -} \ No newline at end of file +public class DekIdConverter() : ValueConverter(id => id.Id, guid => new DekId(guid)); \ No newline at end of file diff --git a/IdentityShroud.Core/EFCore/Converters/DictionaryToJsonConverter.cs b/IdentityShroud.Core/EFCore/Converters/DictionaryToJsonConverter.cs new file mode 100644 index 0000000..1236b67 --- /dev/null +++ b/IdentityShroud.Core/EFCore/Converters/DictionaryToJsonConverter.cs @@ -0,0 +1,14 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace IdentityShroud.Core.EFCore; + +public class DictionaryToJsonConverter : ValueConverter, string> + where TKey : notnull +{ + public DictionaryToJsonConverter() : base( + v => JsonSerializer.Serialize(v), + v => JsonSerializer.Deserialize>(v) ?? new()) + { + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/EFCore/Converters/JwtSigAlgNameConverter.cs b/IdentityShroud.Core/EFCore/Converters/JwtSigAlgNameConverter.cs new file mode 100644 index 0000000..d570d61 --- /dev/null +++ b/IdentityShroud.Core/EFCore/Converters/JwtSigAlgNameConverter.cs @@ -0,0 +1,5 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace IdentityShroud.Core.EFCore; + +public class JwtSigAlgNameConverter() : ValueConverter(j => j.ToString(), s => new(s)); \ No newline at end of file diff --git a/IdentityShroud.Core/EFCore/Converters/KekIdConverter.cs b/IdentityShroud.Core/EFCore/Converters/KekIdConverter.cs index 87c0de9..23f55fe 100644 --- a/IdentityShroud.Core/EFCore/Converters/KekIdConverter.cs +++ b/IdentityShroud.Core/EFCore/Converters/KekIdConverter.cs @@ -1,7 +1,7 @@ using IdentityShroud.Core.Security; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -namespace IdentityShroud.Core; +namespace IdentityShroud.Core.EFCore; public class KekIdConverter : ValueConverter { diff --git a/IdentityShroud.Core/EFCore/Converters/KeyTypeConverter.cs b/IdentityShroud.Core/EFCore/Converters/KeyTypeConverter.cs new file mode 100644 index 0000000..18c8574 --- /dev/null +++ b/IdentityShroud.Core/EFCore/Converters/KeyTypeConverter.cs @@ -0,0 +1,6 @@ +using IdentityShroud.Core.Security.Keys; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace IdentityShroud.Core.EFCore; + +public class KeyTypeConverter() : ValueConverter(id => id.ToString(), s => new(s)); \ No newline at end of file diff --git a/IdentityShroud.Core/EFCore/Converters/RealmSigningKeyIdConverter.cs b/IdentityShroud.Core/EFCore/Converters/RealmSigningKeyIdConverter.cs new file mode 100644 index 0000000..f36ff9a --- /dev/null +++ b/IdentityShroud.Core/EFCore/Converters/RealmSigningKeyIdConverter.cs @@ -0,0 +1,13 @@ +using IdentityShroud.Core.Model; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace IdentityShroud.Core.EFCore; + +public class RealmSigningKeyIdConverter : ValueConverter +{ + public RealmSigningKeyIdConverter() + : base(id => id.Id, guid => new RealmSigningKeyId(guid)) + { + } + +} \ No newline at end of file diff --git a/IdentityShroud.Core/EFCore/Db.cs b/IdentityShroud.Core/EFCore/Db.cs index 90ebea7..b2bc12e 100644 --- a/IdentityShroud.Core/EFCore/Db.cs +++ b/IdentityShroud.Core/EFCore/Db.cs @@ -1,11 +1,11 @@ -using System.Linq.Expressions; using IdentityShroud.Core.Model; using IdentityShroud.Core.Security; +using IdentityShroud.Core.Security.Keys; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -namespace IdentityShroud.Core; +namespace IdentityShroud.Core.EFCore; public class DbConfiguration { @@ -20,7 +20,7 @@ public class Db( { public virtual DbSet Clients { get; set; } public virtual DbSet Realms { get; set; } - public virtual DbSet Keys { get; set; } + public virtual DbSet Keys { get; set; } public virtual DbSet Deks { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) @@ -50,6 +50,10 @@ public class Db( base.ConfigureConventions(b); b.Properties().HaveConversion(); + b.Properties>().HaveConversion>(); + b.Properties().HaveConversion(); b.Properties().HaveConversion(); + b.Properties().HaveConversion(); + b.Properties().HaveConversion(); } } \ No newline at end of file diff --git a/IdentityShroud.Core/IdentityShroud.Core.csproj b/IdentityShroud.Core/IdentityShroud.Core.csproj index 9dd3e34..fb54802 100644 --- a/IdentityShroud.Core/IdentityShroud.Core.csproj +++ b/IdentityShroud.Core/IdentityShroud.Core.csproj @@ -10,12 +10,12 @@ - + diff --git a/IdentityShroud.Core/Model/Client.cs b/IdentityShroud.Core/Model/Client.cs index 5df6c1a..b7d9c60 100644 --- a/IdentityShroud.Core/Model/Client.cs +++ b/IdentityShroud.Core/Model/Client.cs @@ -19,8 +19,16 @@ public class Client public string? Description { get; set; } [MaxLength(20)] - public string? SignatureAlgorithm { get; set; } + public JwtSigAlgName? SignatureAlgorithm { get; set; } + /// + /// Enables confidential flows + /// + public bool Confidential { get; set; } + + /// + /// Enables the client credentials flow which required Confidential to be true too. + /// public bool AllowClientCredentialsFlow { get; set; } = false; public required DateTime CreatedAt { get; set; } diff --git a/IdentityShroud.Core/Model/ClientSecret.cs b/IdentityShroud.Core/Model/ClientSecret.cs index d17e24d..189039f 100644 --- a/IdentityShroud.Core/Model/ClientSecret.cs +++ b/IdentityShroud.Core/Model/ClientSecret.cs @@ -1,6 +1,5 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Security; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -14,8 +13,9 @@ public class ClientSecret public int Id { get; set; } public Guid ClientId { get; set; } public DateTime CreatedAt { get; set; } + public DateTime? Expires { get; set; } public DateTime? RevokedAt { get; set; } - public EncryptedValue? Secret { get; set; } + public required EncryptedValue Secret { get; set; } } public class ClientSecretConfiguration : IEntityTypeConfiguration diff --git a/IdentityShroud.Core/Model/Realm.cs b/IdentityShroud.Core/Model/Realm.cs index 8f6737a..97f08c7 100644 --- a/IdentityShroud.Core/Model/Realm.cs +++ b/IdentityShroud.Core/Model/Realm.cs @@ -1,8 +1,5 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using IdentityShroud.Core.Security; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace IdentityShroud.Core.Model; @@ -20,12 +17,17 @@ public class Realm public string Name { get; set; } = ""; public List Clients { get; init; } = []; - public List Keys { get; init; } = []; + + /// + /// Note multiple keys can be in use at the same time because different clients may be configured to use + /// a different keytype depending on their clients requirements/capabilities. + /// + public List TokenSigningKeys { get; init; } = []; - public List Deks { get; init; } = []; + public List DataEncryptionKeys { get; init; } = []; /// /// Can be overriden per client /// - public string DefaultSignatureAlgorithm { get; set; } = JsonWebAlgorithm.RS256; + public JwtSigAlgName DefaultSignatureAlgorithm { get; set; } = JwtSigAlgName.RS256; } \ No newline at end of file diff --git a/IdentityShroud.Core/Model/RealmDek.cs b/IdentityShroud.Core/Model/RealmDek.cs index ed78b14..92bc57b 100644 --- a/IdentityShroud.Core/Model/RealmDek.cs +++ b/IdentityShroud.Core/Model/RealmDek.cs @@ -1,16 +1,18 @@ using IdentityShroud.Core.Security; +using IdentityShroud.Core.Security.Keys; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace IdentityShroud.Core.Model; + public record RealmDek { public required DekId Id { get; init; } public required bool Active { get; set; } - public required string Algorithm { get; init; } + public required KeyType Algorithm { get; init; } public required EncryptedDek KeyData { get; init; } - public required Guid RealmId { get; init; } + public Guid RealmId { get; init; } } public class RealmDekConfiguration : IEntityTypeConfiguration @@ -21,4 +23,5 @@ public class RealmDekConfiguration : IEntityTypeConfiguration b.HasKey(e => e.Id); b.ComplexProperty(e => e.KeyData, e => e.IsRequired()); } -} \ No newline at end of file +} + diff --git a/IdentityShroud.Core/Model/RealmKey.cs b/IdentityShroud.Core/Model/RealmSigningKey.cs similarity index 66% rename from IdentityShroud.Core/Model/RealmKey.cs rename to IdentityShroud.Core/Model/RealmSigningKey.cs index 4613be8..25b37a2 100644 --- a/IdentityShroud.Core/Model/RealmKey.cs +++ b/IdentityShroud.Core/Model/RealmSigningKey.cs @@ -1,33 +1,34 @@ -using System.ComponentModel.DataAnnotations.Schema; -using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Security; +using IdentityShroud.Core.Security.Keys; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace IdentityShroud.Core.Model; -public record RealmKey +public record RealmSigningKey { - public required Guid Id { get; init; } - public required string KeyType { get; init; } + public required RealmSigningKeyId Id { get; init; } + public required KeyType KeyType { get; init; } public required EncryptedDek Key { get; init; } public required DateTime CreatedAt { get; init; } public DateTime? RevokedAt { get; set; } - /// /// Key with highest priority will be used. While there is not really a use case for this I know some users /// are more comfortable replacing keys by using priority then directly deactivating the old key. /// public int Priority { get; set; } = 10; + + public Dictionary? PublicKeyParameters { get; set; } } -public class RealmKeyConfiguration : IEntityTypeConfiguration +public class RealmKeyConfiguration : IEntityTypeConfiguration { - public void Configure(EntityTypeBuilder b) + public void Configure(EntityTypeBuilder b) { b.ToTable("realm_key"); b.HasKey(e => e.Id); b.ComplexProperty(e => e.Key, e => e.IsRequired()); + b.Property(e => e.PublicKeyParameters).HasColumnType("jsonb"); } -} \ No newline at end of file +} diff --git a/IdentityShroud.Core/Model/RealmSigningKeyId.cs b/IdentityShroud.Core/Model/RealmSigningKeyId.cs new file mode 100644 index 0000000..085b9ff --- /dev/null +++ b/IdentityShroud.Core/Model/RealmSigningKeyId.cs @@ -0,0 +1,24 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace IdentityShroud.Core.Model; + +[JsonConverter(typeof(RealmSigningKeyIdJsonConverter))] +public readonly record struct RealmSigningKeyId(Guid Id) +{ + public override string ToString() => Id.ToString("N"); + + public static RealmSigningKeyId NewId() + { + return new(Guid.NewGuid()); + } +} + +public class RealmSigningKeyIdJsonConverter : JsonConverter +{ + public override RealmSigningKeyId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => new (reader.GetGuid()); + + public override void Write(Utf8JsonWriter writer, RealmSigningKeyId value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToString()); +} diff --git a/IdentityShroud.Core/Security/DekId.cs b/IdentityShroud.Core/Security/DekId.cs index 276178e..d68a985 100644 --- a/IdentityShroud.Core/Security/DekId.cs +++ b/IdentityShroud.Core/Security/DekId.cs @@ -1,6 +1,8 @@ namespace IdentityShroud.Core.Security; -public record struct DekId(Guid Id) +public readonly record struct DekId(Guid Id) { public static DekId NewId() => new(Guid.NewGuid()); + + public override string ToString() => Id.ToString("N"); } \ No newline at end of file diff --git a/IdentityShroud.Core/Security/EncryptedDek.cs b/IdentityShroud.Core/Security/EncryptedDek.cs index 002c1c9..2e44afe 100644 --- a/IdentityShroud.Core/Security/EncryptedDek.cs +++ b/IdentityShroud.Core/Security/EncryptedDek.cs @@ -1,5 +1,3 @@ -using Microsoft.EntityFrameworkCore; - namespace IdentityShroud.Core.Security; public record EncryptedDek(KekId KekId, byte[] Value); \ No newline at end of file diff --git a/IdentityShroud.Core/Security/EncryptedValue.cs b/IdentityShroud.Core/Security/EncryptedValue.cs index 37619fa..03dad86 100644 --- a/IdentityShroud.Core/Security/EncryptedValue.cs +++ b/IdentityShroud.Core/Security/EncryptedValue.cs @@ -1,5 +1,3 @@ -using Microsoft.EntityFrameworkCore; - namespace IdentityShroud.Core.Security; public record EncryptedValue(DekId DekId, byte[] Value); diff --git a/IdentityShroud.Core/Security/Encryption.cs b/IdentityShroud.Core/Security/Encryption.cs index 47344c1..5fe274e 100644 --- a/IdentityShroud.Core/Security/Encryption.cs +++ b/IdentityShroud.Core/Security/Encryption.cs @@ -4,7 +4,7 @@ namespace IdentityShroud.Core.Security; public static class Encryption { - private record struct AlgVersion(int Version, int NonceSize, int TagSize); + private readonly record struct AlgVersion(int Version, int NonceSize, int TagSize); private static AlgVersion[] _versions = [ diff --git a/IdentityShroud.Core/Security/JsonWebAlgorithm.cs b/IdentityShroud.Core/Security/JsonWebAlgorithm.cs deleted file mode 100644 index dc9bc28..0000000 --- a/IdentityShroud.Core/Security/JsonWebAlgorithm.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace IdentityShroud.Core.Security; - -public static class JsonWebAlgorithm -{ - public const string RS256 = "RS256"; -} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Jwt/IJwtSignatureProvider.cs b/IdentityShroud.Core/Security/Jwt/IJwtSignatureProvider.cs new file mode 100644 index 0000000..5100436 --- /dev/null +++ b/IdentityShroud.Core/Security/Jwt/IJwtSignatureProvider.cs @@ -0,0 +1,26 @@ +using System.Text.Json; + +namespace IdentityShroud.Core; + +public interface IJwtSignatureProvider : IDisposable +{ + /* + Of the signature and MAC algorithms specified in JSON Web Algorithms + [JWA], only HMAC SHA-256 ("HS256") and "none" MUST be implemented by + conforming JWT implementations. It is RECOMMENDED that + implementations also support RSASSA-PKCS1-v1_5 with the SHA-256 hash + algorithm ("RS256") and ECDSA using the P-256 curve and the SHA-256 + hash algorithm ("ES256"). Support for other algorithms and key sizes + is OPTIONAL. + */ + + void WriteJwtHeaderFields(Utf8JsonWriter writer); + /// + /// Length of the binary signature in bytes. + /// + /// + int GetSignatureLength(); + + void CalculateSignature(ReadOnlySpan jwt, Span signatureOut); + +} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Jwt/JwtSigAlgName.cs b/IdentityShroud.Core/Security/Jwt/JwtSigAlgName.cs new file mode 100644 index 0000000..7e59dbb --- /dev/null +++ b/IdentityShroud.Core/Security/Jwt/JwtSigAlgName.cs @@ -0,0 +1,23 @@ +using System.Diagnostics.CodeAnalysis; + +namespace IdentityShroud.Core; + +[SuppressMessage("ReSharper", "InconsistentNaming")] +public readonly record struct JwtSigAlgName(string Name) : IEquatable +{ + // HMAC using SHA-??? + public static JwtSigAlgName HS256 => new("HS256"); // REQUIRED + public static JwtSigAlgName HS384 => new("HS384"); + public static JwtSigAlgName HS512 => new("HS512"); + + // RSASSA-PKCS1-v1_5 using SHA-??? + public static JwtSigAlgName RS256 => new("RS256"); + public static JwtSigAlgName RS384 => new("RS384"); + public static JwtSigAlgName RS512 => new("RS512"); + + public static JwtSigAlgName ES256 => new("ES256"); // ECDSA using P-256 and SHA-256 + public static JwtSigAlgName ES384 => new("ES384"); // ECDSA using P-384 and SHA-384 + public static JwtSigAlgName ES512 => new("ES512"); // ECDSA using P-521 and SHA-512 + + public override string ToString() => Name; +} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Jwt/JwtSignatureGenerator.cs b/IdentityShroud.Core/Security/Jwt/JwtSignatureGenerator.cs new file mode 100644 index 0000000..6143f35 --- /dev/null +++ b/IdentityShroud.Core/Security/Jwt/JwtSignatureGenerator.cs @@ -0,0 +1,100 @@ +using System.Buffers.Text; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.WebUtilities; + +namespace IdentityShroud.Core; + +public static class JwtSignatureGenerator +{ + /// + /// Generates a JWT signature using RS256 algorithm + /// + /// Base64Url encoded header + /// Base64Url encoded payload + /// RSA private key (PEM format or RSA parameters) + /// Base64Url encoded signature + public static string GenerateRS256Signature(string headerBase64Url, string payloadBase64Url, RSA privateKey) + { + // Combine header and payload with a period + string dataToSign = $"{headerBase64Url}.{payloadBase64Url}"; + + // Convert to bytes + byte[] dataBytes = Encoding.UTF8.GetBytes(dataToSign); + + // Sign the data using RSA-SHA256 + byte[] signatureBytes = privateKey.SignData(dataBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + // Convert signature to Base64Url encoding + string signature = WebEncoders.Base64UrlEncode(signatureBytes); + + return signature; + } + + public static string GenerateCompleteJwt(string headerBase64Url, string payloadBase64Url, RSA privateKey) + { + string signature = GenerateRS256Signature(headerBase64Url, payloadBase64Url, privateKey); + return $"{headerBase64Url}.{payloadBase64Url}.{signature}"; + } + +} + +public static class JwtCreator +{ + + public static byte[] CreateEncodedJwt(ReadOnlySpan payloadUtf8, IJwtSignatureProvider signatureProvider) + { + MemoryStream memStream = new(); + Utf8JsonWriter writer = new(memStream); + WriteJwtHeader(writer, signatureProvider); + writer.Flush(); + memStream.Seek(0, SeekOrigin.Begin); + + int headerBase64Length = Base64Url.GetEncodedLength((int)memStream.Length); + int payloadBase64Length = Base64Url.GetEncodedLength(payloadUtf8.Length); + int signatureBase64Length = Base64Url.GetEncodedLength(signatureProvider.GetSignatureLength()); + int totalLength = headerBase64Length + 1 + payloadBase64Length + 1 + signatureBase64Length; + + var completeJwt = new byte[totalLength]; + + // + var byteArray = new byte[memStream.Length]; + memStream.ReadExactly(byteArray, 0, (int)memStream.Length); + int written = Base64Url.EncodeToUtf8(byteArray, completeJwt); + + if (written != headerBase64Length) + throw new Exception("expected header length did not match bytes written"); + + completeJwt[headerBase64Length] = (byte)'.'; + + written = Base64Url.EncodeToUtf8(payloadUtf8, completeJwt.AsSpan().Slice(headerBase64Length + 1, payloadBase64Length)); + + if (written != payloadBase64Length) + throw new Exception("expected payload length did not match bytes written"); + + completeJwt[headerBase64Length + 1 + payloadBase64Length] = (byte)'.'; + + + Span signature = stackalloc byte[signatureProvider.GetSignatureLength()]; + signatureProvider.CalculateSignature( + completeJwt.AsSpan().Slice(0, headerBase64Length + 1 + payloadBase64Length), + signature); + + written = Base64Url.EncodeToUtf8(signature, completeJwt.AsSpan() + .Slice(headerBase64Length + 1 + payloadBase64Length + 1)); + + if (written != signatureBase64Length) + throw new Exception("expected signature length did not match bytes written"); + + return completeJwt; + } + + private static void WriteJwtHeader(Utf8JsonWriter writer, IJwtSignatureProvider signatureProvider) + { + writer.WriteStartObject(); + writer.WriteString("typ"u8, "JWT"u8); + signatureProvider.WriteJwtHeaderFields(writer); + writer.WriteEndObject(); + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Jwt/RsaJwtSignatureProvider.cs b/IdentityShroud.Core/Security/Jwt/RsaJwtSignatureProvider.cs new file mode 100644 index 0000000..ab33a72 --- /dev/null +++ b/IdentityShroud.Core/Security/Jwt/RsaJwtSignatureProvider.cs @@ -0,0 +1,88 @@ +using System.Security.Cryptography; +using System.Text.Json; +using IdentityShroud.Core.Model; +using IdentityShroud.Core.Services; + +namespace IdentityShroud.Core; + +public class SignatureProviderFactory(DekEncryptionService dekCryptor, IServiceProvider services) +{ + + public static void SelectAlgorithmAndKey(Realm realm, Client client, out JwtSigAlgName alg, out byte[] key) + { + throw new NotImplementedException(); + } + + public IJwtSignatureProvider Create(JwtSigAlgName algorithm, byte[] keyData) + { + + + //realm.DefaultSignatureAlgorithm + //realm.TokenSigningKeys + + //IJwtSignatureProvider? sigProvider = services.GetKeyedService(); + throw new NotImplementedException(); + } + +} + +public class RsaJwtSignatureProvider : IJwtSignatureProvider +{ + private JwtSigAlgName _sigAlgName; + private RealmSigningKeyId _keyId; + private readonly RSA _rsa; + + public RsaJwtSignatureProvider(DekEncryptionService dekCryptor, + RealmSigningKey privateKey, + JwtSigAlgName sigAlgName) + { + _sigAlgName = sigAlgName; + _keyId = privateKey.Id; + + byte[] key = dekCryptor.Decrypt(privateKey.Key); + _rsa = RSA.Create(); + _rsa.ImportPkcs8PrivateKey(key, out int _); + } + + /* + +-------------------+---------------------------------+ + | "alg" Param Value | Digital Signature Algorithm | + +-------------------+---------------------------------+ + | RS256 | RSASSA-PKCS1-v1_5 using SHA-256 | + | RS384 | RSASSA-PKCS1-v1_5 using SHA-384 | + | RS512 | RSASSA-PKCS1-v1_5 using SHA-512 | + +-------------------+---------------------------------+ + */ + + + public void WriteJwtHeaderFields(Utf8JsonWriter writer) + { + writer.WriteString("alg"u8, _sigAlgName.ToString()); + writer.WriteString("kid"u8, _keyId.ToString()); + } + + public int GetSignatureLength() + { + return _rsa.KeySize / 8; + } + + public void CalculateSignature(ReadOnlySpan jwt, Span sig) + { + _rsa.SignData(jwt, sig, GetHashAlgorithmName(), RSASignaturePadding.Pkcs1); + } + + public void Dispose() + { + _rsa.Dispose(); + } + + private HashAlgorithmName GetHashAlgorithmName() + => _sigAlgName.Name switch + { + "RS256" => HashAlgorithmName.SHA256, + "RS384" => HashAlgorithmName.SHA384, + "RS512" => HashAlgorithmName.SHA512, + _ => throw new ArgumentException("Invalid algorithm for RsaJwtSignatureProvider") + }; + +} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/JwtSignatureGenerator.cs b/IdentityShroud.Core/Security/JwtSignatureGenerator.cs deleted file mode 100644 index e22cfca..0000000 --- a/IdentityShroud.Core/Security/JwtSignatureGenerator.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Security.Cryptography; -using System.Text; -using Microsoft.AspNetCore.WebUtilities; - -namespace IdentityShroud.Core; - -public static class JwtSignatureGenerator -{ - /// - /// Generates a JWT signature using RS256 algorithm - /// - /// Base64Url encoded header - /// Base64Url encoded payload - /// RSA private key (PEM format or RSA parameters) - /// Base64Url encoded signature - public static string GenerateRS256Signature(string headerBase64Url, string payloadBase64Url, RSA privateKey) - { - // Combine header and payload with a period - string dataToSign = $"{headerBase64Url}.{payloadBase64Url}"; - - // Convert to bytes - byte[] dataBytes = Encoding.UTF8.GetBytes(dataToSign); - - // Sign the data using RSA-SHA256 - byte[] signatureBytes = privateKey.SignData(dataBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - - // Convert signature to Base64Url encoding - string signature = WebEncoders.Base64UrlEncode(signatureBytes); - - return signature; - } - - public static string GenerateCompleteJwt(string headerBase64Url, string payloadBase64Url, RSA privateKey) - { - string signature = GenerateRS256Signature(headerBase64Url, payloadBase64Url, privateKey); - return $"{headerBase64Url}.{payloadBase64Url}.{signature}"; - } -} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Keys/Aes/AesKeyPolicy.cs b/IdentityShroud.Core/Security/Keys/Aes/AesKeyPolicy.cs new file mode 100644 index 0000000..5e44402 --- /dev/null +++ b/IdentityShroud.Core/Security/Keys/Aes/AesKeyPolicy.cs @@ -0,0 +1,10 @@ +namespace IdentityShroud.Core.Security.Keys.Aes; + +public class AesKeyPolicy : KeyPolicy +{ + public AesKeyPolicy() + { + KeyType = KeyType.AES; + KeySize = 256; + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Keys/Aes/AesProvider.cs b/IdentityShroud.Core/Security/Keys/Aes/AesProvider.cs new file mode 100644 index 0000000..b30428f --- /dev/null +++ b/IdentityShroud.Core/Security/Keys/Aes/AesProvider.cs @@ -0,0 +1,19 @@ +using System.Security.Cryptography; +using IdentityShroud.Core.Messages; + +namespace IdentityShroud.Core.Security.Keys.Aes; + +public class AesProvider : IKeyProvider +{ + public bool IsPublic => false; + public KeyData CreateKey(KeyPolicy policy) + { + return new KeyData(RandomNumberGenerator.GetBytes(policy.KeySize / 8)); + } + + public void SetJwkParameters(Dictionary parameters, JsonWebKey jwk) + { + // Can we use this for Jwe? + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Keys/IKeyProvider.cs b/IdentityShroud.Core/Security/Keys/IKeyProvider.cs index 8e32309..6a5ce45 100644 --- a/IdentityShroud.Core/Security/Keys/IKeyProvider.cs +++ b/IdentityShroud.Core/Security/Keys/IKeyProvider.cs @@ -2,17 +2,32 @@ using IdentityShroud.Core.Messages; namespace IdentityShroud.Core.Security.Keys; -public abstract class KeyPolicy +public class KeyPolicy { - public abstract string KeyType { get; } + public KeyType KeyType { get; protected init; } + public int KeySize { get; protected init; } +} + +public record KeyData(byte[] PrivateKey, Dictionary? PublicKeyParameters = null) +{ + /// + /// The data to be kept private, also used for symmetric keys + /// + public byte[] PrivateKey { get; set; } = PrivateKey; + + public Dictionary? PublicKeyParameters { get; set; } = PublicKeyParameters; } public interface IKeyProvider { - byte[] CreateKey(KeyPolicy policy); + /// + /// Returns true when this key uses public key cryptography + /// + bool IsPublic { get; } + KeyData CreateKey(KeyPolicy policy); - void SetJwkParameters(byte[] key, JsonWebKey jwk); + void SetJwkParameters(Dictionary parameters, JsonWebKey jwk); } diff --git a/IdentityShroud.Core/Security/Keys/IKeyProviderFactory.cs b/IdentityShroud.Core/Security/Keys/IKeyProviderFactory.cs index 485e6e5..c39a836 100644 --- a/IdentityShroud.Core/Security/Keys/IKeyProviderFactory.cs +++ b/IdentityShroud.Core/Security/Keys/IKeyProviderFactory.cs @@ -3,5 +3,5 @@ namespace IdentityShroud.Core.Security.Keys; public interface IKeyProviderFactory { - public IKeyProvider CreateProvider(string keyType); + public IKeyProvider CreateProvider(KeyType keyType); } \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Keys/KeyProviderFactory.cs b/IdentityShroud.Core/Security/Keys/KeyProviderFactory.cs index a1c3472..33d5092 100644 --- a/IdentityShroud.Core/Security/Keys/KeyProviderFactory.cs +++ b/IdentityShroud.Core/Security/Keys/KeyProviderFactory.cs @@ -1,15 +1,18 @@ +using IdentityShroud.Core.Security.Keys.Aes; using IdentityShroud.Core.Security.Keys.Rsa; namespace IdentityShroud.Core.Security.Keys; public class KeyProviderFactory : IKeyProviderFactory { - public IKeyProvider CreateProvider(string keyType) + public IKeyProvider CreateProvider(KeyType keyType) { - switch (keyType) + switch (keyType.Name) { case "RSA": return new RsaProvider(); + case "AES": + return new AesProvider(); default: throw new NotImplementedException(); } diff --git a/IdentityShroud.Core/Security/Keys/KeyType.cs b/IdentityShroud.Core/Security/Keys/KeyType.cs new file mode 100644 index 0000000..224e989 --- /dev/null +++ b/IdentityShroud.Core/Security/Keys/KeyType.cs @@ -0,0 +1,21 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace IdentityShroud.Core.Security.Keys; + +[JsonConverter(typeof(KeyTypeJsonConverter))] +public readonly record struct KeyType(string Name) +{ + public static KeyType AES => new("AES"); + public static KeyType RSA => new("RSA"); + public override string ToString() => Name; +} + +public class KeyTypeJsonConverter : JsonConverter +{ + public override KeyType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => new KeyType(reader.GetString()!); + + public override void Write(Utf8JsonWriter writer, KeyType value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToString()); +} diff --git a/IdentityShroud.Core/Security/Keys/Rsa/RsaKeyPolicy.cs b/IdentityShroud.Core/Security/Keys/Rsa/RsaKeyPolicy.cs new file mode 100644 index 0000000..0e2919c --- /dev/null +++ b/IdentityShroud.Core/Security/Keys/Rsa/RsaKeyPolicy.cs @@ -0,0 +1,10 @@ +namespace IdentityShroud.Core.Security.Keys.Rsa; + +public class RsaKeyPolicy : KeyPolicy +{ + public RsaKeyPolicy() + { + KeyType = KeyType.RSA; + KeySize = 2048; + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Keys/Rsa/RsaProvider.cs b/IdentityShroud.Core/Security/Keys/Rsa/RsaProvider.cs index daf2b7f..717f9de 100644 --- a/IdentityShroud.Core/Security/Keys/Rsa/RsaProvider.cs +++ b/IdentityShroud.Core/Security/Keys/Rsa/RsaProvider.cs @@ -4,32 +4,31 @@ using IdentityShroud.Core.Messages; namespace IdentityShroud.Core.Security.Keys.Rsa; -public class RsaKeyPolicy : KeyPolicy -{ - public override string KeyType => "RSA"; - public int KeySize { get; } = 2048; -} - public class RsaProvider : IKeyProvider { - public byte[] CreateKey(KeyPolicy policy) + public bool IsPublic => true; + + public KeyData CreateKey(KeyPolicy policy) { if (policy is RsaKeyPolicy p) { using var rsa = RSA.Create(p.KeySize); - return rsa.ExportPkcs8PrivateKey(); + var publicParamaters = rsa.ExportParameters(includePrivateParameters: false); + return new KeyData( + rsa.ExportPkcs8PrivateKey(), + new() + { + ["e"] = Base64Url.EncodeToString(publicParamaters.Exponent), + ["n"] = Base64Url.EncodeToString(publicParamaters.Modulus), + }); } throw new ArgumentException("Incorrect policy type", nameof(policy)); } - public void SetJwkParameters(byte[] key, JsonWebKey jwk) + public void SetJwkParameters(Dictionary parameters, JsonWebKey jwk) { - using var rsa = RSA.Create(); - rsa.ImportPkcs8PrivateKey(key, out _); - var parameters = rsa.ExportParameters(includePrivateParameters: false); - - jwk.Exponent = Base64Url.EncodeToString(parameters.Exponent); - jwk.Modulus = Base64Url.EncodeToString(parameters.Modulus); + jwk.Exponent = parameters["e"]; + jwk.Modulus = parameters["n"]; } } \ No newline at end of file diff --git a/IdentityShroud.Core/Services/ClientService.cs b/IdentityShroud.Core/Services/ClientService.cs index 0887ccd..61be016 100644 --- a/IdentityShroud.Core/Services/ClientService.cs +++ b/IdentityShroud.Core/Services/ClientService.cs @@ -1,5 +1,7 @@ using System.Security.Cryptography; +using FluentValidation; using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.EFCore; using IdentityShroud.Core.Model; using Microsoft.EntityFrameworkCore; @@ -8,24 +10,35 @@ namespace IdentityShroud.Core.Services; public class ClientService( Db db, IDataEncryptionService cryptor, + IValidator clientCreateValidator, IClock clock) : IClientService { public async Task> Create(Guid realmId, ClientCreateRequest request, CancellationToken ct = default) { + clientCreateValidator.ValidateAndThrow(request); + + Realm realm = await db.Realms.FirstOrDefaultAsync(e => e.Id == realmId, ct) + ?? throw new InvalidOperationException("Require the id of an existing realm"); + Client client = new() { RealmId = realmId, ClientId = request.ClientId, Name = request.Name, Description = request.Description, - SignatureAlgorithm = request.SignatureAlgorithm, - AllowClientCredentialsFlow = request.AllowClientCredentialsFlow ?? false, + SignatureAlgorithm = request.SignatureAlgorithm is null ? null : new(request.SignatureAlgorithm), + Confidential = request.Confidential, + AllowClientCredentialsFlow = request.AllowClientCredentialsFlow, CreatedAt = clock.UtcNow(), }; - if (client.AllowClientCredentialsFlow) + if (request.GenerateSecret is true) { - client.Secrets.Add(CreateSecret()); + await db.Entry(realm).Collection(r => r.DataEncryptionKeys) + .Query() + .LoadAsync(ct); + + client.Secrets.Add(CreateSecret(realm)); } await db.AddAsync(client, ct); @@ -50,15 +63,17 @@ public class ClientService( return await db.Clients.FirstOrDefaultAsync(c => c.Id == id && c.RealmId == realmId, ct); } - private ClientSecret CreateSecret() + private ClientSecret CreateSecret(Realm realm) { Span secret = stackalloc byte[24]; RandomNumberGenerator.Fill(secret); + + var dek = realm.DataEncryptionKeys.Single(k => k.Active); return new ClientSecret() { CreatedAt = clock.UtcNow(), - Secret = cryptor.Encrypt(secret.ToArray()), + Secret = cryptor.Encrypt(dek, secret), }; } diff --git a/IdentityShroud.Core/Services/DataEncryptionService.cs b/IdentityShroud.Core/Services/DataEncryptionService.cs index a06cbae..9d8f092 100644 --- a/IdentityShroud.Core/Services/DataEncryptionService.cs +++ b/IdentityShroud.Core/Services/DataEncryptionService.cs @@ -5,37 +5,23 @@ 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? _deks = null; - - private IList GetDeks() + public EncryptedValue Encrypt(RealmDek dek, ReadOnlySpan plain) { - 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 plain) - { - var dek = GetActiveDek(); var key = dekCryptor.Decrypt(dek.KeyData); byte[] cipher = Encryption.Encrypt(plain, key); return new (dek.Id, cipher); } + + public byte[] Decrypt(IReadOnlyList deks, EncryptedValue input) + { + // Note a missing key SHOULD not happen. If it does happen something has seriously gone wrong like + // - Old key removed before migration completed (should not be possible) + // - Wrong keyset because of programming error. + var dek = deks.SingleOrDefault(d => d.Id == input.DekId) + ?? throw new InvalidOperationException("Required key not found"); + var key = dekCryptor.Decrypt(dek.KeyData); + return Encryption.Decrypt(input.Value, key); + } } \ No newline at end of file diff --git a/IdentityShroud.Core/Services/KeyService.cs b/IdentityShroud.Core/Services/KeyService.cs index a2ce9dc..10900dd 100644 --- a/IdentityShroud.Core/Services/KeyService.cs +++ b/IdentityShroud.Core/Services/KeyService.cs @@ -1,46 +1,16 @@ 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 + IKeyProviderFactory keyProviderFactory) : IKeyService { - public RealmKey CreateKey(KeyPolicy policy) + public CreateKeyResponse CreateKey(KeyPolicy policy) { IKeyProvider provider = keyProviderFactory.CreateProvider(policy.KeyType); - var plainKey = provider.CreateKey(policy); + KeyData plainKey = provider.CreateKey(policy); - return CreateKey(policy.KeyType, plainKey); + return new CreateKeyResponse(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(), - }; } diff --git a/IdentityShroud.Core/Services/OpenId/TokenService.cs b/IdentityShroud.Core/Services/OpenId/TokenService.cs new file mode 100644 index 0000000..964b1d9 --- /dev/null +++ b/IdentityShroud.Core/Services/OpenId/TokenService.cs @@ -0,0 +1,30 @@ +namespace IdentityShroud.Core.Services.OpenId; + +public interface ITokenService +{ + Task> Handle( + Dictionary form, + string? basicAuthUser, + string? basicAuthPassword, + CancellationToken ct = default); +} + +public class TokenService : ITokenService +{ + public async Task> Handle( + Dictionary form, + string? basicAuthUser, + string? basicAuthPassword, + CancellationToken ct = default) + { + return new(); + } + + public async Task> ClientCredentialsFlow( + string clientId, + string clientSecret, + CancellationToken ct = default) + { + return new(); + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/RealmContext.cs b/IdentityShroud.Core/Services/RealmContext.cs index 7daa399..8c5de16 100644 --- a/IdentityShroud.Core/Services/RealmContext.cs +++ b/IdentityShroud.Core/Services/RealmContext.cs @@ -16,11 +16,11 @@ public class RealmContext( public async Task> GetDeks(CancellationToken ct = default) { Realm realm = GetRealm(); - if (realm.Deks.Count == 0) + if (realm.DataEncryptionKeys.Count == 0) { await realmService.LoadDeks(realm); } - return realm.Deks; + return realm.DataEncryptionKeys; } } \ No newline at end of file diff --git a/IdentityShroud.Core/Services/RealmService.cs b/IdentityShroud.Core/Services/RealmService.cs index 949c9fe..9dd3ba8 100644 --- a/IdentityShroud.Core/Services/RealmService.cs +++ b/IdentityShroud.Core/Services/RealmService.cs @@ -1,18 +1,21 @@ using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.EFCore; using IdentityShroud.Core.Helpers; using IdentityShroud.Core.Messages.Realm; using IdentityShroud.Core.Model; +using IdentityShroud.Core.Security; using IdentityShroud.Core.Security.Keys; +using IdentityShroud.Core.Security.Keys.Aes; using IdentityShroud.Core.Security.Keys.Rsa; using Microsoft.EntityFrameworkCore; namespace IdentityShroud.Core.Services; -public record RealmCreateResponse(Guid Id, string Slug, string Name); - public class RealmService( Db db, - IKeyService keyService) : IRealmService + IKeyService keyService, + IDekEncryptionService dekCryptor, + IClock clock) : IRealmService { public async Task FindById(Guid id, CancellationToken ct = default) { @@ -26,7 +29,7 @@ public class RealmService( .SingleOrDefaultAsync(r => r.Slug == slug, ct); } - public async Task> Create(RealmCreateRequest request, CancellationToken ct = default) + public async Task> Create(RealmCreateRequest request, CancellationToken ct = default) { Realm realm = new() { @@ -35,26 +38,52 @@ public class RealmService( Name = request.Name, }; - realm.Keys.Add(keyService.CreateKey(GetKeyPolicy(realm))); + realm.TokenSigningKeys.Add(CreateSigningKey(realm)); + realm.DataEncryptionKeys.Add(CreateDataEncryptionKey(realm)); db.Add(realm); await db.SaveChangesAsync(ct); - - return new RealmCreateResponse( - realm.Id, realm.Slug, realm.Name); + + return realm; } + + private RealmSigningKey CreateSigningKey(Realm realm) + { + var k = keyService.CreateKey(GetSigningKeyPolicy(realm)); + return new RealmSigningKey + { + Id = RealmSigningKeyId.NewId(), + KeyType = k.KeyType, + Key = dekCryptor.Encrypt(k.Key.PrivateKey), + PublicKeyParameters = k.Key.PublicKeyParameters, + CreatedAt = clock.UtcNow(), + }; + } + + private RealmDek CreateDataEncryptionKey(Realm realm) + { + var k = keyService.CreateKey(GetDataKeyPolicy(realm)); + return new RealmDek() + { + Id = DekId.NewId(), + Active = true, + Algorithm = k.KeyType, + KeyData = dekCryptor.Encrypt(k.Key.PrivateKey), + }; + } + /// /// Place holder for getting policies from the realm and falling back to sane defaults when no policies have been set. /// /// /// - private KeyPolicy GetKeyPolicy(Realm _) => new RsaKeyPolicy(); - + private KeyPolicy GetSigningKeyPolicy(Realm _) => new RsaKeyPolicy(); + private KeyPolicy GetDataKeyPolicy(Realm _) => new AesKeyPolicy(); public async Task LoadActiveKeys(Realm realm) { - await db.Entry(realm).Collection(r => r.Keys) + await db.Entry(realm).Collection(r => r.TokenSigningKeys) .Query() .Where(k => k.RevokedAt == null) .LoadAsync(); @@ -62,7 +91,7 @@ public class RealmService( public async Task LoadDeks(Realm realm) { - await db.Entry(realm).Collection(r => r.Deks) + await db.Entry(realm).Collection(r => r.DataEncryptionKeys) .Query() .LoadAsync(); } diff --git a/IdentityShroud.Migrations/DesignTimeDbFactory.cs b/IdentityShroud.Migrations/DesignTimeDbFactory.cs index 9459610..e03d3ef 100644 --- a/IdentityShroud.Migrations/DesignTimeDbFactory.cs +++ b/IdentityShroud.Migrations/DesignTimeDbFactory.cs @@ -1,4 +1,4 @@ -using IdentityShroud.Core; +using IdentityShroud.Core.EFCore; using Microsoft.EntityFrameworkCore.Design; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; diff --git a/IdentityShroud.TestUtils/Asserts/JsonObjectAssert.cs b/IdentityShroud.TestUtils/Asserts/JsonObjectAssert.cs index 016f358..9dbf957 100644 --- a/IdentityShroud.TestUtils/Asserts/JsonObjectAssert.cs +++ b/IdentityShroud.TestUtils/Asserts/JsonObjectAssert.cs @@ -35,6 +35,8 @@ public static class JsonObjectAssert return segments.ToArray(); } + public static JsonNode? NavigateToPath(JsonObject jsonObject, string path) + => NavigateToPath(jsonObject, ParsePath(path)); /// /// Navigates to a JsonNode at the specified path and returns it. /// Throws XunitException if the path doesn't exist or is invalid. diff --git a/IdentityShroud.TestUtils/Substitutes/NullDataEncryptionService.cs b/IdentityShroud.TestUtils/Substitutes/NullDataEncryptionService.cs index 4e97bfc..eaf1180 100644 --- a/IdentityShroud.TestUtils/Substitutes/NullDataEncryptionService.cs +++ b/IdentityShroud.TestUtils/Substitutes/NullDataEncryptionService.cs @@ -1,4 +1,5 @@ using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Model; using IdentityShroud.Core.Security; namespace IdentityShroud.TestUtils.Substitutes; @@ -6,12 +7,12 @@ namespace IdentityShroud.TestUtils.Substitutes; public class NullDataEncryptionService : IDataEncryptionService { public DekId KeyId { get; } = DekId.NewId(); - public EncryptedValue Encrypt(ReadOnlySpan plain) + public EncryptedValue Encrypt(RealmDek key, ReadOnlySpan plain) { return new(KeyId, plain.ToArray()); } - public byte[] Decrypt(EncryptedValue input) + public byte[] Decrypt(IReadOnlyList keys, EncryptedValue input) { return input.Value; } diff --git a/IdentityShroud.sln b/IdentityShroud.sln index ef65bf2..4fd0005 100644 --- a/IdentityShroud.sln +++ b/IdentityShroud.sln @@ -16,7 +16,9 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityShroud.TestUtils", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityShroud.TestUtils.Tests", "IdentityShroud.TestUtils.Tests\IdentityShroud.TestUtils.Tests.csproj", "{35D33207-27A8-43E9-A8CA-A158A1E4448C}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{980900AA-E052-498B-A41A-4F33A8678828}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "08_Tests", "08_Tests", "{980900AA-E052-498B-A41A-4F33A8678828}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "01", "01", "{07B08872-1141-4BE6-87E6-B85E52FE4341}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -58,5 +60,7 @@ Global {DC887623-8680-4D3B-B23A-D54F7DA91891} = {980900AA-E052-498B-A41A-4F33A8678828} {35D33207-27A8-43E9-A8CA-A158A1E4448C} = {980900AA-E052-498B-A41A-4F33A8678828} {A8554BCC-C9B6-4D96-90AD-FE80E95441F4} = {980900AA-E052-498B-A41A-4F33A8678828} + {D2B446A0-AB62-4555-9D79-33FF43D7CEF4} = {07B08872-1141-4BE6-87E6-B85E52FE4341} + {8490BF59-B68A-4BE0-9F96-6CB262AF4850} = {07B08872-1141-4BE6-87E6-B85E52FE4341} EndGlobalSection EndGlobal diff --git a/IdentityShroud.sln.DotSettings.user b/IdentityShroud.sln.DotSettings.user index 540f7bf..158c9b3 100644 --- a/IdentityShroud.sln.DotSettings.user +++ b/IdentityShroud.sln.DotSettings.user @@ -1,35 +1,69 @@  ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded + ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded /home/eelke/.cache/JetBrains/Rider2025.3/resharper-host/temp/Rider/vAny/CoverageData/_IdentityShroud.-1277985570/Snapshot/snapshot.utdcvr + + + + /home/eelke/.dotnet/dotnet /home/eelke/.dotnet/sdk/10.0.102/MSBuild.dll - <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <Solution /> -</SessionState> - <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> </SessionState> @@ -44,6 +78,14 @@ + + + + + + + + From 054754f553889d10b03dd0a8a55198b11181911b Mon Sep 17 00:00:00 2001 From: eelke Date: Tue, 18 Aug 2026 07:40:24 +0200 Subject: [PATCH 20/22] Reworked encryption to use less heap allocated buffers for secrets. Also some work on plugin system. --- .editorconfig | 2 + .../Apis/ClientApiTests.cs | 6 +- .../Fixtures/ApplicationFactory.cs | 1 + .../IdentityShroud.Api.Tests.csproj | 2 +- .../Apis/Mappers/ClientMapper.cs | 3 + IdentityShroud.Api/Apis/OpenIdEndpoints.cs | 2 +- .../AppJsonSerializerContext.cs | 14 - IdentityShroud.Api/IdentityShroud.Api.csproj | 3 +- IdentityShroud.Api/Program.cs | 124 ++++--- .../Properties/launchSettings.json | 2 +- .../Security/Jwt/RsaJwtSignerTests.cs | 48 +++ .../Services/DekEncryptionServiceTests.cs | 14 +- .../Services/EncryptionTests.cs | 3 +- .../Contracts/IDekEncryptionService.cs | 4 +- .../CoreServiceCollectionExtensions.cs | 38 +++ .../IdentityShroud.Core.csproj | 5 + .../Model/DecryptedSigningKey.cs | 66 ++++ IdentityShroud.Core/Plugins/PluginLoader.cs | 59 ++++ IdentityShroud.Core/Plugins/PluginRegistry.cs | 18 + IdentityShroud.Core/Security/Encryption.cs | 39 ++- ...IJwtSignatureProvider.cs => IJwtSigner.cs} | 14 +- .../Security/Jwt/IJwtSignerFactory.cs | 6 + .../Security/Jwt/JwtSignatureGenerator.cs | 55 +-- .../Security/Jwt/JwtSignerFactory.cs | 17 + .../Security/Jwt/RsaJwtSignatureProvider.cs | 88 ----- .../Security/Jwt/RsaJwtSigner.cs | 36 ++ .../Services/DataEncryptionService.cs | 32 +- .../Services/DekEncryptionService.cs | 10 +- .../IdentityShroud.GraphQL.csproj | 17 + IdentityShroud.GraphQL/Query.cs | 26 ++ .../RegistrationExtensions.cs | 31 ++ .../20260412083710_Initial.Designer.cs | 318 ++++++++++++++++++ .../Migrations/20260412083710_Initial.cs | 171 ++++++++++ .../Migrations/DbModelSnapshot.cs | 315 +++++++++++++++++ IdentityShroud.PluginSupport/IPlugin.cs | 9 + .../IdentityShroud.PluginSupport.csproj | 9 + .../ISecretProvider.cs | 15 + .../IdentityShroud.SecretProviders.csproj | 13 + IdentityShroud.SecretProviders/PlainSecret.cs | 18 + .../Substitutes/NullDekEncryptionService.cs | 10 + IdentityShroud.sln | 23 ++ IdentityShroud.sln.DotSettings.user | 8 +- 42 files changed, 1452 insertions(+), 242 deletions(-) create mode 100644 .editorconfig delete mode 100644 IdentityShroud.Api/AppJsonSerializerContext.cs create mode 100644 IdentityShroud.Core.Tests/Security/Jwt/RsaJwtSignerTests.cs create mode 100644 IdentityShroud.Core/CoreServiceCollectionExtensions.cs create mode 100644 IdentityShroud.Core/Model/DecryptedSigningKey.cs create mode 100644 IdentityShroud.Core/Plugins/PluginLoader.cs create mode 100644 IdentityShroud.Core/Plugins/PluginRegistry.cs rename IdentityShroud.Core/Security/Jwt/{IJwtSignatureProvider.cs => IJwtSigner.cs} (61%) create mode 100644 IdentityShroud.Core/Security/Jwt/IJwtSignerFactory.cs create mode 100644 IdentityShroud.Core/Security/Jwt/JwtSignerFactory.cs delete mode 100644 IdentityShroud.Core/Security/Jwt/RsaJwtSignatureProvider.cs create mode 100644 IdentityShroud.Core/Security/Jwt/RsaJwtSigner.cs create mode 100644 IdentityShroud.GraphQL/IdentityShroud.GraphQL.csproj create mode 100644 IdentityShroud.GraphQL/Query.cs create mode 100644 IdentityShroud.GraphQL/RegistrationExtensions.cs create mode 100644 IdentityShroud.Migrations/Migrations/20260412083710_Initial.Designer.cs create mode 100644 IdentityShroud.Migrations/Migrations/20260412083710_Initial.cs create mode 100644 IdentityShroud.Migrations/Migrations/DbModelSnapshot.cs create mode 100644 IdentityShroud.PluginSupport/IPlugin.cs create mode 100644 IdentityShroud.PluginSupport/IdentityShroud.PluginSupport.csproj create mode 100644 IdentityShroud.SecretProviders/ISecretProvider.cs create mode 100644 IdentityShroud.SecretProviders/IdentityShroud.SecretProviders.csproj create mode 100644 IdentityShroud.SecretProviders/PlainSecret.cs diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..33a3ce8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,2 @@ +[*.cs] +resharper_naming_rules.abbreviations = QL, DB diff --git a/IdentityShroud.Api.Tests/Apis/ClientApiTests.cs b/IdentityShroud.Api.Tests/Apis/ClientApiTests.cs index 7133bd7..cf1eb9f 100644 --- a/IdentityShroud.Api.Tests/Apis/ClientApiTests.cs +++ b/IdentityShroud.Api.Tests/Apis/ClientApiTests.cs @@ -17,10 +17,8 @@ namespace IdentityShroud.Api.Tests.Apis; public class ClientApiTests : IClassFixture { - private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web) - { - TypeInfoResolver = AppJsonSerializerContext.Default, - }; + private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web); + private readonly ApplicationFactory _factory; public ClientApiTests(ApplicationFactory factory) diff --git a/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs b/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs index 9846559..0c5337d 100644 --- a/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs +++ b/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs @@ -1,3 +1,4 @@ +using IdentityShroud.Api; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; diff --git a/IdentityShroud.Api.Tests/IdentityShroud.Api.Tests.csproj b/IdentityShroud.Api.Tests/IdentityShroud.Api.Tests.csproj index a3aa6a8..4bb8f47 100644 --- a/IdentityShroud.Api.Tests/IdentityShroud.Api.Tests.csproj +++ b/IdentityShroud.Api.Tests/IdentityShroud.Api.Tests.csproj @@ -23,7 +23,7 @@ - + diff --git a/IdentityShroud.Api/Apis/Mappers/ClientMapper.cs b/IdentityShroud.Api/Apis/Mappers/ClientMapper.cs index 8e58717..0c6563f 100644 --- a/IdentityShroud.Api/Apis/Mappers/ClientMapper.cs +++ b/IdentityShroud.Api/Apis/Mappers/ClientMapper.cs @@ -6,6 +6,9 @@ namespace IdentityShroud.Api.Mappers; [Mapper] public partial class ClientMapper { + // skipping secret as we do not have the DEK [MapperIgnoreSource(nameof(Client.Secrets))] + [MapperIgnoreTarget(nameof(ClientRepresentation.Secret))] public partial ClientRepresentation ToDto(Client client); + } \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/OpenIdEndpoints.cs b/IdentityShroud.Api/Apis/OpenIdEndpoints.cs index 053be93..54b972a 100644 --- a/IdentityShroud.Api/Apis/OpenIdEndpoints.cs +++ b/IdentityShroud.Api/Apis/OpenIdEndpoints.cs @@ -47,7 +47,7 @@ public static class OpenIdEndpoints TokenEndpoint = baseUri + "/openid-connect/token", Issuer = baseUri, JwksUri = baseUri + "/openid-connect/jwks", - }, AppJsonSerializerContext.Default.OpenIdConfiguration); + }); } private static async Task, BadRequest>> OpenIdConnectJwks( diff --git a/IdentityShroud.Api/AppJsonSerializerContext.cs b/IdentityShroud.Api/AppJsonSerializerContext.cs deleted file mode 100644 index 5733ac3..0000000 --- a/IdentityShroud.Api/AppJsonSerializerContext.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Text.Json.Serialization; -using IdentityShroud.Api.Apis; -using IdentityShroud.Core.Messages; -using IdentityShroud.Core.Messages.Realm; - -namespace IdentityShroud.Api; - -[JsonSerializable(typeof(ClientRepresentation))] -[JsonSerializable(typeof(ErrorDto))] -[JsonSerializable(typeof(OpenIdConfiguration))] -[JsonSerializable(typeof(RealmCreateRequest))] -public partial class AppJsonSerializerContext : JsonSerializerContext -{ -} \ No newline at end of file diff --git a/IdentityShroud.Api/IdentityShroud.Api.csproj b/IdentityShroud.Api/IdentityShroud.Api.csproj index 31f88b2..5d779e7 100644 --- a/IdentityShroud.Api/IdentityShroud.Api.csproj +++ b/IdentityShroud.Api/IdentityShroud.Api.csproj @@ -5,7 +5,7 @@ enable enable true - true + false Linux 6b8ef434-0577-4a3c-8749-6b547d7787c5 @@ -25,6 +25,7 @@ + diff --git a/IdentityShroud.Api/Program.cs b/IdentityShroud.Api/Program.cs index b2a31e8..2ff5fe6 100644 --- a/IdentityShroud.Api/Program.cs +++ b/IdentityShroud.Api/Program.cs @@ -1,78 +1,74 @@ using FluentValidation; -using IdentityShroud.Api; using IdentityShroud.Api.Mappers; -using IdentityShroud.Core.Contracts; +using IdentityShroud.Core; using IdentityShroud.Core.EFCore; -using IdentityShroud.Core.Security; -using IdentityShroud.Core.Security.Keys; -using IdentityShroud.Core.Services; +using IdentityShroud.GraphQL; using Serilog; using Serilog.Formatting.Json; - // Initial logging until we can set it up from Configuration -Log.Logger = new LoggerConfiguration() - .Enrich.FromLogContext() - .WriteTo.Console(new JsonFormatter()) - .CreateLogger(); -var applicationBuilder = WebApplication.CreateSlimBuilder(args); -ConfigureBuilder(applicationBuilder); -var application = applicationBuilder.Build(); -ConfigureApplication(application); -application.Run(); +namespace IdentityShroud.Api; -void ConfigureBuilder(WebApplicationBuilder builder) +public class Program { - var services = builder.Services; - var configuration = builder.Configuration; - - //services.AddControllers(); - services.ConfigureHttpJsonOptions(options => + public static void Main(string[] args) { - options.SerializerOptions.TypeInfoResolverChain.Insert(0, IdentityShroud.Api.AppJsonSerializerContext.Default); - }); + Log.Logger = new LoggerConfiguration() + .Enrich.FromLogContext() + .WriteTo.Console(new JsonFormatter()) + .CreateLogger(); - // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi - services.AddOpenApi(); - services.AddScoped(); - services.AddScoped(); - services.AddSingleton(); - services.AddSingleton(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddOptions().Bind(configuration.GetSection("db")); - services.AddSingleton(); - services.AddScoped(); - services.AddScoped(); - - services.AddValidatorsFromAssemblyContaining(); - services.AddHttpContextAccessor(); - - services.AddExceptionHandler(); - services.AddProblemDetails(); - - builder.Host.UseSerilog((context, services, configuration) => configuration - .Enrich.FromLogContext() - //.Enrich.With() - .ReadFrom.Configuration(context.Configuration)); -} - -void ConfigureApplication(WebApplication app) -{ - app.UseExceptionHandler(); - if (app.Environment.IsDevelopment()) - { - app.MapOpenApi(); + var applicationBuilder = WebApplication.CreateSlimBuilder(args); + ConfigureBuilder(applicationBuilder); + var application = applicationBuilder.Build(); + ConfigureApplication(application); + application.Run(); } - app.UseSerilogRequestLogging(); - app.MapApis(); - - // app.UseRouting(); - // app.MapControllers(); -} -public partial class Program { } + private static void ConfigureBuilder(WebApplicationBuilder builder) + { + var services = builder.Services; + var configuration = builder.Configuration; + + services.AddOptions().Bind(configuration.GetSection("db")); + + // services.ConfigureHttpJsonOptions(options => + // { + // options.SerializerOptions.TypeInfoResolverChain.Insert(0, IdentityShroud.Api.AppJsonSerializerContext.Default); + // }); + + services.AddScoped(); + + services.AddValidatorsFromAssemblyContaining(); + + services.AddHttpContextAccessor(); + services.AddOpenApi(); + services.AddExceptionHandler(); + services.AddProblemDetails(); + + services + .AddCore() + .AddIdentityShroudGraphQL(); + + builder.Host.UseSerilog((context, services, configuration) => configuration + .Enrich.FromLogContext() + //.Enrich.With() + .ReadFrom.Configuration(context.Configuration)); + } + + private static void ConfigureApplication(WebApplication app) + { + app.UseExceptionHandler(); + if (app.Environment.IsDevelopment()) + { + app.MapOpenApi(); + } + app.UseSerilogRequestLogging(); + app.MapApis(); + app.MapIdentityShroudGraphQL(); + + // app.UseRouting(); + // app.MapControllers(); + } +} \ No newline at end of file diff --git a/IdentityShroud.Api/Properties/launchSettings.json b/IdentityShroud.Api/Properties/launchSettings.json index 9472c5a..8556497 100644 --- a/IdentityShroud.Api/Properties/launchSettings.json +++ b/IdentityShroud.Api/Properties/launchSettings.json @@ -5,7 +5,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "launchUrl": "todos", + "launchUrl": "graphql", "applicationUrl": "http://localhost:5249", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/IdentityShroud.Core.Tests/Security/Jwt/RsaJwtSignerTests.cs b/IdentityShroud.Core.Tests/Security/Jwt/RsaJwtSignerTests.cs new file mode 100644 index 0000000..13b76ac --- /dev/null +++ b/IdentityShroud.Core.Tests/Security/Jwt/RsaJwtSignerTests.cs @@ -0,0 +1,48 @@ +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; + +namespace IdentityShroud.Core.Tests.Security.Jwt; + +public class RsaJwtSignerTests +{ + [Fact] + public void Test() + { + // ISecretProvider secretProvider = Substitute.For(); + // RealmSigningKey privateKey = new() + // { + // Id = default, + // KeyType = KeyType.RSA, + // Key = new EncryptedDek(KekId.NewId(), [1]), + // CreatedAt = default, + // RevokedAt = null, + // Priority = 0, + // PublicKeyParameters = null + // }; + DecryptedSigningKey key = new(); + byte[] jwt = []; + + RsaJwtSigner provider = new(); + provider.CalculateSignature(JwtSigAlgName.RS256, key, jwt); + // + // new DekEncryptionService(secretProvider), privateKey, + // JwtSigAlgName.RS256); + } + + [Theory] + [InlineData(1024)] + [InlineData(2048)] + [InlineData(4096)] + public void EstimateKeySizeTests(int keySizeBits) + { + using var rsa = RSA.Create(); + rsa.KeySize = keySizeBits; + byte[] b = rsa.ExportPkcs8PrivateKey(); + int estimate = DecryptedSigningKey.EstimatePkcs8ExportSize(keySizeBits); + Assert.True(b.Length < estimate - 100); + } +} \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/Services/DekEncryptionServiceTests.cs b/IdentityShroud.Core.Tests/Services/DekEncryptionServiceTests.cs index fc4a45f..c0b9f38 100644 --- a/IdentityShroud.Core.Tests/Services/DekEncryptionServiceTests.cs +++ b/IdentityShroud.Core.Tests/Services/DekEncryptionServiceTests.cs @@ -25,8 +25,13 @@ public class DekEncryptionServiceTests // act DekEncryptionService sut = new(secretProvider); + EncryptedDek cipher = sut.Encrypt(input.ToArray()); - byte[] result = sut.Decrypt(cipher); + int decryptedSize = sut.GetDecryptedSize(cipher); + Assert.Equal(input.Length, decryptedSize); + + var result = new byte[decryptedSize]; + sut.Decrypt(cipher, result); // verify Assert.Equal(input, result); @@ -56,8 +61,10 @@ public class DekEncryptionServiceTests // act DekEncryptionService sut = new(secretProvider); + int decryptedSize = sut.GetDecryptedSize(secret); + var result = new byte[decryptedSize]; Assert.Throws( - () => sut.Decrypt(secret), + () => sut.Decrypt(secret, result), ex => ex.Message.Contains("Decryption failed") ? null : "Expected Decryption failed in message"); } @@ -89,7 +96,8 @@ public class DekEncryptionServiceTests // act DekEncryptionService sut = new(secretProvider); - byte[] result = sut.Decrypt(secret); + byte[] result = new byte[sut.GetDecryptedSize(secret)]; + sut.Decrypt(secret, result); // verify Assert.Equal("Hello, World!"u8, result); diff --git a/IdentityShroud.Core.Tests/Services/EncryptionTests.cs b/IdentityShroud.Core.Tests/Services/EncryptionTests.cs index f040b84..32e4538 100644 --- a/IdentityShroud.Core.Tests/Services/EncryptionTests.cs +++ b/IdentityShroud.Core.Tests/Services/EncryptionTests.cs @@ -19,7 +19,8 @@ public class EncryptionTests byte[] keyValue = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); // act - byte[] result = Encryption.Decrypt(cipher, keyValue); + byte[] result = new byte[Encryption.GetDecryptedLength(cipher)]; + Encryption.Decrypt(cipher, keyValue, result); // verify Assert.Equal("Hello, World!"u8, result); diff --git a/IdentityShroud.Core/Contracts/IDekEncryptionService.cs b/IdentityShroud.Core/Contracts/IDekEncryptionService.cs index 3032040..bbb234c 100644 --- a/IdentityShroud.Core/Contracts/IDekEncryptionService.cs +++ b/IdentityShroud.Core/Contracts/IDekEncryptionService.cs @@ -7,5 +7,7 @@ namespace IdentityShroud.Core.Contracts; public interface IDekEncryptionService { EncryptedDek Encrypt(ReadOnlySpan plain); - byte[] Decrypt(EncryptedDek input); + + void Decrypt(EncryptedDek input, Span output); + int GetDecryptedSize(EncryptedDek input); } \ No newline at end of file diff --git a/IdentityShroud.Core/CoreServiceCollectionExtensions.cs b/IdentityShroud.Core/CoreServiceCollectionExtensions.cs new file mode 100644 index 0000000..86d7339 --- /dev/null +++ b/IdentityShroud.Core/CoreServiceCollectionExtensions.cs @@ -0,0 +1,38 @@ +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.EFCore; +using IdentityShroud.Core.Security; +using IdentityShroud.Core.Security.Keys; +using IdentityShroud.Core.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace IdentityShroud.Core; + +public static class CoreServiceCollectionExtensions +{ + public static IServiceCollection AddCore(this IServiceCollection services) + { + services.AddScoped(); + + services.Scan(scan => scan + .FromAssemblyOf() + .AddClasses(classes => classes.AssignableTo()) + .AsImplementedInterfaces() + .WithSingletonLifetime()); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + + + services.AddScoped(); + services.AddScoped(); + + + return services; + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/IdentityShroud.Core.csproj b/IdentityShroud.Core/IdentityShroud.Core.csproj index fb54802..4562d8d 100644 --- a/IdentityShroud.Core/IdentityShroud.Core.csproj +++ b/IdentityShroud.Core/IdentityShroud.Core.csproj @@ -15,11 +15,16 @@ + + + + + diff --git a/IdentityShroud.Core/Model/DecryptedSigningKey.cs b/IdentityShroud.Core/Model/DecryptedSigningKey.cs new file mode 100644 index 0000000..4a94dc7 --- /dev/null +++ b/IdentityShroud.Core/Model/DecryptedSigningKey.cs @@ -0,0 +1,66 @@ +using System.Security.Cryptography; +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Security.Keys; + +namespace IdentityShroud.Core.Model; + +public sealed class DecryptedSigningKey : IDisposable +{ + private readonly byte[] _keyData; + private readonly int _keyLength; + private bool _disposed; + + public RealmSigningKeyId Id { get; } + public KeyType KeyType { get; } + public ReadOnlySpan KeyData => _disposed + ? throw new ObjectDisposedException(nameof(DecryptedSigningKey)) + : _keyData.AsSpan(0, _keyLength); + + public DecryptedSigningKey(RealmSigningKey realmSigningKey, IDekEncryptionService encryptionService) + { + Id = realmSigningKey.Id; + KeyType = realmSigningKey.KeyType; + int keySize = encryptionService.GetDecryptedSize(realmSigningKey.Key); + _keyData = GC.AllocateArray(keySize, pinned: true); + _keyLength = keySize; + encryptionService.Decrypt(realmSigningKey.Key, _keyData); + } + + public DecryptedSigningKey() + { + Id = RealmSigningKeyId.NewId(); + KeyType = KeyType.RSA; + const int keySize = 2048; + + using var rsa = RSA.Create(); + rsa.KeySize = keySize; + int estimatedSize = EstimatePkcs8ExportSize(keySize); + + Span temp = stackalloc byte[estimatedSize * 2]; + try + { + if (!rsa.TryExportPkcs8PrivateKey(temp, out int bytesWritten)) + throw new CryptographicException("Unable to export RSA private key."); + + _keyData = GC.AllocateArray(bytesWritten, pinned: true); + _keyLength = bytesWritten; + temp[..bytesWritten].CopyTo(_keyData); + } + finally + { + CryptographicOperations.ZeroMemory(temp); + } + } + + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + CryptographicOperations.ZeroMemory(_keyData); + } + + // Note actual accurate coefficients would be *0.566 and +57.4 + public static int EstimatePkcs8ExportSize(int keySizeBits) + => ((keySizeBits * 6) / 10) + 150; +} \ No newline at end of file diff --git a/IdentityShroud.Core/Plugins/PluginLoader.cs b/IdentityShroud.Core/Plugins/PluginLoader.cs new file mode 100644 index 0000000..e216a57 --- /dev/null +++ b/IdentityShroud.Core/Plugins/PluginLoader.cs @@ -0,0 +1,59 @@ +using System.Reflection; +using System.Runtime.Loader; +using IdentityShroud.PluginSupport; + +namespace IdentityShroud.Core.Plugins; + +public static class PluginLoader +{ + public static IEnumerable LoadPlugins(string pluginsFolder) + { + if (!Directory.Exists(pluginsFolder)) + yield break; + + foreach (var dll in Directory.EnumerateFiles(pluginsFolder, "*.dll")) + { + foreach (var plugin in LoadPluginDll(dll)) yield return plugin; + } + } + + private static IEnumerable LoadPluginDll(string dll) + { + Assembly asm; + try + { + asm = AssemblyLoadContext.Default.LoadFromAssemblyPath(Path.GetFullPath(dll)); + } + catch + { + yield break; + } + + IEnumerable pluginTypes; + try + { + pluginTypes = asm.GetTypes() + .Where(t => typeof(IPlugin).IsAssignableFrom(t) && t is { IsInterface: false, IsAbstract: false }); + } + catch + { + yield break; + } + + foreach (var t in pluginTypes) + { + IPlugin? instance = null; + try + { + instance = (IPlugin?)Activator.CreateInstance(t); + } + catch + { + // ignore bad plugin types + } + + if (instance != null) + yield return instance; + } + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Plugins/PluginRegistry.cs b/IdentityShroud.Core/Plugins/PluginRegistry.cs new file mode 100644 index 0000000..d58863c --- /dev/null +++ b/IdentityShroud.Core/Plugins/PluginRegistry.cs @@ -0,0 +1,18 @@ +using System.Collections.ObjectModel; +using IdentityShroud.PluginSupport; + +namespace IdentityShroud.Core.Plugins; + +/// +/// Note +/// +/// +public class PluginRegistry where TPlugin : IPlugin +{ + private ReadOnlyDictionary _plugins; + + public PluginRegistry(ReadOnlyDictionary plugins) + { + _plugins = plugins; + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Encryption.cs b/IdentityShroud.Core/Security/Encryption.cs index 5fe274e..01c8843 100644 --- a/IdentityShroud.Core/Security/Encryption.cs +++ b/IdentityShroud.Core/Security/Encryption.cs @@ -35,36 +35,45 @@ public static class Encryption return result; } - public static byte[] Decrypt(ReadOnlyMemory input, ReadOnlySpan key) + public static void Decrypt(ReadOnlyMemory input, ReadOnlySpan key, Span output) { - var payload = input.Span; - 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)); + AlgVersion versionParams = GetVersionParams(input); + if (input.Length < 1 + versionParams.NonceSize + versionParams.TagSize) + throw new ArgumentException("Cypher data is too short to be valid.", nameof(input)); + var payload = input.Span; ReadOnlySpan nonce = payload.Slice(1, versionParams.NonceSize); ReadOnlySpan tag = payload.Slice(1 + versionParams.NonceSize, versionParams.TagSize); ReadOnlySpan cipher = payload.Slice(1 + versionParams.NonceSize + versionParams.TagSize); - byte[] plaintext = new byte[cipher.Length]; - using var aes = new AesGcm(key, versionParams.TagSize); try { - aes.Decrypt(nonce, cipher, tag, plaintext); + aes.Decrypt(nonce, cipher, tag, output); } catch (CryptographicException ex) { // Tag verification failed → tampering or wrong key/nonce. throw new InvalidOperationException("Decryption failed – authentication tag mismatch.", ex); } + } - return plaintext; + public static int GetDecryptedLength(ReadOnlyMemory input) + { + AlgVersion versionParams = GetVersionParams(input); + int length = input.Length - (1 + versionParams.NonceSize + versionParams.TagSize); + if (length < 0) + throw new ArgumentException("Cypher data is too short to be valid.", nameof(input)); + + return length; + } + + private static AlgVersion GetVersionParams(ReadOnlyMemory input) + { + var versionNumber = (int)input.Span[0]; + if (versionNumber != 1) + throw new ArgumentException("Invalid payload"); + + return _versions[versionNumber]; } } \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Jwt/IJwtSignatureProvider.cs b/IdentityShroud.Core/Security/Jwt/IJwtSigner.cs similarity index 61% rename from IdentityShroud.Core/Security/Jwt/IJwtSignatureProvider.cs rename to IdentityShroud.Core/Security/Jwt/IJwtSigner.cs index 5100436..80fc37e 100644 --- a/IdentityShroud.Core/Security/Jwt/IJwtSignatureProvider.cs +++ b/IdentityShroud.Core/Security/Jwt/IJwtSigner.cs @@ -1,8 +1,9 @@ using System.Text.Json; +using IdentityShroud.Core.Model; namespace IdentityShroud.Core; -public interface IJwtSignatureProvider : IDisposable +public interface IJwtSigner { /* Of the signature and MAC algorithms specified in JSON Web Algorithms @@ -13,14 +14,7 @@ public interface IJwtSignatureProvider : IDisposable hash algorithm ("ES256"). Support for other algorithms and key sizes is OPTIONAL. */ + IReadOnlyList Algorithms { get; } - void WriteJwtHeaderFields(Utf8JsonWriter writer); - /// - /// Length of the binary signature in bytes. - /// - /// - int GetSignatureLength(); - - void CalculateSignature(ReadOnlySpan jwt, Span signatureOut); - + byte[] CalculateSignature(JwtSigAlgName algName, DecryptedSigningKey key, ReadOnlySpan jwt); } \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Jwt/IJwtSignerFactory.cs b/IdentityShroud.Core/Security/Jwt/IJwtSignerFactory.cs new file mode 100644 index 0000000..fbab369 --- /dev/null +++ b/IdentityShroud.Core/Security/Jwt/IJwtSignerFactory.cs @@ -0,0 +1,6 @@ +namespace IdentityShroud.Core; + +public interface IJwtSignerFactory +{ + IJwtSigner Create(JwtSigAlgName algorithm); +} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Jwt/JwtSignatureGenerator.cs b/IdentityShroud.Core/Security/Jwt/JwtSignatureGenerator.cs index 6143f35..99b9097 100644 --- a/IdentityShroud.Core/Security/Jwt/JwtSignatureGenerator.cs +++ b/IdentityShroud.Core/Security/Jwt/JwtSignatureGenerator.cs @@ -2,6 +2,7 @@ using System.Buffers.Text; using System.Security.Cryptography; using System.Text; using System.Text.Json; +using IdentityShroud.Core.Model; using Microsoft.AspNetCore.WebUtilities; namespace IdentityShroud.Core; @@ -40,49 +41,48 @@ public static class JwtSignatureGenerator } -public static class JwtCreator +public class JwtService(IJwtSignerFactory signerFactory) { - public static byte[] CreateEncodedJwt(ReadOnlySpan payloadUtf8, IJwtSignatureProvider signatureProvider) + public byte[] CreateEncodedJwt(ReadOnlySpan payloadUtf8, JwtSigAlgName algName, DecryptedSigningKey key) { - MemoryStream memStream = new(); - Utf8JsonWriter writer = new(memStream); - WriteJwtHeader(writer, signatureProvider); - writer.Flush(); - memStream.Seek(0, SeekOrigin.Begin); + // LATER might be able to improve performance using ArrayPool + + IJwtSigner signer = signerFactory.Create(algName); + MemoryStream headerMemStream = new(); + Utf8JsonWriter headerWriter = new(headerMemStream); + WriteJwtHeader(headerWriter, algName, key.Id.ToString()); + headerWriter.Flush(); + headerMemStream.Seek(0, SeekOrigin.Begin); - int headerBase64Length = Base64Url.GetEncodedLength((int)memStream.Length); + int headerBase64Length = Base64Url.GetEncodedLength((int)headerMemStream.Length); int payloadBase64Length = Base64Url.GetEncodedLength(payloadUtf8.Length); - int signatureBase64Length = Base64Url.GetEncodedLength(signatureProvider.GetSignatureLength()); - int totalLength = headerBase64Length + 1 + payloadBase64Length + 1 + signatureBase64Length; - - var completeJwt = new byte[totalLength]; + var jwtData = new byte[headerBase64Length + payloadBase64Length + 1]; // - var byteArray = new byte[memStream.Length]; - memStream.ReadExactly(byteArray, 0, (int)memStream.Length); - int written = Base64Url.EncodeToUtf8(byteArray, completeJwt); + var byteArray = new byte[headerMemStream.Length]; + headerMemStream.ReadExactly(byteArray, 0, (int)headerMemStream.Length); + int written = Base64Url.EncodeToUtf8(byteArray, jwtData); if (written != headerBase64Length) throw new Exception("expected header length did not match bytes written"); - completeJwt[headerBase64Length] = (byte)'.'; + jwtData[headerBase64Length] = (byte)'.'; - written = Base64Url.EncodeToUtf8(payloadUtf8, completeJwt.AsSpan().Slice(headerBase64Length + 1, payloadBase64Length)); + written = Base64Url.EncodeToUtf8(payloadUtf8, jwtData.AsSpan().Slice(headerBase64Length + 1, payloadBase64Length)); if (written != payloadBase64Length) throw new Exception("expected payload length did not match bytes written"); - completeJwt[headerBase64Length + 1 + payloadBase64Length] = (byte)'.'; + byte[] signature = signer.CalculateSignature(algName, key, jwtData.AsSpan()); + int signatureBase64Length = Base64Url.GetEncodedLength(signature.Length); - Span signature = stackalloc byte[signatureProvider.GetSignatureLength()]; - signatureProvider.CalculateSignature( - completeJwt.AsSpan().Slice(0, headerBase64Length + 1 + payloadBase64Length), - signature); - - written = Base64Url.EncodeToUtf8(signature, completeJwt.AsSpan() - .Slice(headerBase64Length + 1 + payloadBase64Length + 1)); + var completeJwt = new byte[jwtData.Length + 1 + signatureBase64Length]; + Array.Copy(jwtData, completeJwt, jwtData.Length); + completeJwt[jwtData.Length] = (byte)'.'; + + written = Base64Url.EncodeToUtf8(signature, completeJwt.AsSpan().Slice(jwtData.Length + 1, signatureBase64Length)); if (written != signatureBase64Length) throw new Exception("expected signature length did not match bytes written"); @@ -90,11 +90,12 @@ public static class JwtCreator return completeJwt; } - private static void WriteJwtHeader(Utf8JsonWriter writer, IJwtSignatureProvider signatureProvider) + private static void WriteJwtHeader(Utf8JsonWriter writer, JwtSigAlgName algName, string keyId) { writer.WriteStartObject(); writer.WriteString("typ"u8, "JWT"u8); - signatureProvider.WriteJwtHeaderFields(writer); + writer.WriteString("alg"u8, algName.ToString()); + writer.WriteString("kid"u8, keyId); writer.WriteEndObject(); } } \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Jwt/JwtSignerFactory.cs b/IdentityShroud.Core/Security/Jwt/JwtSignerFactory.cs new file mode 100644 index 0000000..85ffe21 --- /dev/null +++ b/IdentityShroud.Core/Security/Jwt/JwtSignerFactory.cs @@ -0,0 +1,17 @@ +namespace IdentityShroud.Core; + +public class JwtSignerFactory(IEnumerable signers) : IJwtSignerFactory +{ + private readonly IReadOnlyDictionary _signers = signers + .SelectMany(s => s.Algorithms.Select(alg => (alg, signer: s))) + .ToDictionary(x => x.alg, x => x.signer); + + public IJwtSigner Create(JwtSigAlgName algorithm) + { + if (_signers.TryGetValue(algorithm, out var signer)) + return signer; + + throw new NotSupportedException($"JWT signing algorithm '{algorithm}' is not registered."); + } + +} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Jwt/RsaJwtSignatureProvider.cs b/IdentityShroud.Core/Security/Jwt/RsaJwtSignatureProvider.cs deleted file mode 100644 index ab33a72..0000000 --- a/IdentityShroud.Core/Security/Jwt/RsaJwtSignatureProvider.cs +++ /dev/null @@ -1,88 +0,0 @@ -using System.Security.Cryptography; -using System.Text.Json; -using IdentityShroud.Core.Model; -using IdentityShroud.Core.Services; - -namespace IdentityShroud.Core; - -public class SignatureProviderFactory(DekEncryptionService dekCryptor, IServiceProvider services) -{ - - public static void SelectAlgorithmAndKey(Realm realm, Client client, out JwtSigAlgName alg, out byte[] key) - { - throw new NotImplementedException(); - } - - public IJwtSignatureProvider Create(JwtSigAlgName algorithm, byte[] keyData) - { - - - //realm.DefaultSignatureAlgorithm - //realm.TokenSigningKeys - - //IJwtSignatureProvider? sigProvider = services.GetKeyedService(); - throw new NotImplementedException(); - } - -} - -public class RsaJwtSignatureProvider : IJwtSignatureProvider -{ - private JwtSigAlgName _sigAlgName; - private RealmSigningKeyId _keyId; - private readonly RSA _rsa; - - public RsaJwtSignatureProvider(DekEncryptionService dekCryptor, - RealmSigningKey privateKey, - JwtSigAlgName sigAlgName) - { - _sigAlgName = sigAlgName; - _keyId = privateKey.Id; - - byte[] key = dekCryptor.Decrypt(privateKey.Key); - _rsa = RSA.Create(); - _rsa.ImportPkcs8PrivateKey(key, out int _); - } - - /* - +-------------------+---------------------------------+ - | "alg" Param Value | Digital Signature Algorithm | - +-------------------+---------------------------------+ - | RS256 | RSASSA-PKCS1-v1_5 using SHA-256 | - | RS384 | RSASSA-PKCS1-v1_5 using SHA-384 | - | RS512 | RSASSA-PKCS1-v1_5 using SHA-512 | - +-------------------+---------------------------------+ - */ - - - public void WriteJwtHeaderFields(Utf8JsonWriter writer) - { - writer.WriteString("alg"u8, _sigAlgName.ToString()); - writer.WriteString("kid"u8, _keyId.ToString()); - } - - public int GetSignatureLength() - { - return _rsa.KeySize / 8; - } - - public void CalculateSignature(ReadOnlySpan jwt, Span sig) - { - _rsa.SignData(jwt, sig, GetHashAlgorithmName(), RSASignaturePadding.Pkcs1); - } - - public void Dispose() - { - _rsa.Dispose(); - } - - private HashAlgorithmName GetHashAlgorithmName() - => _sigAlgName.Name switch - { - "RS256" => HashAlgorithmName.SHA256, - "RS384" => HashAlgorithmName.SHA384, - "RS512" => HashAlgorithmName.SHA512, - _ => throw new ArgumentException("Invalid algorithm for RsaJwtSignatureProvider") - }; - -} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/Jwt/RsaJwtSigner.cs b/IdentityShroud.Core/Security/Jwt/RsaJwtSigner.cs new file mode 100644 index 0000000..80af03c --- /dev/null +++ b/IdentityShroud.Core/Security/Jwt/RsaJwtSigner.cs @@ -0,0 +1,36 @@ +using System.Security.Cryptography; +using IdentityShroud.Core.Model; + +namespace IdentityShroud.Core; + +public class RsaJwtSigner : IJwtSigner +{ + public IReadOnlyList Algorithms => [JwtSigAlgName.RS256, JwtSigAlgName.RS384, JwtSigAlgName.RS512]; + + // +-------------------+---------------------------------+ + // | "alg" Param Value | Digital Signature Algorithm | + // +-------------------+---------------------------------+ + // | RS256 | RSASSA-PKCS1-v1_5 using SHA-256 | + // | RS384 | RSASSA-PKCS1-v1_5 using SHA-384 | + // | RS512 | RSASSA-PKCS1-v1_5 using SHA-512 | + // +-------------------+---------------------------------+ + + public byte[] CalculateSignature(JwtSigAlgName algName, DecryptedSigningKey key, ReadOnlySpan jwt) + { + using var rsa = RSA.Create(); + rsa.ImportPkcs8PrivateKey(key.KeyData, out int _); + var sig = new byte[rsa.KeySize / 8]; + rsa.SignData(jwt, sig, GetHashAlgorithmName(algName), RSASignaturePadding.Pkcs1); + return sig; + } + + private static HashAlgorithmName GetHashAlgorithmName(JwtSigAlgName algName) + => algName.Name switch + { + "RS256" => HashAlgorithmName.SHA256, + "RS384" => HashAlgorithmName.SHA384, + "RS512" => HashAlgorithmName.SHA512, + _ => throw new ArgumentException("Invalid algorithm for RsaJwtSignatureProvider") + }; + +} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/DataEncryptionService.cs b/IdentityShroud.Core/Services/DataEncryptionService.cs index 9d8f092..be0cf51 100644 --- a/IdentityShroud.Core/Services/DataEncryptionService.cs +++ b/IdentityShroud.Core/Services/DataEncryptionService.cs @@ -1,3 +1,4 @@ +using System.Security.Cryptography; using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Model; using IdentityShroud.Core.Security; @@ -9,9 +10,17 @@ public class DataEncryptionService( { public EncryptedValue Encrypt(RealmDek dek, ReadOnlySpan plain) { - var key = dekCryptor.Decrypt(dek.KeyData); - byte[] cipher = Encryption.Encrypt(plain, key); - return new (dek.Id, cipher); + Span key = stackalloc byte[dekCryptor.GetDecryptedSize(dek.KeyData)]; + try + { + dekCryptor.Decrypt(dek.KeyData, key); + byte[] cipher = Encryption.Encrypt(plain, key); + return new (dek.Id, cipher); + } + finally + { + CryptographicOperations.ZeroMemory(key); + } } public byte[] Decrypt(IReadOnlyList deks, EncryptedValue input) @@ -20,8 +29,19 @@ public class DataEncryptionService( // - Old key removed before migration completed (should not be possible) // - Wrong keyset because of programming error. var dek = deks.SingleOrDefault(d => d.Id == input.DekId) - ?? throw new InvalidOperationException("Required key not found"); - var key = dekCryptor.Decrypt(dek.KeyData); - return Encryption.Decrypt(input.Value, key); + ?? throw new InvalidOperationException("Required key not found"); + + Span key = stackalloc byte[dekCryptor.GetDecryptedSize(dek.KeyData)]; + try + { + dekCryptor.Decrypt(dek.KeyData, key); + byte[] output = new byte[Encryption.GetDecryptedLength(input.Value)]; + Encryption.Decrypt(input.Value, key, output); + return output; + } + finally + { + CryptographicOperations.ZeroMemory(key); + } } } \ No newline at end of file diff --git a/IdentityShroud.Core/Services/DekEncryptionService.cs b/IdentityShroud.Core/Services/DekEncryptionService.cs index add9267..b80ea4d 100644 --- a/IdentityShroud.Core/Services/DekEncryptionService.cs +++ b/IdentityShroud.Core/Services/DekEncryptionService.cs @@ -18,8 +18,6 @@ public class DekEncryptionService : IDekEncryptionService public DekEncryptionService(ISecretProvider secretProvider) { _encryptionKeys = secretProvider.GetKeys("master"); - // if (_encryptionKey.Length != 32) // 256‑bit key - // throw new Exception("Key must be 256 bits (32 bytes) for AES‑256‑GCM."); } public EncryptedDek Encrypt(ReadOnlySpan plaintext) @@ -29,10 +27,14 @@ public class DekEncryptionService : IDekEncryptionService return new (encryptionKey.Id, cipher); } - public byte[] Decrypt(EncryptedDek input) + public void Decrypt(EncryptedDek input, Span output) { var encryptionKey = GetKey(input.KekId); + Encryption.Decrypt(input.Value, encryptionKey.Key, output); + } - return Encryption.Decrypt(input.Value, encryptionKey.Key); + public int GetDecryptedSize(EncryptedDek input) + { + return Encryption.GetDecryptedLength(input.Value); } } \ No newline at end of file diff --git a/IdentityShroud.GraphQL/IdentityShroud.GraphQL.csproj b/IdentityShroud.GraphQL/IdentityShroud.GraphQL.csproj new file mode 100644 index 0000000..b0de15c --- /dev/null +++ b/IdentityShroud.GraphQL/IdentityShroud.GraphQL.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + diff --git a/IdentityShroud.GraphQL/Query.cs b/IdentityShroud.GraphQL/Query.cs new file mode 100644 index 0000000..5ccbeb1 --- /dev/null +++ b/IdentityShroud.GraphQL/Query.cs @@ -0,0 +1,26 @@ +susing IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Model; + +namespace IdentityShroud.GraphQL; + +public class Query +{ + public string GetHello() => "Hello, world!"; + + public async Task GetRealms( + Guid id, + [Service] IRealmService realmService) + { + return await realmService.FindById(id); + } +} + +public class Mutation +{ + public async Task RealmCreate(string name) + { + Realm r = new(); + + return r; + } +} \ No newline at end of file diff --git a/IdentityShroud.GraphQL/RegistrationExtensions.cs b/IdentityShroud.GraphQL/RegistrationExtensions.cs new file mode 100644 index 0000000..dad3056 --- /dev/null +++ b/IdentityShroud.GraphQL/RegistrationExtensions.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace IdentityShroud.GraphQL; + +public static class RegistrationExtensions +{ + extension(IServiceCollection services) + { + public IServiceCollection AddIdentityShroudGraphQL() + { + services + .AddGraphQLServer() + .AddMutationConventions(applyToAllMutations: true) + .AddMutationType() + .AddQueryType(); + + return services; + } + } + + extension(IEndpointRouteBuilder app) + { + public IEndpointRouteBuilder MapIdentityShroudGraphQL() + { + app.MapGraphQL(); + return app; + } + } +} \ No newline at end of file diff --git a/IdentityShroud.Migrations/Migrations/20260412083710_Initial.Designer.cs b/IdentityShroud.Migrations/Migrations/20260412083710_Initial.Designer.cs new file mode 100644 index 0000000..6c3df6d --- /dev/null +++ b/IdentityShroud.Migrations/Migrations/20260412083710_Initial.Designer.cs @@ -0,0 +1,318 @@ +// +using System; +using System.Collections.Generic; +using IdentityShroud.Core.EFCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace IdentityShroud.Migrations.Migrations +{ + [DbContext(typeof(Db))] + [Migration("20260412083710_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("IdentityShroud.Core.Model.Client", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowClientCredentialsFlow") + .HasColumnType("boolean") + .HasColumnName("allow_client_credentials_flow"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("client_id"); + + b.Property("Confidential") + .HasColumnType("boolean") + .HasColumnName("confidential"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("description"); + + b.Property("Name") + .HasMaxLength(80) + .HasColumnType("character varying(80)") + .HasColumnName("name"); + + b.Property("RealmId") + .HasColumnType("uuid") + .HasColumnName("realm_id"); + + b.Property("SignatureAlgorithm") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("signature_algorithm"); + + b.HasKey("Id") + .HasName("pk_client"); + + b.HasIndex("ClientId") + .IsUnique() + .HasDatabaseName("ix_client_client_id"); + + b.HasIndex("RealmId") + .HasDatabaseName("ix_client_realm_id"); + + b.ToTable("client", (string)null); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.ClientSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientId") + .HasColumnType("uuid") + .HasColumnName("client_id"); + + b.Property("ClientId1") + .HasColumnType("integer") + .HasColumnName("client_id1"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Expires") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.ComplexProperty(typeof(Dictionary), "Secret", "IdentityShroud.Core.Model.ClientSecret.Secret#EncryptedValue", b1 => + { + b1.IsRequired(); + + b1.Property("DekId") + .HasColumnType("uuid") + .HasColumnName("secret_dek_id"); + + b1.Property("Value") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("secret_value"); + }); + + b.HasKey("Id") + .HasName("pk_client_secret"); + + b.HasIndex("ClientId1") + .HasDatabaseName("ix_client_secret_client_id1"); + + b.ToTable("client_secret", (string)null); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.Realm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultSignatureAlgorithm") + .IsRequired() + .HasColumnType("text") + .HasColumnName("default_signature_algorithm"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_realm"); + + b.ToTable("realm", (string)null); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.RealmDek", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Active") + .HasColumnType("boolean") + .HasColumnName("active"); + + b.Property("Algorithm") + .IsRequired() + .HasColumnType("text") + .HasColumnName("algorithm"); + + b.Property("RealmId") + .HasColumnType("uuid") + .HasColumnName("realm_id"); + + b.ComplexProperty(typeof(Dictionary), "KeyData", "IdentityShroud.Core.Model.RealmDek.KeyData#EncryptedDek", b1 => + { + b1.IsRequired(); + + b1.Property("KekId") + .HasColumnType("uuid") + .HasColumnName("key_data_kek_id"); + + b1.Property("Value") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("key_data_value"); + }); + + b.HasKey("Id") + .HasName("pk_realm_dek"); + + b.HasIndex("RealmId") + .HasDatabaseName("ix_realm_dek_realm_id"); + + b.ToTable("realm_dek", (string)null); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.RealmSigningKey", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("KeyType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("key_type"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("PublicKeyParameters") + .HasColumnType("jsonb") + .HasColumnName("public_key_parameters"); + + b.Property("RealmId") + .HasColumnType("uuid") + .HasColumnName("realm_id"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.ComplexProperty(typeof(Dictionary), "Key", "IdentityShroud.Core.Model.RealmSigningKey.Key#EncryptedDek", b1 => + { + b1.IsRequired(); + + b1.Property("KekId") + .HasColumnType("uuid") + .HasColumnName("key_kek_id"); + + b1.Property("Value") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("key_value"); + }); + + b.HasKey("Id") + .HasName("pk_realm_key"); + + b.HasIndex("RealmId") + .HasDatabaseName("ix_realm_key_realm_id"); + + b.ToTable("realm_key", (string)null); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.Client", b => + { + b.HasOne("IdentityShroud.Core.Model.Realm", null) + .WithMany("Clients") + .HasForeignKey("RealmId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_client_realm_realm_id"); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.ClientSecret", b => + { + b.HasOne("IdentityShroud.Core.Model.Client", null) + .WithMany("Secrets") + .HasForeignKey("ClientId1") + .HasConstraintName("fk_client_secret_client_client_id1"); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.RealmDek", b => + { + b.HasOne("IdentityShroud.Core.Model.Realm", null) + .WithMany("DataEncryptionKeys") + .HasForeignKey("RealmId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_realm_dek_realm_realm_id"); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.RealmSigningKey", b => + { + b.HasOne("IdentityShroud.Core.Model.Realm", null) + .WithMany("TokenSigningKeys") + .HasForeignKey("RealmId") + .HasConstraintName("fk_realm_key_realm_realm_id"); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.Client", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.Realm", b => + { + b.Navigation("Clients"); + + b.Navigation("DataEncryptionKeys"); + + b.Navigation("TokenSigningKeys"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/IdentityShroud.Migrations/Migrations/20260412083710_Initial.cs b/IdentityShroud.Migrations/Migrations/20260412083710_Initial.cs new file mode 100644 index 0000000..78401bb --- /dev/null +++ b/IdentityShroud.Migrations/Migrations/20260412083710_Initial.cs @@ -0,0 +1,171 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace IdentityShroud.Migrations.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "realm", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + slug = table.Column(type: "character varying(40)", maxLength: 40, nullable: false), + name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + default_signature_algorithm = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_realm", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "client", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + realm_id = table.Column(type: "uuid", nullable: false), + client_id = table.Column(type: "character varying(40)", maxLength: 40, nullable: false), + name = table.Column(type: "character varying(80)", maxLength: 80, nullable: true), + description = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), + signature_algorithm = table.Column(type: "character varying(20)", maxLength: 20, nullable: true), + confidential = table.Column(type: "boolean", nullable: false), + allow_client_credentials_flow = table.Column(type: "boolean", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_client", x => x.id); + table.ForeignKey( + name: "fk_client_realm_realm_id", + column: x => x.realm_id, + principalTable: "realm", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "realm_dek", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + active = table.Column(type: "boolean", nullable: false), + algorithm = table.Column(type: "text", nullable: false), + realm_id = table.Column(type: "uuid", nullable: false), + key_data_kek_id = table.Column(type: "uuid", nullable: false), + key_data_value = table.Column(type: "bytea", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_realm_dek", x => x.id); + table.ForeignKey( + name: "fk_realm_dek_realm_realm_id", + column: x => x.realm_id, + principalTable: "realm", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "realm_key", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + key_type = table.Column(type: "text", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + revoked_at = table.Column(type: "timestamp with time zone", nullable: true), + priority = table.Column(type: "integer", nullable: false), + public_key_parameters = table.Column(type: "jsonb", nullable: true), + realm_id = table.Column(type: "uuid", nullable: true), + key_kek_id = table.Column(type: "uuid", nullable: false), + key_value = table.Column(type: "bytea", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_realm_key", x => x.id); + table.ForeignKey( + name: "fk_realm_key_realm_realm_id", + column: x => x.realm_id, + principalTable: "realm", + principalColumn: "id"); + }); + + migrationBuilder.CreateTable( + name: "client_secret", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + client_id = table.Column(type: "uuid", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + expires = table.Column(type: "timestamp with time zone", nullable: true), + revoked_at = table.Column(type: "timestamp with time zone", nullable: true), + client_id1 = table.Column(type: "integer", nullable: true), + secret_dek_id = table.Column(type: "uuid", nullable: false), + secret_value = table.Column(type: "bytea", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_client_secret", x => x.id); + table.ForeignKey( + name: "fk_client_secret_client_client_id1", + column: x => x.client_id1, + principalTable: "client", + principalColumn: "id"); + }); + + migrationBuilder.CreateIndex( + name: "ix_client_client_id", + table: "client", + column: "client_id", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_client_realm_id", + table: "client", + column: "realm_id"); + + migrationBuilder.CreateIndex( + name: "ix_client_secret_client_id1", + table: "client_secret", + column: "client_id1"); + + migrationBuilder.CreateIndex( + name: "ix_realm_dek_realm_id", + table: "realm_dek", + column: "realm_id"); + + migrationBuilder.CreateIndex( + name: "ix_realm_key_realm_id", + table: "realm_key", + column: "realm_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "client_secret"); + + migrationBuilder.DropTable( + name: "realm_dek"); + + migrationBuilder.DropTable( + name: "realm_key"); + + migrationBuilder.DropTable( + name: "client"); + + migrationBuilder.DropTable( + name: "realm"); + } + } +} diff --git a/IdentityShroud.Migrations/Migrations/DbModelSnapshot.cs b/IdentityShroud.Migrations/Migrations/DbModelSnapshot.cs new file mode 100644 index 0000000..f16ec76 --- /dev/null +++ b/IdentityShroud.Migrations/Migrations/DbModelSnapshot.cs @@ -0,0 +1,315 @@ +// +using System; +using System.Collections.Generic; +using IdentityShroud.Core.EFCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace IdentityShroud.Migrations.Migrations +{ + [DbContext(typeof(Db))] + partial class DbModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("IdentityShroud.Core.Model.Client", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowClientCredentialsFlow") + .HasColumnType("boolean") + .HasColumnName("allow_client_credentials_flow"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("client_id"); + + b.Property("Confidential") + .HasColumnType("boolean") + .HasColumnName("confidential"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("description"); + + b.Property("Name") + .HasMaxLength(80) + .HasColumnType("character varying(80)") + .HasColumnName("name"); + + b.Property("RealmId") + .HasColumnType("uuid") + .HasColumnName("realm_id"); + + b.Property("SignatureAlgorithm") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("signature_algorithm"); + + b.HasKey("Id") + .HasName("pk_client"); + + b.HasIndex("ClientId") + .IsUnique() + .HasDatabaseName("ix_client_client_id"); + + b.HasIndex("RealmId") + .HasDatabaseName("ix_client_realm_id"); + + b.ToTable("client", (string)null); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.ClientSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientId") + .HasColumnType("uuid") + .HasColumnName("client_id"); + + b.Property("ClientId1") + .HasColumnType("integer") + .HasColumnName("client_id1"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Expires") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.ComplexProperty(typeof(Dictionary), "Secret", "IdentityShroud.Core.Model.ClientSecret.Secret#EncryptedValue", b1 => + { + b1.IsRequired(); + + b1.Property("DekId") + .HasColumnType("uuid") + .HasColumnName("secret_dek_id"); + + b1.Property("Value") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("secret_value"); + }); + + b.HasKey("Id") + .HasName("pk_client_secret"); + + b.HasIndex("ClientId1") + .HasDatabaseName("ix_client_secret_client_id1"); + + b.ToTable("client_secret", (string)null); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.Realm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultSignatureAlgorithm") + .IsRequired() + .HasColumnType("text") + .HasColumnName("default_signature_algorithm"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_realm"); + + b.ToTable("realm", (string)null); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.RealmDek", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Active") + .HasColumnType("boolean") + .HasColumnName("active"); + + b.Property("Algorithm") + .IsRequired() + .HasColumnType("text") + .HasColumnName("algorithm"); + + b.Property("RealmId") + .HasColumnType("uuid") + .HasColumnName("realm_id"); + + b.ComplexProperty(typeof(Dictionary), "KeyData", "IdentityShroud.Core.Model.RealmDek.KeyData#EncryptedDek", b1 => + { + b1.IsRequired(); + + b1.Property("KekId") + .HasColumnType("uuid") + .HasColumnName("key_data_kek_id"); + + b1.Property("Value") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("key_data_value"); + }); + + b.HasKey("Id") + .HasName("pk_realm_dek"); + + b.HasIndex("RealmId") + .HasDatabaseName("ix_realm_dek_realm_id"); + + b.ToTable("realm_dek", (string)null); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.RealmSigningKey", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("KeyType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("key_type"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("PublicKeyParameters") + .HasColumnType("jsonb") + .HasColumnName("public_key_parameters"); + + b.Property("RealmId") + .HasColumnType("uuid") + .HasColumnName("realm_id"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.ComplexProperty(typeof(Dictionary), "Key", "IdentityShroud.Core.Model.RealmSigningKey.Key#EncryptedDek", b1 => + { + b1.IsRequired(); + + b1.Property("KekId") + .HasColumnType("uuid") + .HasColumnName("key_kek_id"); + + b1.Property("Value") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("key_value"); + }); + + b.HasKey("Id") + .HasName("pk_realm_key"); + + b.HasIndex("RealmId") + .HasDatabaseName("ix_realm_key_realm_id"); + + b.ToTable("realm_key", (string)null); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.Client", b => + { + b.HasOne("IdentityShroud.Core.Model.Realm", null) + .WithMany("Clients") + .HasForeignKey("RealmId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_client_realm_realm_id"); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.ClientSecret", b => + { + b.HasOne("IdentityShroud.Core.Model.Client", null) + .WithMany("Secrets") + .HasForeignKey("ClientId1") + .HasConstraintName("fk_client_secret_client_client_id1"); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.RealmDek", b => + { + b.HasOne("IdentityShroud.Core.Model.Realm", null) + .WithMany("DataEncryptionKeys") + .HasForeignKey("RealmId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_realm_dek_realm_realm_id"); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.RealmSigningKey", b => + { + b.HasOne("IdentityShroud.Core.Model.Realm", null) + .WithMany("TokenSigningKeys") + .HasForeignKey("RealmId") + .HasConstraintName("fk_realm_key_realm_realm_id"); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.Client", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("IdentityShroud.Core.Model.Realm", b => + { + b.Navigation("Clients"); + + b.Navigation("DataEncryptionKeys"); + + b.Navigation("TokenSigningKeys"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/IdentityShroud.PluginSupport/IPlugin.cs b/IdentityShroud.PluginSupport/IPlugin.cs new file mode 100644 index 0000000..fcae4fc --- /dev/null +++ b/IdentityShroud.PluginSupport/IPlugin.cs @@ -0,0 +1,9 @@ +namespace IdentityShroud.PluginSupport; + +/// +/// Any class that should be discovered when loading a dll should implement IPlugin +/// +public interface IPlugin +{ + +} \ No newline at end of file diff --git a/IdentityShroud.PluginSupport/IdentityShroud.PluginSupport.csproj b/IdentityShroud.PluginSupport/IdentityShroud.PluginSupport.csproj new file mode 100644 index 0000000..237d661 --- /dev/null +++ b/IdentityShroud.PluginSupport/IdentityShroud.PluginSupport.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/IdentityShroud.SecretProviders/ISecretProvider.cs b/IdentityShroud.SecretProviders/ISecretProvider.cs new file mode 100644 index 0000000..666dd1e --- /dev/null +++ b/IdentityShroud.SecretProviders/ISecretProvider.cs @@ -0,0 +1,15 @@ +namespace IdentityShroud.SecretProviders; + +/// +/// Required interface of a SecretProvider. +/// +public interface ISecretProvider +{ + /// + /// Used as a key in the registry. This same value should be used for the type field + /// when configuring a secret. + /// + string Key { get; } + + Task GetSecret(string configurationValue, CancellationToken ct = default); +} \ No newline at end of file diff --git a/IdentityShroud.SecretProviders/IdentityShroud.SecretProviders.csproj b/IdentityShroud.SecretProviders/IdentityShroud.SecretProviders.csproj new file mode 100644 index 0000000..3de771e --- /dev/null +++ b/IdentityShroud.SecretProviders/IdentityShroud.SecretProviders.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/IdentityShroud.SecretProviders/PlainSecret.cs b/IdentityShroud.SecretProviders/PlainSecret.cs new file mode 100644 index 0000000..9aca02a --- /dev/null +++ b/IdentityShroud.SecretProviders/PlainSecret.cs @@ -0,0 +1,18 @@ +using System.Security.Cryptography; + +namespace IdentityShroud.SecretProviders; + +public sealed class PlainSecret(byte[] secret) : IDisposable +{ + private bool _disposed; + + public ReadOnlySpan Secret => secret; + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + CryptographicOperations.ZeroMemory(secret); + } +} \ No newline at end of file diff --git a/IdentityShroud.TestUtils/Substitutes/NullDekEncryptionService.cs b/IdentityShroud.TestUtils/Substitutes/NullDekEncryptionService.cs index 879f932..84f9cd7 100644 --- a/IdentityShroud.TestUtils/Substitutes/NullDekEncryptionService.cs +++ b/IdentityShroud.TestUtils/Substitutes/NullDekEncryptionService.cs @@ -11,6 +11,16 @@ public class NullDekEncryptionService : IDekEncryptionService return new(KeyId, plain.ToArray()); } + public void Decrypt(EncryptedDek input, Span output) + { + input.Value.CopyTo(output); + } + + public int GetDecryptedSize(EncryptedDek input) + { + return input.Value.Length; + } + public byte[] Decrypt(EncryptedDek input) { return input.Value; diff --git a/IdentityShroud.sln b/IdentityShroud.sln index 4fd0005..ce316d1 100644 --- a/IdentityShroud.sln +++ b/IdentityShroud.sln @@ -20,6 +20,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "08_Tests", "08_Tests", "{98 EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "01", "01", "{07B08872-1141-4BE6-87E6-B85E52FE4341}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityShroud.GraphQL", "IdentityShroud.GraphQL\IdentityShroud.GraphQL.csproj", "{8E9BAD89-B964-4AC2-9773-8CB7E93F76C8}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "04 Plugin Support", "04 Plugin Support", "{ABF0B435-2D50-41C8-847B-0905136537AB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityShroud.SecretProviders", "IdentityShroud.SecretProviders\IdentityShroud.SecretProviders.csproj", "{A27A70F7-415B-4F06-8A2C-1399675D41AE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityShroud.PluginSupport", "IdentityShroud.PluginSupport\IdentityShroud.PluginSupport.csproj", "{D4984187-8283-4494-88CF-35EDAD13E4DE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -54,6 +62,18 @@ Global {35D33207-27A8-43E9-A8CA-A158A1E4448C}.Debug|Any CPU.Build.0 = Debug|Any CPU {35D33207-27A8-43E9-A8CA-A158A1E4448C}.Release|Any CPU.ActiveCfg = Release|Any CPU {35D33207-27A8-43E9-A8CA-A158A1E4448C}.Release|Any CPU.Build.0 = Release|Any CPU + {8E9BAD89-B964-4AC2-9773-8CB7E93F76C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8E9BAD89-B964-4AC2-9773-8CB7E93F76C8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8E9BAD89-B964-4AC2-9773-8CB7E93F76C8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8E9BAD89-B964-4AC2-9773-8CB7E93F76C8}.Release|Any CPU.Build.0 = Release|Any CPU + {A27A70F7-415B-4F06-8A2C-1399675D41AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A27A70F7-415B-4F06-8A2C-1399675D41AE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A27A70F7-415B-4F06-8A2C-1399675D41AE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A27A70F7-415B-4F06-8A2C-1399675D41AE}.Release|Any CPU.Build.0 = Release|Any CPU + {D4984187-8283-4494-88CF-35EDAD13E4DE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D4984187-8283-4494-88CF-35EDAD13E4DE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D4984187-8283-4494-88CF-35EDAD13E4DE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D4984187-8283-4494-88CF-35EDAD13E4DE}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {4758FE2E-A437-44F0-B58E-09E52D67D288} = {980900AA-E052-498B-A41A-4F33A8678828} @@ -62,5 +82,8 @@ Global {A8554BCC-C9B6-4D96-90AD-FE80E95441F4} = {980900AA-E052-498B-A41A-4F33A8678828} {D2B446A0-AB62-4555-9D79-33FF43D7CEF4} = {07B08872-1141-4BE6-87E6-B85E52FE4341} {8490BF59-B68A-4BE0-9F96-6CB262AF4850} = {07B08872-1141-4BE6-87E6-B85E52FE4341} + {8E9BAD89-B964-4AC2-9773-8CB7E93F76C8} = {07B08872-1141-4BE6-87E6-B85E52FE4341} + {A27A70F7-415B-4F06-8A2C-1399675D41AE} = {ABF0B435-2D50-41C8-847B-0905136537AB} + {D4984187-8283-4494-88CF-35EDAD13E4DE} = {ABF0B435-2D50-41C8-847B-0905136537AB} EndGlobalSection EndGlobal diff --git a/IdentityShroud.sln.DotSettings.user b/IdentityShroud.sln.DotSettings.user index 158c9b3..0e215b5 100644 --- a/IdentityShroud.sln.DotSettings.user +++ b/IdentityShroud.sln.DotSettings.user @@ -25,6 +25,7 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded @@ -54,8 +55,13 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded - /home/eelke/.cache/JetBrains/Rider2025.3/resharper-host/temp/Rider/vAny/CoverageData/_IdentityShroud.-1277985570/Snapshot/snapshot.utdcvr + + + + + From 32ba04043e1b989564257ea6572e18f1a13a2152 Mon Sep 17 00:00:00 2001 From: eelke Date: Tue, 18 Aug 2026 08:05:19 +0200 Subject: [PATCH 21/22] Switch to central package version management and upgrade packages to get rid of known vulnerabilities. --- Directory.Packages.props | 36 +++++++++++++++++++ .../IdentityShroud.Api.Tests.csproj | 24 ++++++------- IdentityShroud.Api/IdentityShroud.Api.csproj | 12 +++---- .../IdentityShroud.Core.Tests.csproj | 22 ++++++------ .../IdentityShroud.Core.csproj | 22 ++++++------ .../IdentityShroud.GraphQL.csproj | 4 +-- .../IdentityShroud.Migrations.csproj | 4 +-- .../IdentityShroud.TestUtils.Tests.csproj | 14 ++++---- .../IdentityShroud.TestUtils.csproj | 10 +++--- 9 files changed, 92 insertions(+), 56 deletions(-) create mode 100644 Directory.Packages.props diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..653fdef --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,36 @@ + + + true + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/IdentityShroud.Api.Tests/IdentityShroud.Api.Tests.csproj b/IdentityShroud.Api.Tests/IdentityShroud.Api.Tests.csproj index 4bb8f47..67cca0e 100644 --- a/IdentityShroud.Api.Tests/IdentityShroud.Api.Tests.csproj +++ b/IdentityShroud.Api.Tests/IdentityShroud.Api.Tests.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -8,20 +8,20 @@ - - - - - - - - - + + + + + + + + + - - + + diff --git a/IdentityShroud.Api/IdentityShroud.Api.csproj b/IdentityShroud.Api/IdentityShroud.Api.csproj index 5d779e7..f6f4148 100644 --- a/IdentityShroud.Api/IdentityShroud.Api.csproj +++ b/IdentityShroud.Api/IdentityShroud.Api.csproj @@ -15,12 +15,12 @@ - - - - - - + + + + + + diff --git a/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj b/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj index 1d98db0..918119c 100644 --- a/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj +++ b/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -8,19 +8,19 @@ - - - - - - - - + + + + + + + + - - + + diff --git a/IdentityShroud.Core/IdentityShroud.Core.csproj b/IdentityShroud.Core/IdentityShroud.Core.csproj index 4562d8d..fe5ed22 100644 --- a/IdentityShroud.Core/IdentityShroud.Core.csproj +++ b/IdentityShroud.Core/IdentityShroud.Core.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -7,16 +7,16 @@ - - - - - - - - - - + + + + + + + + + + diff --git a/IdentityShroud.GraphQL/IdentityShroud.GraphQL.csproj b/IdentityShroud.GraphQL/IdentityShroud.GraphQL.csproj index b0de15c..1ba525f 100644 --- a/IdentityShroud.GraphQL/IdentityShroud.GraphQL.csproj +++ b/IdentityShroud.GraphQL/IdentityShroud.GraphQL.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -7,7 +7,7 @@ - + diff --git a/IdentityShroud.Migrations/IdentityShroud.Migrations.csproj b/IdentityShroud.Migrations/IdentityShroud.Migrations.csproj index f4583e2..8cc28ca 100644 --- a/IdentityShroud.Migrations/IdentityShroud.Migrations.csproj +++ b/IdentityShroud.Migrations/IdentityShroud.Migrations.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -7,7 +7,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/IdentityShroud.TestUtils.Tests/IdentityShroud.TestUtils.Tests.csproj b/IdentityShroud.TestUtils.Tests/IdentityShroud.TestUtils.Tests.csproj index 9ce8074..8e5e7f7 100644 --- a/IdentityShroud.TestUtils.Tests/IdentityShroud.TestUtils.Tests.csproj +++ b/IdentityShroud.TestUtils.Tests/IdentityShroud.TestUtils.Tests.csproj @@ -7,16 +7,16 @@ - - - - - + + + + + - - + + diff --git a/IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj b/IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj index 4b68445..db517cc 100644 --- a/IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj +++ b/IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj @@ -8,9 +8,9 @@ - - - + + + @@ -18,8 +18,8 @@ - - + + From ba69eafc946b2ba0040dc4a1766bddf4f7fdf8e2 Mon Sep 17 00:00:00 2001 From: eelke Date: Tue, 18 Aug 2026 10:41:24 +0200 Subject: [PATCH 22/22] Update readme with new project structure we are going to work towards --- IdentityShroud.sln | 3 +++ README.md | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/IdentityShroud.sln b/IdentityShroud.sln index ce316d1..b22cf9c 100644 --- a/IdentityShroud.sln +++ b/IdentityShroud.sln @@ -7,6 +7,9 @@ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityShroud.Core.Tests", "IdentityShroud.Core.Tests\IdentityShroud.Core.Tests.csproj", "{DC887623-8680-4D3B-B23A-D54F7DA91891}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{576359FF-C672-4CC3-A683-3BB9D647E75D}" + ProjectSection(SolutionItems) = preProject + README.md = README.md + EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityShroud.Migrations", "IdentityShroud.Migrations\IdentityShroud.Migrations.csproj", "{DEECABE3-8934-4696-B0E1-48738DD0CEC4}" EndProject diff --git a/README.md b/README.md index 8bd5aa3..f4d3cc9 100644 --- a/README.md +++ b/README.md @@ -2,3 +2,28 @@ IdentityShroud is a .NET project for identity management and protection. +# Architecture + +## App + +Projects combining all into a working app + +## Api + +Supplies Api that can be wired up in a project to get all the required endpoints + +## Core + +Should provide the OIDC functionality with little external dependencies. + +## Infra.* + +Multiple sub projectes. +Provides implementations of the services that core requires. But these can be swapped for custom implementations. + +## Infra.EfCore + +Implementation of services that use an EF core context. + + +