Support rotation of master key.

The EncryptionService now loads a set of keys and uses the active one to encrypt and selects key based on keyid during decryption. Introduced EncryptedValue to hold keyId and encrypted data.

(There are no intermeddiate keys yet)
This commit is contained in:
eelke 2026-02-24 06:32:58 +01:00
parent 4201d0240d
commit 644b005f2a
19 changed files with 259 additions and 72 deletions

View file

@ -114,7 +114,7 @@ public class RealmApisTests : IClassFixture<ApplicationFactory>
{ {
// act // act
var client = _factory.CreateClient(); 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); TestContext.Current.CancellationToken);
// verify // verify
@ -130,18 +130,20 @@ public class RealmApisTests : IClassFixture<ApplicationFactory>
using var rsa = RSA.Create(2048); using var rsa = RSA.Create(2048);
RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false); RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false);
RealmKey realmKey = new( RealmKey realmKey = new()
Guid.NewGuid(), {
"RSA", Id = Guid.NewGuid(),
encryptionService.Encrypt(rsa.ExportPkcs8PrivateKey()), KeyType = "RSA",
DateTime.UtcNow); Key = encryptionService.Encrypt(rsa.ExportPkcs8PrivateKey()),
CreatedAt = DateTime.UtcNow,
};
await ScopedContextAsync(async db => await ScopedContextAsync(async db =>
{ {
db.Realms.Add(new Realm() { Slug = "foo", Name = "Foo", Keys = [ realmKey ]}); db.Realms.Add(new Realm() { Slug = "foo", Name = "Foo", Keys = [ realmKey ]});
await db.SaveChangesAsync(TestContext.Current.CancellationToken); await db.SaveChangesAsync(TestContext.Current.CancellationToken);
}); });
// act // act
var client = _factory.CreateClient(); var client = _factory.CreateClient();
var response = await client.GetAsync("/auth/realms/foo/openid-connect/jwks", var response = await client.GetAsync("/auth/realms/foo/openid-connect/jwks",

View file

@ -28,7 +28,10 @@ public class ApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
new Dictionary<string, string?> new Dictionary<string, string?>
{ {
["Db:ConnectionString"] = _postgresqlServer.GetConnectionString(), ["Db:ConnectionString"] = _postgresqlServer.GetConnectionString(),
["secrets:Master"] = "GVd07qW0frRX9quPX/X62L88BeRR7+IzgRJHtG7ZzHw=", ["secrets:master:0:Id"] = "key1",
["secrets:master:0:Active"] = "true",
["secrets:master:0:Algorithm"] = "AES",
["secrets:master:0:Key"] = "GVd07qW0frRX9quPX/X62L88BeRR7+IzgRJHtG7ZzHw=",
}); });
}); });

View file

@ -21,12 +21,12 @@ public class KeyServiceTests
RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false); RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false);
RealmKey realmKey = new( RealmKey realmKey = new()
new("60bb79cf-4bac-4521-87f2-ac87cc15541f"),
"RSA",
rsa.ExportPkcs8PrivateKey(),
DateTime.UtcNow)
{ {
Id = new("60bb79cf-4bac-4521-87f2-ac87cc15541f"),
KeyType = "RSA",
Key = new("", rsa.ExportPkcs8PrivateKey()),
CreatedAt = DateTime.UtcNow,
Priority = 10, Priority = 10,
}; };
@ -34,10 +34,11 @@ public class KeyServiceTests
KeyService sut = new(_encryptionService, new KeyProviderFactory(), new ClockService()); KeyService sut = new(_encryptionService, new KeyProviderFactory(), new ClockService());
var jwk = sut.CreateJsonWebKey(realmKey); var jwk = sut.CreateJsonWebKey(realmKey);
Assert.NotNull(jwk);
Assert.Equal("RSA", jwk.KeyType); Assert.Equal("RSA", jwk.KeyType);
Assert.Equal(realmKey.Id.ToString(), jwk.KeyId); Assert.Equal(realmKey.Id.ToString(), jwk.KeyId);
Assert.Equal("sig", jwk.Use); Assert.Equal("sig", jwk.Use);
Assert.Equal(parameters.Exponent, Base64Url.DecodeFromChars(jwk.Exponent)); Assert.Equal(parameters.Exponent, Base64Url.DecodeFromChars(jwk.Exponent));
Assert.Equal(parameters.Modulus, Base64Url.DecodeFromChars(jwk.Modulus)); Assert.Equal(parameters.Modulus, Base64Url.DecodeFromChars(jwk.Modulus));
} }
} }

View file

@ -17,7 +17,6 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" /> <PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0"/> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0"/>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.2" />
<PackageReference Include="Riok.Mapperly" Version="4.3.1" /> <PackageReference Include="Riok.Mapperly" Version="4.3.1" />
<PackageReference Include="Serilog" Version="4.3.0" /> <PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" /> <PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />

View file

@ -0,0 +1,61 @@
using System.Text;
using IdentityShroud.Core.Security;
using Microsoft.Extensions.Configuration;
namespace IdentityShroud.Core.Tests.Security;
public class ConfigurationSecretProviderTests
{
private static IConfiguration BuildConfigFromJson(string json)
{
// Convert the JSON string into a stream that the config builder can read.
var jsonBytes = Encoding.UTF8.GetBytes(json);
using var stream = new MemoryStream(jsonBytes);
// Build the configuration just like the real app does, but from the stream.
var config = new ConfigurationBuilder()
.AddJsonStream(stream) // <-- reads from the inmemory JSON
.Build();
return config;
}
[Fact]
public void Test()
{
string jsonConfig = """
{
"secrets": {
"master": [
{
"Id": "first",
"Active": true,
"Algorithm": "AES",
"Key": "yoQ4W7EaNjo7s3FBYkWo5BLyX1BnLyWd7BlSaDIrkzo="
},
{
"Id": "second",
"Active": false,
"Algorithm": "AES",
"Key": "YSWK6vTJXCJOGLpCo+TtZ6anKNzvA1VT2xXLHbmq4M0="
}
]
}
}
""";
ConfigurationSecretProvider sut = new(BuildConfigFromJson(jsonConfig));
var keys = sut.GetKeys("master");
Assert.Equal(2, keys.Length);
var active = keys.Single(k => k.Active);
Assert.Equal("first", active.Id);
Assert.Equal("AES", active.Algorithm);
Assert.Equal(Convert.FromBase64String("yoQ4W7EaNjo7s3FBYkWo5BLyX1BnLyWd7BlSaDIrkzo="), active.Key);
var inactive = keys.Single(k => !k.Active);
Assert.Equal("second", inactive.Id);
}
}

View file

@ -1,5 +1,3 @@
using System.Buffers.Text;
using System.Security.Cryptography;
using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Services; using IdentityShroud.Core.Services;
@ -11,16 +9,22 @@ public class EncryptionServiceTests
public void RoundtripWorks() public void RoundtripWorks()
{ {
// Note this code will tend to only test the latest verion. // Note this code will tend to only test the latest verion.
// setup // setup
byte[] keyValue = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw=");
var secretProvider = Substitute.For<ISecretProvider>(); var secretProvider = Substitute.For<ISecretProvider>();
secretProvider.GetSecret("Master").Returns("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); EncryptionKey[] keys =
[
new EncryptionKey("1", true, "AES", keyValue)
];
secretProvider.GetKeys("master").Returns(keys);
ReadOnlySpan<byte> input = "Hello, World!"u8; ReadOnlySpan<byte> input = "Hello, World!"u8;
// act // act
EncryptionService sut = new(secretProvider); EncryptionService sut = new(secretProvider);
byte[] cipher = sut.Encrypt(input.ToArray()); EncryptedValue cipher = sut.Encrypt(input.ToArray());
byte[] result = sut.Decrypt(cipher); byte[] result = sut.Decrypt(cipher);
// verify // verify
@ -34,19 +38,109 @@ public class EncryptionServiceTests
// make sure decoding of legacy data still works. // make sure decoding of legacy data still works.
// setup // setup
Span<byte> cipher = byte[] cipher =
[ [
1, 198, 55, 58, 56, 110, 238, 59, 158, 214, 85, 241, 26, 44, 140, 229, 128, 111, 167, 154, 160, 177, 152, 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 193, 74, 4, 235, 82, 207, 87, 32, 10, 239, 4, 246, 25, 21, 249, 25, 59, 160, 101
]; ];
EncryptedValue secret = new("kid", cipher);
byte[] keyValue = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw=");
var secretProvider = Substitute.For<ISecretProvider>(); var secretProvider = Substitute.For<ISecretProvider>();
secretProvider.GetSecret("Master").Returns("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); EncryptionKey[] keys =
[
new EncryptionKey("kid", true, "AES", keyValue)
];
secretProvider.GetKeys("master").Returns(keys);
// act // act
EncryptionService sut = new(secretProvider); EncryptionService sut = new(secretProvider);
byte[] result = sut.Decrypt(cipher.ToArray()); byte[] result = sut.Decrypt(secret);
// verify // verify
Assert.Equal("Hello, World!"u8, result); Assert.Equal("Hello, World!"u8, result);
} }
[Fact]
public void DetectsCorruptInput()
{
// When introducing a new version we need version specific tests to
// make sure decoding of legacy data still works.
// setup
byte[] cipher = // NOTE INCORRECT CIPHER DO NOT USE IN OTHER TESTS
[
1, 198, 55, 58, 56, 110, 238, 59, 158, 214, 85, 241, 26, 44, 140, 229, 128, 111, 167, 154, 160, 177, 152,
193, 75, 4, 235, 82, 207, 87, 32, 10, 239, 4, 246, 25, 21, 249, 25, 59, 160, 101
];
EncryptedValue secret = new("kid", cipher);
byte[] keyValue = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw=");
var secretProvider = Substitute.For<ISecretProvider>();
EncryptionKey[] keys =
[
new EncryptionKey("kid", true, "AES", keyValue)
];
secretProvider.GetKeys("master").Returns(keys);
// act
EncryptionService sut = new(secretProvider);
Assert.Throws<InvalidOperationException>(
() => sut.Decrypt(secret),
ex => ex.Message.Contains("Decryption failed") ? null : "Expected Decryption failed in message");
}
[Fact]
public void DecodeSelectsRightKey()
{
// The key is marked inactive also it is the second key
// setup
byte[] cipher =
[
1, 198, 55, 58, 56, 110, 238, 59, 158, 214, 85, 241, 26, 44, 140, 229, 128, 111, 167, 154, 160, 177, 152,
193, 74, 4, 235, 82, 207, 87, 32, 10, 239, 4, 246, 25, 21, 249, 25, 59, 160, 101
];
EncryptedValue secret = new("1", cipher);
byte[] keyValue1 = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw=");
byte[] keyValue2 = Convert.FromBase64String("Dat1RwRvuLX3wdKMMP4NwHdBl8tJJsKfp01qikyo8aw=");
var secretProvider = Substitute.For<ISecretProvider>();
EncryptionKey[] keys =
[
new EncryptionKey("2", true, "AES", keyValue2),
new EncryptionKey("1", false, "AES", keyValue1),
];
secretProvider.GetKeys("master").Returns(keys);
// act
EncryptionService sut = new(secretProvider);
byte[] result = sut.Decrypt(secret);
// verify
Assert.Equal("Hello, World!"u8, result);
}
[Fact]
public void EncryptionUsesActiveKey()
{
// setup
byte[] keyValue1 = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw=");
byte[] keyValue2 = Convert.FromBase64String("Dat1RwRvuLX3wdKMMP4NwHdBl8tJJsKfp01qikyo8aw=");
var secretProvider = Substitute.For<ISecretProvider>();
EncryptionKey[] keys =
[
new EncryptionKey("1", false, "AES", keyValue1),
new EncryptionKey("2", true, "AES", keyValue2),
];
secretProvider.GetKeys("master").Returns(keys);
ReadOnlySpan<byte> input = "Hello, World!"u8;
// act
EncryptionService sut = new(secretProvider);
EncryptedValue cipher = sut.Encrypt(input.ToArray());
// Verify
Assert.Equal("2", cipher.KeyId);
}
} }

View file

@ -39,7 +39,13 @@ public class RealmServiceTests : IClassFixture<DbFixture>
await using (var db = _dbFixture.CreateDbContext()) await using (var db = _dbFixture.CreateDbContext())
{ {
_keyService.CreateKey(Arg.Any<KeyPolicy>()) _keyService.CreateKey(Arg.Any<KeyPolicy>())
.Returns(new RealmKey(Guid.NewGuid(), "TST", [21], DateTime.UtcNow)); .Returns(new RealmKey()
{
Id = Guid.NewGuid(),
KeyType = "TST",
Key = new("kid", [21]),
CreatedAt = DateTime.UtcNow
});
// Act // Act
RealmService sut = new(db, _keyService); RealmService sut = new(db, _keyService);
var response = await sut.Create( var response = await sut.Create(

View file

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

View file

@ -3,4 +3,10 @@ namespace IdentityShroud.Core.Contracts;
public interface ISecretProvider public interface ISecretProvider
{ {
string GetSecret(string name); string GetSecret(string name);
/// <summary>
/// Should return one active key, might return inactive keys.
/// </summary>
/// <returns></returns>
EncryptionKey[] GetKeys(string name);
} }

View file

@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using IdentityShroud.Core.Contracts;
namespace IdentityShroud.Core.Model; namespace IdentityShroud.Core.Model;
@ -11,5 +12,5 @@ public class ClientSecret
public Guid ClientId { get; set; } public Guid ClientId { get; set; }
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
public DateTime? RevokedAt { get; set; } public DateTime? RevokedAt { get; set; }
public required byte[] SecretEncrypted { get; set; } public required EncryptedValue Secret { get; set; }
} }

View file

@ -1,15 +1,19 @@
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using IdentityShroud.Core.Contracts;
using Microsoft.EntityFrameworkCore;
namespace IdentityShroud.Core.Model; namespace IdentityShroud.Core.Model;
[Table("realm_key")] [Table("realm_key")]
public record RealmKey(Guid Id, string KeyType, byte[] KeyDataEncrypted, DateTime CreatedAt) public record RealmKey
{ {
public Guid Id { get; private set; } = Id; public required Guid Id { get; init; }
public string KeyType { get; private set; } = KeyType; public required string KeyType { get; init; }
public byte[] KeyDataEncrypted { get; private set; } = KeyDataEncrypted;
public DateTime CreatedAt { get; private set; } = CreatedAt;
public required EncryptedValue Key { get; init; }
public required DateTime CreatedAt { get; init; }
public DateTime? RevokedAt { get; set; } public DateTime? RevokedAt { get; set; }
/// <summary> /// <summary>

View file

@ -14,4 +14,9 @@ public class ConfigurationSecretProvider(IConfiguration configuration) : ISecret
{ {
return secrets.GetValue<string>(name) ?? ""; return secrets.GetValue<string>(name) ?? "";
} }
public EncryptionKey[] GetKeys(string name)
{
return secrets.GetSection(name).Get<EncryptionKey[]>() ?? [];
}
} }

View file

@ -0,0 +1,6 @@
using Microsoft.EntityFrameworkCore;
namespace IdentityShroud.Core.Contracts;
[Owned]
public record EncryptedValue(string KeyId, byte[] Value);

View file

@ -0,0 +1,4 @@
namespace IdentityShroud.Core.Contracts;
// Contains an encryption key and associated relevant data
public record EncryptionKey(string Id, bool Active, string Algorithm, byte[] Key);

View file

@ -57,7 +57,7 @@ public class ClientService(
return new ClientSecret() return new ClientSecret()
{ {
CreatedAt = clock.UtcNow(), CreatedAt = clock.UtcNow(),
SecretEncrypted = cryptor.Encrypt(secret), Secret = cryptor.Encrypt(secret),
}; };
} }

View file

@ -1,6 +1,5 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Security;
namespace IdentityShroud.Core.Services; namespace IdentityShroud.Core.Services;
@ -17,16 +16,21 @@ public class EncryptionService : IEncryptionService
new (12, 16), // version 1 new (12, 16), // version 1
]; ];
private readonly byte[] _encryptionKey; // Note this array is expected to have one item in it most of the during key rotation it will have two
// until it is ensured the old key can safely be removed. More then two will work but is not really expected.
private readonly EncryptionKey[] _encryptionKeys;
private EncryptionKey ActiveKey => _encryptionKeys.Single(k => k.Active);
private EncryptionKey GetKey(string keyId) => _encryptionKeys.Single(k => k.Id == keyId);
public EncryptionService(ISecretProvider secretProvider) public EncryptionService(ISecretProvider secretProvider)
{ {
_encryptionKey = Convert.FromBase64String(secretProvider.GetSecret("Master")); _encryptionKeys = secretProvider.GetKeys("master");
if (_encryptionKey.Length != 32) // 256bit key // if (_encryptionKey.Length != 32) // 256bit key
throw new Exception("Key must be 256bits (32 bytes) for AES256GCM."); // throw new Exception("Key must be 256bits (32 bytes) for AES256GCM.");
} }
public byte[] Encrypt(ReadOnlyMemory<byte> plaintext) public EncryptedValue Encrypt(ReadOnlyMemory<byte> plaintext)
{ {
const int versionNumber = 1; const int versionNumber = 1;
AlgVersion versionParams = _versions[versionNumber]; AlgVersion versionParams = _versions[versionNumber];
@ -44,26 +48,21 @@ public class EncryptionService : IEncryptionService
// use the spans to place the data directly in its place // use the spans to place the data directly in its place
RandomNumberGenerator.Fill(nonce); RandomNumberGenerator.Fill(nonce);
using var aes = new AesGcm(_encryptionKey, versionParams.TagSize); var encryptionKey = ActiveKey;
using var aes = new AesGcm(encryptionKey.Key, versionParams.TagSize);
aes.Encrypt(nonce, plaintext.Span, cipher, tag); aes.Encrypt(nonce, plaintext.Span, cipher, tag);
return result; return new (encryptionKey.Id, result);
} }
public byte[] Decrypt(ReadOnlyMemory<byte> input) public byte[] Decrypt(EncryptedValue input)
{ {
var encryptionKey = GetKey(input.KeyId);
// ----------------------------------------------------------------
// 1⃣ Extract the three components. var payload = input.Value.AsSpan();
// ----------------------------------------------------------------
// AesGcm.NonceByteSizes.MaxSize = 12 bytes (standard GCM nonce length)
// AesGcm.TagByteSizes.MaxSize = 16 bytes (128bit authentication tag)
//int nonceSize = AesGcm.NonceByteSizes.MaxSize; // 12
//int tagSize = AesGcm.TagByteSizes.MaxSize; // 16
var payload = input.Span;
int versionNumber = (int)payload[0]; int versionNumber = (int)payload[0];
if (versionNumber != 1) if (versionNumber != 1)
throw new ArgumentException("Invalid payloag"); throw new ArgumentException("Invalid payload");
AlgVersion versionParams = _versions[versionNumber]; AlgVersion versionParams = _versions[versionNumber];
@ -77,7 +76,7 @@ public class EncryptionService : IEncryptionService
byte[] plaintext = new byte[cipher.Length]; byte[] plaintext = new byte[cipher.Length];
using var aes = new AesGcm(_encryptionKey, versionParams.TagSize); using var aes = new AesGcm(encryptionKey.Key, versionParams.TagSize);
try try
{ {
aes.Decrypt(nonce, cipher, tag, plaintext); aes.Decrypt(nonce, cipher, tag, plaintext);

View file

@ -29,23 +29,18 @@ public class KeyService(
IKeyProvider provider = keyProviderFactory.CreateProvider(realmKey.KeyType); IKeyProvider provider = keyProviderFactory.CreateProvider(realmKey.KeyType);
provider.SetJwkParameters( provider.SetJwkParameters(
cryptor.Decrypt(realmKey.KeyDataEncrypted), cryptor.Decrypt(realmKey.Key),
jwk); jwk);
return jwk; return jwk;
} }
private RealmKey CreateKey(string keyType, byte[] plainKey) => private RealmKey CreateKey(string keyType, byte[] plainKey) =>
new RealmKey( new RealmKey()
Guid.NewGuid(), {
keyType, Id = Guid.NewGuid(),
cryptor.Encrypt(plainKey), KeyType = keyType,
clock.UtcNow()); Key = cryptor.Encrypt(plainKey),
CreatedAt = clock.UtcNow(),
// public byte[] GetPrivateKey(IEncryptionService encryptionService) };
// {
// if (_privateKeyDecrypted.Length == 0 && PrivateKeyEncrypted.Length > 0)
// _privateKeyDecrypted = encryptionService.Decrypt(PrivateKeyEncrypted);
// return _privateKeyDecrypted;
// }
} }

View file

@ -8,11 +8,11 @@ public static class EncryptionServiceSubstitute
{ {
var encryptionService = Substitute.For<IEncryptionService>(); var encryptionService = Substitute.For<IEncryptionService>();
encryptionService encryptionService
.Encrypt(Arg.Any<byte[]>()) .Encrypt(Arg.Any<ReadOnlyMemory<byte>>())
.Returns(x => x.ArgAt<byte[]>(0)); .Returns(x => new EncryptedValue("kid", x.ArgAt<ReadOnlyMemory<byte>>(0).ToArray()));
encryptionService encryptionService
.Decrypt(Arg.Any<ReadOnlyMemory<byte>>()) .Decrypt(Arg.Any<EncryptedValue>())
.Returns(x => x.ArgAt<ReadOnlyMemory<byte>>(0).ToArray()); .Returns(x => x.ArgAt<EncryptedValue>(0).Value);
return encryptionService; return encryptionService;
} }
} }

View file

@ -20,10 +20,10 @@
<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_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_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/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/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/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/=92a0e31a_002D2dfa_002D4c9d_002D994b_002D2d5679155267/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt; <s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=b20a3316_002Db435_002D49e2_002Dbeaf_002De4cd62c44994/@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;Solution /&gt;
&lt;/SessionState&gt;</s:String> &lt;/SessionState&gt;</s:String>
@ -36,4 +36,5 @@
</wpf:ResourceDictionary> </wpf:ResourceDictionary>