Support decrypting keys with X509Certificate that is not in the X509Store

The default implementation of EncryptedXml doesn't support using the RSA
key from X509Certificate to decrypt xml unless that cert is in the X509
CurrentUser\My or Localmachine\My store. This adds support for
decrypting with the X509Certificate directly. This is useful for Linux
(often Docker) scenarios, where the user already has a .pfx file, but
may not have added it to X509Store.
This commit is contained in:
Nate McMaster 2018-02-16 11:54:13 -08:00
parent e2373fc4a5
commit eea8c1a146
No known key found for this signature in database
GPG Key ID: A778D9601BD78810
14 changed files with 266 additions and 22 deletions

View File

@ -274,6 +274,8 @@ namespace Microsoft.AspNetCore.DataProtection
});
});
builder.Services.Configure<XmlKeyDecryptionOptions>(o => o.AddKeyDecryptionCertificate(certificate));
return builder;
}

View File

@ -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;
/// <summary>
/// Creates a new instance of an <see cref="EncryptedXmlDecryptor"/>.
@ -31,6 +36,7 @@ namespace Microsoft.AspNetCore.DataProtection.XmlEncryption
public EncryptedXmlDecryptor(IServiceProvider services)
{
_decryptor = services?.GetService<IInternalEncryptedXmlDecryptor>() ?? this;
_options = services?.GetService<IOptions<XmlKeyDecryptionOptions>>()?.Value;
}
/// <summary>
@ -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 <root /> element back off and convert the XmlDocument to an XElement.
@ -69,5 +77,88 @@ namespace Microsoft.AspNetCore.DataProtection.XmlEncryption
{
// no-op
}
/// <summary>
/// Can decrypt the XML key data from an <see cref="X509Certificate2"/> that is not in stored in <see cref="X509Store"/>.
/// </summary>
private class EncryptedXmlWithCertificateKeys : EncryptedXml
{
private readonly IReadOnlyDictionary<string, X509Certificate2> _certificates;
public EncryptedXmlWithCertificateKeys(IReadOnlyDictionary<string, X509Certificate2> 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;
}
}
}
}

View File

@ -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
{
/// <summary>
/// Specifies settings for how to decrypt XML keys.
/// </summary>
internal class XmlKeyDecryptionOptions
{
private readonly Dictionary<string, X509Certificate2> _certs = new Dictionary<string, X509Certificate2>(StringComparer.Ordinal);
/// <summary>
/// A mapping of key thumbprint to the X509Certificate2
/// </summary>
public IReadOnlyDictionary<string, X509Certificate2> KeyDecryptionCertificates => _certs;
public void AddKeyDecryptionCertificate(X509Certificate2 certificate)
{
_certs[certificate.Thumbprint] = certificate;
}
}
}

14
test/CreateTestCert.ps1 Normal file
View File

@ -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)"

View File

@ -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);
});
}
/// <summary>
/// Runs a test and cleans up the temp directory afterward.
/// </summary>
@ -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");
}
}

View File

@ -6,6 +6,7 @@
<ItemGroup>
<Compile Include="..\shared\*.cs" />
<Content Include="TestFiles\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>

View File

@ -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
<expirationDate>2015-06-01T00:00:00Z</expirationDate>
<descriptor deserializerType='deserializer-B'>
<elementB />
</descriptor>
</descriptor>
</key>
</root>";

View File

@ -7,6 +7,7 @@
<ItemGroup>
<Compile Include="..\shared\*.cs" />
<Content Include="TestFiles\**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>

View File

@ -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;

View File

@ -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<CryptographicException>(() =>
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<XmlKeyDecryptionOptions>(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<CryptographicException>(() =>
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<XmlKeyDecryptionOptions>(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<CryptographicException>(() =>
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<XmlKeyDecryptionOptions>(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);
}
}
}