< Summary - Kestrun — Combined Coverage

Information
Class: Kestrun.Certificates.CertificateManager
Assembly: Kestrun
File(s): /home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Certificates/CertificateManager.cs
Tag: Kestrun/Kestrun@2d87023b37eb91155071c91dd3d6a2eeb3004705
Line coverage
71%
Covered lines: 311
Uncovered lines: 124
Coverable lines: 435
Total lines: 1227
Line coverage: 71.4%
Branch coverage
55%
Covered branches: 103
Total branches: 184
Branch coverage: 55.9%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 08/26/2025 - 01:25:22 Line coverage: 75.5% (291/385) Branch coverage: 56.4% (96/170) Total lines: 1075 Tag: Kestrun/Kestrun@07f821172e5dc3657f1be7e6818f18d6721cf38a09/01/2025 - 04:08:24 Line coverage: 74.5% (298/400) Branch coverage: 57.3% (102/178) Total lines: 1122 Tag: Kestrun/Kestrun@d6f26a131219b7a7fcb4e129af3193ec2ec4892909/07/2025 - 18:41:40 Line coverage: 73.5% (311/423) Branch coverage: 57.2% (103/180) Total lines: 1164 Tag: Kestrun/Kestrun@2192d4ccb46312ce89b7f7fda1aa8c915bfa228410/13/2025 - 16:52:37 Line coverage: 71.4% (311/435) Branch coverage: 55.9% (103/184) Total lines: 1227 Tag: Kestrun/Kestrun@10d476bee71c71ad215bb8ab59f219887b5b4a5e 08/26/2025 - 01:25:22 Line coverage: 75.5% (291/385) Branch coverage: 56.4% (96/170) Total lines: 1075 Tag: Kestrun/Kestrun@07f821172e5dc3657f1be7e6818f18d6721cf38a09/01/2025 - 04:08:24 Line coverage: 74.5% (298/400) Branch coverage: 57.3% (102/178) Total lines: 1122 Tag: Kestrun/Kestrun@d6f26a131219b7a7fcb4e129af3193ec2ec4892909/07/2025 - 18:41:40 Line coverage: 73.5% (311/423) Branch coverage: 57.2% (103/180) Total lines: 1164 Tag: Kestrun/Kestrun@2192d4ccb46312ce89b7f7fda1aa8c915bfa228410/13/2025 - 16:52:37 Line coverage: 71.4% (311/435) Branch coverage: 55.9% (103/184) Total lines: 1227 Tag: Kestrun/Kestrun@10d476bee71c71ad215bb8ab59f219887b5b4a5e

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_ShouldAppendKeyToPem()50%22100%
.ctor(...)100%11100%
get_DnsNames()100%11100%
get_KeyType()100%11100%
get_KeyLength()100%11100%
get_Purposes()100%11100%
get_ValidDays()100%11100%
get_Ephemeral()100%11100%
get_Exportable()100%11100%
.ctor(...)100%11100%
get_DnsNames()100%11100%
get_KeyType()100%11100%
get_KeyLength()100%11100%
get_Country()100%11100%
get_Org()100%11100%
get_OrgUnit()100%11100%
get_CommonName()100%11100%
NewSelfSigned(...)58.33%121295.23%
NewCertificateRequest(...)75%131284.05%
Add()100%22100%
Import(...)83.33%121288.88%
ValidateImportInputs(...)100%88100%
ImportPfx(...)100%11100%
ImportDer(...)100%11100%
ImportPem(...)83.33%66100%
ImportPemUnencrypted(...)100%11100%
ImportPemEncrypted(...)25%6450%
TryManualEncryptedPemPairing(...)0%2040%
ExtractEncryptedPemDer(...)0%110100%
TryPairCertificateWithKey(...)0%4260%
TryPairWithRsa(...)0%2040%
TryPairWithEcdsa(...)0%2040%
LoadCertOnlyPem(...)50%4485.71%
Import(...)100%11100%
Import(...)100%11100%
Import(...)100%11100%
Export(...)75%4488.88%
NormalizeExportPath(...)35.71%721433.33%
EnsureOutputDirectoryExists(...)75%5460%
.ctor(...)100%11100%
get_Secure()100%11100%
get_Chars()100%11100%
Dispose()100%44100%
CreatePasswordShapes(...)100%44100%
ExportPfx(...)100%11100%
ExportPem(...)50%231050%
WritePrivateKey(...)40%121071.42%
Export(...)100%11100%
Validate(...)83.33%131283.33%
IsWithinValidityPeriod(...)50%22100%
BuildChainOk(...)75%44100%
PurposesOk(...)100%66100%
UsesWeakAlgorithms(...)50%1010100%
GetPurposes(...)50%22100%
GenRsaKeyPair(...)100%11100%
GenEcKeyPair(...)33.33%6684.61%
ToX509Cert2(...)100%2265.21%

File(s)

/home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Certificates/CertificateManager.cs

#LineLine coverage
 1using System.Net;
 2using System.Security;
 3using System.Security.Cryptography;
 4using System.Security.Cryptography.X509Certificates;
 5using Org.BouncyCastle.Asn1;
 6using Org.BouncyCastle.Asn1.Pkcs;
 7using Org.BouncyCastle.Asn1.X509;
 8using Org.BouncyCastle.Crypto;
 9using Org.BouncyCastle.Crypto.Generators;
 10using Org.BouncyCastle.Crypto.Operators;
 11using Org.BouncyCastle.Crypto.Parameters;
 12using Org.BouncyCastle.Crypto.Prng;
 13using Org.BouncyCastle.Math;
 14using Org.BouncyCastle.OpenSsl;
 15using Org.BouncyCastle.Pkcs;
 16using Org.BouncyCastle.Security;
 17using Org.BouncyCastle.Utilities;
 18using Org.BouncyCastle.X509;
 19using System.Text;
 20using Org.BouncyCastle.Asn1.X9;
 21using Serilog;
 22using Kestrun.Utilities;
 23
 24
 25namespace Kestrun.Certificates;
 26
 27/// <summary>
 28/// Drop-in replacement for Pode’s certificate helpers, powered by Bouncy Castle.
 29/// </summary>
 30public static class CertificateManager
 31{
 32    /// <summary>
 33    /// Controls whether the private key is appended to the certificate PEM file in addition to
 34    /// writing a separate .key file. Appending was initially added to work around platform
 35    /// inconsistencies when importing encrypted PEM pairs on some Linux runners. However, having
 36    /// both a combined (cert+key) file and a separate key file can itself introduce ambiguity in
 37    /// which API path <see cref="X509Certificate2"/> chooses (single-file vs dual-file), which was
 38    /// observed to contribute to rare flakiness (private key occasionally not attached after
 39    /// import). To make behavior deterministic we now disable appending by default and allow it to
 40    /// be re-enabled explicitly via the environment variable KESTRUN_APPEND_KEY_TO_PEM.
 41    /// Set KESTRUN_APPEND_KEY_TO_PEM=1 (or "true") to re-enable.
 42    /// </summary>
 43    private static bool ShouldAppendKeyToPem =>
 244        string.Equals(Environment.GetEnvironmentVariable("KESTRUN_APPEND_KEY_TO_PEM"), "1", StringComparison.OrdinalIgno
 245        string.Equals(Environment.GetEnvironmentVariable("KESTRUN_APPEND_KEY_TO_PEM"), "true", StringComparison.OrdinalI
 46    #region  enums / option records
 47    /// <summary>
 48    /// Specifies the type of cryptographic key to use for certificate operations.
 49    /// </summary>
 50    /// <summary>
 51    /// Specifies the cryptographic key type.
 52    /// </summary>
 53    public enum KeyType
 54    {
 55        /// <summary>
 56        /// RSA key type.
 57        /// </summary>
 58        Rsa,
 59        /// <summary>
 60        /// ECDSA key type.
 61        /// </summary>
 62        Ecdsa
 63    }
 64
 65    /// <summary>
 66    /// Specifies the format to use when exporting certificates.
 67    /// </summary>
 68    /// <summary>
 69    /// Specifies the format to use when exporting certificates.
 70    /// </summary>
 71    public enum ExportFormat
 72    {
 73        /// <summary>
 74        /// PFX/PKCS#12 format.
 75        /// </summary>
 76        Pfx,
 77        /// <summary>
 78        /// PEM format.
 79        /// </summary>
 80        Pem
 81    }
 82
 83    /// <summary>
 84    /// Options for creating a self-signed certificate.
 85    /// </summary>
 86    /// <param name="DnsNames">The DNS names to include in the certificate's Subject Alternative Name (SAN) extension.</
 87    /// <param name="KeyType">The type of cryptographic key to use (RSA or ECDSA).</param>
 88    /// <param name="KeyLength">The length of the cryptographic key in bits.</param>
 89    /// <param name="Purposes">The key purposes (Extended Key Usage) for the certificate.</param>
 90    /// <param name="ValidDays">The number of days the certificate will be valid.</param>
 91    /// <param name="Ephemeral">If true, the certificate will not be stored in the Windows certificate store.</param>
 92    /// <param name="Exportable">If true, the private key can be exported from the certificate.</param>
 93    /// <remarks>
 94    /// This record is used to specify options for creating a self-signed certificate.
 95    /// </remarks>
 1296    public record SelfSignedOptions(
 2497        IEnumerable<string> DnsNames,
 2498        KeyType KeyType = KeyType.Rsa,
 1299        int KeyLength = 2048,
 12100        IEnumerable<KeyPurposeID>? Purposes = null,
 12101        int ValidDays = 365,
 12102        bool Ephemeral = false,
 12103        bool Exportable = false
 12104        );
 105
 106    /// <summary>
 107    /// Options for creating a Certificate Signing Request (CSR).
 108    /// </summary>
 109    /// <param name="DnsNames">The DNS names to include in the CSR's Subject Alternative Name (SAN) extension.</param>
 110    /// <param name="KeyType">The type of cryptographic key to use (RSA or ECDSA).</param>
 111    /// <param name="KeyLength">The length of the cryptographic key in bits.</param>
 112    /// <param name="Country">The country code for the subject distinguished name.</param>
 113    /// <param name="Org">The organization name for the subject distinguished name.</param>
 114    /// <param name="OrgUnit">The organizational unit for the subject distinguished name.</param>
 115    /// <param name="CommonName">The common name for the subject distinguished name.</param>
 116    /// <remarks>
 117    /// This record is used to specify options for creating a Certificate Signing Request (CSR).
 118    /// </remarks>
 2119    public record CsrOptions(
 2120        IEnumerable<string> DnsNames,
 4121        KeyType KeyType = KeyType.Rsa,
 2122        int KeyLength = 2048,
 2123        string? Country = null,
 2124        string? Org = null,
 2125        string? OrgUnit = null,
 4126        string? CommonName = null);
 127    #endregion
 128
 129    #region  Self-signed certificate
 130    /// <summary>
 131    /// Creates a new self-signed X509 certificate using the specified options.
 132    /// </summary>
 133    /// <param name="o">Options for creating the self-signed certificate.</param>
 134    /// <returns>A new self-signed X509Certificate2 instance.</returns>
 135    public static X509Certificate2 NewSelfSigned(SelfSignedOptions o)
 136    {
 12137        var random = new SecureRandom(new CryptoApiRandomGenerator());
 138
 139        // ── 1. Key pair ───────────────────────────────────────────────────────────
 12140        var keyPair =
 12141            o.KeyType switch
 12142            {
 12143                KeyType.Rsa => GenRsaKeyPair(o.KeyLength, random),
 0144                KeyType.Ecdsa => GenEcKeyPair(o.KeyLength, random),
 0145                _ => throw new ArgumentOutOfRangeException()
 12146            };
 147
 148        // ── 2. Certificate body ───────────────────────────────────────────────────
 12149        var notBefore = DateTime.UtcNow.AddMinutes(-5);
 12150        var notAfter = notBefore.AddDays(o.ValidDays);
 12151        var serial = BigIntegers.CreateRandomInRange(
 12152                            BigInteger.One, BigInteger.ValueOf(long.MaxValue), random);
 153
 12154        var subjectDn = new X509Name($"CN={o.DnsNames.First()}");
 12155        var gen = new X509V3CertificateGenerator();
 12156        gen.SetSerialNumber(serial);
 12157        gen.SetIssuerDN(subjectDn);
 12158        gen.SetSubjectDN(subjectDn);
 12159        gen.SetNotBefore(notBefore);
 12160        gen.SetNotAfter(notAfter);
 12161        gen.SetPublicKey(keyPair.Public);
 162
 163        // SANs
 12164        var altNames = o.DnsNames
 23165                        .Select(n => new GeneralName(
 23166                            IPAddress.TryParse(n, out _) ?
 23167                                GeneralName.IPAddress : GeneralName.DnsName, n))
 12168                        .ToArray();
 12169        gen.AddExtension(X509Extensions.SubjectAlternativeName, false,
 12170                         new DerSequence(altNames));
 171
 172        // EKU
 12173        var eku = o.Purposes ??
 12174         [
 12175             KeyPurposeID.id_kp_serverAuth,
 12176            KeyPurposeID.id_kp_clientAuth
 12177         ];
 12178        gen.AddExtension(X509Extensions.ExtendedKeyUsage, false,
 12179                         new ExtendedKeyUsage([.. eku]));
 180
 181        // KeyUsage – allow digitalSignature & keyEncipherment
 12182        gen.AddExtension(X509Extensions.KeyUsage, true,
 12183                         new KeyUsage(KeyUsage.DigitalSignature | KeyUsage.KeyEncipherment));
 184
 185        // ── 3. Sign & output ──────────────────────────────────────────────────────
 12186        var sigAlg = o.KeyType == KeyType.Rsa ? "SHA256WITHRSA" : "SHA384WITHECDSA";
 12187        var signer = new Asn1SignatureFactory(sigAlg, keyPair.Private, random);
 12188        var cert = gen.Generate(signer);
 189
 12190        return ToX509Cert2(cert, keyPair.Private,
 12191            o.Exportable ? X509KeyStorageFlags.Exportable : X509KeyStorageFlags.DefaultKeySet,
 12192            o.Ephemeral);
 193    }
 194    #endregion
 195
 196    #region  CSR
 197
 198    /// <summary>
 199    /// Creates a new Certificate Signing Request (CSR) and returns the PEM-encoded CSR and the private key.
 200    /// </summary>
 201    /// <param name="options">The options for the CSR.</param>
 202    /// <param name="encryptionPassword">The password to encrypt the private key, if desired.</param>
 203    /// <returns>A <see cref="CsrResult"/> containing the CSR and private key information.</returns>
 204    /// <exception cref="ArgumentOutOfRangeException"></exception>
 205    public static CsrResult NewCertificateRequest(CsrOptions options, ReadOnlySpan<char> encryptionPassword = default)
 206    {
 207        // 0️⃣ Keypair
 2208        var random = new SecureRandom(new CryptoApiRandomGenerator());
 2209        var keyPair = options.KeyType switch
 2210        {
 1211            KeyType.Rsa => GenRsaKeyPair(options.KeyLength, random),
 1212            KeyType.Ecdsa => GenEcKeyPair(options.KeyLength, random),
 0213            _ => throw new ArgumentOutOfRangeException(nameof(options.KeyType))
 2214        };
 215
 216        // 1️⃣ Subject DN
 2217        var order = new List<DerObjectIdentifier>();
 2218        var attrs = new Dictionary<DerObjectIdentifier, string>();
 219        void Add(DerObjectIdentifier oid, string? v)
 220        {
 12221            if (!string.IsNullOrWhiteSpace(v)) { order.Add(oid); attrs[oid] = v!; }
 8222        }
 2223        Add(X509Name.C, options.Country);
 2224        Add(X509Name.O, options.Org);
 2225        Add(X509Name.OU, options.OrgUnit);
 2226        Add(X509Name.CN, options.CommonName ?? options.DnsNames.First());
 2227        var subject = new X509Name(order, attrs);
 228
 229        // 2️⃣ SAN extension
 2230        var altNames = options.DnsNames
 3231            .Select(d => new GeneralName(
 3232                IPAddress.TryParse(d, out _)
 3233                    ? GeneralName.IPAddress
 3234                    : GeneralName.DnsName, d))
 2235            .ToArray();
 2236        var sanSeq = new DerSequence(altNames);
 237
 2238        var extGen = new X509ExtensionsGenerator();
 2239        extGen.AddExtension(X509Extensions.SubjectAlternativeName, false, sanSeq);
 2240        var extensions = extGen.Generate();
 241
 2242        var extensionRequestAttr = new AttributePkcs(
 2243            PkcsObjectIdentifiers.Pkcs9AtExtensionRequest,
 2244            new DerSet(extensions));
 2245        var attrSet = new DerSet(extensionRequestAttr);
 246
 247        // 3️⃣ CSR
 2248        var sigAlg = options.KeyType == KeyType.Rsa ? "SHA256WITHRSA" : "SHA384WITHECDSA";
 2249        var csr = new Pkcs10CertificationRequest(sigAlg, subject, keyPair.Public, attrSet, keyPair.Private);
 250
 251        // 4️⃣ CSR PEM + DER
 252        string csrPem;
 2253        using (var sw = new StringWriter())
 254        {
 2255            new PemWriter(sw).WriteObject(csr);
 2256            csrPem = sw.ToString();
 2257        }
 2258        var csrDer = csr.GetEncoded();
 259
 260        // 5️⃣ Private key PEM + DER
 261        string privateKeyPem;
 2262        using (var sw = new StringWriter())
 263        {
 2264            new PemWriter(sw).WriteObject(keyPair.Private);
 2265            privateKeyPem = sw.ToString();
 2266        }
 2267        var pkInfo = PrivateKeyInfoFactory.CreatePrivateKeyInfo(keyPair.Private);
 2268        var privateKeyDer = pkInfo.GetEncoded();
 269
 270        // 6️⃣ Optional encrypted PEM
 2271        string? privateKeyPemEncrypted = null;
 2272        if (!encryptionPassword.IsEmpty)
 273        {
 0274            var pwd = encryptionPassword.ToArray(); // BC requires char[]
 275            try
 276            {
 0277                var gen = new Pkcs8Generator(keyPair.Private, Pkcs8Generator.PbeSha1_3DES)
 0278                {
 0279                    Password = pwd
 0280                };
 0281                using var encSw = new StringWriter();
 0282                new PemWriter(encSw).WriteObject(gen);
 0283                privateKeyPemEncrypted = encSw.ToString();
 284            }
 285            finally
 286            {
 0287                Array.Clear(pwd, 0, pwd.Length); // wipe memory
 0288            }
 289        }
 290
 291        // 7️⃣ Public key PEM + DER
 2292        var spki = SubjectPublicKeyInfoFactory.CreateSubjectPublicKeyInfo(keyPair.Public);
 2293        var publicKeyDer = spki.GetEncoded();
 294        string publicKeyPem;
 2295        using (var sw = new StringWriter())
 296        {
 2297            new PemWriter(sw).WriteObject(spki);
 2298            publicKeyPem = sw.ToString();
 2299        }
 300
 2301        return new CsrResult(
 2302            csrPem,
 2303            csrDer,
 2304            keyPair.Private,
 2305            privateKeyPem,
 2306            privateKeyDer,
 2307            privateKeyPemEncrypted,
 2308            publicKeyPem,
 2309            publicKeyDer
 2310        );
 311    }
 312
 313
 314    #endregion
 315
 316    #region  Import
 317    /// <summary>
 318    /// Imports an X509 certificate from the specified file path, with optional password and private key file.
 319    /// </summary>
 320    /// <param name="certPath">The path to the certificate file.</param>
 321    /// <param name="password">The password for the certificate, if required.</param>
 322    /// <param name="privateKeyPath">The path to the private key file, if separate.</param>
 323    /// <param name="flags">Key storage flags for the imported certificate.</param>
 324    /// <returns>The imported X509Certificate2 instance.</returns>
 325    public static X509Certificate2 Import(
 326       string certPath,
 327       ReadOnlySpan<char> password = default,
 328       string? privateKeyPath = null,
 329       X509KeyStorageFlags flags = X509KeyStorageFlags.DefaultKeySet | X509KeyStorageFlags.Exportable)
 330    {
 9331        ValidateImportInputs(certPath, privateKeyPath);
 332
 6333        var ext = Path.GetExtension(certPath).ToLowerInvariant();
 6334        return ext switch
 6335        {
 2336            ".pfx" or ".p12" => ImportPfx(certPath, password, flags),
 1337            ".cer" or ".der" => ImportDer(certPath),
 3338            ".pem" or ".crt" => ImportPem(certPath, password, privateKeyPath),
 0339            _ => throw new NotSupportedException($"Certificate extension '{ext}' is not supported.")
 6340        };
 341    }
 342
 343    /// <summary>
 344    /// Validates the inputs for importing a certificate.
 345    /// </summary>
 346    /// <param name="certPath">The path to the certificate file.</param>
 347    /// <param name="privateKeyPath">The path to the private key file, if separate.</param>
 348    private static void ValidateImportInputs(string certPath, string? privateKeyPath)
 349    {
 9350        if (string.IsNullOrEmpty(certPath))
 351        {
 1352            throw new ArgumentException("Certificate path cannot be null or empty.", nameof(certPath));
 353        }
 8354        if (!File.Exists(certPath))
 355        {
 1356            throw new FileNotFoundException("Certificate file not found.", certPath);
 357        }
 7358        if (!string.IsNullOrEmpty(privateKeyPath) && !File.Exists(privateKeyPath))
 359        {
 1360            throw new FileNotFoundException("Private key file not found.", privateKeyPath);
 361        }
 6362    }
 363
 364    /// <summary>
 365    /// Imports a PFX certificate from the specified file path.
 366    /// </summary>
 367    /// <param name="certPath">The path to the certificate file.</param>
 368    /// <param name="password">The password for the certificate, if required.</param>
 369    /// <param name="flags">Key storage flags for the imported certificate.</param>
 370    /// <returns>The imported X509Certificate2 instance.</returns>
 371    private static X509Certificate2 ImportPfx(string certPath, ReadOnlySpan<char> password, X509KeyStorageFlags flags)
 372#if NET9_0_OR_GREATER
 2373        => X509CertificateLoader.LoadPkcs12FromFile(certPath, password, flags, Pkcs12LoaderLimits.Defaults);
 374#else
 375        => new(File.ReadAllBytes(certPath), password, flags);
 376#endif
 377
 378    private static X509Certificate2 ImportDer(string certPath)
 379#if NET9_0_OR_GREATER
 1380        => X509CertificateLoader.LoadCertificateFromFile(certPath);
 381#else
 382        => new(File.ReadAllBytes(certPath));
 383#endif
 384
 385
 386    /// <summary>
 387    /// Imports a PEM certificate from the specified file path.
 388    /// </summary>
 389    /// <param name="certPath">The path to the certificate file.</param>
 390    /// <param name="password">The password for the certificate, if required.</param>
 391    /// <param name="privateKeyPath">The path to the private key file, if separate.</param>
 392    /// <returns>The imported X509Certificate2 instance.</returns>
 393    private static X509Certificate2 ImportPem(string certPath, ReadOnlySpan<char> password, string? privateKeyPath)
 394    {
 395        // No separate key file provided
 3396        if (string.IsNullOrEmpty(privateKeyPath))
 397        {
 1398            return password.IsEmpty
 1399                ? LoadCertOnlyPem(certPath)
 1400                : X509Certificate2.CreateFromEncryptedPemFile(certPath, password);
 401        }
 402
 403        // Separate key file provided
 2404        return password.IsEmpty
 2405            ? ImportPemUnencrypted(certPath, privateKeyPath)
 2406            : ImportPemEncrypted(certPath, password, privateKeyPath);
 407    }
 408
 409    /// <summary>
 410    /// Imports an unencrypted PEM certificate from the specified file path.
 411    /// </summary>
 412    /// <param name="certPath">The path to the certificate file.</param>
 413    /// <param name="privateKeyPath">The path to the private key file.</param>
 414    /// <returns>The imported X509Certificate2 instance.</returns>
 415    private static X509Certificate2 ImportPemUnencrypted(string certPath, string privateKeyPath)
 1416        => X509Certificate2.CreateFromPemFile(certPath, privateKeyPath);
 417
 418    /// <summary>
 419    /// Imports a PEM certificate from the specified file path.
 420    /// </summary>
 421    /// <param name="certPath">The path to the certificate file.</param>
 422    /// <param name="password">The password for the certificate, if required.</param>
 423    /// <param name="privateKeyPath">The path to the private key file, if separate.</param>
 424    /// <returns>The imported X509Certificate2 instance.</returns>
 425    private static X509Certificate2 ImportPemEncrypted(string certPath, ReadOnlySpan<char> password, string privateKeyPa
 426    {
 427        // Prefer single-file path (combined) first for reliability on some platforms
 428        try
 429        {
 1430            var single = X509Certificate2.CreateFromEncryptedPemFile(certPath, password);
 0431            if (single.HasPrivateKey)
 432            {
 0433                Log.Debug("Imported encrypted PEM using single-file path (combined cert+key) for {CertPath}", certPath);
 0434                return single;
 435            }
 0436        }
 1437        catch (Exception exSingle)
 438        {
 1439            Log.Debug(exSingle, "Single-file encrypted PEM import failed, falling back to separate key file {KeyFile}", 
 1440        }
 441
 1442        var loaded = X509Certificate2.CreateFromEncryptedPemFile(certPath, password, privateKeyPath);
 443
 1444        if (loaded.HasPrivateKey)
 445        {
 1446            return loaded;
 447        }
 448
 449        // Fallback manual pairing if platform failed to associate the key
 0450        TryManualEncryptedPemPairing(certPath, password, privateKeyPath, ref loaded);
 0451        return loaded;
 0452    }
 453
 454    /// <summary>
 455    /// Tries to manually pair an encrypted PEM certificate with its private key.
 456    /// </summary>
 457    /// <param name="certPath">The path to the certificate file.</param>
 458    /// <param name="password">The password for the certificate, if required.</param>
 459    /// <param name="privateKeyPath">The path to the private key file, if separate.</param>
 460    /// <param name="loaded">The loaded X509Certificate2 instance.</param>
 461    private static void TryManualEncryptedPemPairing(string certPath, ReadOnlySpan<char> password, string privateKeyPath
 462    {
 463        try
 464        {
 0465            var certOnly = LoadCertOnlyPem(certPath);
 0466            var encDer = ExtractEncryptedPemDer(privateKeyPath);
 467
 0468            if (encDer is null)
 469            {
 0470                Log.Debug("Encrypted PEM manual pairing fallback skipped: markers not found in key file {KeyFile}", priv
 0471                return;
 472            }
 473
 0474            var lastErr = TryPairCertificateWithKey(certOnly, password, encDer, ref loaded);
 475
 0476            if (lastErr != null)
 477            {
 0478                Log.Debug(lastErr, "Encrypted PEM manual pairing attempts failed (all rounds); returning original loaded
 479            }
 0480        }
 0481        catch (Exception ex)
 482        {
 0483            Log.Debug(ex, "Encrypted PEM manual pairing fallback failed unexpectedly; returning original loaded certific
 0484        }
 0485    }
 486
 487    /// <summary>
 488    /// Extracts the encrypted PEM DER bytes from a private key file.
 489    /// </summary>
 490    /// <param name="privateKeyPath">The path to the private key file.</param>
 491    /// <returns>The DER bytes if successful, null otherwise.</returns>
 492    private static byte[]? ExtractEncryptedPemDer(string privateKeyPath)
 493    {
 494        const string encBegin = "-----BEGIN ENCRYPTED PRIVATE KEY-----";
 495        const string encEnd = "-----END ENCRYPTED PRIVATE KEY-----";
 496
 0497        byte[]? encDer = null;
 0498        for (var attempt = 0; attempt < 5 && encDer is null; attempt++)
 499        {
 0500            var keyPem = File.ReadAllText(privateKeyPath);
 0501            var start = keyPem.IndexOf(encBegin, StringComparison.Ordinal);
 0502            var end = keyPem.IndexOf(encEnd, StringComparison.Ordinal);
 0503            if (start >= 0 && end > start)
 504            {
 0505                start += encBegin.Length;
 0506                var b64 = keyPem[start..end].Replace("\r", "").Replace("\n", "").Trim();
 0507                try { encDer = Convert.FromBase64String(b64); }
 0508                catch (FormatException fe)
 509                {
 0510                    Log.Debug(fe, "Base64 decode failed on attempt {Attempt} reading encrypted key; retrying", attempt +
 0511                }
 512            }
 0513            if (encDer is null)
 514            {
 0515                Thread.Sleep(40 * (attempt + 1));
 516            }
 517        }
 518
 0519        return encDer;
 520    }
 521
 522    /// <summary>
 523    /// Attempts to pair a certificate with an encrypted private key using RSA and ECDSA.
 524    /// </summary>
 525    /// <param name="certOnly">The certificate without a private key.</param>
 526    /// <param name="password">The password for the encrypted key.</param>
 527    /// <param name="encDer">The encrypted DER bytes.</param>
 528    /// <param name="loaded">The loaded certificate (updated if pairing succeeds).</param>
 529    /// <returns>The last exception encountered, or null if pairing succeeded.</returns>
 530    private static Exception? TryPairCertificateWithKey(X509Certificate2 certOnly, ReadOnlySpan<char> password, byte[] e
 531    {
 0532        Exception? lastErr = null;
 0533        for (var round = 0; round < 2; round++)
 534        {
 0535            if (TryPairWithRsa(certOnly, password, encDer, round, ref loaded, ref lastErr))
 536            {
 0537                return null;
 538            }
 539
 0540            if (TryPairWithEcdsa(certOnly, password, encDer, round, ref loaded, ref lastErr))
 541            {
 0542                return null;
 543            }
 544
 0545            Thread.Sleep(25 * (round + 1));
 546        }
 0547        return lastErr;
 548    }
 549
 550    /// <summary>
 551    /// Tries to pair a certificate with an RSA private key.
 552    /// </summary>
 553    /// <param name="certOnly">The certificate without a private key.</param>
 554    /// <param name="password">The password for the encrypted key.</param>
 555    /// <param name="encDer">The encrypted DER bytes.</param>
 556    /// <param name="round">The attempt round number.</param>
 557    /// <param name="loaded">The loaded certificate (updated if pairing succeeds).</param>
 558    /// <param name="lastErr">The last exception encountered (updated on failure).</param>
 559    /// <returns>True if pairing succeeded, false otherwise.</returns>
 560    private static bool TryPairWithRsa(X509Certificate2 certOnly, ReadOnlySpan<char> password, byte[] encDer, int round,
 561    {
 562        try
 563        {
 0564            using var rsa = RSA.Create();
 0565            rsa.ImportEncryptedPkcs8PrivateKey(password, encDer, out _);
 0566            var withKey = certOnly.CopyWithPrivateKey(rsa);
 0567            if (withKey.HasPrivateKey)
 568            {
 0569                Log.Debug("Encrypted PEM manual pairing succeeded with RSA private key (round {Round}).", round + 1);
 0570                loaded = withKey;
 0571                return true;
 572            }
 0573        }
 0574        catch (Exception exRsa)
 575        {
 0576            lastErr = lastErr is null ? exRsa : new AggregateException(lastErr, exRsa);
 0577        }
 0578        return false;
 0579    }
 580
 581    /// <summary>
 582    /// Tries to pair a certificate with an ECDSA private key.
 583    /// </summary>
 584    /// <param name="certOnly">The certificate without a private key.</param>
 585    /// <param name="password">The password for the encrypted key.</param>
 586    /// <param name="encDer">The encrypted DER bytes.</param>
 587    /// <param name="round">The attempt round number.</param>
 588    /// <param name="loaded">The loaded certificate (updated if pairing succeeds).</param>
 589    /// <param name="lastErr">The last exception encountered (updated on failure).</param>
 590    /// <returns>True if pairing succeeded, false otherwise.</returns>
 591    private static bool TryPairWithEcdsa(X509Certificate2 certOnly, ReadOnlySpan<char> password, byte[] encDer, int roun
 592    {
 593        try
 594        {
 0595            using var ecdsa = ECDsa.Create();
 0596            ecdsa.ImportEncryptedPkcs8PrivateKey(password, encDer, out _);
 0597            var withKey = certOnly.CopyWithPrivateKey(ecdsa);
 0598            if (withKey.HasPrivateKey)
 599            {
 0600                Log.Debug("Encrypted PEM manual pairing succeeded with ECDSA private key (round {Round}).", round + 1);
 0601                loaded = withKey;
 0602                return true;
 603            }
 0604        }
 0605        catch (Exception exEc)
 606        {
 0607            lastErr = lastErr is null ? exEc : new AggregateException(lastErr, exEc);
 0608        }
 0609        return false;
 0610    }
 611
 612    /// <summary>
 613    /// Loads a certificate from a PEM file that contains *only* a CERTIFICATE block (no key).
 614    /// </summary>
 615    /// <param name="certPath">The path to the certificate file.</param>
 616    /// <returns>The loaded X509Certificate2 instance.</returns>
 617    private static X509Certificate2 LoadCertOnlyPem(string certPath)
 618    {
 619        // 1) Read + trim the whole PEM text
 1620        var pem = File.ReadAllText(certPath).Trim();
 621
 622        // 2) Define the BEGIN/END markers
 623        const string begin = "-----BEGIN CERTIFICATE-----";
 624        const string end = "-----END CERTIFICATE-----";
 625
 626        // 3) Find their positions
 1627        var start = pem.IndexOf(begin, StringComparison.Ordinal);
 1628        if (start < 0)
 629        {
 0630            throw new InvalidDataException("BEGIN CERTIFICATE marker not found");
 631        }
 632
 1633        start += begin.Length;
 634
 1635        var stop = pem.IndexOf(end, start, StringComparison.Ordinal);
 1636        if (stop < 0)
 637        {
 0638            throw new InvalidDataException("END CERTIFICATE marker not found");
 639        }
 640
 641        // 4) Extract, clean, and decode the Base64 payload
 1642        var b64 = pem[start..stop]
 1643                       .Replace("\r", "")
 1644                       .Replace("\n", "")
 1645                       .Trim();
 1646        var der = Convert.FromBase64String(b64);
 647
 648        // 5) Return the X509Certificate2
 649
 650#if NET9_0_OR_GREATER
 1651        return X509CertificateLoader.LoadCertificate(der);
 652#else
 653        // .NET 8 or earlier path, using X509Certificate2 ctor
 654        // Note: this will not work in .NET 9+ due to the new X509CertificateLoader API
 655        //       which requires a byte array or a file path.
 656        return new X509Certificate2(der);
 657#endif
 658    }
 659
 660    /// <summary>
 661    /// Imports an X509 certificate from the specified file path, using a SecureString password and optional private key
 662    /// </summary>
 663    /// <param name="certPath">The path to the certificate file.</param>
 664    /// <param name="password">The SecureString password for the certificate, if required.</param>
 665    /// <param name="privateKeyPath">The path to the private key file, if separate.</param>
 666    /// <param name="flags">Key storage flags for the imported certificate.</param>
 667    /// <returns>The imported X509Certificate2 instance.</returns>
 668    public static X509Certificate2 Import(
 669       string certPath,
 670       SecureString password,
 671       string? privateKeyPath = null,
 672       X509KeyStorageFlags flags = X509KeyStorageFlags.DefaultKeySet | X509KeyStorageFlags.Exportable)
 673    {
 1674        X509Certificate2? result = null;
 1675        Log.Debug("Importing certificate from {CertPath} with flags {Flags}", certPath, flags);
 676        // ToSecureSpan zero-frees its buffer as soon as this callback returns.
 1677        password.ToSecureSpan(span =>
 1678        {
 1679            // capture the return value of the span-based overload
 1680            result = Import(certPath: certPath, password: span, privateKeyPath: privateKeyPath, flags: flags);
 2681        });
 682
 683        // at this point, unmanaged memory is already zeroed
 1684        return result!;   // non-null because the callback always runs exactly once
 685    }
 686
 687    /// <summary>
 688    /// Imports an X509 certificate from the specified file path, with optional private key file and key storage flags.
 689    /// </summary>
 690    /// <param name="certPath">The path to the certificate file.</param>
 691    /// <param name="privateKeyPath">The path to the private key file, if separate.</param>
 692    /// <param name="flags">Key storage flags for the imported certificate.</param>
 693    /// <returns>The imported X509Certificate2 instance.</returns>
 694    public static X509Certificate2 Import(
 695         string certPath,
 696         string? privateKeyPath = null,
 697         X509KeyStorageFlags flags = X509KeyStorageFlags.DefaultKeySet | X509KeyStorageFlags.Exportable)
 698    {
 699        // ToSecureSpan zero-frees its buffer as soon as this callback returns.
 2700        ReadOnlySpan<char> passwordSpan = default;
 701        // capture the return value of the span-based overload
 2702        var result = Import(certPath: certPath, password: passwordSpan, privateKeyPath: privateKeyPath, flags: flags);
 1703        return result!;
 704    }
 705
 706    /// <summary>
 707    /// Imports an X509 certificate from the specified file path.
 708    /// </summary>
 709    /// <param name="certPath">The path to the certificate file.</param>
 710    /// <returns>The imported X509Certificate2 instance.</returns>
 711    public static X509Certificate2 Import(string certPath)
 712    {
 713        // ToSecureSpan zero-frees its buffer as soon as this callback returns.
 4714        ReadOnlySpan<char> passwordSpan = default;
 715        // capture the return value of the span-based overload
 4716        var result = Import(certPath: certPath, password: passwordSpan);
 2717        return result!;
 718    }
 719
 720
 721
 722    #endregion
 723
 724    #region Export
 725    /// <summary>
 726    /// Exports the specified X509 certificate to a file in the given format, with optional password and private key inc
 727    /// </summary>
 728    /// <param name="cert">The X509Certificate2 to export.</param>
 729    /// <param name="filePath">The file path to export the certificate to.</param>
 730    /// <param name="fmt">The export format (Pfx or Pem).</param>
 731    /// <param name="password">The password to protect the exported certificate or private key, if applicable.</param>
 732    /// <param name="includePrivateKey">Whether to include the private key in the export.</param>
 733    public static void Export(X509Certificate2 cert, string filePath, ExportFormat fmt,
 734           ReadOnlySpan<char> password = default, bool includePrivateKey = false)
 735    {
 736        // Normalize/validate target path and format
 4737        filePath = NormalizeExportPath(filePath, fmt);
 738
 739        // Ensure output directory exists
 4740        EnsureOutputDirectoryExists(filePath);
 741
 742        // Prepare password shapes once
 4743        using var shapes = CreatePasswordShapes(password);
 744
 745        switch (fmt)
 746        {
 747            case ExportFormat.Pfx:
 2748                ExportPfx(cert, filePath, shapes.Secure);
 2749                break;
 750            case ExportFormat.Pem:
 2751                ExportPem(cert, filePath, password, includePrivateKey);
 2752                break;
 753            default:
 0754                throw new NotSupportedException($"Unsupported export format: {fmt}");
 755        }
 4756    }
 757
 758    /// <summary>
 759    /// Normalizes the export file path based on the desired export format.
 760    /// </summary>
 761    /// <param name="filePath">The original file path.</param>
 762    /// <param name="fmt">The desired export format.</param>
 763    /// <returns>The normalized file path.</returns>
 764    private static string NormalizeExportPath(string filePath, ExportFormat fmt)
 765    {
 4766        var fileExtension = Path.GetExtension(filePath).ToLowerInvariant();
 767        switch (fileExtension)
 768        {
 769            case ".pfx":
 2770                if (fmt != ExportFormat.Pfx)
 771                {
 0772                    throw new NotSupportedException(
 0773                            $"File extension '{fileExtension}' for '{filePath}' is not supported for PFX certificates.")
 774                }
 775
 776                break;
 777            case ".pem":
 2778                if (fmt != ExportFormat.Pem)
 779                {
 0780                    throw new NotSupportedException(
 0781                            $"File extension '{fileExtension}' for '{filePath}' is not supported for PEM certificates.")
 782                }
 783
 784                break;
 785            case "":
 786                // no extension, use the format as the extension
 0787                filePath += fmt == ExportFormat.Pfx ? ".pfx" : ".pem";
 0788                break;
 789            default:
 0790                throw new NotSupportedException(
 0791                    $"File extension '{fileExtension}' for '{filePath}' is not supported. Use .pfx or .pem.");
 792        }
 4793        return filePath;
 794    }
 795
 796    /// <summary>
 797    /// Ensures the output directory exists for the specified file path.
 798    /// </summary>
 799    /// <param name="filePath">The file path to check.</param>
 800    private static void EnsureOutputDirectoryExists(string filePath)
 801    {
 4802        var dir = Path.GetDirectoryName(filePath);
 4803        if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
 804        {
 0805            throw new DirectoryNotFoundException(
 0806                    $"Directory '{dir}' does not exist. Cannot export certificate to {filePath}.");
 807        }
 4808    }
 809
 810    /// <summary>
 811    /// Represents the password shapes used for exporting certificates.
 812    /// </summary>
 4813    private sealed class PasswordShapes(SecureString? secure, char[]? chars) : IDisposable
 814    {
 10815        public SecureString? Secure { get; } = secure;
 14816        public char[]? Chars { get; } = chars;
 817
 818        public void Dispose()
 819        {
 820            try
 821            {
 4822                Secure?.Dispose();
 3823            }
 824            finally
 825            {
 4826                if (Chars is not null)
 827                {
 3828                    Array.Clear(Chars, 0, Chars.Length);
 829                }
 4830            }
 4831        }
 832    }
 833
 834    /// <summary>
 835    /// Creates password shapes from the provided password span.
 836    /// </summary>
 837    /// <param name="password">The password span.</param>
 838    /// <returns>The created password shapes.</returns>
 839    private static PasswordShapes CreatePasswordShapes(ReadOnlySpan<char> password)
 840    {
 4841        var secure = password.IsEmpty ? null : SecureStringUtils.ToSecureString(password);
 4842        var chars = password.IsEmpty ? null : password.ToArray();
 4843        return new PasswordShapes(secure, chars);
 844    }
 845
 846    /// <summary>
 847    /// Exports the specified X509 certificate to a file in the given format.
 848    /// </summary>
 849    /// <param name="cert">The X509Certificate2 to export.</param>
 850    /// <param name="filePath">The file path to export the certificate to.</param>
 851    /// <param name="password">The SecureString password to protect the exported certificate.</param>
 852    private static void ExportPfx(X509Certificate2 cert, string filePath, SecureString? password)
 853    {
 2854        var pfx = cert.Export(X509ContentType.Pfx, password);
 2855        File.WriteAllBytes(filePath, pfx);
 2856    }
 857
 858    /// <summary>
 859    /// Exports the specified X509 certificate to a file in the given format.
 860    /// </summary>
 861    /// <param name="cert">The X509Certificate2 to export.</param>
 862    /// <param name="filePath">The file path to export the certificate to.</param>
 863    /// <param name="password">The SecureString password to protect the exported certificate.</param>
 864    /// <param name="includePrivateKey">Whether to include the private key in the export.</param>
 865    private static void ExportPem(X509Certificate2 cert, string filePath, ReadOnlySpan<char> password, bool includePriva
 866    {
 867        // Write certificate first, then dispose writer before optional key append to avoid file locks on Windows
 2868        using (var sw = new StreamWriter(filePath, false, Encoding.ASCII))
 869        {
 2870            new PemWriter(sw).WriteObject(DotNetUtilities.FromX509Certificate(cert));
 2871        }
 872
 2873        if (includePrivateKey)
 874        {
 1875            WritePrivateKey(cert, password, filePath);
 876            // Fallback safeguard: if append was requested but key block missing, try again
 877            try
 878            {
 1879                if (ShouldAppendKeyToPem && !File.ReadAllText(filePath).Contains("PRIVATE KEY", StringComparison.Ordinal
 880                {
 0881                    var baseName = Path.GetFileNameWithoutExtension(filePath);
 0882                    var dir = Path.GetDirectoryName(filePath);
 0883                    var keyFile = string.IsNullOrEmpty(dir) ? baseName + ".key" : Path.Combine(dir!, baseName + ".key");
 0884                    if (File.Exists(keyFile))
 885                    {
 0886                        File.AppendAllText(filePath, Environment.NewLine + File.ReadAllText(keyFile));
 887                    }
 888                }
 1889            }
 0890            catch (Exception ex)
 891            {
 0892                Log.Debug(ex, "Fallback attempt to append private key to PEM failed");
 0893            }
 894        }
 2895    }
 896
 897    /// <summary>
 898    /// Writes the private key of the specified X509 certificate to a file.
 899    /// </summary>
 900    /// <param name="cert">The X509Certificate2 to export.</param>
 901    /// <param name="password">The SecureString password to protect the exported private key.</param>
 902    /// <param name="certFilePath">The file path to export the certificate to.</param>
 903    private static void WritePrivateKey(X509Certificate2 cert, ReadOnlySpan<char> password, string certFilePath)
 904    {
 905        byte[] keyDer;
 906        string pemLabel;
 1907        if (password.IsEmpty)
 908        {
 909            // unencrypted PKCS#8
 0910            keyDer = cert.GetRSAPrivateKey() is RSA rsa
 0911                       ? rsa.ExportPkcs8PrivateKey()
 0912                       : cert.GetECDsaPrivateKey()!.ExportPkcs8PrivateKey();
 0913            pemLabel = "PRIVATE KEY";
 914        }
 915        else
 916        {
 917            // encrypted PKCS#8
 1918            var pbe = new PbeParameters(
 1919                PbeEncryptionAlgorithm.Aes256Cbc,
 1920                HashAlgorithmName.SHA256,
 1921                100_000
 1922            );
 923
 1924            keyDer = cert.GetRSAPrivateKey() is RSA rsaEnc
 1925                       ? rsaEnc.ExportEncryptedPkcs8PrivateKey(password, pbe)
 1926                       : cert.GetECDsaPrivateKey()!.ExportEncryptedPkcs8PrivateKey(password, pbe);
 1927            pemLabel = "ENCRYPTED PRIVATE KEY";
 928        }
 929
 1930        var keyPem = PemEncoding.WriteString(pemLabel, keyDer);
 1931        var certDir = Path.GetDirectoryName(certFilePath);
 1932        var baseName = Path.GetFileNameWithoutExtension(certFilePath);
 1933        var keyFilePath = string.IsNullOrEmpty(certDir)
 1934            ? baseName + ".key"
 1935            : Path.Combine(certDir!, baseName + ".key");
 1936        File.WriteAllText(keyFilePath, keyPem);
 937
 938        try
 939        {
 1940            if (ShouldAppendKeyToPem)
 941            {
 942                // Optional: append the key to the main certificate PEM when explicitly enabled.
 0943                File.AppendAllText(certFilePath, Environment.NewLine + keyPem);
 944            }
 1945        }
 0946        catch (Exception ex)
 947        {
 0948            Log.Debug(ex, "Failed to append private key to certificate PEM file {CertFilePath}; continuing with separate
 0949        }
 1950    }
 951
 952    /// <summary>
 953    /// Exports the specified X509 certificate to a file in the given format, using a SecureString password and optional
 954    /// </summary>
 955    /// <param name="cert">The X509Certificate2 to export.</param>
 956    /// <param name="filePath">The file path to export the certificate to.</param>
 957    /// <param name="fmt">The export format (Pfx or Pem).</param>
 958    /// <param name="password">The SecureString password to protect the exported certificate or private key, if applicab
 959    /// <param name="includePrivateKey">Whether to include the private key in the export.</param>
 960    public static void Export(
 961        X509Certificate2 cert,
 962        string filePath,
 963        ExportFormat fmt,
 964        SecureString password,
 965        bool includePrivateKey = false)
 966    {
 1967        password.ToSecureSpan(span =>
 1968            Export(cert, filePath, fmt, span, includePrivateKey)
 1969        // this will run your span‐based implementation,
 1970        // then immediately zero & free the unmanaged buffer
 1971        );
 1972    }
 973
 974    #endregion
 975
 976    #region  Validation helpers (Test-PodeCertificate equivalent)
 977    /// <summary>
 978    /// Validates the specified X509 certificate according to the provided options.
 979    /// </summary>
 980    /// <param name="cert">The X509Certificate2 to validate.</param>
 981    /// <param name="checkRevocation">Whether to check certificate revocation status.</param>
 982    /// <param name="allowWeakAlgorithms">Whether to allow weak algorithms such as SHA-1 or small key sizes.</param>
 983    /// <param name="denySelfSigned">Whether to deny self-signed certificates.</param>
 984    /// <param name="expectedPurpose">A collection of expected key purposes (EKU) for the certificate.</param>
 985    /// <param name="strictPurpose">If true, the certificate must match the expected purposes exactly.</param>
 986    /// <returns>True if the certificate is valid according to the specified options; otherwise, false.</returns>
 987    public static bool Validate(
 988     X509Certificate2 cert,
 989     bool checkRevocation = false,
 990     bool allowWeakAlgorithms = false,
 991     bool denySelfSigned = false,
 992     OidCollection? expectedPurpose = null,
 993     bool strictPurpose = false)
 994    {
 995        // 1) Validity period
 7996        if (!IsWithinValidityPeriod(cert))
 997        {
 0998            return false;
 999        }
 1000
 1001        // 2) Self-signed policy
 71002        var isSelfSigned = cert.Subject == cert.Issuer;
 71003        if (denySelfSigned && isSelfSigned)
 1004        {
 11005            return false;
 1006        }
 1007
 1008        // 3) Chain build (with optional revocation)
 61009        if (!BuildChainOk(cert, checkRevocation, isSelfSigned))
 1010        {
 01011            return false;
 1012        }
 1013
 1014        // 4) EKU / purposes
 61015        if (!PurposesOk(cert, expectedPurpose, strictPurpose))
 1016        {
 11017            return false;
 1018        }
 1019
 1020        // 5) Weak algorithms
 51021        if (!allowWeakAlgorithms && UsesWeakAlgorithms(cert))
 1022        {
 11023            return false;
 1024        }
 1025
 41026        return true;   // ✅ everything passed
 1027    }
 1028
 1029    /// <summary>
 1030    /// Checks if the certificate is within its validity period.
 1031    /// </summary>
 1032    /// <param name="cert">The X509Certificate2 to check.</param>
 1033    /// <returns>True if the certificate is within its validity period; otherwise, false.</returns>
 1034    private static bool IsWithinValidityPeriod(X509Certificate2 cert)
 71035        => DateTime.UtcNow >= cert.NotBefore && DateTime.UtcNow <= cert.NotAfter;
 1036
 1037    /// <summary>
 1038    /// Checks if the certificate chain is valid.
 1039    /// </summary>
 1040    /// <param name="cert">The X509Certificate2 to check.</param>
 1041    /// <param name="checkRevocation">Whether to check certificate revocation status.</param>
 1042    /// <param name="isSelfSigned">Whether the certificate is self-signed.</param>
 1043    /// <returns>True if the certificate chain is valid; otherwise, false.</returns>
 1044    private static bool BuildChainOk(X509Certificate2 cert, bool checkRevocation, bool isSelfSigned)
 1045    {
 61046        using var chain = new X509Chain();
 61047        chain.ChainPolicy.RevocationMode = checkRevocation ? X509RevocationMode.Online : X509RevocationMode.NoCheck;
 61048        chain.ChainPolicy.RevocationFlag = X509RevocationFlag.EndCertificateOnly;
 61049        chain.ChainPolicy.DisableCertificateDownloads = !checkRevocation;
 1050
 61051        if (isSelfSigned)
 1052        {
 61053            chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
 1054        }
 1055
 61056        return chain.Build(cert);
 61057    }
 1058
 1059    /// <summary>
 1060    /// Checks if the certificate has the expected key purposes (EKU).
 1061    /// </summary>
 1062    /// <param name="cert">The X509Certificate2 to check.</param>
 1063    /// <param name="expectedPurpose">A collection of expected key purposes (EKU) for the certificate.</param>
 1064    /// <param name="strictPurpose">If true, the certificate must match the expected purposes exactly.</param>
 1065    /// <returns>True if the certificate has the expected purposes; otherwise, false.</returns>
 1066    private static bool PurposesOk(X509Certificate2 cert, OidCollection? expectedPurpose, bool strictPurpose)
 1067    {
 61068        if (expectedPurpose is not { Count: > 0 })
 1069        {
 31070            return true; // nothing to check
 1071        }
 1072
 31073        var eku = cert.Extensions
 31074                       .OfType<X509EnhancedKeyUsageExtension>()
 31075                       .SelectMany(e => e.EnhancedKeyUsages.Cast<Oid>())
 61076                       .Select(o => o.Value)
 31077                       .ToHashSet();
 1078
 31079        var wanted = expectedPurpose.Cast<Oid>()
 41080                                    .Select(o => o.Value)
 31081                                    .ToHashSet();
 1082
 31083        return strictPurpose ? eku.SetEquals(wanted) : wanted.All(eku.Contains);
 1084    }
 1085
 1086    /// <summary>
 1087    /// Checks if the certificate uses weak algorithms.
 1088    /// </summary>
 1089    /// <param name="cert">The X509Certificate2 to check.</param>
 1090    /// <returns>True if the certificate uses weak algorithms; otherwise, false.</returns>
 1091    private static bool UsesWeakAlgorithms(X509Certificate2 cert)
 1092    {
 41093        var isSha1 = cert.SignatureAlgorithm?.FriendlyName?
 41094                           .Contains("sha1", StringComparison.OrdinalIgnoreCase) == true;
 1095
 41096        var weakRsa = cert.GetRSAPublicKey() is { KeySize: < 2048 };
 41097        var weakDsa = cert.GetDSAPublicKey() is { KeySize: < 2048 };
 41098        var weakEcdsa = cert.GetECDsaPublicKey() is { KeySize: < 256 };  // P-256 minimum
 1099
 41100        return isSha1 || weakRsa || weakDsa || weakEcdsa;
 1101    }
 1102
 1103
 1104    /// <summary>
 1105    /// Gets the enhanced key usage purposes (EKU) from the specified X509 certificate.
 1106    /// </summary>
 1107    /// <param name="cert">The X509Certificate2 to extract purposes from.</param>
 1108    /// <returns>An enumerable of purpose names or OID values.</returns>
 1109    public static IEnumerable<string> GetPurposes(X509Certificate2 cert) =>
 11110        cert.Extensions
 11111            .OfType<X509EnhancedKeyUsageExtension>()
 11112            .SelectMany(x => x.EnhancedKeyUsages.Cast<Oid>())
 21113            .Select(o => (o.FriendlyName ?? o.Value)!)   // ← null-forgiving
 31114            .Where(s => s.Length > 0);                   // optional: drop empties
 1115    #endregion
 1116
 1117    #region  private helpers
 1118    private static AsymmetricCipherKeyPair GenRsaKeyPair(int bits, SecureRandom rng)
 1119    {
 131120        var gen = new RsaKeyPairGenerator();
 131121        gen.Init(new KeyGenerationParameters(rng, bits));
 131122        return gen.GenerateKeyPair();
 1123    }
 1124
 1125    /// <summary>
 1126    /// Generates an EC key pair.
 1127    /// </summary>
 1128    /// <param name="bits">The key size in bits.</param>
 1129    /// <param name="rng">The secure random number generator.</param>
 1130    /// <returns>The generated EC key pair.</returns>
 1131    private static AsymmetricCipherKeyPair GenEcKeyPair(int bits, SecureRandom rng)
 1132    {
 1133        // NIST-style names are fine here
 11134        var name = bits switch
 11135        {
 11136            <= 256 => "P-256",
 01137            <= 384 => "P-384",
 01138            _ => "P-521"
 11139        };
 1140
 1141        // ECNamedCurveTable knows about SEC *and* NIST names
 11142        var ecParams = ECNamedCurveTable.GetByName(name)
 11143                       ?? throw new InvalidOperationException($"Curve not found: {name}");
 1144
 11145        var domain = new ECDomainParameters(
 11146            ecParams.Curve, ecParams.G, ecParams.N, ecParams.H, ecParams.GetSeed());
 1147
 11148        var gen = new ECKeyPairGenerator();
 11149        gen.Init(new ECKeyGenerationParameters(domain, rng));
 11150        return gen.GenerateKeyPair();
 1151    }
 1152
 1153    /// <summary>
 1154    /// Converts a BouncyCastle X509Certificate to a .NET X509Certificate2.
 1155    /// </summary>
 1156    /// <param name="cert">The BouncyCastle X509Certificate to convert.</param>
 1157    /// <param name="privKey">The private key associated with the certificate.</param>
 1158    /// <param name="flags">The key storage flags to use.</param>
 1159    /// <param name="ephemeral">Whether the key is ephemeral.</param>
 1160    /// <returns></returns>
 1161    private static X509Certificate2 ToX509Cert2(
 1162        Org.BouncyCastle.X509.X509Certificate cert,
 1163        AsymmetricKeyParameter privKey,
 1164        X509KeyStorageFlags flags,
 1165        bool ephemeral)
 1166    {
 121167        var store = new Pkcs12StoreBuilder().Build();
 121168        var entry = new X509CertificateEntry(cert);
 1169        const string alias = "cert";
 121170        store.SetCertificateEntry(alias, entry);
 121171        store.SetKeyEntry(alias, new AsymmetricKeyEntry(privKey),
 121172                          [entry]);
 1173
 121174        using var ms = new MemoryStream();
 121175        store.Save(ms, [], new SecureRandom());
 121176        var raw = ms.ToArray();
 1177
 1178#if NET9_0_OR_GREATER
 1179        try
 1180        {
 121181            return X509CertificateLoader.LoadPkcs12(
 121182                raw,
 121183                password: default,
 121184                keyStorageFlags: flags | (ephemeral ? X509KeyStorageFlags.EphemeralKeySet : 0),
 121185                loaderLimits: Pkcs12LoaderLimits.Defaults
 121186            );
 1187        }
 01188        catch (PlatformNotSupportedException) when (ephemeral)
 1189        {
 1190            // Some platforms (e.g. certain Linux/macOS runners) don't yet support
 1191            // EphemeralKeySet with the new X509CertificateLoader API. In that case
 1192            // we fall back to re-loading without the EphemeralKeySet flag. The
 1193            // intent of Ephemeral in our API is simply "do not persist in a store" –
 1194            // loading without the flag here still keeps the cert in-memory only.
 01195            Log.Debug("EphemeralKeySet not supported on this platform for X509CertificateLoader; falling back without th
 01196            return X509CertificateLoader.LoadPkcs12(
 01197                raw,
 01198                password: default,
 01199                keyStorageFlags: flags, // omit EphemeralKeySet
 01200                loaderLimits: Pkcs12LoaderLimits.Defaults
 01201            );
 1202        }
 1203#else
 1204        try
 1205        {
 1206            return new X509Certificate2(
 1207                raw,
 1208                (string?)null,
 1209                flags | (ephemeral ? X509KeyStorageFlags.EphemeralKeySet : 0)
 1210            );
 1211        }
 1212        catch (PlatformNotSupportedException) when (ephemeral)
 1213        {
 1214            // macOS (and some Linux distros) under net8 may not support EphemeralKeySet here.
 1215            Log.Debug("EphemeralKeySet not supported on this platform (net8); falling back without the flag.");
 1216            return new X509Certificate2(
 1217                raw,
 1218                (string?)null,
 1219                flags // omit EphemeralKeySet
 1220            );
 1221        }
 1222
 1223#endif
 121224    }
 1225
 1226    #endregion
 1227}

Methods/Properties

get_ShouldAppendKeyToPem()
.ctor(System.Collections.Generic.IEnumerable`1<System.String>,Kestrun.Certificates.CertificateManager/KeyType,System.Int32,System.Collections.Generic.IEnumerable`1<Org.BouncyCastle.Asn1.X509.KeyPurposeID>,System.Int32,System.Boolean,System.Boolean)
get_DnsNames()
get_KeyType()
get_KeyLength()
get_Purposes()
get_ValidDays()
get_Ephemeral()
get_Exportable()
.ctor(System.Collections.Generic.IEnumerable`1<System.String>,Kestrun.Certificates.CertificateManager/KeyType,System.Int32,System.String,System.String,System.String,System.String)
get_DnsNames()
get_KeyType()
get_KeyLength()
get_Country()
get_Org()
get_OrgUnit()
get_CommonName()
NewSelfSigned(Kestrun.Certificates.CertificateManager/SelfSignedOptions)
NewCertificateRequest(Kestrun.Certificates.CertificateManager/CsrOptions,System.ReadOnlySpan`1<System.Char>)
Add()
Import(System.String,System.ReadOnlySpan`1<System.Char>,System.String,System.Security.Cryptography.X509Certificates.X509KeyStorageFlags)
ValidateImportInputs(System.String,System.String)
ImportPfx(System.String,System.ReadOnlySpan`1<System.Char>,System.Security.Cryptography.X509Certificates.X509KeyStorageFlags)
ImportDer(System.String)
ImportPem(System.String,System.ReadOnlySpan`1<System.Char>,System.String)
ImportPemUnencrypted(System.String,System.String)
ImportPemEncrypted(System.String,System.ReadOnlySpan`1<System.Char>,System.String)
TryManualEncryptedPemPairing(System.String,System.ReadOnlySpan`1<System.Char>,System.String,System.Security.Cryptography.X509Certificates.X509Certificate2&)
ExtractEncryptedPemDer(System.String)
TryPairCertificateWithKey(System.Security.Cryptography.X509Certificates.X509Certificate2,System.ReadOnlySpan`1<System.Char>,System.Byte[],System.Security.Cryptography.X509Certificates.X509Certificate2&)
TryPairWithRsa(System.Security.Cryptography.X509Certificates.X509Certificate2,System.ReadOnlySpan`1<System.Char>,System.Byte[],System.Int32,System.Security.Cryptography.X509Certificates.X509Certificate2&,System.Exception&)
TryPairWithEcdsa(System.Security.Cryptography.X509Certificates.X509Certificate2,System.ReadOnlySpan`1<System.Char>,System.Byte[],System.Int32,System.Security.Cryptography.X509Certificates.X509Certificate2&,System.Exception&)
LoadCertOnlyPem(System.String)
Import(System.String,System.Security.SecureString,System.String,System.Security.Cryptography.X509Certificates.X509KeyStorageFlags)
Import(System.String,System.String,System.Security.Cryptography.X509Certificates.X509KeyStorageFlags)
Import(System.String)
Export(System.Security.Cryptography.X509Certificates.X509Certificate2,System.String,Kestrun.Certificates.CertificateManager/ExportFormat,System.ReadOnlySpan`1<System.Char>,System.Boolean)
NormalizeExportPath(System.String,Kestrun.Certificates.CertificateManager/ExportFormat)
EnsureOutputDirectoryExists(System.String)
.ctor(System.Security.SecureString,System.Char[])
get_Secure()
get_Chars()
Dispose()
CreatePasswordShapes(System.ReadOnlySpan`1<System.Char>)
ExportPfx(System.Security.Cryptography.X509Certificates.X509Certificate2,System.String,System.Security.SecureString)
ExportPem(System.Security.Cryptography.X509Certificates.X509Certificate2,System.String,System.ReadOnlySpan`1<System.Char>,System.Boolean)
WritePrivateKey(System.Security.Cryptography.X509Certificates.X509Certificate2,System.ReadOnlySpan`1<System.Char>,System.String)
Export(System.Security.Cryptography.X509Certificates.X509Certificate2,System.String,Kestrun.Certificates.CertificateManager/ExportFormat,System.Security.SecureString,System.Boolean)
Validate(System.Security.Cryptography.X509Certificates.X509Certificate2,System.Boolean,System.Boolean,System.Boolean,System.Security.Cryptography.OidCollection,System.Boolean)
IsWithinValidityPeriod(System.Security.Cryptography.X509Certificates.X509Certificate2)
BuildChainOk(System.Security.Cryptography.X509Certificates.X509Certificate2,System.Boolean,System.Boolean)
PurposesOk(System.Security.Cryptography.X509Certificates.X509Certificate2,System.Security.Cryptography.OidCollection,System.Boolean)
UsesWeakAlgorithms(System.Security.Cryptography.X509Certificates.X509Certificate2)
GetPurposes(System.Security.Cryptography.X509Certificates.X509Certificate2)
GenRsaKeyPair(System.Int32,Org.BouncyCastle.Security.SecureRandom)
GenEcKeyPair(System.Int32,Org.BouncyCastle.Security.SecureRandom)
ToX509Cert2(Org.BouncyCastle.X509.X509Certificate,Org.BouncyCastle.Crypto.AsymmetricKeyParameter,System.Security.Cryptography.X509Certificates.X509KeyStorageFlags,System.Boolean)