diff --git a/src/Security/Authorization/Policy/src/AuthorizationEndpointConventionBuilderExtensions.cs b/src/Security/Authorization/Policy/src/AuthorizationEndpointConventionBuilderExtensions.cs index 6551f048fbba..83a19972ffc7 100644 --- a/src/Security/Authorization/Policy/src/AuthorizationEndpointConventionBuilderExtensions.cs +++ b/src/Security/Authorization/Policy/src/AuthorizationEndpointConventionBuilderExtensions.cs @@ -168,5 +168,4 @@ private static void RequireAuthorizationCore(TBuilder builder, IEnumer } }); } - } diff --git a/src/Servers/Kestrel/Core/src/HttpsConnectionAdapterOptions.cs b/src/Servers/Kestrel/Core/src/HttpsConnectionAdapterOptions.cs index 1c05815c3887..8435bfa998a6 100644 --- a/src/Servers/Kestrel/Core/src/HttpsConnectionAdapterOptions.cs +++ b/src/Servers/Kestrel/Core/src/HttpsConnectionAdapterOptions.cs @@ -29,7 +29,7 @@ public HttpsConnectionAdapterOptions() /// /// - /// Specifies the server certificate used to authenticate HTTPS connections. This is ignored if ServerCertificateSelector is set. + /// Specifies the server certificate information presented when an https connection is initiated. This is ignored if ServerCertificateSelector is set. /// /// /// If the server certificate has an Extended Key Usage extension, the usages must include Server Authentication (OID 1.3.6.1.5.5.7.3.1). @@ -37,6 +37,13 @@ public HttpsConnectionAdapterOptions() /// public X509Certificate2? ServerCertificate { get; set; } + /// + /// + /// Specifies the full server certificate chain presented when an https connection is initiated + /// + /// + public X509Certificate2Collection? ServerCertificateChain { get; set; } + /// /// /// A callback that will be invoked to dynamically select a server certificate. This is higher priority than ServerCertificate. diff --git a/src/Servers/Kestrel/Core/src/Internal/Certificates/CertificateConfigLoader.cs b/src/Servers/Kestrel/Core/src/Internal/Certificates/CertificateConfigLoader.cs index 8040f7d9c4ff..d0239122fcde 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Certificates/CertificateConfigLoader.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Certificates/CertificateConfigLoader.cs @@ -23,11 +23,11 @@ public CertificateConfigLoader(IHostEnvironment hostEnvironment, ILogger false; - public X509Certificate2? LoadCertificate(CertificateConfig? certInfo, string endpointName) + public (X509Certificate2?, X509Certificate2Collection?) LoadCertificate(CertificateConfig? certInfo, string endpointName) { if (certInfo is null) { - return null; + return (null, null); } if (certInfo.IsFileCert && certInfo.IsStoreCert) @@ -37,6 +37,9 @@ public CertificateConfigLoader(IHostEnvironment hostEnvironment, ILogger System.Security.Cryptography.X509Certificates.X509Certificate2Collection? +Microsoft.AspNetCore.Server.Kestrel.Https.HttpsConnectionAdapterOptions.ServerCertificateChain.set -> void diff --git a/src/Servers/Kestrel/Core/test/SniOptionsSelectorTests.cs b/src/Servers/Kestrel/Core/test/SniOptionsSelectorTests.cs index 0e1eab5e563e..943cec0b6cf6 100644 --- a/src/Servers/Kestrel/Core/test/SniOptionsSelectorTests.cs +++ b/src/Servers/Kestrel/Core/test/SniOptionsSelectorTests.cs @@ -1,10 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; using System.IO.Pipelines; -using System.Linq; using System.Net.Security; using System.Security.Authentication; using System.Security.Cryptography.X509Certificates; @@ -17,7 +14,6 @@ using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.Logging; using Moq; -using Xunit; namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests; @@ -186,6 +182,70 @@ public void ServerNameMatchingIsCaseInsensitive() Assert.Equal("WildcardPrefix", pathDictionary[aSubdomainOptions.ServerCertificate]); } + [Fact] + public void FullChainCertsCanBeLoaded() + { + var sniDictionary = new Dictionary + { + { + "Www.Example.Org", + new SniConfig + { + Certificate = new CertificateConfig + { + Path = "Exact" + } + } + }, + { + "*.Example.Org", + new SniConfig + { + Certificate = new CertificateConfig + { + Path = "WildcardPrefix" + } + } + } + }; + + var mockCertificateConfigLoader = new MockCertificateConfigLoader(); + var pathDictionary = mockCertificateConfigLoader.CertToPathDictionary; + var fullChainDictionary = mockCertificateConfigLoader.CertToFullChain; + + var sniOptionsSelector = new SniOptionsSelector( + "TestEndpointName", + sniDictionary, + mockCertificateConfigLoader, + fallbackHttpsOptions: new HttpsConnectionAdapterOptions(), + fallbackHttpProtocols: HttpProtocols.Http1AndHttp2, + logger: Mock.Of>()); + + var (wwwSubdomainOptions, _) = sniOptionsSelector.GetOptions(new MockConnectionContext(), "wWw.eXample.oRg"); + Assert.Equal("Exact", pathDictionary[wwwSubdomainOptions.ServerCertificate]); + + var (baSubdomainOptions, _) = sniOptionsSelector.GetOptions(new MockConnectionContext(), "B.a.eXample.oRg"); + Assert.Equal("WildcardPrefix", pathDictionary[baSubdomainOptions.ServerCertificate]); + + var (aSubdomainOptions, _) = sniOptionsSelector.GetOptions(new MockConnectionContext(), "A.eXample.oRg"); + Assert.Equal("WildcardPrefix", pathDictionary[aSubdomainOptions.ServerCertificate]); + + /* + * Chain test certs were created using smallstep cli: https://github.com/smallstep/cli + * root_ca(pwd: testroot) -> + * intermediate_ca 1(pwd: inter) -> + * intermediate_ca 2(pwd: inter) -> + * leaf.com(pwd: leaf) (bundled) + */ + var fullChain = fullChainDictionary[aSubdomainOptions.ServerCertificate]; + // Expect intermediate 2 cert and leaf.com + Assert.Equal(2, fullChain.Count); + Assert.Equal("CN=leaf.com", fullChain[0].Subject); + Assert.Equal("CN=Test Intermediate CA 2", fullChain[0].IssuerName.Name); + Assert.Equal("CN=Test Intermediate CA 2", fullChain[1].Subject); + Assert.Equal("CN=Test Intermediate CA 1", fullChain[1].IssuerName.Name); + } + [Fact] public void MultipleWildcardPrefixServerNamesOfSameLengthAreAllowed() { @@ -848,19 +908,24 @@ public void CloneSslOptionsClonesAllProperties() private class MockCertificateConfigLoader : ICertificateConfigLoader { public Dictionary CertToPathDictionary { get; } = new Dictionary(ReferenceEqualityComparer.Instance); + public Dictionary CertToFullChain { get; } = new Dictionary(ReferenceEqualityComparer.Instance); public bool IsTestMock => true; - public X509Certificate2 LoadCertificate(CertificateConfig certInfo, string endpointName) + public (X509Certificate2, X509Certificate2Collection) LoadCertificate(CertificateConfig certInfo, string endpointName) { if (certInfo is null) { - return null; + return (null, null); } var cert = TestResources.GetTestCertificate(); CertToPathDictionary.Add(cert, certInfo.Path); - return cert; + + var fullChain = TestResources.GetTestChain(); + CertToFullChain[cert] = fullChain; + + return (cert, fullChain); } } diff --git a/src/Servers/Kestrel/Kestrel/test/KestrelConfigurationLoaderTests.cs b/src/Servers/Kestrel/Kestrel/test/KestrelConfigurationLoaderTests.cs index 04361234ee14..f4a10302463e 100644 --- a/src/Servers/Kestrel/Kestrel/test/KestrelConfigurationLoaderTests.cs +++ b/src/Servers/Kestrel/Kestrel/test/KestrelConfigurationLoaderTests.cs @@ -1,10 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; using System.Security.Authentication; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; @@ -16,7 +12,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Hosting; -using Xunit; namespace Microsoft.AspNetCore.Server.Kestrel.Tests; @@ -140,6 +135,7 @@ public void ConfigureDefaultsAppliesToNewConfigureEndpoints() serverOptions.ConfigureHttpsDefaults(opt => { opt.ServerCertificate = TestResources.GetTestCertificate(); + opt.ServerCertificateChain = TestResources.GetTestChain(); opt.ClientCertificateMode = ClientCertificateMode.RequireCertificate; }); @@ -155,6 +151,8 @@ public void ConfigureDefaultsAppliesToNewConfigureEndpoints() ran1 = true; Assert.True(opt.IsHttps); Assert.NotNull(opt.HttpsOptions.ServerCertificate); + Assert.NotNull(opt.HttpsOptions.ServerCertificateChain); + Assert.Equal(2, opt.HttpsOptions.ServerCertificateChain.Count); Assert.Equal(ClientCertificateMode.RequireCertificate, opt.HttpsOptions.ClientCertificateMode); Assert.Equal(HttpProtocols.Http1, opt.ListenOptions.Protocols); }) diff --git a/src/Servers/Kestrel/shared/test/CertHelper.cs b/src/Servers/Kestrel/shared/test/CertHelper.cs new file mode 100644 index 000000000000..75f245da5e45 --- /dev/null +++ b/src/Servers/Kestrel/shared/test/CertHelper.cs @@ -0,0 +1,191 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using System.Net.Sockets; +using System.Runtime.CompilerServices; +using System.Security.Authentication; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace Microsoft.AspNetCore.Testing; + +#nullable enable +// Copied from https://github.com/dotnet/runtime/main/src/libraries/System.Net.Security/tests/FunctionalTests/TestHelper.cs +public static class CertHelper +{ + private static readonly X509KeyUsageExtension s_eeKeyUsage = + new X509KeyUsageExtension( + X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment | X509KeyUsageFlags.DataEncipherment, + critical: false); + + private static readonly X509EnhancedKeyUsageExtension s_tlsServerEku = + new X509EnhancedKeyUsageExtension( + new OidCollection + { + new Oid("1.3.6.1.5.5.7.3.1", null) + }, + false); + + private static readonly X509EnhancedKeyUsageExtension s_tlsClientEku = + new X509EnhancedKeyUsageExtension( + new OidCollection + { + new Oid("1.3.6.1.5.5.7.3.2", null) + }, + false); + + private static readonly X509BasicConstraintsExtension s_eeConstraints = + new X509BasicConstraintsExtension(false, false, 0, false); + + public static bool AllowAnyServerCertificate(object sender, X509Certificate certificate, X509Chain chain) + { + return true; + } + + internal static (NetworkStream ClientStream, NetworkStream ServerStream) GetConnectedTcpStreams() + { + using (Socket listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)) + { + listener.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + listener.Listen(1); + + var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + clientSocket.Connect(listener.LocalEndPoint!); + Socket serverSocket = listener.Accept(); + + serverSocket.NoDelay = true; + clientSocket.NoDelay = true; + + return (new NetworkStream(clientSocket, ownsSocket: true), new NetworkStream(serverSocket, ownsSocket: true)); + } + } + + internal static void CleanupCertificates([CallerMemberName] string? testName = null) + { + string caName = $"O={testName}"; + try + { + using (X509Store store = new X509Store(StoreName.CertificateAuthority, StoreLocation.LocalMachine)) + { + store.Open(OpenFlags.ReadWrite); + foreach (X509Certificate2 cert in store.Certificates) + { + if (cert.Subject.Contains(caName)) + { + store.Remove(cert); + } + cert.Dispose(); + } + } + } + catch { }; + + try + { + using (X509Store store = new X509Store(StoreName.CertificateAuthority, StoreLocation.CurrentUser)) + { + store.Open(OpenFlags.ReadWrite); + foreach (X509Certificate2 cert in store.Certificates) + { + if (cert.Subject.Contains(caName)) + { + store.Remove(cert); + } + cert.Dispose(); + } + } + } + catch { }; + } + + internal static X509ExtensionCollection BuildTlsServerCertExtensions(string serverName) + { + return BuildTlsCertExtensions(serverName, true); + } + + private static X509ExtensionCollection BuildTlsCertExtensions(string targetName, bool serverCertificate) + { + X509ExtensionCollection extensions = new X509ExtensionCollection(); + + SubjectAlternativeNameBuilder builder = new SubjectAlternativeNameBuilder(); + builder.AddDnsName(targetName); + extensions.Add(builder.Build()); + extensions.Add(s_eeConstraints); + extensions.Add(s_eeKeyUsage); + extensions.Add(serverCertificate ? s_tlsServerEku : s_tlsClientEku); + + return extensions; + } + + internal static (X509Certificate2 certificate, X509Certificate2Collection) GenerateCertificates(string targetName, [CallerMemberName] string? testName = null, bool longChain = false, bool serverCertificate = true) + { + const int keySize = 2048; + if (OperatingSystem.IsWindows() && testName != null) + { + CleanupCertificates(testName); + } + + X509Certificate2Collection chain = new X509Certificate2Collection(); + X509ExtensionCollection extensions = BuildTlsCertExtensions(targetName, serverCertificate); + + CertificateAuthority.BuildPrivatePki( + PkiOptions.IssuerRevocationViaCrl, + out RevocationResponder responder, + out CertificateAuthority root, + out CertificateAuthority[] intermediates, + out X509Certificate2 endEntity, + intermediateAuthorityCount: longChain ? 3 : 1, + subjectName: targetName, + testName: testName, + keySize: keySize, + extensions: extensions); + + // Walk the intermediates backwards so we build the chain collection as + // Issuer3 + // Issuer2 + // Issuer1 + // Root + for (int i = intermediates.Length - 1; i >= 0; i--) + { + CertificateAuthority authority = intermediates[i]; + + chain.Add(authority.CloneIssuerCert()); + authority.Dispose(); + } + + chain.Add(root.CloneIssuerCert()); + + responder.Dispose(); + root.Dispose(); + + if (OperatingSystem.IsWindows()) + { + X509Certificate2 ephemeral = endEntity; + endEntity = new X509Certificate2(endEntity.Export(X509ContentType.Pfx), (string?)null, X509KeyStorageFlags.Exportable); + ephemeral.Dispose(); + } + + return (endEntity, chain); + } + + internal static string GetTestSNIName(string testMethodName, params SslProtocols?[] protocols) + { + static string ProtocolToString(SslProtocols? protocol) + { + return (protocol?.ToString() ?? "null").Replace(", ", "-"); + } + + var args = string.Join(".", protocols.Select(p => ProtocolToString(p))); + var name = testMethodName.Length > 63 ? testMethodName.Substring(0, 63) : testMethodName; + + name = $"{name}.{args}"; + if (OperatingSystem.IsAndroid()) + { + // Android does not support underscores in host names + name = name.Replace("_", string.Empty); + } + + return name; + } +} diff --git a/src/Servers/Kestrel/shared/test/CertificateAuthority.cs b/src/Servers/Kestrel/shared/test/CertificateAuthority.cs new file mode 100644 index 000000000000..14075ccec743 --- /dev/null +++ b/src/Servers/Kestrel/shared/test/CertificateAuthority.cs @@ -0,0 +1,951 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Formats.Asn1; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +// Copied from https://github.com/dotnet/runtime/blob/main/src/libraries/Common/tests/System/Security/Cryptography/X509Certificates/CertificateAuthority.cs +namespace Microsoft.AspNetCore.Testing; + +// This class represents only a portion of what is required to be a proper Certificate Authority. +// +// Please do not use it as the basis for any real Public/Private Key Infrastructure (PKI) system +// without understanding all of the portions of proper CA management that you're skipping. +// +// At minimum, read the current baseline requirements of the CA/Browser Forum. + +[Flags] +public enum PkiOptions +{ + None = 0, + + IssuerRevocationViaCrl = 1 << 0, + IssuerRevocationViaOcsp = 1 << 1, + EndEntityRevocationViaCrl = 1 << 2, + EndEntityRevocationViaOcsp = 1 << 3, + + CrlEverywhere = IssuerRevocationViaCrl | EndEntityRevocationViaCrl, + OcspEverywhere = IssuerRevocationViaOcsp | EndEntityRevocationViaOcsp, + AllIssuerRevocation = IssuerRevocationViaCrl | IssuerRevocationViaOcsp, + AllEndEntityRevocation = EndEntityRevocationViaCrl | EndEntityRevocationViaOcsp, + AllRevocation = CrlEverywhere | OcspEverywhere, + + IssuerAuthorityHasDesignatedOcspResponder = 1 << 16, + RootAuthorityHasDesignatedOcspResponder = 1 << 17, + NoIssuerCertDistributionUri = 1 << 18, + NoRootCertDistributionUri = 1 << 18, +} + +internal sealed class CertificateAuthority : IDisposable +{ + private static readonly Asn1Tag s_context0 = new Asn1Tag(TagClass.ContextSpecific, 0); + private static readonly Asn1Tag s_context1 = new Asn1Tag(TagClass.ContextSpecific, 1); + private static readonly Asn1Tag s_context2 = new Asn1Tag(TagClass.ContextSpecific, 2); + + private static readonly X500DistinguishedName s_nonParticipatingName = + new X500DistinguishedName("CN=The Ghost in the Machine"); + + private static readonly X509BasicConstraintsExtension s_eeConstraints = + new X509BasicConstraintsExtension(false, false, 0, false); + + private static readonly X509KeyUsageExtension s_caKeyUsage = + new X509KeyUsageExtension( + X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, + critical: false); + + private static readonly X509KeyUsageExtension s_eeKeyUsage = + new X509KeyUsageExtension( + X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment | X509KeyUsageFlags.DataEncipherment, + critical: false); + + private static readonly X509EnhancedKeyUsageExtension s_ocspResponderEku = + new X509EnhancedKeyUsageExtension( + new OidCollection + { + new Oid("1.3.6.1.5.5.7.3.9", null), + }, + critical: false); + + private static readonly X509EnhancedKeyUsageExtension s_tlsClientEku = + new X509EnhancedKeyUsageExtension( + new OidCollection + { + new Oid("1.3.6.1.5.5.7.3.2", null) + }, + false); + + private X509Certificate2 _cert; + private byte[] _certData; + private X509Extension _cdpExtension; + private X509Extension _aiaExtension; + private X509AuthorityKeyIdentifierExtension _akidExtension; + + private List<(byte[], DateTimeOffset)> _revocationList; + private byte[] _crl; + private int _crlNumber; + private DateTimeOffset _crlExpiry; + private X509Certificate2 _ocspResponder; + private byte[] _dnHash; + + internal string AiaHttpUri { get; } + internal string CdpUri { get; } + internal string OcspUri { get; } + + internal bool CorruptRevocationSignature { get; set; } + internal DateTimeOffset? RevocationExpiration { get; set; } + internal bool CorruptRevocationIssuerName { get; set; } + internal bool OmitNextUpdateInCrl { get; set; } + + // All keys created in this method are smaller than recommended, + // but they only live for a few seconds (at most), + // and never communicate out of process. + const int DefaultKeySize = 1024; + + internal CertificateAuthority( + X509Certificate2 cert, + string aiaHttpUrl, + string cdpUrl, + string ocspUrl) + { + _cert = cert; + AiaHttpUri = aiaHttpUrl; + CdpUri = cdpUrl; + OcspUri = ocspUrl; + } + + public void Dispose() + { + _cert.Dispose(); + _ocspResponder?.Dispose(); + } + + internal string SubjectName => _cert.Subject; + internal bool HasOcspDelegation => _ocspResponder != null; + internal string OcspResponderSubjectName => (_ocspResponder ?? _cert).Subject; + + internal X509Certificate2 CloneIssuerCert() + { + return new X509Certificate2(_cert.RawData); + } + + internal void Revoke(X509Certificate2 certificate, DateTimeOffset revocationTime) + { + if (!certificate.IssuerName.RawData.SequenceEqual(_cert.SubjectName.RawData)) + { + throw new ArgumentException("Certificate was not from this issuer", nameof(certificate)); + } + + if (_revocationList == null) + { + _revocationList = new List<(byte[], DateTimeOffset)>(); + } + + byte[] serial = certificate.SerialNumberBytes.ToArray(); + _revocationList.Add((serial, revocationTime)); + _crl = null; + } + + internal X509Certificate2 CreateSubordinateCA( + string subject, + RSA publicKey, + int? depthLimit = null) + { + return CreateCertificate( + subject, + publicKey, + TimeSpan.FromMinutes(1), + new X509ExtensionCollection() { + new X509BasicConstraintsExtension( + certificateAuthority: true, + depthLimit.HasValue, + depthLimit.GetValueOrDefault(), + critical: true), + s_caKeyUsage }); + } + + internal X509Certificate2 CreateEndEntity(string subject, RSA publicKey, X509ExtensionCollection extensions) + { + return CreateCertificate( + subject, + publicKey, + TimeSpan.FromSeconds(2), + extensions); + } + + internal X509Certificate2 CreateOcspSigner(string subject, RSA publicKey) + { + return CreateCertificate( + subject, + publicKey, + TimeSpan.FromSeconds(1), + new X509ExtensionCollection() { s_eeConstraints, s_eeKeyUsage, s_ocspResponderEku}, + ocspResponder: true); + } + + internal void RebuildRootWithRevocation() + { + if (_cdpExtension == null && CdpUri != null) + { + _cdpExtension = CreateCdpExtension(CdpUri); + } + + if (_aiaExtension == null && (OcspUri != null || AiaHttpUri != null)) + { + _aiaExtension = CreateAiaExtension(AiaHttpUri, OcspUri); + } + + RebuildRootWithRevocation(_cdpExtension, _aiaExtension); + } + + private void RebuildRootWithRevocation(X509Extension cdpExtension, X509Extension aiaExtension) + { + X500DistinguishedName subjectName = _cert.SubjectName; + + if (!subjectName.RawData.SequenceEqual(_cert.IssuerName.RawData)) + { + throw new InvalidOperationException(); + } + + var req = new CertificateRequest(subjectName, _cert.PublicKey, HashAlgorithmName.SHA256); + + foreach (X509Extension ext in _cert.Extensions) + { + req.CertificateExtensions.Add(ext); + } + + req.CertificateExtensions.Add(cdpExtension); + req.CertificateExtensions.Add(aiaExtension); + + byte[] serial = _cert.SerialNumberBytes.ToArray(); + + X509Certificate2 dispose = _cert; + + using (dispose) + using (RSA rsa = _cert.GetRSAPrivateKey()) + using (X509Certificate2 tmp = req.Create( + subjectName, + X509SignatureGenerator.CreateForRSA(rsa, RSASignaturePadding.Pkcs1), + new DateTimeOffset(_cert.NotBefore), + new DateTimeOffset(_cert.NotAfter), + serial)) + { + _cert = tmp.CopyWithPrivateKey(rsa); + } + } + + private X509Certificate2 CreateCertificate( + string subject, + RSA publicKey, + TimeSpan nestingBuffer, + X509ExtensionCollection extensions, + bool ocspResponder = false) + { + if (_cdpExtension == null && CdpUri != null) + { + _cdpExtension = CreateCdpExtension(CdpUri); + } + + if (_aiaExtension == null && (OcspUri != null || AiaHttpUri != null)) + { + _aiaExtension = CreateAiaExtension(AiaHttpUri, OcspUri); + } + + if (_akidExtension == null) + { + _akidExtension = CreateAkidExtension(); + } + + CertificateRequest request = new CertificateRequest( + subject, + publicKey, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + foreach (X509Extension extension in extensions) + { + request.CertificateExtensions.Add(extension); + } + + // Windows does not accept OCSP Responder certificates which have + // a CDP extension, or an AIA extension with an OCSP endpoint. + if (!ocspResponder) + { + request.CertificateExtensions.Add(_cdpExtension); + request.CertificateExtensions.Add(_aiaExtension); + } + + request.CertificateExtensions.Add(_akidExtension); + request.CertificateExtensions.Add( + new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); + + byte[] serial = new byte[sizeof(long)]; + RandomNumberGenerator.Fill(serial); + + return request.Create( + _cert, + _cert.NotBefore.Add(nestingBuffer), + _cert.NotAfter.Subtract(nestingBuffer), + serial); + } + + internal byte[] GetCertData() + { + return (_certData ??= _cert.RawData); + } + + internal byte[] GetCrl() + { + byte[] crl = _crl; + DateTimeOffset now = DateTimeOffset.UtcNow; + + if (crl != null && now < _crlExpiry) + { + return crl; + } + + DateTimeOffset newExpiry = now.AddSeconds(2); + X509AuthorityKeyIdentifierExtension akid = _akidExtension ??= CreateAkidExtension(); + + if (OmitNextUpdateInCrl) + { + crl = BuildCrlManually(now, newExpiry, akid); + } + else + { + CertificateRevocationListBuilder builder = new CertificateRevocationListBuilder(); + + if (_revocationList is not null) + { + foreach ((byte[] serial, DateTimeOffset when) in _revocationList) + { + builder.AddEntry(serial, when); + } + } + + DateTimeOffset thisUpdate; + DateTimeOffset nextUpdate; + + if (RevocationExpiration.HasValue) + { + nextUpdate = RevocationExpiration.GetValueOrDefault(); + thisUpdate = _cert.NotBefore; + } + else + { + thisUpdate = now; + nextUpdate = newExpiry; + } + + using (RSA key = _cert.GetRSAPrivateKey()) + { + crl = builder.Build( + CorruptRevocationIssuerName ? s_nonParticipatingName : _cert.SubjectName, + X509SignatureGenerator.CreateForRSA(key, RSASignaturePadding.Pkcs1), + _crlNumber, + nextUpdate, + HashAlgorithmName.SHA256, + _akidExtension, + thisUpdate); + } + } + + if (CorruptRevocationSignature) + { + crl[^2] ^= 0xFF; + } + + _crl = crl; + _crlExpiry = newExpiry; + _crlNumber++; + return crl; + } + + private byte[] BuildCrlManually( + DateTimeOffset now, + DateTimeOffset newExpiry, + X509AuthorityKeyIdentifierExtension akidExtension) + { + AsnWriter writer = new AsnWriter(AsnEncodingRules.DER); + + using (writer.PushSequence()) + { + writer.WriteObjectIdentifier("1.2.840.113549.1.1.11"); + writer.WriteNull(); + } + + byte[] signatureAlgId = writer.Encode(); + writer.Reset(); + + // TBSCertList + using (writer.PushSequence()) + { + // version v2(1) + writer.WriteInteger(1); + + // signature (AlgorithmIdentifier) + writer.WriteEncodedValue(signatureAlgId); + + // issuer + if (CorruptRevocationIssuerName) + { + writer.WriteEncodedValue(s_nonParticipatingName.RawData); + } + else + { + writer.WriteEncodedValue(_cert.SubjectName.RawData); + } + + if (RevocationExpiration.HasValue) + { + // thisUpdate + writer.WriteUtcTime(_cert.NotBefore); + + // nextUpdate + if (!OmitNextUpdateInCrl) + { + writer.WriteUtcTime(RevocationExpiration.Value); + } + } + else + { + // thisUpdate + writer.WriteUtcTime(now); + + // nextUpdate + if (!OmitNextUpdateInCrl) + { + writer.WriteUtcTime(newExpiry); + } + } + + // revokedCertificates (don't write down if empty) + if (_revocationList?.Count > 0) + { + // SEQUENCE OF + using (writer.PushSequence()) + { + foreach ((byte[] serial, DateTimeOffset when) in _revocationList) + { + // Anonymous CRL Entry type + using (writer.PushSequence()) + { + writer.WriteInteger(serial); + writer.WriteUtcTime(when); + } + } + } + } + + // extensions [0] EXPLICIT Extensions + using (writer.PushSequence(s_context0)) + { + // Extensions (SEQUENCE OF) + using (writer.PushSequence()) + { + // Authority Key Identifier Extension + using (writer.PushSequence()) + { + writer.WriteObjectIdentifier(akidExtension.Oid.Value); + + if (akidExtension.Critical) + { + writer.WriteBoolean(true); + } + + writer.WriteOctetString(akidExtension.RawData); + } + + // CRL Number Extension + using (writer.PushSequence()) + { + writer.WriteObjectIdentifier("2.5.29.20"); + + using (writer.PushOctetString()) + { + writer.WriteInteger(_crlNumber); + } + } + } + } + } + + byte[] tbsCertList = writer.Encode(); + writer.Reset(); + + byte[] signature; + + using (RSA key = _cert.GetRSAPrivateKey()) + { + signature = + key.SignData(tbsCertList, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + if (CorruptRevocationSignature) + { + signature[5] ^= 0xFF; + } + } + + // CertificateList + using (writer.PushSequence()) + { + writer.WriteEncodedValue(tbsCertList); + writer.WriteEncodedValue(signatureAlgId); + writer.WriteBitString(signature); + } + + return writer.Encode(); + } + + internal void DesignateOcspResponder(X509Certificate2 responder) + { + _ocspResponder = responder; + } + + internal byte[] BuildOcspResponse( + ReadOnlyMemory certId, + ReadOnlyMemory nonceExtension) + { + DateTimeOffset now = DateTimeOffset.UtcNow; + + DateTimeOffset revokedTime = default; + CertStatus status = CheckRevocation(certId, ref revokedTime); + X509Certificate2 responder = (_ocspResponder ?? _cert); + + AsnWriter writer = new AsnWriter(AsnEncodingRules.DER); + + /* +ResponseData ::= SEQUENCE { + version [0] EXPLICIT Version DEFAULT v1, + responderID ResponderID, + producedAt GeneralizedTime, + responses SEQUENCE OF SingleResponse, + responseExtensions [1] EXPLICIT Extensions OPTIONAL } + */ + using (writer.PushSequence()) + { + // Skip version (v1) + + /* +ResponderID ::= CHOICE { +byName [1] Name, +byKey [2] KeyHash } + */ + + using (writer.PushSequence(s_context1)) + { + if (CorruptRevocationIssuerName) + { + writer.WriteEncodedValue(s_nonParticipatingName.RawData); + } + else + { + writer.WriteEncodedValue(responder.SubjectName.RawData); + } + } + + writer.WriteGeneralizedTime(now, omitFractionalSeconds: true); + + using (writer.PushSequence()) + { + /* +SingleResponse ::= SEQUENCE { +certID CertID, +certStatus CertStatus, +thisUpdate GeneralizedTime, +nextUpdate [0] EXPLICIT GeneralizedTime OPTIONAL, +singleExtensions [1] EXPLICIT Extensions OPTIONAL } + */ + using (writer.PushSequence()) + { + writer.WriteEncodedValue(certId.Span); + + if (status == CertStatus.OK) + { + writer.WriteNull(s_context0); + } + else if (status == CertStatus.Revoked) + { + // Android does not support all precisions for seconds - just omit fractional seconds for testing on Android + writer.PushSequence(s_context1); + writer.WriteGeneralizedTime(revokedTime, omitFractionalSeconds: OperatingSystem.IsAndroid()); + writer.PopSequence(s_context1); + } + else + { + Assert.Equal(CertStatus.Unknown, status); + writer.WriteNull(s_context2); + } + + if (RevocationExpiration.HasValue) + { + writer.WriteGeneralizedTime( + _cert.NotBefore, + omitFractionalSeconds: true); + + using (writer.PushSequence(s_context0)) + { + writer.WriteGeneralizedTime( + RevocationExpiration.Value, + omitFractionalSeconds: true); + } + } + else + { + writer.WriteGeneralizedTime(now, omitFractionalSeconds: true); + } + } + } + + if (!nonceExtension.IsEmpty) + { + using (writer.PushSequence(s_context1)) + using (writer.PushSequence()) + { + writer.WriteEncodedValue(nonceExtension.Span); + } + } + } + + byte[] tbsResponseData = writer.Encode(); + writer.Reset(); + + /* + BasicOCSPResponse ::= SEQUENCE { +tbsResponseData ResponseData, +signatureAlgorithm AlgorithmIdentifier, +signature BIT STRING, +certs [0] EXPLICIT SEQUENCE OF Certificate OPTIONAL } + */ + using (writer.PushSequence()) + { + writer.WriteEncodedValue(tbsResponseData); + + using (writer.PushSequence()) + { + writer.WriteObjectIdentifier("1.2.840.113549.1.1.11"); + writer.WriteNull(); + } + + using (RSA rsa = responder.GetRSAPrivateKey()) + { + byte[] signature = rsa.SignData( + tbsResponseData, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + if (CorruptRevocationSignature) + { + signature[5] ^= 0xFF; + } + + writer.WriteBitString(signature); + } + + if (_ocspResponder != null) + { + using (writer.PushSequence(s_context0)) + using (writer.PushSequence()) + { + writer.WriteEncodedValue(_ocspResponder.RawData); + writer.PopSequence(); + } + } + } + + byte[] responseBytes = writer.Encode(); + writer.Reset(); + + using (writer.PushSequence()) + { + writer.WriteEnumeratedValue(OcspResponseStatus.Successful); + + using (writer.PushSequence(s_context0)) + using (writer.PushSequence()) + { + writer.WriteObjectIdentifier("1.3.6.1.5.5.7.48.1.1"); + writer.WriteOctetString(responseBytes); + } + } + + return writer.Encode(); + } + + private CertStatus CheckRevocation(ReadOnlyMemory certId, ref DateTimeOffset revokedTime) + { + AsnReader reader = new AsnReader(certId, AsnEncodingRules.DER); + AsnReader idReader = reader.ReadSequence(); + reader.ThrowIfNotEmpty(); + + AsnReader algIdReader = idReader.ReadSequence(); + + if (algIdReader.ReadObjectIdentifier() != "1.3.14.3.2.26") + { + return CertStatus.Unknown; + } + + if (algIdReader.HasData) + { + algIdReader.ReadNull(); + algIdReader.ThrowIfNotEmpty(); + } + + if (_dnHash == null) + { + _dnHash = SHA1.HashData(_cert.SubjectName.RawData); + } + + if (!idReader.TryReadPrimitiveOctetString(out ReadOnlyMemory reqDn)) + { + idReader.ThrowIfNotEmpty(); + } + + if (!reqDn.Span.SequenceEqual(_dnHash)) + { + return CertStatus.Unknown; + } + + if (!idReader.TryReadPrimitiveOctetString(out ReadOnlyMemory reqKeyHash)) + { + idReader.ThrowIfNotEmpty(); + } + + // We could check the key hash... + + ReadOnlyMemory reqSerial = idReader.ReadIntegerBytes(); + idReader.ThrowIfNotEmpty(); + + if (_revocationList == null) + { + return CertStatus.OK; + } + + ReadOnlySpan reqSerialSpan = reqSerial.Span; + + foreach ((byte[] serial, DateTimeOffset time) in _revocationList) + { + if (reqSerialSpan.SequenceEqual(serial)) + { + revokedTime = time; + return CertStatus.Revoked; + } + } + + return CertStatus.OK; + } + + private static X509Extension CreateAiaExtension(string certLocation, string ocspStem) + { + string[] ocsp = null; + string[] caIssuers = null; + + if (ocspStem is not null) + { + ocsp = new[] { ocspStem }; + } + + if (certLocation is not null) + { + caIssuers = new[] { certLocation }; + } + + return new X509AuthorityInformationAccessExtension(ocsp, caIssuers); + } + + private static X509Extension CreateCdpExtension(string cdp) + { + return CertificateRevocationListBuilder.BuildCrlDistributionPointExtension(new[] { cdp }); + } + + private X509AuthorityKeyIdentifierExtension CreateAkidExtension() + { + X509SubjectKeyIdentifierExtension skid = + _cert.Extensions.OfType().SingleOrDefault(); + + if (skid is null) + { + return X509AuthorityKeyIdentifierExtension.CreateFromCertificate( + _cert, + includeKeyIdentifier: false, + includeIssuerAndSerial: true); + } + + return X509AuthorityKeyIdentifierExtension.CreateFromSubjectKeyIdentifier(skid); + } + + private enum OcspResponseStatus + { + Successful, + } + + private enum CertStatus + { + Unknown, + OK, + Revoked, + } + + internal static void BuildPrivatePki( + PkiOptions pkiOptions, + out RevocationResponder responder, + out CertificateAuthority rootAuthority, + out CertificateAuthority[] intermediateAuthorities, + out X509Certificate2 endEntityCert, + int intermediateAuthorityCount, + string testName = null, + bool registerAuthorities = true, + bool pkiOptionsInSubject = false, + string subjectName = null, + int keySize = DefaultKeySize, + X509ExtensionCollection extensions = null) + { + bool rootDistributionViaHttp = !pkiOptions.HasFlag(PkiOptions.NoRootCertDistributionUri); + bool issuerRevocationViaCrl = pkiOptions.HasFlag(PkiOptions.IssuerRevocationViaCrl); + bool issuerRevocationViaOcsp = pkiOptions.HasFlag(PkiOptions.IssuerRevocationViaOcsp); + bool issuerDistributionViaHttp = !pkiOptions.HasFlag(PkiOptions.NoIssuerCertDistributionUri); + bool endEntityRevocationViaCrl = pkiOptions.HasFlag(PkiOptions.EndEntityRevocationViaCrl); + bool endEntityRevocationViaOcsp = pkiOptions.HasFlag(PkiOptions.EndEntityRevocationViaOcsp); + + Assert.True( + issuerRevocationViaCrl || issuerRevocationViaOcsp || + endEntityRevocationViaCrl || endEntityRevocationViaOcsp, + "At least one revocation mode is enabled"); + + // default to client + extensions ??= new X509ExtensionCollection() { s_eeConstraints, s_eeKeyUsage, s_tlsClientEku }; + + using (RSA rootKey = RSA.Create(keySize)) + using (RSA eeKey = RSA.Create(keySize)) + { + var rootReq = new CertificateRequest( + BuildSubject("A Revocation Test Root", testName, pkiOptions, pkiOptionsInSubject), + rootKey, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + X509BasicConstraintsExtension caConstraints = + new X509BasicConstraintsExtension(true, false, 0, true); + + rootReq.CertificateExtensions.Add(caConstraints); + var rootSkid = new X509SubjectKeyIdentifierExtension(rootReq.PublicKey, false); + rootReq.CertificateExtensions.Add( + rootSkid); + + DateTimeOffset start = DateTimeOffset.UtcNow; + DateTimeOffset end = start.AddMonths(3); + + // Don't dispose this, it's being transferred to the CertificateAuthority + X509Certificate2 rootCert = rootReq.CreateSelfSigned(start.AddDays(-2), end.AddDays(2)); + responder = RevocationResponder.CreateAndListen(); + + string certUrl = $"{responder.UriPrefix}cert/{rootSkid.SubjectKeyIdentifier}.cer"; + string cdpUrl = $"{responder.UriPrefix}crl/{rootSkid.SubjectKeyIdentifier}.crl"; + string ocspUrl = $"{responder.UriPrefix}ocsp/{rootSkid.SubjectKeyIdentifier}"; + + rootAuthority = new CertificateAuthority( + rootCert, + rootDistributionViaHttp ? certUrl : null, + issuerRevocationViaCrl ? cdpUrl : null, + issuerRevocationViaOcsp ? ocspUrl : null); + + CertificateAuthority issuingAuthority = rootAuthority; + intermediateAuthorities = new CertificateAuthority[intermediateAuthorityCount]; + + for (int intermediateIndex = 0; intermediateIndex < intermediateAuthorityCount; intermediateIndex++) + { + using RSA intermediateKey = RSA.Create(keySize); + + // Don't dispose this, it's being transferred to the CertificateAuthority + X509Certificate2 intermedCert; + + { + X509Certificate2 intermedPub = issuingAuthority.CreateSubordinateCA( + BuildSubject($"A Revocation Test CA {intermediateIndex}", testName, pkiOptions, pkiOptionsInSubject), + intermediateKey); + intermedCert = intermedPub.CopyWithPrivateKey(intermediateKey); + intermedPub.Dispose(); + } + + X509SubjectKeyIdentifierExtension intermedSkid = + intermedCert.Extensions.OfType().Single(); + + certUrl = $"{responder.UriPrefix}cert/{intermedSkid.SubjectKeyIdentifier}.cer"; + cdpUrl = $"{responder.UriPrefix}crl/{intermedSkid.SubjectKeyIdentifier}.crl"; + ocspUrl = $"{responder.UriPrefix}ocsp/{intermedSkid.SubjectKeyIdentifier}"; + + CertificateAuthority intermediateAuthority = new CertificateAuthority( + intermedCert, + issuerDistributionViaHttp ? certUrl : null, + endEntityRevocationViaCrl ? cdpUrl : null, + endEntityRevocationViaOcsp ? ocspUrl : null); + + issuingAuthority = intermediateAuthority; + intermediateAuthorities[intermediateIndex] = intermediateAuthority; + } + + endEntityCert = issuingAuthority.CreateEndEntity( + BuildSubject(subjectName ?? "A Revocation Test Cert", testName, pkiOptions, pkiOptionsInSubject), + eeKey, + extensions); + + X509Certificate2 tmp = endEntityCert; + endEntityCert = endEntityCert.CopyWithPrivateKey(eeKey); + tmp.Dispose(); + } + + if (registerAuthorities) + { + responder.AddCertificateAuthority(rootAuthority); + + foreach (CertificateAuthority authority in intermediateAuthorities) + { + responder.AddCertificateAuthority(authority); + } + } + } + + internal static void BuildPrivatePki( + PkiOptions pkiOptions, + out RevocationResponder responder, + out CertificateAuthority rootAuthority, + out CertificateAuthority intermediateAuthority, + out X509Certificate2 endEntityCert, + string testName = null, + bool registerAuthorities = true, + bool pkiOptionsInSubject = false, + string subjectName = null, + int keySize = DefaultKeySize, + X509ExtensionCollection extensions = null) + { + + BuildPrivatePki( + pkiOptions, + out responder, + out rootAuthority, + out CertificateAuthority[] intermediateAuthorities, + out endEntityCert, + intermediateAuthorityCount: 1, + testName: testName, + registerAuthorities: registerAuthorities, + pkiOptionsInSubject: pkiOptionsInSubject, + subjectName: subjectName, + keySize: keySize, + extensions: extensions); + + intermediateAuthority = intermediateAuthorities.Single(); + } + + private static string BuildSubject( + string cn, + string testName, + PkiOptions pkiOptions, + bool includePkiOptions) + { + if (includePkiOptions) + { + return $"CN=\"{cn}\", O=\"{testName}\", OU=\"{pkiOptions}\""; + } + + return $"CN=\"{cn}\", O=\"{testName}\""; + } +} diff --git a/src/Servers/Kestrel/shared/test/RevocationResponder.cs b/src/Servers/Kestrel/shared/test/RevocationResponder.cs new file mode 100644 index 000000000000..9691f168b10c --- /dev/null +++ b/src/Servers/Kestrel/shared/test/RevocationResponder.cs @@ -0,0 +1,426 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Formats.Asn1; +using System.Net; +using System.Security.Cryptography; +using System.Web; + +namespace Microsoft.AspNetCore.Testing; + +// Copied from https://github.com/dotnet/runtime/blob/main/src/libraries/Common/tests/System/Security/Cryptography/X509Certificates/RevocationResponder.cs +internal sealed class RevocationResponder : IDisposable +{ + private static readonly bool s_traceEnabled = + Environment.GetEnvironmentVariable("TRACE_REVOCATION_RESPONSE") != null; + + private readonly HttpListener _listener; + + private readonly Dictionary _aiaPaths = + new Dictionary(); + + private readonly Dictionary _crlPaths + = new Dictionary(); + + private readonly List<(string, CertificateAuthority)> _ocspAuthorities = + new List<(string, CertificateAuthority)>(); + + public string UriPrefix { get; } + + public bool RespondEmpty { get; set; } + + public TimeSpan ResponseDelay { get; set; } + public DelayedActionsFlag DelayedActions { get; set; } + + private RevocationResponder(HttpListener listener, string uriPrefix) + { + _listener = listener; + UriPrefix = uriPrefix; + } + + public void Dispose() + { + _listener.Close(); + } + + internal void AddCertificateAuthority(CertificateAuthority authority) + { + if (authority.AiaHttpUri != null && authority.AiaHttpUri.StartsWith(UriPrefix, StringComparison.OrdinalIgnoreCase)) + { + string path = authority.AiaHttpUri.Substring(UriPrefix.Length - 1); + Trace($"Adding AIA path : {path}"); + _aiaPaths.Add(path, authority); + } + + if (authority.CdpUri != null && authority.CdpUri.StartsWith(UriPrefix, StringComparison.OrdinalIgnoreCase)) + { + string path = authority.CdpUri.Substring(UriPrefix.Length - 1); + Trace($"Adding CRL path : {path}"); + _crlPaths.Add(path, authority); + } + + if (authority.OcspUri != null && authority.OcspUri.StartsWith(UriPrefix, StringComparison.OrdinalIgnoreCase)) + { + string path = authority.OcspUri.Substring(UriPrefix.Length - 1); + Trace($"Adding OCSP path : {path}"); + _ocspAuthorities.Add((path, authority)); + } + } + + private void HandleRequests() + { + ThreadPool.QueueUserWorkItem( + state => + { + while (state._listener.IsListening) + { + state.HandleRequest(); + } + }, + this, + true); + } + + internal void HandleRequest() + { + HttpListenerContext context = null; + + try + { + context = _listener.GetContext(); + } + catch (Exception) + { + } + + if (context != null) + { + ThreadPool.QueueUserWorkItem( + state => HandleRequest(state), + context, + true); + } + } + + internal async Task HandleRequestAsync() + { + HttpListenerContext context = null; + + try + { + context = await _listener.GetContextAsync(); + } + catch (Exception) + { + } + + if (context != null) + { + ThreadPool.QueueUserWorkItem( + state => HandleRequest(state), + context, + true); + } + } + + internal void HandleRequest(HttpListenerContext context) + { + bool responded = false; + try + { + Trace($"{context.Request.HttpMethod} {context.Request.RawUrl} (HTTP {context.Request.ProtocolVersion})"); + HandleRequest(context, ref responded); + } + catch (Exception e) + { + try + { + if (!responded && context != null) + { + context.Response.StatusCode = 500; + context.Response.StatusDescription = "Internal Server Error"; + context.Response.Close(); + + Trace($"Sent 500 due to exception on {context.Request.HttpMethod} {context.Request.RawUrl}"); + Trace(e.ToString()); + } + } + catch (Exception) + { + } + + return; + } + + if (!responded) + { + Trace($"404 for {context.Request.HttpMethod} {context.Request.RawUrl}"); + + try + { + context.Response.StatusCode = 404; + context.Response.Close(); + } + catch (Exception) + { + } + } + } + + private void HandleRequest(HttpListenerContext context, ref bool responded) + { + CertificateAuthority authority; + string url = context.Request.RawUrl; + + if (_aiaPaths.TryGetValue(url, out authority)) + { + if (DelayedActions.HasFlag(DelayedActionsFlag.Aia)) + { + Trace($"Delaying response by {ResponseDelay}."); + Thread.Sleep(ResponseDelay); + } + + byte[] certData = RespondEmpty ? Array.Empty() : authority.GetCertData(); + + responded = true; + context.Response.StatusCode = 200; + context.Response.ContentType = "application/pkix-cert"; + context.Response.Close(certData, willBlock: true); + Trace($"Responded with {certData.Length}-byte certificate from {authority.SubjectName}."); + return; + } + + if (_crlPaths.TryGetValue(url, out authority)) + { + if (DelayedActions.HasFlag(DelayedActionsFlag.Crl)) + { + Trace($"Delaying response by {ResponseDelay}."); + Thread.Sleep(ResponseDelay); + } + + byte[] crl = RespondEmpty ? Array.Empty() : authority.GetCrl(); + + responded = true; + context.Response.StatusCode = 200; + context.Response.ContentType = "application/pkix-crl"; + context.Response.Close(crl, willBlock: true); + Trace($"Responded with {crl.Length}-byte CRL from {authority.SubjectName}."); + return; + } + + string prefix; + + foreach (var tuple in _ocspAuthorities) + { + (prefix, authority) = tuple; + + if (url.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + byte[] reqBytes; + if (TryGetOcspRequestBytes(context.Request, prefix, out reqBytes)) + { + ReadOnlyMemory certId; + ReadOnlyMemory nonce; + try + { + DecodeOcspRequest(reqBytes, out certId, out nonce); + } + catch (Exception e) + { + Trace($"OcspRequest Decode failed ({url}) - {e}"); + context.Response.StatusCode = 400; + context.Response.Close(); + return; + } + + byte[] ocspResponse = RespondEmpty ? Array.Empty() : authority.BuildOcspResponse(certId, nonce); + + if (DelayedActions.HasFlag(DelayedActionsFlag.Ocsp)) + { + Trace($"Delaying response by {ResponseDelay}."); + Thread.Sleep(ResponseDelay); + } + + responded = true; + context.Response.StatusCode = 200; + context.Response.StatusDescription = "OK"; + context.Response.ContentType = "application/ocsp-response"; + context.Response.Close(ocspResponse, willBlock: true); + + if (authority.HasOcspDelegation) + { + Trace($"OCSP Response: {ocspResponse.Length} bytes from {authority.SubjectName} delegated to {authority.OcspResponderSubjectName}"); + } + else + { + Trace($"OCSP Response: {ocspResponse.Length} bytes from {authority.SubjectName}"); + } + + return; + } + } + } + } + + internal static RevocationResponder CreateAndListen() + { + HttpListener listener = OpenListener(out string uriPrefix); + + RevocationResponder responder = new RevocationResponder(listener, uriPrefix); + responder.HandleRequests(); + return responder; + } + + private static HttpListener OpenListener(out string uriPrefix) + { + while (true) + { + int port = RandomNumberGenerator.GetInt32(41000, 42000); + uriPrefix = $"http://127.0.0.1:{port}/"; + + HttpListener listener = new HttpListener(); + listener.Prefixes.Add(uriPrefix); + listener.IgnoreWriteExceptions = true; + + try + { + listener.Start(); + Trace($"Listening at {uriPrefix}"); + return listener; + } + catch + { + } + } + } + + private static bool TryGetOcspRequestBytes(HttpListenerRequest request, string prefix, out byte[] requestBytes) + { + requestBytes = null; + try + { + if (request.HttpMethod == "GET") + { + string base64 = HttpUtility.UrlDecode(request.RawUrl.Substring(prefix.Length + 1)); + requestBytes = Convert.FromBase64String(base64); + return true; + } + else if (request.HttpMethod == "POST" && request.ContentType == "application/ocsp-request") + { + using (System.IO.Stream stream = request.InputStream) + { + requestBytes = new byte[request.ContentLength64]; + int read = stream.Read(requestBytes, 0, requestBytes.Length); + System.Diagnostics.Debug.Assert(read == requestBytes.Length); + return true; + } + } + } + catch (Exception e) + { + Trace($"Failed to get OCSP request bytes ({request.RawUrl}) - {e}"); + } + + return false; + } + + private static void DecodeOcspRequest( + byte[] requestBytes, + out ReadOnlyMemory certId, + out ReadOnlyMemory nonceExtension) + { + Asn1Tag context0 = new Asn1Tag(TagClass.ContextSpecific, 0); + Asn1Tag context1 = new Asn1Tag(TagClass.ContextSpecific, 1); + + AsnReader reader = new AsnReader(requestBytes, AsnEncodingRules.DER); + AsnReader request = reader.ReadSequence(); + reader.ThrowIfNotEmpty(); + + AsnReader tbsRequest = request.ReadSequence(); + + if (request.HasData) + { + // Optional signature + request.ReadEncodedValue(); + request.ThrowIfNotEmpty(); + } + + // Only v1(0) is supported, and it shouldn't be written per DER. + // But Apple writes it anyways, so let's go ahead and be lenient. + if (tbsRequest.PeekTag().HasSameClassAndValue(context0)) + { + AsnReader versionReader = tbsRequest.ReadSequence(context0); + + if (!versionReader.TryReadInt32(out int version) || version != 0) + { + throw new CryptographicException("ASN1 corrupted data"); + } + + versionReader.ThrowIfNotEmpty(); + } + + if (tbsRequest.PeekTag().HasSameClassAndValue(context1)) + { + tbsRequest.ReadEncodedValue(); + } + + AsnReader requestList = tbsRequest.ReadSequence(); + AsnReader requestExtensions = null; + + if (tbsRequest.HasData) + { + AsnReader requestExtensionsWrapper = tbsRequest.ReadSequence(new Asn1Tag(TagClass.ContextSpecific, 2)); + requestExtensions = requestExtensionsWrapper.ReadSequence(); + requestExtensionsWrapper.ThrowIfNotEmpty(); + } + + tbsRequest.ThrowIfNotEmpty(); + + AsnReader firstRequest = requestList.ReadSequence(); + requestList.ThrowIfNotEmpty(); + + certId = firstRequest.ReadEncodedValue(); + + if (firstRequest.HasData) + { + firstRequest.ReadSequence(context0); + } + + firstRequest.ThrowIfNotEmpty(); + + nonceExtension = default; + + if (requestExtensions != null) + { + while (requestExtensions.HasData) + { + ReadOnlyMemory wholeExtension = requestExtensions.PeekEncodedValue(); + AsnReader extension = requestExtensions.ReadSequence(); + + if (extension.ReadObjectIdentifier() == "1.3.6.1.5.5.7.48.1.2") + { + nonceExtension = wholeExtension; + } + } + } + } + + internal void Stop() => _listener.Stop(); + + private static void Trace(string trace) + { + if (s_traceEnabled) + { + Console.WriteLine(trace); + } + } +} + +public enum DelayedActionsFlag : byte +{ + None = 0, + Ocsp = 0b1, + Crl = 0b10, + Aia = 0b100, + All = 0b11111111 +} diff --git a/src/Servers/Kestrel/shared/test/TestCertificates/intermediate2_ca.crt b/src/Servers/Kestrel/shared/test/TestCertificates/intermediate2_ca.crt new file mode 100644 index 000000000000..e6ea0b1311a8 --- /dev/null +++ b/src/Servers/Kestrel/shared/test/TestCertificates/intermediate2_ca.crt @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBpTCCAUygAwIBAgIQXGHz02xa/Z/TmuFnYJbDnzAKBggqhkjOPQQDAjAhMR8w +HQYDVQQDExZUZXN0IEludGVybWVkaWF0ZSBDQSAxMB4XDTIyMDgwNDAyMDI1M1oX +DTMyMDgwMTAyMDI1M1owITEfMB0GA1UEAxMWVGVzdCBJbnRlcm1lZGlhdGUgQ0Eg +MjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABKjRY+RZ5N7KuvqLUnRWf18B7uxP ++aSg0pZ+8rcuplFi+bFJ8RreFtnz5d3I9uay8GuLaRyBtf9TJ8xzr8msWAGjZjBk +MA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSz +pp8o4D5Hj2GpaEGO4claKCh01TAfBgNVHSMEGDAWgBRXGAbzjveEmD2JdmRdRa83 +IYRoHDAKBggqhkjOPQQDAgNHADBEAiA9Dl8IEnwYQJFXGjLXqario1KKTl0na9yR ++5R75MPS6AIgHIvQ+L7skPW9vVwBPKh82Line0fTtFoXHVrncZmdWTQ= +-----END CERTIFICATE----- diff --git a/src/Servers/Kestrel/shared/test/TestCertificates/intermediate2_ca.key b/src/Servers/Kestrel/shared/test/TestCertificates/intermediate2_ca.key new file mode 100644 index 000000000000..7d2e42713261 --- /dev/null +++ b/src/Servers/Kestrel/shared/test/TestCertificates/intermediate2_ca.key @@ -0,0 +1,8 @@ +-----BEGIN EC PRIVATE KEY----- +Proc-Type: 4,ENCRYPTED +DEK-Info: AES-256-CBC,0820556173a34f7a1db6e906d87dd43f + +7Rl3JLUTNgy883s3jsrjFfYwPruP2wMZWNK8N0pVLmtwqBQpgPE5QZueO6dulwWh +uHqM9OC/hMfgnKF4UD9gdNAkt8RS8EZE8yLbjNC30bstYnu+8wh8+mXe4JsNGZmK +d/JFAzm0buQPNSZg7WjbQ+KdSI5NAvHxq4AzXl1LIyk= +-----END EC PRIVATE KEY----- diff --git a/src/Servers/Kestrel/shared/test/TestCertificates/intermediate_ca.crt b/src/Servers/Kestrel/shared/test/TestCertificates/intermediate_ca.crt new file mode 100644 index 000000000000..070a4d3e8e29 --- /dev/null +++ b/src/Servers/Kestrel/shared/test/TestCertificates/intermediate_ca.crt @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBnTCCAUOgAwIBAgIRAKRzk+4+PcDayjDwDDwKQpowCgYIKoZIzj0EAwIwFzEV +MBMGA1UEAxMMVGVzdCBSb290IENBMB4XDTIyMDgwNDAxMDUxN1oXDTMyMDgwMTAx +MDUxN1owITEfMB0GA1UEAxMWVGVzdCBJbnRlcm1lZGlhdGUgQ0EgMTBZMBMGByqG +SM49AgEGCCqGSM49AwEHA0IABPma66cve3jhsOW/GRRedTialf+q94fS1+5690A2 +9tECC4uiLShJjjyYY5NeCcyWkl+q6nAN0Mo2SbRf+H1NiV2jZjBkMA4GA1UdDwEB +/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBRXGAbzjveEmD2J +dmRdRa83IYRoHDAfBgNVHSMEGDAWgBQtqkZL74lrcw8pLfHaEfxFOPQrUDAKBggq +hkjOPQQDAgNIADBFAiA1Hle5SLZTMgQ0FZTHTmZMLSjfYhL6wju1mU8e20riCAIh +AL8k/bDhCSi1ITZy7d2DUBsSuIOsCeur/n1NV/m0be9J +-----END CERTIFICATE----- diff --git a/src/Servers/Kestrel/shared/test/TestCertificates/intermediate_ca.key b/src/Servers/Kestrel/shared/test/TestCertificates/intermediate_ca.key new file mode 100644 index 000000000000..d1a2d57f8ab9 --- /dev/null +++ b/src/Servers/Kestrel/shared/test/TestCertificates/intermediate_ca.key @@ -0,0 +1,8 @@ +-----BEGIN EC PRIVATE KEY----- +Proc-Type: 4,ENCRYPTED +DEK-Info: AES-256-CBC,b0b627e56cbb38608f42bebbc4c4994e + +QxsFr31IRPiBDGGAKXzLidIa9A/cX7k8828O22CPrmxn4XQTkloJV8f58p0vnKbi +E5Qs5ryuBPUvw++By7S2sj7xnwRS8UerGVa2jDUZDAI20MFeaaHAlsBzthQgzovo +PmXtG7ljkeN29Nreod844iaeHYkuG1QKcAXl04WMgCQ= +-----END EC PRIVATE KEY----- diff --git a/src/Servers/Kestrel/shared/test/TestCertificates/leaf.com.crt b/src/Servers/Kestrel/shared/test/TestCertificates/leaf.com.crt new file mode 100644 index 000000000000..1c5de09a2807 --- /dev/null +++ b/src/Servers/Kestrel/shared/test/TestCertificates/leaf.com.crt @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIIBujCCAWCgAwIBAgIQLNgg8bTKvOyNVonxUdeOljAKBggqhkjOPQQDAjAhMR8w +HQYDVQQDExZUZXN0IEludGVybWVkaWF0ZSBDQSAyMB4XDTIyMDgwNDAyMDMzN1oX +DTMyMDgwMTAyMDMzNlowEzERMA8GA1UEAxMIbGVhZi5jb20wWTATBgcqhkjOPQIB +BggqhkjOPQMBBwNCAAQvkWB9GQbJUgGeYBPMA0QXo9LCaTD8gg+In7DtJzPYS15x +ofXWqxSHjZpcKa1VNuL81VCHEwuTPQb7QGFeId4Qo4GHMIGEMA4GA1UdDwEB/wQE +AwIHgDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwHQYDVR0OBBYEFF7T +/Y5c59Au/SXoDsMB1fS/AvRzMB8GA1UdIwQYMBaAFLOmnyjgPkePYaloQY7hyVoo +KHTVMBMGA1UdEQQMMAqCCGxlYWYuY29tMAoGCCqGSM49BAMCA0gAMEUCIQCmIDmB +RcqjhghXby0ALqv8ioCWsJ93TE+iQOWUZPr/8AIgQpEoP1V9+IkBLrjoGu5yhxOn +Xd7OYw8w0BEyjocyh3I= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIBpTCCAUygAwIBAgIQXGHz02xa/Z/TmuFnYJbDnzAKBggqhkjOPQQDAjAhMR8w +HQYDVQQDExZUZXN0IEludGVybWVkaWF0ZSBDQSAxMB4XDTIyMDgwNDAyMDI1M1oX +DTMyMDgwMTAyMDI1M1owITEfMB0GA1UEAxMWVGVzdCBJbnRlcm1lZGlhdGUgQ0Eg +MjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABKjRY+RZ5N7KuvqLUnRWf18B7uxP ++aSg0pZ+8rcuplFi+bFJ8RreFtnz5d3I9uay8GuLaRyBtf9TJ8xzr8msWAGjZjBk +MA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSz +pp8o4D5Hj2GpaEGO4claKCh01TAfBgNVHSMEGDAWgBRXGAbzjveEmD2JdmRdRa83 +IYRoHDAKBggqhkjOPQQDAgNHADBEAiA9Dl8IEnwYQJFXGjLXqario1KKTl0na9yR ++5R75MPS6AIgHIvQ+L7skPW9vVwBPKh82Line0fTtFoXHVrncZmdWTQ= +-----END CERTIFICATE----- diff --git a/src/Servers/Kestrel/shared/test/TestCertificates/leaf.com.key b/src/Servers/Kestrel/shared/test/TestCertificates/leaf.com.key new file mode 100644 index 000000000000..3e72ea61b199 --- /dev/null +++ b/src/Servers/Kestrel/shared/test/TestCertificates/leaf.com.key @@ -0,0 +1,8 @@ +-----BEGIN EC PRIVATE KEY----- +Proc-Type: 4,ENCRYPTED +DEK-Info: AES-256-CBC,987f2fd33cce32c30fad60047f769d05 + +bEYnUZxs2otOyORHVP0guMbMzpn27qrF4p8LNJ8w7wbB8PhhBKFnOCECHBQfoM31 +bFOaEDGmwjTLIBhKvsWkxiMEe49kzI9lIPAjInNTjihS7oHEyQ93tH6Q/xsYdc2a +d8lwqjY4a+w2uW2EJzU1ROySDyX+kQjaA2/mzPhVIfs= +-----END EC PRIVATE KEY----- diff --git a/src/Servers/Kestrel/shared/test/TestCertificates/root_ca.crt b/src/Servers/Kestrel/shared/test/TestCertificates/root_ca.crt new file mode 100644 index 000000000000..5b903c24dac3 --- /dev/null +++ b/src/Servers/Kestrel/shared/test/TestCertificates/root_ca.crt @@ -0,0 +1,10 @@ +-----BEGIN CERTIFICATE----- +MIIBcTCCARegAwIBAgIQYdd9Ti/E/sP2fQ4gILJeoDAKBggqhkjOPQQDAjAXMRUw +EwYDVQQDEwxUZXN0IFJvb3QgQ0EwHhcNMjIwODA0MDEwMzQ3WhcNMzIwODAxMDEw +MzQ3WjAXMRUwEwYDVQQDEwxUZXN0IFJvb3QgQ0EwWTATBgcqhkjOPQIBBggqhkjO +PQMBBwNCAAQLmzooyCtzLAztPVcrQKqGadFdTT7uiyFY1QGBP4e4pZHV6xqTaykL +ci0slpWenTNJvRu99Ro8qLPp7hYDZTtXo0UwQzAOBgNVHQ8BAf8EBAMCAQYwEgYD +VR0TAQH/BAgwBgEB/wIBATAdBgNVHQ4EFgQULapGS++Ja3MPKS3x2hH8RTj0K1Aw +CgYIKoZIzj0EAwIDSAAwRQIgZkIuxnWDipP156JFsg0l4nwZ8WYhPh9GhO+zaAs5 +uTACIQCoj1KDH3Vgkc82EMZQ6QZ9MMxT0KtE/TivfdcRNgtUMA== +-----END CERTIFICATE----- diff --git a/src/Servers/Kestrel/shared/test/TestCertificates/root_ca.key b/src/Servers/Kestrel/shared/test/TestCertificates/root_ca.key new file mode 100644 index 000000000000..54a8eaf74858 --- /dev/null +++ b/src/Servers/Kestrel/shared/test/TestCertificates/root_ca.key @@ -0,0 +1,8 @@ +-----BEGIN EC PRIVATE KEY----- +Proc-Type: 4,ENCRYPTED +DEK-Info: AES-256-CBC,97ea8e6551b8fcf40317474d2ad81a28 + +UHBjG7jKE+/Bdb0wLIw1Lb7MzmtG42eow+8tpWpGUk73EY0iL4x5Yppdz6hZyTdb +peX2qEAtwKOXLeqI2Sup5bSpMACHKfjwGAh6/HGnrCc1MsLUIL2jR0FRpabjlVQV +HfamkEfDaiVY4Rp9r2ckXaSm9waaVjhYMj4jSnhFt0s= +-----END EC PRIVATE KEY----- diff --git a/src/Servers/Kestrel/shared/test/TestResources.cs b/src/Servers/Kestrel/shared/test/TestResources.cs index 0db95cfdd305..125e7613e4ee 100644 --- a/src/Servers/Kestrel/shared/test/TestResources.cs +++ b/src/Servers/Kestrel/shared/test/TestResources.cs @@ -40,4 +40,38 @@ public static X509Certificate2 GetTestCertificate(string certName, string passwo { return new X509Certificate2(GetCertPath(certName), password); } + + public static X509Certificate2 GetTestCertificateWithKey(string certName, string keyName) + { + var cert = X509Certificate2.CreateFromPemFile(GetCertPath(certName), GetCertPath(keyName)); + if (OperatingSystem.IsWindows()) + { + using (cert) + { + return new X509Certificate2(cert.Export(X509ContentType.Pkcs12)); + } + } + return cert; + } + + public static X509Certificate2Collection GetTestChain(string certName = "leaf.com.crt") + { + // On Windows, applications should not import PFX files in parallel to avoid a known system-level + // race condition bug in native code which can cause crashes/corruption of the certificate state. + if (importPfxMutex != null && !importPfxMutex.WaitOne(MutexTimeout)) + { + throw new InvalidOperationException("Cannot acquire the global certificate mutex."); + } + + try + { + var fullChain = new X509Certificate2Collection(); + fullChain.ImportFromPemFile(GetCertPath("leaf.com.crt")); + return fullChain; + } + finally + { + importPfxMutex?.ReleaseMutex(); + } + } } diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsConnectionMiddlewareTests.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsConnectionMiddlewareTests.cs index e2bedfcf36ac..e0ec716e8f9d 100644 --- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsConnectionMiddlewareTests.cs +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsConnectionMiddlewareTests.cs @@ -1,18 +1,13 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; +using System.Globalization; using System.Net; using System.Net.Http; using System.Net.Security; using System.Security.Authentication; using System.Security.Cryptography.X509Certificates; using System.Text; -using System.Threading; -using System.Threading.Tasks; using Microsoft.AspNetCore.Connections.Features; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; @@ -27,7 +22,6 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Moq; -using Xunit; namespace Microsoft.AspNetCore.Server.Kestrel.InMemory.FunctionalTests; @@ -716,6 +710,77 @@ void ConfigureListenOptions(ListenOptions listenOptions) await AssertConnectionResult(stream, true); } + [ConditionalFact] + [OSSkipCondition(OperatingSystems.MacOSX, SkipReason = "Fails on OSX.")] + public async Task ServerCertificateChainInExtraStore() + { + var streams = new List(); + CertHelper.CleanupCertificates(nameof(ServerCertificateChainInExtraStore)); + (var clientCertificate, var clientChain) = CertHelper.GenerateCertificates(nameof(ServerCertificateChainInExtraStore), longChain: true, serverCertificate: false); + + using (var store = new X509Store(StoreName.CertificateAuthority, StoreLocation.CurrentUser)) + { + // add chain certificate so we can construct chain since there is no way how to pass intermediates directly. + store.Open(OpenFlags.ReadWrite); + store.AddRange(clientChain); + store.Close(); + } + + using (var chain = new X509Chain()) + { + chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllFlags; + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + chain.ChainPolicy.DisableCertificateDownloads = true; + var chainStatus = chain.Build(clientCertificate); + } + + void ConfigureListenOptions(ListenOptions listenOptions) + { + listenOptions.UseHttps(new HttpsConnectionAdapterOptions + { + ServerCertificate = _x509Certificate2, + ServerCertificateChain = clientChain, + OnAuthenticate = (con, so) => + { + so.ClientCertificateRequired = true; + so.RemoteCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => + { + Assert.NotEmpty(chain.ChainPolicy.ExtraStore); + Assert.Contains(clientChain[0], chain.ChainPolicy.ExtraStore); + return true; + }; + so.CertificateRevocationCheckMode = X509RevocationMode.NoCheck; + } + }); + } + + await using (var server = new TestServer( + context => context.Response.WriteAsync("hello world"), + new TestServiceContext(LoggerFactory), ConfigureListenOptions)) + { + using (var connection = server.CreateConnection()) + { + var stream = OpenSslStreamWithCert(connection.Stream, clientCertificate); + await stream.AuthenticateAsClientAsync("localhost"); + await AssertConnectionResult(stream, true); + } + } + + CertHelper.CleanupCertificates(nameof(ServerCertificateChainInExtraStore)); + clientCertificate.Dispose(); + var list = (System.Collections.IList)clientChain; + for (var i = 0; i < list.Count; i++) + { + var c = (X509Certificate)list[i]; + c.Dispose(); + } + + foreach (var s in streams) + { + s.Dispose(); + } + } + [ConditionalFact] // TLS 1.2 and lower have to renegotiate the whole connection to get a client cert, and if that hits an error // then the connection is aborted.