From 07393f57fc7b0baa78b1ae54dcac70a674ba1db7 Mon Sep 17 00:00:00 2001 From: eelke Date: Fri, 27 Feb 2026 17:57:42 +0000 Subject: [PATCH] 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. +