From 3d73a9914c5ff5b9a139277cad57c72865a2c4d2 Mon Sep 17 00:00:00 2001 From: eelke Date: Sun, 22 Feb 2026 09:27:48 +0100 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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