Skip to content

REF-31: Verify revocation data before trusting it - #98

Merged
thomasnymand merged 7 commits into
masterfrom
feature/REF-31-revocation-hardening
Sep 2, 2026
Merged

thomasnymand merged 7 commits into
masterfrom
feature/REF-31-revocation-hardening

Conversation

@thomasnymand

@thomasnymand thomasnymand commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator

Makes revocation data prove itself before the SP acts on it, and makes the two revocation paths agree on what they accept.

Problem

The CRL was fetched from the distribution point named in the certificate and consulted for the serial number, without checking who signed it or how current it was. The OCSP trust anchor was whatever certificate the issuer access location returned. Both locations come from the certificate being checked, so neither the absence of a serial number in a list nor a completed OCSP path validation meant anything on its own.

The two paths also disagreed. The OCSP path runs a full PKIX validation; the CRL path checked only the revocation list. Any exception from the OCSP path fell back to the CRL, so a certificate the OCSP path had rejected on its own merits could be accepted by the CRL path instead. Nothing downstream compensates: the assertion signature is verified against the certificate's key alone, so CRLChecker is the only place an IdP metadata certificate is validated at all.

Changes

Revocation data is authenticated

  • The CRL is authenticated and current. It must be issued by the CA that issued the certificate, verify against that CA's public key, and be within thisUpdate/nextUpdate allowing for the configured clock skew. A list without a nextUpdate is refused, since it cannot be told apart from one superseded long ago.
  • The CRL signer is authorised. The issuing CA's key usage must include cRLSign (RFC 5280 §4.2.1.3). The JDK's own CRL handling enforces this; this path fetches and verifies the list itself and so had to.
  • The issuer certificate is bound. The certificate fetched from the issuer access location must be the issuer of the certificate being checked, by name and by signature, before it is used as OCSP trust anchor or CRL signer. It must also carry basic constraints giving it the CA role and a key usage including keyCertSign.
  • Fetch locations are constrained. CRL, issuer certificate and OCSP responder URLs are restricted to http and https, and the fetches now have connect and read timeouts. Note that HTTPS is deliberately not required: the OCES CAs serve AIA, CRL and OCSP over plain http, so requiring TLS would disable revocation checking altogether. The signature checks above are what make plain http acceptable.
  • The issuer cache is self-healing. It was keyed by URL and never revisited, so a CA replaced at the same location would fail every check until the process restarted. A cached certificate is now reused only while it still is the issuer.

The two paths no longer disagree

  • Validity periods are checked once, before either path, for the certificate and for the CA used as trust anchor. Previously only the OCSP path looked at them, via PKIX, and a trust anchor's own validity is not checked by the path validation it anchors.
  • The CRL is consulted only when OCSP could not state a status — an unreachable or unnamed responder, an issuer certificate that could not be fetched. A PKIX rejection of the certificate itself now ends the check. The CRL says nothing about a certificate's validity, so letting it answer for a rejected certificate turned that rejection into an acceptance. This also covers ALGORITHM_CONSTRAINED, so a certificate rejected under jdk.certpath.disabledAlgorithms is no longer accepted by the other path — relevant to [OIO-ALG-01].
  • Revocation is detected from the PKIX reason (BasicReason.REVOKED) rather than by matching the text of the JDK's exception message.

What this does and does not establish

Trust is derived, not configured: the anchor is the CA that demonstrably issued the certificate under check, and those certificates come from the configured IdP metadata. There is no configured root trust store, so this does not establish that the issuing CA is a legitimate OCES CA. Revocation checking is therefore only as trustworthy as the metadata the certificates came from, which is a separate open issue (metadata is not signature-verified against a pinned trust root, and supportSelfSigned disables TLS validation for the metadata fetch).

Verification

mvn test → 143 tests, 0 failures, and the full reactor builds.

New CRLCheckerLocalTest mints a CA, a certificate and CRLs with TestPkiUtil and serves them from the MockServer already used by the test suite, so the logic is testable without the live CA. 14 cases: a certificate absent from a valid list is accepted; one listed in it is rejected; a list signed by another key, a stale list, a list without nextUpdate and a substituted CA are refused; a CA lacking cRLSign, lacking keyCertSign, lacking basic constraints, or past its validity period is not trusted; an expired and a not-yet-valid certificate are rejected; the CRL still answers when the responder cannot state a status, and does not answer for a certificate the OCSP path rejected.

Every negative case was checked to fail with the corresponding check disabled, so none of them passes for an unrelated reason. The live-CA tests in CRLCheckerTest pass, so the new checks also hold against real OCES data.

Also in this PR

idp/pom.xml overrides the Spring Boot parent's Lombok 1.18.16 with 1.18.46 and declares it in annotationProcessorPaths. The module did not compile on a current JDK: 1.18.16 predates the JDK 16 encapsulation of the compiler internals it uses, and since JDK 23 javac no longer runs annotation processors merely found on the classpath. Build fix only, unrelated to revocation checking.

Dependency note

bcpkix-jdk15on is added at test scope, pinned to 1.59 to match the bcprov 1.59 that OpenSAML already brings in; 1.64 fails at runtime against 1.59. If bcprov is bumped, bcpkix has to move with it.

The CRL was fetched from the distribution point named in the certificate and
consulted for the serial number without checking who signed it or how current
it was, and the OCSP trust anchor was whatever certificate the issuer access
location returned. Both locations come from the certificate being checked, so
neither the absence of a serial number in a list nor a completed OCSP path
validation meant anything on its own.

A CRL is now required to be issued by the CA that issued the certificate, to
verify against that CA's public key, and to be current within the configured
clock skew. A list without a nextUpdate is refused, since it cannot be told
apart from one superseded long ago. The certificate fetched from the issuer
access location is required to be the issuer of the certificate being checked,
by name and by signature, before it is used as OCSP trust anchor or CRL signer.

CRL, issuer certificate and OCSP responder locations are restricted to http and
https. The OCES CAs serve revocation data over plain http, which is fine as it
is signed, but a certificate must not be able to point the SP at file:, jar: or
any other protocol the JVM supports. Those fetches had no timeouts and now have
connect and read timeouts.

The issuer certificate cache was keyed by URL and never revisited, so a CA
replaced at the same location would fail every check until the process
restarted. A cached certificate is now reused only while it still is the issuer.

Tested against a locally minted CA served from MockServer: a certificate absent
from a valid list is accepted, one listed in it is rejected, and a list signed
by another key, a stale list, a list without nextUpdate and a substituted CA
are all refused. bcpkix is added as a test dependency to mint that material.
@thomasnymand
thomasnymand requested a review from mthiim August 18, 2026 08:23
Comment thread oiosaml/src/main/java/dk/gov/oio/saml/service/CRLChecker.java
Comment thread oiosaml/src/main/java/dk/gov/oio/saml/service/CRLChecker.java
Comment thread oiosaml/src/main/java/dk/gov/oio/saml/service/CRLChecker.java
…ion is inconclusive (e.g. OCSP responder not available), additional tests for KeyUsage and BasicConstraints.
# Conflicts:
#	oiosaml/src/test/java/dk/gov/oio/saml/util/TestConstants.java
@thomasnymand
thomasnymand requested a review from mthiim September 1, 2026 09:44

@mthiim mthiim left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please see comments

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It seems there is no locking on this map. I don't know if there is in the surrounding code but being static makes it even more likely there could be a race condition here.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

(An easy fix is of course to use a ConcurrentMap assuming it doesn't need to synchronize with other things)

@thomasnymand
thomasnymand requested a review from mthiim September 1, 2026 14:49
@thomasnymand
thomasnymand merged commit 20e0a45 into master Sep 2, 2026
2 checks passed
@thomasnymand
thomasnymand deleted the feature/REF-31-revocation-hardening branch September 2, 2026 10:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants