Skip to content

fix(x509): reject RFC 5280 MUST-NOT builder constructions - #88

Merged
kjanat merged 15 commits into
masterfrom
fix/builder-extension-constraints
Jul 25, 2026
Merged

fix(x509): reject RFC 5280 MUST-NOT builder constructions#88
kjanat merged 15 commits into
masterfrom
fix/builder-extension-constraints

Conversation

@kjanat

@kjanat kjanat commented Jul 24, 2026

Copy link
Copy Markdown
Owner

What

Resolves the three certificate/extension builder findings from the spec audit, all RFC 5280 MUST-NOT constructions the builders emitted.

pathLenConstraint without keyCertSign (§4.2.1.9)

"CAs MUST NOT include the pathLenConstraint field unless the cA boolean is asserted and the key usage extension asserts the keyCertSign bit." Only the cA conjunct was checked, so { basicConstraints: { ca: true, pathLength: 0 }, keyUsage: ['digitalSignature'] } emitted a certificate this library's own verifier rejects. assertPathLengthKeyUsage returns early only when pathLength is absent; an absent, empty, or keyCertSign-less keyUsage all throw path_length_requires_key_cert_sign, on both the certificate path (buildCertificateExtensions) and the CSR path (appendConstraintExtensions).

Empty subject requires a present, critical SAN (§4.2.1.6)

The clause carries two conjoined MUSTs; only criticality was implemented, so subject: {} signed a certificate with no identity at all. buildCertificateExtensions now throws empty_subject_requires_subject_alt_name when subjectIsEmpty and no SAN is configured. A customExtensions SAN counts only when critical: true, and a present-but-empty subjectAltNames array counts as absent. createCertificate delegates, so one guard covers both entry points; buildRequestedExtensions is untouched.

relativeName with multiple cRLIssuer DNs (§4.2.1.13)

"The DistributionPointName MUST NOT use the nameRelativeToCRLIssuer alternative when cRLIssuer contains more than one distinguished name." Lines 88-91 of the same clause make the DN-only requirement unconditional ("If present, the cRLIssuer MUST only contain the distinguished name"), so encodeDistributionPoint rejects any non-directoryName cRLIssuer entry with distribution_point_crl_issuer_not_directory_name, and additionally rejects nameRelativeToCRLIssuer past one DN with distribution_point_relative_name_multiple_crl_issuers.

Citation riders (audit Medium)

The ParsedBitFlags docs claimed non-canonical padding is recorded "so verification layers can decide", but no caller can observe it because requireCanonicalBitFlags throws first; the docs now state rejection. decodeBoolean's doc cited the BER any-non-zero rule while the function enforces X.690 11.1 DER (0xff/0x00 only). The audit's remaining two Medium citation errors (X.690 §11.2.2 six sites, name-constraints step labels) were verified already fixed on master.

Test fixture correction

Three verifier tests built their deliberately-invalid intermediate (ca: true, keyUsage without keyCertSign) with pathLength: 0, which the builder now refuses. Dropping pathLength keeps those chains constructible and verifier-rejected (key_cert_sign_required / ca_required unchanged).

Tests

Encoder level: path_length_requires_key_cert_sign (accepted with keyCertSign, rejected when keyUsage is absent or lacks it), empty_subject_requires_subject_alt_name (empty array rejected, populated accepted), distribution_point_crl_issuer_not_directory_name (a URI cRLIssuer rejected under both fullName and relativeName), distribution_point_relative_name_multiple_crl_issuers (two DNs rejected, one accepted). Public API: createSelfSignedCertificate and createCertificateSigningRequest reject the pathLen case; createCertificate rejects an empty subject without a SAN and accepts one carried as a critical custom extension.

Gate: typecheck, biome, docs:lint, 1482 tests (PKITS in-suite, 249 standalone), and 76 OpenSSL differential cases (11 deterministic in test/differential.test.ts plus 65 generated in test/differential-fuzz.test.ts, both run by test:differential). Green on stable Bun 1.3.14 and on the bun-35433 build.

Canonical extension OIDs and custom payload validation

pushExtension keyed its duplicate set by raw OID text and appendCustomExtensions looked the registry up the same way. 2.5.029.17 and 2.5.29.17 encode to identical DER, so typed subjectAltNames plus a custom 2.5.029.17 emitted two wire-identical SAN OIDs that the parser then rejected, and a custom 2.5.029.18 evaded the certificate-only restriction on issuerAltName. Both keys are now the canonical form via canonicalizeOid; the diagnostic still quotes the OID as submitted.

The effective-value resolvers swallowed DER decode failures and returned undefined, so a customExtensions entry carrying a known OID with a malformed payload passed every cross-field guard and reached the wire unchecked. assertCustomExtensionsValid now decodes known payloads ahead of those guards and throws malformed_known_extension_value, which also lets the three resolvers drop their catch clauses.

Empty IA5 GeneralNames reject on parse

parseGeneralName accepted a zero-length dNSName, rfc822Name, or uniformResourceIdentifier, which RFC 5280 §4.2.1.6 forbids. An external certificate could carry an empty subjectAltName value and parse, leaving chain verification to accept a certificate with no usable identity when no identity match was requested. Certificate and CRL parsing share the decoder, so this covers subjectAltName, issuerAltName, authorityInfoAccess locations, CRL distribution points, cRLIssuer, the issuing distribution point, and certificateIssuer. Name constraints keep their own decoder, where an empty base is meaningful.

Builder strictness means parser-strictness fixtures can no longer be built through the builder, so test/helpers.ts gains raw-extension variants of createCertificate, createSelfSignedCertificate, and createCertificateSigningRequest that splice extensions into the signed structure and re-sign.

Known-extension profiles apply to custom payloads

Decoding a payload proves its structure, not that it obeys the RFC 5280 profile, because the parser is deliberately tolerant. ExtensionDefinition now requires an assertProfile hook that delegates to the encoder owning each rule, and the builders run it over the decoded value of any customExtensions entry carrying a known OID. Parsing never calls it, so the parser and the CRL scanner stay tolerant.

That closes every rule the decoder does not already carry: duplicate policy OIDs and an over-long DisplayText in certificatePolicies, a non-URI OCSP location in authorityInfoAccess, a nameConstraints with neither subtree, a keyUsage with no bit set, and the §4.2.1.13 cRLIssuer rules. Every other registered extension (basicConstraints, extendedKeyUsage, subjectAltName, issuerAltName, policyMappings, policyConstraints, inhibitAnyPolicy, SKI, AKI) was audited: their decoders already reject what their encoders reject, and each still declares the hook so the two paths cannot drift apart later.

The payload decodes once, inside the coded-error boundary. A profile violation keeps its own code. A decode failure maps to malformed_known_extension_value.

The §4.2.1.13 rules now live in assertCrlDistributionPointsProfile, shared by encodeCrlDistributionPoints and the hook. encodeDistributionPointName consumes the resolved name choice, so it has no unreachable branch.

Coded OID errors on every encoding path

encodeExtension is public and called objectIdentifier directly, so encodeExtension('3.1', ...) threw a bare Error. The encodeCertificatePolicies duplicate scan encoded each policy OID before validating it, with the same result. Both validate first and return invalid_oid, alongside the builder paths, for an OID that parses as decimals but breaks the X.660 arc bounds. encodeBasicConstraints gains path_length_requires_ca in place of its bare throw.

Round-two regression matrices

cRLDistributionPoints: a URI cRLIssuer and a relativeName beside two wire-tagged ([4]) DNs are rejected as profile violations; a NULL payload, an empty cRLIssuer, and an empty points SEQUENCE are rejected as malformed; a fullName URI, a relativeName with one DN, a relativeName with no cRLIssuer, and a fullName beside a [4] DN are accepted. Each runs on the certificate and CSR paths under both 2.5.29.31 and 2.5.029.31, and every profile-invalid payload is asserted to still parse.

Profile hooks: one rejecting case per rule the decoder does not carry, one accepting case per registered extension, and one malformed case per rule the decoder does carry, all on both builder paths. Every new guard was reverted in turn to confirm its test fails without it.

Canonical OID comparison on the typed path

getAuthorityInfoAccessMethodOid and getExtendedKeyUsageOid validated a custom OID and then returned the string as submitted, and encodePolicyMappings compared its input by string. Both rules keyed on OID equality were therefore dodgeable by an alias that encodes to the same DER: { method: { type: 'oid', value: '1.3.6.1.5.5.7.048.1' }, location: { type: 'dns', ... } } emitted a non-URI OCSP location, and 2.5.29.032.0 passed the policyMappings anyPolicy check. All three now resolve through validateOid, which returns the canonical spelling. The custom-payload route was already safe, since an OID decoded from DER is canonical by construction.

Criticality is part of the profile

assertProfile saw only the decoded payload, so a customExtensions entry could carry a criticality RFC 5280 forbids while the typed field could not. The hook now takes the flag as well. nameConstraints (§4.2.1.10), policyConstraints (§4.2.1.11), and inhibitAnyPolicy (§4.2.1.14) must be critical; authorityKeyIdentifier (§4.2.1.1), subjectKeyIdentifier (§4.2.1.2), and authorityInfoAccess (§4.2.2.1) must not.

§4.2.1.9 conditions basicConstraints criticality on the certificate being a CA whose key validates signatures on certificates. Neither conjunct is in the extension itself, so that check sits beside assertPathLengthKeyUsage, where the effective basicConstraints and keyUsage are already resolved across the typed field and customExtensions. §4.2.1.3 restricts a key only through a keyUsage extension that reaches the wire, so an absent keyUsage, and an empty one the builder omits, both leave the key free to validate certificate signatures and require a critical basicConstraints; only an emitted keyUsage lacking keyCertSign takes the certificate out of the clause's scope.

A single test runs every registered definition's hook at its own defaultCritical with a conformant payload, so a default that contradicts its profile fails, and a new extension added without a fixture fails too. test/verify.test.ts built its non-critical name-constraints tolerance fixture through the builder; it moves to createSelfSignedCertificateWithRawExtensions.

RFC 1421 PEM header parsing is tracked separately in #92; it is out of scope here.

kjanat added 2 commits July 24, 2026 17:59
Three builder gaps emitted RFC-invalid certificates. pathLenConstraint
now requires the keyUsage keyCertSign bit when a keyUsage extension is
present (§4.2.1.9); the encoder emitted a certificate this library's own
verifier rejects. An empty subject DN requires a subjectAltName that is
present and critical (§4.2.1.6), counting a customExtensions SAN only
when critical and an empty subjectAltNames array as absent; only
criticality was enforced, so subject: {} signed a certificate with no
identity at all. A relativeName distribution point rejects more than one
cRLIssuer distinguished name (§4.2.1.13). All three throw coded
ExtensionEncoderErrorCode errors on both the certificate and CSR paths.

Verifier fixtures that intentionally omit keyCertSign drop their
pathLength so the malformed-chain scenarios stay constructible.

Citation riders from the audit: the ParsedBitFlags docs no longer claim
non-canonical padding is reported for callers to judge (extension flag
decoding rejects it), and decodeBoolean's doc cites the X.690 11.1 DER
restriction instead of the BER any-non-zero rule.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 24, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
micro509 517fc1c Commit Preview URL

Branch Preview URL
Jul 25 2026, 12:49 AM

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 40 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: f6daf994-5e35-4059-8705-3024f3d7b05e

📥 Commits

Reviewing files that changed from the base of the PR and between 702d82c and 517fc1c.

📒 Files selected for processing (6)
  • test/AGENTS.md
  • test/certificate.test.ts
  • test/crl.test.ts
  • test/csr.test.ts
  • test/helpers.ts
  • test/parse.test.ts
📝 Walkthrough

Walkthrough

Certificate and CSR builders now enforce RFC 5280 constraints for path length, key usage, empty subjects, GeneralName values, criticality, OIDs, AIA, and CRL distribution points. Extension registries provide profile assertions, parsers reject empty IA5String GeneralNames, and tests can inject raw extensions to exercise malformed inputs. Tooling, documentation, changelog entries, and fixtures were also updated.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CertificateOrCSRBuilder
  participant ExtensionRegistry
  participant ExtensionProfileValidator
  participant Parser
  CertificateOrCSRBuilder->>ExtensionRegistry: resolve configured or custom extension
  ExtensionRegistry->>ExtensionProfileValidator: validate payload and criticality
  ExtensionProfileValidator-->>CertificateOrCSRBuilder: accept or return coded error
  Parser->>Parser: decode GeneralName and extension DER
  Parser-->>CertificateOrCSRBuilder: reject empty IA5String GeneralName
Loading

Possibly related issues

  • KAJ-332 — The new RFC 5280 test coverage overlaps with the proposed clause-addressable conformance-suite organisation.

Possibly related PRs

Suggested labels: bug, rfc-conformance, security, tests

Poem

RFC rules now guard the sea,
Empty names flee the decree.
OIDs sail in canonical line,
Key-cert signs make paths align,
Arrr, malformed certs walk the plank!

🚥 Pre-merge checks | ✅ 6 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Semver Version Bump Validation ⚠️ Warning Source files changed, but package.json stayed at 0.13.0 in base and PR; these additive fixes need a SemVer MINOR bump. Bump package.json to 0.14.0 (MINOR) or otherwise update the repo’s version field to match the code changes.
Agents.Md Documentation Updated ⚠️ Warning src/internal and test AGENTS were updated, but package.json script/workflow changes lack a matching root AGENTS.md update. Update the root AGENTS.md to document the new package.json scripts/CLI workflow changes, then re-run the check.
✅ Passed checks (6 passed)
Check name Status Explanation
Title check ✅ Passed It clearly describes the PR's X.509 RFC 5280 rejection fixes.
Description check ✅ Passed The description directly matches the builder, parser, and test changes in the patch.
Docstring Coverage ✅ Passed Docstring coverage is 85.07% which is sufficient. The required threshold is 30.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Changelog Update ✅ Passed Source files changed, package.json stayed at 0.13.0, and CHANGELOG.md adds the new fixes under the existing [Unreleased] section.

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Jul 24, 2026

Copy link
Copy Markdown
  • micro509-vite-demo

    npm i https://pkg.pr.new/micro509@88
    
    pnpm add https://pkg.pr.new/micro509@88
    
    bun add https://pkg.pr.new/micro509@88
    

commit: 517fc1c

@kjanat kjanat self-assigned this Jul 24, 2026
@codecov

This comment was marked as resolved.

@kjanat

This comment was marked as resolved.

@kjanat kjanat added the cr:review Allow CodeRabbit review label Jul 24, 2026
@coderabbitai coderabbitai Bot added bug Something isn't working rfc-conformance RFC adherence and conformance evidence tests Test coverage and harnesses labels Jul 24, 2026
coderabbitai[bot]

This comment was marked as resolved.

pathLength/keyCertSign coupling, the empty-subject SAN rule, and
relativeName cRLIssuer validation each admitted a bypass through public
input. Resolve an effective basicConstraints/keyUsage/SAN view across
typed fields and custom-known extensions before the cross-field checks,
so a known extension smuggled through customExtensions no longer evades
them (canonical OID match). Reject absent or empty keyUsage under
pathLength, empty typed and malformed custom SAN under an empty subject,
empty GeneralName string values on encode, and a non-directoryName or
smuggled/multiple cRLIssuer under relativeName.
@kjanat

This comment was marked as resolved.

RFC 5280 §4.2.1.13 lines 88-91 make cRLIssuer DN-only a MUST for every
case, not just nameRelativeToCRLIssuer. The builder now rejects any
non-directoryName cRLIssuer entry unconditionally; relativeName keeps the
extra at-most-one constraint. The parser and revocation scanner stay
tolerant of non-DN cRLIssuer for real-world certificates, fed through a
raw-DER test helper instead of the conformant builder.

Cover every new branch: custom-basicConstraints/keyUsage decode paths
(including malformed-value fallbacks), the canonical leading-zero OID
match, and the non-string SAN identity forms. Replace the SAN identity
switch with a ternary, dropping an unreachable exhaustiveness guard.
@kjanat

This comment was marked as resolved.

@kjanat kjanat removed the cr:review Allow CodeRabbit review label Jul 24, 2026
…loads

Three escapes survived the first pass at the builder cross-field guards,
all reachable through the public API with green CI.

`pushExtension` keyed its duplicate set by raw OID text and
`appendCustomExtensions` looked the registry up the same way. Since
`2.5.029.17` and `2.5.29.17` encode to identical DER, a typed
`subjectAltNames` plus a custom `2.5.029.17` emitted two wire-identical
SAN OIDs that this library's own parser then rejected, and a custom
`2.5.029.18` evaded the certificate-only restriction on `issuerAltName`.
Both keys are now the canonical form; the diagnostic still quotes the OID
as submitted, since that is the string the caller can find in their input.

The effective-value resolvers swallowed DER decode failures and returned
`undefined`, so a `customExtensions` entry carrying a known OID with a
malformed payload passed every cross-field guard and reached the wire
unchecked. Known payloads are now decoded up front, ahead of those guards,
which also lets the three resolvers drop their catch clauses.

`parseGeneralName` accepted a zero-length `dNSName`, `rfc822Name`, or
`uniformResourceIdentifier`, which RFC 5280 §4.2.1.6 forbids. An external
certificate could carry an empty subjectAltName value and parse, leaving
chain verification to accept a certificate with no usable identity.

Builder strictness means parser-strictness fixtures can no longer be built
through the builder, so `test/helpers.ts` gains raw-extension variants of
`createCertificate`, `createSelfSignedCertificate`, and
`createCertificateSigningRequest` that splice extensions into the signed
structure.
@socket-security

This comment was marked as resolved.

kjanat added 8 commits July 25, 2026 00:37
Decoding a known custom extension proves its structure, not the RFC profile
the typed encoders enforce. A `cRLDistributionPoints` value supplied through
`customExtensions` therefore still emitted the exact RFC 5280 §4.2.1.13
constructions this PR claims the builders reject: a non-directoryName
cRLIssuer, and more than one cRLIssuer DN alongside nameRelativeToCRLIssuer.
Both builder paths now run the same assertion the typed field runs, so the
escape is closed on certificates and CSRs alike.

`validateOid` checked decimal syntax only, so an OID such as `3.1` or `1.40`
passed it and then failed inside `objectIdentifier` with an uncoded `Error`.
It now rejects them with `invalid_oid`, matching the builder's coded-error
contract.

The parser stays tolerant of both, so the fixtures that feed it non-conformant
values move to the raw-extension helpers. Parametrized cases become `it.each`
so a failure names the case rather than the whole test.
Decoding proves a payload's structure, not that it obeys the RFC 5280
profile, so a `customExtensions` value under a known OID could still
carry duplicate policy OIDs, an over-long DisplayText, a non-URI OCSP
location, an empty nameConstraints, or a keyUsage with no bit set.

`ExtensionDefinition` now requires `assertProfile`, which delegates to
the encoder owning the rule. Builders run it over the decoded payload.
Parsing never calls it, so the parser stays tolerant.

The payload decodes once, inside the coded-error boundary. A profile
violation keeps its own code. A decode failure maps to
`malformed_known_extension_value`.

`encodeExtension` and the `encodeCertificatePolicies` duplicate scan
validate OIDs before encoding, so an unencodable arc yields
`invalid_oid` instead of a bare `Error`. `encodeBasicConstraints`
gains `path_length_requires_ca`.

CRLDP rules move to `assertCrlDistributionPointsProfile`, shared by
the encoder and the hook. `encodeDistributionPointName` now takes the
resolved name choice, dropping its unreachable branch.
`getAuthorityInfoAccessMethodOid` and `getExtendedKeyUsageOid` returned
the submitted OID, and `encodePolicyMappings` compared its input by
string, so an alias that encodes to a forbidden OID dodged the rule.
`1.3.6.1.5.5.7.048.1` accepted a dNSName OCSP location and
`2.5.29.032.0` passed the anyPolicy check. All three now resolve through
`validateOid`, which returns the canonical spelling.

`assertProfile` had only the decoded payload, so a `customExtensions`
entry could carry a criticality RFC 5280 forbids. It now also receives
the flag, and nameConstraints (§4.2.1.10), policyConstraints (§4.2.1.11),
and inhibitAnyPolicy (§4.2.1.14) require critical, while
authorityKeyIdentifier (§4.2.1.1), subjectKeyIdentifier (§4.2.1.2), and
authorityInfoAccess (§4.2.2.1) require non-critical.

§4.2.1.9 conditions basicConstraints criticality on the certificate
being a CA whose key signs certificates, which no single extension
carries, so that check sits beside `assertPathLengthKeyUsage` where the
effective keyUsage is already resolved.

The name-constraints tolerance fixture emitted a non-critical extension
through the builder and moves to the raw-extension helper.
RFC 5280 §4.2.1.9 covers any CA certificate whose key may validate
signatures on certificates, and §4.2.1.3 restricts a key only through a
keyUsage extension that reaches the wire. An absent keyUsage therefore
leaves the key free to validate certificate signatures, as does an empty
one, which the builder omits. Both were accepting a non-critical
`cA=true` custom basicConstraints.

Only an emitted keyUsage lacking keyCertSign takes the certificate out
of the clause's scope.
@kjanat kjanat added the cr:review Allow CodeRabbit review label Jul 25, 2026
@coderabbitai coderabbitai Bot added the security Vulnerability fix or security-relevant hardening label Jul 25, 2026
coderabbitai[bot]

This comment was marked as resolved.

`expectRejectedErrorCode` existed byte-identically in three suites; it
moves to `test/helpers.ts`.

The raw-extension helpers re-signed with `getSignatureAlgorithm(key)` and
no profile, so a fixture built with `signature: { kind: 'rsa-pss' }` came
back signed PKCS#1 v1.5 without complaint. `appendCertificateExtensions`
takes the profile, and both certificate wrappers plus the CSR wrapper
forward `input.signature`.

The duplicate-extension builder assertions in `parse.test.ts` matched the
message rather than `duplicate_extension_oid`, and neither awaited its
`.rejects` chain.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working cr:review Allow CodeRabbit review rfc-conformance RFC adherence and conformance evidence security Vulnerability fix or security-relevant hardening tests Test coverage and harnesses

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant