diff --git a/Testcontainers.sln b/Testcontainers.sln index 4a415068d..bc6af7eaf 100644 --- a/Testcontainers.sln +++ b/Testcontainers.sln @@ -137,6 +137,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Tests", "tes EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.WebDriver.Tests", "tests\Testcontainers.WebDriver.Tests\Testcontainers.WebDriver.Tests.csproj", "{EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Qdrant", "src\Testcontainers.Qdrant\Testcontainers.Qdrant.csproj", "{7C98973D-53D7-49F9-BDFE-E3268F402584}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Qdrant.Tests", "tests\Testcontainers.Qdrant.Tests\Testcontainers.Qdrant.Tests.csproj", "{9DCE3E7F-B341-4AD0-BAAA-C3B91EB91B0D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -394,6 +398,18 @@ Global {EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2}.Debug|Any CPU.Build.0 = Debug|Any CPU {EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2}.Release|Any CPU.ActiveCfg = Release|Any CPU {EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2}.Release|Any CPU.Build.0 = Release|Any CPU + {23D898F8-36BE-4393-BFE2-41A862C0F951}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {23D898F8-36BE-4393-BFE2-41A862C0F951}.Debug|Any CPU.Build.0 = Debug|Any CPU + {23D898F8-36BE-4393-BFE2-41A862C0F951}.Release|Any CPU.ActiveCfg = Release|Any CPU + {23D898F8-36BE-4393-BFE2-41A862C0F951}.Release|Any CPU.Build.0 = Release|Any CPU + {7C98973D-53D7-49F9-BDFE-E3268F402584}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7C98973D-53D7-49F9-BDFE-E3268F402584}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7C98973D-53D7-49F9-BDFE-E3268F402584}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7C98973D-53D7-49F9-BDFE-E3268F402584}.Release|Any CPU.Build.0 = Release|Any CPU + {9DCE3E7F-B341-4AD0-BAAA-C3B91EB91B0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9DCE3E7F-B341-4AD0-BAAA-C3B91EB91B0D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9DCE3E7F-B341-4AD0-BAAA-C3B91EB91B0D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9DCE3E7F-B341-4AD0-BAAA-C3B91EB91B0D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {3F2E254F-C203-43FD-A078-DC3E2CBC0F9F} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} @@ -458,5 +474,7 @@ Global {1A1983E6-5297-435F-B467-E8E1F11277D6} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {27CDB869-A150-4593-958F-6F26E5391E7C} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} + {7C98973D-53D7-49F9-BDFE-E3268F402584} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} + {9DCE3E7F-B341-4AD0-BAAA-C3B91EB91B0D} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} EndGlobalSection EndGlobal diff --git a/src/Testcontainers.Qdrant/.editorconfig b/src/Testcontainers.Qdrant/.editorconfig new file mode 100644 index 000000000..6f066619d --- /dev/null +++ b/src/Testcontainers.Qdrant/.editorconfig @@ -0,0 +1 @@ +root = true \ No newline at end of file diff --git a/src/Testcontainers.Qdrant/QdrantBuilder.cs b/src/Testcontainers.Qdrant/QdrantBuilder.cs new file mode 100644 index 000000000..ce3a1d1b8 --- /dev/null +++ b/src/Testcontainers.Qdrant/QdrantBuilder.cs @@ -0,0 +1,109 @@ +using System.IO; +using System.Security.Cryptography.X509Certificates; +using Org.BouncyCastle.OpenSsl; +using Org.BouncyCastle.Security; + +namespace Testcontainers.Qdrant; + +/// +[PublicAPI] +public sealed class QdrantBuilder : ContainerBuilder +{ + public const string QdrantImage = "qdrant/qdrant:v1.5.0"; + + public const ushort QdrantHttpPort = 6333; + + public const ushort QdrantGrpcPort = 6334; + + public const string QdrantLocalConfigurationFilePath = "/qdrant/config/local.yaml"; + + public const string QdrantTlsCertFilePath = "/qdrant/tls/cert.pem"; + + public const string QdrantTlsKeyFilePath = "/qdrant/tls/key.pem"; + + public QdrantBuilder() : this(new QdrantConfiguration()) => + DockerResourceConfiguration = Init().DockerResourceConfiguration; + + private QdrantBuilder(QdrantConfiguration dockerResourceConfiguration) : base(dockerResourceConfiguration) => + DockerResourceConfiguration = dockerResourceConfiguration; + + /// + /// A path to a configuration file with which configure the instance. + /// + /// The path to the configuration file + public QdrantBuilder WithConfigFile(string configurationFilePath) => + Merge(DockerResourceConfiguration, new QdrantConfiguration(configurationFilePath: configurationFilePath)) + .WithBindMount(configurationFilePath, QdrantLocalConfigurationFilePath); + + /// + /// The API key used to secure the instance. A certificate should also be provided to + /// to enable TLS + /// + /// The API key + public QdrantBuilder WithApiKey(string apiKey) => + Merge(DockerResourceConfiguration, new QdrantConfiguration(apiKey: apiKey)) + .WithEnvironment("QDRANT__SERVICE__API_KEY", apiKey); + + /// + /// A certificate to use to enable Transport Layer Security (TLS). The certificate must contain the + /// private key. + /// + /// A certificate containing a private key + public QdrantBuilder WithCertificate(X509Certificate2 certificate) + { + if (!certificate.HasPrivateKey) + { + throw new ArgumentException("certificate must contain a private key", nameof(certificate)); + } + + var builder = new StringBuilder(); + builder.AppendLine("-----BEGIN CERTIFICATE-----"); + builder.AppendLine(Convert.ToBase64String(certificate.RawData, Base64FormattingOptions.InsertLineBreaks)); + builder.AppendLine("-----END CERTIFICATE-----"); + var cert = builder.ToString(); + builder.Clear(); + + var keyPair = DotNetUtilities.GetKeyPair(certificate.PrivateKey); + var pemWriter = new PemWriter(new StringWriter(builder)); + pemWriter.WriteObject(keyPair.Private); + var key = builder.ToString(); + + return Merge(DockerResourceConfiguration, new QdrantConfiguration(certificate: certificate)) + .WithEnvironment("QDRANT__SERVICE__ENABLE_TLS", "1") + .WithResourceMapping(Encoding.UTF8.GetBytes(cert), QdrantTlsCertFilePath) + .WithEnvironment("QDRANT__TLS__CERT", QdrantTlsCertFilePath) + .WithResourceMapping(Encoding.UTF8.GetBytes(key), QdrantTlsKeyFilePath) + .WithEnvironment("QDRANT__TLS__KEY", QdrantTlsKeyFilePath); + } + + /// + public override QdrantContainer Build() + { + Validate(); + return new QdrantContainer(DockerResourceConfiguration, TestcontainersSettings.Logger); + } + + /// + protected override QdrantBuilder Init() => + base.Init() + .WithImage(QdrantImage) + .WithPortBinding(QdrantHttpPort, true) + .WithPortBinding(QdrantGrpcPort, true) + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilMessageIsLogged(".*Actix runtime found; starting in Actix runtime.*")); + + /// + protected override QdrantBuilder Clone(IResourceConfiguration resourceConfiguration) => + Merge(DockerResourceConfiguration, new QdrantConfiguration(resourceConfiguration)); + + /// + protected override QdrantBuilder Merge(QdrantConfiguration oldValue, QdrantConfiguration newValue) => + new(new QdrantConfiguration(oldValue, newValue)); + + /// + protected override QdrantConfiguration DockerResourceConfiguration { get; } + + /// + protected override QdrantBuilder Clone(IContainerConfiguration resourceConfiguration) => + Merge(DockerResourceConfiguration, new QdrantConfiguration(resourceConfiguration)); +} diff --git a/src/Testcontainers.Qdrant/QdrantConfiguration.cs b/src/Testcontainers.Qdrant/QdrantConfiguration.cs new file mode 100644 index 000000000..714303f38 --- /dev/null +++ b/src/Testcontainers.Qdrant/QdrantConfiguration.cs @@ -0,0 +1,73 @@ +using System.Security.Cryptography.X509Certificates; + +namespace Testcontainers.Qdrant; + +/// +[PublicAPI] +public sealed class QdrantConfiguration : ContainerConfiguration +{ + /// + /// Initializes a new instance of the class. + /// + public QdrantConfiguration(string apiKey = null, X509Certificate2 certificate = null, string configurationFilePath = null) + { + ApiKey = apiKey; + Certificate = certificate; + ConfigurationFilePath = configurationFilePath; + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + public QdrantConfiguration(IResourceConfiguration resourceConfiguration) + : base(resourceConfiguration) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + public QdrantConfiguration(IContainerConfiguration resourceConfiguration) + : base(resourceConfiguration) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + public QdrantConfiguration(QdrantConfiguration resourceConfiguration) + : this(new QdrantConfiguration(), resourceConfiguration) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The old Docker resource configuration. + /// The new Docker resource configuration. + public QdrantConfiguration(QdrantConfiguration oldValue, QdrantConfiguration newValue) + : base(oldValue, newValue) + { + ApiKey = BuildConfiguration.Combine(oldValue.ApiKey, newValue.ApiKey); + Certificate = BuildConfiguration.Combine(oldValue.Certificate, newValue.Certificate); + ConfigurationFilePath = BuildConfiguration.Combine(oldValue.ConfigurationFilePath, newValue.ConfigurationFilePath); + } + + /// + /// Gets the API key used to secure Qdrant + /// + public string ApiKey { get; } + + /// + /// Gets the certificate used to configure Transport Layer Security + /// + public X509Certificate2 Certificate { get; } + + /// + /// Gets the path to the configuration file used to configure Qdrant + /// + public string ConfigurationFilePath { get; } +} diff --git a/src/Testcontainers.Qdrant/QdrantContainer.cs b/src/Testcontainers.Qdrant/QdrantContainer.cs new file mode 100644 index 000000000..1067e088e --- /dev/null +++ b/src/Testcontainers.Qdrant/QdrantContainer.cs @@ -0,0 +1,27 @@ +namespace Testcontainers.Qdrant; + +/// +[PublicAPI] +public class QdrantContainer : DockerContainer +{ + private readonly QdrantConfiguration _configuration; + + public QdrantContainer(QdrantConfiguration configuration, ILogger logger) : base(configuration, logger) + { + _configuration = configuration; + } + + public string GetHttpConnectionString() + { + var scheme = _configuration.Certificate != null ? Uri.UriSchemeHttps : Uri.UriSchemeHttp; + var endpoint = new UriBuilder(scheme, Hostname, GetMappedPublicPort(QdrantBuilder.QdrantHttpPort)); + return endpoint.ToString(); + } + + public string GetGrpcConnectionString() + { + var scheme = _configuration.Certificate != null ? Uri.UriSchemeHttps : Uri.UriSchemeHttp; + var endpoint = new UriBuilder(scheme, Hostname, GetMappedPublicPort(QdrantBuilder.QdrantGrpcPort)); + return endpoint.ToString(); + } +} diff --git a/src/Testcontainers.Qdrant/Testcontainers.Qdrant.csproj b/src/Testcontainers.Qdrant/Testcontainers.Qdrant.csproj new file mode 100644 index 000000000..4c05d521f --- /dev/null +++ b/src/Testcontainers.Qdrant/Testcontainers.Qdrant.csproj @@ -0,0 +1,13 @@ + + + netstandard2.0;netstandard2.1 + latest + + + + + + + + + \ No newline at end of file diff --git a/src/Testcontainers.Qdrant/Usings.cs b/src/Testcontainers.Qdrant/Usings.cs new file mode 100644 index 000000000..5696bb0bf --- /dev/null +++ b/src/Testcontainers.Qdrant/Usings.cs @@ -0,0 +1,12 @@ +global using System; +global using System.Collections.Generic; +global using System.Linq; +global using System.Text; +global using System.Threading.Tasks; +global using Docker.DotNet.Models; +global using DotNet.Testcontainers; +global using DotNet.Testcontainers.Builders; +global using DotNet.Testcontainers.Configurations; +global using DotNet.Testcontainers.Containers; +global using JetBrains.Annotations; +global using Microsoft.Extensions.Logging; \ No newline at end of file diff --git a/tests/Testcontainers.Qdrant.Tests/.editorconfig b/tests/Testcontainers.Qdrant.Tests/.editorconfig new file mode 100644 index 000000000..6f066619d --- /dev/null +++ b/tests/Testcontainers.Qdrant.Tests/.editorconfig @@ -0,0 +1 @@ +root = true \ No newline at end of file diff --git a/tests/Testcontainers.Qdrant.Tests/QdrantContainerApiKeyCertificateTest.cs b/tests/Testcontainers.Qdrant.Tests/QdrantContainerApiKeyCertificateTest.cs new file mode 100644 index 000000000..d55105e4c --- /dev/null +++ b/tests/Testcontainers.Qdrant.Tests/QdrantContainerApiKeyCertificateTest.cs @@ -0,0 +1,82 @@ +using System.Net.Http; +using System.Security.Cryptography.X509Certificates; + +namespace Testcontainers.Qdrant; + +public sealed class QdrantContainerApiKeyCertificateTest : IAsyncLifetime +{ + private static readonly X509Certificate2 Cert = X509CertificateGenerator.GenerateCert("CN=Testcontainers"); + private const string ApiKey = "password!"; + + private readonly QdrantContainer _qdrantContainer = new QdrantBuilder() + .WithApiKey(ApiKey) + .WithCertificate(Cert) + .Build(); + + public Task InitializeAsync() + { + return _qdrantContainer.StartAsync(); + } + + public Task DisposeAsync() + { + return _qdrantContainer.DisposeAsync().AsTask(); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public async Task PingReturnsValidResponse() + { + var httpMessageHandler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (_, certificate, _, _) => + certificate.Thumbprint == Cert.Thumbprint, + }; + + var client = new HttpClient(httpMessageHandler) + { + BaseAddress = new Uri(_qdrantContainer.GetHttpConnectionString()), + }; + + client.DefaultRequestHeaders.Add("api-key", ApiKey); + + var response = await client.GetAsync("/collections"); + + Assert.True(response.IsSuccessStatusCode); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public async Task PingWithoutApiKeyReturnsInvalidResponse() + { + var httpMessageHandler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (_, _, _, _) => true, + }; + + var client = new HttpClient(httpMessageHandler) + { + BaseAddress = new Uri(_qdrantContainer.GetHttpConnectionString()), + }; + + var response = await client.GetAsync("/collections"); + + Assert.False(response.IsSuccessStatusCode); + Assert.Equal("Invalid api-key", await response.Content.ReadAsStringAsync()); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public async Task PingWithoutCertificateValidationReturnsInvalidResponse() + { + var client = new HttpClient + { + BaseAddress = new Uri(_qdrantContainer.GetHttpConnectionString()), + }; + + client.DefaultRequestHeaders.Add("api-key", ApiKey); + + // The SSL connection could not be established + await Assert.ThrowsAsync(() => client.GetAsync("/collections")); + } +} \ No newline at end of file diff --git a/tests/Testcontainers.Qdrant.Tests/QdrantContainerConfigurationFileTest.cs b/tests/Testcontainers.Qdrant.Tests/QdrantContainerConfigurationFileTest.cs new file mode 100644 index 000000000..716b92a3a --- /dev/null +++ b/tests/Testcontainers.Qdrant.Tests/QdrantContainerConfigurationFileTest.cs @@ -0,0 +1,64 @@ +using System.IO; +using System.Net.Http; + +namespace Testcontainers.Qdrant; + +public sealed class QdrantContainerConfigurationFileTest : IAsyncLifetime +{ + private const string ApiKey = "password!"; + + private readonly QdrantContainer _qdrantContainer = new QdrantBuilder() + .WithConfigFile(CreateConfigFile()) + .Build(); + + private static string CreateConfigFile() + { + var tempFile = Path.GetTempFileName(); + File.WriteAllLines(tempFile, new[] + { + "service:", + $" api_key: {ApiKey}", + }); + return tempFile; + } + + public Task InitializeAsync() + { + return _qdrantContainer.StartAsync(); + } + + public Task DisposeAsync() + { + return _qdrantContainer.DisposeAsync().AsTask(); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public async Task PingReturnsValidResponse() + { + var client = new HttpClient + { + BaseAddress = new Uri(_qdrantContainer.GetHttpConnectionString()), + }; + + client.DefaultRequestHeaders.Add("api-key", ApiKey); + + var response = await client.GetAsync("/collections"); + Assert.True(response.IsSuccessStatusCode); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public async Task PingWithoutApiKeyReturnsInvalidResponse() + { + var client = new HttpClient + { + BaseAddress = new Uri(_qdrantContainer.GetHttpConnectionString()), + }; + + var response = await client.GetAsync("/collections"); + + Assert.False(response.IsSuccessStatusCode); + Assert.Equal("Invalid api-key", await response.Content.ReadAsStringAsync()); + } +} \ No newline at end of file diff --git a/tests/Testcontainers.Qdrant.Tests/QdrantContainerTest.cs b/tests/Testcontainers.Qdrant.Tests/QdrantContainerTest.cs new file mode 100644 index 000000000..5cdb730fc --- /dev/null +++ b/tests/Testcontainers.Qdrant.Tests/QdrantContainerTest.cs @@ -0,0 +1,31 @@ +using System.Net.Http; + +namespace Testcontainers.Qdrant; + +public sealed class QdrantContainerTest : IAsyncLifetime +{ + private readonly QdrantContainer _qdrantContainer = new QdrantBuilder().Build(); + + public Task InitializeAsync() + { + return _qdrantContainer.StartAsync(); + } + + public Task DisposeAsync() + { + return _qdrantContainer.DisposeAsync().AsTask(); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public async Task PingReturnsValidResponse() + { + var client = new HttpClient + { + BaseAddress = new Uri(_qdrantContainer.GetHttpConnectionString()), + }; + + var response = await client.GetAsync("/"); + Assert.True(response.IsSuccessStatusCode); + } +} \ No newline at end of file diff --git a/tests/Testcontainers.Qdrant.Tests/Testcontainers.Qdrant.Tests.csproj b/tests/Testcontainers.Qdrant.Tests/Testcontainers.Qdrant.Tests.csproj new file mode 100644 index 000000000..c243a4546 --- /dev/null +++ b/tests/Testcontainers.Qdrant.Tests/Testcontainers.Qdrant.Tests.csproj @@ -0,0 +1,17 @@ + + + net6.0 + false + false + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Testcontainers.Qdrant.Tests/Usings.cs b/tests/Testcontainers.Qdrant.Tests/Usings.cs new file mode 100644 index 000000000..58083ebd6 --- /dev/null +++ b/tests/Testcontainers.Qdrant.Tests/Usings.cs @@ -0,0 +1,4 @@ +global using System; +global using System.Threading.Tasks; +global using DotNet.Testcontainers.Commons; +global using Xunit; \ No newline at end of file diff --git a/tests/Testcontainers.Qdrant.Tests/X509CertificateGenerator.cs b/tests/Testcontainers.Qdrant.Tests/X509CertificateGenerator.cs new file mode 100644 index 000000000..ef5f2f6fc --- /dev/null +++ b/tests/Testcontainers.Qdrant.Tests/X509CertificateGenerator.cs @@ -0,0 +1,68 @@ +using System.IO; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Crypto.Operators; +using Org.BouncyCastle.Crypto.Prng; +using Org.BouncyCastle.Math; +using Org.BouncyCastle.OpenSsl; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using Org.BouncyCastle.Utilities; +using Org.BouncyCastle.X509; + +namespace Testcontainers.Qdrant; + +public static class X509CertificateGenerator +{ + public static X509Certificate2 GenerateCert(string subjectName) + { + var randomGenerator = new CryptoApiRandomGenerator(); + var random = new SecureRandom(randomGenerator); + var serialNumber = BigIntegers.CreateRandomInRange( + BigInteger.One, + BigInteger.ValueOf(long.MaxValue), random); + var subjectDistinguishedName = new X509Name(subjectName); + var issuerDistinguishedName = subjectDistinguishedName; + var notBefore = DateTime.UtcNow.Date; + var notAfter = notBefore.AddYears(1); + var keyGenerationParameters = new KeyGenerationParameters(random, 2048); + var keyPairGenerator = new RsaKeyPairGenerator(); + keyPairGenerator.Init(keyGenerationParameters); + var subjectKeyPair = keyPairGenerator.GenerateKeyPair(); + var issuerPrivateKey = subjectKeyPair.Private; + + var certificateGenerator = new X509V3CertificateGenerator(); + certificateGenerator.SetSerialNumber(serialNumber); + certificateGenerator.AddExtension( + X509Extensions.ExtendedKeyUsage, + true, + new ExtendedKeyUsage(KeyPurposeID.id_kp_serverAuth)); + certificateGenerator.SetIssuerDN(issuerDistinguishedName); + certificateGenerator.SetSubjectDN(subjectDistinguishedName); + certificateGenerator.SetNotBefore(notBefore); + certificateGenerator.SetNotAfter(notAfter); + certificateGenerator.SetPublicKey(subjectKeyPair.Public); + + var signatureFactory = new Asn1SignatureFactory("SHA512WITHRSA", issuerPrivateKey, random); + var certificate = certificateGenerator.Generate(signatureFactory); + var privateKeyInfo = PrivateKeyInfoFactory.CreatePrivateKeyInfo(subjectKeyPair.Private); + + var builder = new StringBuilder(); + using var writer = new StringWriter(builder); + using var pemWriter = new PemWriter(writer); + + pemWriter.WriteObject(certificate); + var pemCert = builder.ToString(); + builder.Clear(); + + pemWriter.WriteObject(privateKeyInfo); + var pemKey = builder.ToString(); + + return X509Certificate2.CreateFromPem(pemCert, pemKey); + } +} + +