Skip to content

Commit e7a9a31

Browse files
author
Roman Golovanov
committed
add custom serializer support
1 parent a838d8e commit e7a9a31

17 files changed

Lines changed: 568 additions & 13 deletions

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,37 @@ This registers an `IHostedService` that unwraps all stored KEKs during host star
298298

299299
Supported value types are primitives, `string`, `byte[]`, `bool`, `DateTime`, `DateTimeOffset`, `Guid`, enums backed by supported primitives, and nullable variants.
300300

301+
### Custom Value Serializers
302+
303+
Register a serializer when an encrypted property uses a plaintext type outside the built-in set. Built-in formats cannot be overridden, so existing encrypted values stay readable.
304+
305+
```csharp
306+
using System.Text;
307+
using EfCore.EncryptedProperties.Serialization;
308+
309+
services.AddEncryptedProperties(cfg => cfg
310+
.WithFileRsaKeyProvider("rsa-key.pem", "rsa-v1")
311+
.WithDatabaseKeyChain(SqlClientFactory.Instance, connectionString)
312+
.WithValueSerializer<Uri>(new UriValueSerializer()));
313+
314+
public sealed class Customer
315+
{
316+
public Guid Id { get; set; }
317+
318+
[Encrypted("website")]
319+
public Uri Website { get; set; } = new("https://example.com");
320+
}
321+
322+
public sealed class UriValueSerializer : IEncryptedPropertyValueSerializer<Uri>
323+
{
324+
public byte[] Serialize(Uri value)
325+
=> Encoding.UTF8.GetBytes(value.ToString());
326+
327+
public Uri Deserialize(byte[] data)
328+
=> new(Encoding.UTF8.GetString(data), UriKind.Absolute);
329+
}
330+
```
331+
301332
## Edge Cases
302333

303334
### Queries
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,36 @@
1+
using EfCore.EncryptedProperties.Serialization;
2+
13
namespace EfCore.EncryptedProperties.Configuration;
24

35
public sealed class EncryptedPropertiesOptions
46
{
7+
private readonly Dictionary<Type, ICustomValueSerializer> _valueSerializers = new();
8+
59
public RotationPolicy RotationPolicy { get; } = new();
610
public TimeSpan KekCacheLifetime { get; set; } = TimeSpan.FromMinutes(30);
11+
12+
internal IReadOnlyList<Type> CustomValueSerializerTypes =>
13+
_valueSerializers.Keys
14+
.OrderBy(type => type.AssemblyQualifiedName, StringComparer.Ordinal)
15+
.ToArray();
16+
17+
internal void SetValueSerializer<TValue>(IEncryptedPropertyValueSerializer<TValue> serializer)
18+
{
19+
ArgumentNullException.ThrowIfNull(serializer);
20+
21+
_valueSerializers[typeof(TValue)] = new CustomValueSerializer<TValue>(serializer);
22+
}
23+
24+
internal bool TryGetValueSerializer(Type type, out ICustomValueSerializer serializer)
25+
{
26+
var serializerType = Nullable.GetUnderlyingType(type) ?? type;
27+
if (_valueSerializers.TryGetValue(serializerType, out var registered))
28+
{
29+
serializer = registered;
30+
return true;
31+
}
32+
33+
serializer = null!;
34+
return false;
35+
}
736
}

src/EfCore.EncryptedProperties/Configuration/EncryptedPropertyContext.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ public sealed class EncryptedPropertyContext
55
public required string Purpose { get; init; }
66
public string? EntityTypeName { get; init; }
77
public string? PropertyName { get; init; }
8+
public Type? PlaintextType { get; init; }
89
}

src/EfCore.EncryptedProperties/Cryptography/EncryptedPropertyCryptor.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ public EncryptedPropertyCryptor(
3131
if (value is null)
3232
return null;
3333

34-
var plaintext = _serializer.Serialize(value, value.GetType());
34+
var plaintextType = context.PlaintextType ?? value.GetType();
35+
var plaintext = _serializer.Serialize(value, plaintextType);
3536
var kek = await _keyChainManager.GetActiveKeyAsync(context.Purpose, cancellationToken);
3637
var cek = CekGenerator.Generate();
3738

src/EfCore.EncryptedProperties/Extensions/EncryptedPropertiesServiceBuilder.cs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,16 @@ public EncryptedPropertiesServiceBuilder WithKeyChainPreloadOnStartup()
324324
return this;
325325
}
326326

327+
public EncryptedPropertiesServiceBuilder WithValueSerializer<TValue>(
328+
IEncryptedPropertyValueSerializer<TValue> serializer)
329+
{
330+
ArgumentNullException.ThrowIfNull(serializer);
331+
332+
ValidateCustomValueSerializerType(typeof(TValue));
333+
_options.SetValueSerializer(serializer);
334+
return this;
335+
}
336+
327337
internal void Validate()
328338
{
329339
if (!_rsaKeyProviderConfigured)
@@ -347,4 +357,31 @@ private static void ThrowIfNullOrWhiteSpace(string value)
347357
if (string.IsNullOrWhiteSpace(value))
348358
throw new ArgumentException("Value cannot be null or whitespace.", nameof(value));
349359
}
360+
361+
private static void ValidateCustomValueSerializerType(Type type)
362+
{
363+
var nullableType = Nullable.GetUnderlyingType(type);
364+
if (nullableType is not null)
365+
{
366+
throw new ArgumentException(
367+
$"Custom encrypted property value serializer type '{GetTypeDisplayName(type)}' cannot be nullable. Register a serializer for '{GetTypeDisplayName(nullableType)}' instead.");
368+
}
369+
370+
if (EncryptedPropertyTypeSupport.IsEncryptedValueType(type))
371+
{
372+
throw new ArgumentException(
373+
$"Custom encrypted property value serializers target plaintext types. Register a serializer for the inner plaintext type instead of '{GetTypeDisplayName(type)}'.");
374+
}
375+
376+
if (EncryptedPropertyTypeSupport.IsBuiltInPlaintextType(type))
377+
{
378+
throw new ArgumentException(
379+
$"Built-in encrypted property type '{GetTypeDisplayName(type)}' cannot be overridden by a custom value serializer.");
380+
}
381+
}
382+
383+
private static string GetTypeDisplayName(Type type)
384+
{
385+
return type.FullName ?? type.Name;
386+
}
350387
}

src/EfCore.EncryptedProperties/Infrastructure/EncryptedPropertiesConventionSetPlugin.cs

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,31 @@ namespace EfCore.EncryptedProperties.Infrastructure;
1111

1212
internal sealed class EncryptedPropertiesConventionSetPlugin : IConventionSetPlugin
1313
{
14+
private readonly IReadOnlyList<Type> _customValueSerializerTypes;
15+
16+
public EncryptedPropertiesConventionSetPlugin(IReadOnlyList<Type> customValueSerializerTypes)
17+
{
18+
_customValueSerializerTypes = customValueSerializerTypes;
19+
}
20+
1421
public ConventionSet ModifyConventions(ConventionSet conventionSet)
1522
{
16-
conventionSet.ModelFinalizingConventions.Insert(0, new EncryptedPropertiesStorageConvention());
23+
conventionSet.ModelFinalizingConventions.Insert(
24+
0,
25+
new EncryptedPropertiesStorageConvention(_customValueSerializerTypes));
1726
return conventionSet;
1827
}
1928
}
2029

2130
internal sealed class EncryptedPropertiesStorageConvention : IModelFinalizingConvention
2231
{
32+
private readonly IReadOnlyList<Type> _customValueSerializerTypes;
33+
34+
public EncryptedPropertiesStorageConvention(IReadOnlyList<Type> customValueSerializerTypes)
35+
{
36+
_customValueSerializerTypes = customValueSerializerTypes;
37+
}
38+
2339
public void ProcessModelFinalizing(
2440
IConventionModelBuilder modelBuilder,
2541
IConventionContext<IConventionModelBuilder> context)
@@ -126,7 +142,7 @@ private static void ApplyEncryptedAttribute(IConventionProperty property, Encryp
126142
.FirstOrDefault();
127143
}
128144

129-
private static void ConfigureCiphertextStorage(IConventionEntityType entityType, IConventionProperty plaintextProperty)
145+
private void ConfigureCiphertextStorage(IConventionEntityType entityType, IConventionProperty plaintextProperty)
130146
{
131147
if (plaintextProperty.IsKey()
132148
|| plaintextProperty.IsForeignKey()
@@ -235,7 +251,7 @@ private static string GetConfiguredMaterializationMode(IConventionEntityType ent
235251
$"Encrypted property '{GetPropertyDisplayName(entityType, property)}' has invalid materialization mode '{materialization}'. Supported modes are '{EncryptedPropertyTypeSupport.DecryptOnReadMaterialization}' and '{EncryptedPropertyTypeSupport.LazyMaterialization}'.");
236252
}
237253

238-
private static void ValidateEncryptedProperty(
254+
private void ValidateEncryptedProperty(
239255
IConventionEntityType entityType,
240256
IConventionProperty property,
241257
string materialization)
@@ -264,10 +280,10 @@ private static void ValidateEncryptedProperty(
264280
plaintextType = property.ClrType;
265281
}
266282

267-
if (!EncryptedPropertyTypeSupport.IsSupportedPlaintextType(plaintextType))
283+
if (!EncryptedPropertyTypeSupport.IsSupportedPlaintextType(plaintextType, _customValueSerializerTypes))
268284
{
269285
throw new InvalidOperationException(
270-
$"Encrypted property '{GetPropertyDisplayName(entityType, property)}' has unsupported CLR type '{GetTypeDisplayName(plaintextType)}'. Supported encrypted property types are {EncryptedPropertyTypeSupport.SupportedTypesDescription}.");
286+
$"Encrypted property '{GetPropertyDisplayName(entityType, property)}' has unsupported CLR type '{GetTypeDisplayName(plaintextType)}'. Supported encrypted property types are {EncryptedPropertyTypeSupport.SupportedTypesDescriptionWithCustomSerializers}.");
271287
}
272288
}
273289

src/EfCore.EncryptedProperties/Infrastructure/EncryptedPropertiesDbContextOptionsExtension.cs

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using EfCore.EncryptedProperties.Configuration;
12
using Microsoft.EntityFrameworkCore.Infrastructure;
23
using Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure;
34
using Microsoft.Extensions.DependencyInjection;
@@ -11,15 +12,20 @@ internal sealed class EncryptedPropertiesDbContextOptionsExtension : IDbContextO
1112
public EncryptedPropertiesDbContextOptionsExtension(IServiceProvider applicationServiceProvider)
1213
{
1314
ApplicationServiceProvider = applicationServiceProvider;
15+
CustomValueSerializerTypes = applicationServiceProvider
16+
.GetRequiredService<EncryptedPropertiesOptions>()
17+
.CustomValueSerializerTypes;
1418
}
1519

1620
internal IServiceProvider ApplicationServiceProvider { get; }
21+
internal IReadOnlyList<Type> CustomValueSerializerTypes { get; }
1722

1823
public DbContextOptionsExtensionInfo Info => _info ??= new ExtensionInfo(this);
1924

2025
public void ApplyServices(IServiceCollection services)
2126
{
22-
services.AddSingleton<IConventionSetPlugin, EncryptedPropertiesConventionSetPlugin>();
27+
services.AddSingleton<IConventionSetPlugin>(
28+
new EncryptedPropertiesConventionSetPlugin(CustomValueSerializerTypes));
2329
services.AddScoped<EncryptedPropertyStateTracker>();
2430
}
2531

@@ -29,22 +35,35 @@ public void Validate(IDbContextOptions options)
2935

3036
private sealed class ExtensionInfo : DbContextOptionsExtensionInfo
3137
{
32-
public ExtensionInfo(IDbContextOptionsExtension extension) : base(extension)
38+
private readonly EncryptedPropertiesDbContextOptionsExtension _extension;
39+
40+
public ExtensionInfo(EncryptedPropertiesDbContextOptionsExtension extension) : base(extension)
3341
{
42+
_extension = extension;
3443
}
3544

3645
public override bool IsDatabaseProvider => false;
3746
public override string LogFragment => "using EncryptedProperties ";
3847

3948
public override int GetServiceProviderHashCode()
40-
=> 0;
49+
{
50+
var hash = new HashCode();
51+
foreach (var type in _extension.CustomValueSerializerTypes)
52+
hash.Add(type.AssemblyQualifiedName, StringComparer.Ordinal);
53+
54+
return hash.ToHashCode();
55+
}
4156

4257
public override bool ShouldUseSameServiceProvider(DbContextOptionsExtensionInfo other)
43-
=> other is ExtensionInfo;
58+
=> other is ExtensionInfo otherInfo
59+
&& _extension.CustomValueSerializerTypes.SequenceEqual(otherInfo._extension.CustomValueSerializerTypes);
4460

4561
public override void PopulateDebugInfo(IDictionary<string, string> debugInfo)
4662
{
4763
debugInfo["EncryptedProperties:Enabled"] = "true";
64+
debugInfo["EncryptedProperties:CustomValueSerializers"] = string.Join(
65+
",",
66+
_extension.CustomValueSerializerTypes.Select(type => type.AssemblyQualifiedName));
4867
}
4968
}
5069
}

src/EfCore.EncryptedProperties/Metadata/EncryptedPropertyModelBuilder.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ public static EncryptedPropertyModel Build(IModel model)
4646
{
4747
Purpose = purpose,
4848
EntityTypeName = entityType.ClrType.FullName!,
49-
PropertyName = propertyName
49+
PropertyName = propertyName,
50+
PlaintextType = innerType
5051
},
5152
DefaultValue = GetDefaultValue(innerType),
5253
Accessors = EncryptedPropertyAccessors.Create(entityType.ClrType, propertyName, mode)

src/EfCore.EncryptedProperties/Metadata/EncryptedPropertyTypeSupport.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,22 @@ public static bool IsEncryptedValueType(Type type)
1111
}
1212

1313
public static bool IsSupportedPlaintextType(Type type)
14+
{
15+
return IsBuiltInPlaintextType(type);
16+
}
17+
18+
public static bool IsSupportedPlaintextType(
19+
Type type,
20+
IReadOnlyCollection<Type> customValueSerializerTypes)
21+
{
22+
if (IsBuiltInPlaintextType(type))
23+
return true;
24+
25+
var serializerType = Nullable.GetUnderlyingType(type) ?? type;
26+
return customValueSerializerTypes.Contains(serializerType);
27+
}
28+
29+
public static bool IsBuiltInPlaintextType(Type type)
1430
{
1531
var nullableType = Nullable.GetUnderlyingType(type);
1632
if (nullableType is not null)
@@ -31,6 +47,9 @@ public static bool IsSupportedPlaintextType(Type type)
3147
public static string SupportedTypesDescription =>
3248
"string, byte[], bool, numeric primitive types, decimal, DateTime, DateTimeOffset, Guid, enums, and nullable value-type variants";
3349

50+
public static string SupportedTypesDescriptionWithCustomSerializers =>
51+
SupportedTypesDescription + ", or types registered with WithValueSerializer<TValue>()";
52+
3453
private static bool IsSupportedNonNullablePlaintextType(Type type)
3554
{
3655
return type == typeof(bool)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
namespace EfCore.EncryptedProperties.Serialization;
2+
3+
internal interface ICustomValueSerializer
4+
{
5+
byte[] Serialize(object value);
6+
object? Deserialize(byte[] data);
7+
}
8+
9+
internal sealed class CustomValueSerializer<TValue> : ICustomValueSerializer
10+
{
11+
private readonly IEncryptedPropertyValueSerializer<TValue> _serializer;
12+
13+
public CustomValueSerializer(IEncryptedPropertyValueSerializer<TValue> serializer)
14+
{
15+
_serializer = serializer;
16+
}
17+
18+
public byte[] Serialize(object value)
19+
{
20+
return _serializer.Serialize((TValue)value);
21+
}
22+
23+
public object? Deserialize(byte[] data)
24+
{
25+
return _serializer.Deserialize(data);
26+
}
27+
}

0 commit comments

Comments
 (0)