diff --git a/DevProxy.Proxy.Kestrel.Tests/CertificateAuthorityTests.cs b/DevProxy.Proxy.Kestrel.Tests/CertificateAuthorityTests.cs index ebbfcd11..f8687660 100644 --- a/DevProxy.Proxy.Kestrel.Tests/CertificateAuthorityTests.cs +++ b/DevProxy.Proxy.Kestrel.Tests/CertificateAuthorityTests.cs @@ -54,6 +54,8 @@ public void FirstRun_CreatesValidRoot_AndPersistsToDisk() Assert.Equal("CN=Dev Proxy CA", root.Subject); Assert.True(root.HasPrivateKey); Assert.True(IsCertificateAuthority(root)); + Assert.True(HasSubjectKeyIdentifier(root)); + Assert.True(HasKeyCertSignUsage(root)); Assert.True(root.NotAfter > DateTime.Now); Assert.True(File.Exists(_rootPath)); } @@ -105,6 +107,36 @@ public void NonCaRoot_Regenerates() Assert.True(IsCertificateAuthority(ca.RootCertificate)); } + [Fact] + public void CaRootWithoutSki_Regenerates() + { + File.WriteAllBytes(_rootPath, CreateCaWithoutSkiPfx()); + + using var ca = new CertificateAuthority(_rootPath, _leafDir); + + Assert.True(HasSubjectKeyIdentifier(ca.RootCertificate)); + } + + [Fact] + public void CaRootWithoutKeyCertSignUsage_Regenerates() + { + File.WriteAllBytes(_rootPath, CreateCaWithoutKeyCertSignUsagePfx()); + + using var ca = new CertificateAuthority(_rootPath, _leafDir); + + Assert.True(HasKeyCertSignUsage(ca.RootCertificate)); + } + + [Fact] + public void CaRootWithoutKeyUsageExtension_Regenerates() + { + File.WriteAllBytes(_rootPath, CreateCaWithoutKeyUsageExtensionPfx()); + + using var ca = new CertificateAuthority(_rootPath, _leafDir); + + Assert.True(HasKeyCertSignUsage(ca.RootCertificate)); + } + [Fact] public void GetCertificateForHost_MintsLeafSignedByRoot_AndPersists() { @@ -116,6 +148,7 @@ public void GetCertificateForHost_MintsLeafSignedByRoot_AndPersists() Assert.Equal("CN=example.com", leaf.Subject); Assert.Equal(ca.RootCertificate.Subject, leaf.Issuer); Assert.Contains("example.com", GetSanText(leaf), StringComparison.Ordinal); + Assert.True(DoesAuthorityKeyIdentifierMatchCA(leaf, ca.RootCertificate)); Assert.True(File.Exists(Path.Combine(_leafDir, "example.com.pfx"))); } @@ -195,21 +228,111 @@ public void GetCertificateForHost_LeafDoesNotOutliveRoot() Assert.True(leaf.NotAfter <= ca.RootCertificate.NotAfter); } + [Fact] + public void GetCertificateForHost_LeafWithoutAki_Regenerates() + { + using var ca = new CertificateAuthority(_rootPath, _leafDir); + + _ = Directory.CreateDirectory(_leafDir); + File.WriteAllBytes(Path.Combine(_leafDir, "example.com.pfx"), CreateLeafPfx(ca.RootCertificate, null)); + + var leaf = ca.GetCertificateForHost("example.com"); + + Assert.True(DoesAuthorityKeyIdentifierMatchCA(leaf, ca.RootCertificate)); + } + + [Fact] + public void GetCertificateForHost_LeafWithMismatchedAki_Regenerates() + { + var rootNotAfter = DateTimeOffset.UtcNow.AddDays(10); + using var otherCa = CreateRootCertificate(rootNotAfter); + using var ca = new CertificateAuthority(_rootPath, _leafDir); + + _ = Directory.CreateDirectory(_leafDir); + File.WriteAllBytes(Path.Combine(_leafDir, "example.com.pfx"), CreateLeafPfx(ca.RootCertificate, otherCa)); + + var leaf = ca.GetCertificateForHost("example.com"); + + Assert.True(DoesAuthorityKeyIdentifierMatchCA(leaf, ca.RootCertificate)); + } + private static bool IsCertificateAuthority(X509Certificate2 cert) => cert.Extensions.OfType().FirstOrDefault()?.CertificateAuthority == true; + private static bool HasSubjectKeyIdentifier(X509Certificate2 cert) => + cert.Extensions.OfType().Any(); + + private static bool HasKeyCertSignUsage(X509Certificate2 cert) => + (cert.Extensions + .OfType() + .FirstOrDefault()?.KeyUsages & X509KeyUsageFlags.KeyCertSign) == X509KeyUsageFlags.KeyCertSign; + + private static bool DoesAuthorityKeyIdentifierMatchCA(X509Certificate2 cert, X509Certificate2 ca) + { + var ski = ca.Extensions + .OfType() + .FirstOrDefault()?.SubjectKeyIdentifierBytes; + + var aki = cert.Extensions + .OfType() + .FirstOrDefault()?.KeyIdentifier; + + return aki.HasValue + && ski.HasValue + && aki.Value.Span.SequenceEqual(ski.Value.Span); + } + private static string GetSanText(X509Certificate2 cert) { var san = cert.Extensions.FirstOrDefault(e => e.Oid?.Value == "2.5.29.17"); return san?.Format(false) ?? string.Empty; } - private static byte[] CreateRootPfx(DateTimeOffset NotAfter) + private static X509Certificate2 CreateRootCertificate(DateTimeOffset NotAfter) { using var rsa = RSA.Create(2048); var req = new CertificateRequest("CN=Old Root", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); req.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); - using var cert = req.CreateSelfSigned(NotAfter.AddDays(-10), NotAfter); + req.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(req.PublicKey, false)); + req.CertificateExtensions.Add(new X509KeyUsageExtension( + X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true)); + return req.CreateSelfSigned(NotAfter.AddDays(-10), NotAfter); + } + + private static byte[] CreateRootPfx(DateTimeOffset NotAfter) + { + using var cert = CreateRootCertificate(NotAfter); + return cert.Export(X509ContentType.Pkcs12, string.Empty); + } + + private static byte[] CreateCaWithoutSkiPfx() + { + using var rsa = RSA.Create(2048); + var req = new CertificateRequest("CN=CA Root Without SKI", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + req.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + req.CertificateExtensions.Add(new X509KeyUsageExtension( + X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true)); + using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1)); + return cert.Export(X509ContentType.Pkcs12, string.Empty); + } + + private static byte[] CreateCaWithoutKeyCertSignUsagePfx() + { + using var rsa = RSA.Create(2048); + var req = new CertificateRequest("CN=CA Root Without KeyCertSign", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + req.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + req.CertificateExtensions.Add(new X509KeyUsageExtension( + X509KeyUsageFlags.CrlSign, true)); + using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1)); + return cert.Export(X509ContentType.Pkcs12, string.Empty); + } + + private static byte[] CreateCaWithoutKeyUsageExtensionPfx() + { + using var rsa = RSA.Create(2048); + var req = new CertificateRequest("CN=CA Root Without KeyUsageExtension", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + req.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1)); return cert.Export(X509ContentType.Pkcs12, string.Empty); } @@ -221,4 +344,21 @@ private static byte[] CreateNonCaPfx() using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1)); return cert.Export(X509ContentType.Pkcs12, string.Empty); } + + private static byte[] CreateLeafPfx(X509Certificate2 ca, X509Certificate2? akiSource) + { + using var rsa = RSA.Create(2048); + var req = new CertificateRequest("CN=Leaf", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + req.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true)); + if (akiSource is not null) + { + req.CertificateExtensions.Add( + X509AuthorityKeyIdentifierExtension.CreateFromCertificate( + akiSource, includeKeyIdentifier: true, includeIssuerAndSerial: false)); + } + var serialNumber = new byte[8]; + RandomNumberGenerator.Fill(serialNumber); + using var cert = req.Create(ca, DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(10), serialNumber); + return cert.Export(X509ContentType.Pkcs12, string.Empty); + } } diff --git a/DevProxy.Proxy.Kestrel/Internal/CertificateAuthority.cs b/DevProxy.Proxy.Kestrel/Internal/CertificateAuthority.cs index 46d39466..084ff0df 100644 --- a/DevProxy.Proxy.Kestrel/Internal/CertificateAuthority.cs +++ b/DevProxy.Proxy.Kestrel/Internal/CertificateAuthority.cs @@ -148,7 +148,15 @@ private X509Certificate2 LoadOrCreateRoot() .OfType() .FirstOrDefault()?.CertificateAuthority == true; - if (cert.HasPrivateKey && isCa && cert.NotAfter > DateTime.Now) + var hasSKI = cert.Extensions + .OfType() + .Any(); + + var hasKeyCertSign = (cert.Extensions + .OfType() + .FirstOrDefault()?.KeyUsages & X509KeyUsageFlags.KeyCertSign) == X509KeyUsageFlags.KeyCertSign; + + if (cert.HasPrivateKey && isCa && hasSKI && hasKeyCertSign && cert.NotAfter > DateTime.Now) { return cert; } @@ -214,7 +222,7 @@ private X509Certificate2 LoadOrCreateLeaf(string host) return leaf; } - private static X509Certificate2? TryLoadLeaf(string path) + private X509Certificate2? TryLoadLeaf(string path) { if (!File.Exists(path)) { @@ -228,7 +236,19 @@ private X509Certificate2 LoadOrCreateLeaf(string host) string.Empty, X509KeyStorageFlags.Exportable); - if (cert.HasPrivateKey && cert.NotAfter > DateTime.Now) + var ski = _ca.Extensions + .OfType() + .FirstOrDefault()?.SubjectKeyIdentifierBytes; + + var aki = cert.Extensions + .OfType() + .FirstOrDefault()?.KeyIdentifier; + + var akiMatchesCA = aki.HasValue + && ski.HasValue + && aki.Value.Span.SequenceEqual(ski.Value.Span); + + if (cert.HasPrivateKey && akiMatchesCA && cert.NotAfter > DateTime.Now) { return cert; } @@ -326,6 +346,10 @@ private X509Certificate2 CreateLeafCertificate(string host) request.CertificateExtensions.Add(sanBuilder.Build()); request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); + request.CertificateExtensions.Add( + X509AuthorityKeyIdentifierExtension.CreateFromCertificate( + _ca, includeKeyIdentifier: true, includeIssuerAndSerial: false)); + var serialNumber = new byte[8]; RandomNumberGenerator.Fill(serialNumber);