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
- The defect: give the nested CRL-signer build the caller's full trust-anchor set rather than
the single anchor the outer path selected.
- Masking 1: only add
defaultCRLSignCert to validCerts when it actually carries cRLSign,
or track "a real candidate was rejected" separately so signerLastException still surfaces.
- 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
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:
The CRL was downloaded successfully. The reproducer's own HTTP server logs the
200, and therun ends with
CRL fetches served by the local HTTP server: 1. So "No CRLs found ... Searched 0PKIXCRLStore(s)" is actively misleading. Three separate layers of exception masking sit between the
real failure and the message the caller sees.
Environment
bcprov-jdk18on1.85-Dorg.bouncycastle.x509.enableCRLDP=trueReproducer
Repro.javais self-contained and needs no external network: it generates a synthetic PKI andserves the CRL from a local
com.sun.net.httpserver.HttpServeron127.0.0.1.Prints
RESULT: FAILand exits non-zero.run.shwraps both of these:./run.shreproduce (two root generations)./run.sh --basecontrol run, one generation,RESULT: PASSThe only difference between the two is
-Dgenerations. SetBC_LIB=<dir>to point the script at adifferent set of BouncyCastle jars.
PKI shape
One shared, indirect CRL, published at a single URL and named by the
cRLDistributionPointsextension 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.CAchains 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
cRLIssuernamed in the distribution point. Selection between the two is thereforeby
authorityKeyIdentifier, which BouncyCastle does correctly (see below).The
--basecontrol differs only in having a single generation, so the CRL is necessarily signed bythe 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
authorityKeyIdentifiermatches the CRL, it carriescRLSign, and the CRL's issuer DNmatches the
cRLIssuerin the distribution point.Actual:
CertPathBuilderException/No CRLs found for issuer ….Root cause
Adding
System.errtracing toRFC3280CertPathUtilities(see "Reproducing the trace" below) gives:CRL signer selection works correctly — the
authorityKeyIdentifiernarrowing picks exactly theright candidate, and it carries
cRLSign. The failure is downstream, and then gets maskedthree times.
processCRLFvalidates the candidate signer's own path with a nestedengineBuild(RFC3280CertPathUtilities.java:600-624). ThePKIXExtendedParametersit inheritscarry 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 thenested 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
validCertswithoutchecking
cRLSign. SovalidCertsis never empty, the guard at:649(
if (validCerts.isEmpty() && signerLastException != null) throw signerLastException;) never fires,and the real
CertPathBuilderExceptionis dropped.Masking layer 2
:679: the key-usage loop then rejects that appended issuer (the root haskeyCertSignonly, which is precisely why CRL signing was delegated) and reportsIssuer certificate key usage extension does not permit CRL signing.Masking layer 3
checkCRLs(:1910) catches thatAnnotatedExceptionintolastException,then the fallback attempt at
:1949runs against a clone of the base parameters, which has noneof the CRLDP-derived CRL stores attached.
getCompleteCRLsfinds nothing and throwsRecoverableCertPathValidatorExceptionfromCertPathValidatorUtilities.checkCRLsNotEmpty(
:1175/:1383). That type is not anAnnotatedException, so thecatch (AnnotatedException e)around the fallback never sees it: it propagates andlastExceptionis 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 realattempt, and (c) implies the CRL could not be downloaded when it was fetched successfully.
Reproducing the trace
Take
RFC3280CertPathUtilities.javafrom thebcprovsources jar and addSystem.errprints at::557, beforecoll.add(defaultCRLSignCert)dumpparamsPKIX.getTrustAnchors(), the contentsof
coll, anddefaultCRLSignCertwith itscRLSignbit:649dumpvalidCerts.size()andsignerLastExceptionincluding its cause chain:1910, in the per-distribution-pointcatch (AnnotatedException e)insidecheckCRLsdumpeSuggested fixes
the single anchor the outer path selected.
defaultCRLSignCerttovalidCertswhen it actually carriescRLSign,or track "a real candidate was rejected" separately so
signerLastExceptionstill surfaces.checkCRLscatchRecoverableCertPathValidatorExceptiontoo, and chain
lastExceptionas its cause.Files
Repro.javarun.shbuild/run helper: no arguments reproduces,--baseis the passing controlRepro.java
run.sh