Abstract
Storing sensitive data in a database is not an easy task. Many things can go wrong, especially if proper precautions aren’t taken. Sensitive information, such as personal details or financial records, can become vulnerable to unauthorized access if encryption and security measures aren’t carefully implemented.
When it comes to encrypting and decrypting data in an application, ASP.NET Core provides a brilliant option for handling encryption with the powerful DataProtection
API. Alternatively, you can implement custom encryption logic tailored to your application's requirements. However, the challenge is not only encrypting and decrypting the data but also managing how this encrypted data is stored and retrieved efficiently from the database.
There are multiple ways to handle the storage and retrieval of encrypted data in Entity Framework Core. One common approach is to use backing fields, where encryption and decryption are handled on the fly when the data is accessed via property getters or setters. This way, the sensitive data remains encrypted while at rest in the database but is decrypted just in time when needed by the application.
public record PlayerProfile(ICryptographyService cryptographyService)
{
private string _email;
private string _realName;
public string Email
{
get => cryptographyService.Decrypt(_email);
set => _email = cryptographyService.Encrypt(value);
}
public string RealName
{
get => cryptographyService.Decrypt(_realName);
set => _realName = cryptographyService.Encrypt(value);
}
}
Another more robust approach leverages Entity Framework Core's ValueConverters
, which can be registered at the model creation level. Value converters allow you to specify how data is transformed when being read from or written to the database, making them ideal for encrypting sensitive data at the column level without affecting the rest of the business logic.
In this post, I would like to share a flexible solution based on the second approach to encrypt and decrypt columns in your database.
Implementation
Suppose you have a player profile entity in your application that contains sensitive information, such as a player's real name and email address. To comply with data protection regulations like GDPR, as well as to ensure the privacy of your users, this information must be securely stored in an encrypted format within the database.
public record PlayerProfile
{
public required Guid Id { get; init; }
public required string Email { get; init; }
public required string RealName { get; init; }
}
To achieve this, we can implement encryption using a value converter that automatically encrypts the data before it is written to the database and decrypts it when it is retrieved. To perform encryption and decryption, we will use a custom ICryptographyService
interface that provides methods for encrypting and decrypting data. For simplicity, we will use a simple reverse encryption algorithm in this example.
public interface ICryptographyService
{
string Encrypt(string data);
string Decrypt(string data);
}
public class ReverseCryptographyService : ICryptographyService
{
public string Encrypt(string value)
{
ArgumentNullException.ThrowIfNull(value);
var sb = new StringBuilder();
foreach (var c in value.Reverse())
sb.Append(c);
return sb.ToString();
}
public string Decrypt(string value)
{
ArgumentNullException.ThrowIfNull(value);
var sb = new StringBuilder();
foreach (var c in value.Reverse())
sb.Append(c);
return sb.ToString();
}
}
Next, we should register our ICryptographyService
implementation in the DI container in the Startup
class.
var builder = WebApplication.CreateBuilder(args);
// ...
builder.Services.AddSingleton<ICryptographyService, ReverseCryptographyService>();
Now, we are ready to implement the EncryptedConverter
class that will be used to encrypt and decrypt the data. The EncryptedConverter
class inherits from the ValueConverter
class provided by Entity Framework Core. It takes an instance of the ICryptographyService
interface in its constructor and uses it to encrypt and decrypt the data.
public class EncryptedConverter : ValueConverter<string, string>
{
public EncryptedConverter(ICryptographyService cryptographyService, ConverterMappingHints? mappingHints = null)
: base(v => cryptographyService.Encrypt(v),
v => cryptographyService.Decrypt(v),
mappingHints) { }
}
From now on, we can use fluent configuration or define a new attribute to mark properties that need to be encrypted. Let's start with the first approach.
Fluent configuration
To use the EncryptedConverter
class, we need to configure it in the OnModelCreating
method of the DbContext
class. We can do this by calling the HasConversion
method on the PropertyBuilder
instance for each property that needs to be encrypted. To keep DbContext
clean, we can create a fluent configuration for our PlayerProfile
.
public class PlayerProfileConfiguration : IEntityTypeConfiguration<PlayerProfile>
{
private readonly ICryptographyService _cryptographyService;
public PlayerProfileFluentConfiguration(ICryptographyService cryptographyService)
{
_cryptographyService = cryptographyService;
}
public void Configure(EntityTypeBuilder<PlayerProfile> builder)
{
var converter = new EncryptedConverter(_cryptographyService);
builder.HasKey(b => b.Id);
builder.Property(b => b.Email)
.IsRequired()
.HasConversion(converter);
builder.Property(b => b.RealName)
.IsRequired()
.HasConversion(converter);
}
}
Don't forget to register your configuration in the OnModelCreating
method of your DbContext
class.
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfiguration(new PlayerProfileConfiguration(_cryptographyService));
}
Attribute-based configuration
If you prefer to use attributes to mark properties that need to be encrypted, you can create a custom attribute and use it to decorate the properties in your entity class.
[AttributeUsage(AttributeTargets.Property)]
public sealed class EncryptedAttribute : Attribute
{
}
Next step is to add our newly created attribute to the properties that need to be encrypted.
public record PlayerProfile
{
[Required]
public required Guid Id { get; init; }
[Required]
[Encrypted]
public required string Email { get; init; }
[Required]
[Encrypted]
public required string RealName { get; init; }
}
Then we can scan all properties in the OnModelCreating
method of the DbContext
class and apply the EncryptedConverter
to those properties that are marked with the EncryptedAttribute
.
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
var stringType = typeof(string);
var encryptedAttribute = typeof(EncryptedAttribute);
var converter = new EncryptedConverter(_cryptographyService);
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
foreach (var property in entityType.GetProperties())
{
if (!IsDiscriminator(property)
&& property.ClrType == stringType
&& property.PropertyInfo is not null)
{
var attributes = property.PropertyInfo.GetCustomAttributes(encryptedAttribute, false);
if (attributes.Length != 0)
{
property.SetValueConverter(converter);
}
}
}
}
bool IsDiscriminator(IMutableProperty property) => property.Name == "Discriminator";
}
One gotcha with this approach is ensuring that the scanned property is not a discriminator column. The discriminator column is used by EF Core to store information about the entity type in Table Per Hierarchy (TPH) inheritance scenarios.
Results
Finally, let's see how the conversion of PlayerProfile
works!
After inserting a few new records into the database, we can see that the data is stored in an encrypted format.
Id | RealName | |
---|---|---|
035de6f3-9c58-4c39-abfe-997f172cef0e | moc.elpmaxe@divaD | senoJ divaD |
052d8c82-a05f-4590-b444-8590829ab1e1 | moc.elpmaxe@eilrahC | smailliW eilrahC |
2a10c13e-27e2-4a0f-9e87-f1eed8b15354 | moc.elpmaxe@evE | nworB evE |
9f1eccaa-5574-411e-8b40-38f362b650aa | moc.elpmaxe@ecilA | htimS ecilA |
fcfcd03c-d0d1-4bba-ae24-a97b829ee1b8 | moc.elpmaxe@boB | nosnhoJ boB |
When we retrieve the data from the database, the encrypted values should be automatically decrypted. Let's test this scenario by querying and listing items in our store.
info: Encrypto.DemoService[0]
Profile:
Id: 520f79c9-a16e-47ce-9ede-6aeeac857ac4
Email: Eve@example.com
Name: Eve Brown
info: Encrypto.DemoService[0]
Profile:
Id: 669e4809-0bcb-4523-8bc9-54c7fec8b752
Email: Bob@example.com
Name: Bob Johnson
info: Encrypto.DemoService[0]
Profile:
Id: 8b68946a-b490-47df-a9f6-fcc7c481de6a
Email: Charlie@example.com
Name: Charlie Williams
info: Encrypto.DemoService[0]
Profile:
Id: 8c7f41a7-bb6f-4297-b17f-612a6e8881a4
Email: Alice@example.com
Name: Alice Smith
info: Encrypto.DemoService[0]
Profile:
Id: f21dcba3-40e1-4fa4-9239-e8bbf2532928
Email: David@example.com
Name: David Jones
Conclusion
Securing sensitive data is crucial for both user privacy and legal compliance. By utilizing value converters in Entity Framework Core, you can efficiently encrypt and decrypt data stored in your database columns with minimal overhead.
Whether you use the built-in DataProtection API or implement your own encryption service, these two approaches are providing a flexible and powerful solution for handling encrypted data at scale.
Top comments (1)
This will work fine in a small project, but managing keys at scale is non-trivial. I'd prefer to have the database layer handle encryption.