.NET
Core Entity
Framework Core PostgreSQL
▌Introduction
In
previous article, [PostgreSQL]
pgcrypto - encryption and decryption, we created some encryption/decryption functions on PostgreSQL.
We will integrate these DB functions to Entity Framework Core, so that we can
use them to encrypt/decrypt data on runtime.
The
concept can be applied to other RMDB, such as Microsoft SQL Server, Oracle,
etc.
▌Environment
▋.NET Core 3.1
▋Entity Framework Core 3.1.0
▋PostgreSQL 11.6
▋Npgsql.EntityFrameworkCore.PostgreSQL
3.1.0
▌Implement
The sample code is on my Github: KarateJB\EF.Core.Sample.
▋DB scalar functions
Reference
from [PostgreSQL]
pgcrypto - encryption and decryption, I will create a EF migration for creating the DB functions to
database. Notice that the migration won’t include creating asymmetric
public/private keys, so make sure that the key pairs are ready before doing the
DB migration.
First
lets put the create-functions SQL into some SQL files,
▋MigrationBuilderExtensions.cs
public static partial class MigrationBuilderExtensions
{
private const string RootDirName = "Sqls";
private static string datetimeDirName = string.Empty;
/// <summary>
/// Read SQL from file
/// </summary>
/// <param name="fileName">File name</param>
/// <returns>SQL</returns>
private static string ReadMigrationSql(string path)
{
FileInfo file = new FileInfo(path);
string script = file.OpenText().ReadToEnd();
return script;
}
/// <summary>
/// Create DB functions
/// </summary>
/// <param name="migrationBuilder">MigrationBuilder</param>
public static void CreatePgCryptoFunctions_20200108053432(this MigrationBuilder migrationBuilder)
{
datetimeDirName = "20200108053432";
var path = string.Empty;
var script = string.Empty;
// Create Extension
path = Path.Combine(AppContext.BaseDirectory, RootDirName, datetimeDirName, "01.create_extension.sql");
script = ReadMigrationSql(path);
migrationBuilder.Sql(script);
// Create Schema and store secret
path = Path.Combine(AppContext.BaseDirectory, RootDirName, datetimeDirName, "02.store_secret.sql");
script = ReadMigrationSql(path);
migrationBuilder.Sql(script);
// Create functions
path = Path.Combine(AppContext.BaseDirectory, RootDirName, datetimeDirName, "03.create_functions.sql");
script = ReadMigrationSql(path);
migrationBuilder.Sql(script);
}
}
Now we
will create an EMPTY EF Migration by the following command:
$ cd src/EFCore.Ap
$ dotnet ef --project ../EFCore.Dal --startup-project . migrations add CreateFunctions.
Put the
create-method from MigrationBuilder extension to create the DB functions when updating
database.
▋20200108053432_CreateFunctions.cs
(EF migration file)
public partial class CreateFunctions : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
#region Custom migrate sqls
migrationBuilder.CreatePgCryptoFunctions_20200108053432();
#endregion
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
Last
step, run the following command to generate the DB functions on the database.
$ cd src/EFCore.Ap
$ dotnet ef --project ../EFCore.Dal --startup-project . database update
Result:
▋EF Core: Value Conversions
From EF
Core 2.1, we can use Value
Conversions to bind logic
when reading data from table’s column or writing data to table’s column. In our
case, that will be:
1. Decryption on Reading data
2. Encryption on Writing data
Create an
entity model (data table) named “Users”, which contains the following columns,
Column
|
Type
|
Description
|
Id
|
uuid
|
GUID
|
Name
|
varchar(48)
|
|
Password
|
text
|
Must be hashed
|
Phone
|
bytea
|
Must be encrypted
|
CardNo
|
bytea
|
Must be encrypted
|
▋User.cs
[Table("Users")]
public class User
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; set; }
[Column(TypeName = "varchar(48)")]
public string Name { get; set; }
public string Password { get; set; }
[Column(TypeName = "bytea")]
public string Phone { get; set; }
[Column(TypeName = "bytea")]
public string CardNo { get; set; }
}
Now we
will add Value
Conversions: PropertyBuilder.HasConversion
Method, on the columns that must
be hashed or encrypted.
// Parameters:
// convertToProviderExpression:
// An expression to convert objects when writing data to the store.
//
// convertFromProviderExpression:
// An expression to convert objects when reading data from the store.
public virtual PropertyBuilder<TProperty> HasConversion<TProvider>(
[NotNullAttribute] Expression<Func<TProperty, TProvider>> convertToProviderExpression,
[NotNullAttribute] Expression<Func<TProvider, TProperty>> convertFromProviderExpression);
Notice that the Value
Conversions does not do the converting on
SQL execution but BEFORE/AFTER the SQL execution!! In other words, the SQL will be as
same as the original SQL before we adapt the Value
Conversions methodology. |
▋MyDbContext.cs
public class MyDbContext : DbContext
{
private readonly DbContextOptions<MyDbContext> options = null;
public MyDbContext(
DbContextOptions<MyDbContext> options) : base(options)
{
this.options = options;
}
public DbSet<User> Users { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Hash Password
modelBuilder.Entity<User>().Property(p => p.Password).HasConversion(
val => this.HashIt(val),
val => val);
// Encrypt/Decrypt Phone
modelBuilder.Entity<User>().Property(p => p.Phone).HasConversion(
val => this.EncryptMe(val),
val => this.DecryptMe(val));
// Encrypt/Decrypt CardNo
modelBuilder.Entity<User>().Property(p => p.CardNo).HasConversion(
val => this.EncryptMe(val),
val => this.DecryptMe(val));
}
}
Lets get
things done by implementing the HashIt, EncryptMe, DecryptMe
methods.
I will
take HashIt
for example.
1. Inject the configured TOptions object for getting
connection string later.
public class Startup
{
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.Configure<AppSettings>(this.Configuration);
// … Skip
}
}
AppSettings is a class to store the connection
string,
public class AppSettings
{
public ConnectionStringOptions ConnectionStrings { get; set; }
}
public class ConnectionStringOptions
{
public string DB { get; set; }
}
2. Implement
the HashIt
method on MyDbContext.cs:
(Only show the updated code)
public class MyDbContext : DbContext
{
private readonly DbContextOptions<MyDbContext> options = null;
private readonly AppSettings appSettings = null;
public MyDbContext(
DbContextOptions<MyDbContext> options,
IOptions<AppSettings> configuration) : base(options)
{
this.options = options;
this.appSettings = configuration.Value;
}
private string HashIt(string text)
{
var connectionString = this.appSettings.ConnectionStrings.DB;
var optionsBuilder = new DbContextOptionsBuilder<MyDbContext>();
optionsBuilder.UseNpgsql(connectionString);
using (var dbContext = new MyDbContext(optionsBuilder.Options))
using (var command = dbContext.Database.GetDbConnection().CreateCommand())
{
command.CommandType = CommandType.StoredProcedure;
command.CommandText = "my_hash";
command.Parameters.Add(new Npgsql.NpgsqlParameter("t", NpgsqlTypes.NpgsqlDbType.Text)
{ Value = text });
if (command.Connection.State == ConnectionState.Closed)
{
command.Connection.Open();
}
var hashed = (string)command.ExecuteScalar();
return hashed;
}
}
}
Important!! If we disable Database connection pooling, the above codes will take MUCH TIME on CREATING THE DB CONNECTION when the Query/Execution SQL includes lots of records!! This will result in bad performance.
In this case, we can create a DB connection pool(factory) to keep the
connections without closing them immediately. |
The codes
for EncryptMe,
DecryptMe
methods are almost as same as HashIt, you only have to change the
DB function name.
Finally,
lets add some seed data and do a new migrations.
public class MyDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// ...skip
// Seed
modelBuilder.Entity<User>()
.HasData(new User
{
Id = new Guid("ce32bd49-7a7c-4bd1-b2b9-7e960ad86306"),
Name = "JB",
Password = "123456!@#$%^",
Phone = "0912345678",
CardNo = "XXXX-YYYY-1234-5678"
});
modelBuilder.Entity<User>()
.HasData(new User
{
Id = new Guid("3e177c71-f84e-4c23-9bf4-b322b350c8b2"),
Name = "Amy",
Password = "123456!@#$%^",
Phone = "0933333333",
CardNo = "1234-5678-ZZZZ-WWWW"
});
}
}
Then
update database with the new EF migration…
$ cd src/EFCore.Ap
$ dotnet ef --project ../EFCore.Dal --startup-project . migrations add CreateUsers
$ dotnet ef --project ../EFCore.Dal --startup-project . database update
Check the
Users table and see if the seed data’s “Password” is hashed, “Phone” and
“CardNo” are encrypted like following.
I created
an API and see if I can get the DECRYPTED “Phone” and “CardNo” without any
other decryption logic.
[HttpGet("GetUser/{name}")]
public async Task<User> GetUser([FromRoute] string name)
{
return this.dbcontext.Set<User>().FirstOrDefault(x => x.Name.Equals(name));
}
It works!
▋EF Core: Database scalar function mapping
From EF
Core 2.0, we can enable mapping database scalar functions to method stubs so
that they can be used in LINQ queries
and translated to SQL.
Lets
define the scalar-function-mapping in our DbContext,
▋MyDbContext.cs
Only have
to map the DB function’s Name and Schema :-)
public class MyDbContext : DbContext
{
#region Functions
[DbFunction(Name = "my_hash", Schema = "public")]
public static string DbHash(string t) => throw new NotImplementedException();
[DbFunction(Name = "my_hash_match", Schema = "public")]
public static bool DbHashMatch(string t, string hashed) => throw new NotImplementedException();
[DbFunction(Name = "my_sym_encrypt", Schema = "public")]
public static byte[] DbSymEncrypt(string t) => throw new NotImplementedException();
[DbFunction(Name = "my_sym_decrypt", Schema = "public")]
public static string DbSymDecrypt(byte[] t) => throw new NotImplementedException();
#endregion
}
And we
can use them in LINQ Query or Lambda extension. For example, I want to find the
matched user with an input username and password:
var matchedUser =
this.dbcontext.Set<User>().FirstOrDefault(x => x.Name.Equals('JB') && MyDbContext.DbHashMatch('my_pwd', x.Password));
Or by
LINQ:
var users =
from x in this.dbcontext.Users
where x => x.Name.Equals('JB') && MyDbContext.DbHashMatch('my_pwd', x.Password)
select x;
var matchedUser = users.FirstOrDefault();
▋EF Core: Database scalar function mapping (Advanced)
Notice that if
we do not want to encryption/decryption by Value Conversions, we can still use scalar function mapping to do the same thing.
For example, add
a new property to the DAO, that will be encrypted manually later.
▋User.cs
[Table("Users")]
public class User
{
// …
skip
[Column(TypeName = "bytea")]
public byte[] Secret { get; set; } // This column wont apply Value Conversions
}
▋Get user with scalar
function mapping
Now we can select
the user as below,
this.dbcontext.Set<User>().Where(x => x.Name.Equals(name)).Select(x=>
new ApiUserInfo
{
Name = x.Name,
Phone = x.Phone,
CardNo = x.CardNo,
Secret = MyDbContext.DbSymDecrypt(x.Secret)
}).FirstOrDefault();
▋Add/Update user
with scalar function mapping
Since the scalar
function mapping only works in LINQ or Lambda extension, here is the trick to
add/update user,
await this.dbcontext.Set<User>().AddAsync(this.dbcontext.Set<User>().Select(x => new User
{
Name = user.Name,
Password = user.Password,
Phone = user.Phone,
CardNo = user.CardNo,
Secret = MyDbContext.DbSymEncrypt("some secret")
}).First());
await this.dbcontext.SaveChangesAsync();
▋Github
▌Reference
Thanks so much for this!! It's super helpful... I got it working, except it doesn't seem to work in a .Where(..), for example:
回覆刪除_context.Users.Where(x => x.User.EmailEncrypted == emailLower)
... doesn't include the record that I know exists. I'm passing emailLower unencrypted.
Can you please point me in the right direction... do I have to use a function to encrypt emailLower when passing it in the Where()?
HI, Michael,
刪除In your case, you have to set the Value Conversions on the "EmailEncrypted" property.
For example:
--------------------------------------------
public class MyDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Encrypt/Decrypt Phone
modelBuilder.Entity().Property(p => p.EmailEncrypted).HasConversion(
val => this.EncryptMe(val),
val => this.DecryptMe(val));
}
}
--------------------------------------------
See the sample code as below.
https://github.com/KarateJB/EF.Core.Sample/blob/990f46597cb23a2301e7439f30e4c5d76393c67d/src/EFCore.Dal/PgDbContext.cs#L73
The other way is using the Scalar Function Mapping in your Linq or Lambda extension:
https://github.com/KarateJB/EF.Core.Sample/blob/990f46597cb23a2301e7439f30e4c5d76393c67d/src/EFCore.Ap/Controllers/DemoController.cs#L58
Hope this helps!
The comment tool of Blogger is not good to use :(
刪除If you still have any problem, pls feel free to create an issue on my Github:
https://github.com/KarateJB/EF.Core.Sample/issues
Thank you so much karatejb :) I hope you have a great day
回覆刪除No problem, you too.
刪除Good day, then using pgcrypto data send from server by network is encrypt or decrypt, decrypt in server or in client ?
回覆刪除Hi, Serg, The colums "Phone" and "CardNo" are encrypted and decrypted in DB (server side), so what the client see is the decrpted data.
回覆刪除