From 4201d0240d85bbd2a85e1f338c9e069a3c04e80a Mon Sep 17 00:00:00 2001 From: eelke Date: Sun, 22 Feb 2026 19:11:17 +0100 Subject: [PATCH] Improve the binary storage format of encrypted secrets. Move the related code from AesGcmHelper into the EncryptionService. --- .../IdentityShroud.Core.Tests.csproj | 4 - .../JwtSignatureGeneratorTests.cs | 4 +- .../Security/AesGcmHelperTests.cs | 21 ----- .../Services/EncryptionServiceTests.cs | 38 +++++++-- IdentityShroud.Core.Tests/UnitTest1.cs | 4 +- .../Contracts/IEncryptionService.cs | 2 +- IdentityShroud.Core/Security/AesGcmHelper.cs | 70 ----------------- IdentityShroud.Core/Security/RsaHelper.cs | 16 ---- .../Services/EncryptionService.cs | 78 +++++++++++++++++-- IdentityShroud.sln.DotSettings.user | 1 + README.md | 3 - 11 files changed, 110 insertions(+), 131 deletions(-) delete mode 100644 IdentityShroud.Core.Tests/Security/AesGcmHelperTests.cs delete mode 100644 IdentityShroud.Core/Security/AesGcmHelper.cs delete mode 100644 IdentityShroud.Core/Security/RsaHelper.cs diff --git a/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj b/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj index 40c87d5..8af08c1 100644 --- a/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj +++ b/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj @@ -30,8 +30,4 @@ - - - - \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/JwtSignatureGeneratorTests.cs b/IdentityShroud.Core.Tests/JwtSignatureGeneratorTests.cs index 0fb0a42..bf4d0a6 100644 --- a/IdentityShroud.Core.Tests/JwtSignatureGeneratorTests.cs +++ b/IdentityShroud.Core.Tests/JwtSignatureGeneratorTests.cs @@ -72,8 +72,8 @@ public class JwtSignatureGeneratorTests var rsa = RSA.Create(); var parameters = new RSAParameters { - Modulus = WebEncoders.Base64UrlDecode(jwk.Modulus), - Exponent = WebEncoders.Base64UrlDecode(jwk.Exponent) + Modulus = WebEncoders.Base64UrlDecode(jwk.Modulus!), + Exponent = WebEncoders.Base64UrlDecode(jwk.Exponent!) }; rsa.ImportParameters(parameters); diff --git a/IdentityShroud.Core.Tests/Security/AesGcmHelperTests.cs b/IdentityShroud.Core.Tests/Security/AesGcmHelperTests.cs deleted file mode 100644 index 6392676..0000000 --- a/IdentityShroud.Core.Tests/Security/AesGcmHelperTests.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Security.Cryptography; -using System.Text; -using IdentityShroud.Core.Security; - -namespace IdentityShroud.Core.Tests.Security; - -public class AesGcmHelperTests -{ - [Fact] - public void EncryptDecryptCycleWorks() - { - string input = "Hello, world!"; - - var encryptionKey = RandomNumberGenerator.GetBytes(32); - - var cypher = AesGcmHelper.EncryptAesGcm(Encoding.UTF8.GetBytes(input), encryptionKey); - var output = AesGcmHelper.DecryptAesGcm(cypher, encryptionKey); - - Assert.Equal(input, Encoding.UTF8.GetString(output)); - } -} \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/Services/EncryptionServiceTests.cs b/IdentityShroud.Core.Tests/Services/EncryptionServiceTests.cs index b855732..68ab90d 100644 --- a/IdentityShroud.Core.Tests/Services/EncryptionServiceTests.cs +++ b/IdentityShroud.Core.Tests/Services/EncryptionServiceTests.cs @@ -1,3 +1,4 @@ +using System.Buffers.Text; using System.Security.Cryptography; using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Services; @@ -9,18 +10,43 @@ public class EncryptionServiceTests [Fact] public void RoundtripWorks() { + // Note this code will tend to only test the latest verion. + // setup - string key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); var secretProvider = Substitute.For(); - secretProvider.GetSecret("Master").Returns(key); + secretProvider.GetSecret("Master").Returns("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); - EncryptionService sut = new(secretProvider); - byte[] input = RandomNumberGenerator.GetBytes(16); + ReadOnlySpan input = "Hello, World!"u8; // act - var cipher = sut.Encrypt(input); - var result = sut.Decrypt(cipher); + EncryptionService sut = new(secretProvider); + byte[] 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 + Span 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 + ]; + var secretProvider = Substitute.For(); + secretProvider.GetSecret("Master").Returns("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw="); + + // act + EncryptionService sut = new(secretProvider); + byte[] result = sut.Decrypt(cipher.ToArray()); + + // verify + Assert.Equal("Hello, World!"u8, result); + } } \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/UnitTest1.cs b/IdentityShroud.Core.Tests/UnitTest1.cs index e2b5a05..7a12bc4 100644 --- a/IdentityShroud.Core.Tests/UnitTest1.cs +++ b/IdentityShroud.Core.Tests/UnitTest1.cs @@ -66,9 +66,9 @@ public static class JwtReader return new JsonWebToken() { Header = JsonSerializer.Deserialize( - Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(jwt, 0, firstDot))), + Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(jwt, 0, firstDot)))!, Payload = JsonSerializer.Deserialize( - Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(jwt, firstDot + 1, secondDot - (firstDot + 1)))), + Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(jwt, firstDot + 1, secondDot - (firstDot + 1))))!, Signature = WebEncoders.Base64UrlDecode(jwt, secondDot + 1, jwt.Length - (secondDot + 1)) }; } diff --git a/IdentityShroud.Core/Contracts/IEncryptionService.cs b/IdentityShroud.Core/Contracts/IEncryptionService.cs index a737732..388304b 100644 --- a/IdentityShroud.Core/Contracts/IEncryptionService.cs +++ b/IdentityShroud.Core/Contracts/IEncryptionService.cs @@ -2,6 +2,6 @@ namespace IdentityShroud.Core.Contracts; public interface IEncryptionService { - byte[] Encrypt(byte[] plain); + byte[] Encrypt(ReadOnlyMemory plain); byte[] Decrypt(ReadOnlyMemory cipher); } \ No newline at end of file diff --git a/IdentityShroud.Core/Security/AesGcmHelper.cs b/IdentityShroud.Core/Security/AesGcmHelper.cs deleted file mode 100644 index bfa5809..0000000 --- a/IdentityShroud.Core/Security/AesGcmHelper.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System.Security.Cryptography; - -namespace IdentityShroud.Core.Security; - -public static class AesGcmHelper -{ - - public static byte[] EncryptAesGcm(byte[] plaintext, byte[] key) - { - int tagSize = AesGcm.TagByteSizes.MaxSize; - using var aes = new AesGcm(key, tagSize); - - Span nonce = stackalloc byte[AesGcm.NonceByteSizes.MaxSize]; - RandomNumberGenerator.Fill(nonce); - Span ciphertext = stackalloc byte[plaintext.Length]; - Span tag = stackalloc byte[tagSize]; - - aes.Encrypt(nonce, plaintext, ciphertext, tag); - - // Return concatenated nonce|ciphertext|tag - var result = new byte[nonce.Length + ciphertext.Length + tag.Length]; - nonce.CopyTo(result.AsSpan(0, nonce.Length)); - ciphertext.CopyTo(result.AsSpan(nonce.Length, ciphertext.Length)); - tag.CopyTo(result.AsSpan(nonce.Length + ciphertext.Length, tag.Length)); - return result; - } - - // -------------------------------------------------------------------- - // DecryptAesGcm - // • key – 32‑byte (256‑bit) secret key (same key used for encryption) - // • payload – byte[] containing nonce‖ciphertext‖tag - // • returns – the original plaintext bytes - // -------------------------------------------------------------------- - public static byte[] DecryptAesGcm(ReadOnlyMemory payload, byte[] key) - { - if (key == null) throw new ArgumentNullException(nameof(key)); - if (key.Length != 32) // 256‑bit key - throw new ArgumentException("Key must be 256 bits (32 bytes) for AES‑256‑GCM.", nameof(key)); - - // ---------------------------------------------------------------- - // 1️⃣ Extract the three components. - // ---------------------------------------------------------------- - // AesGcm.NonceByteSizes.MaxSize = 12 bytes (standard GCM nonce length) - // AesGcm.TagByteSizes.MaxSize = 16 bytes (128‑bit authentication tag) - int nonceSize = AesGcm.NonceByteSizes.MaxSize; // 12 - int tagSize = AesGcm.TagByteSizes.MaxSize; // 16 - - if (payload.Length < nonceSize + tagSize) - throw new ArgumentException("Payload is too short to contain nonce, ciphertext, and tag.", nameof(payload)); - - ReadOnlySpan nonce = payload.Span[..nonceSize]; - ReadOnlySpan ciphertext = payload.Span.Slice(nonceSize, payload.Length - nonceSize - tagSize); - ReadOnlySpan tag = payload.Span.Slice(payload.Length - tagSize, tagSize); - - byte[] plaintext = new byte[ciphertext.Length]; - - using var aes = new AesGcm(key, tagSize); - try - { - aes.Decrypt(nonce, ciphertext, tag, plaintext); - } - catch (CryptographicException ex) - { - // Tag verification failed → tampering or wrong key/nonce. - throw new InvalidOperationException("Decryption failed – authentication tag mismatch.", ex); - } - - return plaintext; - } -} \ No newline at end of file diff --git a/IdentityShroud.Core/Security/RsaHelper.cs b/IdentityShroud.Core/Security/RsaHelper.cs deleted file mode 100644 index ab49ebd..0000000 --- a/IdentityShroud.Core/Security/RsaHelper.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Security.Cryptography; - -namespace IdentityShroud.Core.Security; - -public static class RsaHelper -{ - /// - /// Load RSA private key from PKCS#8 format - /// - public static RSA LoadFromPkcs8(byte[] pkcs8Key) - { - var rsa = RSA.Create(); - rsa.ImportPkcs8PrivateKey(pkcs8Key, out _); - return rsa; - } -} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/EncryptionService.cs b/IdentityShroud.Core/Services/EncryptionService.cs index a4455e0..8aa5bed 100644 --- a/IdentityShroud.Core/Services/EncryptionService.cs +++ b/IdentityShroud.Core/Services/EncryptionService.cs @@ -1,3 +1,4 @@ +using System.Security.Cryptography; using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Security; @@ -8,20 +9,85 @@ namespace IdentityShroud.Core.Services; /// public class EncryptionService : IEncryptionService { - private readonly byte[] encryptionKey; + private record struct AlgVersion(int NonceSize, int TagSize); + + private AlgVersion[] _versions = + [ + new(0, 0), // version 0 does not realy exist + new (12, 16), // version 1 + ]; + + private readonly byte[] _encryptionKey; public EncryptionService(ISecretProvider secretProvider) { - encryptionKey = Convert.FromBase64String(secretProvider.GetSecret("Master")); + _encryptionKey = Convert.FromBase64String(secretProvider.GetSecret("Master")); + if (_encryptionKey.Length != 32) // 256‑bit key + throw new Exception("Key must be 256 bits (32 bytes) for AES‑256‑GCM."); } - public byte[] Encrypt(byte[] plain) + public byte[] Encrypt(ReadOnlyMemory plaintext) { - return AesGcmHelper.EncryptAesGcm(plain, encryptionKey); + const int versionNumber = 1; + AlgVersion versionParams = _versions[versionNumber]; + + int resultSize = 1 + versionParams.NonceSize + versionParams.TagSize + plaintext.Length; + // allocate buffer for complete response + var result = new byte[resultSize]; + + result[0] = (byte)versionNumber; + + // make the spans that point to the parts of the result where their data is located + var nonce = result.AsSpan(1, versionParams.NonceSize); + var tag = result.AsSpan(1 + versionParams.NonceSize, versionParams.TagSize); + var cipher = result.AsSpan(1 + versionParams.NonceSize + versionParams.TagSize); + + // use the spans to place the data directly in its place + RandomNumberGenerator.Fill(nonce); + using var aes = new AesGcm(_encryptionKey, versionParams.TagSize); + aes.Encrypt(nonce, plaintext.Span, cipher, tag); + + return result; } - public byte[] Decrypt(ReadOnlyMemory cipher) + public byte[] Decrypt(ReadOnlyMemory input) { - return AesGcmHelper.DecryptAesGcm(cipher, encryptionKey); + + // ---------------------------------------------------------------- + // 1️⃣ Extract the three components. + // ---------------------------------------------------------------- + // AesGcm.NonceByteSizes.MaxSize = 12 bytes (standard GCM nonce length) + // AesGcm.TagByteSizes.MaxSize = 16 bytes (128‑bit authentication tag) + //int nonceSize = AesGcm.NonceByteSizes.MaxSize; // 12 + //int tagSize = AesGcm.TagByteSizes.MaxSize; // 16 + var payload = input.Span; + int versionNumber = (int)payload[0]; + if (versionNumber != 1) + throw new ArgumentException("Invalid payloag"); + + AlgVersion versionParams = _versions[versionNumber]; + + + if (payload.Length < 1 + versionParams.NonceSize + versionParams.TagSize) + throw new ArgumentException("Payload is too short to contain nonce, ciphertext, and tag.", nameof(payload)); + + ReadOnlySpan nonce = payload.Slice(1, versionParams.NonceSize); + ReadOnlySpan tag = payload.Slice(1 + versionParams.NonceSize, versionParams.TagSize); + ReadOnlySpan cipher = payload.Slice(1 + versionParams.NonceSize + versionParams.TagSize); + + byte[] plaintext = new byte[cipher.Length]; + + using var aes = new AesGcm(_encryptionKey, versionParams.TagSize); + try + { + aes.Decrypt(nonce, cipher, tag, plaintext); + } + catch (CryptographicException ex) + { + // Tag verification failed → tampering or wrong key/nonce. + throw new InvalidOperationException("Decryption failed – authentication tag mismatch.", ex); + } + + return plaintext; } } \ No newline at end of file diff --git a/IdentityShroud.sln.DotSettings.user b/IdentityShroud.sln.DotSettings.user index 01fd911..d90a7ba 100644 --- a/IdentityShroud.sln.DotSettings.user +++ b/IdentityShroud.sln.DotSettings.user @@ -14,6 +14,7 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded diff --git a/README.md b/README.md index f8839d9..fa9605a 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,5 @@ # IdentityShroud -![Build Status](https://github.com/Eelke76/IdentityShroud/actions/workflows/ci.yml/badge.svg) -![Code Coverage](https://img.shields.io/badge/Code%20Coverage-0%25-critical) - IdentityShroud is a .NET project for identity management and protection. ## Build and Test