diff --git a/sdk/extensions/Azure.Extensions.AspNetCore.DataProtection.Keys/CHANGELOG.md b/sdk/extensions/Azure.Extensions.AspNetCore.DataProtection.Keys/CHANGELOG.md index 780016d96d2e..9c774f8dc47d 100644 --- a/sdk/extensions/Azure.Extensions.AspNetCore.DataProtection.Keys/CHANGELOG.md +++ b/sdk/extensions/Azure.Extensions.AspNetCore.DataProtection.Keys/CHANGELOG.md @@ -1,7 +1,10 @@ # Release History -## 1.1.0-preview.1 (Unreleased) +## 1.0.2 (2020-09-01) +### Fixed + +- Support reading keys created by a previous version of Azure KeyVault Keys DataProtection library. ## 1.0.1 (2020-08-06) diff --git a/sdk/extensions/Azure.Extensions.AspNetCore.DataProtection.Keys/src/Azure.Extensions.AspNetCore.DataProtection.Keys.csproj b/sdk/extensions/Azure.Extensions.AspNetCore.DataProtection.Keys/src/Azure.Extensions.AspNetCore.DataProtection.Keys.csproj index d1d1106049fa..e0bdfca0429b 100644 --- a/sdk/extensions/Azure.Extensions.AspNetCore.DataProtection.Keys/src/Azure.Extensions.AspNetCore.DataProtection.Keys.csproj +++ b/sdk/extensions/Azure.Extensions.AspNetCore.DataProtection.Keys/src/Azure.Extensions.AspNetCore.DataProtection.Keys.csproj @@ -3,7 +3,7 @@ Microsoft Azure Key Vault key encryption support. aspnetcore;dataprotection;azure;keyvault - 1.1.0-preview.1 + 1.0.2 1.0.1 true $(NoWarn);AZC0102 diff --git a/sdk/extensions/Azure.Extensions.AspNetCore.DataProtection.Keys/src/AzureDataProtectionKeyVaultKeyBuilderExtensions.cs b/sdk/extensions/Azure.Extensions.AspNetCore.DataProtection.Keys/src/AzureDataProtectionKeyVaultKeyBuilderExtensions.cs index 3272148ee793..45b277d0587e 100644 --- a/sdk/extensions/Azure.Extensions.AspNetCore.DataProtection.Keys/src/AzureDataProtectionKeyVaultKeyBuilderExtensions.cs +++ b/sdk/extensions/Azure.Extensions.AspNetCore.DataProtection.Keys/src/AzureDataProtectionKeyVaultKeyBuilderExtensions.cs @@ -6,6 +6,7 @@ using Azure.Core; using Azure.Core.Cryptography; using Azure.Security.KeyVault.Keys.Cryptography; +using Microsoft.AspNetCore.DataProtection.Internal; using Microsoft.AspNetCore.DataProtection.KeyManagement; using Microsoft.Extensions.DependencyInjection; @@ -45,6 +46,7 @@ public static IDataProtectionBuilder ProtectKeysWithAzureKeyVault(this IDataProt Argument.AssertNotNullOrEmpty(keyIdentifier, nameof(keyIdentifier)); builder.Services.AddSingleton(keyResolver); + builder.Services.AddSingleton(); builder.Services.Configure(options => { options.XmlEncryptor = new AzureKeyVaultXmlEncryptor(keyResolver, keyIdentifier); diff --git a/sdk/extensions/Azure.Extensions.AspNetCore.DataProtection.Keys/src/AzureKeyVaultXmlEncryptor.cs b/sdk/extensions/Azure.Extensions.AspNetCore.DataProtection.Keys/src/AzureKeyVaultXmlEncryptor.cs index 38ce7ae45662..d6c77c8d70b4 100644 --- a/sdk/extensions/Azure.Extensions.AspNetCore.DataProtection.Keys/src/AzureKeyVaultXmlEncryptor.cs +++ b/sdk/extensions/Azure.Extensions.AspNetCore.DataProtection.Keys/src/AzureKeyVaultXmlEncryptor.cs @@ -66,7 +66,7 @@ private async Task EncryptAsync(XElement plaintextElement) var wrappedKey = await key.WrapKeyAsync(DefaultKeyEncryption, symmetricKey).ConfigureAwait(false); var element = new XElement("encryptedKey", - new XComment(" This key is encrypted with Azure KeyVault. "), + new XComment(" This key is encrypted with Azure Key Vault. "), new XElement("kid", key.KeyId), new XElement("key", Convert.ToBase64String(wrappedKey)), new XElement("iv", Convert.ToBase64String(symmetricIV)), diff --git a/sdk/extensions/Azure.Extensions.AspNetCore.DataProtection.Keys/src/DecryptorTypeForwardingActivator.cs b/sdk/extensions/Azure.Extensions.AspNetCore.DataProtection.Keys/src/DecryptorTypeForwardingActivator.cs new file mode 100644 index 000000000000..9bb5db760a72 --- /dev/null +++ b/sdk/extensions/Azure.Extensions.AspNetCore.DataProtection.Keys/src/DecryptorTypeForwardingActivator.cs @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.DataProtection.Internal; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +#pragma warning disable AZC0001 +namespace Microsoft.AspNetCore.DataProtection +#pragma warning restore AZC0001 +{ + /// + /// This type is a copy of https://github.com/dotnet/aspnetcore/blob/e1bf38ccaf2a98f95e48bf22b8b76d996a0c33ea/src/DataProtection/DataProtection/test/TypeForwardingActivatorTests.cs + /// with addition of AzureKeyVaultXmlDecryptor forwarding logic. + /// + internal class DecryptorTypeForwardingActivator : IActivator + { + private readonly IServiceProvider _services; + private const string OldNamespace = "Microsoft.AspNet.DataProtection"; + private const string CurrentNamespace = "Microsoft.AspNetCore.DataProtection"; + private const string CurrentAzureNamespace = "Azure.Extensions.AspNetCore.DataProtection.Keys"; + + private const string OldDecryptor = "Microsoft.AspNetCore.DataProtection.AzureKeyVault.AzureKeyVaultXmlDecryptor"; + private const string OldDecryptorAssembly = "Microsoft.AspNetCore.DataProtection.AzureKeyVault"; + private const string NewDecryptor = "Azure.Extensions.AspNetCore.DataProtection.Keys.AzureKeyVaultXmlDecryptor"; + private const string NewDecryptorAssembly = "Azure.Extensions.AspNetCore.DataProtection.Keys"; + + private readonly ILogger _logger; + private static readonly Regex _versionPattern = new Regex(@",\s?Version=[0-9]+(\.[0-9]+){0,3}", RegexOptions.Compiled, TimeSpan.FromSeconds(2)); + private static readonly Regex _tokenPattern = new Regex(@",\s?PublicKeyToken=[\w\d]+", RegexOptions.Compiled, TimeSpan.FromSeconds(2)); + + public DecryptorTypeForwardingActivator(IServiceProvider services) + : this(services, NullLoggerFactory.Instance) + { + } + + public DecryptorTypeForwardingActivator(IServiceProvider services, ILoggerFactory loggerFactory) + { + _services = services; + _logger = loggerFactory.CreateLogger(typeof(DecryptorTypeForwardingActivator)); + } + + public object CreateInstance(Type expectedBaseType, string originalTypeName) + => CreateInstance(expectedBaseType, originalTypeName, out var _); + + // for testing + internal object CreateInstance(Type expectedBaseType, string originalTypeName, out bool forwarded) + { + var forwardedTypeName = originalTypeName; + var candidate = false; + if (originalTypeName.Contains(OldNamespace)) + { + candidate = true; + forwardedTypeName = originalTypeName.Replace(OldNamespace, CurrentNamespace); + } + + if (originalTypeName.Contains(OldDecryptor)) + { + candidate = true; + forwardedTypeName = originalTypeName + .Replace(OldDecryptor, NewDecryptor) + .Replace(OldDecryptorAssembly, NewDecryptorAssembly); + } + + if ((candidate || forwardedTypeName.StartsWith(CurrentNamespace + ".", StringComparison.Ordinal)) || + forwardedTypeName.StartsWith(CurrentAzureNamespace + ".", StringComparison.Ordinal)) + { + candidate = true; + forwardedTypeName = RemoveVersionFromAssemblyName(forwardedTypeName); + forwardedTypeName = RemovePublicKeyTokenFromAssemblyName(forwardedTypeName); + } + + if (candidate) + { + var type = Type.GetType(forwardedTypeName, false); + if (type != null) + { + _logger.LogDebug("Forwarded activator type request from {FromType} to {ToType}", + originalTypeName, + forwardedTypeName); + forwarded = true; + return CreateInstanceImpl(expectedBaseType, forwardedTypeName); + } + } + + forwarded = false; + return CreateInstanceImpl(expectedBaseType, originalTypeName); + } + + private static string RemovePublicKeyTokenFromAssemblyName(string forwardedTypeName) + => _tokenPattern.Replace(forwardedTypeName, ""); + + internal static string RemoveVersionFromAssemblyName(string forwardedTypeName) + => _versionPattern.Replace(forwardedTypeName, ""); + + private object CreateInstanceImpl(Type expectedBaseType, string implementationTypeName) + { + // Would the assignment even work? + var implementationType = Type.GetType(implementationTypeName, throwOnError: true); + + if (!expectedBaseType.IsAssignableFrom(implementationType)) + { + // It might seem a bit strange to throw an InvalidCastException explicitly rather than + // to let the CLR generate one, but searching through NetFX there is indeed precedent + // for this pattern when the caller knows ahead of time the operation will fail. + throw new InvalidCastException($"The type '{implementationType.AssemblyQualifiedName}' is not assignable to '{expectedBaseType.AssemblyQualifiedName}'."); + } + + // If no IServiceProvider was specified, prefer .ctor() [if it exists] + if (_services == null) + { + var ctorParameterless = implementationType.GetConstructor(Type.EmptyTypes); + if (ctorParameterless != null) + { + return Activator.CreateInstance(implementationType); + } + } + + // If an IServiceProvider was specified or if .ctor() doesn't exist, prefer .ctor(IServiceProvider) [if it exists] + var ctorWhichTakesServiceProvider = implementationType.GetConstructor(new Type[] { typeof(IServiceProvider) }); + if (ctorWhichTakesServiceProvider != null) + { + return ctorWhichTakesServiceProvider.Invoke(new[] { _services }); + } + + // Finally, prefer .ctor() as an ultimate fallback. + // This will throw if the ctor cannot be called. + return Activator.CreateInstance(implementationType); + } + } +} \ No newline at end of file diff --git a/sdk/extensions/Azure.Extensions.AspNetCore.DataProtection.Keys/tests/Azure.Extensions.AspNetCore.DataProtection.Keys.Tests.csproj b/sdk/extensions/Azure.Extensions.AspNetCore.DataProtection.Keys/tests/Azure.Extensions.AspNetCore.DataProtection.Keys.Tests.csproj index 1ac847ca22e2..7fe90e4ec2f9 100644 --- a/sdk/extensions/Azure.Extensions.AspNetCore.DataProtection.Keys/tests/Azure.Extensions.AspNetCore.DataProtection.Keys.Tests.csproj +++ b/sdk/extensions/Azure.Extensions.AspNetCore.DataProtection.Keys/tests/Azure.Extensions.AspNetCore.DataProtection.Keys.Tests.csproj @@ -12,6 +12,8 @@ + + diff --git a/sdk/extensions/Azure.Extensions.AspNetCore.DataProtection.Keys/tests/DataProtectionKeysFunctionalTests.cs b/sdk/extensions/Azure.Extensions.AspNetCore.DataProtection.Keys/tests/DataProtectionKeysFunctionalTests.cs index 3b27943f1297..aa85ef0625cb 100644 --- a/sdk/extensions/Azure.Extensions.AspNetCore.DataProtection.Keys/tests/DataProtectionKeysFunctionalTests.cs +++ b/sdk/extensions/Azure.Extensions.AspNetCore.DataProtection.Keys/tests/DataProtectionKeysFunctionalTests.cs @@ -14,7 +14,7 @@ using Microsoft.Extensions.DependencyInjection; using NUnit.Framework; -namespace Azure.Extensions.AspNetCore.DataProtection.Blobs.Tests +namespace Azure.Extensions.AspNetCore.DataProtection.Keys.Tests { public class DataProtectionKeysFunctionalTests { @@ -55,7 +55,59 @@ public async Task ProtectsKeysWithKeyVaultKey() foreach (var element in testKeyRepository.GetAllElements()) { - StringAssert.Contains("This key is encrypted with Azure KeyVault", element.ToString()); + StringAssert.Contains("This key is encrypted with Azure Key Vault", element.ToString()); + } + } + + [Test] + [Category("Live")] + public async Task CanUprotectExistingKeys() + { + var credential = new ClientSecretCredential( + DataProtectionTestEnvironment.Instance.TenantId, + DataProtectionTestEnvironment.Instance.ClientId, + DataProtectionTestEnvironment.Instance.ClientSecret); + var client = new KeyClient(new Uri(DataProtectionTestEnvironment.Instance.KeyVaultUrl), credential); + var key = await client.CreateKeyAsync("TestEncryptionKey2", KeyType.Rsa); + + var serviceCollection = new ServiceCollection(); + + var testKeyRepository = new TestKeyRepository(); + + AzureDataProtectionBuilderExtensions.ProtectKeysWithAzureKeyVault( + serviceCollection.AddDataProtection(), + key.Value.Id.AbsoluteUri, + DataProtectionTestEnvironment.Instance.ClientId, + DataProtectionTestEnvironment.Instance.ClientSecret); + + serviceCollection.Configure(options => + { + options.XmlRepository = testKeyRepository; + }); + + var servicesOld = serviceCollection.BuildServiceProvider(); + + var serviceCollectionNew = new ServiceCollection(); + serviceCollectionNew.AddDataProtection().ProtectKeysWithAzureKeyVault(key.Value.Id, credential); + serviceCollectionNew.Configure(options => + { + options.XmlRepository = testKeyRepository; + }); + + var dataProtector = servicesOld.GetService().CreateProtector("Fancy purpose"); + var protectedText = dataProtector.Protect("Hello world!"); + + var newServices = serviceCollectionNew.BuildServiceProvider(); + var newDataProtectionProvider = newServices.GetService().CreateProtector("Fancy purpose"); + var unprotectedText = newDataProtectionProvider.Unprotect(protectedText); + + Assert.AreEqual("Hello world!", unprotectedText); + + // double check that keys were protected with KeyVault + + foreach (var element in testKeyRepository.GetAllElements()) + { + StringAssert.Contains("This key is encrypted with Azure", element.ToString()); } } diff --git a/sdk/extensions/Azure.Extensions.AspNetCore.DataProtection.Keys/tests/TypeForwardingActivatorTests.cs b/sdk/extensions/Azure.Extensions.AspNetCore.DataProtection.Keys/tests/TypeForwardingActivatorTests.cs new file mode 100644 index 000000000000..01219134e306 --- /dev/null +++ b/sdk/extensions/Azure.Extensions.AspNetCore.DataProtection.Keys/tests/TypeForwardingActivatorTests.cs @@ -0,0 +1,157 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using Azure.Identity; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.DataProtection.Internal; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; + +namespace Azure.Extensions.AspNetCore.DataProtection.Keys.Tests +{ + public class TypeForwardingActivatorTests : MarshalByRefObject + { + [Test] + public void CreateInstance_ForwardsToNewNamespaceIfExists() + { + // Arrange + var serviceCollection = new ServiceCollection(); + serviceCollection.AddDataProtection().ProtectKeysWithAzureKeyVault(new Uri("http://localhost"), new DefaultAzureCredential()); + var services = serviceCollection.BuildServiceProvider(); + var activator = services.GetRequiredService(); + + // Act + var name = "Microsoft.AspNetCore.DataProtection.AzureKeyVault.AzureKeyVaultXmlDecryptor, Microsoft.AspNetCore.DataProtection.AzureKeyVault, Version=1.0.0.0"; + var instance = activator.CreateInstance(typeof(object), name); + + // Assert + Assert.IsInstanceOf(instance); + } + + [Test] + public void CreateInstance_DoesNotForwardIfClassDoesNotExist() + { + // Arrange + var serviceCollection = new ServiceCollection(); + serviceCollection.AddDataProtection(); + var services = serviceCollection.BuildServiceProvider(); + var activator = services.GetRequiredService(); + + // Act & Assert + var name = "Microsoft.AspNet.DataProtection.TypeForwardingActivatorTests+NonExistentClassWithParameterlessCtor, Microsoft.AspNet.DataProtection.Tests"; + var exception = Assert.Throws(() => activator.CreateInstance(typeof(object), name)); + + StringAssert.Contains("Microsoft.AspNet.DataProtection.Test", exception.Message); + } + + [TestCase(typeof(GenericType>))] + [TestCase(typeof(GenericType))] + [TestCase(typeof(GenericType>))] + [TestCase(typeof(GenericType>))] + [TestCase(typeof(GenericType))] + [TestCase(typeof(GenericType))] + [TestCase(typeof(List))] + public void CreateInstance_Generics(Type type) + { + // Arrange + var activator = new DecryptorTypeForwardingActivator(null); + var name = type.AssemblyQualifiedName; + + // Act & Assert + Assert.IsInstanceOf(type, activator.CreateInstance(typeof(object), name)); + } + + [TestCase(typeof(GenericType<>))] + [TestCase(typeof(GenericType<,>))] + public void CreateInstance_ThrowsForOpenGenerics(Type type) + { + // Arrange + var activator = new DecryptorTypeForwardingActivator(null); + var name = type.AssemblyQualifiedName; + + // Act & Assert + Assert.Throws(() => activator.CreateInstance(typeof(object), name)); + } + + [TestCase( + "System.Tuple`1[[Some.Type, Microsoft.AspNetCore.DataProtection, Version=1.0.0.0, Culture=neutral]], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089", + "System.Tuple`1[[Some.Type, Microsoft.AspNetCore.DataProtection, Culture=neutral]], mscorlib, Culture=neutral, PublicKeyToken=b77a5c561934e089")] + [TestCase( + "Some.Type`1[[System.Int32, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]], Microsoft.AspNetCore.DataProtection, Version=1.0.0.0, Culture=neutral", + "Some.Type`1[[System.Int32, mscorlib, Culture=neutral, PublicKeyToken=b77a5c561934e089]], Microsoft.AspNetCore.DataProtection, Culture=neutral")] + [TestCase( + "System.Tuple`1[[System.Tuple`1[[Some.Type, Microsoft.AspNetCore.DataProtection, Version=1.0.0.0, Culture=neutral]], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089", + "System.Tuple`1[[System.Tuple`1[[Some.Type, Microsoft.AspNetCore.DataProtection, Culture=neutral]], mscorlib, Culture=neutral, PublicKeyToken=b77a5c561934e089]], mscorlib, Culture=neutral, PublicKeyToken=b77a5c561934e089")] + public void ParsesFullyQualifiedTypeName(string typeName, string expected) + { + Assert.AreEqual(expected, new MockTypeForwardingActivator().Parse(typeName)); + } + + [TestCase(typeof(List))] + [TestCase(typeof(TestAttribute))] + public void CreateInstance_DoesNotForwardingTypesExternalTypes(Type type) + { + new DecryptorTypeForwardingActivator(null).CreateInstance(typeof(object), type.AssemblyQualifiedName, out var forwarded); + Assert.False(forwarded, "Should not have forwarded types that are not in Microsoft.AspNetCore.DataProjection"); + } + + [TestCaseSource(nameof(AssemblyVersions))] + public void CreateInstance_ForwardsAcrossVersionChanges(Version version) + { + CreateInstance_ForwardsAcrossVersionChangesImpl(version); + } + + private void CreateInstance_ForwardsAcrossVersionChangesImpl(Version newVersion) + { + var activator = new DecryptorTypeForwardingActivator(null); + + var typeInfo = typeof(ClassWithParameterlessCtor).GetTypeInfo(); + var typeName = typeInfo.FullName; + var assemblyName = typeInfo.Assembly.GetName(); + + assemblyName.Version = newVersion; + var newName = $"{typeName}, {assemblyName}"; + + Assert.AreNotEqual(typeInfo.AssemblyQualifiedName, newName); + Assert.IsInstanceOf(activator.CreateInstance(typeof(object), newName, out var forwarded)); + Assert.True(forwarded); + } + + public static Version[][] AssemblyVersions + { + get + { + var current = typeof(ClassWithParameterlessCtor).Assembly.GetName().Version; + return new[] + { + new[] { new Version(Math.Max(0, current.Major - 1), 0, 0, 0)}, + new[] { new Version(current.Major + 1, 0, 0, 0)}, + new[] { new Version(current.Major, current.Minor + 1, 0, 0)}, + new[] { new Version(current.Major, current.Minor, current.Build + 1, 0)} + }; + } + } + + private class MockTypeForwardingActivator : DecryptorTypeForwardingActivator + { + public MockTypeForwardingActivator() : base(null) { } + public string Parse(string typeName) => RemoveVersionFromAssemblyName(typeName); + } + + private class ClassWithParameterlessCtor + { + } + + private class GenericType + { + } + + private class GenericType + { + } + } +} \ No newline at end of file