Skip to content

Rework X.509 cert/csr/crl extension representation - #449

Open
cpu wants to merge 28 commits into
mainfrom
ci/rework-exts
Open

Rework X.509 cert/csr/crl extension representation#449
cpu wants to merge 28 commits into
mainfrom
ci/rework-exts

Conversation

@cpu

@cpu cpu commented Aug 29, 2026

Copy link
Copy Markdown
Member

This revives and finishes the extension rework from #446. Each extension is now its own type in a new ext module, with the presence decision living next to the encoding.

Serialization builds an Extensions collection first and only writes what was actually built, so the bug class that produced silently dropped extensions is addressed more structurally.

Throughout duplicate OIDs are rejected instead of silently emitted, on both the write and parse sides (#155).

CSR parsing also got friendlier. Requested extensions rcgen doesn't handle natively used to fail the whole parse with UnsupportedExtension. Now they're preserved as custom extensions, so a CSR round-trips through from_der without losing anything (#150). Same deal for extended key usages with purpose OIDs rcgen doesn't know, those now parse into ExtendedKeyUsagePurpose::Other instead of erroring.

Along the way this grew some related changes:

  • extensions borrow from params instead of cloning
  • the path length enum was renamed PathLenConstraint to free up the BasicConstraints name for the extension type and because it's more precise.
  • SKI is now emitted for end entity certs too (I think this is the right default), and the CustomExtension/ACME identifier API got a rework.

For most users this will all be fairly uneventful and require no code changes since the parameters+issuance APIs remain mostly the same, but some changes are semver-breaking so this bumps to 0.15.0 so we can use main to prepare the next major release (#418).

Best reviewed commit by commit. Each one moves a single extension or makes one focused change, and every commit builds and passes tests on its own. I also tacked one on the end that fixes a clippy CI failure - I might pull that out into a separate PR since this will take longer to review.

Resolves #150
Resolves #155
Resolves #446
Resolves #122

@cpu cpu self-assigned this Aug 29, 2026
cpu added 28 commits August 29, 2026 17:42
Add an `Extension` trait (OID, criticality, DER value) and a single
`write_extension()` serializer so each extension's encoding lives beside
its presence logic as types are migrated. Extensions whose OID and
criticality are fixed by the profile defining them implement the
`StaticExtension` trait instead, receiving `Extension` through a
blanket impl.

Move authority key identifier writing into the module as the first
static extension, replacing `write_x509_authority_key_identifier()` in
the certificate and CRL paths.
Port the SAN extension into an `ext::SubjectAlternativeName` type whose
`from_params` constructor owns the presence decision, replacing
`write_subject_alt_names()` in the certificate and CSR paths. The
RFC 5280 §4.1.2.6 criticality rule (critical if the subject DN is
empty) is now unit tested.
Port the KeyUsage extension into an `ext::KeyUsage` static extension
whose `from_params` constructor owns the presence decision, replacing
`write_key_usage()` in the certificate and CSR paths. The
minimal-length BIT STRING encoding (including the 9-bit `decipherOnly`
case) remains covered by the existing certificate tests.
Port the EKU extension into an `ext::ExtendedKeyUsage` static extension
whose `from_params` constructor owns the presence decision, replacing
`write_extended_key_usage()` in the certificate and CSR paths.
Port the NameConstraints extension into an `ext::NameConstraints`
static extension whose `from_params` constructor owns the presence
decision (including omitting the extension when both subtrees are
empty), replacing the inline writer and `write_general_subtrees()` in
the certificate path.
Port the certificate CRLDistributionPoints extension into an
`ext::CrlDistributionPoints` static extension whose `from_params`
constructor owns the presence decision, replacing the inline writer in
the certificate path. The empty-URIs rejection in
`serialize_der_with_signer` is unchanged.
Port the SKI extension into an `ext::SubjectKeyIdentifier` static
extension constructed from a `KeyIdMethod` and the subject public key
info. The current behavior of only emitting SKI for
`IsCa::Ca`/`ExplicitNoCa` certificates (and never for CSRs) is
preserved.
The following commits make semver-incompatible changes to the public
extension API.
The enum represents the optional pathLenConstraint field of the basic
constraints extension, not the extension itself. Renaming it frees the
`BasicConstraints` name for the extension type introduced next, without
needing to disambiguate between the two.
Port the BasicConstraints extension into an `ext::BasicConstraints`
static extension whose `from_params` constructor owns the presence
decision (extension omitted entirely for `IsCa::NoCa`), removing
`write_ca_extensions()`.
Implement `Extension` for `&CustomExtension` so the certificate and CSR
paths write user-supplied extensions through `ext::write_extension()`
like the built-in ones, borrowing them from the params rather than
cloning. `CustomExtension` now stores a `Criticality` instead of a
`bool`, converting at the public accessors.
Port both CRL-level extensions to static extensions: a `CrlNumber` type
in the ext module built via `From<&SerialNumber>`, and a
`StaticExtension` impl directly on the existing
`CrlIssuingDistributionPoint` params type, replacing the inline writers
in the CRL serialization path.
Port the reasonCode and invalidityDate CRL entry extensions into
`ext::ReasonCode` and `ext::InvalidityDate` static extensions. The
`from_params` constructors own the presence decisions (filtering
`unspecified(0)` per RFC 5280 §5.3.1), so the crlEntryExtensions
presence gate and the writers now share a single source of truth. The
unconditional GeneralizedTime encoding for invalidityDate
(RFC 5280 §5.3.2) is preserved and remains covered by the existing CRL
tests.

With no callers left, the `write_x509_extension()` helper is deleted.
…riting

Add an `ext::Extensions` collection that preserves insertion order and
rejects duplicate OIDs with a new `Error::DuplicateExtension` variant.
The collection borrows the extensions (and through them, the params
payloads) for the duration of one serialization. The certificate path
now builds the full collection via `CertificateParams::extensions()`
and only emits the extensions field of the certificate when the built
collection is non-empty.

This deletes the `should_write_exts` predicate whose drift from the
writers previously caused requested extensions to be silently dropped.
Presence is now observed from what was built rather than predicted from
the params, removing that bug class structurally.

Params that request two extensions with the same OID (previously
serialized as an invalid duplicate) now fail with
`Error::DuplicateExtension`.
Replace `write_extension_request_attribute()` and the
`write_extension_request` predicate with a `csr_extensions()`
collection builder. Like the certificate extensions field, the PKCS #9
extensionRequest attribute elides itself when the built collection is
empty, claiming a slot in the attributes SET only when there is
something to write (yasna rejects set elements that produce no
output).
Build the crlExtensions field (AKI, CRL number, optional IDP) and each
entry's crlEntryExtensions through `ext::Extensions`, eliding the
fields when the built collections are empty. The
reasonCode/invalidityDate presence gate is gone: the collection's
emptiness is the single source of truth. With all writers converted,
`write_extension()` becomes private to the ext module.
Previously SKI was only written for `IsCa::Ca`/`ExplicitNoCa`
certificates. RFC 5280 §4.2.1.2 describes the SKI as a MUST for CA
certificates and a SHOULD for end entity certificates, so emit it
unconditionally. CSRs are unchanged (no SKI is requested).
CSRs previously requested extensions as KU, SAN, EKU while certificates
emit SAN, KU, EKU. Use the certificate order for the CSR extension
request attribute so both paths serialize extensions consistently.
Move the type next to its `Extension` impl, adding whitespace between
the impl's methods; the crate root re-export path is unchanged.
Make `Criticality` public and restructure `CustomExtension` with public
`oid`, `criticality` and `der_value` fields, dropping the
`set_criticality()`/`criticality()`/`content()` accessors.
`from_oid_content()` now takes the criticality directly instead of
defaulting to non-critical with later mutation.
Add a dedicated `AcmeIdentifier` type for RFC 8737 TLS-ALPN-01
challenge response extensions. It converts into a `CustomExtension` for
use in `CertificateParams::custom_extensions`, always critical per
RFC 8737 §3. It stays a `CustomExtension` conversion rather than
becoming a first-class params field: the extension never appears in
CSRs, and nearly every `CertificateParams` would carry a `None` for it.
The `TryFrom<&[u8]>` constructor returns the new
`Error::InvalidAcmeIdentifierLength` instead of panicking on
wrong-length digests like `new_acme_identifier` did.
Move the CSR extension parsing into `from_parsed` constructors on the
ext types (`KeyUsage`, `SubjectAlternativeName`, `ExtendedKeyUsage`,
`BasicConstraints`), symmetric with their `from_params` serializing
counterparts. Unknown requested extensions still yield
`Error::UnsupportedExtension`. Custom EKU purpose OIDs now parse into
`ExtendedKeyUsagePurpose::Other` and round-trip, where `from_der`
previously rejected them with `Error::UnsupportedExtension`.
Rewrite the test-only `CertificateParams::from_ca_cert_der()` as a
single pass over the parsed extensions using the shared `from_parsed`
constructors, adding test-only `from_parsed` for `NameConstraints` and
`SubjectKeyIdentifier`. This deletes the per-field `from_x509` helpers
that each re-scanned the certificate (`SanType`,
`ExtendedKeyUsagePurpose`, `NameConstraints`, `IsCa`).
`KeyUsagePurpose::from_x509` and `KeyIdMethod::from_x509` remain for
`Issuer::from_ca_cert_der`.
Serialize params exercising every certificate extension writer, then
assert the exact extension count and that parsing recovers what was
requested. A certificate missing a requested extension is still
well-formed, so presence regressions can only be caught by comparing
the output against the requested params.
`CertificateSigningRequestParams::from_der` previously returned
`Error::UnsupportedExtension` for any requested extension other than
SAN/KU/EKU/BasicConstraints - including custom extensions rcgen itself
wrote via `serialize_request`. Iterate the raw extension request
attribute (rather than x509-parser's pre-parsed view, which discards
OIDs) and recover anything unhandled into
`CertificateParams::custom_extensions` via a new
`CustomExtension::from_parsed` constructor, preserving OID, criticality
and value so that serializing the recovered params reproduces the
request.
RFC 5280 §4.2 forbids multiple instances of the same extension. A CSR
requesting an extension twice previously either merged both instances
into the recovered `CertificateParams` (for natively handled types) or
preserved both as custom extensions, deferring the failure to
re-serialization. Reject the duplicate up front with
`Error::DuplicateExtension`, mirroring the write-side collection
invariant.
Like the CSR extension request path, `from_ca_cert_der()` previously
merged duplicate instances of natively handled extensions into params
silently. Reject them with `Error::DuplicateExtension` instead,
mirroring the write-side collection invariant.
Clippy's new `chunks_exact_to_as_chunks` lint (denied in CI) flags
`chunks_exact` calls with a constant chunk size. `slice::as_chunks`
stabilized in Rust 1.88, the crate MSRV, and yields `[u8; N]` chunks
directly, resolving the FIXMEs that waited on `array_chunks`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant