< 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@9d3a582b2d63930269564a7591aa77ef297cadeb
Line coverage
73%
Covered lines: 311
Uncovered lines: 112
Coverable lines: 423
Total lines: 1164
Line coverage: 73.5%
Branch coverage
57%
Covered branches: 103
Total branches: 180
Branch coverage: 57.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100

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%600240%
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);
 466            const string encBegin = "-----BEGIN ENCRYPTED PRIVATE KEY-----";
 467            const string encEnd = "-----END ENCRYPTED PRIVATE KEY-----";
 468
 0469            byte[]? encDer = null;
 0470            for (var attempt = 0; attempt < 5 && encDer is null; attempt++)
 471            {
 0472                var keyPem = File.ReadAllText(privateKeyPath);
 0473                var start = keyPem.IndexOf(encBegin, StringComparison.Ordinal);
 0474                var end = keyPem.IndexOf(encEnd, StringComparison.Ordinal);
 0475                if (start >= 0 && end > start)
 476                {
 0477                    start += encBegin.Length;
 0478                    var b64 = keyPem[start..end].Replace("\r", "").Replace("\n", "").Trim();
 0479                    try { encDer = Convert.FromBase64String(b64); }
 0480                    catch (FormatException fe)
 481                    {
 0482                        Log.Debug(fe, "Base64 decode failed on attempt {Attempt} reading encrypted key; retrying", attem
 0483                    }
 484                }
 0485                if (encDer is null)
 486                {
 0487                    Thread.Sleep(40 * (attempt + 1));
 488                }
 489            }
 490
 0491            if (encDer is null)
 492            {
 0493                Log.Debug("Encrypted PEM manual pairing fallback skipped: markers not found in key file {KeyFile}", priv
 0494                return;
 495            }
 496
 0497            Exception? lastErr = null;
 0498            for (var round = 0; round < 2; round++)
 499            {
 500                // Try RSA
 501                try
 502                {
 0503                    using var rsa = RSA.Create();
 0504                    rsa.ImportEncryptedPkcs8PrivateKey(password, encDer, out _);
 0505                    var withKey = certOnly.CopyWithPrivateKey(rsa);
 0506                    if (withKey.HasPrivateKey)
 507                    {
 0508                        Log.Debug("Encrypted PEM manual pairing succeeded with RSA private key (round {Round}).", round 
 0509                        loaded = withKey;
 0510                        return;
 511                    }
 0512                }
 0513                catch (Exception exRsa)
 514                {
 0515                    lastErr = lastErr is null ? exRsa : new AggregateException(lastErr, exRsa);
 0516                }
 517
 518                // Try ECDSA
 519                try
 520                {
 0521                    using var ecdsa = ECDsa.Create();
 0522                    ecdsa.ImportEncryptedPkcs8PrivateKey(password, encDer, out _);
 0523                    var withKey = certOnly.CopyWithPrivateKey(ecdsa);
 0524                    if (withKey.HasPrivateKey)
 525                    {
 0526                        Log.Debug("Encrypted PEM manual pairing succeeded with ECDSA private key (round {Round}).", roun
 0527                        loaded = withKey;
 0528                        return;
 529                    }
 0530                }
 0531                catch (Exception exEc)
 532                {
 0533                    lastErr = lastErr is null ? exEc : new AggregateException(lastErr, exEc);
 0534                }
 0535                Thread.Sleep(25 * (round + 1));
 536            }
 537
 0538            if (lastErr != null)
 539            {
 0540                Log.Debug(lastErr, "Encrypted PEM manual pairing attempts failed (all rounds); returning original loaded
 541            }
 0542        }
 0543        catch (Exception ex)
 544        {
 0545            Log.Debug(ex, "Encrypted PEM manual pairing fallback failed unexpectedly; returning original loaded certific
 0546        }
 0547    }
 548
 549    /// <summary>
 550    /// Loads a certificate from a PEM file that contains *only* a CERTIFICATE block (no key).
 551    /// </summary>
 552    /// <param name="certPath">The path to the certificate file.</param>
 553    /// <returns>The loaded X509Certificate2 instance.</returns>
 554    private static X509Certificate2 LoadCertOnlyPem(string certPath)
 555    {
 556        // 1) Read + trim the whole PEM text
 1557        var pem = File.ReadAllText(certPath).Trim();
 558
 559        // 2) Define the BEGIN/END markers
 560        const string begin = "-----BEGIN CERTIFICATE-----";
 561        const string end = "-----END CERTIFICATE-----";
 562
 563        // 3) Find their positions
 1564        var start = pem.IndexOf(begin, StringComparison.Ordinal);
 1565        if (start < 0)
 566        {
 0567            throw new InvalidDataException("BEGIN CERTIFICATE marker not found");
 568        }
 569
 1570        start += begin.Length;
 571
 1572        var stop = pem.IndexOf(end, start, StringComparison.Ordinal);
 1573        if (stop < 0)
 574        {
 0575            throw new InvalidDataException("END CERTIFICATE marker not found");
 576        }
 577
 578        // 4) Extract, clean, and decode the Base64 payload
 1579        var b64 = pem[start..stop]
 1580                       .Replace("\r", "")
 1581                       .Replace("\n", "")
 1582                       .Trim();
 1583        var der = Convert.FromBase64String(b64);
 584
 585        // 5) Return the X509Certificate2
 586
 587#if NET9_0_OR_GREATER
 1588        return X509CertificateLoader.LoadCertificate(der);
 589#else
 590        // .NET 8 or earlier path, using X509Certificate2 ctor
 591        // Note: this will not work in .NET 9+ due to the new X509CertificateLoader API
 592        //       which requires a byte array or a file path.
 593        return new X509Certificate2(der);
 594#endif
 595    }
 596
 597    /// <summary>
 598    /// Imports an X509 certificate from the specified file path, using a SecureString password and optional private key
 599    /// </summary>
 600    /// <param name="certPath">The path to the certificate file.</param>
 601    /// <param name="password">The SecureString password for the certificate, if required.</param>
 602    /// <param name="privateKeyPath">The path to the private key file, if separate.</param>
 603    /// <param name="flags">Key storage flags for the imported certificate.</param>
 604    /// <returns>The imported X509Certificate2 instance.</returns>
 605    public static X509Certificate2 Import(
 606       string certPath,
 607       SecureString password,
 608       string? privateKeyPath = null,
 609       X509KeyStorageFlags flags = X509KeyStorageFlags.DefaultKeySet | X509KeyStorageFlags.Exportable)
 610    {
 1611        X509Certificate2? result = null;
 1612        Log.Debug("Importing certificate from {CertPath} with flags {Flags}", certPath, flags);
 613        // ToSecureSpan zero-frees its buffer as soon as this callback returns.
 1614        password.ToSecureSpan(span =>
 1615        {
 1616            // capture the return value of the span-based overload
 1617            result = Import(certPath: certPath, password: span, privateKeyPath: privateKeyPath, flags: flags);
 2618        });
 619
 620        // at this point, unmanaged memory is already zeroed
 1621        return result!;   // non-null because the callback always runs exactly once
 622    }
 623
 624    /// <summary>
 625    /// Imports an X509 certificate from the specified file path, with optional private key file and key storage flags.
 626    /// </summary>
 627    /// <param name="certPath">The path to the certificate file.</param>
 628    /// <param name="privateKeyPath">The path to the private key file, if separate.</param>
 629    /// <param name="flags">Key storage flags for the imported certificate.</param>
 630    /// <returns>The imported X509Certificate2 instance.</returns>
 631    public static X509Certificate2 Import(
 632         string certPath,
 633         string? privateKeyPath = null,
 634         X509KeyStorageFlags flags = X509KeyStorageFlags.DefaultKeySet | X509KeyStorageFlags.Exportable)
 635    {
 636        // ToSecureSpan zero-frees its buffer as soon as this callback returns.
 2637        ReadOnlySpan<char> passwordSpan = default;
 638        // capture the return value of the span-based overload
 2639        var result = Import(certPath: certPath, password: passwordSpan, privateKeyPath: privateKeyPath, flags: flags);
 1640        return result!;
 641    }
 642
 643    /// <summary>
 644    /// Imports an X509 certificate from the specified file path.
 645    /// </summary>
 646    /// <param name="certPath">The path to the certificate file.</param>
 647    /// <returns>The imported X509Certificate2 instance.</returns>
 648    public static X509Certificate2 Import(string certPath)
 649    {
 650        // ToSecureSpan zero-frees its buffer as soon as this callback returns.
 4651        ReadOnlySpan<char> passwordSpan = default;
 652        // capture the return value of the span-based overload
 4653        var result = Import(certPath: certPath, password: passwordSpan);
 2654        return result!;
 655    }
 656
 657
 658
 659    #endregion
 660
 661    #region Export
 662    /// <summary>
 663    /// Exports the specified X509 certificate to a file in the given format, with optional password and private key inc
 664    /// </summary>
 665    /// <param name="cert">The X509Certificate2 to export.</param>
 666    /// <param name="filePath">The file path to export the certificate to.</param>
 667    /// <param name="fmt">The export format (Pfx or Pem).</param>
 668    /// <param name="password">The password to protect the exported certificate or private key, if applicable.</param>
 669    /// <param name="includePrivateKey">Whether to include the private key in the export.</param>
 670    public static void Export(X509Certificate2 cert, string filePath, ExportFormat fmt,
 671           ReadOnlySpan<char> password = default, bool includePrivateKey = false)
 672    {
 673        // Normalize/validate target path and format
 4674        filePath = NormalizeExportPath(filePath, fmt);
 675
 676        // Ensure output directory exists
 4677        EnsureOutputDirectoryExists(filePath);
 678
 679        // Prepare password shapes once
 4680        using var shapes = CreatePasswordShapes(password);
 681
 682        switch (fmt)
 683        {
 684            case ExportFormat.Pfx:
 2685                ExportPfx(cert, filePath, shapes.Secure);
 2686                break;
 687            case ExportFormat.Pem:
 2688                ExportPem(cert, filePath, password, includePrivateKey);
 2689                break;
 690            default:
 0691                throw new NotSupportedException($"Unsupported export format: {fmt}");
 692        }
 4693    }
 694
 695    /// <summary>
 696    /// Normalizes the export file path based on the desired export format.
 697    /// </summary>
 698    /// <param name="filePath">The original file path.</param>
 699    /// <param name="fmt">The desired export format.</param>
 700    /// <returns>The normalized file path.</returns>
 701    private static string NormalizeExportPath(string filePath, ExportFormat fmt)
 702    {
 4703        var fileExtension = Path.GetExtension(filePath).ToLowerInvariant();
 704        switch (fileExtension)
 705        {
 706            case ".pfx":
 2707                if (fmt != ExportFormat.Pfx)
 708                {
 0709                    throw new NotSupportedException(
 0710                            $"File extension '{fileExtension}' for '{filePath}' is not supported for PFX certificates.")
 711                }
 712
 713                break;
 714            case ".pem":
 2715                if (fmt != ExportFormat.Pem)
 716                {
 0717                    throw new NotSupportedException(
 0718                            $"File extension '{fileExtension}' for '{filePath}' is not supported for PEM certificates.")
 719                }
 720
 721                break;
 722            case "":
 723                // no extension, use the format as the extension
 0724                filePath += fmt == ExportFormat.Pfx ? ".pfx" : ".pem";
 0725                break;
 726            default:
 0727                throw new NotSupportedException(
 0728                    $"File extension '{fileExtension}' for '{filePath}' is not supported. Use .pfx or .pem.");
 729        }
 4730        return filePath;
 731    }
 732
 733    /// <summary>
 734    /// Ensures the output directory exists for the specified file path.
 735    /// </summary>
 736    /// <param name="filePath">The file path to check.</param>
 737    private static void EnsureOutputDirectoryExists(string filePath)
 738    {
 4739        var dir = Path.GetDirectoryName(filePath);
 4740        if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
 741        {
 0742            throw new DirectoryNotFoundException(
 0743                    $"Directory '{dir}' does not exist. Cannot export certificate to {filePath}.");
 744        }
 4745    }
 746
 747    /// <summary>
 748    /// Represents the password shapes used for exporting certificates.
 749    /// </summary>
 4750    private sealed class PasswordShapes(SecureString? secure, char[]? chars) : IDisposable
 751    {
 10752        public SecureString? Secure { get; } = secure;
 14753        public char[]? Chars { get; } = chars;
 754
 755        public void Dispose()
 756        {
 757            try
 758            {
 4759                Secure?.Dispose();
 3760            }
 761            finally
 762            {
 4763                if (Chars is not null)
 764                {
 3765                    Array.Clear(Chars, 0, Chars.Length);
 766                }
 4767            }
 4768        }
 769    }
 770
 771    /// <summary>
 772    /// Creates password shapes from the provided password span.
 773    /// </summary>
 774    /// <param name="password">The password span.</param>
 775    /// <returns>The created password shapes.</returns>
 776    private static PasswordShapes CreatePasswordShapes(ReadOnlySpan<char> password)
 777    {
 4778        var secure = password.IsEmpty ? null : SecureStringUtils.ToSecureString(password);
 4779        var chars = password.IsEmpty ? null : password.ToArray();
 4780        return new PasswordShapes(secure, chars);
 781    }
 782
 783    /// <summary>
 784    /// Exports the specified X509 certificate to a file in the given format.
 785    /// </summary>
 786    /// <param name="cert">The X509Certificate2 to export.</param>
 787    /// <param name="filePath">The file path to export the certificate to.</param>
 788    /// <param name="password">The SecureString password to protect the exported certificate.</param>
 789    private static void ExportPfx(X509Certificate2 cert, string filePath, SecureString? password)
 790    {
 2791        var pfx = cert.Export(X509ContentType.Pfx, password);
 2792        File.WriteAllBytes(filePath, pfx);
 2793    }
 794
 795    /// <summary>
 796    /// Exports the specified X509 certificate to a file in the given format.
 797    /// </summary>
 798    /// <param name="cert">The X509Certificate2 to export.</param>
 799    /// <param name="filePath">The file path to export the certificate to.</param>
 800    /// <param name="password">The SecureString password to protect the exported certificate.</param>
 801    /// <param name="includePrivateKey">Whether to include the private key in the export.</param>
 802    private static void ExportPem(X509Certificate2 cert, string filePath, ReadOnlySpan<char> password, bool includePriva
 803    {
 804        // Write certificate first, then dispose writer before optional key append to avoid file locks on Windows
 2805        using (var sw = new StreamWriter(filePath, false, Encoding.ASCII))
 806        {
 2807            new PemWriter(sw).WriteObject(DotNetUtilities.FromX509Certificate(cert));
 2808        }
 809
 2810        if (includePrivateKey)
 811        {
 1812            WritePrivateKey(cert, password, filePath);
 813            // Fallback safeguard: if append was requested but key block missing, try again
 814            try
 815            {
 1816                if (ShouldAppendKeyToPem && !File.ReadAllText(filePath).Contains("PRIVATE KEY", StringComparison.Ordinal
 817                {
 0818                    var baseName = Path.GetFileNameWithoutExtension(filePath);
 0819                    var dir = Path.GetDirectoryName(filePath);
 0820                    var keyFile = string.IsNullOrEmpty(dir) ? baseName + ".key" : Path.Combine(dir!, baseName + ".key");
 0821                    if (File.Exists(keyFile))
 822                    {
 0823                        File.AppendAllText(filePath, Environment.NewLine + File.ReadAllText(keyFile));
 824                    }
 825                }
 1826            }
 0827            catch (Exception ex)
 828            {
 0829                Log.Debug(ex, "Fallback attempt to append private key to PEM failed");
 0830            }
 831        }
 2832    }
 833
 834    /// <summary>
 835    /// Writes the private key of the specified X509 certificate to a file.
 836    /// </summary>
 837    /// <param name="cert">The X509Certificate2 to export.</param>
 838    /// <param name="password">The SecureString password to protect the exported private key.</param>
 839    /// <param name="certFilePath">The file path to export the certificate to.</param>
 840    private static void WritePrivateKey(X509Certificate2 cert, ReadOnlySpan<char> password, string certFilePath)
 841    {
 842        byte[] keyDer;
 843        string pemLabel;
 1844        if (password.IsEmpty)
 845        {
 846            // unencrypted PKCS#8
 0847            keyDer = cert.GetRSAPrivateKey() is RSA rsa
 0848                       ? rsa.ExportPkcs8PrivateKey()
 0849                       : cert.GetECDsaPrivateKey()!.ExportPkcs8PrivateKey();
 0850            pemLabel = "PRIVATE KEY";
 851        }
 852        else
 853        {
 854            // encrypted PKCS#8
 1855            var pbe = new PbeParameters(
 1856                PbeEncryptionAlgorithm.Aes256Cbc,
 1857                HashAlgorithmName.SHA256,
 1858                100_000
 1859            );
 860
 1861            keyDer = cert.GetRSAPrivateKey() is RSA rsaEnc
 1862                       ? rsaEnc.ExportEncryptedPkcs8PrivateKey(password, pbe)
 1863                       : cert.GetECDsaPrivateKey()!.ExportEncryptedPkcs8PrivateKey(password, pbe);
 1864            pemLabel = "ENCRYPTED PRIVATE KEY";
 865        }
 866
 1867        var keyPem = PemEncoding.WriteString(pemLabel, keyDer);
 1868        var certDir = Path.GetDirectoryName(certFilePath);
 1869        var baseName = Path.GetFileNameWithoutExtension(certFilePath);
 1870        var keyFilePath = string.IsNullOrEmpty(certDir)
 1871            ? baseName + ".key"
 1872            : Path.Combine(certDir!, baseName + ".key");
 1873        File.WriteAllText(keyFilePath, keyPem);
 874
 875        try
 876        {
 1877            if (ShouldAppendKeyToPem)
 878            {
 879                // Optional: append the key to the main certificate PEM when explicitly enabled.
 0880                File.AppendAllText(certFilePath, Environment.NewLine + keyPem);
 881            }
 1882        }
 0883        catch (Exception ex)
 884        {
 0885            Log.Debug(ex, "Failed to append private key to certificate PEM file {CertFilePath}; continuing with separate
 0886        }
 1887    }
 888
 889    /// <summary>
 890    /// Exports the specified X509 certificate to a file in the given format, using a SecureString password and optional
 891    /// </summary>
 892    /// <param name="cert">The X509Certificate2 to export.</param>
 893    /// <param name="filePath">The file path to export the certificate to.</param>
 894    /// <param name="fmt">The export format (Pfx or Pem).</param>
 895    /// <param name="password">The SecureString password to protect the exported certificate or private key, if applicab
 896    /// <param name="includePrivateKey">Whether to include the private key in the export.</param>
 897    public static void Export(
 898        X509Certificate2 cert,
 899        string filePath,
 900        ExportFormat fmt,
 901        SecureString password,
 902        bool includePrivateKey = false)
 903    {
 1904        password.ToSecureSpan(span =>
 1905            // this will run your span‐based implementation,
 1906            // then immediately zero & free the unmanaged buffer
 1907            Export(cert, filePath, fmt, span, includePrivateKey)
 1908        );
 1909    }
 910
 911    #endregion
 912
 913    #region  Validation helpers (Test-PodeCertificate equivalent)
 914    /// <summary>
 915    /// Validates the specified X509 certificate according to the provided options.
 916    /// </summary>
 917    /// <param name="cert">The X509Certificate2 to validate.</param>
 918    /// <param name="checkRevocation">Whether to check certificate revocation status.</param>
 919    /// <param name="allowWeakAlgorithms">Whether to allow weak algorithms such as SHA-1 or small key sizes.</param>
 920    /// <param name="denySelfSigned">Whether to deny self-signed certificates.</param>
 921    /// <param name="expectedPurpose">A collection of expected key purposes (EKU) for the certificate.</param>
 922    /// <param name="strictPurpose">If true, the certificate must match the expected purposes exactly.</param>
 923    /// <returns>True if the certificate is valid according to the specified options; otherwise, false.</returns>
 924    public static bool Validate(
 925     X509Certificate2 cert,
 926     bool checkRevocation = false,
 927     bool allowWeakAlgorithms = false,
 928     bool denySelfSigned = false,
 929     OidCollection? expectedPurpose = null,
 930     bool strictPurpose = false)
 931    {
 932        // 1) Validity period
 7933        if (!IsWithinValidityPeriod(cert))
 934        {
 0935            return false;
 936        }
 937
 938        // 2) Self-signed policy
 7939        var isSelfSigned = cert.Subject == cert.Issuer;
 7940        if (denySelfSigned && isSelfSigned)
 941        {
 1942            return false;
 943        }
 944
 945        // 3) Chain build (with optional revocation)
 6946        if (!BuildChainOk(cert, checkRevocation, isSelfSigned))
 947        {
 0948            return false;
 949        }
 950
 951        // 4) EKU / purposes
 6952        if (!PurposesOk(cert, expectedPurpose, strictPurpose))
 953        {
 1954            return false;
 955        }
 956
 957        // 5) Weak algorithms
 5958        if (!allowWeakAlgorithms && UsesWeakAlgorithms(cert))
 959        {
 1960            return false;
 961        }
 962
 4963        return true;   // ✅ everything passed
 964    }
 965
 966    /// <summary>
 967    /// Checks if the certificate is within its validity period.
 968    /// </summary>
 969    /// <param name="cert">The X509Certificate2 to check.</param>
 970    /// <returns>True if the certificate is within its validity period; otherwise, false.</returns>
 971    private static bool IsWithinValidityPeriod(X509Certificate2 cert)
 7972        => DateTime.UtcNow >= cert.NotBefore && DateTime.UtcNow <= cert.NotAfter;
 973
 974    /// <summary>
 975    /// Checks if the certificate chain is valid.
 976    /// </summary>
 977    /// <param name="cert">The X509Certificate2 to check.</param>
 978    /// <param name="checkRevocation">Whether to check certificate revocation status.</param>
 979    /// <param name="isSelfSigned">Whether the certificate is self-signed.</param>
 980    /// <returns>True if the certificate chain is valid; otherwise, false.</returns>
 981    private static bool BuildChainOk(X509Certificate2 cert, bool checkRevocation, bool isSelfSigned)
 982    {
 6983        using var chain = new X509Chain();
 6984        chain.ChainPolicy.RevocationMode = checkRevocation ? X509RevocationMode.Online : X509RevocationMode.NoCheck;
 6985        chain.ChainPolicy.RevocationFlag = X509RevocationFlag.EndCertificateOnly;
 6986        chain.ChainPolicy.DisableCertificateDownloads = !checkRevocation;
 987
 6988        if (isSelfSigned)
 989        {
 6990            chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
 991        }
 992
 6993        return chain.Build(cert);
 6994    }
 995
 996    /// <summary>
 997    /// Checks if the certificate has the expected key purposes (EKU).
 998    /// </summary>
 999    /// <param name="cert">The X509Certificate2 to check.</param>
 1000    /// <param name="expectedPurpose">A collection of expected key purposes (EKU) for the certificate.</param>
 1001    /// <param name="strictPurpose">If true, the certificate must match the expected purposes exactly.</param>
 1002    /// <returns>True if the certificate has the expected purposes; otherwise, false.</returns>
 1003    private static bool PurposesOk(X509Certificate2 cert, OidCollection? expectedPurpose, bool strictPurpose)
 1004    {
 61005        if (expectedPurpose is not { Count: > 0 })
 1006        {
 31007            return true; // nothing to check
 1008        }
 1009
 31010        var eku = cert.Extensions
 31011                       .OfType<X509EnhancedKeyUsageExtension>()
 31012                       .SelectMany(e => e.EnhancedKeyUsages.Cast<Oid>())
 61013                       .Select(o => o.Value)
 31014                       .ToHashSet();
 1015
 31016        var wanted = expectedPurpose.Cast<Oid>()
 41017                                    .Select(o => o.Value)
 31018                                    .ToHashSet();
 1019
 31020        return strictPurpose ? eku.SetEquals(wanted) : wanted.All(eku.Contains);
 1021    }
 1022
 1023    /// <summary>
 1024    /// Checks if the certificate uses weak algorithms.
 1025    /// </summary>
 1026    /// <param name="cert">The X509Certificate2 to check.</param>
 1027    /// <returns>True if the certificate uses weak algorithms; otherwise, false.</returns>
 1028    private static bool UsesWeakAlgorithms(X509Certificate2 cert)
 1029    {
 41030        var isSha1 = cert.SignatureAlgorithm?.FriendlyName?
 41031                           .Contains("sha1", StringComparison.OrdinalIgnoreCase) == true;
 1032
 41033        var weakRsa = cert.GetRSAPublicKey() is { KeySize: < 2048 };
 41034        var weakDsa = cert.GetDSAPublicKey() is { KeySize: < 2048 };
 41035        var weakEcdsa = cert.GetECDsaPublicKey() is { KeySize: < 256 };  // P-256 minimum
 1036
 41037        return isSha1 || weakRsa || weakDsa || weakEcdsa;
 1038    }
 1039
 1040
 1041    /// <summary>
 1042    /// Gets the enhanced key usage purposes (EKU) from the specified X509 certificate.
 1043    /// </summary>
 1044    /// <param name="cert">The X509Certificate2 to extract purposes from.</param>
 1045    /// <returns>An enumerable of purpose names or OID values.</returns>
 1046    public static IEnumerable<string> GetPurposes(X509Certificate2 cert) =>
 11047        cert.Extensions
 11048            .OfType<X509EnhancedKeyUsageExtension>()
 11049            .SelectMany(x => x.EnhancedKeyUsages.Cast<Oid>())
 21050            .Select(o => (o.FriendlyName ?? o.Value)!)   // ← null-forgiving
 31051            .Where(s => s.Length > 0);                   // optional: drop empties
 1052    #endregion
 1053
 1054    #region  private helpers
 1055    private static AsymmetricCipherKeyPair GenRsaKeyPair(int bits, SecureRandom rng)
 1056    {
 131057        var gen = new RsaKeyPairGenerator();
 131058        gen.Init(new KeyGenerationParameters(rng, bits));
 131059        return gen.GenerateKeyPair();
 1060    }
 1061
 1062    /// <summary>
 1063    /// Generates an EC key pair.
 1064    /// </summary>
 1065    /// <param name="bits">The key size in bits.</param>
 1066    /// <param name="rng">The secure random number generator.</param>
 1067    /// <returns>The generated EC key pair.</returns>
 1068    private static AsymmetricCipherKeyPair GenEcKeyPair(int bits, SecureRandom rng)
 1069    {
 1070        // NIST-style names are fine here
 11071        var name = bits switch
 11072        {
 11073            <= 256 => "P-256",
 01074            <= 384 => "P-384",
 01075            _ => "P-521"
 11076        };
 1077
 1078        // ECNamedCurveTable knows about SEC *and* NIST names
 11079        var ecParams = ECNamedCurveTable.GetByName(name)
 11080                       ?? throw new InvalidOperationException($"Curve not found: {name}");
 1081
 11082        var domain = new ECDomainParameters(
 11083            ecParams.Curve, ecParams.G, ecParams.N, ecParams.H, ecParams.GetSeed());
 1084
 11085        var gen = new ECKeyPairGenerator();
 11086        gen.Init(new ECKeyGenerationParameters(domain, rng));
 11087        return gen.GenerateKeyPair();
 1088    }
 1089
 1090    /// <summary>
 1091    /// Converts a BouncyCastle X509Certificate to a .NET X509Certificate2.
 1092    /// </summary>
 1093    /// <param name="cert">The BouncyCastle X509Certificate to convert.</param>
 1094    /// <param name="privKey">The private key associated with the certificate.</param>
 1095    /// <param name="flags">The key storage flags to use.</param>
 1096    /// <param name="ephemeral">Whether the key is ephemeral.</param>
 1097    /// <returns></returns>
 1098    private static X509Certificate2 ToX509Cert2(
 1099        Org.BouncyCastle.X509.X509Certificate cert,
 1100        AsymmetricKeyParameter privKey,
 1101        X509KeyStorageFlags flags,
 1102        bool ephemeral)
 1103    {
 121104        var store = new Pkcs12StoreBuilder().Build();
 121105        var entry = new X509CertificateEntry(cert);
 1106        const string alias = "cert";
 121107        store.SetCertificateEntry(alias, entry);
 121108        store.SetKeyEntry(alias, new AsymmetricKeyEntry(privKey),
 121109                          [entry]);
 1110
 121111        using var ms = new MemoryStream();
 121112        store.Save(ms, [], new SecureRandom());
 121113        var raw = ms.ToArray();
 1114
 1115#if NET9_0_OR_GREATER
 1116        try
 1117        {
 121118            return X509CertificateLoader.LoadPkcs12(
 121119                raw,
 121120                password: default,
 121121                keyStorageFlags: flags | (ephemeral ? X509KeyStorageFlags.EphemeralKeySet : 0),
 121122                loaderLimits: Pkcs12LoaderLimits.Defaults
 121123            );
 1124        }
 01125        catch (PlatformNotSupportedException) when (ephemeral)
 1126        {
 1127            // Some platforms (e.g. certain Linux/macOS runners) don't yet support
 1128            // EphemeralKeySet with the new X509CertificateLoader API. In that case
 1129            // we fall back to re-loading without the EphemeralKeySet flag. The
 1130            // intent of Ephemeral in our API is simply "do not persist in a store" –
 1131            // loading without the flag here still keeps the cert in-memory only.
 01132            Log.Debug("EphemeralKeySet not supported on this platform for X509CertificateLoader; falling back without th
 01133            return X509CertificateLoader.LoadPkcs12(
 01134                raw,
 01135                password: default,
 01136                keyStorageFlags: flags, // omit EphemeralKeySet
 01137                loaderLimits: Pkcs12LoaderLimits.Defaults
 01138            );
 1139        }
 1140#else
 1141        try
 1142        {
 1143            return new X509Certificate2(
 1144                raw,
 1145                (string?)null,
 1146                flags | (ephemeral ? X509KeyStorageFlags.EphemeralKeySet : 0)
 1147            );
 1148        }
 1149        catch (PlatformNotSupportedException) when (ephemeral)
 1150        {
 1151            // macOS (and some Linux distros) under net8 may not support EphemeralKeySet here.
 1152            Log.Debug("EphemeralKeySet not supported on this platform (net8); falling back without the flag.");
 1153            return new X509Certificate2(
 1154                raw,
 1155                (string?)null,
 1156                flags // omit EphemeralKeySet
 1157            );
 1158        }
 1159
 1160#endif
 121161    }
 1162
 1163    #endregion
 1164}

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&)
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)