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

@ -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.Services;
@ -11,16 +9,22 @@ public class EncryptionServiceTests
public void RoundtripWorks()
{
// Note this code will tend to only test the latest verion.
// setup
byte[] keyValue = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw=");
var secretProvider = Substitute.For<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;
// act
EncryptionService sut = new(secretProvider);
byte[] cipher = sut.Encrypt(input.ToArray());
EncryptedValue cipher = sut.Encrypt(input.ToArray());
byte[] result = sut.Decrypt(cipher);
// verify
@ -34,19 +38,109 @@ public class EncryptionServiceTests
// make sure decoding of legacy data still works.
// setup
Span<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,
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>();
secretProvider.GetSecret("Master").Returns("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw=");
EncryptionKey[] keys =
[
new EncryptionKey("kid", true, "AES", keyValue)
];
secretProvider.GetKeys("master").Returns(keys);
// act
EncryptionService sut = new(secretProvider);
byte[] result = sut.Decrypt(cipher.ToArray());
byte[] result = sut.Decrypt(secret);
// verify
Assert.Equal("Hello, World!"u8, result);
}
[Fact]
public void DetectsCorruptInput()
{
// When introducing a new version we need version specific tests to
// make sure decoding of legacy data still works.
// setup
byte[] cipher = // NOTE INCORRECT CIPHER DO NOT USE IN OTHER TESTS
[
1, 198, 55, 58, 56, 110, 238, 59, 158, 214, 85, 241, 26, 44, 140, 229, 128, 111, 167, 154, 160, 177, 152,
193, 75, 4, 235, 82, 207, 87, 32, 10, 239, 4, 246, 25, 21, 249, 25, 59, 160, 101
];
EncryptedValue secret = new("kid", cipher);
byte[] keyValue = Convert.FromBase64String("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw=");
var secretProvider = Substitute.For<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())
{
_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
RealmService sut = new(db, _keyService);
var response = await sut.Create(