From d18fb130ef92316bf22e01d47cb876d9de788495 Mon Sep 17 00:00:00 2001 From: Jeremy Barton Date: Thu, 14 Jul 2022 17:23:53 -0700 Subject: [PATCH 1/7] Add X509 SAN extension and RFC6125 MatchesHostname --- .../Security/Cryptography/Oids.Shared.cs | 17 + .../ExtensionsTests/ComprehensiveTests.cs | 7 +- .../SubjectAlternativeNameTests.cs | 149 ++++ .../tests/MatchesHostnameTests.cs | 758 ++++++++++++++++++ ...Cryptography.X509Certificates.Tests.csproj | 2 + .../ref/System.Security.Cryptography.cs | 10 + .../src/Resources/Strings.resx | 6 + .../src/System.Security.Cryptography.csproj | 1 + .../Security/Cryptography/CryptoConfig.cs | 1 + .../X509Certificates/X509Certificate2.cs | 235 ++++++ .../X509SubjectAlternativeNameExtension.cs | 107 +++ 11 files changed, 1292 insertions(+), 1 deletion(-) create mode 100644 src/libraries/System.Security.Cryptography.X509Certificates/tests/ExtensionsTests/SubjectAlternativeNameTests.cs create mode 100644 src/libraries/System.Security.Cryptography.X509Certificates/tests/MatchesHostnameTests.cs create mode 100644 src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/X509SubjectAlternativeNameExtension.cs diff --git a/src/libraries/Common/src/System/Security/Cryptography/Oids.Shared.cs b/src/libraries/Common/src/System/Security/Cryptography/Oids.Shared.cs index 7e99ffd28dbccf..6181c291311dd5 100644 --- a/src/libraries/Common/src/System/Security/Cryptography/Oids.Shared.cs +++ b/src/libraries/Common/src/System/Security/Cryptography/Oids.Shared.cs @@ -148,5 +148,22 @@ internal static Oid GetSharedOrNewOid(ref AsnValueReader asnValueReader) return null; #endif } + + internal static bool ValueEquals(this Oid oid, Oid? other) + { + Debug.Assert(oid is not null); + + if (ReferenceEquals(oid, other)) + { + return true; + } + + if (other is null) + { + return false; + } + + return oid.Value is not null && oid.Value.Equals(other.Value); + } } } diff --git a/src/libraries/System.Security.Cryptography.X509Certificates/tests/ExtensionsTests/ComprehensiveTests.cs b/src/libraries/System.Security.Cryptography.X509Certificates/tests/ExtensionsTests/ComprehensiveTests.cs index f5eec64f279949..1647ffa91fc283 100644 --- a/src/libraries/System.Security.Cryptography.X509Certificates/tests/ExtensionsTests/ComprehensiveTests.cs +++ b/src/libraries/System.Security.Cryptography.X509Certificates/tests/ExtensionsTests/ComprehensiveTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Linq; +using System.Net; using Test.Cryptography; using Xunit; @@ -75,7 +76,11 @@ public static void ReadExtensions() Assert.Equal(expected, sans.RawData); - Assert.IsType(sans); + // This SAN only contains an alternate DirectoryName entry, so both the DNSNames and + // IPAddresses enumerations being empty is correct. + X509SubjectAlternativeNameExtension rich = Assert.IsType(sans); + Assert.Equal(Enumerable.Empty(), rich.EnumerateDnsNames()); + Assert.Equal(Enumerable.Empty(), rich.EnumerateIPAddresses()); } { diff --git a/src/libraries/System.Security.Cryptography.X509Certificates/tests/ExtensionsTests/SubjectAlternativeNameTests.cs b/src/libraries/System.Security.Cryptography.X509Certificates/tests/ExtensionsTests/SubjectAlternativeNameTests.cs new file mode 100644 index 00000000000000..2b11dd073dbe76 --- /dev/null +++ b/src/libraries/System.Security.Cryptography.X509Certificates/tests/ExtensionsTests/SubjectAlternativeNameTests.cs @@ -0,0 +1,149 @@ +// 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 Xunit; + +namespace System.Security.Cryptography.X509Certificates.Tests.ExtensionsTests +{ + public static class SubjectAlternativeNameTests + { + [Fact] + public static void DefaultConstructor() + { + X509SubjectAlternativeNameExtension ext = new X509SubjectAlternativeNameExtension(); + Assert.Empty(ext.RawData); + Assert.Equal("2.5.29.17", ext.Oid.Value); + Assert.Empty(ext.EnumerateDnsNames()); + Assert.Empty(ext.EnumerateIPAddresses()); + } + + [Fact] + public static void ArrayCtorRejectsNull() + { + Assert.Throws( + "rawData", + () => new X509SubjectAlternativeNameExtension((byte[])null)); + } + + [Theory] + [InlineData(LoadMode.CopyFrom)] + [InlineData(LoadMode.Array)] + [InlineData(LoadMode.Span)] + public static void EnumerateDnsNames(LoadMode loadMode) + { + SubjectAlternativeNameBuilder builder = new SubjectAlternativeNameBuilder(); + builder.AddDnsName("foo"); + builder.AddIpAddress(IPAddress.Loopback); + builder.AddUserPrincipalName("user@some.domain"); + builder.AddIpAddress(IPAddress.IPv6Loopback); + builder.AddDnsName("*.foo"); + X509Extension built = builder.Build(true); + + X509SubjectAlternativeNameExtension ext; + + switch (loadMode) + { + case LoadMode.CopyFrom: + ext = new X509SubjectAlternativeNameExtension(); + ext.CopyFrom(built); + break; + case LoadMode.Array: + ext = new X509SubjectAlternativeNameExtension(built.RawData); + break; + case LoadMode.Span: + byte[] tmp = new byte[built.RawData.Length + 2]; + built.RawData.AsSpan().CopyTo(tmp.AsSpan(1)); + ext = new X509SubjectAlternativeNameExtension(tmp.AsSpan()[1..^1]); + tmp.AsSpan().Clear(); + break; + default: + throw new ArgumentOutOfRangeException(nameof(loadMode), loadMode, "Unexpected mode"); + } + + Assert.Equal(new[] { "foo", "*.foo" }, ext.EnumerateDnsNames()); + } + + [Theory] + [InlineData(LoadMode.CopyFrom)] + [InlineData(LoadMode.Array)] + [InlineData(LoadMode.Span)] + public static void EnumerateIPAddresses(LoadMode loadMode) + { + SubjectAlternativeNameBuilder builder = new SubjectAlternativeNameBuilder(); + builder.AddDnsName("foo"); + builder.AddIpAddress(IPAddress.Loopback); + builder.AddUserPrincipalName("user@some.domain"); + builder.AddIpAddress(IPAddress.IPv6Loopback); + builder.AddDnsName("*.foo"); + X509Extension built = builder.Build(true); + + X509SubjectAlternativeNameExtension ext; + + switch (loadMode) + { + case LoadMode.CopyFrom: + ext = new X509SubjectAlternativeNameExtension(); + ext.CopyFrom(built); + break; + case LoadMode.Array: + ext = new X509SubjectAlternativeNameExtension(built.RawData); + break; + case LoadMode.Span: + byte[] tmp = new byte[built.RawData.Length + 2]; + built.RawData.AsSpan().CopyTo(tmp.AsSpan(1)); + ext = new X509SubjectAlternativeNameExtension(tmp.AsSpan()[1..^1]); + tmp.AsSpan().Clear(); + break; + default: + throw new ArgumentOutOfRangeException(nameof(loadMode), loadMode, "Unexpected mode"); + } + + Assert.Equal(new[] { IPAddress.Loopback, IPAddress.IPv6Loopback }, ext.EnumerateIPAddresses()); + } + + [Theory] + [InlineData(LoadMode.CopyFrom)] + [InlineData(LoadMode.Array)] + [InlineData(LoadMode.Span)] + public static void VerifyInvalidDataBehavior(LoadMode loadMode) + { + byte[] invalidEncoding = { 0x05, 0x00 }; + + switch (loadMode) + { + case LoadMode.CopyFrom: + X509Extension untyped = new X509Extension("0.0", invalidEncoding, true); + X509SubjectAlternativeNameExtension ext = new X509SubjectAlternativeNameExtension(); + + // The pattern for X509Extension is that CopyFrom doesn't validate data, + // and it blindly accepts the incoming OID. The semantic properties then throw late. + ext.CopyFrom(untyped); + Assert.True(ext.Critical); + Assert.Equal("0.0", ext.Oid.Value); + AssertExtensions.SequenceEqual(invalidEncoding, ext.RawData); + Assert.Throws(ext.EnumerateDnsNames); + Assert.Throws(ext.EnumerateIPAddresses); + break; + case LoadMode.Array: + // The ctors don't need to be so forgiving, through. + Assert.Throws( + () => new X509SubjectAlternativeNameExtension(invalidEncoding)); + break; + case LoadMode.Span: + Assert.Throws( + () => new X509SubjectAlternativeNameExtension(new ReadOnlySpan(invalidEncoding))); + break; + default: + throw new ArgumentOutOfRangeException(nameof(loadMode), loadMode, "Unexpected mode"); + } + } + + public enum LoadMode + { + CopyFrom, + Array, + Span, + } + } +} diff --git a/src/libraries/System.Security.Cryptography.X509Certificates/tests/MatchesHostnameTests.cs b/src/libraries/System.Security.Cryptography.X509Certificates/tests/MatchesHostnameTests.cs new file mode 100644 index 00000000000000..9cf21577fb21ed --- /dev/null +++ b/src/libraries/System.Security.Cryptography.X509Certificates/tests/MatchesHostnameTests.cs @@ -0,0 +1,758 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Linq; +using System.Net; +using Test.Cryptography; +using Xunit; + +namespace System.Security.Cryptography.X509Certificates.Tests +{ + public static class MatchesHostnameTests + { + [Theory] + [InlineData("fruit.example", false)] + [InlineData("127.0.0.1", false)] + [InlineData("microsoft.com", true)] + [InlineData("www.microsoft.com", true)] + [InlineData("wwwqa.microsoft.com", true)] + [InlineData("wwwqa2.microsoft.com", false)] + [InlineData("staticview.microsoft.com", true)] + [InlineData("c.s-microsoft.com", true)] + [InlineData("i.s-microsoft.com", true)] + [InlineData("j.s-microsoft.com", false)] + [InlineData("s-microsoft.com", false)] + [InlineData("privacy.microsoft.com", true)] + [InlineData("more.privacy.microsoft.com", false)] + [InlineData("moreprivacy.microsoft.com", false)] + public static void MicrosoftDotComSslMatchesHostname(string candidate, bool expected) + { + using (X509Certificate2 cert = new X509Certificate2(TestData.MicrosoftDotComSslCertBytes)) + { + AssertMatch(expected, cert, candidate); + } + } + + [Fact] + public static void SanDnsMeansNoCommonNameFallback() + { + using (ECDsa key = ECDsa.Create()) + { + CertificateRequest req = new CertificateRequest( + "CN=zalzalak.fruit.example", + key, + HashAlgorithmName.SHA256); + + SubjectAlternativeNameBuilder sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddDnsName("yumberry.fruit.example"); + sanBuilder.AddDnsName("*.pome.fruit.example"); + + req.CertificateExtensions.Add(sanBuilder.Build()); + + DateTimeOffset now = DateTimeOffset.UtcNow; + DateTimeOffset notBefore = now.AddMinutes(-1); + DateTimeOffset notAfter = now.AddMinutes(1); + + using (X509Certificate2 cert = req.CreateSelfSigned(notBefore, notAfter)) + { + AssertMatch(true, cert, "yumberry.fruit.example"); + AssertMatch(true, cert, "zalzalak.pome.fruit.example"); + + // zalzalak is a pome, and our fake DNS knows that, but the certificate doesn't. + AssertMatch(false, cert, "zalzalak.fruit.example"); + } + } + } + + [Fact] + public static void SanWithNoDnsMeansDoCommonNameFallback() + { + using (ECDsa key = ECDsa.Create()) + { + CertificateRequest req = new CertificateRequest( + "CN=zalzalak.fruit.example", + key, + HashAlgorithmName.SHA256); + + SubjectAlternativeNameBuilder sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddIpAddress(IPAddress.Loopback); + sanBuilder.AddEmailAddress("it@fruit.example"); + + req.CertificateExtensions.Add(sanBuilder.Build()); + + DateTimeOffset now = DateTimeOffset.UtcNow; + DateTimeOffset notBefore = now.AddMinutes(-1); + DateTimeOffset notAfter = now.AddMinutes(1); + + using (X509Certificate2 cert = req.CreateSelfSigned(notBefore, notAfter)) + { + AssertMatch(false, cert, "yumberry.fruit.example"); + AssertMatch(true, cert, "127.0.0.1"); + + // Since the SAN contains no dNSName values, we fall back to the CN. + AssertMatch(true, cert, "zalzalak.fruit.example"); + AssertMatch(false, cert, "zalzalak.fruit.example", allowCommonName: false); + } + } + } + + [Fact] + public static void SanWithIPAddressMeansNoCommonNameFallback() + { + using (ECDsa key = ECDsa.Create()) + { + CertificateRequest req = new CertificateRequest( + "CN=10.0.0.1", + key, + HashAlgorithmName.SHA256); + + SubjectAlternativeNameBuilder sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddIpAddress(IPAddress.Loopback); + sanBuilder.AddEmailAddress("it@fruit.example"); + + req.CertificateExtensions.Add(sanBuilder.Build()); + + DateTimeOffset now = DateTimeOffset.UtcNow; + DateTimeOffset notBefore = now.AddMinutes(-1); + DateTimeOffset notAfter = now.AddMinutes(1); + + using (X509Certificate2 cert = req.CreateSelfSigned(notBefore, notAfter)) + { + AssertMatch(true, cert, "127.0.0.1"); + + // Since the SAN has an iPAddress value, we do not fall back to the CN. + AssertMatch(false, cert, "10.0.0.1"); + } + } + } + + [Fact] + public static void SanDoesNotMatchIPAddressInDnsName() + { + using (ECDsa key = ECDsa.Create()) + { + CertificateRequest req = new CertificateRequest( + "CN=10.0.0.1", + key, + HashAlgorithmName.SHA256); + + SubjectAlternativeNameBuilder sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddDnsName("127.0.0.1"); + sanBuilder.AddEmailAddress("it@fruit.example"); + + req.CertificateExtensions.Add(sanBuilder.Build()); + + DateTimeOffset now = DateTimeOffset.UtcNow; + DateTimeOffset notBefore = now.AddMinutes(-1); + DateTimeOffset notAfter = now.AddMinutes(1); + + using (X509Certificate2 cert = req.CreateSelfSigned(notBefore, notAfter)) + { + // 127.0.0.1 is an IP Address, but the SAN calls it a dNSName, so it won't match. + AssertMatch(false, cert, "127.0.0.1"); + + // Since the SAN contains no iPAddress values, we fall back to the CN. + AssertMatch(true, cert, "10.0.0.1"); + } + } + } + + [Fact] + public static void CommonNameDoesNotUseWildcards() + { + using (ECDsa key = ECDsa.Create()) + { + CertificateRequest req = new CertificateRequest( + "CN=*.fruit.example", + key, + HashAlgorithmName.SHA256); + + DateTimeOffset now = DateTimeOffset.UtcNow; + DateTimeOffset notBefore = now.AddMinutes(-1); + DateTimeOffset notAfter = now.AddMinutes(1); + + using (X509Certificate2 cert = req.CreateSelfSigned(notBefore, notAfter)) + { + AssertMatch(false, cert, "papaya.fruit.example"); + + AssertMatch(true, cert, "*.fruit.example"); + } + } + } + + [Fact] + public static void NoPartialWildcards() + { + using (ECDsa key = ECDsa.Create()) + { + CertificateRequest req = new CertificateRequest( + "CN=10.0.0.1", + key, + HashAlgorithmName.SHA256); + + SubjectAlternativeNameBuilder sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddDnsName("*berry.fruit.example"); + sanBuilder.AddDnsName("cran*.fruit.example"); + sanBuilder.AddEmailAddress("it@fruit.example"); + + req.CertificateExtensions.Add(sanBuilder.Build()); + + DateTimeOffset now = DateTimeOffset.UtcNow; + DateTimeOffset notBefore = now.AddMinutes(-1); + DateTimeOffset notAfter = now.AddMinutes(1); + + using (X509Certificate2 cert = req.CreateSelfSigned(notBefore, notAfter)) + { + AssertMatch(false, cert, "cranberry.fruit.example"); + + // Since we don't consider the partial wildcards as wildcards, they do match unexpanded. + AssertMatch(true, cert, "*berry.fruit.example"); + AssertMatch(true, cert, "cran*.fruit.example"); + } + } + } + + [Fact] + public static void WildcardsDoNotMatchThroughPeriods() + { + using (ECDsa key = ECDsa.Create()) + { + CertificateRequest req = new CertificateRequest( + "CN=10.0.0.1", + key, + HashAlgorithmName.SHA256); + + SubjectAlternativeNameBuilder sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddDnsName("fruit.example"); + sanBuilder.AddDnsName("*.fruit.example"); + sanBuilder.AddDnsName("rambutan.fruit.example"); + sanBuilder.AddEmailAddress("it@fruit.example"); + + req.CertificateExtensions.Add(sanBuilder.Build()); + + DateTimeOffset now = DateTimeOffset.UtcNow; + DateTimeOffset notBefore = now.AddMinutes(-1); + DateTimeOffset notAfter = now.AddMinutes(1); + + using (X509Certificate2 cert = req.CreateSelfSigned(notBefore, notAfter)) + { + AssertMatch(true, cert, "apple.fruit.example"); + AssertMatch(true, cert, "blackberry.fruit.example"); + AssertMatch(true, cert, "pome.fruit.example"); + AssertMatch(true, cert, "pomme.fruit.example"); + AssertMatch(true, cert, "rambutan.fruit.example"); + AssertMatch(false, cert, "apple.pome.fruit.example"); + AssertMatch(false, cert, "apple.pomme.fruit.example"); + + AssertMatch(true, cert, "*.fruit.example"); + AssertMatch(true, cert, "*.fruit.example", allowWildcards: false); + + AssertMatch(false, cert, "apple.fruit.example", allowWildcards: false); + AssertMatch(false, cert, "blackberry.fruit.example", allowWildcards: false); + AssertMatch(false, cert, "pome.fruit.example", allowWildcards: false); + AssertMatch(false, cert, "pomme.fruit.example", allowWildcards: false); + // This one has a redundant dNSName after the wildcard + AssertMatch(true, cert, "rambutan.fruit.example", allowWildcards: false); + + AssertMatch(true, cert, "fruit.example"); + AssertMatch(true, cert, "fruit.example", allowWildcards: false); + } + } + } + + [Fact] + public static void DnsMatchNotCaseSensitive() + { + using (ECDsa key = ECDsa.Create()) + { + CertificateRequest req = new CertificateRequest( + "CN=10.0.0.1", + key, + HashAlgorithmName.SHA256); + + SubjectAlternativeNameBuilder sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddDnsName("fruit.EXAMPLE"); + sanBuilder.AddDnsName("*.FrUIt.eXaMpLE"); + sanBuilder.AddEmailAddress("it@fruit.example"); + + req.CertificateExtensions.Add(sanBuilder.Build()); + + DateTimeOffset now = DateTimeOffset.UtcNow; + DateTimeOffset notBefore = now.AddMinutes(-1); + DateTimeOffset notAfter = now.AddMinutes(1); + + using (X509Certificate2 cert = req.CreateSelfSigned(notBefore, notAfter)) + { + AssertMatch(true, cert, "aPPlE.fruit.example"); + AssertMatch(true, cert, "tOmaTO.FRUIT.example"); + AssertMatch(false, cert, "tOmaTO.vegetable.example"); + AssertMatch(true, cert, "FRUit.example"); + AssertMatch(false, cert, "VEGetaBlE.example"); + } + } + } + + [Fact] + public static void DnsNameIgnoresTrailingPeriod() + { + using (ECDsa key = ECDsa.Create()) + { + CertificateRequest req = new CertificateRequest( + "CN=10.0.0.1", + key, + HashAlgorithmName.SHA256); + + SubjectAlternativeNameBuilder sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddDnsName("fruit.example."); + sanBuilder.AddDnsName("*.FrUIt.eXaMpLE."); + sanBuilder.AddEmailAddress("it@fruit.example"); + + req.CertificateExtensions.Add(sanBuilder.Build()); + + DateTimeOffset now = DateTimeOffset.UtcNow; + DateTimeOffset notBefore = now.AddMinutes(-1); + DateTimeOffset notAfter = now.AddMinutes(1); + + using (X509Certificate2 cert = req.CreateSelfSigned(notBefore, notAfter)) + { + AssertMatch(true, cert, "aPPlE.fruit.example"); + AssertMatch(true, cert, "tOmaTO.FRUIT.example"); + AssertMatch(false, cert, "tOmaTO.vegetable.example"); + AssertMatch(true, cert, "FRUit.example"); + AssertMatch(false, cert, "VEGetaBlE.example"); + } + } + } + + [Fact] + public static void DnsNameMatchIgnoresTrailingPeriodFromParameter() + { + using (ECDsa key = ECDsa.Create()) + { + CertificateRequest req = new CertificateRequest( + "CN=10.0.0.1", + key, + HashAlgorithmName.SHA256); + + SubjectAlternativeNameBuilder sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddDnsName("fruit.EXAMPLE"); + sanBuilder.AddDnsName("*.FrUIt.eXaMpLE"); + sanBuilder.AddEmailAddress("it@fruit.example"); + + req.CertificateExtensions.Add(sanBuilder.Build()); + + DateTimeOffset now = DateTimeOffset.UtcNow; + DateTimeOffset notBefore = now.AddMinutes(-1); + DateTimeOffset notAfter = now.AddMinutes(1); + + using (X509Certificate2 cert = req.CreateSelfSigned(notBefore, notAfter)) + { + AssertMatch(true, cert, "aPPlE.fruit.example."); + AssertMatch(true, cert, "tOmaTO.FRUIT.example."); + AssertMatch(false, cert, "tOmaTO.vegetable.example."); + AssertMatch(true, cert, "FRUit.example."); + AssertMatch(false, cert, "VEGetaBlE.example."); + } + } + } + + [Fact] + public static void DnsNameMatchRejectsLeadingPeriodFromParameter() + { + using (ECDsa key = ECDsa.Create()) + { + CertificateRequest req = new CertificateRequest( + "CN=10.0.0.1", + key, + HashAlgorithmName.SHA256); + + SubjectAlternativeNameBuilder sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddDnsName("fruit.EXAMPLE"); + sanBuilder.AddDnsName("*.FrUIt.eXaMpLE"); + sanBuilder.AddEmailAddress("it@fruit.example"); + + req.CertificateExtensions.Add(sanBuilder.Build()); + + DateTimeOffset now = DateTimeOffset.UtcNow; + DateTimeOffset notBefore = now.AddMinutes(-1); + DateTimeOffset notAfter = now.AddMinutes(1); + + using (X509Certificate2 cert = req.CreateSelfSigned(notBefore, notAfter)) + { + AssertMatch(true, cert, "aPPlE.fruit.example."); + AssertMatch(true, cert, "tOmaTO.FRUIT.example."); + AssertMatch(false, cert, "tOmaTO.vegetable.example."); + AssertMatch(true, cert, "FRUit.example."); + AssertMatch(false, cert, ".FRUit.example."); + AssertMatch(false, cert, "VEGetaBlE.example."); + } + } + } + + [Fact] + public static void CommonNameMatchDoesNotIgnoreTrailingPeriodFromParameter() + { + using (ECDsa key = ECDsa.Create()) + { + CertificateRequest req = new CertificateRequest( + "CN=fruit.example", + key, + HashAlgorithmName.SHA256); + + DateTimeOffset now = DateTimeOffset.UtcNow; + DateTimeOffset notBefore = now.AddMinutes(-1); + DateTimeOffset notAfter = now.AddMinutes(1); + + using (X509Certificate2 cert = req.CreateSelfSigned(notBefore, notAfter)) + { + AssertMatch(true, cert, "FRUit.example"); + AssertMatch(false, cert, "FRUit.example", allowCommonName: false); + AssertMatch(false, cert, "FRUit.example."); + } + } + } + + [Fact] + public static void CommonNameMatchDoesNotIgnoreTrailingPeriodFromValue() + { + using (ECDsa key = ECDsa.Create()) + { + CertificateRequest req = new CertificateRequest( + "CN=fruit.example.", + key, + HashAlgorithmName.SHA256); + + DateTimeOffset now = DateTimeOffset.UtcNow; + DateTimeOffset notBefore = now.AddMinutes(-1); + DateTimeOffset notAfter = now.AddMinutes(1); + + using (X509Certificate2 cert = req.CreateSelfSigned(notBefore, notAfter)) + { + AssertMatch(true, cert, "FRUit.example."); + AssertMatch(false, cert, "FRUit.example.", allowCommonName: false); + AssertMatch(false, cert, "FRUit.example"); + } + } + } + + [Fact] + public static void NoMatchIfMultipleCommonNames() + { + using (ECDsa key = ECDsa.Create()) + { + CertificateRequest req = new CertificateRequest( + "CN=fruit.example, CN=potato.vegetable.example", + key, + HashAlgorithmName.SHA256); + + DateTimeOffset now = DateTimeOffset.UtcNow; + DateTimeOffset notBefore = now.AddMinutes(-1); + DateTimeOffset notAfter = now.AddMinutes(1); + + using (X509Certificate2 cert = req.CreateSelfSigned(notBefore, notAfter)) + { + AssertMatch(false, cert, "FRUit.example"); + AssertMatch(false, cert, "potato.vegetable.example"); + } + } + } + + [Fact] + public static void NoMatchIfMultipleCommonNamesWithMultiRDN() + { + using (ECDsa key = ECDsa.Create()) + { + CertificateRequest req = new CertificateRequest( + "CN=fruit.example, CN=potato.vegetable.example+ST=Idaho", + key, + HashAlgorithmName.SHA256); + + DateTimeOffset now = DateTimeOffset.UtcNow; + DateTimeOffset notBefore = now.AddMinutes(-1); + DateTimeOffset notAfter = now.AddMinutes(1); + + using (X509Certificate2 cert = req.CreateSelfSigned(notBefore, notAfter)) + { + AssertMatch(false, cert, "FRUit.example"); + AssertMatch(false, cert, "potato.vegetable.example"); + } + } + } + + [Fact] + public static void NoMatchIfCommonNamesInMultiRDN() + { + using (ECDsa key = ECDsa.Create()) + { + CertificateRequest req = new CertificateRequest( + "CN=potato.vegetable.example+ST=Idaho", + key, + HashAlgorithmName.SHA256); + + DateTimeOffset now = DateTimeOffset.UtcNow; + DateTimeOffset notBefore = now.AddMinutes(-1); + DateTimeOffset notAfter = now.AddMinutes(1); + + using (X509Certificate2 cert = req.CreateSelfSigned(notBefore, notAfter)) + { + AssertMatch(false, cert, "FRUit.example"); + AssertMatch(false, cert, "potato.vegetable.example"); + } + } + } + + [Fact] + public static void MultiRdnWithNoCommonNameIsOK() + { + using (ECDsa key = ECDsa.Create()) + { + CertificateRequest req = new CertificateRequest( + "CN=potato.vegetable.example,ST=Idaho+ST=Utah", + key, + HashAlgorithmName.SHA256); + + DateTimeOffset now = DateTimeOffset.UtcNow; + DateTimeOffset notBefore = now.AddMinutes(-1); + DateTimeOffset notAfter = now.AddMinutes(1); + + using (X509Certificate2 cert = req.CreateSelfSigned(notBefore, notAfter)) + { + AssertMatch(false, cert, "FRUit.example"); + AssertMatch(true, cert, "potato.vegetable.example"); + } + } + } + + [Fact] + public static void NoMatchAndNoCommonName() + { + using (ECDsa key = ECDsa.Create()) + { + CertificateRequest req = new CertificateRequest( + "ST=Idaho", + key, + HashAlgorithmName.SHA256); + + DateTimeOffset now = DateTimeOffset.UtcNow; + DateTimeOffset notBefore = now.AddMinutes(-1); + DateTimeOffset notAfter = now.AddMinutes(1); + + using (X509Certificate2 cert = req.CreateSelfSigned(notBefore, notAfter)) + { + AssertMatch(false, cert, ""); + AssertMatch(false, cert, "FRUit.example"); + AssertMatch(false, cert, "potato.vegetable.example"); + } + } + } + + [Fact] + public static void NoMatchAndEmptyCommonName() + { + using (ECDsa key = ECDsa.Create()) + { + CertificateRequest req = new CertificateRequest( + "CN=,ST=Idaho", + key, + HashAlgorithmName.SHA256); + + DateTimeOffset now = DateTimeOffset.UtcNow; + DateTimeOffset notBefore = now.AddMinutes(-1); + DateTimeOffset notAfter = now.AddMinutes(1); + + using (X509Certificate2 cert = req.CreateSelfSigned(notBefore, notAfter)) + { + AssertMatch(false, cert, ""); + AssertMatch(false, cert, "FRUit.example"); + AssertMatch(false, cert, "potato.vegetable.example"); + } + } + } + + [Fact] + public static void NoMatchOnEmptyDnsName() + { + using (ECDsa key = ECDsa.Create()) + { + CertificateRequest req = new CertificateRequest( + "CN=potato.vegetable.example", + key, + HashAlgorithmName.SHA256); + + req.CertificateExtensions.Add( + new X509Extension("2.5.29.17", "30028200".HexToByteArray(), false)); + + DateTimeOffset now = DateTimeOffset.UtcNow; + DateTimeOffset notBefore = now.AddMinutes(-1); + DateTimeOffset notAfter = now.AddMinutes(1); + + using (X509Certificate2 cert = req.CreateSelfSigned(notBefore, notAfter)) + { + X509SubjectAlternativeNameExtension sanExt = + (X509SubjectAlternativeNameExtension)cert.Extensions[0]; + + if (sanExt.EnumerateDnsNames().Single() != "") + { + throw new InvalidOperationException("Invalid test data"); + } + + AssertMatch(false, cert, "example"); + AssertMatch(false, cert, "example."); + AssertMatch(false, cert, "."); + AssertMatch(false, cert, "*"); + AssertMatch(false, cert, "*."); + AssertMatch(false, cert, ""); + } + } + } + + [Fact] + public static void NoMatchOnDnsNameWithLeadingPeriod() + { + using (ECDsa key = ECDsa.Create()) + { + CertificateRequest req = new CertificateRequest( + "CN=potato.vegetable.example", + key, + HashAlgorithmName.SHA256); + + req.CertificateExtensions.Add( + new X509Extension( + "2.5.29.17", + "301682142E70656163682E66727569742E6578616D706C65".HexToByteArray(), + false)); + + DateTimeOffset now = DateTimeOffset.UtcNow; + DateTimeOffset notBefore = now.AddMinutes(-1); + DateTimeOffset notAfter = now.AddMinutes(1); + + using (X509Certificate2 cert = req.CreateSelfSigned(notBefore, notAfter)) + { + X509SubjectAlternativeNameExtension sanExt = + (X509SubjectAlternativeNameExtension)cert.Extensions[0]; + + if (sanExt.EnumerateDnsNames().Single() != ".peach.fruit.example") + { + throw new InvalidOperationException("Invalid test data"); + } + + AssertMatch(false, cert, "peach.fruit.example"); + AssertMatch(false, cert, ""); + } + } + } + + [Fact] + public static void WildcardRequiresSuffixToMatch() + { + using (ECDsa key = ECDsa.Create()) + { + CertificateRequest req = new CertificateRequest( + "CN=potato.vegetable.example", + key, + HashAlgorithmName.SHA256); + + SubjectAlternativeNameBuilder sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddDnsName("*"); + sanBuilder.AddDnsName("*."); + sanBuilder.AddEmailAddress("it@fruit.example"); + + req.CertificateExtensions.Add(sanBuilder.Build()); + + DateTimeOffset now = DateTimeOffset.UtcNow; + DateTimeOffset notBefore = now.AddMinutes(-1); + DateTimeOffset notAfter = now.AddMinutes(1); + + using (X509Certificate2 cert = req.CreateSelfSigned(notBefore, notAfter)) + { + AssertMatch(false, cert, "example"); + AssertMatch(false, cert, "example."); + AssertMatch(false, cert, "."); + AssertMatch(true, cert, "*"); + AssertMatch(true, cert, "*."); + } + } + } + + [Fact] + public static void TooManySANsThrows() + { + byte[] tooManySans = ( + "3082021430820175A00302010202083C883E44C34DA5CB300A06082A8648CE3D" + + "04030230233121301F06035504031318706F7461746F2E766567657461626C65" + + "2E6578616D706C65301E170D3232303630383232333530365A170D3232303630" + + "383232333730365A30233121301F06035504031318706F7461746F2E76656765" + + "7461626C652E6578616D706C6530819B301006072A8648CE3D020106052B8104" + + "0023038186000400BA92930960C2C98D81F4DEAB62E75C0F768B5518A8FF58C2" + + "1D43B453AA2D1C73FA6BB0586349DDD61D0C25DC46B444BF5806F72F0F83546C" + + "B27583AE0007B101780007B7AE5717D4343C85D168212F2C2E4EC8F8B9F1953F" + + "A159C5E74A191B609E6A38FAAC404E3A0C094DD39A6732673545EE8C195A2B9B" + + "600420E9F55C145232304EA350304E30180603551D110411300F820D66727569" + + "742E6578616D706C6530320603551D11042B302982152A2E64727570652E6672" + + "7569742E6578616D706C65811069744066727569742E6578616D706C65300A06" + + "082A8648CE3D04030203818C003081880242009DA8DF6009D12EC733ADEE7479" + + "18B4611E185E478BA1D33AB7150A6A29F21FF31B48846B132868934A9F989C88" + + "39C7B8955A70DD5D4E9E1BB7C0D78F6AD8C3C6DC024200958482B9444D1AD2D3" + + "F67B51AD13064F2FDD4EC2F64ECB352D3F11BE8066F9021DD0CF309654351781" + + "69E940B767111BB2D28119EB3A2461617792F1CDF131F794").HexToByteArray(); + + using (X509Certificate2 cert = new X509Certificate2(tooManySans)) + { + Assert.Throws( + () => cert.MatchesHostname("fruit.example")); + + Assert.Throws( + () => cert.MatchesHostname("fruit.example", allowWildcards: false)); + + Assert.Throws( + () => cert.MatchesHostname("fruit.example", allowCommonName: false)); + + Assert.Throws( + () => cert.MatchesHostname("fruit.example", false, false)); + + // But argument validation comes first. + Assert.Throws("hostname", () => cert.MatchesHostname(null)); + Assert.Throws("hostname", () => cert.MatchesHostname(null, false, true)); + Assert.Throws("hostname", () => cert.MatchesHostname(null, true, false)); + Assert.Throws("hostname", () => cert.MatchesHostname(null, false, false)); + } + } + + private static void AssertMatch( + bool expected, + X509Certificate2 cert, + string hostname, + bool allowWildcards = true, + bool allowCommonName = true) + { + bool match = cert.MatchesHostname(hostname, allowWildcards, allowCommonName); + + if (match != expected) + { + string display = $"Matches {(hostname.Contains('*') ? "(literal) " : "")}'{hostname}'"; + + if (!allowWildcards && !allowCommonName) + { + display += " with no wildcards or common name fallback"; + } + else if (!allowWildcards) + { + display += " with no wildcards"; + } + else if (!allowCommonName) + { + display += " with no common name fallback"; + } + + if (expected) + { + Assert.True(match, display); + } + else + { + Assert.False(match, display); + } + } + } + } +} diff --git a/src/libraries/System.Security.Cryptography.X509Certificates/tests/System.Security.Cryptography.X509Certificates.Tests.csproj b/src/libraries/System.Security.Cryptography.X509Certificates/tests/System.Security.Cryptography.X509Certificates.Tests.csproj index 547a33c182cae6..93c6f595486cb9 100644 --- a/src/libraries/System.Security.Cryptography.X509Certificates/tests/System.Security.Cryptography.X509Certificates.Tests.csproj +++ b/src/libraries/System.Security.Cryptography.X509Certificates/tests/System.Security.Cryptography.X509Certificates.Tests.csproj @@ -39,9 +39,11 @@ + + diff --git a/src/libraries/System.Security.Cryptography/ref/System.Security.Cryptography.cs b/src/libraries/System.Security.Cryptography/ref/System.Security.Cryptography.cs index 4514b1e25bb2bd..4de38e25adb990 100644 --- a/src/libraries/System.Security.Cryptography/ref/System.Security.Cryptography.cs +++ b/src/libraries/System.Security.Cryptography/ref/System.Security.Cryptography.cs @@ -2780,6 +2780,7 @@ public override void Import(string fileName) { } public override void Import(string fileName, System.Security.SecureString? password, System.Security.Cryptography.X509Certificates.X509KeyStorageFlags keyStorageFlags) { } [System.ObsoleteAttribute("X509Certificate and X509Certificate2 are immutable. Use the appropriate constructor to create a new certificate.", DiagnosticId="SYSLIB0026", UrlFormat="https://aka.ms/dotnet-warnings/{0}")] public override void Import(string fileName, string? password, System.Security.Cryptography.X509Certificates.X509KeyStorageFlags keyStorageFlags) { } + public bool MatchesHostname(string hostname, bool allowWildcards = true, bool allowCommonName = true) { throw null; } public override void Reset() { } public override string ToString() { throw null; } public override string ToString(bool verbose) { throw null; } @@ -3139,6 +3140,15 @@ public void Open(System.Security.Cryptography.X509Certificates.OpenFlags flags) public void Remove(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate) { } public void RemoveRange(System.Security.Cryptography.X509Certificates.X509Certificate2Collection certificates) { } } + public partial class X509SubjectAlternativeNameExtension : System.Security.Cryptography.X509Certificates.X509Extension + { + public X509SubjectAlternativeNameExtension() { } + public X509SubjectAlternativeNameExtension(byte[] rawData, bool critical = false) { } + public X509SubjectAlternativeNameExtension(System.ReadOnlySpan rawData, bool critical = false) { } + public override void CopyFrom(System.Security.Cryptography.AsnEncodedData asnEncodedData) { } + public System.Collections.Generic.IEnumerable EnumerateDnsNames() { throw null; } + public System.Collections.Generic.IEnumerable EnumerateIPAddresses() { throw null; } + } public sealed partial class X509SubjectKeyIdentifierExtension : System.Security.Cryptography.X509Certificates.X509Extension { public X509SubjectKeyIdentifierExtension() { } diff --git a/src/libraries/System.Security.Cryptography/src/Resources/Strings.resx b/src/libraries/System.Security.Cryptography/src/Resources/Strings.resx index 5cf6712da987dc..fcbfdc139c7c55 100644 --- a/src/libraries/System.Security.Cryptography/src/Resources/Strings.resx +++ b/src/libraries/System.Security.Cryptography/src/Resources/Strings.resx @@ -648,6 +648,9 @@ The PKCS#12 PersistKeySet flag is not supported on this platform. + + The Subject Alternative Name has an ipAddress entry that is not a recognized size. + Root certificate store is not supported on this platform. @@ -678,6 +681,9 @@ The X509 certificate could not be removed from the store. + + The X509 certificate has more than one Subject Alternative Name extension. + Removing the requested certificate would modify admin trust settings, and has been denied. diff --git a/src/libraries/System.Security.Cryptography/src/System.Security.Cryptography.csproj b/src/libraries/System.Security.Cryptography/src/System.Security.Cryptography.csproj index 79e9a633ae2cf9..3a2bffbbf6c47c 100644 --- a/src/libraries/System.Security.Cryptography/src/System.Security.Cryptography.csproj +++ b/src/libraries/System.Security.Cryptography/src/System.Security.Cryptography.csproj @@ -479,6 +479,7 @@ + diff --git a/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/CryptoConfig.cs b/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/CryptoConfig.cs index dec1bb8460803d..bc7ba49a4459c6 100644 --- a/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/CryptoConfig.cs +++ b/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/CryptoConfig.cs @@ -258,6 +258,7 @@ private static Dictionary DefaultNameHT ht.Add("2.5.29.15", typeof(X509Certificates.X509KeyUsageExtension)); ht.Add("2.5.29.37", typeof(X509Certificates.X509EnhancedKeyUsageExtension)); ht.Add(Oids.AuthorityInformationAccess, typeof(X509Certificates.X509AuthorityInformationAccessExtension)); + ht.Add(Oids.SubjectAltName, typeof(X509Certificates.X509SubjectAlternativeNameExtension)); // X509Chain class can be overridden to use a different chain engine. ht.Add("X509Chain", typeof(X509Certificates.X509Chain)); diff --git a/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/X509Certificate2.cs b/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/X509Certificate2.cs index 072923e4d03bbd..f3c76b6b290b64 100644 --- a/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/X509Certificate2.cs +++ b/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/X509Certificate2.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.Formats.Asn1; using System.IO; +using System.Net; using System.Runtime.Serialization; using System.Runtime.Versioning; using System.Security.Cryptography.X509Certificates.Asn1; @@ -1166,6 +1167,239 @@ public bool TryExportCertificatePem(Span destination, out int charsWritten return PemEncoding.TryWrite(PemLabels.X509Certificate, RawDataMemory.Span, destination, out charsWritten); } + /// + /// Checks to see if the certificate matches the provided hostname. + /// + /// The host name to match against. + /// + /// to allow wildcard matching for dNSName values in the + /// Subject Alternative Name extension; otherwise, . + /// + /// + /// to allow matching against the subject Common Name value; + /// otherwise, . + /// + /// + /// if the certificate is a match for the requested hostname; + /// otherwise, + /// + /// + /// + /// This method is a platform neutral implementation of IETF RFC 6125 host matching logic. + /// The SslStream class uses the hostname validator from the operating system, which may + /// result in different values from this implementation. + /// + /// + /// The logical flow of this method is: + /// + /// + /// If the hostname parses as an then IPAddress matching is done; + /// otherwise, DNS Name matching is done. + /// + /// + /// For IPAddress matching, the value must be an exact match against an iPAddress value in an + /// entry of the Subject Alternative Name extension. + /// + /// + /// For DNS Name matching, the value must be an exact match against a dNSName value in an + /// entry of the Subject Alternative Name extension, or a wildcard match against the same. + /// + /// + /// For wildcard matching, the wildcard must be the first character in the dNSName entry, + /// the second character must be a period (.), and the entry must have a length greater than two. + /// The wildcard will only match the value up to the first period (.), + /// remaining characters must be an exact match. + /// + /// + /// If there is no Subject Alternative Name extension, or the extension does not have any entries + /// of the appropriate type, then Common Name matching is used as a fallback. + /// + /// + /// For Common Name matching, if the Subject Name contains a single Common Name, and that attribute + /// is not defined as part of a multi-valued Relative Distinguished Name, then the hostname is matched + /// against the Common Name attribute's value. + /// Note that wildcards are not used in Common Name matching. + /// + /// + /// + /// + /// This method does not convert non-ASCII hostnames to the IDNA representation. For Unicode domains, + /// the caller must make use of or an equivalent IDNA mapper. + /// + /// + /// The "exact" matches performed by this routine are , + /// as domain names are not case-sensitive. + /// + /// + /// This method does not determine if the hostname is authorized by a trusted authority. A trust + /// decision cannot be made without additionally checking for trust via . + /// + /// + /// This method does not check that the certificate has an id-kp-serverAuth (1.3.6.1.5.5.7.3.1) + /// extended key usage. + /// + /// + /// + /// The certificate contains multiple Subject Alternative Name extensions. + /// - or - + /// The Subject Alternative Name extension or Subject Name could not be decooded. + /// + public bool MatchesHostname(string hostname, bool allowWildcards = true, bool allowCommonName = true) + { + ArgumentNullException.ThrowIfNull(hostname); + + if (hostname.Length == 0) + { + return false; + } + + X509Extension? rawSAN = null; + + foreach (X509Extension extension in Pal.Extensions) + { + if (extension.Oid!.Value == Oids.SubjectAltName) + { + if (rawSAN is null) + { + rawSAN = extension; + } + else + { + throw new CryptographicException(SR.Cryptography_X509_TooManySANs); + } + } + } + + if (rawSAN is not null) + { + var san = new X509SubjectAlternativeNameExtension(); + san.CopyFrom(rawSAN); + + bool hadAny = false; + + if (IPAddress.TryParse(hostname, out IPAddress? ipAddress)) + { + foreach (IPAddress sanEntry in san.EnumerateIPAddresses()) + { + if (sanEntry.Equals(ipAddress)) + { + return true; + } + + hadAny = true; + } + } + else + { + ReadOnlySpan match = hostname; + + // Treat "something.example.org." as "something.example.org" + if (hostname.EndsWith('.')) + { + match = match.Slice(0, match.Length - 1); + + if (match.IsEmpty) + { + return false; + } + } + + ReadOnlySpan afterFirstDot = default; + int firstDot = match.IndexOf('.'); + + // ".something.example.org" always fails to match. + if (firstDot == 0) + { + return false; + } + + if (firstDot > 0) + { + afterFirstDot = match.Slice(firstDot + 1); + } + + foreach (string embedded in san.EnumerateDnsNames()) + { + hadAny = true; + + if (embedded.Length == 0) + { + continue; + } + + ReadOnlySpan embeddedSpan = embedded; + + // Convert embedded "something.example.org." to "something.example.org" + if (embedded.EndsWith('.')) + { + embeddedSpan = embeddedSpan.Slice(0, embeddedSpan.Length - 1); + } + + if (allowWildcards && embeddedSpan.StartsWith("*.") && embeddedSpan.Length > 2) + { + if (embeddedSpan.Slice(2).Equals(afterFirstDot, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + else if (embeddedSpan.Equals(match, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + } + + if (hadAny) + { + return false; + } + } + + if (allowCommonName) + { + X500RelativeDistinguishedName? cn = null; + + foreach (X500RelativeDistinguishedName rdn in SubjectName.EnumerateRelativeDistinguishedNames()) + { + if (rdn.HasMultipleElements) + { + AsnValueReader reader = new AsnValueReader(rdn.RawData.Span, AsnEncodingRules.DER); + // Be lax with the sort order because Windows is + AsnValueReader set = reader.ReadSetOf(skipSortOrderValidation: true); + + while (set.HasData) + { + AsnValueReader attributeTypeAndValue = set.ReadSequence(); + Oid? type = Oids.GetSharedOrNullOid(ref attributeTypeAndValue); + + if (Oids.CommonNameOid.ValueEquals(type)) + { + return false; + } + } + } + else if (Oids.CommonNameOid.ValueEquals(rdn.GetSingleElementType())) + { + if (cn is null) + { + cn = rdn; + } + else + { + return false; + } + } + } + + if (cn is not null) + { + return hostname.Equals(cn.GetSingleElementValue(), StringComparison.OrdinalIgnoreCase); + } + } + + return false; + } + private static X509Certificate2 ExtractKeyFromPem( ReadOnlySpan keyPem, string[] labels, @@ -1237,6 +1471,7 @@ private static X509Certificate2 ExtractKeyFromEncryptedPem( Oids.EnhancedKeyUsage => new X509EnhancedKeyUsageExtension(), Oids.SubjectKeyIdentifier => new X509SubjectKeyIdentifierExtension(), Oids.AuthorityInformationAccess => new X509AuthorityInformationAccessExtension(), + Oids.SubjectAltName => new X509SubjectAlternativeNameExtension(), _ => null, }; diff --git a/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/X509SubjectAlternativeNameExtension.cs b/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/X509SubjectAlternativeNameExtension.cs new file mode 100644 index 00000000000000..fb6bd425cca7f6 --- /dev/null +++ b/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/X509SubjectAlternativeNameExtension.cs @@ -0,0 +1,107 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Formats.Asn1; +using System.Net; +using System.Security.Cryptography.Asn1; + +namespace System.Security.Cryptography.X509Certificates +{ + public class X509SubjectAlternativeNameExtension : X509Extension + { + private List? _decoded; + + public X509SubjectAlternativeNameExtension() : base(Oids.SubjectAltName) + { + _decoded = new List(0); + } + + public X509SubjectAlternativeNameExtension(byte[] rawData, bool critical = false) + : base(Oids.SubjectAltName, rawData, critical) + { + _decoded = Decode(RawData); + } + + public X509SubjectAlternativeNameExtension(ReadOnlySpan rawData, bool critical = false) + : base(Oids.SubjectAltName, rawData, critical) + { + _decoded = Decode(RawData); + } + + public override void CopyFrom(AsnEncodedData asnEncodedData) + { + base.CopyFrom(asnEncodedData); + _decoded = null; + } + + public IEnumerable EnumerateDnsNames() + { + List decoded = (_decoded ??= Decode(RawData)); + + return EnumerateDnsNames(decoded); + } + + private static IEnumerable EnumerateDnsNames(List decoded) + { + foreach (GeneralNameAsn item in decoded) + { + if (item.DnsName is not null) + { + yield return item.DnsName; + } + } + } + + public IEnumerable EnumerateIPAddresses() + { + List decoded = (_decoded ??= Decode(RawData)); + + return EnumerateIPAddresses(decoded); + } + + private static IEnumerable EnumerateIPAddresses(List decoded) + { + foreach (GeneralNameAsn item in decoded) + { + if (item.IPAddress.HasValue) + { + ReadOnlySpan value = item.IPAddress.GetValueOrDefault().Span; + + if (value.Length is 4 or 16) + { + yield return new IPAddress(value); + } + else + { + throw new CryptographicException(SR.Cryptography_X509_SAN_UnknownIPAddressSize); + } + } + } + } + + private static List Decode(ReadOnlySpan rawData) + { + try + { + AsnValueReader outer = new AsnValueReader(rawData, AsnEncodingRules.DER); + AsnValueReader sequence = outer.ReadSequence(); + outer.ThrowIfNotEmpty(); + + List decoded = new List(); + + while (sequence.HasData) + { + GeneralNameAsn.Decode(ref sequence, default, out GeneralNameAsn item); + decoded.Add(item); + } + + return decoded; + } + catch (AsnContentException e) + { + throw new CryptographicException(SR.Cryptography_Der_Invalid_Encoding, e); + } + } + } +} From ba03c5795f4bab0adf95697e6dd14bad32c23abb Mon Sep 17 00:00:00 2001 From: Jeremy Barton Date: Mon, 18 Jul 2022 17:32:00 -0700 Subject: [PATCH 2/7] Address feedback --- .../SubjectAlternativeNameTests.cs | 55 ++++++++- .../tests/MatchesHostnameTests.cs | 112 ++++++++++++++---- .../ref/System.Security.Cryptography.cs | 2 +- .../src/Resources/Strings.resx | 3 + .../X509Certificates/X509Certificate2.cs | 24 +++- .../X509SubjectAlternativeNameExtension.cs | 31 +++-- 6 files changed, 193 insertions(+), 34 deletions(-) diff --git a/src/libraries/System.Security.Cryptography.X509Certificates/tests/ExtensionsTests/SubjectAlternativeNameTests.cs b/src/libraries/System.Security.Cryptography.X509Certificates/tests/ExtensionsTests/SubjectAlternativeNameTests.cs index 2b11dd073dbe76..3a8663905c1394 100644 --- a/src/libraries/System.Security.Cryptography.X509Certificates/tests/ExtensionsTests/SubjectAlternativeNameTests.cs +++ b/src/libraries/System.Security.Cryptography.X509Certificates/tests/ExtensionsTests/SubjectAlternativeNameTests.cs @@ -106,10 +106,63 @@ public static void EnumerateIPAddresses(LoadMode loadMode) [InlineData(LoadMode.CopyFrom)] [InlineData(LoadMode.Array)] [InlineData(LoadMode.Span)] - public static void VerifyInvalidDataBehavior(LoadMode loadMode) + public static void VerifyDecodeFailureBehavior(LoadMode loadMode) { byte[] invalidEncoding = { 0x05, 0x00 }; + VerifyDecodeFailure(invalidEncoding, loadMode); + } + + [Theory] + [InlineData(LoadMode.CopyFrom)] + [InlineData(LoadMode.Array)] + [InlineData(LoadMode.Span)] + public static void VerifyInvalidIPAddressBehavior(LoadMode loadMode) + { + // dNSName: foo + // iPAddress: 127.0.0.1 + // iPAddress: 7F 00 00 01 00 (127.0.0.1 with a trailing 0 + // UPN: user@some.domain + // iPAddress: ::1 + // dNSName: *.foo + byte[] invalidEncoding = + { + 0x30, 0x4D, 0x82, 0x03, 0x66, 0x6F, 0x6F, 0x87, 0x04, 0x7F, 0x00, 0x00, 0x01, 0x87, 0x05, 0x7F, + 0x00, 0x00, 0x01, 0x00, 0xA0, 0x20, 0x06, 0x0A, 0x2B, 0x06, 0x01, 0x04, 0x01, 0x82, 0x37, 0x14, + 0x02, 0x03, 0xA0, 0x12, 0x0C, 0x10, 0x75, 0x73, 0x65, 0x72, 0x40, 0x73, 0x6F, 0x6D, 0x65, 0x2E, + 0x64, 0x6F, 0x6D, 0x61, 0x69, 0x6E, 0x87, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x82, 0x05, 0x2A, 0x2E, 0x66, 0x6F, 0x6F, + }; + + VerifyDecodeFailure(invalidEncoding, loadMode); + } + + [Theory] + [InlineData(LoadMode.CopyFrom)] + [InlineData(LoadMode.Array)] + [InlineData(LoadMode.Span)] + public static void VerifyInvalidDnsNameBehavior(LoadMode loadMode) + { + // dNSName: foo + // iPAddress: 127.0.0.1 + // UPN: user@some.domain + // dNSName: 86 6F 6F ("foo" with the f changed from 66 to 86) + // iPAddress: ::1 + // dNSName: *.foo + byte[] invalidEncoding = new byte[] + { + 0x30, 0x4B, 0x82, 0x03, 0x66, 0x6F, 0x6F, 0x87, 0x04, 0x7F, 0x00, 0x00, 0x01, 0xA0, 0x20, 0x06, + 0x0A, 0x2B, 0x06, 0x01, 0x04, 0x01, 0x82, 0x37, 0x14, 0x02, 0x03, 0xA0, 0x12, 0x0C, 0x10, 0x75, + 0x73, 0x65, 0x72, 0x40, 0x73, 0x6F, 0x6D, 0x65, 0x2E, 0x64, 0x6F, 0x6D, 0x61, 0x69, 0x6E, 0x82, + 0x03, 0x86, 0x6F, 0x6F, 0x87, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x82, 0x05, 0x2A, 0x2E, 0x66, 0x6F, 0x6F, + }; + + VerifyDecodeFailure(invalidEncoding, loadMode); + } + + private static void VerifyDecodeFailure(byte[] invalidEncoding, LoadMode loadMode) + { switch (loadMode) { case LoadMode.CopyFrom: diff --git a/src/libraries/System.Security.Cryptography.X509Certificates/tests/MatchesHostnameTests.cs b/src/libraries/System.Security.Cryptography.X509Certificates/tests/MatchesHostnameTests.cs index 9cf21577fb21ed..d9cadfe3ced559 100644 --- a/src/libraries/System.Security.Cryptography.X509Certificates/tests/MatchesHostnameTests.cs +++ b/src/libraries/System.Security.Cryptography.X509Certificates/tests/MatchesHostnameTests.cs @@ -174,8 +174,6 @@ public static void CommonNameDoesNotUseWildcards() using (X509Certificate2 cert = req.CreateSelfSigned(notBefore, notAfter)) { AssertMatch(false, cert, "papaya.fruit.example"); - - AssertMatch(true, cert, "*.fruit.example"); } } } @@ -204,10 +202,6 @@ public static void NoPartialWildcards() using (X509Certificate2 cert = req.CreateSelfSigned(notBefore, notAfter)) { AssertMatch(false, cert, "cranberry.fruit.example"); - - // Since we don't consider the partial wildcards as wildcards, they do match unexpanded. - AssertMatch(true, cert, "*berry.fruit.example"); - AssertMatch(true, cert, "cran*.fruit.example"); } } } @@ -244,8 +238,52 @@ public static void WildcardsDoNotMatchThroughPeriods() AssertMatch(false, cert, "apple.pome.fruit.example"); AssertMatch(false, cert, "apple.pomme.fruit.example"); - AssertMatch(true, cert, "*.fruit.example"); - AssertMatch(true, cert, "*.fruit.example", allowWildcards: false); + AssertMatch(false, cert, "apple.fruit.example", allowWildcards: false); + AssertMatch(false, cert, "blackberry.fruit.example", allowWildcards: false); + AssertMatch(false, cert, "pome.fruit.example", allowWildcards: false); + AssertMatch(false, cert, "pomme.fruit.example", allowWildcards: false); + // This one has a redundant dNSName after the wildcard + AssertMatch(true, cert, "rambutan.fruit.example", allowWildcards: false); + + AssertMatch(true, cert, "fruit.example"); + AssertMatch(true, cert, "fruit.example", allowWildcards: false); + } + } + } + + [Fact] + public static void WildcardsDoNotMatchAfterFirstPosition() + { + using (ECDsa key = ECDsa.Create()) + { + CertificateRequest req = new CertificateRequest( + "CN=10.0.0.1", + key, + HashAlgorithmName.SHA256); + + SubjectAlternativeNameBuilder sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddDnsName("fruit.example"); + sanBuilder.AddDnsName("*.fruit.example"); + sanBuilder.AddDnsName("*.*.fruit.example"); + sanBuilder.AddDnsName("apple.*.fruit.example"); + sanBuilder.AddDnsName("rambutan.fruit.example"); + sanBuilder.AddEmailAddress("it@fruit.example"); + + req.CertificateExtensions.Add(sanBuilder.Build()); + + DateTimeOffset now = DateTimeOffset.UtcNow; + DateTimeOffset notBefore = now.AddMinutes(-1); + DateTimeOffset notAfter = now.AddMinutes(1); + + using (X509Certificate2 cert = req.CreateSelfSigned(notBefore, notAfter)) + { + AssertMatch(true, cert, "apple.fruit.example"); + AssertMatch(true, cert, "blackberry.fruit.example"); + AssertMatch(true, cert, "pome.fruit.example"); + AssertMatch(true, cert, "pomme.fruit.example"); + AssertMatch(true, cert, "rambutan.fruit.example"); + AssertMatch(false, cert, "apple.pome.fruit.example"); + AssertMatch(false, cert, "apple.pomme.fruit.example"); AssertMatch(false, cert, "apple.fruit.example", allowWildcards: false); AssertMatch(false, cert, "blackberry.fruit.example", allowWildcards: false); @@ -383,7 +421,6 @@ public static void DnsNameMatchRejectsLeadingPeriodFromParameter() AssertMatch(true, cert, "tOmaTO.FRUIT.example."); AssertMatch(false, cert, "tOmaTO.vegetable.example."); AssertMatch(true, cert, "FRUit.example."); - AssertMatch(false, cert, ".FRUit.example."); AssertMatch(false, cert, "VEGetaBlE.example."); } } @@ -539,7 +576,6 @@ public static void NoMatchAndNoCommonName() using (X509Certificate2 cert = req.CreateSelfSigned(notBefore, notAfter)) { - AssertMatch(false, cert, ""); AssertMatch(false, cert, "FRUit.example"); AssertMatch(false, cert, "potato.vegetable.example"); } @@ -562,7 +598,6 @@ public static void NoMatchAndEmptyCommonName() using (X509Certificate2 cert = req.CreateSelfSigned(notBefore, notAfter)) { - AssertMatch(false, cert, ""); AssertMatch(false, cert, "FRUit.example"); AssertMatch(false, cert, "potato.vegetable.example"); } @@ -598,10 +633,6 @@ public static void NoMatchOnEmptyDnsName() AssertMatch(false, cert, "example"); AssertMatch(false, cert, "example."); - AssertMatch(false, cert, "."); - AssertMatch(false, cert, "*"); - AssertMatch(false, cert, "*."); - AssertMatch(false, cert, ""); } } } @@ -637,7 +668,6 @@ public static void NoMatchOnDnsNameWithLeadingPeriod() } AssertMatch(false, cert, "peach.fruit.example"); - AssertMatch(false, cert, ""); } } } @@ -667,15 +697,12 @@ public static void WildcardRequiresSuffixToMatch() { AssertMatch(false, cert, "example"); AssertMatch(false, cert, "example."); - AssertMatch(false, cert, "."); - AssertMatch(true, cert, "*"); - AssertMatch(true, cert, "*."); } } } [Fact] - public static void TooManySANsThrows() + public static void TooManySanExtensionsThrows() { byte[] tooManySans = ( "3082021430820175A00302010202083C883E44C34DA5CB300A06082A8648CE3D" + @@ -718,6 +745,51 @@ public static void TooManySANsThrows() } } + [Theory] + [InlineData(true)] + [InlineData(false)] + public static void OnlyValidHostnamesAccepted(bool addSanExt) + { + using (ECDsa key = ECDsa.Create()) + { + CertificateRequest req = new CertificateRequest( + "CN=John Smith", + key, + HashAlgorithmName.SHA256); + + DateTimeOffset now = DateTimeOffset.UtcNow; + DateTimeOffset notBefore = now.AddMinutes(-1); + DateTimeOffset notAfter = now.AddMinutes(1); + + if (addSanExt) + { + SubjectAlternativeNameBuilder sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddDnsName("fruit.example"); + sanBuilder.AddDnsName("*.fruit.example"); + sanBuilder.AddDnsName("*.*.fruit.example"); + sanBuilder.AddDnsName("apple.*.fruit.example"); + sanBuilder.AddDnsName("rambutan.fruit.example"); + sanBuilder.AddEmailAddress("it@fruit.example"); + + req.CertificateExtensions.Add(sanBuilder.Build()); + } + + using (X509Certificate2 cert = req.CreateSelfSigned(notBefore, notAfter)) + { + Assert.Throws("hostname", () => cert.MatchesHostname("John Smith")); + Assert.Throws("hostname", () => cert.MatchesHostname("*.pomme.fruit.example")); + Assert.Throws("hostname", () => cert.MatchesHostname(".pomme.fruit.example")); + Assert.Throws("hostname", () => cert.MatchesHostname("*berry.fruit.example")); + Assert.Throws("hostname", () => cert.MatchesHostname("cran*.fruit.example")); + Assert.Throws("hostname", () => cert.MatchesHostname("cran*.fruit.example")); + Assert.Throws("hostname", () => cert.MatchesHostname("")); + Assert.Throws("hostname", () => cert.MatchesHostname(".")); + Assert.Throws("hostname", () => cert.MatchesHostname("*.")); + Assert.Throws("hostname", () => cert.MatchesHostname("*")); + } + } + } + private static void AssertMatch( bool expected, X509Certificate2 cert, diff --git a/src/libraries/System.Security.Cryptography/ref/System.Security.Cryptography.cs b/src/libraries/System.Security.Cryptography/ref/System.Security.Cryptography.cs index 4de38e25adb990..4f4b6b6d657995 100644 --- a/src/libraries/System.Security.Cryptography/ref/System.Security.Cryptography.cs +++ b/src/libraries/System.Security.Cryptography/ref/System.Security.Cryptography.cs @@ -3140,7 +3140,7 @@ public void Open(System.Security.Cryptography.X509Certificates.OpenFlags flags) public void Remove(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate) { } public void RemoveRange(System.Security.Cryptography.X509Certificates.X509Certificate2Collection certificates) { } } - public partial class X509SubjectAlternativeNameExtension : System.Security.Cryptography.X509Certificates.X509Extension + public sealed partial class X509SubjectAlternativeNameExtension : System.Security.Cryptography.X509Certificates.X509Extension { public X509SubjectAlternativeNameExtension() { } public X509SubjectAlternativeNameExtension(byte[] rawData, bool critical = false) { } diff --git a/src/libraries/System.Security.Cryptography/src/Resources/Strings.resx b/src/libraries/System.Security.Cryptography/src/Resources/Strings.resx index fcbfdc139c7c55..cf4b84f4a00a44 100644 --- a/src/libraries/System.Security.Cryptography/src/Resources/Strings.resx +++ b/src/libraries/System.Security.Cryptography/src/Resources/Strings.resx @@ -96,6 +96,9 @@ Value of flags is invalid. + + The provided value is not a valid DNS hostname or IP Address. + The value of 'nameType' is invalid. diff --git a/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/X509Certificate2.cs b/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/X509Certificate2.cs index f3c76b6b290b64..e974b52fc66178 100644 --- a/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/X509Certificate2.cs +++ b/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/X509Certificate2.cs @@ -1239,18 +1239,30 @@ public bool TryExportCertificatePem(Span destination, out int charsWritten /// extended key usage. /// /// + /// + /// The parameter is not a valid DNS hostname or IP address. + /// /// /// The certificate contains multiple Subject Alternative Name extensions. /// - or - /// The Subject Alternative Name extension or Subject Name could not be decooded. /// + /// public bool MatchesHostname(string hostname, bool allowWildcards = true, bool allowCommonName = true) { ArgumentNullException.ThrowIfNull(hostname); + IPAddress? ipAddress; - if (hostname.Length == 0) + if (!IPAddress.TryParse(hostname, out ipAddress)) { - return false; + UriHostNameType kind = Uri.CheckHostName(hostname); + + if (kind != UriHostNameType.Dns) + { + throw new ArgumentException( + SR.Argument_InvalidHostnameOrIPAddress, + nameof(hostname)); + } } X509Extension? rawSAN = null; @@ -1272,12 +1284,14 @@ public bool MatchesHostname(string hostname, bool allowWildcards = true, bool al if (rawSAN is not null) { + Debug.Assert(rawSAN.GetType() == typeof(X509Extension)); + var san = new X509SubjectAlternativeNameExtension(); san.CopyFrom(rawSAN); bool hadAny = false; - if (IPAddress.TryParse(hostname, out IPAddress? ipAddress)) + if (ipAddress is not null) { foreach (IPAddress sanEntry in san.EnumerateIPAddresses()) { @@ -1369,6 +1383,10 @@ public bool MatchesHostname(string hostname, bool allowWildcards = true, bool al while (set.HasData) { + // We're not concerned with the possibility that the attribute structure + // is malformed here, because X500RelativeDistinguishedName already ensures it. + // So we don't bother checking that there's a value after the OID and then nothing + // after that. AsnValueReader attributeTypeAndValue = set.ReadSequence(); Oid? type = Oids.GetSharedOrNullOid(ref attributeTypeAndValue); diff --git a/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/X509SubjectAlternativeNameExtension.cs b/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/X509SubjectAlternativeNameExtension.cs index fb6bd425cca7f6..79434cb8e47ea1 100644 --- a/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/X509SubjectAlternativeNameExtension.cs +++ b/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/X509SubjectAlternativeNameExtension.cs @@ -2,13 +2,14 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Generic; +using System.Diagnostics; using System.Formats.Asn1; using System.Net; using System.Security.Cryptography.Asn1; namespace System.Security.Cryptography.X509Certificates { - public class X509SubjectAlternativeNameExtension : X509Extension + public sealed class X509SubjectAlternativeNameExtension : X509Extension { private List? _decoded; @@ -68,14 +69,8 @@ private static IEnumerable EnumerateIPAddresses(List { ReadOnlySpan value = item.IPAddress.GetValueOrDefault().Span; - if (value.Length is 4 or 16) - { - yield return new IPAddress(value); - } - else - { - throw new CryptographicException(SR.Cryptography_X509_SAN_UnknownIPAddressSize); - } + Debug.Assert(value.Length is 4 or 16); + yield return new IPAddress(value); } } } @@ -93,6 +88,24 @@ private static List Decode(ReadOnlySpan rawData) while (sequence.HasData) { GeneralNameAsn.Decode(ref sequence, default, out GeneralNameAsn item); + + // GeneralName already validates dNSName is a valid IA5String, + // so check iPAddress here so that it's always a consistent decode failure. + if (item.IPAddress.HasValue) + { + switch (item.IPAddress.GetValueOrDefault().Length) + { + case 4: + // IPv4 + case 16: + // UPv6 + break; + default: + throw new CryptographicException( + SR.Cryptography_X509_SAN_UnknownIPAddressSize); + } + } + decoded.Add(item); } From fa945790fcb5520bcb32ec5ca1bc94f2b3a6c94e Mon Sep 17 00:00:00 2001 From: Jeremy Barton Date: Mon, 18 Jul 2022 17:34:07 -0700 Subject: [PATCH 3/7] Make macOS happy --- .../src/System/Security/Cryptography/OidLookup.NoFallback.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/OidLookup.NoFallback.cs b/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/OidLookup.NoFallback.cs index 5396d1a4367580..624c4df1bdc6ea 100644 --- a/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/OidLookup.NoFallback.cs +++ b/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/OidLookup.NoFallback.cs @@ -53,6 +53,7 @@ private static bool ShouldUseCache(OidGroup oidGroup) { "signingTime", "1.2.840.113549.1.9.5" }, { "X509v3 Subject Key Identifier", "2.5.29.14" }, { "X509v3 Key Usage", "2.5.29.15" }, + { "X509v3 Subject Alternative Name", "2.5.29.17" }, { "X509v3 Basic Constraints", "2.5.29.19" }, { "X509v3 Extended Key Usage", "2.5.29.37" }, { "prime256v1", "1.2.840.10045.3.1.7" }, From 0a462d0cbbb072b72ca8af09fea06e9c45712ee0 Mon Sep 17 00:00:00 2001 From: Jeremy Barton Date: Mon, 18 Jul 2022 19:07:17 -0700 Subject: [PATCH 4/7] Make macOS happier --- .../src/System/Security/Cryptography/OidLookup.NoFallback.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/OidLookup.NoFallback.cs b/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/OidLookup.NoFallback.cs index 624c4df1bdc6ea..316865df7941e1 100644 --- a/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/OidLookup.NoFallback.cs +++ b/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/OidLookup.NoFallback.cs @@ -34,7 +34,7 @@ private static bool ShouldUseCache(OidGroup oidGroup) } /// Expected size of . - private const int ExtraFriendlyNameToOidCount = 11; + private const int ExtraFriendlyNameToOidCount = 12; // There are places inside the framework where Oid.FromFriendlyName is called // (to pass in an OID group restriction for Windows) and an exception is not tolerated. From 42075040cb6a096fd8ba5416fd19878da586a8e9 Mon Sep 17 00:00:00 2001 From: Jeremy Barton Date: Tue, 19 Jul 2022 09:08:03 -0700 Subject: [PATCH 5/7] Small test enhancements --- .../SubjectAlternativeNameTests.cs | 56 ++++++++++++++++++- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/src/libraries/System.Security.Cryptography.X509Certificates/tests/ExtensionsTests/SubjectAlternativeNameTests.cs b/src/libraries/System.Security.Cryptography.X509Certificates/tests/ExtensionsTests/SubjectAlternativeNameTests.cs index 3a8663905c1394..b46c2d6fd1d8d1 100644 --- a/src/libraries/System.Security.Cryptography.X509Certificates/tests/ExtensionsTests/SubjectAlternativeNameTests.cs +++ b/src/libraries/System.Security.Cryptography.X509Certificates/tests/ExtensionsTests/SubjectAlternativeNameTests.cs @@ -16,6 +16,7 @@ public static void DefaultConstructor() Assert.Equal("2.5.29.17", ext.Oid.Value); Assert.Empty(ext.EnumerateDnsNames()); Assert.Empty(ext.EnumerateIPAddresses()); + Assert.False(ext.Critical, "ext.Critical"); } [Fact] @@ -102,6 +103,57 @@ public static void EnumerateIPAddresses(LoadMode loadMode) Assert.Equal(new[] { IPAddress.Loopback, IPAddress.IPv6Loopback }, ext.EnumerateIPAddresses()); } + [Theory] + [InlineData(LoadMode.CopyFrom)] + [InlineData(LoadMode.Array)] + [InlineData(LoadMode.Span)] + public static void CopyFromAfterLoaded(LoadMode originalLoadMode) + { + SubjectAlternativeNameBuilder builder = new SubjectAlternativeNameBuilder(); + builder.AddDnsName("foo"); + builder.AddIpAddress(IPAddress.Loopback); + builder.AddUserPrincipalName("user@some.domain"); + builder.AddIpAddress(IPAddress.IPv6Loopback); + builder.AddDnsName("*.foo"); + X509Extension built = builder.Build(true); + + X509SubjectAlternativeNameExtension ext; + + switch (originalLoadMode) + { + case LoadMode.CopyFrom: + ext = new X509SubjectAlternativeNameExtension(); + ext.CopyFrom(built); + break; + case LoadMode.Array: + ext = new X509SubjectAlternativeNameExtension(built.RawData, critical: true); + break; + case LoadMode.Span: + byte[] tmp = new byte[built.RawData.Length + 2]; + built.RawData.AsSpan().CopyTo(tmp.AsSpan(1)); + ext = new X509SubjectAlternativeNameExtension(tmp.AsSpan()[1..^1], critical: true); + tmp.AsSpan().Clear(); + break; + default: + throw new ArgumentOutOfRangeException(nameof(originalLoadMode), originalLoadMode, "Unexpected mode"); + } + + Assert.True(ext.Critical, "ext.Critical"); + Assert.Equal(new[] { "foo", "*.foo" }, ext.EnumerateDnsNames()); + Assert.Equal(new[] { IPAddress.Loopback, IPAddress.IPv6Loopback }, ext.EnumerateIPAddresses()); + + builder = new SubjectAlternativeNameBuilder(); + builder.AddDnsName("a"); + builder.AddDnsName("b"); + builder.AddDnsName("c"); + builder.AddIpAddress(IPAddress.IPv6Loopback); + ext.CopyFrom(builder.Build()); + + Assert.False(ext.Critical, "ext.Critical"); + Assert.Equal(new[] { "a", "b", "c" }, ext.EnumerateDnsNames()); + Assert.Equal(new[] { IPAddress.IPv6Loopback }, ext.EnumerateIPAddresses()); + } + [Theory] [InlineData(LoadMode.CopyFrom)] [InlineData(LoadMode.Array)] @@ -121,7 +173,7 @@ public static void VerifyInvalidIPAddressBehavior(LoadMode loadMode) { // dNSName: foo // iPAddress: 127.0.0.1 - // iPAddress: 7F 00 00 01 00 (127.0.0.1 with a trailing 0 + // iPAddress: 7F 00 00 01 00 (127.0.0.1 with a trailing 0) // UPN: user@some.domain // iPAddress: ::1 // dNSName: *.foo @@ -149,7 +201,7 @@ public static void VerifyInvalidDnsNameBehavior(LoadMode loadMode) // dNSName: 86 6F 6F ("foo" with the f changed from 66 to 86) // iPAddress: ::1 // dNSName: *.foo - byte[] invalidEncoding = new byte[] + byte[] invalidEncoding = { 0x30, 0x4B, 0x82, 0x03, 0x66, 0x6F, 0x6F, 0x87, 0x04, 0x7F, 0x00, 0x00, 0x01, 0xA0, 0x20, 0x06, 0x0A, 0x2B, 0x06, 0x01, 0x04, 0x01, 0x82, 0x37, 0x14, 0x02, 0x03, 0xA0, 0x12, 0x0C, 0x10, 0x75, From 0719d7b0591b65e6e98a9d6a158213df7dda60af Mon Sep 17 00:00:00 2001 From: Jeremy Barton Date: Tue, 19 Jul 2022 09:12:10 -0700 Subject: [PATCH 6/7] Add new X.509 extensions into CryptoConfig tests --- .../System.Security.Cryptography/tests/CryptoConfigTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/libraries/System.Security.Cryptography/tests/CryptoConfigTests.cs b/src/libraries/System.Security.Cryptography/tests/CryptoConfigTests.cs index 921b9bab3c4ca7..21f0deaf63c78f 100644 --- a/src/libraries/System.Security.Cryptography/tests/CryptoConfigTests.cs +++ b/src/libraries/System.Security.Cryptography/tests/CryptoConfigTests.cs @@ -475,10 +475,12 @@ public static IEnumerable AllValidNames yield return new object[] { "http://www.w3.org/2001/04/xmldsig-more#hmac-sha512", typeof(HMACSHA512).FullName, true }; // X509 + yield return new object[] { "1.3.6.1.5.5.7.1.1", "System.Security.Cryptography.X509Certificates.X509AuthorityInformationAccessExtension", true }; yield return new object[] { "2.5.29.10", "System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension", true }; yield return new object[] { "2.5.29.19", "System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension", true }; yield return new object[] { "2.5.29.14", "System.Security.Cryptography.X509Certificates.X509SubjectKeyIdentifierExtension", true }; yield return new object[] { "2.5.29.15", "System.Security.Cryptography.X509Certificates.X509KeyUsageExtension", true }; + yield return new object[] { "2.5.29.17", "System.Security.Cryptography.X509Certificates.X509SubjectAlternativeNameExtension", true }; yield return new object[] { "2.5.29.37", "System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension", true }; yield return new object[] { "X509Chain", "System.Security.Cryptography.X509Certificates.X509Chain", true }; From 6aca049f7444c227af2ebfa2013651ba0b0617c1 Mon Sep 17 00:00:00 2001 From: Jeremy Barton Date: Tue, 19 Jul 2022 16:49:46 -0700 Subject: [PATCH 7/7] Apply feedback * Clarify the position on SRV-ID and URI-ID matching in API docs and tests * Add some more IPv6 tests * Delete a now-redundant test * Change a dead if to an assert. --- .../tests/MatchesHostnameTests.cs | 148 ++++++++++++++---- .../X509Certificates/X509Certificate2.cs | 15 +- 2 files changed, 124 insertions(+), 39 deletions(-) diff --git a/src/libraries/System.Security.Cryptography.X509Certificates/tests/MatchesHostnameTests.cs b/src/libraries/System.Security.Cryptography.X509Certificates/tests/MatchesHostnameTests.cs index d9cadfe3ced559..9c29ac1842af7d 100644 --- a/src/libraries/System.Security.Cryptography.X509Certificates/tests/MatchesHostnameTests.cs +++ b/src/libraries/System.Security.Cryptography.X509Certificates/tests/MatchesHostnameTests.cs @@ -108,6 +108,8 @@ public static void SanWithIPAddressMeansNoCommonNameFallback() SubjectAlternativeNameBuilder sanBuilder = new SubjectAlternativeNameBuilder(); sanBuilder.AddIpAddress(IPAddress.Loopback); + sanBuilder.AddIpAddress(IPAddress.IPv6Loopback); + sanBuilder.AddIpAddress(IPAddress.Parse("[fe80::1]")); sanBuilder.AddEmailAddress("it@fruit.example"); req.CertificateExtensions.Add(sanBuilder.Build()); @@ -119,6 +121,22 @@ public static void SanWithIPAddressMeansNoCommonNameFallback() using (X509Certificate2 cert = req.CreateSelfSigned(notBefore, notAfter)) { AssertMatch(true, cert, "127.0.0.1"); + // Various text forms of IPv6Loopback + AssertMatch(true, cert, "[::1]"); + AssertMatch(true, cert, "::1"); + AssertMatch(true, cert, "[::01]"); + AssertMatch(true, cert, "[0000::1]"); + // Various text forms of fe80::1 + AssertMatch(true, cert, "[fe80::1]"); + AssertMatch(true, cert, "[FE80::1]"); + AssertMatch(true, cert, "fe80::1"); + AssertMatch(true, cert, "[fe80:0000::1]"); + AssertMatch(true, cert, "[fe80:0000::01]"); + // Various text forms of fe80:: + AssertMatch(false, cert, "[fe80::]"); + AssertMatch(false, cert, "fe80::"); + AssertMatch(false, cert, "[fe80::0]"); + AssertMatch(false, cert, "[fe80:0000::]"); // Since the SAN has an iPAddress value, we do not fall back to the CN. AssertMatch(false, cert, "10.0.0.1"); @@ -126,6 +144,42 @@ public static void SanWithIPAddressMeansNoCommonNameFallback() } } + [Fact] + public static void IPv6InCommonNameIsTextMatch() + { + using (ECDsa key = ECDsa.Create()) + { + CertificateRequest req = new CertificateRequest( + "CN=[fe80::1]", + key, + HashAlgorithmName.SHA256); + + SubjectAlternativeNameBuilder sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddDnsName("huckleberry.fruit.example"); + sanBuilder.AddEmailAddress("it@fruit.example"); + + req.CertificateExtensions.Add(sanBuilder.Build()); + + DateTimeOffset now = DateTimeOffset.UtcNow; + DateTimeOffset notBefore = now.AddMinutes(-1); + DateTimeOffset notAfter = now.AddMinutes(1); + + using (X509Certificate2 cert = req.CreateSelfSigned(notBefore, notAfter)) + { + // Various text forms of fe80::1. These would all match under IP-ID, but + // only the first couple match under CN-ID (case-insensitive exact) + AssertMatch(true, cert, "[fe80::1]"); + AssertMatch(true, cert, "[fE80::1]"); + AssertMatch(true, cert, "[FE80::1]"); + AssertMatch(true, cert, "[Fe80::1]"); + + AssertMatch(false, cert, "fe80::1"); + AssertMatch(false, cert, "[fe80:0000::1]"); + AssertMatch(false, cert, "[fe80:0000::01]"); + } + } + } + [Fact] public static void SanDoesNotMatchIPAddressInDnsName() { @@ -202,6 +256,8 @@ public static void NoPartialWildcards() using (X509Certificate2 cert = req.CreateSelfSigned(notBefore, notAfter)) { AssertMatch(false, cert, "cranberry.fruit.example"); + AssertMatch(false, cert, "cranapple.fruit.example"); + AssertMatch(false, cert, "strawberry.fruit.example"); } } } @@ -394,38 +450,6 @@ public static void DnsNameMatchIgnoresTrailingPeriodFromParameter() } } - [Fact] - public static void DnsNameMatchRejectsLeadingPeriodFromParameter() - { - using (ECDsa key = ECDsa.Create()) - { - CertificateRequest req = new CertificateRequest( - "CN=10.0.0.1", - key, - HashAlgorithmName.SHA256); - - SubjectAlternativeNameBuilder sanBuilder = new SubjectAlternativeNameBuilder(); - sanBuilder.AddDnsName("fruit.EXAMPLE"); - sanBuilder.AddDnsName("*.FrUIt.eXaMpLE"); - sanBuilder.AddEmailAddress("it@fruit.example"); - - req.CertificateExtensions.Add(sanBuilder.Build()); - - DateTimeOffset now = DateTimeOffset.UtcNow; - DateTimeOffset notBefore = now.AddMinutes(-1); - DateTimeOffset notAfter = now.AddMinutes(1); - - using (X509Certificate2 cert = req.CreateSelfSigned(notBefore, notAfter)) - { - AssertMatch(true, cert, "aPPlE.fruit.example."); - AssertMatch(true, cert, "tOmaTO.FRUIT.example."); - AssertMatch(false, cert, "tOmaTO.vegetable.example."); - AssertMatch(true, cert, "FRUit.example."); - AssertMatch(false, cert, "VEGetaBlE.example."); - } - } - } - [Fact] public static void CommonNameMatchDoesNotIgnoreTrailingPeriodFromParameter() { @@ -701,6 +725,66 @@ public static void WildcardRequiresSuffixToMatch() } } + [Theory] + [InlineData(false)] + [InlineData(true)] + public static void DnsMatchIgnoresUriAndSrv(bool includeDnsName) + { + // URI: https://tangerine.fruit.example + // Other(SRV): _mail.ugli.fruit.example + ReadOnlySpan uriAndSrv = new byte[] + { + 0x30, 0x4A, 0x86, 0x20, 0x68, 0x74, 0x74, 0x70, 0x73, 0x3A, 0x2F, 0x2F, 0x74, 0x61, 0x6E, 0x67, + 0x65, 0x72, 0x69, 0x6E, 0x65, 0x2E, 0x66, 0x72, 0x75, 0x69, 0x74, 0x2E, 0x65, 0x78, 0x61, 0x6D, + 0x70, 0x6C, 0x65, 0x2F, 0xA0, 0x26, 0x06, 0x08, 0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x08, 0x07, + 0xA0, 0x1A, 0x16, 0x18, 0x5F, 0x6D, 0x61, 0x69, 0x6C, 0x2E, 0x75, 0x67, 0x6C, 0x69, 0x2E, 0x66, + 0x72, 0x75, 0x69, 0x74, 0x2E, 0x65, 0x78, 0x61, 0x6D, 0x70, 0x6C, 0x65, + }; + + // URI: https://tangerine.fruit.example + // Other(SRV): _mail.ugli.fruit.example + // DNS: strawberry.aggregate.fruit.example + // DNS: strawberry.fruit.example + ReadOnlySpan uriSrvAndDns = new byte[] + { + 0x30, 0x81, 0x88, 0x86, 0x20, 0x68, 0x74, 0x74, 0x70, 0x73, 0x3A, 0x2F, 0x2F, 0x74, 0x61, 0x6E, + 0x67, 0x65, 0x72, 0x69, 0x6E, 0x65, 0x2E, 0x66, 0x72, 0x75, 0x69, 0x74, 0x2E, 0x65, 0x78, 0x61, + 0x6D, 0x70, 0x6C, 0x65, 0x2F, 0xA0, 0x26, 0x06, 0x08, 0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x08, + 0x07, 0xA0, 0x1A, 0x16, 0x18, 0x5F, 0x6D, 0x61, 0x69, 0x6C, 0x2E, 0x75, 0x67, 0x6C, 0x69, 0x2E, + 0x66, 0x72, 0x75, 0x69, 0x74, 0x2E, 0x65, 0x78, 0x61, 0x6D, 0x70, 0x6C, 0x65, 0x82, 0x22, 0x73, + 0x74, 0x72, 0x61, 0x77, 0x62, 0x65, 0x72, 0x72, 0x79, 0x2E, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, + 0x61, 0x74, 0x65, 0x2E, 0x66, 0x72, 0x75, 0x69, 0x74, 0x2E, 0x65, 0x78, 0x61, 0x6D, 0x70, 0x6C, + 0x65, 0x82, 0x18, 0x73, 0x74, 0x72, 0x61, 0x77, 0x62, 0x65, 0x72, 0x72, 0x79, 0x2E, 0x66, 0x72, + 0x75, 0x69, 0x74, 0x2E, 0x65, 0x78, 0x61, 0x6D, 0x70, 0x6C, 0x65, + }; + + using (ECDsa key = ECDsa.Create()) + { + CertificateRequest req = new CertificateRequest( + "CN=strawberry.fruit.example", + key, + HashAlgorithmName.SHA256); + + req.CertificateExtensions.Add( + new X509Extension( + "2.5.29.17", + includeDnsName ? uriSrvAndDns : uriAndSrv, + critical: false)); + + DateTimeOffset now = DateTimeOffset.UtcNow; + DateTimeOffset notBefore = now.AddMinutes(-1); + DateTimeOffset notAfter = now.AddMinutes(1); + + using (X509Certificate2 cert = req.CreateSelfSigned(notBefore, notAfter)) + { + AssertMatch(includeDnsName, cert, "strawberry.aggregate.fruit.example"); + AssertMatch(true, cert, "strawberry.fruit.example"); + AssertMatch(false, cert, "tangerine.fruit.example"); + AssertMatch(false, cert, "ugli.fruit.example"); + } + } + } + [Fact] public static void TooManySanExtensionsThrows() { diff --git a/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/X509Certificate2.cs b/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/X509Certificate2.cs index e974b52fc66178..9fc8c2e37725d3 100644 --- a/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/X509Certificate2.cs +++ b/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/X509Certificate2.cs @@ -1223,6 +1223,11 @@ public bool TryExportCertificatePem(Span destination, out int charsWritten /// /// /// + /// This implementation considers SRV-ID values or URI-ID values as out-of-scope, + /// and will not use their presence as a reason to stop the fallback from DNS-ID matching + /// to the CN-ID. + /// + /// /// This method does not convert non-ASCII hostnames to the IDNA representation. For Unicode domains, /// the caller must make use of or an equivalent IDNA mapper. /// @@ -1245,8 +1250,9 @@ public bool TryExportCertificatePem(Span destination, out int charsWritten /// /// The certificate contains multiple Subject Alternative Name extensions. /// - or - - /// The Subject Alternative Name extension or Subject Name could not be decooded. + /// The Subject Alternative Name extension or Subject Name could not be decoded. /// + /// /// public bool MatchesHostname(string hostname, bool allowWildcards = true, bool allowCommonName = true) { @@ -1320,12 +1326,7 @@ public bool MatchesHostname(string hostname, bool allowWildcards = true, bool al ReadOnlySpan afterFirstDot = default; int firstDot = match.IndexOf('.'); - - // ".something.example.org" always fails to match. - if (firstDot == 0) - { - return false; - } + Debug.Assert(firstDot != 0, "Leading periods should have been rejected."); if (firstDot > 0) {