diff --git a/src/Microsoft.AspNetCore.DataProtection.Extensions/DataProtectionProvider.cs b/src/Microsoft.AspNetCore.DataProtection.Extensions/DataProtectionProvider.cs index cedcc2bded..58972aa4d9 100644 --- a/src/Microsoft.AspNetCore.DataProtection.Extensions/DataProtectionProvider.cs +++ b/src/Microsoft.AspNetCore.DataProtection.Extensions/DataProtectionProvider.cs @@ -3,6 +3,7 @@ using System; using System.IO; +using System.Security.Cryptography.X509Certificates; using Microsoft.Extensions.DependencyInjection; namespace Microsoft.AspNetCore.DataProtection @@ -14,6 +15,25 @@ namespace Microsoft.AspNetCore.DataProtection /// Use these methods when not using dependency injection to provide the service to the application. public static class DataProtectionProvider { + /// + /// Creates a that store keys in a location based on + /// the platform and operating system. + /// + /// An identifier that uniquely discriminates this application from all other + /// applications on the machine. + public static IDataProtectionProvider Create(string applicationName) + { + if (string.IsNullOrEmpty(applicationName)) + { + throw new ArgumentNullException(nameof(applicationName)); + } + + return CreateProvider( + keyDirectory: null, + setupAction: builder => { builder.SetApplicationName(applicationName); }, + certificate: null); + } + /// /// Creates an given a location at which to store keys. /// @@ -21,7 +41,12 @@ namespace Microsoft.AspNetCore.DataProtection /// represent a directory on a local disk or a UNC share. public static IDataProtectionProvider Create(DirectoryInfo keyDirectory) { - return Create(keyDirectory, setupAction: builder => { }); + if (keyDirectory == null) + { + throw new ArgumentNullException(nameof(keyDirectory)); + } + + return CreateProvider(keyDirectory, setupAction: builder => { }, certificate: null); } /// @@ -40,22 +65,116 @@ namespace Microsoft.AspNetCore.DataProtection { throw new ArgumentNullException(nameof(keyDirectory)); } - if (setupAction == null) { throw new ArgumentNullException(nameof(setupAction)); } + return CreateProvider(keyDirectory, setupAction, certificate: null); + } + +#if !NETSTANDARD1_3 // [[ISSUE60]] Remove this #ifdef when Core CLR gets support for EncryptedXml + /// + /// Creates a that store keys in a location based on + /// the platform and operating system and uses the given to encrypt the keys. + /// + /// An identifier that uniquely discriminates this application from all other + /// applications on the machine. + /// The to be used for encryption. + public static IDataProtectionProvider Create(string applicationName, X509Certificate2 certificate) + { + if (string.IsNullOrEmpty(applicationName)) + { + throw new ArgumentNullException(nameof(applicationName)); + } + if (certificate == null) + { + throw new ArgumentNullException(nameof(certificate)); + } + + return CreateProvider( + keyDirectory: null, + setupAction: builder => { builder.SetApplicationName(applicationName); }, + certificate: certificate); + } + + /// + /// Creates an given a location at which to store keys + /// and a used to encrypt the keys. + /// + /// The in which keys should be stored. This may + /// represent a directory on a local disk or a UNC share. + /// The to be used for encryption. + public static IDataProtectionProvider Create( + DirectoryInfo keyDirectory, + X509Certificate2 certificate) + { + if (keyDirectory == null) + { + throw new ArgumentNullException(nameof(keyDirectory)); + } + if (certificate == null) + { + throw new ArgumentNullException(nameof(certificate)); + } + + return CreateProvider(keyDirectory, setupAction: builder => { }, certificate: certificate); + } + + /// + /// Creates an given a location at which to store keys, an + /// optional configuration callback and a used to encrypt the keys. + /// + /// The in which keys should be stored. This may + /// represent a directory on a local disk or a UNC share. + /// An optional callback which provides further configuration of the data protection + /// system. See for more information. + /// The to be used for encryption. + public static IDataProtectionProvider Create( + DirectoryInfo keyDirectory, + Action setupAction, + X509Certificate2 certificate) + { + if (keyDirectory == null) + { + throw new ArgumentNullException(nameof(keyDirectory)); + } + if (setupAction == null) + { + throw new ArgumentNullException(nameof(setupAction)); + } + if (certificate == null) + { + throw new ArgumentNullException(nameof(certificate)); + } + + return CreateProvider(keyDirectory, setupAction, certificate); + } +#endif + + private static IDataProtectionProvider CreateProvider( + DirectoryInfo keyDirectory, + Action setupAction, + X509Certificate2 certificate) + { // build the service collection var serviceCollection = new ServiceCollection(); var builder = serviceCollection.AddDataProtection(); - builder.PersistKeysToFileSystem(keyDirectory); - if (setupAction != null) + if (keyDirectory != null) { - setupAction(builder); + builder.PersistKeysToFileSystem(keyDirectory); } +#if !NETSTANDARD1_3 // [[ISSUE60]] Remove this #ifdef when Core CLR gets support for EncryptedXml + if (certificate != null) + { + builder.ProtectKeysWithCertificate(certificate); + } +#endif + + setupAction(builder); + // extract the provider instance from the service collection return serviceCollection.BuildServiceProvider().GetRequiredService(); } diff --git a/test/Microsoft.AspNetCore.DataProtection.Extensions.Test/DataProtectionProviderTests.cs b/test/Microsoft.AspNetCore.DataProtection.Extensions.Test/DataProtectionProviderTests.cs index 5e882c70c8..baf03a742f 100644 --- a/test/Microsoft.AspNetCore.DataProtection.Extensions.Test/DataProtectionProviderTests.cs +++ b/test/Microsoft.AspNetCore.DataProtection.Extensions.Test/DataProtectionProviderTests.cs @@ -3,6 +3,8 @@ using System; using System.IO; +using System.Reflection; +using System.Security.Cryptography.X509Certificates; using Microsoft.AspNetCore.DataProtection.Test.Shared; using Microsoft.AspNetCore.Testing.xunit; using Xunit; @@ -35,6 +37,47 @@ namespace Microsoft.AspNetCore.DataProtection }); } + [ConditionalFact] + [ConditionalRunTestOnlyIfLocalAppDataAvailable] + [ConditionalRunTestOnlyOnWindows] + public void System_NoKeysDirectoryProvided_UsesDefaultKeysDirectory() + { + var keysPath = Path.Combine(Environment.ExpandEnvironmentVariables("%LOCALAPPDATA%"), "ASP.NET", "DataProtection-Keys"); + var tempPath = Path.Combine(Environment.ExpandEnvironmentVariables("%LOCALAPPDATA%"), "ASP.NET", "DataProtection-KeysTemp"); + + try + { + // Step 1: Move the current contents, if any, to a temporary directory. + if (Directory.Exists(keysPath)) + { + Directory.Move(keysPath, tempPath); + } + + // Step 2: Instantiate the system and round-trip a payload + var protector = DataProtectionProvider.Create("TestApplication").CreateProtector("purpose"); + Assert.Equal("payload", protector.Unprotect(protector.Protect("payload"))); + + // Step 3: Validate that there's now a single key in the directory and that it's protected using Windows DPAPI. + var newFileName = Assert.Single(Directory.GetFiles(keysPath)); + var file = new FileInfo(newFileName); + Assert.StartsWith("key-", file.Name, StringComparison.OrdinalIgnoreCase); + var fileText = File.ReadAllText(file.FullName); + Assert.DoesNotContain("Warning: the key below is in an unencrypted form.", fileText, StringComparison.Ordinal); + Assert.Contains("This key is encrypted with Windows DPAPI.", fileText, StringComparison.Ordinal); + } + finally + { + if (Directory.Exists(keysPath)) + { + Directory.Delete(keysPath, recursive: true); + } + if (Directory.Exists(tempPath)) + { + Directory.Move(tempPath, keysPath); + } + } + } + [ConditionalFact] [ConditionalRunTestOnlyIfLocalAppDataAvailable] [ConditionalRunTestOnlyOnWindows] @@ -63,6 +106,51 @@ namespace Microsoft.AspNetCore.DataProtection }); } +#if !NETSTANDARDAPP1_5 // [[ISSUE60]] Remove this #ifdef when Core CLR gets support for EncryptedXml + [ConditionalFact] + [ConditionalRunTestOnlyIfLocalAppDataAvailable] + [ConditionalRunTestOnlyOnWindows] + public void System_UsesProvidedDirectoryAndCertificate() + { + var filePath = Path.Combine(GetTestFilesPath(), "TestCert.pfx"); + var store = new X509Store(StoreName.My, StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadWrite); + store.Add(new X509Certificate2(filePath, "password")); + store.Close(); + + WithUniqueTempDirectory(directory => + { + var certificateStore = new X509Store(StoreName.My, StoreLocation.CurrentUser); + certificateStore.Open(OpenFlags.ReadWrite); + var certificate = certificateStore.Certificates.Find(X509FindType.FindBySubjectName, "TestCert", false)[0]; + + try + { + // Step 1: directory should be completely empty + directory.Create(); + Assert.Empty(directory.GetFiles()); + + // Step 2: instantiate the system and round-trip a payload + var protector = DataProtectionProvider.Create(directory, certificate).CreateProtector("purpose"); + Assert.Equal("payload", protector.Unprotect(protector.Protect("payload"))); + + // Step 3: validate that there's now a single key in the directory and that it's is protected using the certificate + var allFiles = directory.GetFiles(); + Assert.Equal(1, allFiles.Length); + Assert.StartsWith("key-", allFiles[0].Name, StringComparison.OrdinalIgnoreCase); + string fileText = File.ReadAllText(allFiles[0].FullName); + Assert.DoesNotContain("Warning: the key below is in an unencrypted form.", fileText, StringComparison.Ordinal); + Assert.Contains("X509Certificate", fileText, StringComparison.Ordinal); + } + finally + { + certificateStore.Remove(certificate); + certificateStore.Close(); + } + }); + } +#endif + /// /// Runs a test and cleans up the temp directory afterward. /// @@ -90,5 +178,26 @@ namespace Microsoft.AspNetCore.DataProtection public string SkipReason { get; } = "%LOCALAPPDATA% couldn't be located."; } + + private static string GetTestFilesPath() + { + var projectName = typeof(DataProtectionProviderTests).GetTypeInfo().Assembly.GetName().Name; + var projectPath = RecursiveFind(projectName, Path.GetFullPath(".")); + + return Path.Combine(projectPath, projectName, "TestFiles"); + } + + private static string RecursiveFind(string path, string start) + { + var test = Path.Combine(start, path); + if (Directory.Exists(test)) + { + return start; + } + else + { + return RecursiveFind(path, new DirectoryInfo(start).Parent.FullName); + } + } } } diff --git a/test/Microsoft.AspNetCore.DataProtection.Extensions.Test/TestFiles/TestCert.pfx b/test/Microsoft.AspNetCore.DataProtection.Extensions.Test/TestFiles/TestCert.pfx new file mode 100644 index 0000000000..266754e8ee Binary files /dev/null and b/test/Microsoft.AspNetCore.DataProtection.Extensions.Test/TestFiles/TestCert.pfx differ