diff --git a/src/Microsoft.AspNetCore.DataProtection/DataProtectionBuilderExtensions.cs b/src/Microsoft.AspNetCore.DataProtection/DataProtectionBuilderExtensions.cs index ec1d1136dd..f37dab4331 100644 --- a/src/Microsoft.AspNetCore.DataProtection/DataProtectionBuilderExtensions.cs +++ b/src/Microsoft.AspNetCore.DataProtection/DataProtectionBuilderExtensions.cs @@ -274,6 +274,8 @@ namespace Microsoft.AspNetCore.DataProtection }); }); + builder.Services.Configure(o => o.AddKeyDecryptionCertificate(certificate)); + return builder; } diff --git a/src/Microsoft.AspNetCore.DataProtection/XmlEncryption/EncryptedXmlDecryptor.cs b/src/Microsoft.AspNetCore.DataProtection/XmlEncryption/EncryptedXmlDecryptor.cs index 6bc280900c..e020ac7bb0 100644 --- a/src/Microsoft.AspNetCore.DataProtection/XmlEncryption/EncryptedXmlDecryptor.cs +++ b/src/Microsoft.AspNetCore.DataProtection/XmlEncryption/EncryptedXmlDecryptor.cs @@ -2,10 +2,14 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using System.Security.Cryptography.Xml; using System.Xml; using System.Xml.Linq; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; namespace Microsoft.AspNetCore.DataProtection.XmlEncryption { @@ -15,6 +19,7 @@ namespace Microsoft.AspNetCore.DataProtection.XmlEncryption public sealed class EncryptedXmlDecryptor : IInternalEncryptedXmlDecryptor, IXmlDecryptor { private readonly IInternalEncryptedXmlDecryptor _decryptor; + private readonly XmlKeyDecryptionOptions _options; /// /// Creates a new instance of an . @@ -31,6 +36,7 @@ namespace Microsoft.AspNetCore.DataProtection.XmlEncryption public EncryptedXmlDecryptor(IServiceProvider services) { _decryptor = services?.GetService() ?? this; + _options = services?.GetService>()?.Value; } /// @@ -57,8 +63,10 @@ namespace Microsoft.AspNetCore.DataProtection.XmlEncryption var elementToDecrypt = (XmlElement)xmlDocument.DocumentElement.FirstChild; // Perform the decryption and update the document in-place. - var encryptedXml = new EncryptedXml(xmlDocument); + var decryptionCerts = _options?.KeyDecryptionCertificates; + var encryptedXml = new EncryptedXmlWithCertificateKeys(decryptionCerts, xmlDocument); _decryptor.PerformPreDecryptionSetup(encryptedXml); + encryptedXml.DecryptDocument(); // Strip the element back off and convert the XmlDocument to an XElement. @@ -69,5 +77,88 @@ namespace Microsoft.AspNetCore.DataProtection.XmlEncryption { // no-op } + + /// + /// Can decrypt the XML key data from an that is not in stored in . + /// + private class EncryptedXmlWithCertificateKeys : EncryptedXml + { + private readonly IReadOnlyDictionary _certificates; + + public EncryptedXmlWithCertificateKeys(IReadOnlyDictionary certificates, XmlDocument document) + : base(document) + { + _certificates = certificates; + } + + public override byte[] DecryptEncryptedKey(EncryptedKey encryptedKey) + { + byte[] key = base.DecryptEncryptedKey(encryptedKey); + if (key != null) + { + return key; + } + + if (_certificates == null || _certificates.Count == 0) + { + return null; + } + + var keyInfoEnum = encryptedKey.KeyInfo?.GetEnumerator(); + if (keyInfoEnum == null) + { + return null; + } + + while (keyInfoEnum.MoveNext()) + { + if (!(keyInfoEnum.Current is KeyInfoX509Data kiX509Data)) + { + continue; + } + + key = GetKeyFromCert(encryptedKey, kiX509Data); + if (key != null) + { + return key; + } + } + + return null; + } + + private byte[] GetKeyFromCert(EncryptedKey encryptedKey, KeyInfoX509Data keyInfo) + { + var certEnum = keyInfo.Certificates?.GetEnumerator(); + if (certEnum == null) + { + return null; + } + + while (certEnum.MoveNext()) + { + if (!(certEnum.Current is X509Certificate2 certInfo)) + { + continue; + } + + if (!_certificates.TryGetValue(certInfo.Thumbprint, out var certificate)) + { + continue; + } + + using (RSA privateKey = certificate.GetRSAPrivateKey()) + { + if (privateKey != null) + { + var useOAEP = encryptedKey.EncryptionMethod?.KeyAlgorithm == XmlEncRSAOAEPUrl; + return DecryptKey(encryptedKey.CipherData.CipherValue, privateKey, useOAEP); + } + } + } + + return null; + } + } } } diff --git a/src/Microsoft.AspNetCore.DataProtection/XmlEncryption/XmlKeyDecryptionOptions.cs b/src/Microsoft.AspNetCore.DataProtection/XmlEncryption/XmlKeyDecryptionOptions.cs new file mode 100644 index 0000000000..01999c224d --- /dev/null +++ b/src/Microsoft.AspNetCore.DataProtection/XmlEncryption/XmlKeyDecryptionOptions.cs @@ -0,0 +1,27 @@ +// 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.Collections.Generic; +using System.Security.Cryptography.X509Certificates; + +namespace Microsoft.AspNetCore.DataProtection.XmlEncryption +{ + /// + /// Specifies settings for how to decrypt XML keys. + /// + internal class XmlKeyDecryptionOptions + { + private readonly Dictionary _certs = new Dictionary(StringComparer.Ordinal); + + /// + /// A mapping of key thumbprint to the X509Certificate2 + /// + public IReadOnlyDictionary KeyDecryptionCertificates => _certs; + + public void AddKeyDecryptionCertificate(X509Certificate2 certificate) + { + _certs[certificate.Thumbprint] = certificate; + } + } +} diff --git a/test/CreateTestCert.ps1 b/test/CreateTestCert.ps1 new file mode 100644 index 0000000000..a85a040f05 --- /dev/null +++ b/test/CreateTestCert.ps1 @@ -0,0 +1,14 @@ +# +# Generates a new test cert in a .pfx file +# Obviously, don't actually use this to produce production certs +# + +param( + [Parameter(Mandatory = $true)] + $OutFile +) + +$password = ConvertTo-SecureString -Force -AsPlainText -String "password" +$cert = New-SelfSignedCertificate -DnsName "localhost" -CertStoreLocation Cert:\CurrentUser\My\ +Export-PfxCertificate -Cert $cert -Password $password -FilePath $OutFile +Remove-Item "Cert:\CurrentUser\My\$($cert.Thumbprint)" diff --git a/test/Microsoft.AspNetCore.DataProtection.Extensions.Test/DataProtectionProviderTests.cs b/test/Microsoft.AspNetCore.DataProtection.Extensions.Test/DataProtectionProviderTests.cs index fc73e1397d..ad3dbb3a27 100644 --- a/test/Microsoft.AspNetCore.DataProtection.Extensions.Test/DataProtectionProviderTests.cs +++ b/test/Microsoft.AspNetCore.DataProtection.Extensions.Test/DataProtectionProviderTests.cs @@ -5,6 +5,7 @@ using System; using System.IO; using System.Reflection; using System.Runtime.InteropServices; +using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using Microsoft.AspNetCore.DataProtection.Repositories; using Microsoft.AspNetCore.DataProtection.Test.Shared; @@ -155,6 +156,39 @@ namespace Microsoft.AspNetCore.DataProtection }); } + [Fact] + public void System_UsesInMemoryCertificate() + { + var filePath = Path.Combine(GetTestFilesPath(), "TestCert2.pfx"); + var certificate = new X509Certificate2(filePath, "password"); + + using (var store = new X509Store(StoreName.My, StoreLocation.CurrentUser)) + { + store.Open(OpenFlags.ReadOnly); + // ensure this cert is not in the x509 store + Assert.Empty(store.Certificates.Find(X509FindType.FindByThumbprint, certificate.Thumbprint, false)); + } + + WithUniqueTempDirectory(directory => + { + // 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.Single(allFiles); + 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); + }); + } + /// /// Runs a test and cleans up the temp directory afterward. /// @@ -177,24 +211,6 @@ namespace Microsoft.AspNetCore.DataProtection } 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); - } - } + => Path.Combine(AppContext.BaseDirectory, "TestFiles"); } } diff --git a/test/Microsoft.AspNetCore.DataProtection.Extensions.Test/Microsoft.AspNetCore.DataProtection.Extensions.Test.csproj b/test/Microsoft.AspNetCore.DataProtection.Extensions.Test/Microsoft.AspNetCore.DataProtection.Extensions.Test.csproj index 29cf82928f..16a4f12c98 100644 --- a/test/Microsoft.AspNetCore.DataProtection.Extensions.Test/Microsoft.AspNetCore.DataProtection.Extensions.Test.csproj +++ b/test/Microsoft.AspNetCore.DataProtection.Extensions.Test/Microsoft.AspNetCore.DataProtection.Extensions.Test.csproj @@ -6,6 +6,7 @@ + diff --git a/test/Microsoft.AspNetCore.DataProtection.Extensions.Test/TestFiles/TestCert2.pfx b/test/Microsoft.AspNetCore.DataProtection.Extensions.Test/TestFiles/TestCert2.pfx new file mode 100644 index 0000000000..4ed9bbe394 Binary files /dev/null and b/test/Microsoft.AspNetCore.DataProtection.Extensions.Test/TestFiles/TestCert2.pfx differ diff --git a/test/Microsoft.AspNetCore.DataProtection.Test/KeyManagement/XmlKeyManagerTests.cs b/test/Microsoft.AspNetCore.DataProtection.Test/KeyManagement/XmlKeyManagerTests.cs index ba9f21be61..c6a2e068a3 100644 --- a/test/Microsoft.AspNetCore.DataProtection.Test/KeyManagement/XmlKeyManagerTests.cs +++ b/test/Microsoft.AspNetCore.DataProtection.Test/KeyManagement/XmlKeyManagerTests.cs @@ -4,6 +4,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using System.Xml; using System.Xml.Linq; using Microsoft.AspNetCore.Cryptography.Cng; @@ -347,7 +349,7 @@ namespace Microsoft.AspNetCore.DataProtection.KeyManagement 2015-06-01T00:00:00Z - + "; diff --git a/test/Microsoft.AspNetCore.DataProtection.Test/Microsoft.AspNetCore.DataProtection.Test.csproj b/test/Microsoft.AspNetCore.DataProtection.Test/Microsoft.AspNetCore.DataProtection.Test.csproj index 54469e4063..bf45498fbf 100644 --- a/test/Microsoft.AspNetCore.DataProtection.Test/Microsoft.AspNetCore.DataProtection.Test.csproj +++ b/test/Microsoft.AspNetCore.DataProtection.Test/Microsoft.AspNetCore.DataProtection.Test.csproj @@ -7,6 +7,7 @@ + diff --git a/test/Microsoft.AspNetCore.DataProtection.Test/TestFiles/TestCert1.PublicKeyOnly.cer b/test/Microsoft.AspNetCore.DataProtection.Test/TestFiles/TestCert1.PublicKeyOnly.cer new file mode 100644 index 0000000000..329c90a83b Binary files /dev/null and b/test/Microsoft.AspNetCore.DataProtection.Test/TestFiles/TestCert1.PublicKeyOnly.cer differ diff --git a/test/Microsoft.AspNetCore.DataProtection.Test/TestFiles/TestCert1.pfx b/test/Microsoft.AspNetCore.DataProtection.Test/TestFiles/TestCert1.pfx new file mode 100644 index 0000000000..8bf695f1d6 Binary files /dev/null and b/test/Microsoft.AspNetCore.DataProtection.Test/TestFiles/TestCert1.pfx differ diff --git a/test/Microsoft.AspNetCore.DataProtection.Test/TestFiles/TestCert2.pfx b/test/Microsoft.AspNetCore.DataProtection.Test/TestFiles/TestCert2.pfx new file mode 100644 index 0000000000..a54c93ba34 Binary files /dev/null and b/test/Microsoft.AspNetCore.DataProtection.Test/TestFiles/TestCert2.pfx differ diff --git a/test/Microsoft.AspNetCore.DataProtection.Test/XmlEncryption/CertificateXmlEncryptionTests.cs b/test/Microsoft.AspNetCore.DataProtection.Test/XmlEncryption/CertificateXmlEncryptionTests.cs index 9c5cd6b9c7..425e15f51c 100644 --- a/test/Microsoft.AspNetCore.DataProtection.Test/XmlEncryption/CertificateXmlEncryptionTests.cs +++ b/test/Microsoft.AspNetCore.DataProtection.Test/XmlEncryption/CertificateXmlEncryptionTests.cs @@ -7,7 +7,6 @@ using System.Security.Cryptography.Xml; using System.Xml; using System.Xml.Linq; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; diff --git a/test/Microsoft.AspNetCore.DataProtection.Test/XmlEncryption/EncryptedXmlDecryptorTests.cs b/test/Microsoft.AspNetCore.DataProtection.Test/XmlEncryption/EncryptedXmlDecryptorTests.cs new file mode 100644 index 0000000000..5d3bb6943a --- /dev/null +++ b/test/Microsoft.AspNetCore.DataProtection.Test/XmlEncryption/EncryptedXmlDecryptorTests.cs @@ -0,0 +1,91 @@ +// 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.IO; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Xml.Linq; +using Microsoft.AspNetCore.DataProtection.XmlEncryption; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Microsoft.AspNetCore.DataProtection.Test.XmlEncryption +{ + public class EncryptedXmlDecryptorTests + { + [Fact] + public void ThrowsIfCannotDecrypt() + { + var testCert1 = new X509Certificate2(Path.Combine(AppContext.BaseDirectory, "TestFiles", "TestCert1.pfx"), "password"); + var encryptor = new CertificateXmlEncryptor(testCert1, NullLoggerFactory.Instance); + var data = new XElement("SampleData", "Lorem ipsum"); + var encryptedXml = encryptor.Encrypt(data); + var decryptor = new EncryptedXmlDecryptor(); + + var ex = Assert.Throws(() => + decryptor.Decrypt(encryptedXml.EncryptedElement)); + Assert.Equal("Unable to retrieve the decryption key.", ex.Message); + } + + [Fact] + public void ThrowsIfProvidedCertificateDoesNotMatch() + { + var testCert1 = new X509Certificate2(Path.Combine(AppContext.BaseDirectory, "TestFiles", "TestCert1.pfx"), "password"); + var testCert2 = new X509Certificate2(Path.Combine(AppContext.BaseDirectory, "TestFiles", "TestCert2.pfx"), "password"); + var services = new ServiceCollection() + .Configure(o => o.AddKeyDecryptionCertificate(testCert2)) + .BuildServiceProvider(); + var encryptor = new CertificateXmlEncryptor(testCert1, NullLoggerFactory.Instance); + var data = new XElement("SampleData", "Lorem ipsum"); + var encryptedXml = encryptor.Encrypt(data); + var decryptor = new EncryptedXmlDecryptor(services); + + var ex = Assert.Throws(() => + decryptor.Decrypt(encryptedXml.EncryptedElement)); + Assert.Equal("Unable to retrieve the decryption key.", ex.Message); + } + + [Fact] + public void ThrowsIfProvidedCertificateDoesHavePrivateKey() + { + var fullCert = new X509Certificate2(Path.Combine(AppContext.BaseDirectory, "TestFiles", "TestCert1.pfx"), "password"); + var publicKeyOnly = new X509Certificate2(Path.Combine(AppContext.BaseDirectory, "TestFiles", "TestCert1.PublicKeyOnly.cer"), ""); + var services = new ServiceCollection() + .Configure(o => o.AddKeyDecryptionCertificate(publicKeyOnly)) + .BuildServiceProvider(); + var encryptor = new CertificateXmlEncryptor(fullCert, NullLoggerFactory.Instance); + var data = new XElement("SampleData", "Lorem ipsum"); + var encryptedXml = encryptor.Encrypt(data); + var decryptor = new EncryptedXmlDecryptor(services); + + var ex = Assert.Throws(() => + decryptor.Decrypt(encryptedXml.EncryptedElement)); + Assert.Equal("Unable to retrieve the decryption key.", ex.Message); + } + + [Fact] + public void XmlCanRoundTrip() + { + var testCert1 = new X509Certificate2(Path.Combine(AppContext.BaseDirectory, "TestFiles", "TestCert1.pfx"), "password"); + var testCert2 = new X509Certificate2(Path.Combine(AppContext.BaseDirectory, "TestFiles", "TestCert2.pfx"), "password"); + var services = new ServiceCollection() + .Configure(o => + { + o.AddKeyDecryptionCertificate(testCert1); + o.AddKeyDecryptionCertificate(testCert2); + }) + .BuildServiceProvider(); + var encryptor = new CertificateXmlEncryptor(testCert1, NullLoggerFactory.Instance); + var data = new XElement("SampleData", "Lorem ipsum"); + var encryptedXml = encryptor.Encrypt(data); + var decryptor = new EncryptedXmlDecryptor(services); + + var decrypted = decryptor.Decrypt(encryptedXml.EncryptedElement); + + Assert.Equal("SampleData", decrypted.Name); + Assert.Equal("Lorem ipsum", decrypted.Value); + } + } +}