diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0d4a6fd --- /dev/null +++ b/.editorconfig @@ -0,0 +1,39 @@ +[*.cs] +indent_style = space +indent_size = 4 + +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_open_brace = methods, object_collection_array_initializers, control_blocks, types +dotnet_sort_system_directives_first = true + +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_parameter_list_parentheses = false + +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = false +csharp_prefer_braces = true:warning + +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion + +dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion +csharp_preferred_modifier_order = public,private,internal,protected,static,readonly,sealed,async,override,abstract:suggestion + +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion +dotnet_style_object_initializer = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +csharp_prefer_simple_default_expression = true:suggestion + +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_property = false:suggestion diff --git a/EntityFrameworkCore.DataEncryption.sln b/EntityFrameworkCore.DataEncryption.sln index d8d9233..fb35659 100644 --- a/EntityFrameworkCore.DataEncryption.sln +++ b/EntityFrameworkCore.DataEncryption.sln @@ -28,6 +28,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "pipelines", "pipelines", "{ ProjectSection(SolutionItems) = preProject .azure\pipelines\azure-pipelines.yml = .azure\pipelines\azure-pipelines.yml EndProjectSection +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{5EE4E8BE-6B15-49DB-A4A8-D2CD63D5E90C}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + EndProjectSection EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/samples/AesSample/Program.cs b/samples/AesSample/Program.cs index 2d977d0..c39313d 100644 --- a/samples/AesSample/Program.cs +++ b/samples/AesSample/Program.cs @@ -2,12 +2,13 @@ using Microsoft.EntityFrameworkCore.DataEncryption.Providers; using System; using System.Linq; +using System.Security; namespace AesSample { - class Program + static class Program { - static void Main(string[] args) + static void Main() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName: "MyInMemoryDatabase") @@ -23,7 +24,8 @@ static void Main(string[] args) { FirstName = "John", LastName = "Doe", - Email = "john@doe.com" + Email = "john@doe.com", + Password = BuildPassword(), }; context.Users.Add(user); @@ -31,9 +33,24 @@ static void Main(string[] args) Console.WriteLine($"Users count: {context.Users.Count()}"); - user = context.Users.FirstOrDefault(); + user = context.Users.First(); - Console.WriteLine($"User: {user.FirstName} {user.LastName} - {user.Email}"); + Console.WriteLine($"User: {user.FirstName} {user.LastName} - {user.Email} ({user.Password.Length})"); + } + + static SecureString BuildPassword() + { + SecureString result = new(); + result.AppendChar('L'); + result.AppendChar('e'); + result.AppendChar('t'); + result.AppendChar('M'); + result.AppendChar('e'); + result.AppendChar('I'); + result.AppendChar('n'); + result.AppendChar('!'); + result.MakeReadOnly(); + return result; } } } diff --git a/samples/AesSample/UserEntity.cs b/samples/AesSample/UserEntity.cs index 316ec17..671890b 100644 --- a/samples/AesSample/UserEntity.cs +++ b/samples/AesSample/UserEntity.cs @@ -1,6 +1,7 @@ using System; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using System.Security; namespace AesSample { @@ -19,5 +20,7 @@ public class UserEntity [Required] [Encrypted] public string Email { get; set; } + + public SecureString Password { get; set; } } } diff --git a/src/EntityFrameworkCore.DataEncryption/Attributes/EncryptedAttribute.cs b/src/EntityFrameworkCore.DataEncryption/Attributes/EncryptedAttribute.cs index 6c1ba89..c76c5cf 100644 --- a/src/EntityFrameworkCore.DataEncryption/Attributes/EncryptedAttribute.cs +++ b/src/EntityFrameworkCore.DataEncryption/Attributes/EncryptedAttribute.cs @@ -6,5 +6,27 @@ [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = false)] public sealed class EncryptedAttribute : Attribute { + /// + /// Initializes a new instance of the class. + /// + /// + /// The storage format. + /// + public EncryptedAttribute(StorageFormat format) + { + Format = format; + } + + /// + /// Initializes a new instance of the class. + /// + public EncryptedAttribute() : this(StorageFormat.Default) + { + } + + /// + /// Returns the storage format for the database value. + /// + public StorageFormat Format { get; } } -} +} \ No newline at end of file diff --git a/src/EntityFrameworkCore.DataEncryption/Attributes/StorageFormat.cs b/src/EntityFrameworkCore.DataEncryption/Attributes/StorageFormat.cs new file mode 100644 index 0000000..f948d12 --- /dev/null +++ b/src/EntityFrameworkCore.DataEncryption/Attributes/StorageFormat.cs @@ -0,0 +1,26 @@ +namespace System.ComponentModel.DataAnnotations +{ + /// + /// Represents the storage format for an encrypted value. + /// + public enum StorageFormat + { + /// + /// The format is determined by the model data type. + /// + Default, + /// + /// The value is stored in binary. + /// + Binary, + /// + /// The value is stored in a Base64-encoded string. + /// + /// + /// NB: If the source property is a , + /// and no encryption provider is configured, + /// the string will not be modified. + /// + Base64, + } +} \ No newline at end of file diff --git a/src/EntityFrameworkCore.DataEncryption/EntityFrameworkCore.DataEncryption.csproj b/src/EntityFrameworkCore.DataEncryption/EntityFrameworkCore.DataEncryption.csproj index 3cf2a8d..c4ba5c4 100644 --- a/src/EntityFrameworkCore.DataEncryption/EntityFrameworkCore.DataEncryption.csproj +++ b/src/EntityFrameworkCore.DataEncryption/EntityFrameworkCore.DataEncryption.csproj @@ -1,38 +1,46 @@  - - netstandard2.0 - 9.0 - EntityFrameworkCore.DataEncryption - Microsoft.EntityFrameworkCore.DataEncryption - 2.0.0 - Filipe GOMES PEIXOTO - EntityFrameworkCore.DataEncryption - https://github.com/Eastrall/EntityFrameworkCore.DataEncryption - https://github.com/Eastrall/EntityFrameworkCore.DataEncryption.git - git - true - true - entity-framework-core, extensions, dotnet-core, dotnet, encryption, fluent-api - 8.0 - icon.png - Filipe GOMES PEIXOTO © 2019 - 2020 - A plugin for Microsoft.EntityFrameworkCore to add support of encrypted fields using built-in or custom encryption providers. - LICENSE - - Remove initializationVector parameter from `AesProvider` constructor. -- Apply unique IV for each row. - + + netstandard2.0 + 9.0 + true + EntityFrameworkCore.DataEncryption + Microsoft.EntityFrameworkCore.DataEncryption + 3.0.0 + Filipe GOMES PEIXOTO + EntityFrameworkCore.DataEncryption + https://github.com/Eastrall/EntityFrameworkCore.DataEncryption + https://github.com/Eastrall/EntityFrameworkCore.DataEncryption.git + git + true + true + entity-framework-core, extensions, dotnet-core, dotnet, encryption, fluent-api + icon.png + Filipe GOMES PEIXOTO © 2019 - 2020 + A plugin for Microsoft.EntityFrameworkCore to add support of encrypted fields using built-in or custom encryption providers. + LICENSE + + - Add support for storing data as binary or Base64 + - Add support for SecureString and binary model properties + + - - - + + + - - - True - - - - + + + True + + + + + + + + <_Parameter1>Microsoft.EntityFrameworkCore.Encryption.Test + + diff --git a/src/EntityFrameworkCore.DataEncryption/EntityFrameworkCore.DataEncryption.csproj.DotSettings b/src/EntityFrameworkCore.DataEncryption/EntityFrameworkCore.DataEncryption.csproj.DotSettings new file mode 100644 index 0000000..6162834 --- /dev/null +++ b/src/EntityFrameworkCore.DataEncryption/EntityFrameworkCore.DataEncryption.csproj.DotSettings @@ -0,0 +1,2 @@ + + CSharp90 \ No newline at end of file diff --git a/src/EntityFrameworkCore.DataEncryption/IEncryptionProvider.cs b/src/EntityFrameworkCore.DataEncryption/IEncryptionProvider.cs index b441839..49a8c6d 100644 --- a/src/EntityFrameworkCore.DataEncryption/IEncryptionProvider.cs +++ b/src/EntityFrameworkCore.DataEncryption/IEncryptionProvider.cs @@ -1,4 +1,7 @@ -namespace Microsoft.EntityFrameworkCore.DataEncryption +using System; +using System.IO; + +namespace Microsoft.EntityFrameworkCore.DataEncryption { /// /// Provides a mechanism for implementing a custom encryption provider. @@ -6,17 +9,59 @@ public interface IEncryptionProvider { /// - /// Encrypts a string. + /// Encrypts a value. /// - /// Input data as a string to encrypt. - /// Encrypted data as a string. - string Encrypt(string dataToEncrypt); + /// + /// The type of data stored in the database. + /// + /// + /// The type of value stored in the model. + /// + /// + /// Input data to encrypt. + /// + /// + /// Function which converts the model value to a byte array. + /// + /// + /// Function which encodes the value for storing the the database. + /// + /// + /// Encrypted data. + /// + /// + /// is . + /// -or- + /// is . + /// + TStore Encrypt(TModel dataToEncrypt, Func converter, Func encoder); /// - /// Decrypts a string. + /// Decrypts a value. /// - /// Encrypted data as a string to decrypt. - /// Decrypted data as a string. - string Decrypt(string dataToDecrypt); + /// + /// The type of data stored in the database. + /// + /// + /// The type of value stored in the model. + /// + /// + /// Encrypted data to decrypt. + /// + /// + /// Function which converts the stored data to a byte array. + /// + /// + /// Function which converts the decrypted to the return value. + /// + /// + /// Decrypted data. + /// + /// + /// is . + /// -or- + /// is . + /// + TModel Decrypt(TStore dataToDecrypt, Func decoder, Func converter); } } diff --git a/src/EntityFrameworkCore.DataEncryption/Internal/ConverterBuilder.cs b/src/EntityFrameworkCore.DataEncryption/Internal/ConverterBuilder.cs new file mode 100644 index 0000000..28144e6 --- /dev/null +++ b/src/EntityFrameworkCore.DataEncryption/Internal/ConverterBuilder.cs @@ -0,0 +1,206 @@ +using System; +using System.IO; +using System.Security; +using System.Text; + +namespace Microsoft.EntityFrameworkCore.DataEncryption.Internal +{ + /// + /// Utilities for building value converters. + /// + public static class ConverterBuilder + { + /// + /// Builds a converter for a property with a custom model type. + /// + /// + /// The model type. + /// + /// + /// The , if any. + /// + /// + /// The function used to decode the model type to a byte array. + /// + /// + /// The function used to encode a byte array to the model type. + /// + /// + /// An instance. + /// + /// + /// is . + /// -or- + /// is . + /// + public static ConverterBuilder From( + this IEncryptionProvider encryptionProvider, + Func decoder, + Func encoder) + { + if (decoder is null) + { + throw new ArgumentNullException(nameof(decoder)); + } + + if (encoder is null) + { + throw new ArgumentNullException(nameof(encoder)); + } + + return new ConverterBuilder(encryptionProvider, decoder, encoder); + } + + /// + /// Builds a converter for a binary property. + /// + /// + /// The , if any. + /// + /// + /// An instance. + /// + public static ConverterBuilder FromBinary(this IEncryptionProvider encryptionProvider) + { + return new ConverterBuilder(encryptionProvider, b => b, StandardConverters.StreamToBytes); + } + + /// + /// Builds a converter for a string property. + /// + /// + /// The , if any. + /// + /// + /// An instance. + /// + public static ConverterBuilder FromString(this IEncryptionProvider encryptionProvider) + { + return new ConverterBuilder(encryptionProvider, Encoding.UTF8.GetBytes, StandardConverters.StreamToString); + } + + /// + /// Builds a converter for a property. + /// + /// + /// The , if any. + /// + /// + /// An instance. + /// + public static ConverterBuilder FromSecureString(this IEncryptionProvider encryptionProvider) + { + return new ConverterBuilder(encryptionProvider, Encoding.UTF8.GetBytes, StandardConverters.StreamToSecureString); + } + + /// + /// Specifies that the property should be stored in the database using a custom format. + /// + /// + /// The model type. + /// + /// + /// The store type. + /// + /// + /// The representing the model type. + /// + /// + /// The function used to decode the store type into a byte array. + /// + /// + /// The function used to encode a byte array into the store type. + /// + /// + /// An instance. + /// + /// + /// is . + /// -or- + /// is . + /// -or- + /// is . + /// + /// + /// is not a supported type. + /// + public static ConverterBuilder To( + ConverterBuilder modelType, + Func decoder, + Func encoder) + { + if (modelType.IsEmpty) + { + throw new ArgumentNullException(nameof(modelType)); + } + + if (decoder is null) + { + throw new ArgumentNullException(nameof(decoder)); + } + + if (encoder is null) + { + throw new ArgumentNullException(nameof(encoder)); + } + + return new ConverterBuilder(modelType, decoder, encoder); + } + + /// + /// Specifies that the property should be stored in the database in binary. + /// + /// + /// The model type. + /// + /// + /// The representing the model type. + /// + /// + /// An instance. + /// + /// + /// is . + /// + /// + /// is not a supported type. + /// + public static ConverterBuilder ToBinary(this ConverterBuilder modelType) + { + if (modelType.IsEmpty) + { + throw new ArgumentNullException(nameof(modelType)); + } + + return new ConverterBuilder(modelType, b => b, StandardConverters.StreamToBytes); + } + + /// + /// Specifies that the property should be stored in the database in a Base64-encoded string. + /// + /// + /// The model type. + /// + /// + /// The representing the model type. + /// + /// + /// An instance. + /// + /// + /// is . + /// + /// + /// is not a supported type. + /// + public static ConverterBuilder ToBase64(this ConverterBuilder modelType) + { + if (modelType.IsEmpty) + { + throw new ArgumentNullException(nameof(modelType)); + } + + return new ConverterBuilder(modelType, Convert.FromBase64String, StandardConverters.StreamToBase64String); + } + } +} \ No newline at end of file diff --git a/src/EntityFrameworkCore.DataEncryption/Internal/ConverterBuilder`1.cs b/src/EntityFrameworkCore.DataEncryption/Internal/ConverterBuilder`1.cs new file mode 100644 index 0000000..c0ae461 --- /dev/null +++ b/src/EntityFrameworkCore.DataEncryption/Internal/ConverterBuilder`1.cs @@ -0,0 +1,38 @@ +using System; +using System.Diagnostics; +using System.IO; + +namespace Microsoft.EntityFrameworkCore.DataEncryption.Internal +{ + /// + /// A converter builder class which has the model type specified. + /// + /// + /// The model type. + /// + public readonly struct ConverterBuilder + { + internal ConverterBuilder(IEncryptionProvider encryptionProvider, Func decoder, Func encoder) + { + Debug.Assert(decoder is not null); + Debug.Assert(encoder is not null); + + EncryptionProvider = encryptionProvider; + Decoder = decoder; + Encoder = encoder; + } + + private readonly IEncryptionProvider EncryptionProvider; + private readonly Func Decoder; + private readonly Func Encoder; + + internal bool IsEmpty => Decoder is null || Encoder is null; + + internal void Deconstruct(out IEncryptionProvider encryptionProvider, out Func decoder, out Func encoder) + { + encryptionProvider = EncryptionProvider; + decoder = Decoder; + encoder = Encoder; + } + } +} \ No newline at end of file diff --git a/src/EntityFrameworkCore.DataEncryption/Internal/ConverterBuilder`2.cs b/src/EntityFrameworkCore.DataEncryption/Internal/ConverterBuilder`2.cs new file mode 100644 index 0000000..dddda9f --- /dev/null +++ b/src/EntityFrameworkCore.DataEncryption/Internal/ConverterBuilder`2.cs @@ -0,0 +1,69 @@ +using System; +using System.Diagnostics; +using System.IO; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace Microsoft.EntityFrameworkCore.DataEncryption.Internal +{ + /// + /// A converter builder class which has both the model type and store type specified. + /// + /// + /// The model type. + /// + /// + /// The store type. + /// + public readonly struct ConverterBuilder + { + internal ConverterBuilder(ConverterBuilder modelType, Func decoder, Func encoder) + { + Debug.Assert(!modelType.IsEmpty); + Debug.Assert(decoder is not null); + Debug.Assert(encoder is not null); + + ModelType = modelType; + Decoder = decoder; + Encoder = encoder; + } + + private readonly ConverterBuilder ModelType; + private readonly Func Decoder; + private readonly Func Encoder; + + /// + /// Builds the value converter. + /// + /// + /// The mapping hints to use, if any. + /// + /// + /// The . + /// + public ValueConverter Build(ConverterMappingHints mappingHints = null) + { + var (encryptionProvider, modelDecoder, modelEncoder) = ModelType; + var storeDecoder = Decoder; + var storeEncoder = Encoder; + + if (modelDecoder is null || modelEncoder is null || storeDecoder is null || storeEncoder is null) + { + return null; + } + + if (encryptionProvider is null) + { + return new ValueConverter( + m => storeEncoder(StandardConverters.BytesToStream(modelDecoder(m))), + s => modelEncoder(StandardConverters.BytesToStream(storeDecoder(s))), + mappingHints); + } + + return new EncryptionConverter( + encryptionProvider, + m => encryptionProvider.Encrypt(m, modelDecoder, storeEncoder), + s => encryptionProvider.Decrypt(s, storeDecoder, modelEncoder), + mappingHints); + } + } +} \ No newline at end of file diff --git a/src/EntityFrameworkCore.DataEncryption/Internal/EncodingExtensions.cs b/src/EntityFrameworkCore.DataEncryption/Internal/EncodingExtensions.cs new file mode 100644 index 0000000..7cf1634 --- /dev/null +++ b/src/EntityFrameworkCore.DataEncryption/Internal/EncodingExtensions.cs @@ -0,0 +1,57 @@ +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Security; +using System.Text; + +namespace Microsoft.EntityFrameworkCore.DataEncryption.Internal +{ + internal static class EncodingExtensions + { + internal static byte[] GetBytes(this Encoding encoding, SecureString value) + { + if (encoding is null) + { + throw new ArgumentNullException(nameof(encoding)); + } + + if (value is null || value.Length == 0) + { + return Array.Empty(); + } + + IntPtr valuePtr = IntPtr.Zero; + try + { + valuePtr = Marshal.SecureStringToGlobalAllocUnicode(value); + if (valuePtr == IntPtr.Zero) + { + return Array.Empty(); + } + + unsafe + { + char* chars = (char*)valuePtr; + Debug.Assert(chars != null); + + int byteCount = encoding.GetByteCount(chars, value.Length); + + var result = new byte[byteCount]; + fixed (byte* bytes = result) + { + encoding.GetBytes(chars, value.Length, bytes, byteCount); + } + + return result; + } + } + finally + { + if (valuePtr != IntPtr.Zero) + { + Marshal.ZeroFreeGlobalAllocUnicode(valuePtr); + } + } + } + } +} \ No newline at end of file diff --git a/src/EntityFrameworkCore.DataEncryption/Internal/EncryptionConverter.cs b/src/EntityFrameworkCore.DataEncryption/Internal/EncryptionConverter.cs index f6506be..20794f2 100644 --- a/src/EntityFrameworkCore.DataEncryption/Internal/EncryptionConverter.cs +++ b/src/EntityFrameworkCore.DataEncryption/Internal/EncryptionConverter.cs @@ -1,20 +1,28 @@ -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using System; +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Microsoft.EntityFrameworkCore.DataEncryption.Internal { /// /// Defines the internal encryption converter for string values. /// - internal sealed class EncryptionConverter : ValueConverter + internal sealed class EncryptionConverter : ValueConverter, IEncryptionValueConverter { /// - /// Creates a new instance. + /// Creates a new instance. /// - /// Encryption provider - /// Entity Framework mapping hints - public EncryptionConverter(IEncryptionProvider encryptionProvider, ConverterMappingHints mappingHints = null) - : base(x => encryptionProvider.Encrypt(x), x => encryptionProvider.Decrypt(x), mappingHints) + public EncryptionConverter( + IEncryptionProvider encryptionProvider, + Expression> convertToProviderExpression, + Expression> convertFromProviderExpression, + ConverterMappingHints mappingHints = null) + : base(convertToProviderExpression, convertFromProviderExpression, mappingHints) { + EncryptionProvider = encryptionProvider; } + + /// + public IEncryptionProvider EncryptionProvider { get; } } } diff --git a/src/EntityFrameworkCore.DataEncryption/Internal/IEncryptionValueConverter.cs b/src/EntityFrameworkCore.DataEncryption/Internal/IEncryptionValueConverter.cs new file mode 100644 index 0000000..f54fa82 --- /dev/null +++ b/src/EntityFrameworkCore.DataEncryption/Internal/IEncryptionValueConverter.cs @@ -0,0 +1,18 @@ +using System; + +namespace Microsoft.EntityFrameworkCore.DataEncryption.Internal +{ + /// + /// Interface for an encryption value converter. + /// + public interface IEncryptionValueConverter + { + /// + /// Returns the encryption provider, if any. + /// + /// + /// The for this converter, if any. + /// + IEncryptionProvider EncryptionProvider { get; } + } +} \ No newline at end of file diff --git a/src/EntityFrameworkCore.DataEncryption/Internal/StandardConverters.cs b/src/EntityFrameworkCore.DataEncryption/Internal/StandardConverters.cs new file mode 100644 index 0000000..0a5df4d --- /dev/null +++ b/src/EntityFrameworkCore.DataEncryption/Internal/StandardConverters.cs @@ -0,0 +1,57 @@ +using System; +using System.IO; +using System.Security; +using System.Text; + +namespace Microsoft.EntityFrameworkCore.DataEncryption.Internal +{ + internal static class StandardConverters + { + internal static Stream BytesToStream(byte[] bytes) => new MemoryStream(bytes); + + internal static byte[] StreamToBytes(Stream stream) + { + if (stream is MemoryStream ms) + { + return ms.ToArray(); + } + + using var output = new MemoryStream(); + stream.CopyTo(output); + return output.ToArray(); + } + + internal static string StreamToBase64String(Stream stream) => Convert.ToBase64String(StreamToBytes(stream)); + + internal static string StreamToString(Stream stream) + { + using var reader = new StreamReader(stream, Encoding.UTF8); + return reader.ReadToEnd().Trim('\0'); + } + + internal static SecureString StreamToSecureString(Stream stream) + { + using var reader = new StreamReader(stream, Encoding.UTF8); + + var result = new SecureString(); + var buffer = new char[100]; + while (!reader.EndOfStream) + { + var charsRead = reader.Read(buffer, 0, buffer.Length); + if (charsRead != 0) + { + for (int index = 0; index < charsRead; index++) + { + char c = buffer[index]; + if (c != '\0') + { + result.AppendChar(c); + } + } + } + } + + return result; + } + } +} \ No newline at end of file diff --git a/src/EntityFrameworkCore.DataEncryption/Migration/EncryptionMigrator.cs b/src/EntityFrameworkCore.DataEncryption/Migration/EncryptionMigrator.cs new file mode 100644 index 0000000..4127cb8 --- /dev/null +++ b/src/EntityFrameworkCore.DataEncryption/Migration/EncryptionMigrator.cs @@ -0,0 +1,208 @@ +using System; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore.DataEncryption.Internal; +using Microsoft.EntityFrameworkCore.DataEncryption.Providers; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.Extensions.Logging; + +namespace Microsoft.EntityFrameworkCore.DataEncryption.Migration +{ + /// + /// Utilities for migrating encrypted data from one provider to another. + /// + /// + /// To migrate from v1 to v2 of : + /// + /// var sourceProvider = new AesProvider(key, iv); + /// var destinationProvider = new AesProvider(key); + /// var migrationProvider = new MigrationEncryptionProvider(sourceProvider, destinationProvider); + /// await using var migrationContext = new DatabaseContext(options, migrationProvider); + /// await migrationContext.MigrateAsync(logger, cancellationToken); + /// + /// + public static class EncryptionMigrator + { + private static readonly MethodInfo SetMethod = typeof(DbContext).GetMethod(nameof(DbContext.Set)); + + private static IQueryable Set(this DbContext context, IEntityType entityType) + { + var method = SetMethod.MakeGenericMethod(entityType.ClrType); + var result = method.Invoke(context, null); + return (IQueryable)result; + } + + /// + /// Migrates the data for a single property to a new encryption provider. + /// + /// + /// The . + /// + /// + /// The to migrate. + /// + /// + /// The to use, if any. + /// + /// + /// The to use, if any. + /// + /// + /// is . + /// -or- + /// is . + /// + public static async Task MigrateAsync(this DbContext context, IProperty property, ILogger logger = default, CancellationToken cancellationToken = default) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (property is null) + { + throw new ArgumentNullException(nameof(property)); + } + + if (property.GetValueConverter() is not IEncryptionValueConverter converter) + { + logger?.LogWarning("Property {Property} on entity type {EntityType} is not using an encryption value converter. ({Converter})", + property.Name, property.DeclaringEntityType.Name, property.GetValueConverter()); + + return; + } + + if (converter.EncryptionProvider is not MigrationEncryptionProvider { IsEmpty: false }) + { + logger?.LogWarning("Property {Property} on entity type {EntityType} is not using a non-empty migration encryption value converter. ({EncryptionProvider})", + property.Name, property.DeclaringEntityType.Name, converter.EncryptionProvider); + + return; + } + + logger?.LogInformation("Loading data for {EntityType} ({Property})...", + property.DeclaringEntityType.Name, property.Name); + + var set = context.Set(property.DeclaringEntityType); + var list = await set.ToListAsync(cancellationToken); + + logger?.LogInformation("Migrating data for {EntityType} :: {Property}} ({RecordCount} records)...", + property.DeclaringEntityType.Name, property.Name, list.Count); + + foreach (var entity in list) + { + context.Entry(entity).Property(property.Name).IsModified = true; + } + + await context.SaveChangesAsync(cancellationToken); + } + + private static async ValueTask MigrateAsyncCore(DbContext context, IEntityType entityType, ILogger logger = default, CancellationToken cancellationToken = default) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (entityType is null) + { + throw new ArgumentNullException(nameof(entityType)); + } + + var encryptedProperties = entityType.GetProperties() + .Select(p => (property: p, encryptionProvider: (p.GetValueConverter() as IEncryptionValueConverter)?.EncryptionProvider)) + .Where(p => p.encryptionProvider is MigrationEncryptionProvider { IsEmpty: false }) + .Select(p => p.property) + .ToList(); + + if (encryptedProperties.Count == 0) + { + logger?.LogDebug("Entity type {EntityType} has no encrypted properties.", entityType.Name); + return; + } + + logger?.LogInformation("Loading data for {EntityType} ({PropertyCount} properties)...", entityType.Name, encryptedProperties.Count); + + var set = context.Set(entityType); + var list = await set.ToListAsync(cancellationToken); + logger?.LogInformation("Migrating data for {EntityType} ({RecordCount} records)...", entityType.Name, list.Count); + + foreach (var entity in list) + { + var entry = context.Entry(entity); + foreach (var property in encryptedProperties) + { + entry.Property(property.Name).IsModified = true; + } + } + } + + /// + /// Migrates the encrypted data for a single entity type to a new encryption provider. + /// + /// + /// The . + /// + /// + /// The to migrate. + /// + /// + /// The to use, if any. + /// + /// + /// The to use, if any. + /// + /// + /// is . + /// -or- + /// is . + /// + public static async Task MigrateAsync(this DbContext context, IEntityType entityType, ILogger logger = default, CancellationToken cancellationToken = default) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (entityType is null) + { + throw new ArgumentNullException(nameof(entityType)); + } + + await MigrateAsyncCore(context, entityType, logger, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } + + /// + /// Migrates the encrypted data for the entire context to a new encryption provider. + /// + /// + /// The . + /// + /// + /// The to use, if any. + /// + /// + /// The to use, if any. + /// + /// + /// is . + /// + public static async Task MigrateAsync(this DbContext context, ILogger logger = default, CancellationToken cancellationToken = default) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + foreach (var entityType in context.Model.GetEntityTypes()) + { + await MigrateAsyncCore(context, entityType, logger, cancellationToken); + } + + await context.SaveChangesAsync(cancellationToken); + } + } +} \ No newline at end of file diff --git a/src/EntityFrameworkCore.DataEncryption/Migration/MigrationEncryptionProvider.cs b/src/EntityFrameworkCore.DataEncryption/Migration/MigrationEncryptionProvider.cs new file mode 100644 index 0000000..e3e62cf --- /dev/null +++ b/src/EntityFrameworkCore.DataEncryption/Migration/MigrationEncryptionProvider.cs @@ -0,0 +1,105 @@ +using System; +using System.IO; + +namespace Microsoft.EntityFrameworkCore.DataEncryption.Migration +{ + /// + /// An encryption provided used for migrating from one encryption scheme to another. + /// + public class MigrationEncryptionProvider : IEncryptionProvider + { + /// + /// Initializes a new instance of the class. + /// + /// The source encryption provider. + /// The destination encryption provider. + public MigrationEncryptionProvider( + IEncryptionProvider sourceEncryptionProvider, + IEncryptionProvider destinationEncryptionProvider) + { + SourceEncryptionProvider = sourceEncryptionProvider; + DestinationEncryptionProvider = destinationEncryptionProvider; + } + + /// + /// Returns the original encryption provider, if any. + /// + /// + /// The original , if any. + /// + public IEncryptionProvider SourceEncryptionProvider { get; } + + /// + /// Returns the new encryption provider, if any. + /// + /// + /// The new , if any. + /// + public IEncryptionProvider DestinationEncryptionProvider { get; } + + /// + /// Returns a flag indicating whether this provider is empty. + /// + /// + /// if this provider is empty; + /// otherwise, . + /// + public bool IsEmpty => SourceEncryptionProvider is null && DestinationEncryptionProvider is null; + + /// + public TModel Decrypt(TStore dataToDecrypt, Func decoder, Func converter) + { + if (decoder is null) + { + throw new ArgumentNullException(nameof(decoder)); + } + + if (converter is null) + { + throw new ArgumentNullException(nameof(converter)); + } + + if (SourceEncryptionProvider is not null) + { + return SourceEncryptionProvider.Decrypt(dataToDecrypt, decoder, converter); + } + + byte[] data = decoder(dataToDecrypt); + if (data is null || data.Length == 0) + { + return default; + } + + using var ms = new MemoryStream(data); + return converter(ms); + } + + /// + public TStore Encrypt(TModel dataToEncrypt, Func converter, Func encoder) + { + if (converter is null) + { + throw new ArgumentNullException(nameof(converter)); + } + + if (encoder is null) + { + throw new ArgumentNullException(nameof(encoder)); + } + + if (DestinationEncryptionProvider is not null) + { + return DestinationEncryptionProvider.Encrypt(dataToEncrypt, converter, encoder); + } + + byte[] data = converter(dataToEncrypt); + if (data is null || data.Length == 0) + { + return default; + } + + using var ms = new MemoryStream(data); + return encoder(ms); + } + } +} \ No newline at end of file diff --git a/src/EntityFrameworkCore.DataEncryption/ModelBuilderExtensions.cs b/src/EntityFrameworkCore.DataEncryption/ModelBuilderExtensions.cs index 1c0f49f..f2ffb30 100644 --- a/src/EntityFrameworkCore.DataEncryption/ModelBuilderExtensions.cs +++ b/src/EntityFrameworkCore.DataEncryption/ModelBuilderExtensions.cs @@ -1,8 +1,11 @@ using Microsoft.EntityFrameworkCore.DataEncryption.Internal; using Microsoft.EntityFrameworkCore.Metadata; using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using System.Linq; +using System.Reflection; +using System.Security; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Microsoft.EntityFrameworkCore.DataEncryption { @@ -12,47 +15,161 @@ namespace Microsoft.EntityFrameworkCore.DataEncryption public static class ModelBuilderExtensions { /// - /// Enables string encryption on this model using an encryption provider. + /// Enables encryption on this model using an encryption provider. /// - /// Current instance. - /// Encryption provider. - public static void UseEncryption(this ModelBuilder modelBuilder, IEncryptionProvider encryptionProvider) + /// + /// The instance. + /// + /// + /// The to use, if any. + /// + /// + /// The updated . + /// + /// + /// is . + /// + public static ModelBuilder UseEncryption(this ModelBuilder modelBuilder, IEncryptionProvider encryptionProvider) { if (modelBuilder is null) { - throw new ArgumentNullException(nameof(modelBuilder), "The given model builder cannot be null"); + throw new ArgumentNullException(nameof(modelBuilder)); } - if (encryptionProvider is null) - { - throw new ArgumentNullException(nameof(encryptionProvider), "Cannot initialize encryption with a null provider."); - } - - var encryptionConverter = new EncryptionConverter(encryptionProvider); + ValueConverter binaryToBinary = null, binaryToString = null; + ValueConverter stringToBinary = null, stringToString = null; + ValueConverter secureStringToBinary = null, secureStringToString = null; + var secureStringProperties = new List<(Type entityType, string propertyName)>(); foreach (IMutableEntityType entityType in modelBuilder.Model.GetEntityTypes()) { foreach (IMutableProperty property in entityType.GetProperties()) { - if (property.ClrType == typeof(string) && !IsDiscriminator(property)) + var (shouldEncrypt, format) = property.ShouldEncrypt(); + if (!shouldEncrypt) + { + continue; + } + + if (property.ClrType == typeof(byte[])) + { + switch (format) + { + case StorageFormat.Base64: + { + binaryToString ??= encryptionProvider.FromBinary().ToBase64().Build(); + property.SetValueConverter(binaryToString); + break; + } + case StorageFormat.Binary: + case StorageFormat.Default: + { + if (encryptionProvider is not null) + { + binaryToBinary ??= encryptionProvider.FromBinary().ToBinary().Build(); + property.SetValueConverter(binaryToBinary); + } + break; + } + default: + { + throw new NotSupportedException($"Storage format {format} is not supported."); + } + } + } + else if (property.ClrType == typeof(string)) + { + switch (format) + { + case StorageFormat.Binary: + { + stringToBinary ??= encryptionProvider.FromString().ToBinary().Build(); + property.SetValueConverter(stringToBinary); + break; + } + case StorageFormat.Base64: + case StorageFormat.Default: + { + if (encryptionProvider is not null) + { + stringToString ??= encryptionProvider.FromString().ToBase64().Build(); + property.SetValueConverter(stringToString); + } + break; + } + default: + { + throw new NotSupportedException($"Storage format {format} is not supported."); + } + } + } + else if (property.ClrType == typeof(SecureString)) + { + switch (format) + { + case StorageFormat.Base64: + { + secureStringToString ??= encryptionProvider.FromSecureString().ToBase64().Build(); + property.SetValueConverter(secureStringToString); + break; + } + case StorageFormat.Binary: + case StorageFormat.Default: + { + secureStringToBinary ??= encryptionProvider.FromSecureString().ToBinary().Build(); + property.SetValueConverter(secureStringToBinary); + break; + } + default: + { + throw new NotSupportedException($"Storage format {format} is not supported."); + } + } + } + } + + // By default, SecureString properties are created as navigation properties, and need to be reconfigured: + foreach (var navigation in entityType.GetNavigations()) + { + if (navigation.ClrType == typeof(SecureString)) { - object[] attributes = property.PropertyInfo.GetCustomAttributes(typeof(EncryptedAttribute), false); + secureStringProperties.Add((entityType.ClrType, navigation.Name)); + } + } + } + + if (secureStringProperties.Count != 0) + { + foreach (var (entityType, propertyName) in secureStringProperties) + { + var property = modelBuilder.Entity(entityType).Property(propertyName); + var attribute = property.Metadata.PropertyInfo?.GetCustomAttribute(false); + var format = attribute?.Format ?? StorageFormat.Default; - if (attributes.Any()) + switch (format) + { + case StorageFormat.Base64: + { + secureStringToString ??= encryptionProvider.FromSecureString().ToBase64().Build(); + property.HasConversion(secureStringToString); + break; + } + case StorageFormat.Binary: + case StorageFormat.Default: + { + secureStringToBinary ??= encryptionProvider.FromSecureString().ToBinary().Build(); + property.HasConversion(secureStringToBinary); + break; + } + default: { - property.SetValueConverter(encryptionConverter); + throw new NotSupportedException($"Storage format {format} is not supported."); } } } } - } - /// - /// Gets a boolean value that indicates if the given property is a descrimitator. - /// - /// - /// - private static bool IsDiscriminator(IMutableProperty property) - => property.Name == "Discriminator" || property.PropertyInfo == null; + return modelBuilder; + } } } diff --git a/src/EntityFrameworkCore.DataEncryption/ModelExtensions.cs b/src/EntityFrameworkCore.DataEncryption/ModelExtensions.cs new file mode 100644 index 0000000..8417479 --- /dev/null +++ b/src/EntityFrameworkCore.DataEncryption/ModelExtensions.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Reflection; +using System.Security; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Internal; + +namespace Microsoft.EntityFrameworkCore.DataEncryption +{ + /// + /// Extension methods for EF models. + /// + public static class ModelExtensions + { + /// + /// Returns a value indicating whether the specified property should be encrypted. + /// + /// + /// The . + /// + /// + /// A value indicating whether the specified property should be encrypted, + /// and how the encrypted value should be stored. + /// + /// + /// is . + /// + public static (bool shouldEncrypt, StorageFormat format) ShouldEncrypt(this IProperty property) + { + if (property is null) + { + throw new ArgumentNullException(nameof(property)); + } + + var attribute = property.PropertyInfo?.GetCustomAttribute(false); + if (property.ClrType == typeof(SecureString)) + { + return (true, attribute?.Format ?? StorageFormat.Binary); + } + + return attribute is null ? (false, StorageFormat.Default) : (true, attribute.Format); + } + + /// + /// Returns a value indicating whether the specified property should be encrypted. + /// + /// + /// The . + /// + /// + /// A value indicating whether the specified property should be encrypted, + /// and how the encrypted value should be stored. + /// + /// + /// is . + /// + public static (bool shouldEncrypt, StorageFormat format) ShouldEncrypt(this IMutableProperty property) + { + if (property is null) + { + throw new ArgumentNullException(nameof(property)); + } + +#pragma warning disable EF1001 // Internal EF Core API usage. + if (property.FindAnnotation(CoreAnnotationNames.ValueConverter) is not null) + { + return (false, StorageFormat.Default); + } +#pragma warning restore EF1001 // Internal EF Core API usage. + + var attribute = property.PropertyInfo?.GetCustomAttribute(false); + if (property.ClrType == typeof(SecureString)) + { + return (true, attribute?.Format ?? StorageFormat.Binary); + } + + return attribute is null ? (false, StorageFormat.Default) : (true, attribute.Format); + } + + /// + /// Returns the list of encrypted properties for the specified entity type. + /// + /// + /// The . + /// + /// + /// A list of the properties for the specified type which should be encrypted. + /// + /// + /// is . + /// + public static IReadOnlyList<(IProperty property, StorageFormat format)> ListEncryptedProperties(this IEntityType entityType) + { + if (entityType is null) + { + throw new ArgumentNullException(nameof(entityType)); + } + + return entityType.GetProperties() + .Select(p => (property: p, flag: p.ShouldEncrypt())) + .Where(p => p.flag.shouldEncrypt) + .Select(p => (p.property, p.flag.format)).ToList(); + } + } +} \ No newline at end of file diff --git a/src/EntityFrameworkCore.DataEncryption/PropertyBuilderExtensions.cs b/src/EntityFrameworkCore.DataEncryption/PropertyBuilderExtensions.cs new file mode 100644 index 0000000..79277fd --- /dev/null +++ b/src/EntityFrameworkCore.DataEncryption/PropertyBuilderExtensions.cs @@ -0,0 +1,147 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Security; +using Microsoft.EntityFrameworkCore.DataEncryption.Internal; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace Microsoft.EntityFrameworkCore.DataEncryption +{ + /// + /// Provides extensions for the . + /// + public static class PropertyBuilderExtensions + { + /// + /// Configures the property as capable of storing encrypted data. + /// + /// + /// The . + /// + /// + /// The to use, if any. + /// + /// + /// One of the values indicating how the value should be stored in the database. + /// + /// + /// The to use, if any. + /// + /// + /// The updated . + /// + /// + /// is . + /// + /// + /// is not a recognised value. + /// + public static PropertyBuilder IsEncrypted( + this PropertyBuilder property, + IEncryptionProvider encryptionProvider, + StorageFormat format = StorageFormat.Default, + ConverterMappingHints mappingHints = null) + { + if (property is null) + { + throw new ArgumentNullException(nameof(property)); + } + + return format switch + { + StorageFormat.Default => encryptionProvider is null ? property : property.HasConversion(encryptionProvider.FromBinary().ToBinary().Build(mappingHints)), + StorageFormat.Binary => encryptionProvider is null ? property : property.HasConversion(encryptionProvider.FromBinary().ToBinary().Build(mappingHints)), + StorageFormat.Base64 => property.HasConversion(encryptionProvider.FromBinary().ToBase64().Build(mappingHints)), + _ => throw new ArgumentOutOfRangeException(nameof(format)), + }; + } + + /// + /// Configures the property as capable of storing encrypted data. + /// + /// + /// The . + /// + /// + /// The to use, if any. + /// + /// + /// One of the values indicating how the value should be stored in the database. + /// + /// + /// The to use, if any. + /// + /// + /// The updated . + /// + /// + /// is . + /// + /// + /// is not a recognised value. + /// + public static PropertyBuilder IsEncrypted( + this PropertyBuilder property, + IEncryptionProvider encryptionProvider, + StorageFormat format = StorageFormat.Default, + ConverterMappingHints mappingHints = null) + { + if (property is null) + { + throw new ArgumentNullException(nameof(property)); + } + + return format switch + { + StorageFormat.Default => encryptionProvider is null ? property : property.HasConversion(encryptionProvider.FromString().ToBase64().Build(mappingHints)), + StorageFormat.Base64 => encryptionProvider is null ? property : property.HasConversion(encryptionProvider.FromString().ToBase64().Build(mappingHints)), + StorageFormat.Binary => property.HasConversion(encryptionProvider.FromString().ToBinary().Build(mappingHints)), + _ => throw new ArgumentOutOfRangeException(nameof(format)), + }; + } + + /// + /// Configures the property as capable of storing encrypted data. + /// + /// + /// The . + /// + /// + /// The to use, if any. + /// + /// + /// One of the values indicating how the value should be stored in the database. + /// + /// + /// The to use, if any. + /// + /// + /// The updated . + /// + /// + /// is . + /// + /// + /// is not a recognised value. + /// + public static PropertyBuilder IsEncrypted( + this PropertyBuilder property, + IEncryptionProvider encryptionProvider, + StorageFormat format = StorageFormat.Default, + ConverterMappingHints mappingHints = null) + { + if (property is null) + { + throw new ArgumentNullException(nameof(property)); + } + + return format switch + { + StorageFormat.Default => property.HasConversion(encryptionProvider.FromSecureString().ToBinary().Build(mappingHints)), + StorageFormat.Binary => property.HasConversion(encryptionProvider.FromSecureString().ToBinary().Build(mappingHints)), + StorageFormat.Base64 => property.HasConversion(encryptionProvider.FromSecureString().ToBase64().Build(mappingHints)), + _ => throw new ArgumentOutOfRangeException(nameof(format)), + }; + } + } +} \ No newline at end of file diff --git a/src/EntityFrameworkCore.DataEncryption/Providers/AesProvider.cs b/src/EntityFrameworkCore.DataEncryption/Providers/AesProvider.cs index 6dcf1ed..4a0565b 100644 --- a/src/EntityFrameworkCore.DataEncryption/Providers/AesProvider.cs +++ b/src/EntityFrameworkCore.DataEncryption/Providers/AesProvider.cs @@ -1,7 +1,6 @@ using System; using System.IO; using System.Security.Cryptography; -using System.Text; namespace Microsoft.EntityFrameworkCore.DataEncryption.Providers { @@ -23,13 +22,14 @@ public class AesProvider : IEncryptionProvider private readonly byte[] _key; private readonly CipherMode _mode; private readonly PaddingMode _padding; + private readonly byte[] _iv; /// - /// Creates a new instance used to perform symetric encryption and decryption on strings. + /// Creates a new instance used to perform symmetric encryption and decryption on strings. /// - /// AES key used for the symetric encryption. - /// Mode for operation used in the symetric encryption. - /// Padding mode used in the symetric encryption. + /// AES key used for the symmetric encryption. + /// Mode for operation used in the symmetric encryption. + /// Padding mode used in the symmetric encryption. public AesProvider(byte[] key, CipherMode mode = CipherMode.CBC, PaddingMode padding = PaddingMode.PKCS7) { _key = key; @@ -38,75 +38,90 @@ public AesProvider(byte[] key, CipherMode mode = CipherMode.CBC, PaddingMode pad } /// - /// Creates a new instance used to perform symetric encryption and decryption on strings. + /// Creates a new instance used to perform symmetric encryption and decryption on strings. /// - /// AES key used for the symetric encryption. - /// AES Initialization Vector used for the symetric encryption. - /// Mode for operation used in the symetric encryption. - /// Padding mode used in the symetric encryption. - [Obsolete("This constructor has been deprecated and will be removed in future versions. Please use the AesProvider(byte[], CipherMode, PaddingMode) constructor instead.")] - public AesProvider(byte[] key, byte[] initializationVector, CipherMode mode = CipherMode.CBC, PaddingMode padding = PaddingMode.PKCS7) - : this(key, mode, padding) + /// AES key used for the symmetric encryption. + /// AES Initialization Vector used for the symmetric encryption. + /// Mode for operation used in the symmetric encryption. + /// Padding mode used in the symmetric encryption. + public AesProvider(byte[] key, byte[] initializationVector, CipherMode mode = CipherMode.CBC, PaddingMode padding = PaddingMode.PKCS7) : this(key, mode, padding) { + // Re-enabled to allow for a static IV. + // This reduces security, but allows for encrypted values to be searched using LINQ. + _iv = initializationVector; } - /// - /// Encrypt a string using the AES algorithm. - /// - /// - /// - public string Encrypt(string dataToEncrypt) + /// + public TStore Encrypt(TModel dataToEncrypt, Func converter, Func encoder) { - byte[] input = Encoding.UTF8.GetBytes(dataToEncrypt); - byte[] encrypted = null; + if (converter is null) + { + throw new ArgumentNullException(nameof(converter)); + } - using (AesCryptoServiceProvider cryptoServiceProvider = CreateCryptographyProvider()) + if (encoder is null) { - cryptoServiceProvider.GenerateIV(); + throw new ArgumentNullException(nameof(encoder)); + } - byte[] initializationVector = cryptoServiceProvider.IV; + byte[] data = converter(dataToEncrypt); + if (data is null || data.Length == 0) + { + return default; + } - using ICryptoTransform encryptor = cryptoServiceProvider.CreateEncryptor(_key, initializationVector); - using var memoryStream = new MemoryStream(); - using (var cryptoStream = new CryptoStream(memoryStream, encryptor, CryptoStreamMode.Write)) - { - memoryStream.Write(initializationVector, 0, initializationVector.Length); - cryptoStream.Write(input, 0, input.Length); - cryptoStream.FlushFinalBlock(); - } + using var aes = CreateCryptographyProvider(); + using var memoryStream = new MemoryStream(); - encrypted = memoryStream.ToArray(); + byte[] initializationVector = _iv; + if (initializationVector is null) + { + aes.GenerateIV(); + initializationVector = aes.IV; + memoryStream.Write(initializationVector, 0, initializationVector.Length); } - return Convert.ToBase64String(encrypted); + using var transform = aes.CreateEncryptor(_key, initializationVector); + using var crypto = new CryptoStream(memoryStream, transform, CryptoStreamMode.Write); + crypto.Write(data, 0, data.Length); + crypto.FlushFinalBlock(); + + memoryStream.Seek(0L, SeekOrigin.Begin); + return encoder(memoryStream); } - /// - /// Decrypt a string using the AES algorithm. - /// - /// - /// - public string Decrypt(string dataToDecrypt) + /// + public TModel Decrypt(TStore dataToDecrypt, Func decoder, Func converter) { - byte[] input = Convert.FromBase64String(dataToDecrypt); - - string decrypted = string.Empty; + if (decoder is null) + { + throw new ArgumentNullException(nameof(decoder)); + } - using (var memoryStream = new MemoryStream(input)) + if (converter is null) { - var initializationVector = new byte[InitializationVectorSize]; + throw new ArgumentNullException(nameof(converter)); + } - memoryStream.Read(initializationVector, 0, initializationVector.Length); + byte[] data = decoder(dataToDecrypt); + if (data is null || data.Length == 0) + { + return default; + } - using AesCryptoServiceProvider cryptoServiceProvider = CreateCryptographyProvider(); - using ICryptoTransform cryptoTransform = cryptoServiceProvider.CreateDecryptor(_key, initializationVector); - using var crypto = new CryptoStream(memoryStream, cryptoTransform, CryptoStreamMode.Read); - using var reader = new StreamReader(crypto); + using var memoryStream = new MemoryStream(data); - decrypted = reader.ReadToEnd().Trim('\0'); + byte[] initializationVector = _iv; + if (initializationVector is null) + { + initializationVector = new byte[InitializationVectorSize]; + memoryStream.Read(initializationVector, 0, initializationVector.Length); } - return decrypted; + using var aes = CreateCryptographyProvider(); + using var transform = aes.CreateDecryptor(_key, initializationVector); + using var crypto = new CryptoStream(memoryStream, transform, CryptoStreamMode.Read); + return converter(crypto); } /// diff --git a/test/EntityFrameworkCore.DataEncryption.Test/Context/AuthorEntity.cs b/test/EntityFrameworkCore.DataEncryption.Test/Context/AuthorEntity.cs index 7c1243b..1361cef 100644 --- a/test/EntityFrameworkCore.DataEncryption.Test/Context/AuthorEntity.cs +++ b/test/EntityFrameworkCore.DataEncryption.Test/Context/AuthorEntity.cs @@ -1,6 +1,8 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using System.Security; namespace Microsoft.EntityFrameworkCore.DataEncryption.Test.Context { @@ -10,10 +12,12 @@ public sealed class AuthorEntity [DatabaseGenerated(DatabaseGeneratedOption.Identity)] public int Id { get; set; } + public Guid UniqueId { get; set; } + [Required] [Encrypted] public string FirstName { get; set; } - + [Required] [Encrypted] public string LastName { get; set; } @@ -21,6 +25,8 @@ public sealed class AuthorEntity [Required] public int Age { get; set; } + public SecureString Password { get; set; } + public IList Books { get; set; } public AuthorEntity(string firstName, string lastName, int age) @@ -29,6 +35,7 @@ public AuthorEntity(string firstName, string lastName, int age) LastName = lastName; Age = age; Books = new List(); + UniqueId = Guid.NewGuid(); } } } diff --git a/test/EntityFrameworkCore.DataEncryption.Test/Context/BookEntity.cs b/test/EntityFrameworkCore.DataEncryption.Test/Context/BookEntity.cs index 5e2b0c3..0622d27 100644 --- a/test/EntityFrameworkCore.DataEncryption.Test/Context/BookEntity.cs +++ b/test/EntityFrameworkCore.DataEncryption.Test/Context/BookEntity.cs @@ -1,4 +1,5 @@ -using System.ComponentModel.DataAnnotations; +using System; +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; namespace Microsoft.EntityFrameworkCore.DataEncryption.Test.Context @@ -9,6 +10,8 @@ public sealed class BookEntity [DatabaseGenerated(DatabaseGeneratedOption.Identity)] public int Id { get; set; } + public Guid UniqueId { get; set; } + [Required] [Encrypted] public string Name { get; set; } @@ -26,6 +29,7 @@ public BookEntity(string name, int numberOfPages) { Name = name; NumberOfPages = numberOfPages; + UniqueId = Guid.NewGuid(); } } } diff --git a/test/EntityFrameworkCore.DataEncryption.Test/Context/DatabaseContextFactory.cs b/test/EntityFrameworkCore.DataEncryption.Test/Context/DatabaseContextFactory.cs index c6fb576..eefb329 100644 --- a/test/EntityFrameworkCore.DataEncryption.Test/Context/DatabaseContextFactory.cs +++ b/test/EntityFrameworkCore.DataEncryption.Test/Context/DatabaseContextFactory.cs @@ -9,15 +9,16 @@ namespace Microsoft.EntityFrameworkCore.DataEncryption.Test.Context /// public sealed class DatabaseContextFactory : IDisposable { - private const string DatabaseConnectionString = "DataSource=:memory:"; + private const string InMemoryDatabaseConnectionString = "DataSource=:memory:"; + private const string DatabaseConnectionString = "DataSource={0}"; private readonly DbConnection _connection; /// /// Creates a new instance. /// - public DatabaseContextFactory() + public DatabaseContextFactory(string databaseName = null) { - _connection = new SqliteConnection(DatabaseConnectionString); + _connection = new SqliteConnection(string.IsNullOrEmpty(databaseName) ? InMemoryDatabaseConnectionString : DatabaseConnectionString.Replace("{0}", databaseName)); _connection.Open(); } @@ -41,7 +42,7 @@ public TContext CreateContext(IEncryptionProvider provider = null) whe /// /// /// - private DbContextOptions CreateOptions() where TContext : DbContext + public DbContextOptions CreateOptions() where TContext : DbContext => new DbContextOptionsBuilder().UseSqlite(_connection).Options; /// @@ -49,10 +50,7 @@ private DbContextOptions CreateOptions() where TContext : Db /// public void Dispose() { - if (_connection != null) - { - _connection.Dispose(); - } + _connection?.Dispose(); } } } diff --git a/test/EntityFrameworkCore.DataEncryption.Test/EntityFrameworkCore.DataEncryption.Test.csproj b/test/EntityFrameworkCore.DataEncryption.Test/EntityFrameworkCore.DataEncryption.Test.csproj index c440aa1..551ba52 100644 --- a/test/EntityFrameworkCore.DataEncryption.Test/EntityFrameworkCore.DataEncryption.Test.csproj +++ b/test/EntityFrameworkCore.DataEncryption.Test/EntityFrameworkCore.DataEncryption.Test.csproj @@ -1,30 +1,31 @@  - net5.0 - false - Microsoft.EntityFrameworkCore.Encryption.Test - Microsoft.EntityFrameworkCore.Encryption.Test + net5.0 + false + Microsoft.EntityFrameworkCore.Encryption.Test + Microsoft.EntityFrameworkCore.Encryption.Test - - all - runtime; build; native; contentfiles; analyzers - - - - - - - all - runtime; build; native; contentfiles; analyzers - - + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + all + runtime; build; native; contentfiles; analyzers + + - + diff --git a/test/EntityFrameworkCore.DataEncryption.Test/Helpers/DataHelper.cs b/test/EntityFrameworkCore.DataEncryption.Test/Helpers/DataHelper.cs new file mode 100644 index 0000000..4ac3ba1 --- /dev/null +++ b/test/EntityFrameworkCore.DataEncryption.Test/Helpers/DataHelper.cs @@ -0,0 +1,42 @@ +using System; +using System.Security; + +namespace Microsoft.EntityFrameworkCore.DataEncryption.Test.Helpers +{ + public static class DataHelper + { + private static readonly string Characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + private static readonly Random Randomizer = new(); + + public static byte[] RandomBytes(int length) + { + var result = new byte[length]; + Randomizer.NextBytes(result); + return result; + } + + public static string RandomString(int length) + { + var result = new char[length]; + for (int i = 0; i < length; i++) + { + char c = Characters[Randomizer.Next(Characters.Length)]; + result[i] = c; + } + + return new(result); + } + + public static SecureString RandomSecureString(int length) + { + var result = new SecureString(); + for (int i = 0; i < length; i++) + { + char c = Characters[Randomizer.Next(Characters.Length)]; + result.AppendChar(c); + } + + return result; + } + } +} diff --git a/test/EntityFrameworkCore.DataEncryption.Test/Helpers/StringHelper.cs b/test/EntityFrameworkCore.DataEncryption.Test/Helpers/StringHelper.cs deleted file mode 100644 index a07173d..0000000 --- a/test/EntityFrameworkCore.DataEncryption.Test/Helpers/StringHelper.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; - -namespace Microsoft.EntityFrameworkCore.DataEncryption.Test.Helpers -{ - public static class StringHelper - { - private static readonly string Characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - private static readonly Random Randomizer = new Random(); - - /// - /// Generates a new string. - /// - /// - /// - public static string RandomString(int length) - => new string(Enumerable.Repeat(Characters, length).Select(s => s[Randomizer.Next(s.Length)]).ToArray()); - } -} diff --git a/test/EntityFrameworkCore.DataEncryption.Test/Migration/EncryptedToOriginalMigratorTest.cs b/test/EntityFrameworkCore.DataEncryption.Test/Migration/EncryptedToOriginalMigratorTest.cs new file mode 100644 index 0000000..12d5aff --- /dev/null +++ b/test/EntityFrameworkCore.DataEncryption.Test/Migration/EncryptedToOriginalMigratorTest.cs @@ -0,0 +1,20 @@ +using System; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore.DataEncryption.Migration; +using Microsoft.EntityFrameworkCore.DataEncryption.Providers; +using Xunit; + +namespace Microsoft.EntityFrameworkCore.Encryption.Test.Migration +{ + public class EncryptedToOriginalMigratorTest : MigratorBaseTest + { + [Fact] + public async Task MigrateEncryptedToOriginalTest() + { + var aesKeys = AesProvider.GenerateKey(AesKeySize.AES256Bits); + var sourceProvider = new AesProvider(aesKeys.Key); + var provider = new MigrationEncryptionProvider(sourceProvider, null); + await Execute(provider); + } + } +} \ No newline at end of file diff --git a/test/EntityFrameworkCore.DataEncryption.Test/Migration/MigratorBaseTest.cs b/test/EntityFrameworkCore.DataEncryption.Test/Migration/MigratorBaseTest.cs new file mode 100644 index 0000000..7354fd4 --- /dev/null +++ b/test/EntityFrameworkCore.DataEncryption.Test/Migration/MigratorBaseTest.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Bogus; +using Microsoft.EntityFrameworkCore.DataEncryption.Migration; +using Microsoft.EntityFrameworkCore.DataEncryption.Test.Context; +using Xunit; + +namespace Microsoft.EntityFrameworkCore.Encryption.Test.Migration +{ + public abstract class MigratorBaseTest + { + private IEnumerable Authors { get; } + + protected MigratorBaseTest() + { + var faker = new Faker(); + Authors = Enumerable.Range(0, faker.Random.Byte()) + .Select(_ => new AuthorEntity(faker.Name.FirstName(), faker.Name.LastName(), faker.Random.Int(0, 90)) + { + Books = Enumerable.Range(0, 10).Select(_ => new BookEntity(faker.Lorem.Sentence(), faker.Random.Int(100, 500))).ToList() + }).ToList(); + } + + private static void AssertAuthor(AuthorEntity expected, AuthorEntity actual) + { + Assert.NotNull(actual); + Assert.Equal(expected.FirstName, actual.FirstName); + Assert.Equal(expected.LastName, actual.LastName); + Assert.Equal(expected.Age, actual.Age); + Assert.Equal(expected.Books.Count, actual.Books.Count); + + foreach (BookEntity actualBook in expected.Books) + { + BookEntity expectedBook = actual.Books.FirstOrDefault(x => x.UniqueId == actualBook.UniqueId); + + Assert.NotNull(expectedBook); + Assert.Equal(expectedBook.Name, actualBook.Name); + Assert.Equal(expectedBook.NumberOfPages, actualBook.NumberOfPages); + } + } + + protected async Task Execute(MigrationEncryptionProvider provider) + { + string databaseName = Guid.NewGuid().ToString(); + + // Feed database with data. + using (var contextFactory = new DatabaseContextFactory(databaseName)) + { + await using var context = contextFactory.CreateContext(provider.SourceEncryptionProvider); + await context.Authors.AddRangeAsync(Authors); + await context.SaveChangesAsync(); + } + + // Process data migration + using (var contextFactory = new DatabaseContextFactory(databaseName)) + { + await using var context = contextFactory.CreateContext(provider); + await context.MigrateAsync(); + } + + // Assert if the context has been decrypted + using (var contextFactory = new DatabaseContextFactory(databaseName)) + { + await using var context = contextFactory.CreateContext(provider.DestinationEncryptionProvider); + IEnumerable authors = await context.Authors.Include(x => x.Books).ToListAsync(); + + foreach (AuthorEntity author in authors) + { + AuthorEntity original = Authors.FirstOrDefault(x => x.UniqueId == author.UniqueId); + AssertAuthor(original, author); + } + } + } + } +} \ No newline at end of file diff --git a/test/EntityFrameworkCore.DataEncryption.Test/Migration/OriginalToEncryptedMigratorTest.cs b/test/EntityFrameworkCore.DataEncryption.Test/Migration/OriginalToEncryptedMigratorTest.cs new file mode 100644 index 0000000..6fe6d7e --- /dev/null +++ b/test/EntityFrameworkCore.DataEncryption.Test/Migration/OriginalToEncryptedMigratorTest.cs @@ -0,0 +1,20 @@ +using System; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore.DataEncryption.Migration; +using Microsoft.EntityFrameworkCore.DataEncryption.Providers; +using Xunit; + +namespace Microsoft.EntityFrameworkCore.Encryption.Test.Migration +{ + public class OriginalToEncryptedMigratorTest : MigratorBaseTest + { + [Fact] + public async Task MigrateOriginalToEncryptedTest() + { + var aesKeys = AesProvider.GenerateKey(AesKeySize.AES256Bits); + var destinationProvider = new AesProvider(aesKeys.Key); + var provider = new MigrationEncryptionProvider(null, destinationProvider); + await Execute(provider); + } + } +} \ No newline at end of file diff --git a/test/EntityFrameworkCore.DataEncryption.Test/Migration/V1ToV2MigratorTest.cs b/test/EntityFrameworkCore.DataEncryption.Test/Migration/V1ToV2MigratorTest.cs new file mode 100644 index 0000000..5f8dceb --- /dev/null +++ b/test/EntityFrameworkCore.DataEncryption.Test/Migration/V1ToV2MigratorTest.cs @@ -0,0 +1,21 @@ +using System; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore.DataEncryption.Migration; +using Microsoft.EntityFrameworkCore.DataEncryption.Providers; +using Xunit; + +namespace Microsoft.EntityFrameworkCore.Encryption.Test.Migration +{ + public class V1ToV2MigratorTest : MigratorBaseTest + { + [Fact] + public async Task MigrateV1ToV2Test() + { + var aesKeys = AesProvider.GenerateKey(AesKeySize.AES256Bits); + var sourceProvider = new AesProvider(aesKeys.Key, aesKeys.IV); + var destinationProvider = new AesProvider(aesKeys.Key); + var provider = new MigrationEncryptionProvider(sourceProvider, destinationProvider); + await Execute(provider); + } + } +} \ No newline at end of file diff --git a/test/EntityFrameworkCore.DataEncryption.Test/Providers/AesProviderTest.cs b/test/EntityFrameworkCore.DataEncryption.Test/Providers/AesProviderTest.cs index 64331b3..b464d38 100644 --- a/test/EntityFrameworkCore.DataEncryption.Test/Providers/AesProviderTest.cs +++ b/test/EntityFrameworkCore.DataEncryption.Test/Providers/AesProviderTest.cs @@ -4,32 +4,76 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Security; using System.Security.Cryptography; +using System.Text; +using Microsoft.EntityFrameworkCore.DataEncryption.Internal; using Xunit; namespace Microsoft.EntityFrameworkCore.DataEncryption.Test.Providers { public class AesProviderTest { + [Theory] + [InlineData(AesKeySize.AES128Bits)] + [InlineData(AesKeySize.AES192Bits)] + [InlineData(AesKeySize.AES256Bits)] + public void EncryptDecryptByteArrayTest(AesKeySize keySize) + { + byte[] input = DataHelper.RandomBytes(20); + AesKeyInfo encryptionKeyInfo = AesProvider.GenerateKey(keySize); + var provider = new AesProvider(encryptionKeyInfo.Key); + + byte[] encryptedData = provider.Encrypt(input, b => b, StandardConverters.StreamToBytes); + Assert.NotNull(encryptedData); + + byte[] decryptedData = provider.Decrypt(encryptedData, b => b, StandardConverters.StreamToBytes); + Assert.NotNull(decryptedData); + + Assert.Equal(input, decryptedData); + } + [Theory] [InlineData(AesKeySize.AES128Bits)] [InlineData(AesKeySize.AES192Bits)] [InlineData(AesKeySize.AES256Bits)] public void EncryptDecryptStringTest(AesKeySize keySize) { - string input = StringHelper.RandomString(20); + string input = DataHelper.RandomString(20); AesKeyInfo encryptionKeyInfo = AesProvider.GenerateKey(keySize); var provider = new AesProvider(encryptionKeyInfo.Key); - string encryptedData = provider.Encrypt(input); + string encryptedData = provider.Encrypt(input, Encoding.UTF8.GetBytes, StandardConverters.StreamToBase64String); Assert.NotNull(encryptedData); - string decryptedData = provider.Decrypt(encryptedData); + string decryptedData = provider.Decrypt(encryptedData, Convert.FromBase64String, StandardConverters.StreamToString); Assert.NotNull(decryptedData); Assert.Equal(input, decryptedData); } + [Theory] + [InlineData(AesKeySize.AES128Bits)] + [InlineData(AesKeySize.AES192Bits)] + [InlineData(AesKeySize.AES256Bits)] + public void EncryptDecryptSecureStringTest(AesKeySize keySize) + { + SecureString input = DataHelper.RandomSecureString(20); + AesKeyInfo encryptionKeyInfo = AesProvider.GenerateKey(keySize); + var provider = new AesProvider(encryptionKeyInfo.Key); + + string encryptedData = provider.Encrypt(input, Encoding.UTF8.GetBytes, StandardConverters.StreamToBase64String); + Assert.NotNull(encryptedData); + + SecureString decryptedData = provider.Decrypt(encryptedData, Convert.FromBase64String, StandardConverters.StreamToSecureString); + Assert.NotNull(decryptedData); + + byte[] inputBytes = Encoding.UTF8.GetBytes(input); + byte[] decryptedBytes = Encoding.UTF8.GetBytes(decryptedData); + + Assert.Equal(inputBytes, decryptedBytes); + } + [Theory] [InlineData(AesKeySize.AES128Bits)] [InlineData(AesKeySize.AES192Bits)] @@ -67,10 +111,9 @@ public void CompareTwoAesKeysInstancesTest(AesKeySize keySize) [Fact] public void CreateDataContextWithoutProvider() { - using (var contextFactory = new DatabaseContextFactory()) - { - Assert.Throws(() => contextFactory.CreateContext()); - } + using var contextFactory = new DatabaseContextFactory(); + using var context = contextFactory.CreateContext(); + Assert.NotNull(context); } [Fact] @@ -91,42 +134,42 @@ public void EncryptUsingAes256Provider() ExecuteAesEncryptionTest(AesKeySize.AES256Bits); } - private void ExecuteAesEncryptionTest(AesKeySize aesKeyType) where TContext : DatabaseContext + private static void ExecuteAesEncryptionTest(AesKeySize aesKeyType) where TContext : DatabaseContext { AesKeyInfo encryptionKeyInfo = AesProvider.GenerateKey(aesKeyType); var provider = new AesProvider(encryptionKeyInfo.Key, CipherMode.CBC, PaddingMode.Zeros); var author = new AuthorEntity("John", "Doe", 42) { - Books = new List() + Password = DataHelper.RandomSecureString(10), + Books = new List { - new BookEntity("Lorem Ipsum", 300), - new BookEntity("Dolor sit amet", 390) + new("Lorem Ipsum", 300), + new("Dolor sit amet", 390) } }; - using (var contextFactory = new DatabaseContextFactory()) + using var contextFactory = new DatabaseContextFactory(); + + // Save data to an encrypted database context + using (var dbContext = contextFactory.CreateContext(provider)) { - // Save data to an encrypted database context - using (var dbContext = contextFactory.CreateContext(provider)) - { - dbContext.Authors.Add(author); - dbContext.SaveChanges(); - } + dbContext.Authors.Add(author); + dbContext.SaveChanges(); + } - // Read decrypted data and compare with original data - using (var dbContext = contextFactory.CreateContext(provider)) - { - var authorFromDb = dbContext.Authors.Include(x => x.Books).FirstOrDefault(); - - Assert.NotNull(authorFromDb); - Assert.Equal(author.FirstName, authorFromDb.FirstName); - Assert.Equal(author.LastName, authorFromDb.LastName); - Assert.NotNull(authorFromDb.Books); - Assert.NotEmpty(authorFromDb.Books); - Assert.Equal(2, authorFromDb.Books.Count); - Assert.Equal(author.Books.First().Name, authorFromDb.Books.First().Name); - Assert.Equal(author.Books.Last().Name, authorFromDb.Books.Last().Name); - } + // Read decrypted data and compare with original data + using (var dbContext = contextFactory.CreateContext(provider)) + { + var authorFromDb = dbContext.Authors.Include(x => x.Books).FirstOrDefault(); + + Assert.NotNull(authorFromDb); + Assert.Equal(author.FirstName, authorFromDb.FirstName); + Assert.Equal(author.LastName, authorFromDb.LastName); + Assert.NotNull(authorFromDb.Books); + Assert.NotEmpty(authorFromDb.Books); + Assert.Equal(2, authorFromDb.Books.Count); + Assert.Equal(author.Books.First().Name, authorFromDb.Books.First().Name); + Assert.Equal(author.Books.Last().Name, authorFromDb.Books.Last().Name); } }