Skip to content

Continued extension representation rework - #454

Draft
cpu wants to merge 19 commits into
mainfrom
ci/rework-exts-pt2
Draft

cpu wants to merge 19 commits into
mainfrom
ci/rework-exts-pt2

Conversation

@cpu

@cpu cpu commented Sep 18, 2026

Copy link
Copy Markdown
Member

This revives and finishes the extension rework from #446 and continues where #449 left off by starting on breaking changes. 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).

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

@cpu
cpu added this pull request to stack #455 September 18, 2026 18:04
@cpu cpu self-assigned this Sep 18, 2026
Base automatically changed from ci/rework-exts to main September 21, 2026 09:42
@djc
djc force-pushed the ci/rework-exts-pt2 branch from 0e1d34e to 33f178f Compare September 21, 2026 09:42
@djc djc mentioned this pull request Sep 21, 2026
@cpu
cpu force-pushed the ci/rework-exts-pt2 branch from 33f178f to 6171866 Compare September 21, 2026 15:43
@cpu
cpu marked this pull request as draft September 21, 2026 15:48
cpu added 19 commits September 21, 2026 12:00
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.
@cpu
cpu force-pushed the ci/rework-exts-pt2 branch from 6171866 to c624890 Compare September 21, 2026 16:00
@cpu
cpu marked this pull request as ready for review September 21, 2026 16:32
@cpu

cpu commented Sep 21, 2026

Copy link
Copy Markdown
Member Author

cpu marked this pull request as ready for review now

Rebased on main and carried forward the tweaks from #456 throughout.

@djc djc left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest we split this once more, before the AcmeIdentifier inclusion?

Two concerns remaining (somewhat from the previous PR):

  • It feels like in a number of cases, this is wrapping a local type in another local type, when we could arguably implement Extension for the existing type rather than wrapping it in another layer.
  • The from_params(params: ..) -> Option<Self> pattern feels somewhat overwrought? Especially to the extent we can reuse existing types, can we just have write() yield bool instead of having this indirection between something that exists, then is written?

Comment thread rcgen/src/certificate.rs Outdated
pub struct CustomExtension {
oid: Vec<u64>,
critical: bool,
pub(crate) oid: Vec<u64>,

@djc djc Sep 22, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest inserting a commit before this one that moves CustomExtension into extension? Definitely want to keep the type together with its Extension implementation, too.

(I did find this commit later on, but would be better to do it here IMO.)

Comment thread rcgen/src/crl.rs
}
}

/// A certificate revocation list (CRL) issuing distribution point, to be included in a CRL's

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, on main the crl module is 480 lines and extension is already 1276 lines. Might be better to keep CRL-only extensions in crl?

(Also better to keep moves and refactoring separate in terms of commits.)

Comment thread rcgen/src/extension.rs
///
/// Returns [`Error::DuplicateExtension`] if the extension's OID is already present
/// in the collection.
pub(crate) fn add_extension(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: suggest just calling this push() to mimic Vec::push()?

Comment thread rcgen/src/extension.rs
/// built collection, not predicted from the params, so an empty extensions
/// field is never emitted and requested extensions can never be silently
/// dropped.
pub(crate) fn write_exts_der(&self, writer: DERWriter) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the exts_ infix feels superfluous here?

@djc

djc commented Sep 22, 2026

Copy link
Copy Markdown
Member
  • The from_params(params: ..) -> Option<Self> pattern feels somewhat overwrought? Especially to the extent we can reuse existing types, can we just have write() yield bool instead of having this indirection between something that exists, then is written?

Oh, I guess this is necessary because we only want to write the extensions wrapper when we know there are 1+ extensions to write? Still wondering if there isn't a more light-weight pattern at least for some of the extensions.

@cpu

cpu commented Sep 22, 2026

Copy link
Copy Markdown
Member Author

Still wondering if there isn't a more light-weight pattern at least for some of the extensions.

I will noodle on this when I split it up & address your other feedback. This part of the solution is more or less as-is from 2023 (time flies!) and I wouldn't be surprised if in 2026 I have a better idea 😆

@cpu
cpu marked this pull request as draft September 22, 2026 17:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants