Skip to content

Indirect CRL rejected when its delegated signer is issued by a different (also trusted) root generation #2427

Description

@tobiasbayer

Summary

When a root CA delegates CRL signing to a separate CRL signer certificate (an indirect CRL,
IssuingDistributionPoint.indirectCRL = TRUE) and the PKI has more than one root generation,
path validation fails for any certificate whose CRL happens to be signed by a different
generation's signer than the one that issued the certificate under check. Even though every root
generation is supplied as a trust anchor and the correct signer certificate is supplied in a
CertStore.

The reported error names neither the real cause nor the real location:

java.security.cert.CertPathBuilderException: Certification path could not be validated.
  org.bouncycastle.jce.provider.RecoverableCertPathValidatorException:
  No CRLs found for issuer "serialNumber=1,c=DE,o=Test-PKI,cn=Test-Root.CA".
  CRL Distribution Points listed by the certificate: http://127.0.0.1:56303/root.crl.
  Searched 0 PKIXCRLStore(s) and 1 CertStore(s). -Dorg.bouncycastle.x509.enableCRLDP is set, ...

The CRL was downloaded successfully. The reproducer's own HTTP server logs the 200, and the
run ends with CRL fetches served by the local HTTP server: 1. So "No CRLs found ... Searched 0
PKIXCRLStore(s)" is actively misleading. Three separate layers of exception masking sit between the
real failure and the message the caller sees.

Environment

  • BouncyCastle bcprov-jdk18on 1.85
  • OpenJDK 23.0.2, macOS
  • -Dorg.bouncycastle.x509.enableCRLDP=true

Reproducer

Repro.java is self-contained and needs no external network: it generates a synthetic PKI and
serves the CRL from a local com.sun.net.httpserver.HttpServer on 127.0.0.1.

javac -cp bcprov-jdk18on-1.85.jar:bcpkix-jdk18on-1.85.jar:bcutil-jdk18on-1.85.jar -d build Repro.java
java  -cp build:bcprov-jdk18on-1.85.jar:bcpkix-jdk18on-1.85.jar:bcutil-jdk18on-1.85.jar Repro

Prints RESULT: FAIL and exits non-zero.

run.sh wraps both of these:

  • ./run.sh reproduce (two root generations)
  • ./run.sh --base control run, one generation, RESULT: PASS

The only difference between the two is -Dgenerations. Set BC_LIB=<dir> to point the script at a
different set of BouncyCastle jars.

PKI shape

Test-Root.CA (serialNumber=1)     keyUsage = keyCertSign ONLY    [trust anchor]
  |- Test-Root.CRL-S              keyUsage = cRLSign             [in CertStore]
  |- Test-Sub.CA                  <- the certificate being checked
Test-Root.CA (serialNumber=2)     keyUsage = keyCertSign ONLY    [trust anchor]
  |- Test-Root.CRL-S              same subject DN, different key [in CertStore]

One shared, indirect CRL, published at a single URL and named by the cRLDistributionPoints
extension of every certificate under either root, with
cRLIssuer = CN=Test-Root.CRL-S,O=Test-PKI,C=DE. The CRL is signed by generation 2's signer;
Test-Sub.CA chains to generation 1.

Both signer certificates deliberately share a subject DN. This is what real deployments look like
when a CA rolls its root but keeps the CRL signer's name stable, and it means the CRL's issuer DN
does match the cRLIssuer named in the distribution point. Selection between the two is therefore
by authorityKeyIdentifier, which BouncyCastle does correctly (see below).

The --base control differs only in having a single generation, so the CRL is necessarily signed by
the same generation that issued Test-Sub.CA. It passes, through the same code path.

Expected vs actual

Expected: the chain validates. The CRL signer is trusted (its issuer is a configured trust
anchor), its authorityKeyIdentifier matches the CRL, it carries cRLSign, and the CRL's issuer DN
matches the cRLIssuer in the distribution point.

Actual: CertPathBuilderException / No CRLs found for issuer ….

Root cause

Adding System.err tracing to RFC3280CertPathUtilities (see "Reproducing the trace" below) gives:

[crl-server] GET /root.crl -> 200, 485 bytes
[PATCH] processCRLF: trust anchors visible to the nested signer build = 1
[PATCH]   anchor: SERIALNUMBER=1, C=DE, O=Test-PKI, CN=Test-Root.CA
[PATCH] processCRLF: candidate signers found in CertStores = 1
[PATCH]   candidate: C=DE, O=Test-PKI, CN=Test-Root.CRL-S issuedBy=SERIALNUMBER=2, ... cRLSign=true
[PATCH]   defaultCRLSignCert (always appended) = SERIALNUMBER=1, ... cRLSign=false
[PATCH]   validCerts=1 signerLastException=CertPath for CRL signer failed to validate.
[PATCH]     suppressed: java.security.cert.CertPathBuilderException: No issuer certificate for certificate in certification path found.
[PATCH] primary distribution-point attempt #0 failed; this is the exception that gets discarded:
[PATCH]   org.bouncycastle.jce.provider.AnnotatedException: Issuer certificate key usage extension does not permit CRL signing.

CRL signer selection works correctly — the authorityKeyIdentifier narrowing picks exactly the
right candidate, and it carries cRLSign. The failure is downstream, and then gets masked
three times.

processCRLF validates the candidate signer's own path with a nested
engineBuild (RFC3280CertPathUtilities.java:600-624). The PKIXExtendedParameters it inherits
carry only the single trust anchor the outer path builder already selected (serialNumber=1),
not the full anchor set the caller configured. The signer is issued by serialNumber=2, so the
nested build cannot find its issuer and throws
No issuer certificate for certificate in certification path found.

A CRL signer legitimately anchored under a different, equally trusted root generation is therefore
unvalidatable.

Masking layer 1 RFC3280CertPathUtilities.java:557: coll.add(defaultCRLSignCert)
unconditionally appends the certificate's own issuer, and the loop adds it to validCerts without
checking cRLSign. So validCerts is never empty, the guard at :649
(if (validCerts.isEmpty() && signerLastException != null) throw signerLastException;) never fires,
and the real CertPathBuilderException is dropped.

Masking layer 2 :679: the key-usage loop then rejects that appended issuer (the root has
keyCertSign only, which is precisely why CRL signing was delegated) and reports
Issuer certificate key usage extension does not permit CRL signing.

Masking layer 3 checkCRLs (:1910) catches that AnnotatedException into lastException,
then the fallback attempt at :1949 runs against a clone of the base parameters, which has none
of the CRLDP-derived CRL stores attached. getCompleteCRLs finds nothing and throws
RecoverableCertPathValidatorException from CertPathValidatorUtilities.checkCRLsNotEmpty
(:1175 / :1383). That type is not an AnnotatedException, so the
catch (AnnotatedException e) around the fallback never sees it: it propagates and lastException
is discarded unread.

The caller is left with a message that (a) names the wrong issuer, (b) reports
Searched 0 PKIXCRLStore(s) describing the fallback's empty parameters rather than the real
attempt, and (c) implies the CRL could not be downloaded when it was fetched successfully.

Reproducing the trace

Take RFC3280CertPathUtilities.java from the bcprov sources jar and add System.err prints at:

  • :557, before coll.add(defaultCRLSignCert) dump paramsPKIX.getTrustAnchors(), the contents
    of coll, and defaultCRLSignCert with its cRLSign bit
  • :649 dump validCerts.size() and signerLastException including its cause chain
  • :1910, in the per-distribution-point catch (AnnotatedException e) inside checkCRLs dump e

Suggested fixes

  1. The defect: give the nested CRL-signer build the caller's full trust-anchor set rather than
    the single anchor the outer path selected.
  2. Masking 1: only add defaultCRLSignCert to validCerts when it actually carries cRLSign,
    or track "a real candidate was rejected" separately so signerLastException still surfaces.
  3. Masking 3: have the fallback in checkCRLs catch RecoverableCertPathValidatorException
    too, and chain lastException as its cause.

Files

  • Repro.java
  • run.sh build/run helper: no arguments reproduces, --base is the passing control

Repro.java
run.sh

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions