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:
parent
e2373fc4a5
commit
eea8c1a146
|
|
@ -274,6 +274,8 @@ namespace Microsoft.AspNetCore.DataProtection
|
|||
});
|
||||
});
|
||||
|
||||
builder.Services.Configure<XmlKeyDecryptionOptions>(o => o.AddKeyDecryptionCertificate(certificate));
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)"
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\shared\*.cs" />
|
||||
<Content Include="TestFiles\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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>";
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\shared\*.cs" />
|
||||
<Content Include="TestFiles\**" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue