From f62bad3778869392b8db5148f24d7b8b8400ff11 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 6 Nov 2025 13:03:25 -0800 Subject: [PATCH 1/5] Add Subject Key Identifier and Authority Key Identifier extensions to the generated dev cert --- .../CertificateManager.cs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/Shared/CertificateGeneration/CertificateManager.cs b/src/Shared/CertificateGeneration/CertificateManager.cs index aba1240c7b12..b67b8dcdf990 100644 --- a/src/Shared/CertificateGeneration/CertificateManager.cs +++ b/src/Shared/CertificateGeneration/CertificateManager.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; +using System.Formats.Asn1; using System.Linq; using System.Runtime.InteropServices; using System.Security.Cryptography; @@ -19,8 +20,8 @@ namespace Microsoft.AspNetCore.Certificates.Generation; internal abstract class CertificateManager { - internal const int CurrentAspNetCoreCertificateVersion = 4; - internal const int CurrentMinimumAspNetCoreCertificateVersion = 4; + internal const int CurrentAspNetCoreCertificateVersion = 5; + internal const int CurrentMinimumAspNetCoreCertificateVersion = 5; // OID used for HTTPS certs internal const string AspNetHttpsOid = "1.3.6.1.4.1.311.84.1.1"; @@ -28,7 +29,7 @@ internal abstract class CertificateManager private const string ServerAuthenticationEnhancedKeyUsageOid = "1.3.6.1.5.5.7.3.1"; private const string ServerAuthenticationEnhancedKeyUsageOidFriendlyName = "Server Authentication"; - + // dns names of the host from a container private const string LocalhostDockerHttpsDnsName = "host.docker.internal"; private const string ContainersDockerHttpsDnsName = "host.containers.internal"; @@ -828,6 +829,18 @@ internal static X509Certificate2 CreateSelfSignedCertificate( request.CertificateExtensions.Add(extension); } + // Only add the SKI and AKI extensions if neither is already present. + if (!request.CertificateExtensions.OfType().Any() && !request.CertificateExtensions.OfType().Any()) + { + // RFC 5280 section 4.2.1.2 + var subjectKeyIdentifier = new X509SubjectKeyIdentifierExtension(new PublicKey(key), critical: false); + // RFC 5280 section 4.2.1.1 + var authorityKeyIdentifier = X509AuthorityKeyIdentifierExtension.CreateFromSubjectKeyIdentifier(subjectKeyIdentifier); + + request.CertificateExtensions.Add(subjectKeyIdentifier); + request.CertificateExtensions.Add(authorityKeyIdentifier); + } + var result = request.CreateSelfSigned(notBefore, notAfter); return result; From 35e13d45bb96a6ca2d1088f91cf74f136bf25076 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 6 Nov 2025 13:24:24 -0800 Subject: [PATCH 2/5] Add more detail to the comment --- src/Shared/CertificateGeneration/CertificateManager.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Shared/CertificateGeneration/CertificateManager.cs b/src/Shared/CertificateGeneration/CertificateManager.cs index b67b8dcdf990..2bba8b277b9b 100644 --- a/src/Shared/CertificateGeneration/CertificateManager.cs +++ b/src/Shared/CertificateGeneration/CertificateManager.cs @@ -830,6 +830,8 @@ internal static X509Certificate2 CreateSelfSignedCertificate( } // Only add the SKI and AKI extensions if neither is already present. + // OpenSSL needs these to correctly identify the trust chain for a private key. If multiple certificates don't have a subject key identifier and share the same subject, + // the wrong certificate can be chosen for the trust chain, leading to validation errors. if (!request.CertificateExtensions.OfType().Any() && !request.CertificateExtensions.OfType().Any()) { // RFC 5280 section 4.2.1.2 From 64a9631db4f5c27324f0cd2d8214646bc654ae1a Mon Sep 17 00:00:00 2001 From: David Negstad <50252651+danegsta@users.noreply.github.com> Date: Thu, 6 Nov 2025 13:30:29 -0800 Subject: [PATCH 3/5] Update src/Shared/CertificateGeneration/CertificateManager.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Shared/CertificateGeneration/CertificateManager.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Shared/CertificateGeneration/CertificateManager.cs b/src/Shared/CertificateGeneration/CertificateManager.cs index 2bba8b277b9b..fd639993333e 100644 --- a/src/Shared/CertificateGeneration/CertificateManager.cs +++ b/src/Shared/CertificateGeneration/CertificateManager.cs @@ -4,7 +4,6 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; -using System.Formats.Asn1; using System.Linq; using System.Runtime.InteropServices; using System.Security.Cryptography; From 87fe974ff56cd4835cb6f2999d6c5cd36316e989 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 6 Nov 2025 14:39:23 -0800 Subject: [PATCH 4/5] Use SHA256 and check by OID instead of extension type --- src/Shared/CertificateGeneration/CertificateManager.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Shared/CertificateGeneration/CertificateManager.cs b/src/Shared/CertificateGeneration/CertificateManager.cs index fd639993333e..c07337210cfb 100644 --- a/src/Shared/CertificateGeneration/CertificateManager.cs +++ b/src/Shared/CertificateGeneration/CertificateManager.cs @@ -29,6 +29,9 @@ internal abstract class CertificateManager private const string ServerAuthenticationEnhancedKeyUsageOid = "1.3.6.1.5.5.7.3.1"; private const string ServerAuthenticationEnhancedKeyUsageOidFriendlyName = "Server Authentication"; + internal const string SubjectKeyIdentifierOid = "2.5.29.14"; + internal const string AuthorityKeyIdentifierOid = "2.5.29.35"; + // dns names of the host from a container private const string LocalhostDockerHttpsDnsName = "host.docker.internal"; private const string ContainersDockerHttpsDnsName = "host.containers.internal"; @@ -831,10 +834,10 @@ internal static X509Certificate2 CreateSelfSignedCertificate( // Only add the SKI and AKI extensions if neither is already present. // OpenSSL needs these to correctly identify the trust chain for a private key. If multiple certificates don't have a subject key identifier and share the same subject, // the wrong certificate can be chosen for the trust chain, leading to validation errors. - if (!request.CertificateExtensions.OfType().Any() && !request.CertificateExtensions.OfType().Any()) + if (!request.CertificateExtensions.Any(ext => ext.Oid?.Value is SubjectKeyIdentifierOid or AuthorityKeyIdentifierOid)) { // RFC 5280 section 4.2.1.2 - var subjectKeyIdentifier = new X509SubjectKeyIdentifierExtension(new PublicKey(key), critical: false); + var subjectKeyIdentifier = new X509SubjectKeyIdentifierExtension(request.PublicKey, X509SubjectKeyIdentifierHashAlgorithm.Sha256, critical: false); // RFC 5280 section 4.2.1.1 var authorityKeyIdentifier = X509AuthorityKeyIdentifierExtension.CreateFromSubjectKeyIdentifier(subjectKeyIdentifier); From b13ae8c7a7feae62fd163e593a3cb26bb54ce167 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 6 Nov 2025 15:36:29 -0800 Subject: [PATCH 5/5] Add test coverage for CertificateManager --- .../Shared.Tests/CertificateManagerTests.cs | 133 ++++++++++++++++++ .../Microsoft.AspNetCore.Shared.Tests.csproj | 1 + 2 files changed, 134 insertions(+) create mode 100644 src/Shared/test/Shared.Tests/CertificateManagerTests.cs diff --git a/src/Shared/test/Shared.Tests/CertificateManagerTests.cs b/src/Shared/test/Shared.Tests/CertificateManagerTests.cs new file mode 100644 index 000000000000..d4ef5975fab9 --- /dev/null +++ b/src/Shared/test/Shared.Tests/CertificateManagerTests.cs @@ -0,0 +1,133 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Microsoft.AspNetCore.Certificates.Generation; + +namespace Microsoft.AspNetCore.Internal.Tests; + +public class CertificateManagerTests +{ + [Fact] + public void CreateAspNetCoreHttpsDevelopmentCertificateIsValid() + { + var notBefore = DateTimeOffset.Now; + var notAfter = notBefore.AddMinutes(5); + var certificate = CertificateManager.Instance.CreateAspNetCoreHttpsDevelopmentCertificate(notBefore, notAfter); + + // Certificate should be valid for the expected time range + Assert.Equal(notBefore, certificate.NotBefore, TimeSpan.FromSeconds(1)); + Assert.Equal(notAfter, certificate.NotAfter, TimeSpan.FromSeconds(1)); + + // Certificate should have a private key + Assert.True(certificate.HasPrivateKey); + + // Certificate should be recognized as an ASP.NET Core HTTPS development certificate + Assert.True(CertificateManager.IsHttpsDevelopmentCertificate(certificate)); + + // Certificate should include a Subject Key Identifier extension + var subjectKeyIdentifier = Assert.Single(certificate.Extensions.OfType()); + + // Certificate should include an Authority Key Identifier extension + var authorityKeyIdentifier = Assert.Single(certificate.Extensions.OfType()); + + // The Authority Key Identifier should match the Subject Key Identifier + Assert.True(authorityKeyIdentifier.KeyIdentifier?.Span.SequenceEqual(subjectKeyIdentifier.SubjectKeyIdentifierBytes.Span)); + } + + [Fact] + public void CreateSelfSignedCertificate_ExistingSubjectKeyIdentifierExtension() + { + var subject = new X500DistinguishedName("CN=TestCertificate"); + var notBefore = DateTimeOffset.Now; + var notAfter = notBefore.AddMinutes(5); + var testSubjectKeyId = new byte[] { 1, 2, 3, 4, 5 }; + var extensions = new List + { + new X509SubjectKeyIdentifierExtension(testSubjectKeyId, critical: false), + }; + + var certificate = CertificateManager.CreateSelfSignedCertificate(subject, extensions, notBefore, notAfter); + + Assert.Equal(notBefore, certificate.NotBefore, TimeSpan.FromSeconds(1)); + Assert.Equal(notAfter, certificate.NotAfter, TimeSpan.FromSeconds(1)); + + // Certificate had an existing Subject Key Identifier extension, so AKID should not be added + Assert.Empty(certificate.Extensions.OfType()); + + var subjectKeyIdentifier = Assert.Single(certificate.Extensions.OfType()); + Assert.True(subjectKeyIdentifier.SubjectKeyIdentifierBytes.Span.SequenceEqual(testSubjectKeyId)); + } + + [Fact] + public void CreateSelfSignedCertificate_ExistingRawSubjectKeyIdentifierExtension() + { + var subject = new X500DistinguishedName("CN=TestCertificate"); + var notBefore = DateTimeOffset.Now; + var notAfter = notBefore.AddMinutes(5); + var testSubjectKeyId = new byte[] { 5, 4, 3, 2, 1 }; + // Pass the extension as a raw X509Extension to simulate pre-encoded data + var extension = new X509SubjectKeyIdentifierExtension(testSubjectKeyId, critical: false); + var extensions = new List + { + new X509Extension(extension.Oid, extension.RawData, extension.Critical), + }; + + var certificate = CertificateManager.CreateSelfSignedCertificate(subject, extensions, notBefore, notAfter); + + Assert.Equal(notBefore, certificate.NotBefore, TimeSpan.FromSeconds(1)); + Assert.Equal(notAfter, certificate.NotAfter, TimeSpan.FromSeconds(1)); + + Assert.Empty(certificate.Extensions.OfType()); + + var subjectKeyIdentifier = Assert.Single(certificate.Extensions.OfType()); + Assert.True(subjectKeyIdentifier.SubjectKeyIdentifierBytes.Span.SequenceEqual(testSubjectKeyId)); + } + + [Fact] + public void CreateSelfSignedCertificate_ExistingRawAuthorityKeyIdentifierExtension() + { + var subject = new X500DistinguishedName("CN=TestCertificate"); + var notBefore = DateTimeOffset.Now; + var notAfter = notBefore.AddMinutes(5); + var testSubjectKeyId = new byte[] { 9, 8, 7, 6, 5 }; + // Pass the extension as a raw X509Extension to simulate pre-encoded data + var subjectExtension = new X509SubjectKeyIdentifierExtension(testSubjectKeyId, critical: false); + var authorityExtension = X509AuthorityKeyIdentifierExtension.CreateFromSubjectKeyIdentifier(subjectExtension); + var extensions = new List + { + new X509Extension(authorityExtension.Oid, authorityExtension.RawData, authorityExtension.Critical), + }; + + var certificate = CertificateManager.CreateSelfSignedCertificate(subject, extensions, notBefore, notAfter); + + Assert.Equal(notBefore, certificate.NotBefore, TimeSpan.FromSeconds(1)); + Assert.Equal(notAfter, certificate.NotAfter, TimeSpan.FromSeconds(1)); + + Assert.Empty(certificate.Extensions.OfType()); + + var authorityKeyIdentifier = Assert.Single(certificate.Extensions.OfType()); + Assert.True(authorityKeyIdentifier.KeyIdentifier?.Span.SequenceEqual(testSubjectKeyId)); + } + + [Fact] + public void CreateSelfSignedCertificate_NoSubjectKeyIdentifierExtension() + { + var subject = new X500DistinguishedName("CN=TestCertificate"); + var notBefore = DateTimeOffset.Now; + var notAfter = notBefore.AddMinutes(5); + var extensions = new List(); + + var certificate = CertificateManager.CreateSelfSignedCertificate(subject, extensions, notBefore, notAfter); + + Assert.Equal(notBefore, certificate.NotBefore, TimeSpan.FromSeconds(1)); + Assert.Equal(notAfter, certificate.NotAfter, TimeSpan.FromSeconds(1)); + + var subjectKeyIdentifier = Assert.Single(certificate.Extensions.OfType()); + var authorityKeyIdentifier = Assert.Single(certificate.Extensions.OfType()); + + // The Authority Key Identifier should match the Subject Key Identifier + Assert.True(authorityKeyIdentifier.KeyIdentifier?.Span.SequenceEqual(subjectKeyIdentifier.SubjectKeyIdentifierBytes.Span)); + } +} \ No newline at end of file diff --git a/src/Shared/test/Shared.Tests/Microsoft.AspNetCore.Shared.Tests.csproj b/src/Shared/test/Shared.Tests/Microsoft.AspNetCore.Shared.Tests.csproj index 72496024ef3b..6f418ef98db0 100644 --- a/src/Shared/test/Shared.Tests/Microsoft.AspNetCore.Shared.Tests.csproj +++ b/src/Shared/test/Shared.Tests/Microsoft.AspNetCore.Shared.Tests.csproj @@ -10,6 +10,7 @@ +