Skip to content

Repository files navigation

📖 FluentCertificates Overview

⚠️ Note: while version numbers are v0.x.y, this software is under initial development and there may be breaking changes in its API between minor versions. ⚠️

NuGet Build & Publish GitHub license

FluentCertificates is a library for creating, finding, and exporting certificates, built around an immutable fluent builder pattern. Use it to generate your own certificate chains, or just stand-alone self-signed certificates.

NuGet packages

This project is published in several NuGet packages:

Documentation is incomplete. More examples can be found in the project's unit tests.

CertificateBuilder examples

CertificateBuilder requires the FluentCertificates.Builder package and is found under the FluentCertificates namespace.

Minimum example

The absolute minimum needed to create a certificate, whether it's useful or not.

using var cert = new CertificateBuilder().SetSubject("CN=Example").Create();

A name is the one thing you have to supply. A certificate with an empty subject and no Subject Alternative Name identifies nobody, which RFC 5280 s4.2.1.6 forbids, so the builder refuses it. Naming the certificate through SetSubjectAlternativeNames instead satisfies the rule just as well.

Create a certificate signing request

For signing, exporting and passing to a 3rd party CA.

//A public & private keypair must be created first, outside of the CertificateBuilder, otherwise you'd have no way to retrieve the private-key used for the new CertificateSigningRequest object
using var keys = RSA.Create();

//Creating a CertificateSigningRequest
var csr = new CertificateBuilder()
    .SetUsage(CertificateUsage.Server)
    .SetSubject(b => b.SetCommonName("*.fake.domain"))
    .SetSubjectAlternativeNames(x => x.AddDnsNames("*.fake.domain", "fake.domain"))
    .SetKeyPair(keys)
    .CreateCertificateSigningRequest();

//The CertificateRequest object is accessible here:
var certRequest = csr.CertificateRequest;

//CSR can be exported to a string
Console.WriteLine(csr.ToPemString());

//Or to a file or StringWriter instance
csr.ExportAsPem("csr.pem");

Issue a certificate from a received CSR

For acting as the CA at the other end of that exchange.

UseCertificateSigningRequest takes the subject name and the public key out of the request and nothing else. The issuer, validity and usage profile stay yours to decide, so one configured builder can issue from many requests. The requester keeps the private key, so the certificate comes back without one.

//Note: the 'issuer' certificate used must have a private-key attached in order to sign the new certificate
var csr = CertificateSigningRequest.FromPem(File.ReadAllText("csr.pem"));

using var issued = new CertificateBuilder()
    .SetUsage(CertificateUsage.Server)
    .SetIssuer(issuer)
    .UseCertificateSigningRequest(csr)
    .SetSubjectAlternativeNames(x => x.AddDnsName("approved.fake.domain"))
    .SetValidity(TimeSpan.FromDays(90))
    .Create();

A requester should not get to dictate their own subject alternative names, extended key usages or basic constraints unchallenged, so anything the request asked for is discarded unless you say otherwise. To honour some of it, pass a predicate deciding one extension at a time:

//Requested extensions are only readable at all when you ask for them at parse time
var csr = CertificateSigningRequest.FromPem(
    File.ReadAllText("csr.pem"),
    CertificateRequestLoadOptions.UnsafeLoadCertificateExtensions);

using var issued = new CertificateBuilder()
    .SetUsage(CertificateUsage.Server)
    .SetIssuer(issuer)
    .UseCertificateSigningRequest(csr, x => x.Oid?.Value == Oids.SubjectAltName)
    .SetValidity(TimeSpan.FromDays(90))
    .Create();

An accepted extension is applied as though you had added it yourself, so it replaces anything already present under the same OID and overrides what the usage profile would otherwise have generated. The last call still wins after that: AddExtension, SetCertificatePolicies and SetSubjectAlternativeNames each displace an accepted extension under their own OID, so you can accept the requester's names and then pin the ones you actually verified. SetUsage does the same for the basic constraints, key usage and extended key usage its profile generates, SetPathLength for basic constraints under CertificateUsage.CA, and SetKeyPair, SetPublicKey and SetKeyAlgorithm for the Subject Key Identifier, so setting any of them after accepting hands that OID back. The key generated for you when you name no key is the one exception: it leaves a Subject Key Identifier you added alone. An accepted extension also stays on the builder that call returns, so issue each further request from your configured builder rather than from the result of the previous one, or the next requester inherits the last one's extensions.

FromPem and FromDer verify the request's signature, which is how a PKCS#10 request proves the requester holds the private key. Passing CertificateRequestLoadOptions.SkipSignatureValidation gives that up.

Build a self-signed web server certificate

Using the fluent style:

using var webCert = new CertificateBuilder()
    .SetFriendlyName("Example self-signed web-server certificate")
    .SetUsage(CertificateUsage.Server)
    .SetSubject(b => b.SetCommonName("*.fake.domain"))
    .SetSubjectAlternativeNames(x => x.AddDnsNames("*.fake.domain", "fake.domain"))
    .SetNotAfter(DateTimeOffset.UtcNow.AddMonths(1))
    .Create();

Or alternatively using object initializers (other examples will use fluent style from now on though):

var builder = new CertificateBuilder() {
    FriendlyName = "Example self-signed web-server certificate",
    Usage = CertificateUsage.Server,
    Subject = new X500NameBuilder().SetCommonName("*.fake.domain"),
    NotAfter = DateTimeOffset.UtcNow.AddMonths(1)
};
using var webCert = builder
    .SetSubjectAlternativeNames(x => x.AddDnsNames("*.fake.domain", "fake.domain"))
    .Create();

SubjectAlternativeNames, Extensions and KeyAlgorithm have no initializer, because setting each of them also clears something else and that only happens through the method. Every other property can be set either way, and the two that discard extensions do so whichever route you take: Usage drops the ones its profile generates, and PathLength drops a basic constraints extension under CertificateUsage.CA.

Build a certificate authority (CA)

//A CA's expiry date must be later than that of any certificates it will issue
using var issuer = new CertificateBuilder()
    .SetFriendlyName("Example root CA")
    .SetUsage(CertificateUsage.CA)
    .SetSubject(b => b.SetCommonName("Example root CA"))
    .SetNotAfter(DateTimeOffset.UtcNow.AddYears(100))
    .Create();

Build a client-auth certificate signed by a CA

//Note: the 'issuer' certificate used must have a private-key attached in order to sign this new certificate
using var clientAuthCert = new CertificateBuilder()
    .SetFriendlyName("Example client-auth certificate")
    .SetUsage(CertificateUsage.Client)
    .SetSubject(b => b.SetCommonName("User: Michael"))
    .SetNotAfter(DateTimeOffset.UtcNow.AddYears(1))
    .SetIssuer(issuer)
    .Create();

Set a validity period from a duration

SetValidity sets NotBefore and NotAfter together. The single-argument overload starts at the current time; note that it does not backdate the start, so use the two-argument overload if you need to tolerate clock skew on the verifying machine.

using var cert = new CertificateBuilder()
    .SetUsage(CertificateUsage.Server)
    .SetSubject(b => b.SetCommonName("*.fake.domain"))
    .SetValidity(TimeSpan.FromDays(90))
    .Create();

//Backdated by 5 minutes to allow for clock skew
using var skewTolerant = new CertificateBuilder()
    .SetUsage(CertificateUsage.Server)
    .SetSubject(b => b.SetCommonName("*.fake.domain"))
    .SetValidity(DateTimeOffset.UtcNow.AddMinutes(-5), TimeSpan.FromDays(90))
    .Create();

Choose a key algorithm

A KeyAlgorithm carries its own parameters: a key length for RSA and DSA, a curve for the elliptic-curve algorithms, a parameter set for the post-quantum ones. There is no separate KeyLength or ECCurve to set alongside it, so a curve can never be paired with RSA and a key length can never be paired with ECDsa. Defaults are RSA-4096, DSA-1024 and nistP256.

using var rsa = new CertificateBuilder()
    .SetKeyAlgorithm(KeyAlgorithm.RSA(2048))
    .SetSubject(b => b.SetCommonName("Example RSA-2048 certificate"))
    .Create();

using var cert = new CertificateBuilder()
    .SetKeyAlgorithm(KeyAlgorithm.ECDsa(ECCurve.NamedCurves.nistP384))
    .SetSubject(b => b.SetCommonName("Example P-384 certificate"))
    .Create();

A key supplied through SetKeyPair already carries its own parameters and takes precedence over anything set here.

Build an OCSP responder, time-stamping or CRL signing certificate

using var ocspResponder = new CertificateBuilder()
    .SetUsage(CertificateUsage.OcspSigning)
    .SetSubject(b => b.SetCommonName("Example OCSP responder"))
    .SetIssuer(issuer)
    .Create();

//RFC 3161 requires a TSA certificate's extended key usage to be critical, which the builder does
using var timeStampingAuthority = new CertificateBuilder()
    .SetUsage(CertificateUsage.TimeStamping)
    .SetSubject(b => b.SetCommonName("Example TSA"))
    .SetIssuer(issuer)
    .Create();

//RFC 5280 defines no extended key usage for CRL signing, so this profile emits none and asserts cRLSign
//alone. An indirect CRL issuer is conventionally issued under the authority's own name.
using var crlIssuer = new CertificateBuilder()
    .SetUsage(CertificateUsage.CrlSigning)
    .SetSubject(issuer.SubjectName)
    .SetIssuer(issuer)
    .Create();

Build a key agreement (ECDH) certificate

An ECDH key derives a shared secret and cannot sign anything, so these certificates assert keyAgreement rather than digitalSignature and must be issued by a CA. Self-signing, CSRs, and the CA, CodeSign, OcspSigning and TimeStamping usages are all rejected. Supplying a SignatureGenerator does not lift those restrictions, since it signs with an unrelated key.

using var ecdhCert = new CertificateBuilder()
    .SetUsage(CertificateUsage.SMime)
    .SetSubject(b => b.SetCommonName("user@fake.domain"))
    .SetKeyAlgorithm(KeyAlgorithm.ECDiffieHellman(ECCurve.NamedCurves.nistP384))
    .SetIssuer(issuer)
    .Create();

using var privateKey = ecdhCert.GetECDiffieHellmanPrivateKey();

An ECDH public key is indistinguishable from an ECDsa one inside a certificate: same algorithm OID, same curve parameters. The builder therefore takes the distinction from SetKeyAlgorithm, or from the runtime type of a key passed to SetKeyPair. If you use SetPublicKey for an ECDH key held elsewhere, call SetKeyAlgorithm(KeyAlgorithm.ECDiffieHellman()) first, or the key will be treated as ECDsa.

Build a post-quantum certificate

⚠️ Experimental. The post-quantum surface is marked [Experimental("FLUENTCERT001")] and may change. Suppress it per call site with #pragma warning disable FLUENTCERT001, or project-wide with <NoWarn>$(NoWarn);FLUENTCERT001</NoWarn>. The .NET types underneath are themselves experimental under SYSLIB5006, so any code naming one already has to suppress that; this library adds its own ID rather than implying only Microsoft's half is unsettled.

Requires .NET 10 at runtime. ML-DSA (FIPS 204), SLH-DSA (FIPS 205), Composite ML-DSA and ML-KEM (FIPS 203) each expose their parameter sets as KeyAlgorithm members.

#pragma warning disable FLUENTCERT001

using var cert = new CertificateBuilder()
    .SetKeyAlgorithm(KeyAlgorithm.MLDsa65)
    .SetSubject(b => b.SetCommonName("Example ML-DSA certificate"))
    .Create();

Availability depends on the platform's cryptographic provider at runtime, so test for it rather than inferring it from the operating system:

if (KeyAlgorithm.SlhDsaSha2_128f.IsSupported) {
    //...
}

IsSupported reports whether a certificate can actually be built, not merely whether a key can be generated. The two come apart in practice. As of .NET 10:

Algorithm Windows Linux, OpenSSL 3.5+ Linux, OpenSSL 3.0
ML-DSA ✅ ✅ ❌
SLH-DSA ❌ ✅ ❌
ML-KEM ❌ (key cannot be attached to a certificate) ✅ ❌
Composite ML-DSA ❌ (no platform can sign a certificate with one) ❌ ❌

On Linux what decides it is the OpenSSL version, not the distribution. OpenSSL 3.5+ supports these algorithms and 3.0 supports none of them, so Ubuntu 26.04, Debian 13 and Alpine 3.22+ work while Ubuntu 24.04 does not. Selecting an unsupported algorithm throws PlatformNotSupportedException from Create() rather than producing a certificate that does not work.

The members exist on every target framework so the API surface does not vary; on .NET 8 and .NET 9 selecting one throws.

ML-KEM is key encapsulation, not signing. Like ECDiffieHellman, an ML-KEM certificate must be issued by a CA, cannot self-sign, cannot be a CA or a code-signing, OCSP-signing or time-stamping certificate, and has no CSR. It asserts keyEncipherment, not keyAgreement: encapsulating to the certified key is key transport rather than Diffie-Hellman agreement.

Advanced: signing with a key held in an HSM, TPM or cloud KMS

When the private key can't leave the device, supply the public key to certify with SetPublicKey and an X509SignatureGenerator to do the signing with SetSignatureGenerator. The builder never needs the private key, and the certificate it returns has none attached.

//Your implementation, calling out to the HSM/TPM/KMS to sign
var remoteSigner = new MyRemoteSignatureGenerator(keyId);

//Issuing from a CA whose key is remote: the issuer certificate needs no private key
using var issuedCert = new CertificateBuilder()
    .SetUsage(CertificateUsage.Server)
    .SetSubject(b => b.SetCommonName("*.fake.domain"))
    .SetIssuer(caCertWithoutPrivateKey)
    .SetSignatureGenerator(remoteSigner)
    .Create();

//Self-signing a root whose key is remote: supply both halves of that key
using var rootCert = new CertificateBuilder()
    .SetUsage(CertificateUsage.CA)
    .SetSubject(b => b.SetCommonName("Example HSM-backed root CA"))
    .SetPublicKey(remotePublicKey)
    .SetSignatureGenerator(remoteSigner)
    .Create();

Nothing checks that the generator matches the public key you supplied; that pairing is yours to get right. What is checked is that you supply both when self-signing, since either one alone produces a certificate that cannot verify.

Advanced: certificate with customized extensions

using var customCert = new CertificateBuilder()
    .SetFriendlyName("Example certificate with customized extensions")
    .SetSubject(b => b.SetCommonName("Example certificate with customized extensions"))
    .AddExtension(new X509BasicConstraintsExtension(false, false, 0, true))
    .AddExtension(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment | X509KeyUsageFlags.DataEncipherment, true))
    .AddExtension(new X509EnhancedKeyUsageExtension(new OidCollection { new Oid(Oids.AnyExtendedKeyUsage) }, false))
    .SetIssuer(issuer)
    .Create();

Advanced: certificates with name constraints, revocation and policy information

//Permit the CA cert to issue certificates for specific names and IP addresses
var permittedNames = new GeneralNameListBuilder()
    .AddDnsName(".mydomain.local")
    .AddEmailAddress("@mydomain.local")
    .AddIPAddress(ipAddress: "192.168.0.0", subnetMask: "255.255.255.0")
    .Create();

using var issuer = new CertificateBuilder()
    .SetFriendlyName("Example constrained root CA")
    .SetUsage(CertificateUsage.CA)
    .SetSubject(b => b.SetCommonName("Example constrained root CA"))
    .SetNotAfter(DateTimeOffset.UtcNow.AddMonths(1))
    .SetPathLength(1)
    .AddExtension(new X509NameConstraintExtension(permittedNames, null))
    .Create();

using var webCert = new CertificateBuilder()
    .SetFriendlyName("Example certificate with revocation and policy information")
    .SetUsage(CertificateUsage.Server)
    .SetIssuer(issuer)
    .SetSubject(b => b.SetCommonName("*.mydomain.local"))
    .SetSubjectAlternativeNames(x => x.AddDnsName("*.mydomain.local"))
    //Where to check this certificate's revocation status, and where to fetch the issuer
    .SetAuthorityInformationAccess(
        ocspUri: "http://ocsp.mydomain.local/",
        caIssuersUri: "http://pki.mydomain.local/issuer.cer")
    //Where the issuer publishes its revocation lists
    .SetCrlDistributionPoints("http://crl.mydomain.local/root.crl")
    //The policies this certificate is issued under
    .SetCertificatePolicies(new Oid(Oids.DomainValidatedCertPolicy))
    .Create();

SetAuthorityInformationAccess also takes collections, for more than one OCSP responder or CA Issuers location. SetCertificatePolicies takes Oids, either as params or a collection; a raw OID string is also accepted, as a single value or a collection, for callers who would rather not construct an Oid. Oids has the CA/Browser Forum's other baseline-requirements and EV policy identifiers: OrganizationValidatedCertPolicy, IndividualValidatedCertPolicy, ExtendedValidationCertPolicy, ExtendedValidationCodeSigningCertPolicy and CodeSigningRequirementsCertPolicy. Each of the three helpers replaces any earlier value rather than adding a second extension under the same OID.

All three extensions are non-critical by default, but the specifications back that differently for each. Authority Information Access has no critical option at all: RFC 5280 s4.2.2.1 says it MUST be non-critical. CRL Distribution Points only SHOULD be non-critical under RFC 5280 s4.2.1.13; the CA/Browser Forum Baseline Requirements certificate profiles (s7.1.2) go further and require it. Certificate Policies criticality is neither required nor recommended either way by RFC 5280, which only says what a validator must do when the extension is critical, but the same Baseline Requirements profiles require it non-critical too. SetCrlDistributionPoints and SetCertificatePolicies both accept critical: true for a profile that needs otherwise, alongside a collection rather than params.

Criticality conformance

RFC 5280 states hard criticality rules for several extensions, and each is about the flag beside the extension rather than the value inside it. So an extension breaking one is written with the flag the RFC requires, and its value goes out exactly as supplied:

Extension Required Rule
Authority Key Identifier non-critical s4.2.1.1
Subject Key Identifier non-critical s4.2.1.2
Subject Directory Attributes non-critical s4.2.1.8
Freshest CRL non-critical s4.2.1.15
Authority Information Access non-critical s4.2.2.1
Subject Information Access non-critical s4.2.2.2
Name Constraints critical s4.2.1.10
Policy Constraints critical s4.2.1.11
Inhibit anyPolicy critical s4.2.1.14
Basic Constraints, cA=TRUE with keyCertSign critical s4.2.1.9
Subject Alternative Name, empty subject critical s4.2.1.6

This matters most for UseCertificateSigningRequest, where the extension came from the requester rather than from you. An accept predicate that whitelists by OID alone would otherwise issue whatever criticality was asked for:

using var issued = new CertificateBuilder()
    .SetIssuer(ca)
    //The request asks for a critical Authority Information Access, which RFC 5280 s4.2.2.1 forbids
    .UseCertificateSigningRequest(csr, ext => ext.Oid?.Value == Oids.AuthorityInformationAccess)
    .Create();

//...but it is issued non-critical, with the OCSP and CA Issuers URIs the request named
Console.WriteLine(issued.Extensions
    .First(x => x.Oid?.Value == Oids.AuthorityInformationAccess)
    .Critical); //False

Every rule above keys off the extension's OID alone, except the last two. Basic Constraints is corrected only when its value decodes and says cA=TRUE and the certificate's key may validate signatures on certificates, since s4.2.1.9 attaches its requirement to that condition and leaves the choice open otherwise: a CA certificate whose key signs only revocation lists keeps whatever flag you gave it. A key usage extension that reads back and omits keyCertSign is the only thing that settles this, so a certificate with no key usage at all, or one this library cannot read, is treated as able to sign certificates. A Basic Constraints value that will not decode goes out with the flag as supplied. Subject Alternative Name is corrected only when the subject name is empty.

What this library is responsible for

FluentCertificates builds the certificate you describe. It is not a certificate authority, and it does not own your issuance policy.

Everything it consumes is yours except a signing request. When you configure a builder you are the trust authority for what you are making, and you could produce the same certificate from CertificateRequest directly. A signing request is the one input that comes from somebody else, and it contributes a subject name, a public key, and whichever extensions you explicitly accepted.

So the library takes responsibility for encoding faithfully what you asked for, for correcting criticality where RFC 5280 requires it, for never letting a request quietly replace something you set yourself, and for refusing a certificate that contradicts the Usage you stated.

It does not decide whether you should issue. Whether a requester is entitled to a name, which extensions your policy permits, what values those may carry, and what your CA may certify are all yours. A certificate this library agrees to build is not thereby safe to trust, because no such property exists independently of the policy you issue under.

What the builder refuses

Criticality is a flag beside an extension, so a violation can be corrected. Other things cannot be corrected without deciding what the caller meant, and those are refused. They all throw an InvalidOperationException. The list is short, and follows the boundary above: a value contradicting the Usage you stated about whether this is a certificate authority, a signing request signed by a key other than the one it certifies, and the narrow case of a value this builder cannot read, since it can neither correct nor vouch for that. Everything else is your policy to set:

  • Basic Constraints disagreeing with the profile about whether this is a certificate authority. A requester slipping cA=TRUE past a permissive accept predicate on an end-entity profile walks away able to issue certificates for anyone, and correcting the criticality does not stop that: a validator honours cA=TRUE whichever way the flag is set. The mirror case, cA=FALSE on CertificateUsage.CA, strips the authority you asked for.
  • Key Usage asserting keyCertSign under an end-entity profile, or not asserting it under CertificateUsage.CA. keyCertSign is what makes a certificate able to mint others. cRLSign is left alone, since an indirect CRL issuer is conventionally an end-entity certificate asserting exactly that; CertificateUsage.CrlSigning is the profile for one.
  • A Usage profile whose certificate exists to sign, on a key that cannot sign. CertificateUsage.CA, CodeSign, OcspSigning, TimeStamping and CrlSigning all assert a key usage describing an operation an ML-KEM or ECDH key can never perform, and RFC 9935 s5 permits an ML-KEM certificate no key usage but keyEncipherment. Substituting that bit instead would hand you a certificate you did not ask for, so this refuses. It is checked on the request path as well as on Validate, since CreateCertificateRequest hands back something you can sign yourself.
  • Either of those two extensions carrying a value this builder cannot read. The value has to decode, and it has to be a single encoded value with nothing after it. Bytes past the end are what one reader skips and another reads: an empty SEQUENCE followed by a stray cA=TRUE reads here as cA=FALSE, agreeing with an end-entity profile, while OpenSSL and Windows CryptoAPI read the well-formed part and honour the authority. Issuing such a value would let a requester assert to a validator the very thing the check above failed to see. How the value is spelled is not asked about: a cA written out as FALSE rather than omitted at its DEFAULT is not canonical DER, but real certificates carry it and every reader takes it for FALSE. What counts as readable is the framework's answer, not this library's, and on .NET 8 and 9 basic constraints are decoded through the platform: a pathLenConstraint larger than an Int32 conforms to RFC 5280, and those two frameworks refuse it on Windows while reading it as 0 on Linux. Only cA is consulted and the value is written out as you supplied it, so nothing here turns on the path length.
  • A signing request whose SignatureGenerator holds a key other than the one it certifies. A PKCS#10 request is signed to prove the requester holds the private key for the public key being certified, so CreateCertificateSigningRequest refuses one signed by any other key: such a signature proves nothing. A generator over the key it certifies, which is how an unexportable key signs its own request, is fine. This is the one place a mismatched signing key is refused. On the certificate path it is not, because there every key is yours to choose and the hazard is one of naming, described below.
  • An Authority Key Identifier naming a key other than the Issuer's, or carrying no readable key identifier at all. This one turns on Issuer rather than Usage: RFC 5280 s4.2.1.2 makes the issuer's subject key identifier the value that MUST appear there, so naming an issuer settles what belongs in it and anything else contradicts the certificate's own account of who signed it. Detailed below.
  • A certificate with an empty subject and no Subject Alternative Name. RFC 5280 s4.2.1.6 requires that extension of a certificate whose subject is empty, because it is then the only name the certificate has, and one without either identifies nobody at all. This is the only refusal on the list settled from the builder's own configuration rather than from an extension's value, so it comes from Validate alongside the other configuration checks. A signing request is exempt: CreateCertificateSigningRequest does not call Validate, and leaving your name to the authority is a normal thing to ask of one.
  • A Subject Alternative Name extension carrying no entries, or one this builder cannot read. RFC 5280 s4.2.1.6 requires at least one entry when the extension is present; an empty one identifies nobody, and the criticality rule above would still mark it critical over an empty subject, asserting to every validator that a value carrying no names must be understood and honoured. Unlike the empty-subject refusal, this one has nothing to do with the subject, so it applies to a signing request too. Bytes past the SAN's own encoded value are refused the same way an unreadable basic constraints or key usage value is: read the extent, not the spelling.

Set a Usage before accepting anything from a request. A builder with no Usage has declared no intent to measure an extension against, and makes none of these refusals bar the Authority Key Identifier one. A request accepted onto such a builder can carry cA=TRUE and keyCertSign, and the certificate issued from it will sign other certificates that chain to your issuer.

Almost nothing else in a request is screened. What an extension says is the accept predicate's decision, and every other field crosses over as the requester wrote it — the subject name included. Nothing here asks whether a requester is entitled to the name it wants, so call SetSubject afterwards if your CA issues only under names it has verified. RFC 5280 s6.3.3 accepts a revocation list from any certificate whose subject matches the target's issuer and whose key usage asserts cRLSign, without requiring cA=TRUE, so a leaf you issue under your own CA's name can revoke everything that CA ever issued; both OpenSSL and Java PKIX honour that. Comparing a requested name against your own is a judgement about how a relying party will read it, which depends on the validator and the Unicode tables it carries, so it stays with you.

The key behind the name is yours to choose in the same way. Build a certificate under a real CA's name but signed with an unrelated key, whether by leaving Issuer unset so it is self-issued or by naming that CA as Issuer while signing with something else, and a validator doing that name match may read it as the CA's own successor and build a path for it, cA=FALSE notwithstanding: Java's CertPathBuilder will select such a certificate as a CRL issuer and report a third party revoked. RFC 5280 defines exactly this shape, a self-issued certificate that is not self-signed, and uses it for CA key rollover, so the builder cannot tell the abuse from the legitimate use and encodes what you asked for. Every key in that certificate is one you supplied, so whether to build it is your call, not the library's. The distinct case where the request itself is signed by the wrong key, which no rollover explains, is the proof-of-possession refusal above.

The one exception is an Authority Key Identifier. It names whoever signs the certificate, which the requester cannot know beforehand, and unlike everything else here there is exactly one right answer that the builder already knows. RFC 5280 s4.2.1.2 states it as a MUST: the issuer's subject key identifier is the value that belongs in the key identifier field of the certificates it issues. So an Authority Key Identifier that names some other key is refused at issuance, whether it arrived through AddExtension, a Set* helper or an accepted request, and regardless of whether SetIssuer was called before or after it. This is the same shape as the Usage refusals: setting an issuer declares who signs, and an identifier naming a different key contradicts it.

Only the key identifier field is compared, so an extension also carrying authorityCertIssuer and authorityCertSerialNumber, which s4.2.1.1 permits alongside it, is not refused for carrying them. An extension with no readable key identifier at all is refused too, because s4.2.1.1 requires that field in every certificate a conforming CA generates, and a supplied one displaces the extension the builder would have written, leaving the certificate naming no signing key. The check needs an issuer to compare against, so it is skipped where none is set: to build a certificate whose Authority Key Identifier deliberately names something else, leave Issuer unset and sign it with SetSignatureGenerator, or use CertificateRequest directly.

Where an issuer publishes no subject key identifier of its own, the value is derived from its public key rather than substituted with its name and serial number, both for the extension the builder writes and for the one it compares a request against. That follows s4.2.1.1's own advice that the key identifier "SHOULD be derived from the public key used to verify the certificate's signature".

A requested Subject Key Identifier is not checked, which is worth saying because the symmetry invites the assumption that it is. It labels the requester's own key, so the requester knows the right answer. RFC 5280 s4.2.1.2 only recommends deriving that label from the key: it describes two common derivations, the full SHA-1 hash and a truncated 8-byte form, and then allows that other methods of generating unique numbers are acceptable too. A label need not be a function of the key at all, so no comparison distinguishes a conforming one from a careless one, and refusing on a mismatch would assert a rule that section does not state. Which labels you honour is your policy, applied through the accept predicate.

Extensions on the builder keeps reporting whatever it was handed.


Key ownership and disposal

X509Certificate2, every AsymmetricAlgorithm and every CertificateKey are disposable. Three rules cover who releases what:

  • Keys the builder generates are disposed by the builder, as soon as Create() no longer needs them. You never see them.
  • Keys you supply, through SetKeyPair or SetPublicKey, are yours. The builder never disposes them, so the same key can be reused across as many certificates as you like.
  • Keys you extract from a certificate, through GetPrivateKey() or .NET's own GetRSAPrivateKey() and friends, are yours to dispose. Each call hands back a new instance, so calling it in a loop without a using leaks one handle per iteration.

Disposing an extracted key doesn't affect the certificate it came from, or any other instance obtained from it, so the certificate stays usable and can be asked for its key again.

//The certificate and the extracted key are separate disposables
using var cert = new CertificateBuilder().SetSubject(b => b.SetCommonName("Example")).Create();
using var key = cert.GetPrivateKey();

Certificates the library returns to you are always yours. Nothing in CertificateFinder or the export path disposes a certificate you can still reach.

CertificateFinder does dispose certificates you can't reach: ones it loaded from a store or a file and then discarded, because a Where rejected them or because a terminal counted them without handing them back. You never see those, and nothing else could release them. Certificates you supplied yourself, through AddCertificates or a custom source that overrides Release to a no-op, are left alone either way.

Two exceptions, both producing a sequence that mixes objects you own with objects the call created, with no way to tell them apart, so don't dispose their elements:

  • FilterPrivateKeys: when it strips a private key it returns a keyless copy, and otherwise passes your original through.
  • X509ChainBuilder.Export(): it hands back your own instances wherever you supplied them, and a keyless copy only for a chain element the platform supplied itself. See Building a Certificate Chain.

Exporting certificates

Exporting requires the FluentCertificates.Extensions package (included in the top-level FluentCertificates package) and is found under the FluentCertificates namespace.

Everything goes through the Export() extension method, available on X509Certificate2, X509Certificate2Collection, X509Chain and IEnumerable<X509Certificate2>. It returns a CertificateExportBuilder: configure it with With*, choose a format with As*, then finish with To*.

Private keys are opt-in. An export carries certificates and nothing else until you ask for a key, so cert.Export().AsPkcs12().ToFile("cert.pfx") writes a PFX with no private key in it. Add WithPrivateKey() for the anchor's key (see below), or WithAllPrivateKeys() for every key you hold.

//PEM, certificate only
cert.Export().AsPem().ToPemString();

//PEM including the private key
cert.Export().WithPrivateKey().AsPem().ToFile("cert.pem");

//Password-protected PKCS#12 (PFX), key included
cert.Export().WithPrivateKey().WithPassword("hunter2").AsPkcs12().ToFile("cert.pfx");

//Raw DER/CER bytes
cert.Export().AsCert().ToByteArray();

//A whole chain as PKCS#7, binary DER or base64 in a PKCS7 block
chain.Export().AsPkcs7().ToByteArray();
chain.Export().AsPkcs7Pem().ToFile("chain.p7b");
chain.Export().AsPkcs7Pem().ToPemString();

//A leaf plus its issuers, no private keys anywhere
leafCert.Export().AddChain([leafCert, intermediateCert, rootCert]).AsPkcs12().ToByteArray();
Stage Methods
Configure WithPrivateKey(), WithAllPrivateKeys(), WithoutPrivateKeys(), WithKeys(ExportKeys), WithPassword(string?), WithPassword(SecureString), WithoutPassword()
Add AddChain(X509Chain), AddChain(...), AddCertificates(...)
Format AsPem(), AsPkcs12(), AsPkcs7(), AsPkcs7Pem(), AsCert()
Finish ToPemString() (AsPem() and AsPkcs7Pem() only), ToByteArray(), ToFile(path), ToStream(stream)

With* configures the export and replaces whatever was set before; Add* appends certificates to it. Every Add* method deduplicates by thumbprint, so a certificate already present is skipped.

WithPrivateKey() (singular, the anchor's key) and WithAllPrivateKeys() (every key) do different things, so they are named to be hard to confuse.

AddChain and AddCertificates take params IEnumerable<X509Certificate2>, so an array, a LINQ query, an X509Certificate2Collection, or a handful of individual certificates all bind to the same method:

leafCert.Export().AddChain(midCert, rootCert);              //loose arguments
leafCert.Export().AddChain(chainArray);                     //an array
leafCert.Export().AddCertificates(store.Certificates);      //an X509Certificate2Collection
leafCert.Export().AddCertificates(certs.Where(IsCurrent));  //a lazy sequence

Each WithPassword overload clears the other kind of password, so the last call wins, and WithoutPassword() clears both. A SecureString password is honoured by every format, but only AsPem() keeps it out of the managed heap: the platform's PKCS#12 export takes a string, so AsPkcs12() has to materialise one.

Ordering follows the API you used, not what the certificates look like. A chain is sorted; a collection is preserved:

//A chain: AddChain declares it one, so it is sorted leaf-first however it arrives
leafCert.Export().AddChain([rootCert, midCert]).AsPem().ToPemString();
//  -> leaf, mid, root

//Several chains: each call sorted as a unit, blocks appended in call order
leaf1.Export().AddChain([mid1, root1]).AddChain([root2, mid2, leaf2]).AsPem().ToPemString();
//  -> leaf1, mid1, root1, leaf2, mid2, root2

//A collection: a bundle, written exactly as supplied even if it happens to form a chain
new[] { rootCert, midCert, leafCert }.Export().AsPem().ToPemString();
//  -> root, mid, leaf

//AddCertificates appends without claiming a relationship, so it never reorders either
leafCert.Export().AddChain([rootCert, midCert]).AddCertificates([otherRoot, unrelated]).AsPem().ToPemString();
//  -> leaf, mid, root, otherRoot, unrelated

chain.Export() needs no sorting, since X509Chain.ChainElements is already leaf-first. An AddChain group that does not form a single chain is appended in the order given.

This matters most for PEM, where TLS servers require the sender's certificate first, but the order is preserved in PKCS#12 and PKCS#7 too and reappears in PEM as soon as anyone runs openssl pkcs12 -in cert.pfx -nokeys.

ExportKeys.Primary and AsCert() are the only parts that need a designated certificate, and they read it from the builder's Anchor rather than from position. cert.Export() anchors on that certificate and chain.Export() on the chain's end certificate, so adding issuers with AddChain(...) can never retarget the export, even when the result does form a valid chain:

//Exports the intermediate, because that is what the builder was anchored on
intermediateCert.Export().AddChain([rootCert, leafCert]).AsCert().ToByteArray();

collection.Export() and the IEnumerable<X509Certificate2> overload designate no leaf, so both throw InvalidOperationException there. This holds even when the certificates do form a chain: a bundle names no primary certificate, and arriving first is not evidence of being one. Since keys are opt-in, the ExportKeys.Primary half of that only bites when you actually write WithPrivateKey() on a bundle.

//Throws: a bundle, so nothing says which certificate to export
new[] { rootCert, midCert, leafCert }.Export().AsCert().ToByteArray();

//Fine: declaring a chain is what makes the leaf knowable
leafCert.Export().AddChain([rootCert, midCert]).AsCert().ToByteArray();

CertificateFinder examples

CertificateFinder requires the FluentCertificates.Finder package and is found under the FluentCertificates namespace.

CertificateFinder searches certificate stores, directories and certificates you already hold, and returns the ones that match. Like the other builders it is immutable, so every Add* and Where call returns a new finder and leaves the original alone.

Choosing where to search

Method Searches
AddCommonStores() My, CA and Root for CurrentUser, plus My, CA, Root and WebHosting for LocalMachine
AddStore(...), AddStores(...) An X509Store, or a store name and StoreLocation
AddDirectory(path, recurse), AddDirectories(...) .crt, .cer, .der, .pem, .ca-bundle, .pfx, .p12, .pkcs12, .p7b and .p7c files
AddCertificates(...) Certificates you already hold in memory
AddSource(...), AddSources(...) A source of your own, covered at the end of this section
var finder = new CertificateFinder()
    .AddCommonStores()
    .AddDirectory("/etc/ssl/certs", recurse: true)
    .AddCertificates(alreadyLoaded);

The same source added twice is searched once. Searching a directory's top level and searching its whole tree are different searches, so adding both runs both.

A searchPattern narrows a directory by file name, and is the one filter that saves work: a file it excludes is never opened or parsed. Everything else you can ask about a certificate needs the file read first. The pattern narrows the supported extensions rather than widening them, so *.txt finds nothing.

var finder = new CertificateFinder().AddDirectory("/etc/ssl/certs", searchPattern: "ca-*.pem");

Every extension above except .pfx, .p12 and .pkcs12 is read by what the file actually holds rather than by what its name promised. PEM or DER, a single certificate or a PKCS#7 bundle: all four combinations are read under any of those names, because all of them turn up under each in practice. Private keys and anything else that is not certificate material are passed over.

The name still decides one thing. A file called .pem or .ca-bundle may legitimately hold no certificates, so an empty one is not reported. Under the other extensions an empty result means the file is not what it claims, and it reaches OnLoadFailure.

PKCS#12 is the exception to all of this, since it has no text form and needs its own loader.

Pass a password to read password-protected .pfx, .p12 and .pkcs12 files. One password covers the directory, and a file it does not open is skipped like any other unreadable file.

var finder = new CertificateFinder().AddDirectory("/opt/deploy/certs", password: pfxPassword);

A source that is not there contributes nothing rather than failing the search: a store that does not exist, a directory that does not exist, a directory or subdirectory that cannot be read, and a file that cannot be parsed are all skipped. To see what a directory search skipped, give the source a handler:

var certs = new CertificateDirectorySource("/etc/ssl/certs", recurse: true) {
    OnLoadFailure = (path, ex) => logger.LogWarning(ex, "Skipped {Path}", path)
};

var finder = new CertificateFinder().AddSource(certs);

RemoveSource(...), RemoveSources(...) and ClearSources() narrow a finder that is already configured. Sources compare by value, so you remove one by describing it rather than by holding on to the instance you added.

var withoutDirectories = finder.RemoveSources(x => x.Kind == "Directory");

Narrowing the search

Where hands your predicate to every source, so a source able to answer it natively can, and one that cannot applies it itself. Both LINQ forms bind to it:

finder.Where(x => x.Certificate.Subject.Contains("example.com"));
from x in finder where x.Certificate.HasPrivateKey select x.Certificate;

Any, All, First, FirstOrDefault, Last, LastOrDefault, Single, SingleOrDefault and Count take a predicate the same way, and stop as soon as they can: FirstOrDefault reads no further than the source holding the first match.

Two things filter after collation instead, which is still correct and only costs work:

  • Any other LINQ operator, Select, OrderBy and Take included. Once you call one, a later Where is ordinary LINQ over the results already gathered.
  • A predicate held in a Func<> variable rather than written inline, since only an inline lambda becomes an expression tree.

Last and LastOrDefault read sources newest-added first. Which certificate is last within a source is unspecified, because neither a directory listing nor a store enumeration promises an order.

Searching asynchronously

AsAsyncEnumerable returns the same results in the same order, reads files asynchronously, and takes a CancellationToken, so a recursive scan over a large tree can be abandoned.

await foreach (var result in finder.AsAsyncEnumerable(cancellationToken)) {
    Console.WriteLine(result.Certificate.Subject);
}

Every predicate-taking method above has an Async counterpart: AnyAsync, AllAsync, FirstAsync, FirstOrDefaultAsync, LastAsync, LastOrDefaultAsync, SingleAsync, SingleOrDefaultAsync and CountAsync, each taking an optional CancellationToken.

var count = await finder.CountAsync(x => x.Certificate.HasPrivateKey, cancellationToken);

Prefer these to async LINQ over AsAsyncEnumerable. A terminal that matches certificates without returning them has to dispose them, and only these do.

The finder is not itself an IAsyncEnumerable<T>, which is why AsAsyncEnumerable is a method. A type implementing both sequence interfaces makes every LINQ operator ambiguous on .NET 10, where System.Linq.AsyncEnumerable is part of the framework, so finder.Select(...) and from x in finder select x would stop compiling. EF Core's DbSet<T> dropped IAsyncEnumerable<T> in version 6 over the same ambiguity and offers AsAsyncEnumerable in its place.

Reading a result

Each result carries the Certificate, the Source that produced it, and a Location naming it within that source: a full file path, or a store's location and name.

Results are never deduplicated, because where a certificate was found is part of the answer. The same certificate in CurrentUser\My and LocalMachine\My is two results, and a file two overlapping directory sources both reach is reported by each. To collapse them:

finder.DistinctBy(r => (r.Certificate.Thumbprint, r.Source.Kind, r.Location));

Certificates the finder hands you are yours to dispose. Ones it loaded and then discarded it disposes itself, and ones you supplied through AddCertificates it never touches. See Key ownership and disposal.

Find a specific certificate by thumbprint

const string thumbprint = "622A2B8374D9BBE3969B91EDBC8F5152783AFC78";

var cert = new CertificateFinder()
    .AddCommonStores()
    .FirstOrDefault(x => x.Certificate.Thumbprint.Equals(thumbprint, StringComparison.OrdinalIgnoreCase));

Find a valid certificate with matching subject, giving preference to included private keys

Both predicates go to the sources. The ordering runs afterwards, over the results that matched.

var subject = new X500NameBuilder()
    .SetOrganization("My Org")
    .SetCountry("AU")
    .SetCommonName("fake.domain");

var cert = new CertificateFinder()
    .AddCommonStores()
    .Where(x => x.Certificate.IsValidNow())
    .Where(x => subject.EquivalentTo(x.Certificate.SubjectName, false))
    .OrderBy(x => !x.Certificate.HasPrivateKey) //Ensure certs with private keys are listed before those without
    .Select(x => x.Certificate)
    .FirstOrDefault();

Find certificates by subject or issuer name

WhereSubjectMatches and WhereIssuerMatches narrow the search by name, using any IEqualityComparer<X500DistinguishedName>. See Comparing names for the built-in comparers and what each of them disregards.

var issued = new CertificateFinder()
    .AddCommonStores()
    .WhereIssuerMatches(ca.SubjectName)
    .ToList();

//Loosen it to match names that differ only in case, spacing or Unicode spelling
var alsoMisspelled = new CertificateFinder()
    .AddCommonStores()
    .WhereIssuerMatches(ca.SubjectName, X500NameComparer.Folded)
    .ToList();

Both default to X500NameComparer.Values, which disregards how the characters were encoded but nothing else, and answers the same on every runtime.

Find a certificate whose private key can actually sign

HasPrivateKey only reports that the certificate carries metadata naming a key. Picking an issuer on that basis can select one whose key container was deleted, whose key ACL excludes you, or whose token is absent, and the failure then surfaces much later as CryptographicException: Keyset does not exist from somewhere unrelated. CanSign() resolves the key instead, so the dud is rejected at selection time:

var ca = new CertificateFinder()
    .AddCommonStores()
    .Where(x => subject.EquivalentTo(x.Certificate.SubjectName, false))
    .Select(x => x.Certificate)
    .FirstOrDefault(x => x.CanSign());

It reaches the key store, so it costs far more than the property read it replaces. Narrow by subject or thumbprint first and apply it last, as above.

Advanced: write your own source

Derive from AbstractCertificateSource and hand it to AddSource. Two members are required: Kind, a label for your source type, and Enumerate, which produces the candidates as CertificateBatches. A batch is one group of certificates your source loads at once, such as a file, paired with the Location identifying where they came from.

public sealed record EnvironmentCertificateSource(string Prefix) : AbstractCertificateSource
{
    public override string Kind => "Environment";

    protected override IEnumerable<CertificateBatch> Enumerate(CertificateFilter filter)
        => Variables().Select(name => new CertificateBatch(Load(name), name));

    private IEnumerable<string> Variables()
        => Environment.GetEnvironmentVariables()
            .Keys.Cast<string>()
            .Where(name => name.StartsWith(Prefix, StringComparison.Ordinal))
            .Order();

    private IEnumerable<X509Certificate2> Load(string name)
    {
        var pem = new X509Certificate2Collection();
        pem.ImportFromPem(Environment.GetEnvironmentVariable(name) ?? "");
        return pem;
    }
}

var cert = new CertificateFinder()
    .AddSource(new EnvironmentCertificateSource("TLS_CERT_"))
    .FirstOrDefault(x => x.Certificate.IsValidNow());

Enumerate receives the CertificateFilter the caller built with Where. Apply as much of it as your source can answer cheaply and ignore the rest: returning more than matches is always correct, and returning less never is. The finder applies the filter in full afterwards, so a source that pushes nothing down still gives the right answer and only costs speed. To translate a predicate into a native query, read filter.Predicates, each of which carries the expression tree and a delegate compiled once.

Yielding a batch hands its certificates to the finder. The ones that match reach the caller, and every other one is passed to Release, including the ones behind a caller who stopped reading at the first match. Batches are pulled one at a time, so a source that yields a batch per file only ever loads as far as the caller reads. Keep nothing of your own between batches and your source cannot leak.

Three optional members:

Member Why
Release(CertificateFinderResult) What happens to a certificate the caller never receives. Disposes it by default, which is right for a source that loads certificates. Override it to a no-op for a source passing through certificates someone else owns.
EnumerateDescending(CertificateFilter) Produces the same batches in reverse order. Each batch is read back to front for you, so reverse the order they come in and nothing else. Return null, the default, if your source cannot go backwards. Implementing it lets Last and LastOrDefault stop at the first match from the end instead of reading everything.
EnumerateAsync(CertificateFilter, CancellationToken) Only worth overriding if your source has real asynchronous work, such as reading files or calling a service. By default it wraps Enumerate, so a source implementing the synchronous members alone is already usable from AsAsyncEnumerable and already cancellable. EnumerateDescendingAsync pairs with it the same way.
Kind Required, but free-form. Callers group and deduplicate results on it.

Make the source a record rather than a class. The finder deduplicates sources by value, so two records describing the same thing are searched once, whereas a class is compared by reference and would be searched twice.


X500NameBuilder examples

X500NameBuilder requires the FluentCertificates.Builder package and is found under the FluentCertificates namespace.

X500NameBuilder builds the distinguished names used for a certificate's subject and issuer. Like the other builders it is immutable: every method returns a new instance, so a builder can be shared and used as a template safely.

Building a subject name

var subject = new X500NameBuilder()
    .SetCommonName("*.fake.domain")
    .SetOrganization("Example Pty Ltd")
    .SetOrganizationalUnits("Engineering", "Platform")
    .SetCountry("AU")
    .SetState("Victoria")
    .SetLocality("Melbourne")
    .SetEmail("admin@fake.domain");

//Renders as a string in the usual RFC 4514 form
Console.WriteLine(subject.ToString());

//Converts to X500DistinguishedName explicitly or implicitly
var dn = subject.Create();
X500DistinguishedName implicitly = subject;

CertificateBuilder.SetSubject and SetIssuer take a delegate, so the same methods are usually used inline:

using var cert = new CertificateBuilder()
    .SetSubject(b => b.SetCommonName("*.fake.domain").SetOrganization("Example Pty Ltd"))
    .Create();

Reading values back

Every Set* method has a matching Get*. Single-valued attributes return null when absent, and multi-valued ones return an empty sequence.

string? cn = subject.GetCommonName();                       //"*.fake.domain"
string? org = subject.GetOrganization();                    //"Example Pty Ltd"
IEnumerable<string> ous = subject.GetOrganizationalUnits();  //"Engineering", "Platform"
string? missing = new X500NameBuilder().GetCommonName();     //null

Starting from an existing name

var fromString = new X500NameBuilder("CN=example.com, O=Example Pty Ltd");
var fromDn = new X500NameBuilder(cert.SubjectName);

//Builders are immutable, so this leaves fromString untouched
var renamed = fromString.SetCommonName("other.example.com");

Attributes without a dedicated method

Use Add or Set with an OID, optionally choosing the ASN.1 string encoding. Add appends another RDN with the same OID; Set replaces any existing ones.

var custom = new X500NameBuilder()
    .SetCommonName("example.com")
    .Add("0.9.2342.19200300.100.1.25", UniversalTagNumber.IA5String, "example", "com")
    .Remove(Oids.EmailAddressOid);

Comparing names

X500NameComparer answers "are these the same name?", and is an IEqualityComparer<X500DistinguishedName>, so it can key a dictionary or be handed to any API that takes one. Five members, loosest last:

Comparer Disregards
Exact Nothing. Compares the encoded bytes.
Values How the characters were encoded.
ValuesAnyOrder That, plus the order of the relative distinguished names.
Folded Encoding, letter case, whitespace runs and Unicode spelling.
FoldedAnyOrder That, plus the order of the relative distinguished names.
var bySubject = new Dictionary<X500DistinguishedName, X509Certificate2>(X500NameComparer.Values);
bySubject[cert.SubjectName] = cert;

X500NameComparer.Values.Equals(cert.IssuerName, ca.SubjectName);   //name chaining

Values is the usual choice. It finds names that differ only in ASN.1 string type, which is a real difference rather than a hypothetical one: RFC 5280 s4.1.2.4 lets a conforming CA use either PrintableString or UTF8String, and the RFC's own notes cite comparing the bytes across such a transition as a cause of name chaining failures. It is also the only decoding member that answers the same on every runtime.

Folded approximates how RFC 5280 s7.1 asks a relying party to compare names, and errs deliberately towards matching. That bias is safe when looking something up and risky when deciding whether to trust something. It also depends on the runtime's globalization support: under DOTNET_SYSTEM_GLOBALIZATION_INVARIANT it silently stops folding and matches fewer names, so check X500NameComparer.CanFold before relying on it.

The AnyOrder members depart from RFC 5280 s7.1, which matches two names only when the matching parts appear in the same sequence. Prefer the ordered members wherever the answer decides whether something is trusted.

From an X500NameBuilder

EquivalentTo takes any of them, defaulting to ValuesAnyOrder:

var a = new X500NameBuilder().SetCommonName("example.com").SetCountry("AU");
var b = new X500NameBuilder().SetCountry("AU").SetCommonName("example.com");

a.EquivalentTo(b);                                      //true: same attributes, different order
a.EquivalentTo(b, X500NameComparer.Values);             //false: order differs
a.EquivalentTo("CN=example.com, C=AU");                 //true

The order-agnostic default is deliberate: this builder emits its attributes in the order the setters were called, and Set moves an attribute it replaces to the end, so the order is not something you can state.

Equals is a different question: it compares the encoded bytes, as X500NameComparer.Exact does. Two names that render as the same string can still differ, because the ASN.1 string type is part of the encoding. The Set* methods use UTF8String, whereas parsing a string into an X500DistinguishedName yields PrintableString for values that fit it:

var built = new X500NameBuilder().SetCommonName("example.com").SetCountry("AU");

built.ToString();                           //"CN=example.com, C=AU"
built.Equals("CN=example.com, C=AU");       //false: UTF8String vs PrintableString
built.EquivalentTo("CN=example.com, C=AU"); //true

Reach for EquivalentTo unless you specifically need byte-for-byte identity. If you do need the encoding to match, set it explicitly with Set(oid, UniversalTagNumber.PrintableString, value).

Method summary

Method Description
SetCommonName, SetCountry, SetLocality, SetState, SetOrganization, SetStreetAddress, SetPostalCode, SetEmail, SetPhoneNumber, SetGivenName, SetSurname, SetTitle, SetSerialNumber, SetUserId, SetDistinguishedNameQualifier Set a single-valued attribute, replacing any existing value.
SetOrganizationalUnits, SetDomainComponents Replace all values of a multi-valued attribute.
AddOrganizationalUnit(s), AddDomainComponent(s) Append to a multi-valued attribute.
Add(oid, ...), Set(oid, ...) Append or replace by OID, with an optional UniversalTagNumber encoding. The OID may be an Oid or a string.
Remove(oid), Clear() Remove attributes by OID, or all of them.
GetCommonName, GetCountry, ... GetOrganizationalUnits, GetDomainComponents Read attribute values back.
Create() Build the X500DistinguishedName. Also available as an implicit conversion.
EquivalentTo(other, comparer = null) Compare against another builder, an X500DistinguishedName or a string, under any IEqualityComparer<X500DistinguishedName>. Defaults to X500NameComparer.ValuesAnyOrder.
Equals(other) Compare encoded bytes against an X500DistinguishedName or a string.
RelativeDistinguishedNames The attributes as (Oid, UniversalTagNumber, string) tuples.

X509Certificate2 extension methods

These extension methods require the FluentCertificates.Extensions package and are found under the FluentCertificates namespace.

Extension-Method Description
BuildChain() Starts a fluent X509ChainBuilder for building and verifying a chain for this certificate. See Building a certificate chain.
IsValidNow() Whether the current UTC time falls within the certificate's validity period.
IsValidAt(DateTimeOffset atTime) Whether the given instant falls within the validity period. Both bounds are inclusive. There is no DateTime overload, because a DateTime carries no offset and its DateTimeKind would change the result.
IsSelfSigned(bool verifySignature = false, IEqualityComparer<X500DistinguishedName>? comparer = null) Whether subject and issuer match. Pass true to also verify the certificate's signature against its own public key. See Comparing names for the comparer, which defaults to X500NameComparer.Values.
IsIssuedBy(X509Certificate2 issuer, bool verifySignature = false, IEqualityComparer<X500DistinguishedName>? comparer = null) Whether the certificate names the given issuer. Pass true to also verify the signature, which is what distinguishes a genuine issuer from one merely claiming the name. See Comparing names for the comparer, which defaults to X500NameComparer.Values.
CanSign() Whether the private key can actually be used for signing, as opposed to merely being associated with the certificate. Every "cannot sign" outcome returns false rather than throwing. Costs a key-store lookup. See Find a certificate whose private key can actually sign.
GetPrivateKey() Returns the private key as a CertificateKey, whatever its algorithm, classical or post-quantum. Reach a classical key through .AsAsymmetricAlgorithm. Every call returns a new instance which you own and should dispose; see Key ownership.
GetSignatureAlgorithm() Returns the SignatureAlgorithm the certificate was signed with, combining key algorithm, hash and padding.
GetToBeSignedData() The raw "to be signed" (TBS) bytes, i.e. what the issuer's signature covers.
GetSignatureData() The raw signature bytes. Together with GetToBeSignedData() this allows verifying a signature yourself.
Export() Returns a CertificateExportBuilder; see Exporting Certificates

Building a certificate chain

cert.BuildChain() returns an immutable X509ChainBuilder. Configure it, then terminate with either Create() (inspect the outcome) or Export() (verify and export in one step).

Method Description
TrustRoot(params IEnumerable<X509Certificate2> roots) Trusts these certificates as the only valid roots (X509ChainTrustMode.CustomRootTrust). Never calling it leaves the system trust store in effect. Calling it is what replaces system trust, not the number of roots passed, so an empty set trusts no root at all rather than falling back.
AddCertificates(params IEnumerable<X509Certificate2> certs) Offers extra certificates, typically intermediates, to path building via ExtraStore. Candidates only: an untrusted root stays untrusted however it arrives here.
AllowInvalidTime() Ignores expired or not-yet-valid certificates anywhere in the chain. Structural and trust failures still fail.
WithPolicy(Action<X509ChainPolicy> configure) Escape hatch for anything else: revocation checking, ApplicationPolicy, a custom VerificationTime, and so on. Applied after the builder's own settings, so it always wins; multiple calls run in registration order.
Create() Builds the chain and returns a disposable ChainResult. Never throws on verification failure.
Export() Builds, verifies, and returns a CertificateExportBuilder over the chain's certificates, leaf first. Throws CryptographicException naming the failed statuses when the chain does not verify, so a gap can never silently reach the exported file.

Revocation defaults to NoCheck, so a chain build never reaches the network unless WithPolicy says so.

ChainResult owns the built chain and exposes Verified, Chain, ChainStatus, EnsureVerified() (throws unless verified, otherwise returns itself) and Export().

//Verify and write a leaf-first fullchain in one line
leaf.BuildChain().TrustRoot(root).AddCertificates(mid).Export().AsPem().ToFile("fullchain.pem");

//Or inspect the outcome rather than throwing on it
using var result = leaf.BuildChain().TrustRoot(root).AddCertificates(mid).Create();
if (!result.Verified) {
    Console.WriteLine(String.Join("; ", result.ChainStatus.Select(x => x.Status)));
    return;
}
result.Export().WithPrivateKey().AsPkcs12().ToFile("bundle.pfx");

ChainResult.Export() does not verify, matching every other Export() in the library: exporting an unverified result writes whatever was built, which for a partial chain is an incomplete bundle. Check Verified first as above, or write result.EnsureVerified().Export(). Only builder.Export() verifies on your behalf, because it is a one-liner with nowhere to intervene.

Neither terminator carries a private key until you ask, the same as every other export. Call WithPrivateKey() for the leaf's, which is what a fullchain wants, or WithAllPrivateKeys() to include any CA keys you happen to hold.

builder.Export() disposes its internal chain before returning, so it cannot hand out the chain's own element certificates. Each element is mapped back to the instance you supplied through the certificate itself, TrustRoot(...), AddCertificates(...) or a WithPolicy(...) action that populated ExtraStore or CustomTrustStore, which you already own and dispose. Only an element the platform supplied itself, such as a root from the system store or an intermediate fetched via AIA, has no such instance and is copied; that copy is keyless and must not be disposed by you (the same rule as FilterPrivateKeys; see Key ownership). result.Export() copies nothing, so keep the ChainResult undisposed until that export terminates.


X509Chain extension methods

These extension methods require the FluentCertificates.Extensions package and are found under the FluentCertificates namespace.

Extension-Method Description
ToEnumerable() Returns the chain's certificates in leaf-first order, matching X509Chain.ChainElements. The root is therefore last.
ToCollection(ExportKeys include = ExportKeys.None) As ToEnumerable(), but returns an X509Certificate2Collection and applies FilterPrivateKeys(include). Keys are opt-in, as everywhere else.
Export() Returns a CertificateExportBuilder; see Exporting Certificates

X509Certificate2Collection extension methods

These extension methods require the FluentCertificates.Extensions package and are found under the FluentCertificates namespace.

Extension-Method Description
ToEnumerable() Exposes the collection as an IEnumerable<X509Certificate2>, so the LINQ operators and the extension methods below can be used against it.
Export() Returns a CertificateExportBuilder; see Exporting Certificates

IEnumerable extension methods

These extension methods require the FluentCertificates.Extensions package and are found under the FluentCertificates namespace.

Extension-Method Description
ToCollection() Copies the sequence into a new X509Certificate2Collection.
FilterPrivateKeys(ExportKeys include) Returns the sequence with private keys kept or stripped according to include. ExportKeys.Primary keeps only the first certificate's private key: a bare sequence has no anchor, so the primary one is taken to be the first.
Export() Returns a CertificateExportBuilder; see Exporting Certificates

AsymmetricAlgorithm extension methods

These extension methods require the FluentCertificates.Extensions package and are found under the FluentCertificates namespace.

Extension-Method Description
ToPrivateKeyPemString(string? password = null) Returns the private key as a PEM-encoded string. When a password is supplied the key is encrypted.
ToPublicKeyPemString() Returns the public key as a PEM-encoded string.
ExportAsPrivateKeyPem(TextWriter writer, string? password = null) Writes the private key as PEM to a TextWriter, encrypting it when a password is supplied. Returns the key for chaining.
ExportAsPrivateKeyPem(string path, string? password = null) Writes the private key as PEM to a file, encrypting it when a password is supplied. Returns the key for chaining.
ExportAsPublicKeyPem(TextWriter writer) Writes the public key as PEM to a TextWriter. Returns the key for chaining.
ExportAsPublicKeyPem(string path) Writes the public key as PEM to a file. Returns the key for chaining.

CertificateRequest extension methods

These extension methods require the FluentCertificates.Extensions package and are found under the FluentCertificates namespace.

Extension-Method Description
ToPemString() Exports the CertificateRequest to a PEM string.
ExportAsPem(string path) Exports the CertificateRequest to the specified PEM file.
ExportAsPem(TextWriter writer) Exports the CertificateRequest in PEM format to the given TextWriter.

About

FluentCertificates is a library using the Immutable Fluent Builder pattern for easily creating, finding and exporting certificates. Makes it simple to generate your own certificate chains, or just stand-alone self-signed certificates.

Topics

Resources

Stars

11 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages