5-improve-encrypted-storage #6
11 changed files with 110 additions and 131 deletions
|
|
@ -30,8 +30,4 @@
|
|||
<ProjectReference Include="..\IdentityShroud.TestUtils\IdentityShroud.TestUtils.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Model\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
// setup
|
||||
string key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||
var secretProvider = Substitute.For<ISecretProvider>();
|
||||
secretProvider.GetSecret("Master").Returns(key);
|
||||
// Note this code will tend to only test the latest verion.
|
||||
|
||||
EncryptionService sut = new(secretProvider);
|
||||
byte[] input = RandomNumberGenerator.GetBytes(16);
|
||||
// setup
|
||||
var secretProvider = Substitute.For<ISecretProvider>();
|
||||
secretProvider.GetSecret("Master").Returns("IGd9yUMusjNW0ezv8ink3QWlAHKFH45d21LyrbJTokw=");
|
||||
|
||||
ReadOnlySpan<byte> 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<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);
|
||||
}
|
||||
}
|
||||
|
|
@ -66,9 +66,9 @@ public static class JwtReader
|
|||
return new JsonWebToken()
|
||||
{
|
||||
Header = JsonSerializer.Deserialize<JsonWebTokenHeader>(
|
||||
Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(jwt, 0, firstDot))),
|
||||
Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(jwt, 0, firstDot)))!,
|
||||
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))
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@ namespace IdentityShroud.Core.Contracts;
|
|||
|
||||
public interface IEncryptionService
|
||||
{
|
||||
byte[] Encrypt(byte[] plain);
|
||||
byte[] Encrypt(ReadOnlyMemory<byte> plain);
|
||||
byte[] Decrypt(ReadOnlyMemory<byte> cipher);
|
||||
}
|
||||
|
|
@ -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 – 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<byte> 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<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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Security.Cryptography;
|
||||
using IdentityShroud.Core.Contracts;
|
||||
using IdentityShroud.Core.Security;
|
||||
|
||||
|
|
@ -8,20 +9,85 @@ namespace IdentityShroud.Core.Services;
|
|||
/// </summary>
|
||||
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<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 (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<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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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_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_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_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>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
# IdentityShroud
|
||||
|
||||

|
||||

|
||||
|
||||
IdentityShroud is a .NET project for identity management and protection.
|
||||
|
||||
## Build and Test
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue