From eb872a4f449dfcd1629adb3a781853eec1c3fb8e Mon Sep 17 00:00:00 2001 From: eelke Date: Fri, 20 Feb 2026 17:35:38 +0100 Subject: [PATCH 01/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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. +