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
11 changed files with 110 additions and 131 deletions
Showing only changes of commit 4201d0240d - Show all commits

View file

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

View file

@ -72,8 +72,8 @@ public class JwtSignatureGeneratorTests
var rsa = RSA.Create(); var rsa = RSA.Create();
var parameters = new RSAParameters var parameters = new RSAParameters
{ {
Modulus = WebEncoders.Base64UrlDecode(jwk.Modulus), Modulus = WebEncoders.Base64UrlDecode(jwk.Modulus!),
Exponent = WebEncoders.Base64UrlDecode(jwk.Exponent) Exponent = WebEncoders.Base64UrlDecode(jwk.Exponent!)
}; };
rsa.ImportParameters(parameters); rsa.ImportParameters(parameters);

View file

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

View file

@ -1,3 +1,4 @@
using System.Buffers.Text;
using System.Security.Cryptography; using System.Security.Cryptography;
using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Services; using IdentityShroud.Core.Services;
@ -9,18 +10,43 @@ public class EncryptionServiceTests
[Fact] [Fact]
public void RoundtripWorks() public void RoundtripWorks()
{ {
// setup // Note this code will tend to only test the latest verion.
string key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
var secretProvider = Substitute.For<ISecretProvider>();
secretProvider.GetSecret("Master").Returns(key);
EncryptionService sut = new(secretProvider); // setup
byte[] input = RandomNumberGenerator.GetBytes(16); var secretProvider = Substitute.For<ISecretProvider>();
secretProvider.GetSecret("Master").Returns("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw=");
ReadOnlySpan<byte> input = "Hello, World!"u8;
// act // act
var cipher = sut.Encrypt(input); EncryptionService sut = new(secretProvider);
var result = sut.Decrypt(cipher); byte[] cipher = sut.Encrypt(input.ToArray());
byte[] result = sut.Decrypt(cipher);
// verify
Assert.Equal(input, result); 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<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
];
var secretProvider = Substitute.For<ISecretProvider>();
secretProvider.GetSecret("Master").Returns("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw=");
// act
EncryptionService sut = new(secretProvider);
byte[] result = sut.Decrypt(cipher.ToArray());
// verify
Assert.Equal("Hello, World!"u8, result);
}
} }

View file

@ -66,9 +66,9 @@ public static class JwtReader
return new JsonWebToken() return new JsonWebToken()
{ {
Header = JsonSerializer.Deserialize<JsonWebTokenHeader>( Header = JsonSerializer.Deserialize<JsonWebTokenHeader>(
Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(jwt, 0, firstDot))), Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(jwt, 0, firstDot)))!,
Payload = JsonSerializer.Deserialize<JsonWebTokenPayload>( Payload = JsonSerializer.Deserialize<JsonWebTokenPayload>(
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)) Signature = WebEncoders.Base64UrlDecode(jwt, secondDot + 1, jwt.Length - (secondDot + 1))
}; };
} }

View file

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

View file

@ -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<byte> nonce = stackalloc byte[AesGcm.NonceByteSizes.MaxSize];
RandomNumberGenerator.Fill(nonce);
Span<byte> ciphertext = stackalloc byte[plaintext.Length];
Span<byte> 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 32byte (256bit) secret key (same key used for encryption)
// • payload byte[] containing nonce‖ciphertext‖tag
// • returns the original plaintext bytes
// --------------------------------------------------------------------
public static byte[] DecryptAesGcm(ReadOnlyMemory<byte> payload, byte[] key)
{
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));
// ----------------------------------------------------------------
// 1⃣ Extract the three components.
// ----------------------------------------------------------------
// 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
if (payload.Length < nonceSize + tagSize)
throw new ArgumentException("Payload is too short to contain nonce, ciphertext, and tag.", nameof(payload));
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];
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;
}
}

View file

@ -1,16 +0,0 @@
using System.Security.Cryptography;
namespace IdentityShroud.Core.Security;
public static class RsaHelper
{
/// <summary>
/// Load RSA private key from PKCS#8 format
/// </summary>
public static RSA LoadFromPkcs8(byte[] pkcs8Key)
{
var rsa = RSA.Create();
rsa.ImportPkcs8PrivateKey(pkcs8Key, out _);
return rsa;
}
}

View file

@ -1,3 +1,4 @@
using System.Security.Cryptography;
using IdentityShroud.Core.Contracts; using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Security; using IdentityShroud.Core.Security;
@ -8,20 +9,85 @@ namespace IdentityShroud.Core.Services;
/// </summary> /// </summary>
public class EncryptionService : IEncryptionService 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) public EncryptionService(ISecretProvider secretProvider)
{ {
encryptionKey = Convert.FromBase64String(secretProvider.GetSecret("Master")); _encryptionKey = Convert.FromBase64String(secretProvider.GetSecret("Master"));
if (_encryptionKey.Length != 32) // 256bit key
throw new Exception("Key must be 256bits (32 bytes) for AES256GCM.");
} }
public byte[] Encrypt(byte[] plain) public byte[] Encrypt(ReadOnlyMemory<byte> 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<byte> cipher) public byte[] Decrypt(ReadOnlyMemory<byte> 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 (128bit 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<byte> nonce = payload.Slice(1, versionParams.NonceSize);
ReadOnlySpan<byte> tag = payload.Slice(1 + versionParams.NonceSize, versionParams.TagSize);
ReadOnlySpan<byte> 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;
} }
} }

View file

@ -14,6 +14,7 @@
<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_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_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_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_003AReadOnlyMemory_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fc19b2538fdfabf70658aed8979dd83e9ca11e27f5b3df68950e8ecb4d879e_003FReadOnlyMemory_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_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_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_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>

View file

@ -1,8 +1,5 @@
# IdentityShroud # 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. IdentityShroud is a .NET project for identity management and protection.
## Build and Test ## Build and Test