diff --git a/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs b/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs index a91ea62..ecc46c0 100644 --- a/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs +++ b/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs @@ -125,7 +125,7 @@ public class RealmApisTests : IClassFixture public async Task GetJwks() { // setup - IEncryptionService encryptionService = _factory.Services.GetRequiredService(); + IDekEncryptionService dekEncryptionService = _factory.Services.GetRequiredService(); using var rsa = RSA.Create(2048); RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false); @@ -134,7 +134,7 @@ public class RealmApisTests : IClassFixture { Id = Guid.NewGuid(), KeyType = "RSA", - Key = encryptionService.Encrypt(rsa.ExportPkcs8PrivateKey()), + Key = dekEncryptionService.Encrypt(rsa.ExportPkcs8PrivateKey()), CreatedAt = DateTime.UtcNow, }; diff --git a/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs b/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs index 2a2be31..9846559 100644 --- a/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs +++ b/IdentityShroud.Api.Tests/Fixtures/ApplicationFactory.cs @@ -28,7 +28,7 @@ public class ApplicationFactory : WebApplicationFactory, IAsyncLifetime new Dictionary { ["Db:ConnectionString"] = _postgresqlServer.GetConnectionString(), - ["secrets:master:0:Id"] = "key1", + ["secrets:master:0:Id"] = "94970f27-3d88-4223-9940-7dd57548f5b5", ["secrets:master:0:Active"] = "true", ["secrets:master:0:Algorithm"] = "AES", ["secrets:master:0:Key"] = "GVd07qW0frRX9quPX/X62L88BeRR7+IzgRJHtG7ZzHw=", diff --git a/IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs b/IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs index 0df74a3..b6350cf 100644 --- a/IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs +++ b/IdentityShroud.Api.Tests/Mappers/KeyServiceTests.cs @@ -2,6 +2,7 @@ using System.Buffers.Text; using System.Security.Cryptography; using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Model; +using IdentityShroud.Core.Security; using IdentityShroud.Core.Security.Keys; using IdentityShroud.Core.Services; using IdentityShroud.TestUtils.Substitutes; @@ -10,7 +11,9 @@ namespace IdentityShroud.Api.Tests.Mappers; public class KeyServiceTests { - private readonly IEncryptionService _encryptionService = EncryptionServiceSubstitute.CreatePassthrough(); + private readonly IDekEncryptionService _dekEncryptionService = EncryptionServiceSubstitute.CreatePassthrough(); + + //private readonly IDataEncryptionService _dataEncryptionService = Substitute.For(); //private readonly IKeyProviderFactory _keyProviderFactory = Substitute.For(); [Fact] @@ -20,18 +23,20 @@ public class KeyServiceTests using RSA rsa = RSA.Create(2048); RSAParameters parameters = rsa.ExportParameters(includePrivateParameters: false); + + DekId kid = DekId.NewId(); RealmKey realmKey = new() { Id = new("60bb79cf-4bac-4521-87f2-ac87cc15541f"), KeyType = "RSA", - Key = new("", rsa.ExportPkcs8PrivateKey()), + Key = new(EncryptionServiceSubstitute.KeyId, rsa.ExportPkcs8PrivateKey()), CreatedAt = DateTime.UtcNow, Priority = 10, }; // Act - KeyService sut = new(_encryptionService, new KeyProviderFactory(), new ClockService()); + KeyService sut = new(_dekEncryptionService, new KeyProviderFactory(), new ClockService()); var jwk = sut.CreateJsonWebKey(realmKey); Assert.NotNull(jwk); diff --git a/IdentityShroud.Api/Program.cs b/IdentityShroud.Api/Program.cs index 0a145c2..29f6736 100644 --- a/IdentityShroud.Api/Program.cs +++ b/IdentityShroud.Api/Program.cs @@ -38,15 +38,19 @@ void ConfigureBuilder(WebApplicationBuilder builder) services.AddScoped(); services.AddScoped(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddOptions().Bind(configuration.GetSection("db")); services.AddSingleton(); services.AddScoped(); + services.AddScoped(); - services.AddValidatorsFromAssemblyContaining(); + services.AddValidatorsFromAssemblyContaining(); + services.AddHttpContextAccessor(); builder.Host.UseSerilog((context, services, configuration) => configuration .Enrich.FromLogContext() diff --git a/IdentityShroud.Core.Tests/Security/ConfigurationSecretProviderTests.cs b/IdentityShroud.Core.Tests/Security/ConfigurationSecretProviderTests.cs index 180732b..01851a4 100644 --- a/IdentityShroud.Core.Tests/Security/ConfigurationSecretProviderTests.cs +++ b/IdentityShroud.Core.Tests/Security/ConfigurationSecretProviderTests.cs @@ -28,13 +28,13 @@ public class ConfigurationSecretProviderTests "secrets": { "master": [ { - "Id": "first", + "Id": "5676d159-5495-4945-aa84-59ee694aa8a2", "Active": true, "Algorithm": "AES", "Key": "yoQ4W7EaNjo7s3FBYkWo5BLyX1BnLyWd7BlSaDIrkzo=" }, { - "Id": "second", + "Id": "b82489e7-a05a-4d64-b9a5-58d2f2c0dc39", "Active": false, "Algorithm": "AES", "Key": "YSWK6vTJXCJOGLpCo+TtZ6anKNzvA1VT2xXLHbmq4M0=" @@ -47,15 +47,17 @@ public class ConfigurationSecretProviderTests ConfigurationSecretProvider sut = new(BuildConfigFromJson(jsonConfig)); + // act var keys = sut.GetKeys("master"); + // verify Assert.Equal(2, keys.Length); var active = keys.Single(k => k.Active); - Assert.Equal("first", active.Id); + Assert.Equal(new Guid("5676d159-5495-4945-aa84-59ee694aa8a2"), active.Id.Id); Assert.Equal("AES", active.Algorithm); Assert.Equal(Convert.FromBase64String("yoQ4W7EaNjo7s3FBYkWo5BLyX1BnLyWd7BlSaDIrkzo="), active.Key); var inactive = keys.Single(k => !k.Active); - Assert.Equal("second", inactive.Id); + Assert.Equal(new Guid("b82489e7-a05a-4d64-b9a5-58d2f2c0dc39"), inactive.Id.Id); } } \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs b/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs index 30bb3b6..5b08563 100644 --- a/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs +++ b/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs @@ -1,5 +1,6 @@ using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Model; +using IdentityShroud.Core.Security; using IdentityShroud.Core.Services; using IdentityShroud.Core.Tests.Fixtures; using IdentityShroud.TestUtils.Substitutes; @@ -10,12 +11,17 @@ namespace IdentityShroud.Core.Tests.Services; public class ClientServiceTests : IClassFixture { private readonly DbFixture _dbFixture; - private readonly IEncryptionService _encryptionService = EncryptionServiceSubstitute.CreatePassthrough(); + //private readonly IDekEncryptionService _dekEncryptionService = EncryptionServiceSubstitute.CreatePassthrough(); + private readonly IDataEncryptionService _dataEncryptionService = Substitute.For(); + private readonly IClock _clock = Substitute.For(); private readonly Guid _realmId = new("a1b2c3d4-0000-0000-0000-000000000001"); public ClientServiceTests(DbFixture dbFixture) { + _dataEncryptionService.Encrypt(Arg.Any>()) + .Returns(x => new EncryptedValue(DekId.NewId(), x.ArgAt>(0).ToArray())); + _dbFixture = dbFixture; using Db db = dbFixture.CreateDbContext(); if (!db.Database.EnsureCreated()) @@ -51,7 +57,7 @@ public class ClientServiceTests : IClassFixture await using (var db = _dbFixture.CreateDbContext()) { // Act - ClientService sut = new(db, _encryptionService, _clock); + ClientService sut = new(db, _dataEncryptionService, _clock); var response = await sut.Create( _realmId, new ClientCreateRequest @@ -107,7 +113,7 @@ public class ClientServiceTests : IClassFixture await using var actContext = _dbFixture.CreateDbContext(); // Act - ClientService sut = new(actContext, _encryptionService, _clock); + ClientService sut = new(actContext, _dataEncryptionService, _clock); Client? result = await sut.GetByClientId(_realmId, clientId, TestContext.Current.CancellationToken); // Verify @@ -142,7 +148,7 @@ public class ClientServiceTests : IClassFixture await using var actContext = _dbFixture.CreateDbContext(); // Act - ClientService sut = new(actContext, _encryptionService, _clock); + ClientService sut = new(actContext, _dataEncryptionService, _clock); Client? result = await sut.FindById(_realmId, searchId, TestContext.Current.CancellationToken); // Verify diff --git a/IdentityShroud.Core.Tests/Services/EncryptionServiceTests.cs b/IdentityShroud.Core.Tests/Services/DekEncryptionServiceTests.cs similarity index 60% rename from IdentityShroud.Core.Tests/Services/EncryptionServiceTests.cs rename to IdentityShroud.Core.Tests/Services/DekEncryptionServiceTests.cs index 7a7be2c..fc4a45f 100644 --- a/IdentityShroud.Core.Tests/Services/EncryptionServiceTests.cs +++ b/IdentityShroud.Core.Tests/Services/DekEncryptionServiceTests.cs @@ -1,9 +1,10 @@ using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Security; using IdentityShroud.Core.Services; namespace IdentityShroud.Core.Tests.Services; -public class EncryptionServiceTests +public class DekEncryptionServiceTests { [Fact] public void RoundtripWorks() @@ -13,9 +14,9 @@ public class EncryptionServiceTests // setup byte[] keyValue = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); var secretProvider = Substitute.For(); - EncryptionKey[] keys = + KeyEncryptionKey[] keys = [ - new EncryptionKey("1", true, "AES", keyValue) + new KeyEncryptionKey(KekId.NewId(), true, "AES", keyValue) ]; secretProvider.GetKeys("master").Returns(keys); @@ -23,68 +24,38 @@ public class EncryptionServiceTests ReadOnlySpan input = "Hello, World!"u8; // act - EncryptionService sut = new(secretProvider); - EncryptedValue cipher = sut.Encrypt(input.ToArray()); + DekEncryptionService sut = new(secretProvider); + EncryptedDek cipher = sut.Encrypt(input.ToArray()); byte[] result = sut.Decrypt(cipher); // verify Assert.Equal(input, result); } - [Fact] - public void DecodeV1_Success() - { - // When introducing a new version we need version specific tests to - // make sure decoding of legacy data still works. - - // setup - byte[] cipher = - [ - 1, 198, 55, 58, 56, 110, 238, 59, 158, 214, 85, 241, 26, 44, 140, 229, 128, 111, 167, 154, 160, 177, 152, - 193, 74, 4, 235, 82, 207, 87, 32, 10, 239, 4, 246, 25, 21, 249, 25, 59, 160, 101 - ]; - EncryptedValue secret = new("kid", cipher); - - byte[] keyValue = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); - var secretProvider = Substitute.For(); - EncryptionKey[] keys = - [ - new EncryptionKey("kid", true, "AES", keyValue) - ]; - secretProvider.GetKeys("master").Returns(keys); - - // act - EncryptionService sut = new(secretProvider); - byte[] result = sut.Decrypt(secret); - - // verify - Assert.Equal("Hello, World!"u8, result); - } - [Fact] public void DetectsCorruptInput() { // When introducing a new version we need version specific tests to // make sure decoding of legacy data still works. - + KekId kid = KekId.NewId(); // setup byte[] cipher = // NOTE INCORRECT CIPHER DO NOT USE IN OTHER TESTS [ 1, 198, 55, 58, 56, 110, 238, 59, 158, 214, 85, 241, 26, 44, 140, 229, 128, 111, 167, 154, 160, 177, 152, 193, 75, 4, 235, 82, 207, 87, 32, 10, 239, 4, 246, 25, 21, 249, 25, 59, 160, 101 ]; - EncryptedValue secret = new("kid", cipher); + EncryptedDek secret = new(kid, cipher); byte[] keyValue = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); var secretProvider = Substitute.For(); - EncryptionKey[] keys = + KeyEncryptionKey[] keys = [ - new EncryptionKey("kid", true, "AES", keyValue) + new KeyEncryptionKey(kid, true, "AES", keyValue) ]; secretProvider.GetKeys("master").Returns(keys); // act - EncryptionService sut = new(secretProvider); + DekEncryptionService sut = new(secretProvider); Assert.Throws( () => sut.Decrypt(secret), ex => ex.Message.Contains("Decryption failed") ? null : "Expected Decryption failed in message"); @@ -96,25 +67,28 @@ public class EncryptionServiceTests // The key is marked inactive also it is the second key // setup + KekId kid1 = KekId.NewId(); + KekId kid2 = KekId.NewId(); + byte[] cipher = [ 1, 198, 55, 58, 56, 110, 238, 59, 158, 214, 85, 241, 26, 44, 140, 229, 128, 111, 167, 154, 160, 177, 152, 193, 74, 4, 235, 82, 207, 87, 32, 10, 239, 4, 246, 25, 21, 249, 25, 59, 160, 101 ]; - EncryptedValue secret = new("1", cipher); + EncryptedDek secret = new(kid1, cipher); byte[] keyValue1 = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); byte[] keyValue2 = Convert.FromBase64String("Dat1RwRvuLX3wdKMMP4NwHdBl8tJJsKfp01qikyo8aw="); var secretProvider = Substitute.For(); - EncryptionKey[] keys = + KeyEncryptionKey[] keys = [ - new EncryptionKey("2", true, "AES", keyValue2), - new EncryptionKey("1", false, "AES", keyValue1), + new KeyEncryptionKey(kid2, true, "AES", keyValue2), + new KeyEncryptionKey(kid1, false, "AES", keyValue1), ]; secretProvider.GetKeys("master").Returns(keys); // act - EncryptionService sut = new(secretProvider); + DekEncryptionService sut = new(secretProvider); byte[] result = sut.Decrypt(secret); // verify @@ -125,22 +99,25 @@ public class EncryptionServiceTests public void EncryptionUsesActiveKey() { // setup + KekId kid1 = KekId.NewId(); + KekId kid2 = KekId.NewId(); + byte[] keyValue1 = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); byte[] keyValue2 = Convert.FromBase64String("Dat1RwRvuLX3wdKMMP4NwHdBl8tJJsKfp01qikyo8aw="); var secretProvider = Substitute.For(); - EncryptionKey[] keys = + KeyEncryptionKey[] keys = [ - new EncryptionKey("1", false, "AES", keyValue1), - new EncryptionKey("2", true, "AES", keyValue2), + new KeyEncryptionKey(kid1, false, "AES", keyValue1), + new KeyEncryptionKey(kid2, true, "AES", keyValue2), ]; secretProvider.GetKeys("master").Returns(keys); ReadOnlySpan input = "Hello, World!"u8; // act - EncryptionService sut = new(secretProvider); - EncryptedValue cipher = sut.Encrypt(input.ToArray()); + DekEncryptionService sut = new(secretProvider); + EncryptedDek cipher = sut.Encrypt(input.ToArray()); // Verify - Assert.Equal("2", cipher.KeyId); + Assert.Equal(kid2, cipher.KekId); } } \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/Services/EncryptionTests.cs b/IdentityShroud.Core.Tests/Services/EncryptionTests.cs new file mode 100644 index 0000000..2dfbb52 --- /dev/null +++ b/IdentityShroud.Core.Tests/Services/EncryptionTests.cs @@ -0,0 +1,30 @@ +using IdentityShroud.Core.Security; +using IdentityShroud.Core.Services; + +namespace IdentityShroud.Core.Tests.Services; + +public class EncryptionTests +{ + [Fact] + public void DecodeV1_Success() + { + // When introducing a new version we need version specific tests to + // make sure decoding of legacy data still works. + + // setup + byte[] cipher = + [ + 1, 198, 55, 58, 56, 110, 238, 59, 158, 214, 85, 241, 26, 44, 140, 229, 128, 111, 167, 154, 160, 177, 152, + 193, 74, 4, 235, 82, 207, 87, 32, 10, 239, 4, 246, 25, 21, 249, 25, 59, 160, 101 + ]; + byte[] keyValue = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); + + // act + byte[] result = Encryption.Decrypt(cipher, keyValue); + + // verify + Assert.Equal("Hello, World!"u8, result); + } + + +} \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs b/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs index ea34ca8..fda233e 100644 --- a/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs +++ b/IdentityShroud.Core.Tests/Services/RealmServiceTests.cs @@ -1,5 +1,6 @@ using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Model; +using IdentityShroud.Core.Security; using IdentityShroud.Core.Security.Keys; using IdentityShroud.Core.Services; using IdentityShroud.Core.Tests.Fixtures; @@ -43,7 +44,7 @@ public class RealmServiceTests : IClassFixture { Id = Guid.NewGuid(), KeyType = "TST", - Key = new("kid", [21]), + Key = new(KekId.NewId(), [21]), CreatedAt = DateTime.UtcNow }); // Act diff --git a/IdentityShroud.Core.Tests/UnitTest1.cs b/IdentityShroud.Core.Tests/UnitTest1.cs index 7a12bc4..7506fd0 100644 --- a/IdentityShroud.Core.Tests/UnitTest1.cs +++ b/IdentityShroud.Core.Tests/UnitTest1.cs @@ -35,7 +35,6 @@ public class UnitTest1 // Option 3: Generate a new key for testing rsa.KeySize = 2048; - // Your already encoded header and payload string header = "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJybVZ3TU5rM0o1WHlmMWhyS3NVbEVYN1BNUm42dlZKY0h3U3FYMUVQRnFJIn0"; string payload = "eyJleHAiOjE3Njk5MzY5MDksImlhdCI6MTc2OTkzNjYwOSwianRpIjoiMjNiZDJmNjktODdhYi00YmM2LWE0MWQtZGZkNzkxNDc4ZDM0IiwiaXNzIjoiaHR0cHM6Ly9pYW0ua2Fzc2FjbG91ZC5ubC9hdXRoL3JlYWxtcy9tcGx1c2thc3NhIiwiYXVkIjpbImthc3NhLW1hbmFnZW1lbnQtc2VydmljZSIsImFwYWNoZTItaW50cmFuZXQtYXV0aCIsImFjY291bnQiXSwic3ViIjoiMDkzY2NmMTUtYzRhOS00YWI0LTk3MWYtZDVhMDIyMzZkODVhIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoibXBvYmFja2VuZCIsInNpZCI6IjI2NmUyNjJiLTU5NjMtNDUyZi04ZTI3LWIwZTkzMjBkNTZkNiIsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJkZWZhdWx0LXJvbGVzLW1wbHVza2Fzc2EiLCJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIiwiZGVhbGVyLW1lZGV3ZXJrZXItcm9sZSIsIm1wbHVza2Fzc2EtbWVkZXdlcmtlci1yb2xlIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsiYXBhY2hlMi1pbnRyYW5ldC1hdXRoIjp7InJvbGVzIjpbImludHJhbmV0IiwicmVsZWFzZW5vdGVzX3dyaXRlIl19LCJrYXNzYS1tYW5hZ2VtZW50LXNlcnZpY2UiOnsicm9sZXMiOlsicG9zYWNjb3VudF9wYXNzd29yZHJlc2V0IiwiZHJhZnRfbGljZW5zZV93cml0ZSIsImxpY2Vuc2VfcmVhZCIsImtub3dsZWRnZUl0ZW1fcmVhZCIsIm1haWxpbmdfcmVhZCIsIm1wbHVzYXBpX3JlYWQiLCJkYXRhYmFzZV91c2VyX3dyaXRlIiwiZW52aXJvbm1lbnRfd3JpdGUiLCJna3NfYXV0aGNvZGVfcmVhZCIsImVtcGxveWVlX3JlYWQiLCJkYXRhYmFzZV91c2VyX3JlYWQiLCJhcGlhY2NvdW50X3Bhc3N3b3JkcmVzZXQiLCJtcGx1c2FwaV93cml0ZSIsImVudmlyb25tZW50X3JlYWQiLCJrbm93bGVkZ2VJdGVtX3dyaXRlIiwiZGF0YWJhc2VfdXNlcl9wYXNzd29yZF9yZWFkIiwibGljZW5zZV93cml0ZSIsImN1c3RvbWVyX3dyaXRlIiwiZGVhbGVyX3JlYWQiLCJlbXBsb3llZV93cml0ZSIsImRhdGFiYXNlX2NvbmZpZ3VyYXRpb25fd3JpdGUiLCJyZWxhdGlvbnNfcmVhZCIsImRhdGFiYXNlX3VzZXJfcGFzc3dvcmRfbXBsdXNfZW5jcnlwdGVkX3JlYWQiLCJkcmFmdF9saWNlbnNlX3JlYWQiLCJkYXRhYmFzZV9jb25maWd1cmF0aW9uX3JlYWQiXX0sImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoia21zIGVtYWlsIHByb2ZpbGUiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZGVhbGVySWQiOjEsIm5hbWUiOiJFZWxrZSBLbGVpbiIsInByZWZlcnJlZF91c2VybmFtZSI6ImVlbGtlQGJvbHQubmwiLCJsb2NhbGUiOiJlbiIsImdpdmVuX25hbWUiOiJFZWxrZSIsImZhbWlseV9uYW1lIjoiS2xlaW4iLCJlbWFpbCI6ImVlbGtlQGJvbHQubmwiLCJlbXBsb3llZU51bWJlciI6NTR9"; @@ -51,6 +50,15 @@ public class UnitTest1 // Or generate complete JWT // string completeJwt = JwtSignatureGenerator.GenerateCompleteJwt(header, payload, rsa); // Console.WriteLine($"Complete JWT: {completeJwt}"); + + rsa.ExportRSAPublicKey(); // PKCS#1 + } + + using (ECDsa dsa = ECDsa.Create()) + { + dsa.ExportPkcs8PrivateKey(); + + dsa.ExportSubjectPublicKeyInfo(); // x509 } } } diff --git a/IdentityShroud.Core/Contracts/IEncryptionService.cs b/IdentityShroud.Core/Contracts/IDataEncryptionService.cs similarity index 65% rename from IdentityShroud.Core/Contracts/IEncryptionService.cs rename to IdentityShroud.Core/Contracts/IDataEncryptionService.cs index 2fa7e9c..55eafe2 100644 --- a/IdentityShroud.Core/Contracts/IEncryptionService.cs +++ b/IdentityShroud.Core/Contracts/IDataEncryptionService.cs @@ -1,6 +1,8 @@ +using IdentityShroud.Core.Security; + namespace IdentityShroud.Core.Contracts; -public interface IEncryptionService +public interface IDataEncryptionService { EncryptedValue Encrypt(ReadOnlyMemory plain); byte[] Decrypt(EncryptedValue input); diff --git a/IdentityShroud.Core/Contracts/IDekEncryptionService.cs b/IdentityShroud.Core/Contracts/IDekEncryptionService.cs new file mode 100644 index 0000000..45e9b3f --- /dev/null +++ b/IdentityShroud.Core/Contracts/IDekEncryptionService.cs @@ -0,0 +1,11 @@ +using IdentityShroud.Core.Security; + +namespace IdentityShroud.Core.Contracts; + + + +public interface IDekEncryptionService +{ + EncryptedDek Encrypt(ReadOnlyMemory plain); + byte[] Decrypt(EncryptedDek input); +} \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/IRealmContext.cs b/IdentityShroud.Core/Contracts/IRealmContext.cs new file mode 100644 index 0000000..c757a02 --- /dev/null +++ b/IdentityShroud.Core/Contracts/IRealmContext.cs @@ -0,0 +1,9 @@ +using IdentityShroud.Core.Model; + +namespace IdentityShroud.Core.Contracts; + +public interface IRealmContext +{ + public Realm GetRealm(); + Task> GetDeks(CancellationToken ct = default); +} \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/IRealmService.cs b/IdentityShroud.Core/Contracts/IRealmService.cs index b740aa5..4598b97 100644 --- a/IdentityShroud.Core/Contracts/IRealmService.cs +++ b/IdentityShroud.Core/Contracts/IRealmService.cs @@ -11,4 +11,5 @@ public interface IRealmService Task> Create(RealmCreateRequest request, CancellationToken ct = default); Task LoadActiveKeys(Realm realm); + Task LoadDeks(Realm realm); } \ No newline at end of file diff --git a/IdentityShroud.Core/Contracts/ISecretProvider.cs b/IdentityShroud.Core/Contracts/ISecretProvider.cs index a586fe7..4d4182e 100644 --- a/IdentityShroud.Core/Contracts/ISecretProvider.cs +++ b/IdentityShroud.Core/Contracts/ISecretProvider.cs @@ -1,3 +1,5 @@ +using IdentityShroud.Core.Security; + namespace IdentityShroud.Core.Contracts; public interface ISecretProvider @@ -8,5 +10,5 @@ public interface ISecretProvider /// Should return one active key, might return inactive keys. /// /// - EncryptionKey[] GetKeys(string name); + KeyEncryptionKey[] GetKeys(string name); } diff --git a/IdentityShroud.Core/Db.cs b/IdentityShroud.Core/Db.cs index cd7a493..a37136c 100644 --- a/IdentityShroud.Core/Db.cs +++ b/IdentityShroud.Core/Db.cs @@ -1,5 +1,7 @@ using IdentityShroud.Core.Model; +using IdentityShroud.Core.Security; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -19,7 +21,41 @@ public class Db( public virtual DbSet Clients { get; set; } public virtual DbSet Realms { get; set; } public virtual DbSet Keys { get; set; } - + public virtual DbSet Deks { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var dekIdConverter = new ValueConverter( + id => id.Id, + guid => new DekId(guid)); + + var kekIdConverter = new ValueConverter( + id => id.Id, + guid => new KekId(guid)); + + modelBuilder.Entity() + .Property(d => d.Id) + .HasConversion(dekIdConverter); + + modelBuilder.Entity() + .OwnsOne(d => d.KeyData, keyData => + { + keyData.Property(k => k.KekId).HasConversion(kekIdConverter); + }); + + modelBuilder.Entity() + .OwnsOne(k => k.Key, key => + { + key.Property(k => k.KekId).HasConversion(kekIdConverter); + }); + + modelBuilder.Entity() + .OwnsOne(c => c.Secret, secret => + { + secret.Property(s => s.DekId).HasConversion(dekIdConverter); + }); + } + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseNpgsql(""); diff --git a/IdentityShroud.Core/IdentityShroud.Core.csproj b/IdentityShroud.Core/IdentityShroud.Core.csproj index 1e7e8d0..9dd3e34 100644 --- a/IdentityShroud.Core/IdentityShroud.Core.csproj +++ b/IdentityShroud.Core/IdentityShroud.Core.csproj @@ -12,6 +12,7 @@ + diff --git a/IdentityShroud.Core/Model/ClientSecret.cs b/IdentityShroud.Core/Model/ClientSecret.cs index 0b0122d..52d25cc 100644 --- a/IdentityShroud.Core/Model/ClientSecret.cs +++ b/IdentityShroud.Core/Model/ClientSecret.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Security; namespace IdentityShroud.Core.Model; diff --git a/IdentityShroud.Core/Model/Realm.cs b/IdentityShroud.Core/Model/Realm.cs index 7fcd10c..f3e087a 100644 --- a/IdentityShroud.Core/Model/Realm.cs +++ b/IdentityShroud.Core/Model/Realm.cs @@ -21,9 +21,20 @@ public class Realm public List Keys { get; init; } = []; + public List Deks { get; init; } = []; + /// /// Can be overriden per client /// public string DefaultSignatureAlgorithm { get; set; } = JsonWebAlgorithm.RS256; - +} + +[Table("realm_dek")] +public record RealmDek +{ + public required DekId Id { get; init; } + public required bool Active { get; init; } + public required string Algorithm { get; init; } + public required EncryptedDek KeyData { get; init; } + public required Guid RealmId { get; init; } } diff --git a/IdentityShroud.Core/Model/RealmKey.cs b/IdentityShroud.Core/Model/RealmKey.cs index 038f853..3fcf2d1 100644 --- a/IdentityShroud.Core/Model/RealmKey.cs +++ b/IdentityShroud.Core/Model/RealmKey.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations.Schema; using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Security; using Microsoft.EntityFrameworkCore; namespace IdentityShroud.Core.Model; @@ -12,7 +13,7 @@ public record RealmKey public required string KeyType { get; init; } - public required EncryptedValue Key { get; init; } + public required EncryptedDek Key { get; init; } public required DateTime CreatedAt { get; init; } public DateTime? RevokedAt { get; set; } diff --git a/IdentityShroud.Core/Security/ConfigurationSecretProvider.cs b/IdentityShroud.Core/Security/ConfigurationSecretProvider.cs index dd616b1..9355c0b 100644 --- a/IdentityShroud.Core/Security/ConfigurationSecretProvider.cs +++ b/IdentityShroud.Core/Security/ConfigurationSecretProvider.cs @@ -15,8 +15,8 @@ public class ConfigurationSecretProvider(IConfiguration configuration) : ISecret return secrets.GetValue(name) ?? ""; } - public EncryptionKey[] GetKeys(string name) + public KeyEncryptionKey[] GetKeys(string name) { - return secrets.GetSection(name).Get() ?? []; + return secrets.GetSection(name).Get() ?? []; } } \ No newline at end of file diff --git a/IdentityShroud.Core/Security/DekId.cs b/IdentityShroud.Core/Security/DekId.cs new file mode 100644 index 0000000..276178e --- /dev/null +++ b/IdentityShroud.Core/Security/DekId.cs @@ -0,0 +1,6 @@ +namespace IdentityShroud.Core.Security; + +public record struct DekId(Guid Id) +{ + public static DekId NewId() => new(Guid.NewGuid()); +} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/EncryptedDek.cs b/IdentityShroud.Core/Security/EncryptedDek.cs new file mode 100644 index 0000000..377a2f6 --- /dev/null +++ b/IdentityShroud.Core/Security/EncryptedDek.cs @@ -0,0 +1,6 @@ +using Microsoft.EntityFrameworkCore; + +namespace IdentityShroud.Core.Security; + +[Owned] +public record EncryptedDek(KekId KekId, byte[] Value); \ No newline at end of file diff --git a/IdentityShroud.Core/Security/EncryptedValue.cs b/IdentityShroud.Core/Security/EncryptedValue.cs index 655ab13..173c295 100644 --- a/IdentityShroud.Core/Security/EncryptedValue.cs +++ b/IdentityShroud.Core/Security/EncryptedValue.cs @@ -1,6 +1,8 @@ using Microsoft.EntityFrameworkCore; -namespace IdentityShroud.Core.Contracts; +namespace IdentityShroud.Core.Security; [Owned] -public record EncryptedValue(string KeyId, byte[] Value); \ No newline at end of file +public record EncryptedValue(DekId DekId, byte[] Value); + + diff --git a/IdentityShroud.Core/Services/EncryptionService.cs b/IdentityShroud.Core/Security/Encryption.cs similarity index 55% rename from IdentityShroud.Core/Services/EncryptionService.cs rename to IdentityShroud.Core/Security/Encryption.cs index a6b39c0..a80a273 100644 --- a/IdentityShroud.Core/Services/EncryptionService.cs +++ b/IdentityShroud.Core/Security/Encryption.cs @@ -1,36 +1,18 @@ using System.Security.Cryptography; -using IdentityShroud.Core.Contracts; -namespace IdentityShroud.Core.Services; +namespace IdentityShroud.Core.Security; -/// -/// -/// -public class EncryptionService : IEncryptionService +public static class Encryption { - private record struct AlgVersion(int NonceSize, int TagSize); + private record struct AlgVersion(int Version, int NonceSize, int TagSize); - private AlgVersion[] _versions = + private static AlgVersion[] _versions = [ - new(0, 0), // version 0 does not realy exist - new (12, 16), // version 1 + new(0, 0, 0), // version 0 does not realy exist + new(1, 12, 16), // version 1 ]; - // Note this array is expected to have one item in it most of the during key rotation it will have two - // until it is ensured the old key can safely be removed. More then two will work but is not really expected. - private readonly EncryptionKey[] _encryptionKeys; - - private EncryptionKey ActiveKey => _encryptionKeys.Single(k => k.Active); - private EncryptionKey GetKey(string keyId) => _encryptionKeys.Single(k => k.Id == keyId); - - public EncryptionService(ISecretProvider secretProvider) - { - _encryptionKeys = secretProvider.GetKeys("master"); - // if (_encryptionKey.Length != 32) // 256‑bit key - // throw new Exception("Key must be 256 bits (32 bytes) for AES‑256‑GCM."); - } - - public EncryptedValue Encrypt(ReadOnlyMemory plaintext) + public static byte[] Encrypt(ReadOnlyMemory plaintext, ReadOnlySpan key) { const int versionNumber = 1; AlgVersion versionParams = _versions[versionNumber]; @@ -39,7 +21,7 @@ public class EncryptionService : IEncryptionService // allocate buffer for complete response var result = new byte[resultSize]; - result[0] = (byte)versionNumber; + result[0] = (byte)versionParams.Version; // make the spans that point to the parts of the result where their data is located var nonce = result.AsSpan(1, versionParams.NonceSize); @@ -48,18 +30,14 @@ public class EncryptionService : IEncryptionService // use the spans to place the data directly in its place RandomNumberGenerator.Fill(nonce); - var encryptionKey = ActiveKey; - using var aes = new AesGcm(encryptionKey.Key, versionParams.TagSize); + using var aes = new AesGcm(key, versionParams.TagSize); aes.Encrypt(nonce, plaintext.Span, cipher, tag); - - return new (encryptionKey.Id, result); + return result; } - - public byte[] Decrypt(EncryptedValue input) + + public static byte[] Decrypt(ReadOnlyMemory input, ReadOnlySpan key) { - var encryptionKey = GetKey(input.KeyId); - - var payload = input.Value.AsSpan(); + var payload = input.Span; int versionNumber = (int)payload[0]; if (versionNumber != 1) throw new ArgumentException("Invalid payload"); @@ -76,7 +54,7 @@ public class EncryptionService : IEncryptionService byte[] plaintext = new byte[cipher.Length]; - using var aes = new AesGcm(encryptionKey.Key, versionParams.TagSize); + using var aes = new AesGcm(key, versionParams.TagSize); try { aes.Decrypt(nonce, cipher, tag, plaintext); diff --git a/IdentityShroud.Core/Security/EncryptionKey.cs b/IdentityShroud.Core/Security/EncryptionKey.cs deleted file mode 100644 index 2e857a1..0000000 --- a/IdentityShroud.Core/Security/EncryptionKey.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace IdentityShroud.Core.Contracts; - -// Contains an encryption key and associated relevant data -public record EncryptionKey(string Id, bool Active, string Algorithm, byte[] Key); \ No newline at end of file diff --git a/IdentityShroud.Core/Security/KekId.cs b/IdentityShroud.Core/Security/KekId.cs new file mode 100644 index 0000000..c794078 --- /dev/null +++ b/IdentityShroud.Core/Security/KekId.cs @@ -0,0 +1,41 @@ +using System.ComponentModel; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace IdentityShroud.Core.Security; + +[JsonConverter(typeof(KekIdJsonConverter))] +[TypeConverter(typeof(KekIdTypeConverter))] +public readonly record struct KekId +{ + public Guid Id { get; } + + public KekId(Guid id) + { + Id = id; + } + + public static KekId NewId() + { + return new KekId(Guid.NewGuid()); + } +} + +public class KekIdJsonConverter : JsonConverter +{ + public override KekId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => new KekId(reader.GetGuid()); + + public override void Write(Utf8JsonWriter writer, KekId value, JsonSerializerOptions options) + => writer.WriteStringValue(value.Id); +} + +public class KekIdTypeConverter : TypeConverter +{ + public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) + => sourceType == typeof(string) || base.CanConvertFrom(context, sourceType); + + public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) + => value is string s ? new KekId(Guid.Parse(s)) : base.ConvertFrom(context, culture, value); +} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/KeyEncryptionKey.cs b/IdentityShroud.Core/Security/KeyEncryptionKey.cs new file mode 100644 index 0000000..35f7917 --- /dev/null +++ b/IdentityShroud.Core/Security/KeyEncryptionKey.cs @@ -0,0 +1,10 @@ +namespace IdentityShroud.Core.Security; + +/// +/// Contains a KEK and associated relevant data. This structure +/// +/// +/// +/// +/// +public record KeyEncryptionKey(KekId Id, bool Active, string Algorithm, byte[] Key); diff --git a/IdentityShroud.Core/Services/ClientService.cs b/IdentityShroud.Core/Services/ClientService.cs index e6b5c32..0887ccd 100644 --- a/IdentityShroud.Core/Services/ClientService.cs +++ b/IdentityShroud.Core/Services/ClientService.cs @@ -7,7 +7,7 @@ namespace IdentityShroud.Core.Services; public class ClientService( Db db, - IEncryptionService cryptor, + IDataEncryptionService cryptor, IClock clock) : IClientService { public async Task> Create(Guid realmId, ClientCreateRequest request, CancellationToken ct = default) @@ -52,12 +52,13 @@ public class ClientService( private ClientSecret CreateSecret() { - byte[] secret = RandomNumberGenerator.GetBytes(24); + Span secret = stackalloc byte[24]; + RandomNumberGenerator.Fill(secret); return new ClientSecret() { CreatedAt = clock.UtcNow(), - Secret = cryptor.Encrypt(secret), + Secret = cryptor.Encrypt(secret.ToArray()), }; } diff --git a/IdentityShroud.Core/Services/DataEncryptionService.cs b/IdentityShroud.Core/Services/DataEncryptionService.cs new file mode 100644 index 0000000..603f833 --- /dev/null +++ b/IdentityShroud.Core/Services/DataEncryptionService.cs @@ -0,0 +1,41 @@ +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Model; +using IdentityShroud.Core.Security; + +namespace IdentityShroud.Core.Services; + +public class DataEncryptionService( + IRealmContext realmContext, + IDekEncryptionService dekCryptor) : IDataEncryptionService +{ + + // Note this array is expected to have one item in it most of the during key rotation it will have two + // until it is ensured the old key can safely be removed. More then two will work but is not really expected. + private IList? _deks = null; + + private IList GetDeks() + { + if (_deks is null) + _deks = realmContext.GetDeks().Result; + + return _deks; + } + + private RealmDek GetActiveDek() => GetDeks().Single(d => d.Active); + private RealmDek GetKey(DekId id) => GetDeks().Single(d => d.Id == id); + + public byte[] Decrypt(EncryptedValue input) + { + var dek = GetKey(input.DekId); + var key = dekCryptor.Decrypt(dek.KeyData); + return Encryption.Decrypt(input.Value, key); + } + + public EncryptedValue Encrypt(ReadOnlyMemory plain) + { + var dek = GetActiveDek(); + var key = dekCryptor.Decrypt(dek.KeyData); + byte[] cipher = Encryption.Encrypt(plain, key); + return new (dek.Id, cipher); + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/DekEncryptionService.cs b/IdentityShroud.Core/Services/DekEncryptionService.cs new file mode 100644 index 0000000..c147662 --- /dev/null +++ b/IdentityShroud.Core/Services/DekEncryptionService.cs @@ -0,0 +1,38 @@ +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Security; + +namespace IdentityShroud.Core.Services; + +/// +/// +/// +public class DekEncryptionService : IDekEncryptionService +{ + // Note this array is expected to have one item in it most of the during key rotation it will have two + // until it is ensured the old key can safely be removed. More then two will work but is not really expected. + private readonly KeyEncryptionKey[] _encryptionKeys; + + private KeyEncryptionKey ActiveKey => _encryptionKeys.Single(k => k.Active); + private KeyEncryptionKey GetKey(KekId keyId) => _encryptionKeys.Single(k => k.Id == keyId); + + public DekEncryptionService(ISecretProvider secretProvider) + { + _encryptionKeys = secretProvider.GetKeys("master"); + // if (_encryptionKey.Length != 32) // 256‑bit key + // throw new Exception("Key must be 256 bits (32 bytes) for AES‑256‑GCM."); + } + + public EncryptedDek Encrypt(ReadOnlyMemory plaintext) + { + var encryptionKey = ActiveKey; + byte[] cipher = Encryption.Encrypt(plaintext, encryptionKey.Key); + return new (encryptionKey.Id, cipher); + } + + public byte[] Decrypt(EncryptedDek input) + { + var encryptionKey = GetKey(input.KekId); + + return Encryption.Decrypt(input.Value, encryptionKey.Key); + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/KeyService.cs b/IdentityShroud.Core/Services/KeyService.cs index 16af5a4..a2ce9dc 100644 --- a/IdentityShroud.Core/Services/KeyService.cs +++ b/IdentityShroud.Core/Services/KeyService.cs @@ -6,7 +6,7 @@ using IdentityShroud.Core.Security.Keys; namespace IdentityShroud.Core.Services; public class KeyService( - IEncryptionService cryptor, + IDekEncryptionService cryptor, IKeyProviderFactory keyProviderFactory, IClock clock) : IKeyService { diff --git a/IdentityShroud.Core/Services/RealmContext.cs b/IdentityShroud.Core/Services/RealmContext.cs new file mode 100644 index 0000000..7daa399 --- /dev/null +++ b/IdentityShroud.Core/Services/RealmContext.cs @@ -0,0 +1,26 @@ +using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Model; +using Microsoft.AspNetCore.Http; + +namespace IdentityShroud.Core.Services; + +public class RealmContext( + IHttpContextAccessor accessor, + IRealmService realmService) : IRealmContext +{ + public Realm GetRealm() + { + return (Realm)accessor.HttpContext.Items["RealmEntity"]; + } + + public async Task> GetDeks(CancellationToken ct = default) + { + Realm realm = GetRealm(); + if (realm.Deks.Count == 0) + { + await realmService.LoadDeks(realm); + } + + return realm.Deks; + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/RealmService.cs b/IdentityShroud.Core/Services/RealmService.cs index f8e7185..949c9fe 100644 --- a/IdentityShroud.Core/Services/RealmService.cs +++ b/IdentityShroud.Core/Services/RealmService.cs @@ -58,6 +58,12 @@ public class RealmService( .Query() .Where(k => k.RevokedAt == null) .LoadAsync(); - + } + + public async Task LoadDeks(Realm realm) + { + await db.Entry(realm).Collection(r => r.Deks) + .Query() + .LoadAsync(); } } \ No newline at end of file diff --git a/IdentityShroud.TestUtils/Substitutes/EncryptionServiceSubstitute.cs b/IdentityShroud.TestUtils/Substitutes/EncryptionServiceSubstitute.cs index 36045ae..009629e 100644 --- a/IdentityShroud.TestUtils/Substitutes/EncryptionServiceSubstitute.cs +++ b/IdentityShroud.TestUtils/Substitutes/EncryptionServiceSubstitute.cs @@ -1,18 +1,21 @@ using IdentityShroud.Core.Contracts; +using IdentityShroud.Core.Security; namespace IdentityShroud.TestUtils.Substitutes; public static class EncryptionServiceSubstitute { - public static IEncryptionService CreatePassthrough() + public static KekId KeyId { get; } = KekId.NewId(); + + public static IDekEncryptionService CreatePassthrough() { - var encryptionService = Substitute.For(); + var encryptionService = Substitute.For(); encryptionService .Encrypt(Arg.Any>()) - .Returns(x => new EncryptedValue("kid", x.ArgAt>(0).ToArray())); + .Returns(x => new EncryptedDek(KeyId, x.ArgAt>(0).ToArray())); encryptionService - .Decrypt(Arg.Any()) - .Returns(x => x.ArgAt(0).Value); + .Decrypt(Arg.Any()) + .Returns(x => x.ArgAt(0).Value); return encryptionService; } } \ No newline at end of file diff --git a/IdentityShroud.sln.DotSettings.user b/IdentityShroud.sln.DotSettings.user index 795f362..88c8f46 100644 --- a/IdentityShroud.sln.DotSettings.user +++ b/IdentityShroud.sln.DotSettings.user @@ -2,8 +2,10 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded @@ -20,10 +22,11 @@ ForceIncluded ForceIncluded ForceIncluded + /home/eelke/.cache/JetBrains/Rider2025.3/resharper-host/temp/Rider/vAny/CoverageData/_IdentityShroud.-1277985570/Snapshot/snapshot.utdcvr /home/eelke/.dotnet/dotnet /home/eelke/.dotnet/sdk/10.0.102/MSBuild.dll - <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> </SessionState> @@ -37,4 +40,9 @@ + + + + + \ No newline at end of file