diff --git a/src/Microsoft.AspNetCore.DataProtection.Extensions/DataProtectionProvider.cs b/src/Microsoft.AspNetCore.DataProtection.Extensions/DataProtectionProvider.cs index 7b080a9a87..cc82fe9ef8 100644 --- a/src/Microsoft.AspNetCore.DataProtection.Extensions/DataProtectionProvider.cs +++ b/src/Microsoft.AspNetCore.DataProtection.Extensions/DataProtectionProvider.cs @@ -150,7 +150,7 @@ namespace Microsoft.AspNetCore.DataProtection return CreateProvider(keyDirectory, setupAction, certificate); } - private static IDataProtectionProvider CreateProvider( + internal static IDataProtectionProvider CreateProvider( DirectoryInfo keyDirectory, Action setupAction, X509Certificate2 certificate) diff --git a/src/Microsoft.AspNetCore.DataProtection/KeyManagement/XmlKeyManager.cs b/src/Microsoft.AspNetCore.DataProtection/KeyManagement/XmlKeyManager.cs index 66e7a96dcb..06baad13ed 100644 --- a/src/Microsoft.AspNetCore.DataProtection/KeyManagement/XmlKeyManager.cs +++ b/src/Microsoft.AspNetCore.DataProtection/KeyManagement/XmlKeyManager.cs @@ -53,6 +53,7 @@ namespace Microsoft.AspNetCore.DataProtection.KeyManagement private readonly ILoggerFactory _loggerFactory; private readonly ILogger _logger; private readonly IEnumerable _encryptorFactories; + private readonly IDefaultKeyStorageDirectories _keyStorageDirectories; private CancellationTokenSource _cacheExpirationTokenSource; @@ -62,7 +63,7 @@ namespace Microsoft.AspNetCore.DataProtection.KeyManagement /// The instance that provides the configuration. /// The . public XmlKeyManager(IOptions keyManagementOptions, IActivator activator) - : this (keyManagementOptions, activator, NullLoggerFactory.Instance) + : this(keyManagementOptions, activator, NullLoggerFactory.Instance) { } /// @@ -72,9 +73,18 @@ namespace Microsoft.AspNetCore.DataProtection.KeyManagement /// The . /// The . public XmlKeyManager(IOptions keyManagementOptions, IActivator activator, ILoggerFactory loggerFactory) + : this(keyManagementOptions, activator, loggerFactory, DefaultKeyStorageDirectories.Instance) + { } + + internal XmlKeyManager( + IOptions keyManagementOptions, + IActivator activator, + ILoggerFactory loggerFactory, + IDefaultKeyStorageDirectories keyStorageDirectories) { _loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); _logger = _loggerFactory.CreateLogger(); + _keyStorageDirectories = keyStorageDirectories ?? throw new ArgumentNullException(nameof(keyStorageDirectories)); KeyRepository = keyManagementOptions.Value.XmlRepository; KeyEncryptor = keyManagementOptions.Value.XmlEncryptor; @@ -469,7 +479,7 @@ namespace Microsoft.AspNetCore.DataProtection.KeyManagement IXmlEncryptor encryptor = null; // If we're running in Azure Web Sites, the key repository goes in the %HOME% directory. - var azureWebSitesKeysFolder = FileSystemXmlRepository.GetKeyStorageDirectoryForAzureWebSites(); + var azureWebSitesKeysFolder = _keyStorageDirectories.GetKeyStorageDirectoryForAzureWebSites(); if (azureWebSitesKeysFolder != null) { _logger.UsingAzureAsKeyRepository(azureWebSitesKeysFolder.FullName); @@ -481,7 +491,7 @@ namespace Microsoft.AspNetCore.DataProtection.KeyManagement else { // If the user profile is available, store keys in the user profile directory. - var localAppDataKeysFolder = FileSystemXmlRepository.DefaultKeyStorageDirectory; + var localAppDataKeysFolder = _keyStorageDirectories.GetKeyStorageDirectory(); if (localAppDataKeysFolder != null) { if (OSVersionUtil.IsWindows()) diff --git a/src/Microsoft.AspNetCore.DataProtection/Properties/AssemblyInfo.cs b/src/Microsoft.AspNetCore.DataProtection/Properties/AssemblyInfo.cs index 7816360b8b..614112bd73 100644 --- a/src/Microsoft.AspNetCore.DataProtection/Properties/AssemblyInfo.cs +++ b/src/Microsoft.AspNetCore.DataProtection/Properties/AssemblyInfo.cs @@ -4,5 +4,6 @@ using System.Runtime.CompilerServices; // for unit testing +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.DataProtection.Extensions.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] [assembly: InternalsVisibleTo("Microsoft.AspNetCore.DataProtection.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")] diff --git a/src/Microsoft.AspNetCore.DataProtection/Repositories/DefaultKeyStorageDirectories.cs b/src/Microsoft.AspNetCore.DataProtection/Repositories/DefaultKeyStorageDirectories.cs new file mode 100644 index 0000000000..a0717263fb --- /dev/null +++ b/src/Microsoft.AspNetCore.DataProtection/Repositories/DefaultKeyStorageDirectories.cs @@ -0,0 +1,112 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; + +namespace Microsoft.AspNetCore.DataProtection.Repositories +{ + internal sealed class DefaultKeyStorageDirectories : IDefaultKeyStorageDirectories + { + private static readonly Lazy _defaultDirectoryLazy = new Lazy(GetKeyStorageDirectoryImpl); + + private DefaultKeyStorageDirectories() + { + } + + public static IDefaultKeyStorageDirectories Instance { get; } = new DefaultKeyStorageDirectories(); + + /// + /// The default key storage directory. + /// On Windows, this currently corresponds to "Environment.SpecialFolder.LocalApplication/ASP.NET/DataProtection-Keys". + /// On Linux and macOS, this currently corresponds to "$HOME/.aspnet/DataProtection-Keys". + /// + /// + /// This property can return null if no suitable default key storage directory can + /// be found, such as the case when the user profile is unavailable. + /// + public DirectoryInfo GetKeyStorageDirectory() => _defaultDirectoryLazy.Value; + + private static DirectoryInfo GetKeyStorageDirectoryImpl() + { + DirectoryInfo retVal; + + // Environment.GetFolderPath returns null if the user profile isn't loaded. + var localAppDataFromSystemPath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var localAppDataFromEnvPath = Environment.GetEnvironmentVariable("LOCALAPPDATA"); + var userProfilePath = Environment.GetEnvironmentVariable("USERPROFILE"); + var homePath = Environment.GetEnvironmentVariable("HOME"); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !string.IsNullOrEmpty(localAppDataFromSystemPath)) + { + // To preserve backwards-compatibility with 1.x, Environment.SpecialFolder.LocalApplicationData + // cannot take precedence over $LOCALAPPDATA and $HOME/.aspnet on non-Windows platforms + retVal = GetKeyStorageDirectoryFromBaseAppDataPath(localAppDataFromSystemPath); + } + else if (localAppDataFromEnvPath != null) + { + retVal = GetKeyStorageDirectoryFromBaseAppDataPath(localAppDataFromEnvPath); + } + else if (userProfilePath != null) + { + retVal = GetKeyStorageDirectoryFromBaseAppDataPath(Path.Combine(userProfilePath, "AppData", "Local")); + } + else if (homePath != null) + { + // If LOCALAPPDATA and USERPROFILE are not present but HOME is, + // it's a good guess that this is a *NIX machine. Use *NIX conventions for a folder name. + retVal = new DirectoryInfo(Path.Combine(homePath, ".aspnet", DataProtectionKeysFolderName)); + } + else if (!string.IsNullOrEmpty(localAppDataFromSystemPath)) + { + // Starting in 2.x, non-Windows platforms may use Environment.SpecialFolder.LocalApplicationData + // but only after checking for $LOCALAPPDATA, $USERPROFILE, and $HOME. + retVal = GetKeyStorageDirectoryFromBaseAppDataPath(localAppDataFromSystemPath); + } + else + { + return null; + } + + Debug.Assert(retVal != null); + + try + { + retVal.Create(); // throws if we don't have access, e.g., user profile not loaded + return retVal; + } + catch + { + return null; + } + } + + public DirectoryInfo GetKeyStorageDirectoryForAzureWebSites() + { + // Azure Web Sites needs to be treated specially, as we need to store the keys in a + // correct persisted location. We use the existence of the %WEBSITE_INSTANCE_ID% env + // variable to determine if we're running in this environment, and if so we then use + // the %HOME% variable to build up our base key storage path. + if (!String.IsNullOrEmpty(Environment.GetEnvironmentVariable("WEBSITE_INSTANCE_ID"))) + { + var homeEnvVar = Environment.GetEnvironmentVariable("HOME"); + if (!String.IsNullOrEmpty(homeEnvVar)) + { + return GetKeyStorageDirectoryFromBaseAppDataPath(homeEnvVar); + } + } + + // nope + return null; + } + + private const string DataProtectionKeysFolderName = "DataProtection-Keys"; + + private static DirectoryInfo GetKeyStorageDirectoryFromBaseAppDataPath(string basePath) + { + return new DirectoryInfo(Path.Combine(basePath, "ASP.NET", DataProtectionKeysFolderName)); + } + } +} diff --git a/src/Microsoft.AspNetCore.DataProtection/Repositories/FileSystemXmlRepository.cs b/src/Microsoft.AspNetCore.DataProtection/Repositories/FileSystemXmlRepository.cs index 914cc3f9ba..7ceede33d1 100644 --- a/src/Microsoft.AspNetCore.DataProtection/Repositories/FileSystemXmlRepository.cs +++ b/src/Microsoft.AspNetCore.DataProtection/Repositories/FileSystemXmlRepository.cs @@ -3,10 +3,8 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.IO; using System.Linq; -using System.Runtime.InteropServices; using System.Xml.Linq; using Microsoft.AspNetCore.DataProtection.Internal; using Microsoft.Extensions.Logging; @@ -18,8 +16,6 @@ namespace Microsoft.AspNetCore.DataProtection.Repositories /// public class FileSystemXmlRepository : IXmlRepository { - private static readonly Lazy _defaultDirectoryLazy = new Lazy(GetDefaultKeyStorageDirectory); - private readonly ILogger _logger; /// @@ -29,12 +25,8 @@ namespace Microsoft.AspNetCore.DataProtection.Repositories /// The . public FileSystemXmlRepository(DirectoryInfo directory, ILoggerFactory loggerFactory) { - if (directory == null) - { - throw new ArgumentNullException(nameof(directory)); - } + Directory = directory ?? throw new ArgumentNullException(nameof(directory)); - Directory = directory; _logger = loggerFactory.CreateLogger(); try @@ -63,20 +55,13 @@ namespace Microsoft.AspNetCore.DataProtection.Repositories /// This property can return null if no suitable default key storage directory can /// be found, such as the case when the user profile is unavailable. /// - public static DirectoryInfo DefaultKeyStorageDirectory => _defaultDirectoryLazy.Value; + public static DirectoryInfo DefaultKeyStorageDirectory => DefaultKeyStorageDirectories.Instance.GetKeyStorageDirectory(); /// /// The directory into which key material will be written. /// public DirectoryInfo Directory { get; } - private const string DataProtectionKeysFolderName = "DataProtection-Keys"; - - private static DirectoryInfo GetKeyStorageDirectoryFromBaseAppDataPath(string basePath) - { - return new DirectoryInfo(Path.Combine(basePath, "ASP.NET", DataProtectionKeysFolderName)); - } - public virtual IReadOnlyCollection GetAllElements() { // forces complete enumeration @@ -99,79 +84,6 @@ namespace Microsoft.AspNetCore.DataProtection.Repositories } } - private static DirectoryInfo GetDefaultKeyStorageDirectory() - { - DirectoryInfo retVal; - - // Environment.GetFolderPath returns null if the user profile isn't loaded. - var localAppDataFromSystemPath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - var localAppDataFromEnvPath = Environment.GetEnvironmentVariable("LOCALAPPDATA"); - var userProfilePath = Environment.GetEnvironmentVariable("USERPROFILE"); - var homePath = Environment.GetEnvironmentVariable("HOME"); - - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !string.IsNullOrEmpty(localAppDataFromSystemPath)) - { - // To preserve backwards-compatibility with 1.x, Environment.SpecialFolder.LocalApplicationData - // cannot take precedence over $LOCALAPPDATA and $HOME/.aspnet on non-Windows platforms - retVal = GetKeyStorageDirectoryFromBaseAppDataPath(localAppDataFromSystemPath); - } - else if (localAppDataFromEnvPath != null) - { - retVal = GetKeyStorageDirectoryFromBaseAppDataPath(localAppDataFromEnvPath); - } - else if (userProfilePath != null) - { - retVal = GetKeyStorageDirectoryFromBaseAppDataPath(Path.Combine(userProfilePath, "AppData", "Local")); - } - else if (homePath != null) - { - // If LOCALAPPDATA and USERPROFILE are not present but HOME is, - // it's a good guess that this is a *NIX machine. Use *NIX conventions for a folder name. - retVal = new DirectoryInfo(Path.Combine(homePath, ".aspnet", DataProtectionKeysFolderName)); - } - else if (!string.IsNullOrEmpty(localAppDataFromSystemPath)) - { - // Starting in 2.x, non-Windows platforms may use Environment.SpecialFolder.LocalApplicationData - // but only after checking for $LOCALAPPDATA, $USERPROFILE, and $HOME. - retVal = GetKeyStorageDirectoryFromBaseAppDataPath(localAppDataFromSystemPath); - } - else - { - return null; - } - - Debug.Assert(retVal != null); - - try - { - retVal.Create(); // throws if we don't have access, e.g., user profile not loaded - return retVal; - } - catch - { - return null; - } - } - - internal static DirectoryInfo GetKeyStorageDirectoryForAzureWebSites() - { - // Azure Web Sites needs to be treated specially, as we need to store the keys in a - // correct persisted location. We use the existence of the %WEBSITE_INSTANCE_ID% env - // variable to determine if we're running in this environment, and if so we then use - // the %HOME% variable to build up our base key storage path. - if (!String.IsNullOrEmpty(Environment.GetEnvironmentVariable("WEBSITE_INSTANCE_ID"))) - { - var homeEnvVar = Environment.GetEnvironmentVariable("HOME"); - if (!String.IsNullOrEmpty(homeEnvVar)) - { - return GetKeyStorageDirectoryFromBaseAppDataPath(homeEnvVar); - } - } - - // nope - return null; - } - private static bool IsSafeFilename(string filename) { // Must be non-empty and contain only a-zA-Z0-9, hyphen, and underscore. diff --git a/src/Microsoft.AspNetCore.DataProtection/Repositories/IDefaultKeyStorageDirectory.cs b/src/Microsoft.AspNetCore.DataProtection/Repositories/IDefaultKeyStorageDirectory.cs new file mode 100644 index 0000000000..e7e1410e79 --- /dev/null +++ b/src/Microsoft.AspNetCore.DataProtection/Repositories/IDefaultKeyStorageDirectory.cs @@ -0,0 +1,17 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.IO; + +namespace Microsoft.AspNetCore.DataProtection.Repositories +{ + /// + /// This interface enables overridding the default storage location of keys on disk + /// + internal interface IDefaultKeyStorageDirectories + { + DirectoryInfo GetKeyStorageDirectory(); + + DirectoryInfo GetKeyStorageDirectoryForAzureWebSites(); + } +} diff --git a/test/Microsoft.AspNetCore.DataProtection.Extensions.Test/DataProtectionProviderTests.cs b/test/Microsoft.AspNetCore.DataProtection.Extensions.Test/DataProtectionProviderTests.cs index 7c7bcf9c36..40e470ea58 100644 --- a/test/Microsoft.AspNetCore.DataProtection.Extensions.Test/DataProtectionProviderTests.cs +++ b/test/Microsoft.AspNetCore.DataProtection.Extensions.Test/DataProtectionProviderTests.cs @@ -7,9 +7,16 @@ using System.Reflection; using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; +using Microsoft.AspNetCore.DataProtection.Internal; +using Microsoft.AspNetCore.DataProtection.KeyManagement; using Microsoft.AspNetCore.DataProtection.Repositories; using Microsoft.AspNetCore.DataProtection.Test.Shared; using Microsoft.AspNetCore.Testing.xunit; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; using Xunit; namespace Microsoft.AspNetCore.DataProtection @@ -42,49 +49,42 @@ namespace Microsoft.AspNetCore.DataProtection [Fact] public void System_NoKeysDirectoryProvided_UsesDefaultKeysDirectory() { - Assert.NotNull(FileSystemXmlRepository.DefaultKeyStorageDirectory); + var mock = new Mock(); + var keysPath = Path.Combine(AppContext.BaseDirectory, Path.GetRandomFileName()); + mock.Setup(m => m.GetKeyStorageDirectory()).Returns(new DirectoryInfo(keysPath)); - var keysPath = FileSystemXmlRepository.DefaultKeyStorageDirectory.FullName; - var tempPath = FileSystemXmlRepository.DefaultKeyStorageDirectory.FullName + Path.GetRandomFileName(); - - try + // Step 1: Instantiate the system and round-trip a payload + var provider = DataProtectionProvider.CreateProvider( + keyDirectory: null, + certificate: null, + setupAction: builder => { - // Step 1: Move the current contents, if any, to a temporary directory. - if (Directory.Exists(keysPath)) - { - Directory.Move(keysPath, tempPath); - } + builder.SetApplicationName("TestApplication"); + builder.Services.AddSingleton(s => + new XmlKeyManager( + s.GetRequiredService>(), + s.GetRequiredService(), + NullLoggerFactory.Instance, + mock.Object)); + }); - // 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"))); + var protector = provider.CreateProtector("Protector"); + Assert.Equal("payload", protector.Unprotect(protector.Protect("payload"))); - // Step 3: Validate that there's now a single key in the directory - 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); - // On Windows, validate that it's protected using Windows DPAPI. - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - 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); - } - else - { - Assert.Contains("Warning: the key below is in an unencrypted form.", fileText, StringComparison.Ordinal); - } + // Step 2: Validate that there's now a single key in the directory + 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); + // On Windows, validate that it's protected using Windows DPAPI. + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + 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 + else { - if (Directory.Exists(keysPath)) - { - Directory.Delete(keysPath, recursive: true); - } - if (Directory.Exists(tempPath)) - { - Directory.Move(tempPath, keysPath); - } + Assert.Contains("Warning: the key below is in an unencrypted form.", fileText, StringComparison.Ordinal); } } diff --git a/test/Microsoft.AspNetCore.DataProtection.Extensions.Test/TimeLimitedDataProtectorTests.cs b/test/Microsoft.AspNetCore.DataProtection.Extensions.Test/TimeLimitedDataProtectorTests.cs index 6f71977154..47dfc26fd7 100644 --- a/test/Microsoft.AspNetCore.DataProtection.Extensions.Test/TimeLimitedDataProtectorTests.cs +++ b/test/Microsoft.AspNetCore.DataProtection.Extensions.Test/TimeLimitedDataProtectorTests.cs @@ -4,13 +4,14 @@ using System; using System.Globalization; using System.Security.Cryptography; -using Microsoft.AspNetCore.DataProtection.Extensions; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; +using ExtResources = Microsoft.AspNetCore.DataProtection.Extensions.Resources; namespace Microsoft.AspNetCore.DataProtection { + public class TimeLimitedDataProtectorTests { private const string TimeLimitedPurposeString = "Microsoft.AspNetCore.DataProtection.TimeLimitedDataProtector.v1"; @@ -106,7 +107,7 @@ namespace Microsoft.AspNetCore.DataProtection => timeLimitedProtector.UnprotectCore(new byte[] { 0x10, 0x11 }, now, out var _)); // Assert - Assert.Equal(Resources.FormatTimeLimitedDataProtector_PayloadExpired(expectedExpiration), ex.Message); + Assert.Equal(ExtResources.FormatTimeLimitedDataProtector_PayloadExpired(expectedExpiration), ex.Message); } [Fact] @@ -127,7 +128,7 @@ namespace Microsoft.AspNetCore.DataProtection => timeLimitedProtector.Unprotect(new byte[] { 0x10, 0x11 }, out var _)); // Assert - Assert.Equal(Resources.TimeLimitedDataProtector_PayloadInvalid, ex.Message); + Assert.Equal(ExtResources.TimeLimitedDataProtector_PayloadInvalid, ex.Message); } [Fact]