From 22576eb53924c932f0b917fac35dedf04ae330d4 Mon Sep 17 00:00:00 2001 From: Stef Heyenrath Date: Thu, 12 Nov 2026 20:56:01 +0100 Subject: [PATCH] Use X509CertificateLoader on .NET 9+ (#1543) * Use X509CertificateLoader on .NET 9+ Preserve older-target certificate loading and add tests for supported formats, passwords, and embedded certificates. * ! * Potential fix for pull request finding 'Missed 'using' opportunity' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * enum * namespace WireMock.Compatiblity; --------- Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- .../HttpsCertificate/CertificateLoader.cs | 63 +++++++++----- .../PublicCertificateHelper.cs | 4 + .../Compatiblity/EnumCompat.cs | 10 +++ .../CertificateLoaderTests.cs | 87 +++++++++++++++++++ .../WireMockServerTests.ClientCertificate.cs | 4 + 5 files changed, 146 insertions(+), 22 deletions(-) create mode 100644 src/WireMock.Net.Shared/Compatiblity/EnumCompat.cs create mode 100644 test/WireMock.Net.Tests/HttpsCertificate/CertificateLoaderTests.cs diff --git a/src/WireMock.Net.Minimal/HttpsCertificate/CertificateLoader.cs b/src/WireMock.Net.Minimal/HttpsCertificate/CertificateLoader.cs index fc15e944..b74f1a04 100644 --- a/src/WireMock.Net.Minimal/HttpsCertificate/CertificateLoader.cs +++ b/src/WireMock.Net.Minimal/HttpsCertificate/CertificateLoader.cs @@ -2,6 +2,9 @@ using System.Security.Cryptography.X509Certificates; using WireMock.Owin; +#if NETSTANDARD2_0 +using Enum = WireMock.Compatiblity.EnumCompat; +#endif namespace WireMock.HttpsCertificate; @@ -18,32 +21,24 @@ internal static class CertificateLoader { var thumbprintOrSubjectNameOrHost = options.X509ThumbprintOrSubjectName ?? host; - var certStore = new X509Store((StoreName)Enum.Parse(typeof(StoreName), options.X509StoreName!), (StoreLocation)Enum.Parse(typeof(StoreLocation), options.X509StoreLocation!)); - try - { - certStore.Open(OpenFlags.ReadOnly); + using var certStore = new X509Store(Enum.Parse(options.X509StoreName!), Enum.Parse(options.X509StoreLocation!)); + certStore.Open(OpenFlags.ReadOnly); - // Attempt to find by Thumbprint first - var matchingCertificates = certStore.Certificates.Find(X509FindType.FindByThumbprint, thumbprintOrSubjectNameOrHost, false); + // Attempt to find by Thumbprint first + var matchingCertificates = certStore.Certificates.Find(X509FindType.FindByThumbprint, thumbprintOrSubjectNameOrHost, false); + if (matchingCertificates.Count == 0) + { + // Fallback to SubjectName + matchingCertificates = certStore.Certificates.Find(X509FindType.FindBySubjectName, thumbprintOrSubjectNameOrHost, false); if (matchingCertificates.Count == 0) { - // Fallback to SubjectName - matchingCertificates = certStore.Certificates.Find(X509FindType.FindBySubjectName, thumbprintOrSubjectNameOrHost, false); - if (matchingCertificates.Count == 0) - { - // No certificates matched the search criteria. - throw new FileNotFoundException($"No Certificate found with in store '{options.X509StoreName}', location '{options.X509StoreLocation}' for Thumbprint or SubjectName '{thumbprintOrSubjectNameOrHost}'."); - } + // No certificates matched the search criteria. + throw new FileNotFoundException($"No Certificate found with in store '{options.X509StoreName}', location '{options.X509StoreLocation}' for Thumbprint or SubjectName '{thumbprintOrSubjectNameOrHost}'."); } + } - // Use the first matching certificate. - return matchingCertificates[0]; - } - finally - { - certStore.Dispose(); - certStore.Close(); - } + // Use the first matching certificate. + return matchingCertificates[0]; } if (!string.IsNullOrEmpty(options.X509CertificateFilePath)) @@ -55,10 +50,14 @@ internal static class CertificateLoader if (!string.IsNullOrEmpty(options.X509CertificatePassword)) { var certPem = File.ReadAllText(options.X509CertificateFilePath); - var cert = X509Certificate2.CreateFromPem(certPem, options.X509CertificatePassword); + using var cert = X509Certificate2.CreateFromPem(certPem, options.X509CertificatePassword); const string defaultPasswordPem = "WireMock.Net"; +#if NET9_0_OR_GREATER + return X509CertificateLoader.LoadPkcs12(cert.Export(X509ContentType.Pfx, defaultPasswordPem), defaultPasswordPem); +#else return new X509Certificate2(cert.Export(X509ContentType.Pfx, defaultPasswordPem), defaultPasswordPem); +#endif } return X509Certificate2.CreateFromPemFile(options.X509CertificateFilePath); #else @@ -66,9 +65,29 @@ internal static class CertificateLoader #endif } +#if NET9_0_OR_GREATER + var data = File.ReadAllBytes(options.X509CertificateFilePath); + var contentType = X509Certificate2.GetCertContentType(data); + if (contentType == X509ContentType.Pfx) + { + return X509CertificateLoader.LoadPkcs12(data, string.IsNullOrEmpty(options.X509CertificatePassword) ? null : options.X509CertificatePassword); + } + if (contentType == X509ContentType.Cert) + { + return X509CertificateLoader.LoadCertificate(data); + } + + // The loader does not support legacy formats such as PKCS#7 and serialized certificates. + #pragma warning disable SYSLIB0057 + return !string.IsNullOrEmpty(options.X509CertificatePassword) ? + new X509Certificate2(data, options.X509CertificatePassword) : + new X509Certificate2(data); + #pragma warning restore SYSLIB0057 +#else return !string.IsNullOrEmpty(options.X509CertificatePassword) ? new X509Certificate2(options.X509CertificateFilePath, options.X509CertificatePassword) : new X509Certificate2(options.X509CertificateFilePath); +#endif } if (options.X509Certificate != null) diff --git a/src/WireMock.Net.Minimal/HttpsCertificate/PublicCertificateHelper.cs b/src/WireMock.Net.Minimal/HttpsCertificate/PublicCertificateHelper.cs index 4c14ed80..51bd1fc4 100644 --- a/src/WireMock.Net.Minimal/HttpsCertificate/PublicCertificateHelper.cs +++ b/src/WireMock.Net.Minimal/HttpsCertificate/PublicCertificateHelper.cs @@ -89,6 +89,10 @@ wTM1Z+CJZG9xAcf1zAVGl4ggYyYEFGBFyJ8VBwijS2zy1qwN1WYGtcWoAgIH0A== public static X509Certificate2 GetX509Certificate2() { byte[] data = Convert.FromBase64String(Data); +#if NET9_0_OR_GREATER + return X509CertificateLoader.LoadPkcs12(data, null); +#else return new X509Certificate2(data); +#endif } } \ No newline at end of file diff --git a/src/WireMock.Net.Shared/Compatiblity/EnumCompat.cs b/src/WireMock.Net.Shared/Compatiblity/EnumCompat.cs new file mode 100644 index 00000000..69cfff6e --- /dev/null +++ b/src/WireMock.Net.Shared/Compatiblity/EnumCompat.cs @@ -0,0 +1,10 @@ +// Copyright © WireMock.Net + +#if NETSTANDARD2_0 +namespace WireMock.Compatiblity; + +internal static class EnumCompat +{ + public static TEnum Parse(string value) where TEnum : struct, Enum => (TEnum)Enum.Parse(typeof(TEnum), value); +} +#endif \ No newline at end of file diff --git a/test/WireMock.Net.Tests/HttpsCertificate/CertificateLoaderTests.cs b/test/WireMock.Net.Tests/HttpsCertificate/CertificateLoaderTests.cs new file mode 100644 index 00000000..f670396a --- /dev/null +++ b/test/WireMock.Net.Tests/HttpsCertificate/CertificateLoaderTests.cs @@ -0,0 +1,87 @@ +// Copyright © WireMock.Net + +#if NET8_0_OR_GREATER +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using WireMock.HttpsCertificate; +using WireMock.Owin; + +namespace WireMock.Net.Tests.HttpsCertificate; + +public class CertificateLoaderTests +{ + [Theory] + [InlineData("pfx", null, true)] + [InlineData("pfx", "password", true)] + [InlineData("cer", null, false)] + [InlineData("cer", "password", false)] + [InlineData("pem", null, true)] + [InlineData("pem", "private-key", true)] + public void LoadCertificate_LoadsSupportedFormats(string format, string? password, bool hasPrivateKey) + { + using var key = RSA.Create(2048); + var request = new CertificateRequest("CN=localhost", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + using var original = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1)); + var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.{format}"); + + try + { + if (format == "pem") + { + var pem = original.ExportCertificatePem(); + File.WriteAllText(path, password == null ? pem + "\n" + key.ExportPkcs8PrivateKeyPem() : pem); + } + else + { + var contentType = format == "pfx" ? X509ContentType.Pfx : X509ContentType.Cert; + File.WriteAllBytes(path, original.Export(contentType, format == "pfx" ? password : null)); + } + + var options = new WireMockMiddlewareOptions + { + X509CertificateFilePath = path, + X509CertificatePassword = format == "pem" && password != null ? key.ExportPkcs8PrivateKeyPem() : password + }; + using var loaded = CertificateLoader.LoadCertificate(options, "localhost"); + + loaded.Thumbprint.Should().Be(original.Thumbprint); + loaded.HasPrivateKey.Should().Be(hasPrivateKey); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void LoadCertificate_InvalidPfxPassword_Throws() + { + using var key = RSA.Create(2048); + var request = new CertificateRequest("CN=localhost", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + using var original = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1)); + var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.pfx"); + + try + { + File.WriteAllBytes(path, original.Export(X509ContentType.Pfx, "password")); + var options = new WireMockMiddlewareOptions { X509CertificateFilePath = path, X509CertificatePassword = "wrong" }; + + Action load = () => CertificateLoader.LoadCertificate(options, "localhost"); + load.Should().Throw(); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void GetX509Certificate2_LoadsEmbeddedCertificate() + { + using var certificate = PublicCertificateHelper.GetX509Certificate2(); + + certificate.HasPrivateKey.Should().BeTrue(); + certificate.Thumbprint.Should().NotBeNullOrEmpty(); + } +} +#endif \ No newline at end of file diff --git a/test/WireMock.Net.Tests/WireMockServerTests.ClientCertificate.cs b/test/WireMock.Net.Tests/WireMockServerTests.ClientCertificate.cs index 5f51a4b5..5965b10d 100644 --- a/test/WireMock.Net.Tests/WireMockServerTests.ClientCertificate.cs +++ b/test/WireMock.Net.Tests/WireMockServerTests.ClientCertificate.cs @@ -35,8 +35,12 @@ public partial class WireMockServerTests : 403 })); +#if NET9_0_OR_GREATER + var certificates = X509CertificateLoader.LoadPkcs12CollectionFromFile("client_cert.pfx", "1234", X509KeyStorageFlags.Exportable); +#else var certificates = new X509Certificate2Collection(); certificates.Import("client_cert.pfx", "1234", X509KeyStorageFlags.Exportable); +#endif var httpMessageHandler = new HttpClientHandler {