Skip to content

Commit a85e13f

Browse files
committed
added some tests
fixed some readme issues
1 parent 175d2a5 commit a85e13f

File tree

14 files changed

+365
-67
lines changed

14 files changed

+365
-67
lines changed

README.md

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ This allows you to query the encrypted data without decrypting it first. using `
1717

1818
## Disclaimer
1919

20-
This project is maintained by [one (tenx) developer](https://github.com/ddjerqq) and is not affiliated with Microsoft.
20+
This project is maintained by [one (10x) developer](https://github.com/ddjerqq) and is not affiliated with Microsoft.
2121

2222
I made this library to solve my own problems with EFCore. I needed to store a bunch of protected personal data encrypted, among these properties were personal IDs, Emails, SocialSecurityNumbers and so on.
2323
As you know, you cannot query encrypted data with EFCore, and I wanted a simple yet boilerplate-free solution. Thus, I made this library.
@@ -79,8 +79,6 @@ public class Your(DbContextOptions<Your> options, IDataProtectionProvider dataPr
7979
`Program.cs`
8080

8181
```csharp
82-
builder.Services.AddDbContext<YourDbContext>(/* ... */);
83-
8482
var keyDirectory = new DirectoryInfo("keys");
8583
builder.Services.AddDataProtectionServices()
8684
.PersistKeysToFileSystem(keyDirectory);
@@ -90,6 +88,19 @@ builder.Services.AddDataProtectionServices()
9088
> See the [Microsoft documentation](https://docs.microsoft.com/en-us/aspnet/core/security/data-protection/configuration/overview) for more
9189
> information on **how to configure the data protection** services, and how to store your encryption keys securely.
9290
91+
### Configure the data protection options on your DbContext
92+
93+
`Program.cs`
94+
```csharp
95+
services.AddDbContext<YourDbContext>(opt => opt
96+
.AddDataProtectionInterceptors()
97+
.UseSqlite()); // or anything else
98+
```
99+
100+
> [!WARNING]
101+
> You **MUST** call `AddDataProtectionInterceptors` if you are using any encrypted properties marked as queryable in your entities.
102+
> If you are not using any queryable encrypted properties, you can skip this step.
103+
93104
### Marking your properties as encrypted
94105

95106
There are three ways you can mark your properties as encrypted:
@@ -112,8 +123,8 @@ protected override void OnModelCreating(ModelBuilder builder)
112123
{
113124
builder.Entity<User>(entity =>
114125
{
115-
entity.Property(e => e.SocialSecurityNumber).IsEncrypted(true);
116-
entity.Property(e => e.IdPicture).IsEncrypted();
126+
entity.Property(e => e.SocialSecurityNumber).IsEncrypted(isQueryable: true);
127+
entity.Property(e => e.IdPicture).IsEncrypted(isQueryable: false);
117128
});
118129
}
119130
```
@@ -124,8 +135,8 @@ class UserConfiguration : IEntityTypeConfiguration<User>
124135
{
125136
public void Configure(EntityTypeBuilder<User> builder)
126137
{
127-
builder.Property(e => e.SocialSecurityNumber).IsEncrypted(true);
128-
builder.Property(e => e.IdPicture).IsEncrypted();
138+
builder.Property(e => e.SocialSecurityNumber).IsEncrypted(isQueryable: true);
139+
builder.Property(e => e.IdPicture).IsEncrypted(isQueryable: false);
129140
}
130141
}
131142
```
@@ -143,6 +154,7 @@ var foo = await DbContext.Users
143154
> [!WARNING]
144155
> The `QueryableExt.WherePdEquals` method is only available for properties that are marked as Queryable using the `[Encrypt(isQueryable: true)]` attribute or the
145156
> `IsEncrypted(isQueryable: true)` method.
157+
> And before using `WherePdEquals` you **MUST** call `AddDataProtectionInterceptors` in your `DbContext` configuration.
146158
147159
> [!TIP]
148160
> The `WherePdEquals` extension method generates an expression like this one under the hood:<br/>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using EntityFrameworkCore.DataProtection.Interceptors;
2+
using Microsoft.EntityFrameworkCore;
3+
4+
namespace EntityFrameworkCore.DataProtection.Extensions;
5+
6+
/// <summary>
7+
/// Extension methods for <see cref="DbContextOptionsBuilder"/>.
8+
/// </summary>
9+
public static class DbContextOptionsBuilderExt
10+
{
11+
/// <summary>
12+
/// Adds the <see cref="ShadowHashSynchronizerSaveChangesInterceptor"/> to the <see cref="DbContextOptionsBuilder"/>.
13+
/// This is crucial for synchronizing the shadow property hashes with the actual properties.
14+
/// </summary>
15+
public static DbContextOptionsBuilder AddDataProtectionInterceptors(this DbContextOptionsBuilder options) =>
16+
options.AddInterceptors(ShadowHashSynchronizerSaveChangesInterceptor.Instance);
17+
}

src/EntityFrameworkCore.DataProtection/Extensions/ModelBuilderExt.cs

Lines changed: 29 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
using System.Reflection;
2-
using EntityFrameworkCore.DataProtection.ValueConverters;
1+
using EntityFrameworkCore.DataProtection.ValueConverters;
32
using Microsoft.AspNetCore.DataProtection;
43
using Microsoft.EntityFrameworkCore;
4+
using Microsoft.EntityFrameworkCore.Metadata;
55

66
namespace EntityFrameworkCore.DataProtection.Extensions;
77

@@ -48,50 +48,45 @@ public static ModelBuilder UseDataProtection(this ModelBuilder builder, IDataPro
4848
{
4949
var protector = dataProtectionProvider.CreateProtector("EntityFrameworkCore.DataProtection");
5050

51-
var properties = builder.Model
52-
.GetEntityTypes()
53-
.SelectMany(type => type.GetProperties())
54-
.Select(prop =>
55-
{
56-
var status = prop.GetEncryptionStatus();
57-
return (prop, status.SupportsEncryption, status.SupportsQuerying);
58-
})
59-
.Where(status => status.SupportsEncryption)
51+
var properties = (
52+
from prop in builder.Model.GetEntityTypes().SelectMany(type => type.GetProperties())
53+
let status = prop.GetEncryptionStatus()
54+
where status.SupportsEncryption
55+
select (prop, status.SupportsQuerying))
56+
// need to collect to a list because it is modified in AddShadowProperty
6057
.ToList();
6158

62-
foreach (var (property, _, supportsQuerying) in properties)
59+
foreach (var (property, supportsQuerying) in properties)
6360
{
64-
if (supportsQuerying)
65-
{
66-
var entityType = property.DeclaringType;
67-
var originalPropertyName = property.Name;
68-
var shadowPropertyName = $"{originalPropertyName}ShadowHash";
69-
70-
if (entityType.GetProperties().All(p => p.Name != shadowPropertyName))
71-
{
72-
var shadowProperty = entityType.AddProperty(shadowPropertyName, typeof(string));
73-
shadowProperty.IsShadowProperty();
74-
// TODO: do we need the shadow hashes to be unique indexes?
75-
shadowProperty.IsUniqueIndex();
76-
shadowProperty.IsNullable = property.IsNullable;
77-
}
78-
}
79-
8061
var propertyType = property.PropertyInfo?.PropertyType;
62+
8163
if (propertyType == typeof(string))
82-
{
8364
property.SetValueConverter(new StringDataProtectionValueConverter(protector));
84-
}
8565
else if (propertyType == typeof(byte[]))
86-
{
8766
property.SetValueConverter(new ByteArrayDataProtectionValueConverter(protector));
88-
}
8967
else
90-
{
9168
throw new InvalidOperationException("Only string and byte[] properties are supported for now. Please open an issue on https://github.com/ddjerqq/EntityFrameworkCore.DataProtection/issues to request a new feature");
92-
}
69+
70+
if (supportsQuerying)
71+
AddShadowProperty(property);
9372
}
9473

9574
return builder;
9675
}
76+
77+
private static void AddShadowProperty(IMutableProperty property)
78+
{
79+
var entityType = property.DeclaringType;
80+
var originalPropertyName = property.Name;
81+
var shadowPropertyName = $"{originalPropertyName}ShadowHash";
82+
83+
if (entityType.GetProperties().All(p => p.Name != shadowPropertyName))
84+
{
85+
var shadowProperty = entityType.AddProperty(shadowPropertyName, typeof(string));
86+
shadowProperty.IsShadowProperty();
87+
// TODO: do we need the shadow hashes to be unique indexes?
88+
shadowProperty.IsUniqueIndex();
89+
shadowProperty.IsNullable = property.IsNullable;
90+
}
91+
}
9792
}

src/EntityFrameworkCore.DataProtection/Extensions/PropertyBuilderExtensions.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ public static class PropertyBuilderExtensions
2626
/// <typeparam name="TProperty">must be either string or byte[]</typeparam>
2727
/// <exception cref="NotImplementedException">if <see cref="TProperty"/> is neither string nor byte[]</exception>
2828
/// <returns>The same builder instance so that multiple configuration calls can be chained.</returns>
29-
public static PropertyBuilder<TProperty> IsEncrypted<TProperty>(this PropertyBuilder<TProperty> builder, bool isQueryable = false)
29+
public static PropertyBuilder<TProperty> IsEncrypted<TProperty>(this PropertyBuilder<TProperty> builder, bool isQueryable)
3030
{
3131
if (typeof(TProperty) != typeof(string) && typeof(TProperty) != typeof(byte[]))
3232
throw new InvalidOperationException("Only string and byte[] properties are supported for now. Please open an issue on https://github.com/ddjerqq/EntityFrameworkCore.DataProtection/issues to request a new feature");
@@ -36,7 +36,7 @@ public static PropertyBuilder<TProperty> IsEncrypted<TProperty>(this PropertyBui
3636
return builder;
3737
}
3838

39-
internal static (bool SupportsEncryption, bool SupportsQuerying) GetEncryptionStatus(this IMutableProperty property)
39+
internal static (bool SupportsEncryption, bool SupportsQuerying) GetEncryptionStatus(this IReadOnlyProperty property)
4040
{
4141
var encryptAttribute = property.PropertyInfo?.GetCustomAttribute<EncryptAttribute>();
4242

src/EntityFrameworkCore.DataProtection/Extensions/QueryableExt.cs

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
using System.Linq.Expressions;
2-
using System.Reflection;
3-
using System.Security.Cryptography;
4-
using System.Text;
2+
using EntityFrameworkCore.DataProtection.Interceptors;
53
using Microsoft.EntityFrameworkCore;
4+
using Microsoft.EntityFrameworkCore.Infrastructure;
65

76
namespace EntityFrameworkCore.DataProtection.Extensions;
87

@@ -14,6 +13,16 @@ public static class QueryableExt
1413
/// <summary>
1514
/// Queries the entity by personal data. Please make sure your property is marked as encrypted (using <see cref="EncryptAttribute"/>) AND queryable (<see cref="EncryptAttribute.IsQueryable"/>)
1615
/// </summary>
16+
/// <remarks>
17+
/// You MUST call <see cref="DbContextOptionsBuilderExt.AddDataProtectionInterceptors"/> on the DbContext options to enable this feature.
18+
/// <code>
19+
/// services.AddDbContext&lt;YourDbContext&gt;(opt => opt
20+
/// .AddDataProtectionInterceptors()
21+
/// .UseWhateverYouHave());
22+
/// </code>
23+
/// <para></para>
24+
/// The property you are querying for MUST be marked with <see cref="EncryptAttribute"/> and <see cref="EncryptAttribute.IsQueryable"/> set to true.
25+
/// </remarks>
1726
/// <example>
1827
/// Example usage:
1928
/// <code>
@@ -57,17 +66,9 @@ public static IQueryable<T> WherePdEquals<T>(this IQueryable<T> query, string pr
5766
parameter,
5867
Expression.Constant(shadowPropertyName));
5968

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

6372
return query.Where(lambda);
6473
}
65-
66-
private static string Sha256Hash(string value)
67-
{
68-
var bytes = Encoding.UTF8.GetBytes(value);
69-
var hash = SHA256.HashData(bytes);
70-
var hexDigest = Convert.ToHexString(hash);
71-
return hexDigest.ToLower();
72-
}
7374
}

src/EntityFrameworkCore.DataProtection/Extensions/ServiceCollectionExt.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using Microsoft.AspNetCore.DataProtection;
1+
using EntityFrameworkCore.DataProtection.Interceptors;
2+
using Microsoft.AspNetCore.DataProtection;
23
using Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption;
34
using Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.ConfigurationModel;
45
using Microsoft.Extensions.DependencyInjection;
@@ -19,10 +20,13 @@ public static class ServiceCollectionExt
1920
/// You need to configure Persistence with the returned <see cref="IDataProtectionBuilder"/> instance.
2021
/// see <see href="https://learn.microsoft.com/en-us/aspnet/core/security/data-protection/configuration/overview#persistkeystofilesystem">persisting keys to file system</see>
2122
/// </remarks>
23+
/// <param name="services">The service collection</param>
2224
/// <param name="applicationName">The application name to use with data protection</param>
2325
/// <returns>An instance of <see cref="IDataProtectionBuilder"/> used to configure the data protection services</returns>
2426
public static IDataProtectionBuilder AddDataProtectionServices(this IServiceCollection services, string applicationName)
2527
{
28+
services.AddSingleton<ShadowHashSynchronizerSaveChangesInterceptor>();
29+
2630
return services.AddDataProtection()
2731
.UseCryptographicAlgorithms(
2832
new AuthenticatedEncryptorConfiguration
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
using System.Security.Cryptography;
2+
using System.Text;
3+
4+
namespace EntityFrameworkCore.DataProtection.Extensions;
5+
6+
/// <summary>
7+
/// Provides extensions for the <see cref="string"/> type.
8+
/// </summary>
9+
internal static class StringExt
10+
{
11+
internal static string Sha256Hash(this string value)
12+
{
13+
var bytes = Encoding.UTF8.GetBytes(value);
14+
var hash = SHA256.HashData(bytes);
15+
var hexDigest = Convert.ToHexString(hash);
16+
return hexDigest.ToLower();
17+
}
18+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
using EntityFrameworkCore.DataProtection.Extensions;
2+
using Microsoft.EntityFrameworkCore;
3+
using Microsoft.EntityFrameworkCore.ChangeTracking;
4+
using Microsoft.EntityFrameworkCore.Diagnostics;
5+
using Microsoft.EntityFrameworkCore.Metadata;
6+
7+
namespace EntityFrameworkCore.DataProtection.Interceptors;
8+
9+
/// <summary>
10+
/// Interceptor to synchronize shadow hash properties with their original values.
11+
/// </summary>
12+
internal sealed class ShadowHashSynchronizerSaveChangesInterceptor : SaveChangesInterceptor
13+
{
14+
/// <summary>
15+
/// The singleton instance.
16+
/// </summary>
17+
public static readonly ShadowHashSynchronizerSaveChangesInterceptor Instance = new();
18+
19+
/// <inheritdoc />
20+
public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
21+
{
22+
UpdateEntities(eventData.Context);
23+
return base.SavingChanges(eventData, result);
24+
}
25+
26+
/// <inheritdoc />
27+
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, InterceptionResult<int> result, CancellationToken ct = default)
28+
{
29+
UpdateEntities(eventData.Context);
30+
return await base.SavingChangesAsync(eventData, result, ct);
31+
}
32+
33+
private static void UpdateEntities(DbContext? context)
34+
{
35+
if (context is null)
36+
return;
37+
38+
var entries =
39+
from entry in context.ChangeTracker.Entries()
40+
where entry.State is EntityState.Added or EntityState.Modified
41+
let entity = entry.Entity
42+
let entityType = context.Model.FindEntityType(entity.GetType())
43+
where entityType is not null
44+
select (entry, entity, entityType);
45+
46+
foreach (var (entry, entity, entityType) in entries)
47+
SynchronizeShadowHash(entityType, entity, entry);
48+
}
49+
50+
private static void SynchronizeShadowHash(IEntityType entityType, object entity, EntityEntry entry)
51+
{
52+
var properties =
53+
from prop in entityType.GetProperties()
54+
let status = prop.GetEncryptionStatus()
55+
where status is { SupportsEncryption: true, SupportsQuerying: true }
56+
select prop;
57+
58+
foreach (var property in properties)
59+
{
60+
var originalValue = property.PropertyInfo?.GetValue(entity)?.ToString() ?? string.Empty;
61+
var shadowPropertyName = $"{property.Name}ShadowHash";
62+
var shadowProperty = entityType.FindProperty(shadowPropertyName);
63+
64+
if (!string.IsNullOrWhiteSpace(originalValue) && shadowProperty is not null)
65+
entry.Property(shadowPropertyName).CurrentValue = originalValue.Sha256Hash();
66+
}
67+
}
68+
}

test/EntityFrameworkCore.DataProtection.Test/Data/TestDbContext.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using EntityFrameworkCore.DataProtection.Extensions;
22
using Microsoft.AspNetCore.DataProtection;
33
using Microsoft.EntityFrameworkCore;
4+
using Microsoft.EntityFrameworkCore.Metadata.Builders;
45

56
namespace EntityFrameworkCore.DataProtection.Test.Data;
67

@@ -10,7 +11,16 @@ internal sealed class TestDbContext(DbContextOptions<TestDbContext> options, IDa
1011

1112
protected override void OnModelCreating(ModelBuilder builder)
1213
{
14+
builder.ApplyConfigurationsFromAssembly(typeof(TestDbContext).Assembly);
1315
base.OnModelCreating(builder);
1416
builder.UseDataProtection(dataProtectionProvider);
1517
}
18+
}
19+
20+
internal class UserConfiguration : IEntityTypeConfiguration<User>
21+
{
22+
public void Configure(EntityTypeBuilder<User> builder)
23+
{
24+
builder.Property(x => x.Email).IsEncrypted(true);
25+
}
1626
}

0 commit comments

Comments
 (0)