5-improve-encrypted-storage #6

Merged
eelke merged 17 commits from 5-improve-encrypted-storage into main 2026-02-27 17:57:44 +00:00
40 changed files with 474 additions and 281 deletions
Showing only changes of commit 0c6f227049 - Show all commits

View file

@ -44,7 +44,9 @@ public class RealmApisTests : IClassFixture<ApplicationFactory>
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<ApplicationFactory>
// 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<JsonObject>(TestContext.Current.CancellationToken);
Assert.NotNull(result);
JsonObjectAssert.Equal("http://localhost/realms/foo/openid-connect/auth", result, "authorization_endpoint");
JsonObjectAssert.Equal("http://localhost/realms/foo", result, "issuer");
JsonObjectAssert.Equal("http://localhost/realms/foo/openid-connect/token", result, "token_endpoint");
JsonObjectAssert.Equal("http://localhost/realms/foo/openid-connect/jwks", result, "jwks_uri");
JsonObjectAssert.Equal("http://localhost/auth/realms/foo/openid-connect/auth", result, "authorization_endpoint");
JsonObjectAssert.Equal("http://localhost/auth/realms/foo", result, "issuer");
JsonObjectAssert.Equal("http://localhost/auth/realms/foo/openid-connect/token", result, "token_endpoint");
JsonObjectAssert.Equal("http://localhost/auth/realms/foo/openid-connect/jwks", result, "jwks_uri");
}
[Theory]
@ -137,7 +144,7 @@ public class RealmApisTests : IClassFixture<ApplicationFactory>
// 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);

View file

@ -1,41 +1,17 @@
using System.Security.Cryptography;
using IdentityShroud.Api.Mappers;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Messages;
using IdentityShroud.Core.Model;
using IdentityShroud.TestUtils.Substitutes;
using Microsoft.AspNetCore.WebUtilities;
namespace IdentityShroud.Api.Tests.Mappers;
public class KeyMapperTests
{
private readonly IEncryptionService _encryptionService = EncryptionServiceSubstitute.CreatePassthrough();
[Fact]
public void Test()
{
// Setup
using RSA rsa = RSA.Create(2048);
RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false);
RealmKey realmKey = new()
{
Id = new("60bb79cf-4bac-4521-87f2-ac87cc15541f"),
CreatedAt = DateTime.UtcNow,
Priority = 10,
};
realmKey.SetPrivateKey(_encryptionService, rsa.ExportPkcs8PrivateKey());
// Act
KeyMapper mapper = new(_encryptionService);
JsonWebKey jwk = mapper.KeyToJsonWebKey(realmKey);
Assert.Equal("RSA", jwk.KeyType);
Assert.Equal(realmKey.Id.ToString(), jwk.KeyId);
Assert.Equal("sig", jwk.Use);
Assert.Equal(parameters.Exponent, WebEncoders.Base64UrlDecode(jwk.Exponent));
Assert.Equal(parameters.Modulus, WebEncoders.Base64UrlDecode(jwk.Modulus));
}
}
// public class KeyMapperTests
// {
// private readonly IEncryptionService _encryptionService = EncryptionServiceSubstitute.CreatePassthrough();
//
// [Fact]
// public void Test()
// {
// }
// }

View file

@ -0,0 +1,43 @@
using System.Buffers.Text;
using System.Security.Cryptography;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security.Keys;
using IdentityShroud.Core.Services;
using IdentityShroud.TestUtils.Substitutes;
namespace IdentityShroud.Api.Tests.Mappers;
public class KeyServiceTests
{
private readonly IEncryptionService _encryptionService = EncryptionServiceSubstitute.CreatePassthrough();
//private readonly IKeyProviderFactory _keyProviderFactory = Substitute.For<IKeyProviderFactory>();
[Fact]
public void Test()
{
// Setup
using RSA rsa = RSA.Create(2048);
RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false);
RealmKey realmKey = new(
new("60bb79cf-4bac-4521-87f2-ac87cc15541f"),
"RSA",
rsa.ExportPkcs8PrivateKey(),
DateTime.UtcNow)
{
Priority = 10,
};
// Act
KeyService sut = new(_encryptionService, new KeyProviderFactory(), new ClockService());
var jwk = sut.CreateJsonWebKey(realmKey);
Assert.Equal("RSA", jwk.KeyType);
Assert.Equal(realmKey.Id.ToString(), jwk.KeyId);
Assert.Equal("sig", jwk.Use);
Assert.Equal(parameters.Exponent, Base64Url.DecodeFromChars(jwk.Exponent));
Assert.Equal(parameters.Modulus, Base64Url.DecodeFromChars(jwk.Modulus));
}
}

View file

@ -20,11 +20,17 @@ public static class ClientApi
public static void MapEndpoints(this IEndpointRouteBuilder erp)
{
erp.MapPost("", ClientCreate)
RouteGroupBuilder clientsGroup = erp.MapGroup("clients");
clientsGroup.MapPost("", ClientCreate)
.Validate<ClientCreateRequest>()
.WithName("ClientCreate")
.Produces(StatusCodes.Status201Created);
erp.MapGet("{clientId}", ClientGet)
var clientIdGroup = clientsGroup.MapGroup("{clientId}")
.AddEndpointFilter<ClientIdValidationFilter>();
clientIdGroup.MapGet("", ClientGet)
.WithName(ClientGetRouteName);
}
@ -33,7 +39,7 @@ public static class ClientApi
throw new NotImplementedException();
}
private static async Task<Results<Created<ClientCreateReponse>, InternalServerError>>
private static async Task<Results<CreatedAtRoute<ClientCreateReponse>, InternalServerError>>
ClientCreate(
ClientCreateRequest request,
[FromServices] IClientService service,
@ -43,13 +49,21 @@ public static class ClientApi
Realm realm = context.GetValidatedRealm();
Result<Client> result = await service.Create(realm.Id, request, cancellationToken);
// Should i have two set of paths? one for actual REST and one for openid
// openid: auth/realms/{realmSlug}/.well-known/openid-configuration
// openid: auth/realms/{realmSlug}/openid-connect/(auth|token|jwks)
// api: api/v1/realms/{realmId}/....
// api: api/v1/realms/{realmId}/clients/{clientId}
if (result.IsFailed)
{
throw new NotImplementedException();
}
//return Results.CreatedAtRoute(ClientGetRouteName, [ "realmSlug" = realmId!?])
Client client = result.Value;
return TypedResults.CreatedAtRoute(
new ClientCreateReponse(client.Id, client.ClientId),
ClientGetRouteName,
new RouteValueDictionary()
{
["realmId"] = realm.Id,
["clientId"] = client.Id,
});
throw new NotImplementedException();
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -7,41 +7,14 @@ using Microsoft.AspNetCore.WebUtilities;
namespace IdentityShroud.Api.Mappers;
public class KeyMapper(IEncryptionService encryptionService)
public class KeyMapper(IKeyService keyService)
{
public JsonWebKey? KeyToJsonWebKey(RealmKey realmKey)
{
JsonWebKey result = new()
{
KeyId = realmKey.Id.ToString(),
Use = "sig",
};
switch (realmKey.KeyType)
{
case "RSA":
using (var rsa = RsaHelper.LoadFromPkcs8(realmKey.GetPrivateKey(encryptionService)))
{
RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false);
result.KeyType = rsa.SignatureAlgorithm;
result.Exponent = WebEncoders.Base64UrlEncode(parameters.Exponent!);
result.Modulus = WebEncoders.Base64UrlEncode(parameters.Modulus!);
}
break;
default:
return null;
}
return result;
}
public JsonWebKeySet KeyListToJsonWebKeySet(IEnumerable<RealmKey> keys)
{
JsonWebKeySet wks = new();
foreach (var k in keys)
{
var wk = KeyToJsonWebKey(k);
var wk = keyService.CreateJsonWebKey(k);
if (wk is {})
{
wks.Keys.Add(wk);

View file

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

View file

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

View file

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

View file

@ -4,6 +4,7 @@ using IdentityShroud.Api.Mappers;
using IdentityShroud.Core;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Security;
using IdentityShroud.Core.Security.Keys;
using IdentityShroud.Core.Services;
using Serilog;
using Serilog.Formatting.Json;
@ -35,11 +36,15 @@ void ConfigureBuilder(WebApplicationBuilder builder)
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
services.AddOpenApi();
services.AddScoped<Db>();
services.AddScoped<IClientService, ClientService>();
services.AddSingleton<IClock, ClockService>();
services.AddSingleton<IEncryptionService, EncryptionService>();
services.AddScoped<IKeyProviderFactory, KeyProviderFactory>();
services.AddScoped<IKeyService, KeyService>();
services.AddScoped<IRealmService, RealmService>();
services.AddOptions<DbConfiguration>().Bind(configuration.GetSection("db"));
services.AddSingleton<ISecretProvider, ConfigurationSecretProvider>();
services.AddSingleton<KeyMapper>();
services.AddSingleton<IEncryptionService, EncryptionService>();
services.AddScoped<KeyMapper>();
services.AddValidatorsFromAssemblyContaining<RealmCreateRequestValidator>();
@ -56,7 +61,8 @@ void ConfigureApplication(WebApplication app)
app.MapOpenApi();
}
app.UseSerilogRequestLogging();
app.MapRealmEndpoints();
app.MapApis();
// app.UseRouting();
// app.MapControllers();
}

View file

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

View file

@ -30,4 +30,8 @@
<ProjectReference Include="..\IdentityShroud.TestUtils\IdentityShroud.TestUtils.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Model\" />
</ItemGroup>
</Project>

View file

@ -1,51 +0,0 @@
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Model;
namespace IdentityShroud.Core.Tests.Model;
public class RealmKeyTests
{
[Fact]
public void SetNewKey()
{
byte[] privateKey = [5, 6, 7, 8];
byte[] encryptedPrivateKey = [1, 2, 3, 4];
var encryptionService = Substitute.For<IEncryptionService>();
encryptionService
.Encrypt(Arg.Any<byte[]>())
.Returns(x => encryptedPrivateKey);
RealmKey realmKey = new();
realmKey.SetPrivateKey(encryptionService, privateKey);
// should be able to return original without calling decrypt
Assert.Equal(privateKey, realmKey.GetPrivateKey(encryptionService));
Assert.Equal(encryptedPrivateKey, realmKey.PrivateKeyEncrypted);
encryptionService.Received(1).Encrypt(privateKey);
encryptionService.DidNotReceive().Decrypt(Arg.Any<byte[]>());
}
[Fact]
public void GetDecryptedKey()
{
byte[] privateKey = [5, 6, 7, 8];
byte[] encryptedPrivateKey = [1, 2, 3, 4];
var encryptionService = Substitute.For<IEncryptionService>();
encryptionService
.Decrypt(encryptedPrivateKey)
.Returns(x => privateKey);
RealmKey realmKey = new();
realmKey.PrivateKeyEncrypted = encryptedPrivateKey;
// should be able to return original without calling decrypt
Assert.Equal(privateKey, realmKey.GetPrivateKey(encryptionService));
Assert.Equal(encryptedPrivateKey, realmKey.PrivateKeyEncrypted);
encryptionService.Received(1).Decrypt(encryptedPrivateKey);
}
}

View file

@ -1,4 +1,6 @@
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security.Keys;
using IdentityShroud.Core.Services;
using IdentityShroud.Core.Tests.Fixtures;
using IdentityShroud.TestUtils.Substitutes;
@ -9,7 +11,7 @@ namespace IdentityShroud.Core.Tests.Services;
public class RealmServiceTests : IClassFixture<DbFixture>
{
private readonly DbFixture _dbFixture;
private readonly IEncryptionService _encryptionService = EncryptionServiceSubstitute.CreatePassthrough();
private readonly IKeyService _keyService = Substitute.For<IKeyService>();
public RealmServiceTests(DbFixture dbFixture)
{
@ -34,16 +36,19 @@ public class RealmServiceTests : IClassFixture<DbFixture>
if (idString is not null)
realmId = new(idString);
using Db db = _dbFixture.CreateDbContext();
RealmService sut = new(db, _encryptionService);
RealmCreateResponse? val;
await using (var db = _dbFixture.CreateDbContext())
{
_keyService.CreateKey(Arg.Any<KeyPolicy>())
.Returns(new RealmKey(Guid.NewGuid(), "TST", [21], DateTime.UtcNow));
// Act
RealmService sut = new(db, _keyService);
var response = await sut.Create(
new(realmId, "slug", "New realm"),
TestContext.Current.CancellationToken);
// Verify
RealmCreateResponse val = ResultAssert.Success(response);
val = ResultAssert.Success(response);
if (realmId.HasValue)
Assert.Equal(realmId, val.Id);
else
@ -52,7 +57,16 @@ public class RealmServiceTests : IClassFixture<DbFixture>
Assert.Equal("slug", val.Slug);
Assert.Equal("New realm", val.Name);
// TODO verify data has been stored!
_keyService.Received().CreateKey(Arg.Any<KeyPolicy>());
}
await using (var db = _dbFixture.CreateDbContext())
{
var dbRecord = await db.Realms
.Include(e => e.Keys)
.SingleAsync(e => e.Id == val.Id, TestContext.Current.CancellationToken);
Assert.Equal("TST", dbRecord.Keys[0].KeyType);
}
}
[Theory]
@ -60,7 +74,7 @@ public class RealmServiceTests : IClassFixture<DbFixture>
[InlineData("foo", "Foo")]
public async Task FindBySlug(string slug, string? name)
{
using (var setupContext = _dbFixture.CreateDbContext())
await using (var setupContext = _dbFixture.CreateDbContext())
{
setupContext.Realms.Add(new()
{
@ -76,8 +90,8 @@ public class RealmServiceTests : IClassFixture<DbFixture>
await setupContext.SaveChangesAsync(TestContext.Current.CancellationToken);
}
using Db actContext = _dbFixture.CreateDbContext();
RealmService sut = new(actContext, _encryptionService);
await using var actContext = _dbFixture.CreateDbContext();
RealmService sut = new(actContext, _keyService);
// Act
var result = await sut.FindBySlug(slug, TestContext.Current.CancellationToken);

View file

@ -6,7 +6,7 @@ namespace IdentityShroud.Core.Contracts;
public class ClientCreateRequest
{
public string ClientId { get; set; }
public required string ClientId { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public string? SignatureAlgorithm { get; set; }
@ -22,4 +22,5 @@ public interface IClientService
CancellationToken ct = default);
Task<Client?> GetByClientId(string clientId, CancellationToken ct = default);
Task<Client?> FindById(int id, CancellationToken ct = default);
}

View file

@ -3,5 +3,5 @@ namespace IdentityShroud.Core.Contracts;
public interface IEncryptionService
{
byte[] Encrypt(byte[] plain);
byte[] Decrypt(byte[] cipher);
byte[] Decrypt(ReadOnlyMemory<byte> cipher);
}

View file

@ -1,8 +0,0 @@
using IdentityShroud.Core.Model;
namespace IdentityShroud.Core.Contracts;
public interface IKeyProvisioningService
{
RealmKey CreateRsaKey(int keySize = 2048);
}

View file

@ -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);
}

View file

@ -6,6 +6,7 @@ namespace IdentityShroud.Core.Contracts;
public interface IRealmService
{
Task<Realm?> FindById(Guid id, CancellationToken ct = default);
Task<Realm?> FindBySlug(string slug, CancellationToken ct = default);
Task<Result<RealmCreateResponse>> Create(RealmCreateRequest request, CancellationToken ct = default);

View file

@ -11,6 +11,7 @@
<PackageReference Include="FluentResults" Version="4.0.0" />
<PackageReference Include="FluentValidation" Version="12.1.1" />
<PackageReference Include="jose-jwt" Version="5.2.0" />
<PackageReference Include="LanguageExt.Core" Version="4.4.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.2" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
</ItemGroup>

View file

@ -10,7 +10,7 @@ namespace IdentityShroud.Core.Model;
public class Client
{
[Key]
public Guid Id { get; set; }
public int Id { get; set; }
public Guid RealmId { get; set; }
[MaxLength(40)]
public required string ClientId { get; set; }

View file

@ -31,9 +31,8 @@ public static class AesGcmHelper
// • payload byte[] containing nonce‖ciphertext‖tag
// • returns the original plaintext bytes
// --------------------------------------------------------------------
public static byte[] DecryptAesGcm(byte[] payload, byte[] key)
public static byte[] DecryptAesGcm(ReadOnlyMemory<byte> payload, byte[] key)
{
if (payload == null) throw new ArgumentNullException(nameof(payload));
if (key == null) throw new ArgumentNullException(nameof(key));
if (key.Length != 32) // 256bit key
throw new ArgumentException("Key must be 256bits (32 bytes) for AES256GCM.", nameof(key));
@ -49,9 +48,9 @@ public static class AesGcmHelper
if (payload.Length < nonceSize + tagSize)
throw new ArgumentException("Payload is too short to contain nonce, ciphertext, and tag.", nameof(payload));
ReadOnlySpan<byte> nonce = new(payload, 0, nonceSize);
ReadOnlySpan<byte> ciphertext = new(payload, nonceSize, payload.Length - nonceSize - tagSize);
ReadOnlySpan<byte> tag = new(payload, payload.Length - tagSize, tagSize);
ReadOnlySpan<byte> nonce = payload.Span[..nonceSize];
ReadOnlySpan<byte> ciphertext = payload.Span.Slice(nonceSize, payload.Length - nonceSize - tagSize);
ReadOnlySpan<byte> tag = payload.Span.Slice(payload.Length - tagSize, tagSize);
byte[] plaintext = new byte[ciphertext.Length];

View file

@ -0,0 +1,20 @@
using IdentityShroud.Core.Messages;
using IdentityShroud.Core.Model;
namespace IdentityShroud.Core.Security.Keys;
public abstract class KeyPolicy
{
public abstract string KeyType { get; }
}
public interface IKeyProvider
{
byte[] CreateKey(KeyPolicy policy);
void SetJwkParameters(byte[] key, JsonWebKey jwk);
}

View file

@ -0,0 +1,7 @@
namespace IdentityShroud.Core.Security.Keys;
public interface IKeyProviderFactory
{
public IKeyProvider CreateProvider(string keyType);
}

View file

@ -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();
}
}
}

View file

@ -0,0 +1,37 @@
using System.Buffers.Text;
using System.Security.Cryptography;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Messages;
using IdentityShroud.Core.Model;
namespace IdentityShroud.Core.Security.Keys.Rsa;
public class RsaKeyPolicy : KeyPolicy
{
public override string KeyType => "RSA";
public int KeySize { get; } = 2048;
}
public class RsaProvider : IKeyProvider
{
public byte[] CreateKey(KeyPolicy policy)
{
if (policy is RsaKeyPolicy p)
{
using var rsa = RSA.Create(p.KeySize);
return rsa.ExportPkcs8PrivateKey();
}
throw new ArgumentException("Incorrect policy type", nameof(policy));
}
public void SetJwkParameters(byte[] key, JsonWebKey jwk)
{
using var rsa = RSA.Create();
rsa.ImportPkcs8PrivateKey(key, out _);
var parameters = rsa.ExportParameters(includePrivateParameters: false);
jwk.Exponent = Base64Url.EncodeToString(parameters.Exponent);
jwk.Modulus = Base64Url.EncodeToString(parameters.Modulus);
}
}

View file

@ -14,7 +14,6 @@ public class ClientService(
{
Client client = new()
{
Id = Guid.NewGuid(),
RealmId = realmId,
ClientId = request.ClientId,
Name = request.Name,
@ -40,6 +39,11 @@ public class ClientService(
return await db.Clients.FirstOrDefaultAsync(c => c.ClientId == clientId, ct);
}
public async Task<Client?> FindById(int id, CancellationToken ct = default)
{
return await db.Clients.FirstOrDefaultAsync(c => c.Id == id, ct);
}
private ClientSecret CreateSecret()
{
byte[] secret = RandomNumberGenerator.GetBytes(24);

View file

@ -20,7 +20,7 @@ public class EncryptionService : IEncryptionService
return AesGcmHelper.EncryptAesGcm(plain, encryptionKey);
}
public byte[] Decrypt(byte[] cipher)
public byte[] Decrypt(ReadOnlyMemory<byte> cipher)
{
return AesGcmHelper.DecryptAesGcm(cipher, encryptionKey);
}

View file

@ -1,30 +0,0 @@
using System.Security.Cryptography;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Model;
namespace IdentityShroud.Core.Services;
public class KeyProvisioningService(
IEncryptionService encryptionService,
IClock clock) : IKeyProvisioningService
{
public RealmKey CreateRsaKey(int keySize = 2048)
{
using var rsa = RSA.Create(keySize);
return CreateKey("RSA", rsa.ExportPkcs8PrivateKey());
}
private RealmKey CreateKey(string keyType, byte[] keyData) =>
new RealmKey(
Guid.NewGuid(),
keyType,
encryptionService.Encrypt(keyData),
clock.UtcNow());
// public byte[] GetPrivateKey(IEncryptionService encryptionService)
// {
// if (_privateKeyDecrypted.Length == 0 && PrivateKeyEncrypted.Length > 0)
// _privateKeyDecrypted = encryptionService.Decrypt(PrivateKeyEncrypted);
// return _privateKeyDecrypted;
// }
}

View file

@ -0,0 +1,52 @@
using System.Security.Cryptography;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Messages;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security.Keys;
namespace IdentityShroud.Core.Services;
public class KeyService(
IEncryptionService cryptor,
IKeyProviderFactory keyProviderFactory,
IClock clock) : IKeyService
{
public RealmKey CreateKey(KeyPolicy policy)
{
IKeyProvider provider = keyProviderFactory.CreateProvider(policy.KeyType);
var plainKey = provider.CreateKey(policy);
return CreateKey(policy.KeyType, plainKey);
}
public JsonWebKey? CreateJsonWebKey(RealmKey realmKey)
{
JsonWebKey jwk = new()
{
KeyId = realmKey.Id.ToString(),
KeyType = realmKey.KeyType,
Use = "sig",
};
IKeyProvider provider = keyProviderFactory.CreateProvider(realmKey.KeyType);
provider.SetJwkParameters(
cryptor.Decrypt(realmKey.KeyDataEncrypted),
jwk);
return jwk;
}
private RealmKey CreateKey(string keyType, byte[] plainKey) =>
new RealmKey(
Guid.NewGuid(),
keyType,
cryptor.Encrypt(plainKey),
clock.UtcNow());
// public byte[] GetPrivateKey(IEncryptionService encryptionService)
// {
// if (_privateKeyDecrypted.Length == 0 && PrivateKeyEncrypted.Length > 0)
// _privateKeyDecrypted = encryptionService.Decrypt(PrivateKeyEncrypted);
// return _privateKeyDecrypted;
// }
}

View file

@ -3,6 +3,8 @@ using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Helpers;
using IdentityShroud.Core.Messages.Realm;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security.Keys;
using IdentityShroud.Core.Security.Keys.Rsa;
using Microsoft.EntityFrameworkCore;
namespace IdentityShroud.Core.Services;
@ -11,8 +13,14 @@ public record RealmCreateResponse(Guid Id, string Slug, string Name);
public class RealmService(
Db db,
IKeyProvisioningService keyProvisioningService) : IRealmService
IKeyService keyService) : IRealmService
{
public async Task<Realm?> FindById(Guid id, CancellationToken ct = default)
{
return await db.Realms
.SingleOrDefaultAsync(r => r.Id == id, ct);
}
public async Task<Realm?> FindBySlug(string slug, CancellationToken ct = default)
{
return await db.Realms
@ -26,9 +34,10 @@ public class RealmService(
Id = request.Id ?? Guid.CreateVersion7(),
Slug = request.Slug ?? SlugHelper.GenerateSlug(request.Name),
Name = request.Name,
Keys = [ keyProvisioningService.CreateRsaKey() ],
};
realm.Keys.Add(keyService.CreateKey(GetKeyPolicy(realm)));
db.Add(realm);
await db.SaveChangesAsync(ct);
@ -36,6 +45,14 @@ public class RealmService(
realm.Id, realm.Slug, realm.Name);
}
/// <summary>
/// Place holder for getting policies from the realm and falling back to sane defaults when no policies have been set.
/// </summary>
/// <param name="_"></param>
/// <returns></returns>
private KeyPolicy GetKeyPolicy(Realm _) => new RsaKeyPolicy();
public async Task LoadActiveKeys(Realm realm)
{
await db.Entry(realm).Collection(r => r.Keys)

View file

@ -11,8 +11,8 @@ public static class EncryptionServiceSubstitute
.Encrypt(Arg.Any<byte[]>())
.Returns(x => x.ArgAt<byte[]>(0));
encryptionService
.Decrypt(Arg.Any<byte[]>())
.Returns(x => x.ArgAt<byte[]>(0));
.Decrypt(Arg.Any<ReadOnlyMemory<byte>>())
.Returns(x => x.ArgAt<ReadOnlyMemory<byte>>(0).ToArray());
return encryptionService;
}
}

View file

@ -5,31 +5,32 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADebugger_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Ff9d2f95d72fa884d8b6ddefc717c56da3657fbb2d5fb683656c3589eb6587_003FDebugger_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADeveloperExceptionPageMiddlewareImpl_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F2b5a64a615692cae2c8f378e99676581abe4bc355bb3844bfc6c6db3d576853_003FDeveloperExceptionPageMiddlewareImpl_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AGeneratedRouteBuilderExtensions_002Eg_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F698a85dfa04f73158f8da37069798c22c467dfc_003FGeneratedRouteBuilderExtensions_002Eg_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AGeneratedRouteBuilderExtensions_002Eg_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F9f95c1d38311d5248a1d1324797b98c2e56789a_003FGeneratedRouteBuilderExtensions_002Eg_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHealthCheckEndpointRouteBuilderExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F6d0f079e13da4e98881aa3e6e169c6d34f08_003F0e_003Fc2b30661_003FHealthCheckEndpointRouteBuilderExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIAsyncDisposable_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F7d59f4f94af72f8d3797655412cdc64435acc6454985685e415ee5fe817f_003FIAsyncDisposable_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AKeySizes_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fe6cebf5d2d92b49eb99f568415b3cd457a252cacf81d426ca4f3e94ff429daf7_003FKeySizes_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AList_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fd2753e160c1949ef9afa6a794019cfe8d908_003Fce_003Fba21ad0a_003FList_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANamingConventionsExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Feacd26cff49d864d97bf44d3424fd383a26620b1d0c43fb1d6f115da85c655_003FNamingConventionsExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOkOfT_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fe2a19de442f561af862af2dcad0852b7e62707a5cf194d266d1656f92bbb6d2_003FOkOfT_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APostgreSqlBuilder_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fcdd0beaf7beaf8366c0862f34fe40da30911084d957625ab31577851ee8cae7_003FPostgreSqlBuilder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APostgreSqlContainer_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fc82112acf224de1d157da0309437b227be6c1ef877865c23872f49eaf9d73c_003FPostgreSqlContainer_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AResultsOfT_002EGenerated_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fff2e2c5ca93c7786ef8425ca6caf751702328924211687ce72e74fd1265e8_003FResultsOfT_002EGenerated_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARouteGroupBuilder_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fd42b8f8feda3bfb3dc17f133a52ce45931ed5066c46a4d834c8ed46e0a6_003FRouteGroupBuilder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AThrowHelper_002ESerialization_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F8433b9271c0f176fb5ceb7b1c3d62e1318fe8e62b4e5d7e882952dc543fec_003FThrowHelper_002ESerialization_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATypedResults_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fcea118513a410f660e578fe32bed95cf86457dd135e4b4632ca91eb4f7b_003FTypedResults_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AWebEncoders_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fce6b69dd397f614758bc5821136ec8af3fa22563dd657769e231f51be1fbbc_003FWebEncoders_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/dotCover/Editor/HighlightingSourceSnapshotLocation/@EntryValue">/home/eelke/.cache/JetBrains/Rider2025.3/resharper-host/temp/Rider/vAny/CoverageData/_IdentityShroud.-1277985570/Snapshot/snapshot.utdcvr</s:String>
<s:String x:Key="/Default/Environment/Hierarchy/Build/BuildTool/DotNetCliExePath/@EntryValue">/home/eelke/.dotnet/dotnet</s:String>
<s:String x:Key="/Default/Environment/Hierarchy/Build/BuildTool/CustomBuildToolPath/@EntryValue">/home/eelke/.dotnet/sdk/10.0.102/MSBuild.dll</s:String>
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=6e5d049f_002D5af8_002D43d4_002D878d_002D591b09b1e74a/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" Name="All tests from Solution #3" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt;
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=4bf578c0_002Dc8f9_002D46e4_002D9bdc_002D38da0a3f253a/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt;
&lt;Solution /&gt;
&lt;/SessionState&gt;</s:String>
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=a4b5fea0_002D4511_002D4f66_002D888d_002Daea8a1e4c94d/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt;
&lt;Solution /&gt;
&lt;/SessionState&gt;</s:String>
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=b6b17914_002D7f7b_002D403e_002Db1eb_002D2c847c515018/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution #2" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt;
&lt;Solution /&gt;
&lt;/SessionState&gt;</s:String>
</wpf:ResourceDictionary>