Skip to content

Commit 502a56f

Browse files
committed
added salting to the hashes.
made marking of entities queryable explicit and forced.
1 parent e740661 commit 502a56f

File tree

10 files changed

+84
-18
lines changed

10 files changed

+84
-18
lines changed

README.md

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ When you need to store sensitive data in your database, you may want to encrypt
1515
encrypt data, it becomes impossible to query it by EF-core, which is not really convenient if you want to encrypt, for example, email addresses, or SSNs
1616
AND then filter entities by them.
1717

18-
This library has support for hashing the sensitive data and storing their (sha256) hashes in a shadow property alongside the encrypted data.
18+
This library has support for hashing the salted sensitive data and storing their (Hmac Sha256) hashes in a shadow property alongside the encrypted data.
1919
This allows you to query for the encrypted properties without decrypting them first. using `QueryableExt.WherePdEquals`
2020

2121
# Disclaimer
@@ -83,6 +83,11 @@ builder.Services.AddDataProtectionServices()
8383
.PersistKeysToFileSystem(keyDirectory);
8484
```
8585

86+
> [!WARNING]
87+
> If you want to query for encrypted properties, along with marking your properties as queryable, you **MUST** set
88+
> `EFCORE_DATA_PROTECTION__HASHING_SALT` in the environment. I could suggest you setting it to a random guid, or a very long string.
89+
> This was implemented to prevent rainbow attacks on sensitive data.
90+
8691
> [!TIP]
8792
> See the [Microsoft documentation](https://docs.microsoft.com/en-us/aspnet/core/security/data-protection/configuration/overview) for more
8893
> information on **how to configure the data protection** services, and how to store your encryption keys securely.
@@ -110,17 +115,21 @@ Using the EncryptedAttribute:
110115
```csharp
111116
class User
112117
{
113-
[Encrypt(IsQueryable = true, IsUnique = true)]
118+
[Encrypt(isQueryable: true, isUnique: true)]
114119
public string SocialSecurityNumber { get; set; }
115120

116-
[Encrypt(IsQueryable = false, IsUnique = false)]
121+
[Encrypt(isQueryable: false, isUnique: false)]
117122
public byte[] IdPicture { get; set; }
118123
}
119124
```
120125

121126
> [!TIP]
122-
> By default `IsQueryable` and `IsUnique` are set to `true`, you can omit them if you want to use the default values.
123-
> If you have a property that is marked as `IsQueryable = true` and you want to query it, you **MUST** call `AddDataProtectionInterceptors` in your `DbContext` configuration.
127+
> `isQueryable` marks a property as queryable (this will generate a shadow hash) <br/>
128+
> `isUnique` marks a property as unique, and adds a unique index on the property. An index is added by default, even if the property is not marked as unique. However, that default index is not Unique.
129+
130+
> [!WARNING]
131+
> If you have a property that is marked as queryable, you **MUST** call `AddDataProtectionInterceptors` in your `DbContext` configuration.
132+
> And you **MUST** set `EFCORE_DATA_PROTECTION__HASHING_SALT` in the environment. Otherwise, an exception will be thrown while trying to save the entity.
124133
125134
Using the FluentApi (in your `DbContext.OnModelCreating` method):
126135
```csharp
@@ -148,7 +157,7 @@ var foo = await DbContext.Users
148157
```
149158

150159
> [!WARNING]
151-
> The `QueryableExt.WherePdEquals` method is only available for properties that are marked as Queryable using the `[Encrypt(IsQueryable = true)]` attribute or the
160+
> The `QueryableExt.WherePdEquals` method is only available for properties that are marked as Queryable using the `[Encrypt(isQueryable: true)]` attribute or the
152161
> `IsEncryptedQueryable()` method.
153162
154163
> [!CAUTION]
@@ -157,7 +166,7 @@ var foo = await DbContext.Users
157166
158167
> [!NOTE]
159168
> The `WherePdEquals` extension method generates an expression like this one under the hood:<br/>
160-
> `Where(e => EF.Property<string>(e, $"{propertyName}ShadowHash") == value.Sha256Hash())`
169+
> `Where(e => EF.Property<string>(e, $"{propertyName}ShadowHash") == value.HmacSha256Hash())`
161170
162171
### Profit!
163172

src/Klean.EntityFrameworkCore.DataProtection/EncryptAttribute.cs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,25 @@ public sealed class EncryptAttribute : Attribute
1515
/// Gets a boolean value indicating if this property can be queried from the database.
1616
/// Because of how data protection in this library is implemented, if you want your protected property to be queryable, you must ensure that the property is a string or byte[].
1717
/// </summary>
18-
public bool IsQueryable { get; init; } = true;
18+
internal bool IsQueryable { get; init; }
1919

2020
/// <summary>
2121
/// Gets a boolean value indicating if this property should have a unique index.
2222
/// </summary>
23-
public bool IsUnique { get; init; } = true;
23+
internal bool IsUnique { get; init; }
24+
25+
/// <summary>
26+
/// Marks a property as encrypted.
27+
/// Optionally choose if you want your property to be queryable or not.
28+
/// Optionally choose if you want your properties to have a Unique Index or not.
29+
/// </summary>
30+
/// <remarks>
31+
/// Because of how data protection in this library is implemented, if you want your protected property to be queryable, you must ensure that the property is a string or byte[].
32+
/// Please note, you must set `EFCORE_DATA_PROTECTION__HASHING_SALT` in the environment to be able to query for data.
33+
/// </remarks>
34+
public EncryptAttribute(bool isQueryable, bool isUnique)
35+
{
36+
IsQueryable = isQueryable;
37+
IsUnique = isUnique;
38+
}
2439
}

src/Klean.EntityFrameworkCore.DataProtection/Extensions/QueryableExt.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ public static IQueryable<T> WherePdEquals<T>(this IQueryable<T> query, string pr
6666
parameter,
6767
Expression.Constant(shadowPropertyName));
6868

69-
var comp = Expression.Equal(property, Expression.Constant(value.Sha256Hash()));
69+
var comp = Expression.Equal(property, Expression.Constant(value.HmacSha256Hash()));
7070
var lambda = Expression.Lambda<Func<T, bool>>(comp, parameter);
7171

7272
return query.Where(lambda);

src/Klean.EntityFrameworkCore.DataProtection/Extensions/StringExt.cs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,17 @@ namespace EntityFrameworkCore.DataProtection.Extensions;
88
/// </summary>
99
internal static class StringExt
1010
{
11-
internal static string Sha256Hash(this string value)
11+
internal const string EnvKey = "EFCORE_DATA_PROTECTION__HASHING_SALT";
12+
13+
internal static string HmacSha256Hash(this string value)
1214
{
13-
var bytes = Encoding.UTF8.GetBytes(value);
14-
var hash = SHA256.HashData(bytes);
15+
var salt =
16+
Environment.GetEnvironmentVariable(EnvKey)
17+
?? throw new InvalidOperationException($"{EnvKey} is not present in the environment, please set it to a strong value and keep it safe, otherwise querying will not work.");
18+
19+
var saltBytes = Encoding.UTF8.GetBytes(salt);
20+
var payloadBytes = Encoding.UTF8.GetBytes(value);
21+
var hash = HMACSHA256.HashData(saltBytes, payloadBytes);
1522
var hexDigest = Convert.ToHexString(hash);
1623
return hexDigest.ToLower();
1724
}

src/Klean.EntityFrameworkCore.DataProtection/Interceptors/ShadowHashSynchronizingSaveChangesInterceptor.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ from prop in entityType.GetProperties()
6262
var shadowProperty = entityType.FindProperty(shadowPropertyName);
6363

6464
if (!string.IsNullOrWhiteSpace(originalValue) && shadowProperty is not null)
65-
entry.Property(shadowPropertyName).CurrentValue = originalValue.Sha256Hash();
65+
entry.Property(shadowPropertyName).CurrentValue = originalValue.HmacSha256Hash();
6666
}
6767
}
6868
}

src/Klean.EntityFrameworkCore.DataProtection/Klean.EntityFrameworkCore.DataProtection.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
4-
<Version>1.1.0</Version>
4+
<Version>1.2.0</Version>
55
<TargetFrameworks>net6.0;net7.0;net8.0;net9.0</TargetFrameworks>
66
<ImplicitUsings>enable</ImplicitUsings>
77
<Nullable>enable</Nullable>

test/Klean.EntityFrameworkCore.DataProtection.Test/Data/User.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ internal sealed class User
99

1010
public required string Name { get; init; }
1111

12-
[Encrypt]
12+
[Encrypt(true, true)]
1313
public required string SocialSecurityNumber { get; init; }
1414

1515
/// <summary>
@@ -19,7 +19,7 @@ internal sealed class User
1919

2020
public required AddressData Address { get; init; }
2121

22-
[Encrypt(IsQueryable = false, IsUnique = false)]
22+
[Encrypt(false, false)]
2323
public required byte[] IdPicture { get; init; }
2424

2525
public static User CreateRandom()

test/Klean.EntityFrameworkCore.DataProtection.Test/DbContext.Test.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ namespace Klean.EntityFrameworkCore.DataProtection.Test;
77

88
internal sealed class DbContextTests
99
{
10+
[OneTimeSetUp]
11+
public void InitializeEnv()
12+
{
13+
var value = Environment.GetEnvironmentVariable(StringExt.EnvKey);
14+
Environment.SetEnvironmentVariable(StringExt.EnvKey, value ?? Guid.NewGuid().ToString());
15+
}
16+
1017
[Test]
1118
public void Test_CreationWorks()
1219
{

test/Klean.EntityFrameworkCore.DataProtection.Test/ExceptionHandling.Test.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
6666

6767
class BadEntity
6868
{
69-
[Encrypt]
69+
[Encrypt(true, true)]
7070
public Guid Id { get; set; }
7171
}
7272
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using EntityFrameworkCore.DataProtection.Extensions;
2+
using FluentAssertions;
3+
4+
namespace Klean.EntityFrameworkCore.DataProtection.Test;
5+
6+
internal sealed class StringExtTests
7+
{
8+
[OneTimeSetUp]
9+
public void InitializeEnv()
10+
{
11+
var value = Environment.GetEnvironmentVariable(StringExt.EnvKey);
12+
Environment.SetEnvironmentVariable(StringExt.EnvKey, value ?? Guid.NewGuid().ToString());
13+
}
14+
15+
[Test]
16+
public void Test_HmacSha256_Is_Deterministic()
17+
{
18+
var payload = "hello world";
19+
20+
var hash = payload.HmacSha256Hash();
21+
Console.WriteLine(hash);
22+
23+
var hash2 = payload.HmacSha256Hash();
24+
Console.WriteLine(hash2);
25+
26+
hash.Should().BeEquivalentTo(hash2);
27+
}
28+
}

0 commit comments

Comments
 (0)