From ddf6c89fa5dca8f0969dcc50dd64fe1fa2aad5c3 Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Fri, 30 Jan 2026 10:48:33 +0200 Subject: [PATCH] Add resilient OCSP certificate revocation checker Signed-off-by: Madis Jaagup Laurson --- pom.xml | 29 ++ .../OcspCertificateRevocationChecker.java | 27 +- .../eu/webeid/ocsp/client/OcspClient.java | 4 +- .../eu/webeid/ocsp/client/OcspClientImpl.java | 30 +- .../ocsp/exceptions/OCSPClientException.java | 45 +++ ...erCertificateOCSPCheckFailedException.java | 4 + .../UserCertificateRevokedException.java | 4 + .../protocol/IssuerDistinguishedName.java | 21 + .../webeid/ocsp/service/AiaOcspService.java | 15 +- .../service/AiaOcspServiceConfiguration.java | 13 +- .../ocsp/service/FallbackOcspService.java | 111 +++++ .../FallbackOcspServiceConfiguration.java | 70 ++++ .../eu/webeid/ocsp/service/OcspService.java | 5 + .../ocsp/service/OcspServiceProvider.java | 24 +- ...lientOcspCertificateRevocationChecker.java | 378 ++++++++++++++++++ ...erCertificateOCSPCheckFailedException.java | 34 ++ ...ilientUserCertificateRevokedException.java | 24 ++ .../revocationcheck/RevocationInfo.java | 25 +- .../OcspCertificateRevocationCheckerTest.java | 34 +- .../ocsp/client/OcspClientOverrideTest.java | 5 +- .../protocol/OcspResponseValidatorTest.java | 20 + .../AiaOcspServiceConfigurationTest.java | 14 +- .../FallbackOcspServiceConfigurationTest.java | 39 ++ .../webeid/ocsp/service/OcspServiceMaker.java | 5 +- .../ocsp/service/OcspServiceProviderTest.java | 78 +++- ...tOcspCertificateRevocationCheckerTest.java | 366 +++++++++++++++++ .../resources/ocsp_response_unauthorized.der | 2 + 27 files changed, 1376 insertions(+), 50 deletions(-) create mode 100644 src/main/java/eu/webeid/ocsp/exceptions/OCSPClientException.java create mode 100644 src/main/java/eu/webeid/ocsp/protocol/IssuerDistinguishedName.java create mode 100644 src/main/java/eu/webeid/ocsp/service/FallbackOcspService.java create mode 100644 src/main/java/eu/webeid/ocsp/service/FallbackOcspServiceConfiguration.java create mode 100644 src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java create mode 100644 src/main/java/eu/webeid/resilientocsp/exceptions/ResilientUserCertificateOCSPCheckFailedException.java create mode 100644 src/main/java/eu/webeid/resilientocsp/exceptions/ResilientUserCertificateRevokedException.java create mode 100644 src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceConfigurationTest.java create mode 100644 src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java create mode 100644 src/test/resources/ocsp_response_unauthorized.der diff --git a/pom.xml b/pom.xml index 5d5a0e8a..3682952f 100644 --- a/pom.xml +++ b/pom.xml @@ -18,6 +18,7 @@ 1.86 2.22.2 2.0.19 + 2.3.0 5.14.4 3.27.7 5.23.0 @@ -67,6 +68,34 @@ bcpkix-jdk18on ${bouncycastle.version} + + io.github.resilience4j + resilience4j-all + ${resilience4j.version} + + + io.github.resilience4j + resilience4j-bulkhead + + + io.github.resilience4j + resilience4j-cache + + + io.github.resilience4j + resilience4j-ratelimiter + + + io.github.resilience4j + resilience4j-timelimiter + + + + + io.github.resilience4j + resilience4j-vavr + ${resilience4j.version} + org.junit.jupiter diff --git a/src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java b/src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java index d8db3d32..80696e3a 100644 --- a/src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java +++ b/src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java @@ -4,7 +4,7 @@ package eu.webeid.ocsp; import eu.webeid.ocsp.client.OcspClient; -import eu.webeid.ocsp.exceptions.UserCertificateRevokedException; +import eu.webeid.ocsp.exceptions.OCSPClientException; import eu.webeid.ocsp.protocol.DigestCalculatorImpl; import eu.webeid.ocsp.protocol.OcspRequestBuilder; import eu.webeid.ocsp.protocol.OcspResponseValidator; @@ -46,7 +46,7 @@ import static eu.webeid.security.util.DateAndTime.requirePositiveDuration; import static java.util.Objects.requireNonNull; -public final class OcspCertificateRevocationChecker implements CertificateRevocationChecker { +public class OcspCertificateRevocationChecker implements CertificateRevocationChecker { public static final Duration DEFAULT_TIME_SKEW = Duration.ofMinutes(15); public static final Duration DEFAULT_THIS_UPDATE_AGE = Duration.ofMinutes(2); @@ -113,7 +113,7 @@ public List validateCertificateNotRevoked(X509Certificate subjec } LOG.debug("OCSP response received successfully"); - verifyOcspResponse(basicResponse, ocspService, certificateId, issuerCertificate); + verifyOcspResponse(basicResponse, ocspService, certificateId, issuerCertificate, maxOcspResponseThisUpdateAge); if (ocspService.doesSupportNonce()) { checkNonce(request, basicResponse, ocspResponderUri); } @@ -121,12 +121,12 @@ public List validateCertificateNotRevoked(X509Certificate subjec return List.of(new RevocationInfo(ocspResponderUri, Map.of(RevocationInfo.KEY_OCSP_RESPONSE, response))); - } catch (OCSPException | CertificateException | OperatorCreationException | IOException e) { + } catch (OCSPException | CertificateException | OperatorCreationException | IOException | OCSPClientException e) { throw new UserCertificateOCSPCheckFailedException(e, ocspResponderUri); } } - private void verifyOcspResponse(BasicOCSPResp basicResponse, OcspService ocspService, CertificateID requestCertificateId, X509Certificate issuerCertificate) throws UserCertificateOCSPCheckFailedException, UserCertificateRevokedException, OCSPException, CertificateException, OperatorCreationException { + protected void verifyOcspResponse(BasicOCSPResp basicResponse, OcspService ocspService, CertificateID requestCertificateId, X509Certificate issuerCertificate, Duration maxOcspResponseThisUpdateAge) throws AuthTokenException, OCSPException, CertificateException, OperatorCreationException { // The verification algorithm follows RFC 2560, https://www.ietf.org/rfc/rfc2560.txt. // // 3.2. Signed Response Acceptance Requirements @@ -189,7 +189,7 @@ private void verifyOcspResponse(BasicOCSPResp basicResponse, OcspService ocspSer LOG.debug("OCSP check result is GOOD"); } - private static void checkNonce(OCSPReq request, BasicOCSPResp response, URI ocspResponderUri) throws UserCertificateOCSPCheckFailedException { + protected static void checkNonce(OCSPReq request, BasicOCSPResp response, URI ocspResponderUri) throws UserCertificateOCSPCheckFailedException { final Extension requestNonce = request.getExtension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce); final Extension responseNonce = response.getExtension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce); if (requestNonce == null || responseNonce == null) { @@ -202,14 +202,14 @@ private static void checkNonce(OCSPReq request, BasicOCSPResp response, URI ocsp } } - private static CertificateID getCertificateId(X509Certificate subjectCertificate, X509Certificate issuerCertificate) throws CertificateEncodingException, IOException, OCSPException { + protected static CertificateID getCertificateId(X509Certificate subjectCertificate, X509Certificate issuerCertificate) throws CertificateEncodingException, IOException, OCSPException { final BigInteger serial = subjectCertificate.getSerialNumber(); final DigestCalculator digestCalculator = DigestCalculatorImpl.sha1(); return new CertificateID(digestCalculator, new X509CertificateHolder(issuerCertificate.getEncoded()), serial); } - private static String ocspStatusToString(int status) { + protected static String ocspStatusToString(int status) { return switch (status) { case OCSPResp.MALFORMED_REQUEST -> "malformed request"; case OCSPResp.INTERNAL_ERROR -> "internal error"; @@ -220,4 +220,15 @@ private static String ocspStatusToString(int status) { }; } + protected OcspClient getOcspClient() { + return ocspClient; + } + + protected OcspServiceProvider getOcspServiceProvider() { + return ocspServiceProvider; + } + + protected Duration getMaxOcspResponseThisUpdateAge() { + return maxOcspResponseThisUpdateAge; + } } diff --git a/src/main/java/eu/webeid/ocsp/client/OcspClient.java b/src/main/java/eu/webeid/ocsp/client/OcspClient.java index 61c2c5bf..2ebb62ae 100644 --- a/src/main/java/eu/webeid/ocsp/client/OcspClient.java +++ b/src/main/java/eu/webeid/ocsp/client/OcspClient.java @@ -3,14 +3,14 @@ package eu.webeid.ocsp.client; +import eu.webeid.ocsp.exceptions.OCSPClientException; import org.bouncycastle.cert.ocsp.OCSPReq; import org.bouncycastle.cert.ocsp.OCSPResp; -import java.io.IOException; import java.net.URI; public interface OcspClient { - OCSPResp request(URI url, OCSPReq request) throws IOException; + OCSPResp request(URI url, OCSPReq request) throws OCSPClientException; } diff --git a/src/main/java/eu/webeid/ocsp/client/OcspClientImpl.java b/src/main/java/eu/webeid/ocsp/client/OcspClientImpl.java index 4f842446..74dd466c 100644 --- a/src/main/java/eu/webeid/ocsp/client/OcspClientImpl.java +++ b/src/main/java/eu/webeid/ocsp/client/OcspClientImpl.java @@ -3,6 +3,7 @@ package eu.webeid.ocsp.client; +import eu.webeid.ocsp.exceptions.OCSPClientException; import org.bouncycastle.cert.ocsp.OCSPReq; import org.bouncycastle.cert.ocsp.OCSPResp; import org.slf4j.Logger; @@ -43,15 +44,21 @@ public static OcspClient build(Duration ocspRequestTimeout) { * @param uri OCSP server URL * @param ocspReq OCSP request * @return OCSP response from the server - * @throws IOException if the request could not be executed due to cancellation, a connectivity problem or timeout, + * @throws OCSPClientException if the request could not be executed due to cancellation, a connectivity problem or timeout, * or if the response status is not successful, or if response has wrong content type. */ @Override - public OCSPResp request(URI uri, OCSPReq ocspReq) throws IOException { + public OCSPResp request(URI uri, OCSPReq ocspReq) throws OCSPClientException { + byte[] encodedOcspReq; + try { + encodedOcspReq = ocspReq.getEncoded(); + } catch (IOException e) { + throw new OCSPClientException(e); + } final HttpRequest request = HttpRequest.newBuilder() .uri(uri) .header(CONTENT_TYPE, OCSP_REQUEST_TYPE) - .POST(HttpRequest.BodyPublishers.ofByteArray(ocspReq.getEncoded())) + .POST(HttpRequest.BodyPublishers.ofByteArray(encodedOcspReq)) .timeout(ocspRequestTimeout) .build(); @@ -60,19 +67,28 @@ public OCSPResp request(URI uri, OCSPReq ocspReq) throws IOException { response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - throw new IOException("Interrupted while sending OCSP request", e); + throw new OCSPClientException("Interrupted while sending OCSP request", e); + } catch (IOException e) { + throw new OCSPClientException(e); } if (response.statusCode() != 200) { - throw new IOException("OCSP request was not successful, response: " + response); + throw new OCSPClientException("OCSP request was not successful", response.body(), response.statusCode()); } else { LOG.debug("OCSP response: {}", response); } final String contentType = response.headers().firstValue(CONTENT_TYPE).orElse(""); if (!contentType.startsWith(OCSP_RESPONSE_TYPE)) { - throw new IOException("OCSP response content type is not " + OCSP_RESPONSE_TYPE); + throw new OCSPClientException("OCSP response content type is not " + OCSP_RESPONSE_TYPE); + } + + OCSPResp ocspResp; + try { + ocspResp = new OCSPResp(response.body()); + } catch (IOException e) { + throw new OCSPClientException(e); } - return new OCSPResp(response.body()); + return ocspResp; } public OcspClientImpl(HttpClient httpClient, Duration ocspRequestTimeout) { diff --git a/src/main/java/eu/webeid/ocsp/exceptions/OCSPClientException.java b/src/main/java/eu/webeid/ocsp/exceptions/OCSPClientException.java new file mode 100644 index 00000000..c025d4ce --- /dev/null +++ b/src/main/java/eu/webeid/ocsp/exceptions/OCSPClientException.java @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.ocsp.exceptions; + +public class OCSPClientException extends Exception { + + private final byte[] responseBody; + + private final Integer statusCode; + + public OCSPClientException() { + this(null, null); + } + + public OCSPClientException(String message) { + this(message, null, null); + } + + public OCSPClientException(Throwable cause) { + this(null, cause, null, null); + } + + public OCSPClientException(String message, Throwable cause) { + this(message, cause, null, null); + } + + public OCSPClientException(String message, byte[] responseBody, Integer statusCode) { + this(message, null, responseBody, statusCode); + } + + public OCSPClientException(String message, Throwable cause, byte[] responseBody, Integer statusCode) { + super(message, cause); + this.responseBody = responseBody; + this.statusCode = statusCode; + } + + public byte[] getResponseBody() { + return responseBody; + } + + public Integer getStatusCode() { + return statusCode; + } +} diff --git a/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateOCSPCheckFailedException.java b/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateOCSPCheckFailedException.java index f440542e..adae8579 100644 --- a/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateOCSPCheckFailedException.java +++ b/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateOCSPCheckFailedException.java @@ -14,6 +14,10 @@ */ public class UserCertificateOCSPCheckFailedException extends CertificateRevocationCheckFailedException { + public UserCertificateOCSPCheckFailedException() { + super("User certificate revocation check has failed"); + } + public UserCertificateOCSPCheckFailedException(Throwable cause, URI ocspResponderUri) { super(appendResponderUri("User certificate revocation check has failed", ocspResponderUri), cause); } diff --git a/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateRevokedException.java b/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateRevokedException.java index f5ce0d6d..c0eb08e3 100644 --- a/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateRevokedException.java +++ b/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateRevokedException.java @@ -33,6 +33,10 @@ */ public class UserCertificateRevokedException extends CertificateRevokedException { + public UserCertificateRevokedException() { + super("User certificate has been revoked"); + } + public UserCertificateRevokedException(URI ocspResponderUri) { super(appendResponderUri("User certificate has been revoked", ocspResponderUri)); } diff --git a/src/main/java/eu/webeid/ocsp/protocol/IssuerDistinguishedName.java b/src/main/java/eu/webeid/ocsp/protocol/IssuerDistinguishedName.java new file mode 100644 index 00000000..8969350a --- /dev/null +++ b/src/main/java/eu/webeid/ocsp/protocol/IssuerDistinguishedName.java @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.ocsp.protocol; + +import org.bouncycastle.asn1.x500.X500Name; + +import java.security.cert.X509Certificate; +import java.util.Objects; + +public class IssuerDistinguishedName { + + public static X500Name getIssuerDistinguishedName(X509Certificate certificate) { + Objects.requireNonNull(certificate, "certificate"); + return X500Name.getInstance(certificate.getIssuerX500Principal().getEncoded()); + } + + private IssuerDistinguishedName() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/src/main/java/eu/webeid/ocsp/service/AiaOcspService.java b/src/main/java/eu/webeid/ocsp/service/AiaOcspService.java index 48ee9b7b..21980ba5 100644 --- a/src/main/java/eu/webeid/ocsp/service/AiaOcspService.java +++ b/src/main/java/eu/webeid/ocsp/service/AiaOcspService.java @@ -9,6 +9,7 @@ import eu.webeid.ocsp.exceptions.UserCertificateOCSPCheckFailedException; import eu.webeid.ocsp.protocol.OcspResponseValidator; import eu.webeid.security.validator.revocationcheck.RevocationMode; +import org.bouncycastle.asn1.x500.X500Name; import org.bouncycastle.cert.X509CertificateHolder; import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; @@ -19,8 +20,10 @@ import java.security.cert.X509Certificate; import java.util.Date; import java.util.Objects; +import java.util.Optional; import java.util.Set; +import static eu.webeid.ocsp.protocol.IssuerDistinguishedName.getIssuerDistinguishedName; import static eu.webeid.ocsp.protocol.OcspUrl.getOcspUri; /** @@ -33,13 +36,16 @@ public class AiaOcspService implements OcspService { private final CertStore trustedCACertificateCertStore; private final URI url; private final boolean supportsNonce; + private final FallbackOcspService fallbackOcspService; - public AiaOcspService(AiaOcspServiceConfiguration configuration, X509Certificate certificate) throws AuthTokenException { + public AiaOcspService(AiaOcspServiceConfiguration configuration, X509Certificate certificate, FallbackOcspService fallbackOcspService) throws AuthTokenException { Objects.requireNonNull(configuration); this.trustedCACertificateAnchors = configuration.getTrustedCACertificateAnchors(); this.trustedCACertificateCertStore = configuration.getTrustedCACertificateCertStore(); this.url = getOcspAiaUrlFromCertificate(Objects.requireNonNull(certificate)); - this.supportsNonce = !configuration.getNonceDisabledOcspUrls().contains(this.url); + this.fallbackOcspService = fallbackOcspService; + X500Name issuerDN = getIssuerDistinguishedName(certificate); + this.supportsNonce = !configuration.getNonceDisabledIssuerDNs().contains(issuerDN); } @Override @@ -52,6 +58,11 @@ public URI getAccessLocation() { return url; } + @Override + public Optional getFallbackService() { + return Optional.ofNullable(fallbackOcspService); + } + @Override public void validateResponderCertificate(X509CertificateHolder cert, X509Certificate issuerCertificate, Date now) throws AuthTokenException { try { diff --git a/src/main/java/eu/webeid/ocsp/service/AiaOcspServiceConfiguration.java b/src/main/java/eu/webeid/ocsp/service/AiaOcspServiceConfiguration.java index 4d56c211..44b601fd 100644 --- a/src/main/java/eu/webeid/ocsp/service/AiaOcspServiceConfiguration.java +++ b/src/main/java/eu/webeid/ocsp/service/AiaOcspServiceConfiguration.java @@ -3,7 +3,8 @@ package eu.webeid.ocsp.service; -import java.net.URI; +import org.bouncycastle.asn1.x500.X500Name; + import java.security.cert.CertStore; import java.security.cert.TrustAnchor; import java.util.Collection; @@ -12,18 +13,18 @@ public class AiaOcspServiceConfiguration { - private final Collection nonceDisabledOcspUrls; + private final Collection nonceDisabledIssuerDNs; private final Set trustedCACertificateAnchors; private final CertStore trustedCACertificateCertStore; - public AiaOcspServiceConfiguration(Collection nonceDisabledOcspUrls, Set trustedCACertificateAnchors, CertStore trustedCACertificateCertStore) { - this.nonceDisabledOcspUrls = Set.copyOf(nonceDisabledOcspUrls); + public AiaOcspServiceConfiguration(Collection nonceDisabledIssuerDNs, Set trustedCACertificateAnchors, CertStore trustedCACertificateCertStore) { + this.nonceDisabledIssuerDNs = Set.copyOf(nonceDisabledIssuerDNs); this.trustedCACertificateAnchors = Set.copyOf(trustedCACertificateAnchors); this.trustedCACertificateCertStore = Objects.requireNonNull(trustedCACertificateCertStore); } - public Collection getNonceDisabledOcspUrls() { - return nonceDisabledOcspUrls; + public Collection getNonceDisabledIssuerDNs() { + return nonceDisabledIssuerDNs; } public Set getTrustedCACertificateAnchors() { diff --git a/src/main/java/eu/webeid/ocsp/service/FallbackOcspService.java b/src/main/java/eu/webeid/ocsp/service/FallbackOcspService.java new file mode 100644 index 00000000..c272c903 --- /dev/null +++ b/src/main/java/eu/webeid/ocsp/service/FallbackOcspService.java @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.ocsp.service; + +import eu.webeid.ocsp.exceptions.OCSPCertificateException; +import eu.webeid.ocsp.protocol.OcspResponseValidator; +import eu.webeid.security.certificate.CertificateValidator; +import eu.webeid.security.exceptions.AuthTokenException; +import eu.webeid.security.validator.revocationcheck.RevocationMode; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; + +import java.net.URI; +import java.security.GeneralSecurityException; +import java.security.cert.CertStore; +import java.security.cert.TrustAnchor; +import java.security.cert.X509Certificate; +import java.util.Date; +import java.util.Objects; +import java.util.Set; + + +import static eu.webeid.security.certificate.CertificateValidator.requireCertificateIsValidOnDate; + +public class FallbackOcspService implements OcspService { + + private final JcaX509CertificateConverter certificateConverter = new JcaX509CertificateConverter(); + private final URI url; + private final boolean supportsNonce; + private final X509Certificate trustedResponderCertificate; + private final X509Certificate issuerCertificate; + private final FallbackOcspService nextFallback; + private final Set trustedCACertificateAnchors; + private final CertStore trustedCACertificateCertStore; + + public FallbackOcspService(FallbackOcspServiceConfiguration configuration) { + this.url = configuration.getAccessLocation(); + this.supportsNonce = configuration.doesSupportNonce(); + this.trustedResponderCertificate = configuration.getResponderCertificate(); + this.issuerCertificate = configuration.getIssuerCertificate(); + this.nextFallback = configuration.getNextFallbackConfiguration() != null + ? new FallbackOcspService(configuration.getNextFallbackConfiguration()) + : null; + this.trustedCACertificateAnchors = configuration.getTrustedCACertificateAnchors(); + this.trustedCACertificateCertStore = configuration.getTrustedCACertificateCertStore(); + } + + @Override + public boolean doesSupportNonce() { + return supportsNonce; + } + + @Override + public URI getAccessLocation() { + return url; + } + + @Override + public void validateResponderCertificate(X509CertificateHolder cert, X509Certificate issuerCertificate, Date now) throws AuthTokenException { + try { + Objects.requireNonNull(issuerCertificate, "issuerCertificate"); + if (!this.issuerCertificate.equals(issuerCertificate)) { + throw new OCSPCertificateException("Fallback OCSP service is not configured for the subject certificate's issuer"); + } + final X509Certificate responderCertificate = certificateConverter.getCertificate(cert); + requireCertificateIsValidOnDate(responderCertificate, now, "Fallback OCSP responder"); + if (trustedResponderCertificate != null) { + validatePinnedResponderCertificate(responderCertificate); + } else { + validateResponderCertificateAgainstTrustedCa(responderCertificate, issuerCertificate, now); + } + } catch (GeneralSecurityException e) { + throw new OCSPCertificateException("Invalid responder certificate", e); + } + } + + private void validatePinnedResponderCertificate(X509Certificate responderCertificate) throws OCSPCertificateException { + // Certificate pinning is implemented simply by comparing the certificates or their public keys, + // see https://owasp.org/www-community/controls/Certificate_and_Public_Key_Pinning. + if (!trustedResponderCertificate.equals(responderCertificate)) { + throw new OCSPCertificateException("Responder certificate from the OCSP response is not equal to " + + "the configured fallback OCSP responder certificate"); + } + } + + private void validateResponderCertificateAgainstTrustedCa(X509Certificate responderCertificate, X509Certificate issuerCertificate, Date now) throws AuthTokenException, GeneralSecurityException { + if (!responderCertificate.equals(issuerCertificate)) { + OcspResponseValidator.validateHasSigningExtension(responderCertificate); + // A delegated OCSP signer must be issued directly by the CA whose certificate status was requested. + if (!responderCertificate.getIssuerX500Principal().equals(issuerCertificate.getSubjectX500Principal())) { + throw new OCSPCertificateException("Fallback OCSP responder is not issued by the subject certificate's issuer"); + } + responderCertificate.verify(issuerCertificate.getPublicKey()); + } + CertificateValidator.validateCertificateTrustAndRevocation( + responderCertificate, + trustedCACertificateAnchors, + trustedCACertificateCertStore, + now, + RevocationMode.DISABLED, + null, + null, + false + ); + } + + public FallbackOcspService getNextFallback() { + return nextFallback; + } +} diff --git a/src/main/java/eu/webeid/ocsp/service/FallbackOcspServiceConfiguration.java b/src/main/java/eu/webeid/ocsp/service/FallbackOcspServiceConfiguration.java new file mode 100644 index 00000000..ead8e3e7 --- /dev/null +++ b/src/main/java/eu/webeid/ocsp/service/FallbackOcspServiceConfiguration.java @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.ocsp.service; + +import eu.webeid.ocsp.exceptions.OCSPCertificateException; +import eu.webeid.ocsp.protocol.OcspResponseValidator; + +import java.net.URI; +import java.security.cert.CertStore; +import java.security.cert.TrustAnchor; +import java.security.cert.X509Certificate; +import java.util.Objects; +import java.util.Set; + +public class FallbackOcspServiceConfiguration { + + private final URI accessLocation; + private final X509Certificate responderCertificate; + private final boolean doesSupportNonce; + private final FallbackOcspServiceConfiguration nextFallbackConfiguration; + private final X509Certificate issuerCertificate; + private final Set trustedCACertificateAnchors; + private final CertStore trustedCACertificateCertStore; + + public FallbackOcspServiceConfiguration(URI accessLocation, X509Certificate responderCertificate, + boolean doesSupportNonce, + FallbackOcspServiceConfiguration nextFallbackConfiguration, + X509Certificate issuerCertificate, Set trustedCACertificateAnchors, + CertStore trustedCACertificateCertStore) throws OCSPCertificateException { + this.accessLocation = Objects.requireNonNull(accessLocation, "Fallback OCSP service access location"); + this.responderCertificate = responderCertificate; + if (responderCertificate != null) { + OcspResponseValidator.validateHasSigningExtension(responderCertificate); + } + this.doesSupportNonce = doesSupportNonce; + this.nextFallbackConfiguration = nextFallbackConfiguration; + this.issuerCertificate = Objects.requireNonNull(issuerCertificate, "issuerCertificate"); + this.trustedCACertificateAnchors = Set.copyOf(Objects.requireNonNull(trustedCACertificateAnchors, "trustedCACertificateAnchors")); + this.trustedCACertificateCertStore = Objects.requireNonNull(trustedCACertificateCertStore, "trustedCACertificateCertStore"); + } + + public URI getAccessLocation() { + return accessLocation; + } + + public X509Certificate getResponderCertificate() { + return responderCertificate; + } + + public boolean doesSupportNonce() { + return doesSupportNonce; + } + + public FallbackOcspServiceConfiguration getNextFallbackConfiguration() { + return nextFallbackConfiguration; + } + + public X509Certificate getIssuerCertificate() { + return issuerCertificate; + } + + public Set getTrustedCACertificateAnchors() { + return trustedCACertificateAnchors; + } + + public CertStore getTrustedCACertificateCertStore() { + return trustedCACertificateCertStore; + } +} diff --git a/src/main/java/eu/webeid/ocsp/service/OcspService.java b/src/main/java/eu/webeid/ocsp/service/OcspService.java index 96d055ed..221737ab 100644 --- a/src/main/java/eu/webeid/ocsp/service/OcspService.java +++ b/src/main/java/eu/webeid/ocsp/service/OcspService.java @@ -9,6 +9,7 @@ import java.net.URI; import java.security.cert.X509Certificate; import java.util.Date; +import java.util.Optional; public interface OcspService { @@ -18,4 +19,8 @@ public interface OcspService { void validateResponderCertificate(X509CertificateHolder cert, X509Certificate issuerCertificate, Date now) throws AuthTokenException; + default Optional getFallbackService() { + return Optional.empty(); + } + } diff --git a/src/main/java/eu/webeid/ocsp/service/OcspServiceProvider.java b/src/main/java/eu/webeid/ocsp/service/OcspServiceProvider.java index d7987144..96fca916 100644 --- a/src/main/java/eu/webeid/ocsp/service/OcspServiceProvider.java +++ b/src/main/java/eu/webeid/ocsp/service/OcspServiceProvider.java @@ -6,18 +6,38 @@ import eu.webeid.security.exceptions.AuthTokenException; import java.security.cert.X509Certificate; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; import java.util.Objects; public class OcspServiceProvider { private final DesignatedOcspService designatedOcspService; private final AiaOcspServiceConfiguration aiaOcspServiceConfiguration; + private final Map fallbackOcspServiceMap; public OcspServiceProvider(DesignatedOcspServiceConfiguration designatedOcspServiceConfiguration, AiaOcspServiceConfiguration aiaOcspServiceConfiguration) { + this(designatedOcspServiceConfiguration, aiaOcspServiceConfiguration, null); + } + + public OcspServiceProvider(DesignatedOcspServiceConfiguration designatedOcspServiceConfiguration, AiaOcspServiceConfiguration aiaOcspServiceConfiguration, Collection fallbackOcspServiceConfigurations) { designatedOcspService = designatedOcspServiceConfiguration != null ? new DesignatedOcspService(designatedOcspServiceConfiguration) : null; this.aiaOcspServiceConfiguration = Objects.requireNonNull(aiaOcspServiceConfiguration, "aiaOcspServiceConfiguration"); + this.fallbackOcspServiceMap = buildFallbackOcspServiceMap(fallbackOcspServiceConfigurations); + } + + private static Map buildFallbackOcspServiceMap(Collection fallbackOcspServiceConfigurations) { + if (fallbackOcspServiceConfigurations != null) { + Map fallbackOcspServices = new HashMap<>(); + for (FallbackOcspServiceConfiguration configuration : fallbackOcspServiceConfigurations) { + fallbackOcspServices.put(configuration.getIssuerCertificate(), new FallbackOcspService(configuration)); + } + return Map.copyOf(fallbackOcspServices); + } + return Map.of(); } /** @@ -33,7 +53,7 @@ public OcspService getService(X509Certificate certificate, X509Certificate issue if (designatedOcspService != null && designatedOcspService.supportsIssuer(issuerCertificate)) { return designatedOcspService; } - return new AiaOcspService(aiaOcspServiceConfiguration, certificate); + final FallbackOcspService fallbackOcspService = fallbackOcspServiceMap.get(issuerCertificate); + return new AiaOcspService(aiaOcspServiceConfiguration, certificate, fallbackOcspService); } - } diff --git a/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java b/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java new file mode 100644 index 00000000..4eeaf2ef --- /dev/null +++ b/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java @@ -0,0 +1,378 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.resilientocsp; + +import eu.webeid.ocsp.OcspCertificateRevocationChecker; +import eu.webeid.ocsp.client.OcspClient; +import eu.webeid.ocsp.exceptions.OCSPClientException; +import eu.webeid.ocsp.exceptions.UserCertificateRevokedException; +import eu.webeid.ocsp.protocol.OcspRequestBuilder; +import eu.webeid.ocsp.service.OcspService; +import eu.webeid.ocsp.service.OcspServiceProvider; +import eu.webeid.resilientocsp.exceptions.ResilientUserCertificateOCSPCheckFailedException; +import eu.webeid.resilientocsp.exceptions.ResilientUserCertificateRevokedException; +import eu.webeid.ocsp.service.FallbackOcspService; +import eu.webeid.security.exceptions.AuthTokenException; +import eu.webeid.security.validator.ValidationInfo; +import eu.webeid.security.validator.revocationcheck.RevocationInfo; +import io.github.resilience4j.circuitbreaker.CallNotPermittedException; +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +import io.github.resilience4j.core.functions.CheckedSupplier; +import io.github.resilience4j.decorators.Decorators; +import io.github.resilience4j.retry.Retry; +import io.github.resilience4j.retry.RetryConfig; +import io.github.resilience4j.retry.RetryRegistry; +import io.vavr.control.Try; +import org.bouncycastle.asn1.ocsp.OCSPResponseStatus; +import org.bouncycastle.cert.ocsp.BasicOCSPResp; +import org.bouncycastle.cert.ocsp.CertificateID; +import org.bouncycastle.cert.ocsp.OCSPReq; +import org.bouncycastle.cert.ocsp.OCSPResp; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URI; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static eu.webeid.security.util.DateAndTime.requirePositiveDuration; +import static java.util.Objects.requireNonNull; + +/** + * OCSP revocation checker that falls back to configured fallback OCSP responders when the primary OCSP service fails. + * + *

Retry and circuit breaker handling are applied only when a fallback OCSP service is configured for the + * certificate issuer. If no fallback is configured, validation is handled by the primary OCSP service directly. + */ +public class ResilientOcspCertificateRevocationChecker extends OcspCertificateRevocationChecker { + + private static final Logger LOG = LoggerFactory.getLogger(ResilientOcspCertificateRevocationChecker.class); + + private final CircuitBreakerRegistry circuitBreakerRegistry; + private final RetryRegistry retryRegistry; + private final Duration fallbackMaxOcspResponseThisUpdateAge; + + public ResilientOcspCertificateRevocationChecker(OcspClient ocspClient, + OcspServiceProvider ocspServiceProvider, + CircuitBreakerConfig circuitBreakerConfig, + RetryConfig retryConfig, + Duration allowedOcspResponseTimeSkew, + Duration primaryMaxOcspResponseThisUpdateAge, + Duration fallbackMaxOcspResponseThisUpdateAge) { + super(ocspClient, ocspServiceProvider, allowedOcspResponseTimeSkew, primaryMaxOcspResponseThisUpdateAge); + this.fallbackMaxOcspResponseThisUpdateAge = requirePositiveDuration(fallbackMaxOcspResponseThisUpdateAge, "fallbackMaxOcspResponseThisUpdateAge"); + this.circuitBreakerRegistry = CircuitBreakerRegistry.custom() + .withCircuitBreakerConfig(getCircuitBreakerConfig(circuitBreakerConfig)) + .build(); + this.retryRegistry = retryConfig != null ? RetryRegistry.custom() + .withRetryConfig(getRetryConfig(retryConfig)) + .build() : null; + if (LOG.isDebugEnabled()) { + this.circuitBreakerRegistry.getEventPublisher() + .onEntryAdded(entryAddedEvent -> { + CircuitBreaker circuitBreaker = entryAddedEvent.getAddedEntry(); + LOG.debug("CircuitBreaker {} added", circuitBreaker.getName()); + circuitBreaker.getEventPublisher() + .onEvent(event -> LOG.debug(event.toString())); + }); + } + } + + @Override + public List validateCertificateNotRevoked(X509Certificate subjectCertificate, + X509Certificate issuerCertificate) throws AuthTokenException { + OcspService primaryService = getOcspServiceProvider().getService(subjectCertificate, issuerCertificate); + Optional firstFallbackServiceOpt = primaryService.getFallbackService(); + if (firstFallbackServiceOpt.isEmpty()) { + // Without a configured fallback, use the primary service directly without retry or circuit breaker. + return List.of(request(primaryService, subjectCertificate, issuerCertificate, getMaxOcspResponseThisUpdateAge())); + } + + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(primaryService.getAccessLocation().toASCIIString()); + List revocationInfoList = new ArrayList<>(); + CheckedSupplier fallbackSupplier = buildFallbackSupplier(firstFallbackServiceOpt.get(), subjectCertificate, + issuerCertificate, revocationInfoList); + CheckedSupplier decoratedSupplier = decorateWithResilience(primaryService, subjectCertificate, + issuerCertificate, revocationInfoList, fallbackSupplier, circuitBreaker); + + // Take a snapshot of circuit breaker statistics right before the first request. + CircuitBreakerStatistics circuitBreakerStatistics = createCircuitBreakerStatistics(circuitBreaker); + RevocationInfo revocationInfo = processResult(Try.of(decoratedSupplier::get), subjectCertificate, revocationInfoList, circuitBreakerStatistics); + revocationInfoList.add(revocationInfo); + return revocationInfoList; + } + + private CircuitBreakerStatistics createCircuitBreakerStatistics(CircuitBreaker circuitBreaker) { + CircuitBreaker.Metrics metrics = circuitBreaker.getMetrics(); + return new CircuitBreakerStatistics( + circuitBreaker.getState(), + metrics.getFailureRate(), + metrics.getSlowCallRate(), + metrics.getNumberOfSlowCalls(), + metrics.getNumberOfSlowSuccessfulCalls(), + metrics.getNumberOfSlowFailedCalls(), + metrics.getNumberOfBufferedCalls(), + metrics.getNumberOfFailedCalls(), + metrics.getNumberOfNotPermittedCalls(), + metrics.getNumberOfSuccessfulCalls() + ); + } + + private CheckedSupplier buildFallbackSupplier(FallbackOcspService firstFallbackService, + X509Certificate subjectCertificate, + X509Certificate issuerCertificate, + List revocationInfoList) { + CheckedSupplier firstFallbackSupplier = () -> { + try { + return request(firstFallbackService, subjectCertificate, issuerCertificate, fallbackMaxOcspResponseThisUpdateAge); + } catch (Exception e) { + createAndAddRevocationInfoToList(e, revocationInfoList); + throw e; + } + }; + // NOTE: Up to two fallbacks are currently supported. To enable the full potential of recursive fallbacks + // with FallbackOcspService#getNextFallback, the fallback supplier creation needs to be changed. + OcspService secondFallbackService = firstFallbackService.getNextFallback(); + if (secondFallbackService == null) { + return firstFallbackSupplier; + } + CheckedSupplier secondFallbackSupplier = () -> { + try { + return request(secondFallbackService, subjectCertificate, issuerCertificate, fallbackMaxOcspResponseThisUpdateAge); + } catch (Exception e) { + createAndAddRevocationInfoToList(e, revocationInfoList); + throw e; + } + }; + return () -> { + try { + return firstFallbackSupplier.get(); + } catch (ResilientUserCertificateRevokedException e) { + // NOTE: ResilientUserCertificateRevokedException must be re-thrown before the generic + // catch (Exception) block. Without this, a "revoked" verdict from the first fallback would + // be swallowed, and the second fallback could silently override it with a "good" response. + throw e; + } catch (Exception e) { + return secondFallbackSupplier.get(); + } + }; + } + + private CheckedSupplier decorateWithResilience(OcspService primaryService, + X509Certificate subjectCertificate, + X509Certificate issuerCertificate, + List revocationInfoList, + CheckedSupplier fallbackSupplier, + CircuitBreaker circuitBreaker) { + CheckedSupplier primarySupplier = () -> { + try { + return request(primaryService, subjectCertificate, issuerCertificate, getMaxOcspResponseThisUpdateAge()); + } catch (Exception e) { + createAndAddRevocationInfoToList(e, revocationInfoList); + throw e; + } + }; + Decorators.DecorateCheckedSupplier decorateCheckedSupplier = Decorators.ofCheckedSupplier(primarySupplier); + if (retryRegistry != null) { + Retry retry = retryRegistry.retry(primaryService.getAccessLocation().toASCIIString()); + decorateCheckedSupplier.withRetry(retry); + } + decorateCheckedSupplier.withCircuitBreaker(circuitBreaker) + .withFallback(List.of(ResilientUserCertificateOCSPCheckFailedException.class, CallNotPermittedException.class), e -> fallbackSupplier.get()); + + return decorateCheckedSupplier.decorate(); + } + + private RevocationInfo processResult(Try result, X509Certificate subjectCertificate, + List revocationInfoList, + CircuitBreakerStatistics circuitBreakerStatistics) throws AuthTokenException { + if (result.isSuccess()) { + RevocationInfo revocationInfo = result.get(); + if (revocationInfoList.isEmpty()) { + revocationInfo = withCircuitBreakerStatistics(revocationInfo, circuitBreakerStatistics); + } else { + addCircuitBreakerStatistics(revocationInfoList, circuitBreakerStatistics); + } + return revocationInfo; + } + addCircuitBreakerStatistics(revocationInfoList, circuitBreakerStatistics); + Throwable throwable = result.getCause(); + if (throwable instanceof ResilientUserCertificateOCSPCheckFailedException exception) { + exception.setValidationInfo(new ValidationInfo(subjectCertificate, revocationInfoList)); + throw exception; + } + if (throwable instanceof ResilientUserCertificateRevokedException exception) { + exception.setValidationInfo(new ValidationInfo(subjectCertificate, revocationInfoList)); + throw exception; + } + throw new ResilientUserCertificateOCSPCheckFailedException(new ValidationInfo(subjectCertificate, revocationInfoList)); + } + + private void addCircuitBreakerStatistics(List revocationInfoList, + CircuitBreakerStatistics circuitBreakerStatistics) { + revocationInfoList.set(0, withCircuitBreakerStatistics(revocationInfoList.get(0), circuitBreakerStatistics)); + } + + + private void createAndAddRevocationInfoToList(Throwable throwable, List revocationInfoList) { + if (throwable instanceof ResilientUserCertificateOCSPCheckFailedException exception) { + revocationInfoList.addAll((exception.getValidationInfo().revocationInfoList())); + return; + } + if (throwable instanceof ResilientUserCertificateRevokedException exception) { + revocationInfoList.addAll((exception.getValidationInfo().revocationInfoList())); + return; + } + revocationInfoList.add(new RevocationInfo(null, new HashMap<>(Map.ofEntries( + Map.entry(RevocationInfo.KEY_OCSP_ERROR, throwable) + )))); + } + + private RevocationInfo request(OcspService ocspService, X509Certificate subjectCertificate, X509Certificate issuerCertificate, Duration maxOcspResponseThisUpdateAge) throws ResilientUserCertificateOCSPCheckFailedException, ResilientUserCertificateRevokedException { + URI ocspResponderUri = null; + OCSPResp response = null; + OCSPReq request = null; + Duration requestDuration = null; + Instant responseTime = null; + try { + ocspResponderUri = requireNonNull(ocspService.getAccessLocation(), "ocspResponderUri"); + + final CertificateID certificateId = getCertificateId(subjectCertificate, issuerCertificate); + request = new OcspRequestBuilder() + .withCertificateId(certificateId) + .enableOcspNonce(ocspService.doesSupportNonce()) + .build(); + + if (!ocspService.doesSupportNonce()) { + LOG.debug("Disabling OCSP nonce extension"); + } + + LOG.debug("Sending OCSP request"); + Instant requestTime = Instant.now(); + try { + response = requireNonNull(getOcspClient().request(ocspResponderUri, request), "OCSPResp"); + responseTime = Instant.now(); + requestDuration = Duration.between(requestTime, responseTime); + } catch (OCSPClientException e) { + responseTime = Instant.now(); + requestDuration = Duration.between(requestTime, responseTime); + RevocationInfo revocationInfo = getRevocationInfo(ocspResponderUri, e, request, null, requestDuration, responseTime); + revocationInfo = withOCSPClientException(revocationInfo, e); + throw new ResilientUserCertificateOCSPCheckFailedException(new ValidationInfo(subjectCertificate, List.of(revocationInfo))); + } + if (response.getStatus() != OCSPResponseStatus.SUCCESSFUL) { + throw createException("Response status: " + ocspStatusToString(response.getStatus()), + subjectCertificate, ocspResponderUri, request, response, requestDuration, responseTime + ); + } + + if (!(response.getResponseObject() instanceof BasicOCSPResp basicResponse)) { + throw createException("Missing or unsupported Basic OCSP Response", subjectCertificate, + ocspResponderUri, request, response, requestDuration, responseTime + ); + } + LOG.debug("OCSP response received successfully"); + + verifyOcspResponse(basicResponse, ocspService, certificateId, issuerCertificate, maxOcspResponseThisUpdateAge); + if (ocspService.doesSupportNonce()) { + checkNonce(request, basicResponse, ocspResponderUri); + } + LOG.debug("OCSP response verified successfully"); + + return getRevocationInfo(ocspResponderUri, null, request, response, requestDuration, responseTime); + } catch (ResilientUserCertificateOCSPCheckFailedException e) { + throw e; + } catch (UserCertificateRevokedException e) { + // NOTE: unknown status does not throw UserCertificateRevokedException, it throws + // UserCertificateOCSPCheckFailedException instead (see OcspResponseValidator.validateSubjectCertificateStatus), + // so it falls through to the generic catch (Exception) block below, gets wrapped as + // ResilientUserCertificateOCSPCheckFailedException, and triggers the circuit breaker fallback. + // Here, wrapping as ResilientUserCertificateRevokedException ensures the circuit breaker ignores it + // (a definitive OCSP answer, not a transient failure) and no fallback is attempted. + RevocationInfo revocationInfo = getRevocationInfo(ocspResponderUri, e, request, response, requestDuration, responseTime); + throw new ResilientUserCertificateRevokedException(new ValidationInfo(subjectCertificate, List.of(revocationInfo))); + } catch (Exception e) { + RevocationInfo revocationInfo = getRevocationInfo(ocspResponderUri, e, request, response, requestDuration, responseTime); + throw new ResilientUserCertificateOCSPCheckFailedException(new ValidationInfo(subjectCertificate, List.of(revocationInfo))); + } + } + + + private ResilientUserCertificateOCSPCheckFailedException createException(String message, X509Certificate subjectCertificate, + URI ocspResponderUri, OCSPReq request, OCSPResp response, + Duration requestDuration, Instant responseTime) throws ResilientUserCertificateOCSPCheckFailedException { + ResilientUserCertificateOCSPCheckFailedException exception = new ResilientUserCertificateOCSPCheckFailedException(message); + RevocationInfo revocationInfo = getRevocationInfo(ocspResponderUri, exception, request, response, requestDuration, responseTime); + exception.setValidationInfo(new ValidationInfo(subjectCertificate, List.of(revocationInfo))); + return exception; + } + + private RevocationInfo getRevocationInfo(URI ocspResponderUri, Exception e, OCSPReq request, OCSPResp response, + Duration requestDuration, Instant end) { + Map ocspResponseAttributes = new HashMap<>(); + if (e != null) { + ocspResponseAttributes.put(RevocationInfo.KEY_OCSP_ERROR, e); + } + if (request != null) { + ocspResponseAttributes.put(RevocationInfo.KEY_OCSP_REQUEST, request); + } + if (response != null) { + ocspResponseAttributes.put(RevocationInfo.KEY_OCSP_RESPONSE, response); + } + if (requestDuration != null) { + ocspResponseAttributes.put(RevocationInfo.KEY_REQUEST_DURATION, requestDuration); + } + if (end != null) { + ocspResponseAttributes.put(RevocationInfo.KEY_OCSP_RESPONSE_TIME, end); + } + return new RevocationInfo(ocspResponderUri, ocspResponseAttributes); + } + + private static CircuitBreakerConfig getCircuitBreakerConfig(CircuitBreakerConfig circuitBreakerConfig) { + return CircuitBreakerConfig.from(circuitBreakerConfig) + // Users must not be able to modify these three values. + .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED) + .ignoreExceptions(ResilientUserCertificateRevokedException.class) + .automaticTransitionFromOpenToHalfOpenEnabled(true) + .build(); + } + + private static RetryConfig getRetryConfig(RetryConfig retryConfig) { + return RetryConfig.from(retryConfig) + // Users must not be able to modify this value. + .ignoreExceptions(ResilientUserCertificateRevokedException.class) + .build(); + } + + private static RevocationInfo withCircuitBreakerStatistics(RevocationInfo revocationInfo, CircuitBreakerStatistics circuitBreakerStatistics) { + return revocationInfo.withAdditionalOcspResponseAttribute(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS, circuitBreakerStatistics); + } + + private static RevocationInfo withOCSPClientException(RevocationInfo revocationInfo, OCSPClientException e) { + return revocationInfo + .withAdditionalOcspResponseAttribute(RevocationInfo.KEY_OCSP_RESPONSE, e.getResponseBody()) + .withAdditionalOcspResponseAttribute(RevocationInfo.KEY_HTTP_STATUS_CODE, e.getStatusCode()); + } + + public record CircuitBreakerStatistics( + CircuitBreaker.State state, + float failureRate, + float slowCallRate, + int numberOfSlowCalls, + int numberOfSlowSuccessfulCalls, + int numberOfSlowFailedCalls, + int numberOfBufferedCalls, + int numberOfFailedCalls, + long numberOfNotPermittedCalls, + int numberOfSuccessfulCalls + ) {} +} diff --git a/src/main/java/eu/webeid/resilientocsp/exceptions/ResilientUserCertificateOCSPCheckFailedException.java b/src/main/java/eu/webeid/resilientocsp/exceptions/ResilientUserCertificateOCSPCheckFailedException.java new file mode 100644 index 00000000..83c126a5 --- /dev/null +++ b/src/main/java/eu/webeid/resilientocsp/exceptions/ResilientUserCertificateOCSPCheckFailedException.java @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.resilientocsp.exceptions; + +import eu.webeid.ocsp.exceptions.UserCertificateOCSPCheckFailedException; +import eu.webeid.security.validator.ValidationInfo; + +public class ResilientUserCertificateOCSPCheckFailedException extends UserCertificateOCSPCheckFailedException { + + private ValidationInfo validationInfo; + + public ResilientUserCertificateOCSPCheckFailedException(String message) { + this(message, null); + } + + public ResilientUserCertificateOCSPCheckFailedException(ValidationInfo validationInfo) { + super(); + this.validationInfo = validationInfo; + } + + public ResilientUserCertificateOCSPCheckFailedException(String message, ValidationInfo validationInfo) { + super(message); + this.validationInfo = validationInfo; + } + + public ValidationInfo getValidationInfo() { + return validationInfo; + } + + public void setValidationInfo(ValidationInfo validationInfo) { + this.validationInfo = validationInfo; + } +} diff --git a/src/main/java/eu/webeid/resilientocsp/exceptions/ResilientUserCertificateRevokedException.java b/src/main/java/eu/webeid/resilientocsp/exceptions/ResilientUserCertificateRevokedException.java new file mode 100644 index 00000000..8c49d8d3 --- /dev/null +++ b/src/main/java/eu/webeid/resilientocsp/exceptions/ResilientUserCertificateRevokedException.java @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.resilientocsp.exceptions; + +import eu.webeid.ocsp.exceptions.UserCertificateRevokedException; +import eu.webeid.security.validator.ValidationInfo; + +public class ResilientUserCertificateRevokedException extends UserCertificateRevokedException { + + private ValidationInfo validationInfo; + + public ResilientUserCertificateRevokedException(ValidationInfo validationInfo) { + this.validationInfo = validationInfo; + } + + public ValidationInfo getValidationInfo() { + return validationInfo; + } + + public void setValidationInfo(ValidationInfo validationInfo) { + this.validationInfo = validationInfo; + } +} diff --git a/src/main/java/eu/webeid/security/validator/revocationcheck/RevocationInfo.java b/src/main/java/eu/webeid/security/validator/revocationcheck/RevocationInfo.java index f2b7265a..8a85691a 100644 --- a/src/main/java/eu/webeid/security/validator/revocationcheck/RevocationInfo.java +++ b/src/main/java/eu/webeid/security/validator/revocationcheck/RevocationInfo.java @@ -3,11 +3,34 @@ package eu.webeid.security.validator.revocationcheck; import java.net.URI; +import java.util.HashMap; import java.util.Map; public record RevocationInfo(URI ocspResponderUri, Map ocspResponseAttributes) { + public static final String KEY_OCSP_REQUEST = "OCSP_REQUEST"; public static final String KEY_OCSP_RESPONSE = "OCSP_RESPONSE"; public static final String KEY_OCSP_ERROR = "OCSP_ERROR"; + public static final String KEY_HTTP_STATUS_CODE = "HTTP_STATUS_CODE"; + public static final String KEY_REQUEST_DURATION = "REQUEST_DURATION"; + public static final String KEY_CIRCUIT_BREAKER_STATISTICS = "CIRCUIT_BREAKER_STATISTICS"; + public static final String KEY_OCSP_RESPONSE_TIME = "OCSP_RESPONSE_TIME"; -} \ No newline at end of file + public RevocationInfo(URI ocspResponderUri, Map ocspResponseAttributes) { + this.ocspResponderUri = ocspResponderUri; + this.ocspResponseAttributes = ocspResponseAttributes != null + ? Map.copyOf(ocspResponseAttributes) + : null; + } + + public RevocationInfo withAdditionalOcspResponseAttribute(String key, Object value) { + if (value == null) { + return this; + } + Map newOcspResponseAttributes = ocspResponseAttributes != null + ? new HashMap<>(ocspResponseAttributes) + : new HashMap<>(); + newOcspResponseAttributes.put(key, value); + return new RevocationInfo(ocspResponderUri, newOcspResponseAttributes); + } +} diff --git a/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java b/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java index 97a14245..9b90c70e 100644 --- a/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java +++ b/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java @@ -3,6 +3,7 @@ package eu.webeid.ocsp; +import eu.webeid.ocsp.exceptions.OCSPClientException; import eu.webeid.security.exceptions.CertificateExpiredException; import eu.webeid.ocsp.exceptions.OCSPCertificateException; import eu.webeid.security.exceptions.JceException; @@ -42,14 +43,17 @@ import static eu.webeid.security.testutil.DateMocker.mockDate; import static eu.webeid.ocsp.service.OcspServiceMaker.getAiaOcspServiceProvider; import static eu.webeid.ocsp.service.OcspServiceMaker.getDesignatedOcspServiceProvider; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.when; -class OcspCertificateRevocationCheckerTest extends AbstractTestWithValidator { +public class OcspCertificateRevocationCheckerTest extends AbstractTestWithValidator { private final OcspClient ocspClient = OcspClientImpl.build(Duration.ofSeconds(5)); private X509Certificate estEid2018Cert; @@ -104,6 +108,8 @@ void whenOcspUrlIsInvalid_thenThrows() throws Exception { validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) .isInstanceOf(UserCertificateOCSPCheckFailedException.class) .cause() + .isInstanceOf(OCSPClientException.class) + .cause() .isInstanceOf(ConnectException.class); } @@ -111,12 +117,11 @@ void whenOcspUrlIsInvalid_thenThrows() throws Exception { void whenOcspRequestFails_thenThrows() throws Exception { final OcspServiceProvider ocspServiceProvider = getDesignatedOcspServiceProvider("http://demo.sk.ee/ocsps"); final OcspCertificateRevocationChecker validator = getOcspCertificateRevocationChecker(ocspServiceProvider); - assertThatCode(() -> - validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) - .isInstanceOf(UserCertificateOCSPCheckFailedException.class) - .cause() - .isInstanceOf(IOException.class) - .hasMessageStartingWith("OCSP request was not successful, response: (POST http://demo.sk.ee/ocsps) 404"); + UserCertificateOCSPCheckFailedException ex = assertThrows(UserCertificateOCSPCheckFailedException.class, () -> + validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); + OCSPClientException ocspClientException = assertInstanceOf(OCSPClientException.class, ex.getCause()); + assertThat(ocspClientException).hasMessageStartingWith("OCSP request was not successful"); + assertThat(ocspClientException.getStatusCode()).isEqualTo(404); } @Test @@ -128,7 +133,10 @@ void whenOcspRequestHasInvalidBody_thenThrows() throws Exception { validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) .isInstanceOf(UserCertificateOCSPCheckFailedException.class) .cause() - .isExactlyInstanceOf(CertIOException.class); + .isInstanceOf(OCSPClientException.class) + .cause() + .isInstanceOf(IOException.class) + .hasMessage("malformed response: corrupted stream - out of bounds length found: 110 > 7"); } @Test @@ -348,7 +356,7 @@ private static byte[] getOcspResponseBytesFromResources() throws IOException { return getOcspResponseBytesFromResources("ocsp_response.der"); } - private static byte[] getOcspResponseBytesFromResources(String resource) throws IOException { + public static byte[] getOcspResponseBytesFromResources(String resource) throws IOException { try (final InputStream resourceAsStream = ClassLoader.getSystemResourceAsStream(resource)) { return toByteArray(resourceAsStream); } @@ -388,7 +396,13 @@ private HttpResponse getMockedResponse(byte[] bodyContent) throws URISyn } private OcspClient getMockClient(HttpResponse response) { - return (url, request) -> new OCSPResp(Objects.requireNonNull(response.body())); + return (url, request) -> { + try { + return new OCSPResp(Objects.requireNonNull(response.body())); + } catch (IOException e) { + throw new OCSPClientException(e); + } + }; } private static byte[] toByteArray(InputStream resourceAsStream) throws IOException { diff --git a/src/test/java/eu/webeid/ocsp/client/OcspClientOverrideTest.java b/src/test/java/eu/webeid/ocsp/client/OcspClientOverrideTest.java index 06e1fc18..828d2915 100644 --- a/src/test/java/eu/webeid/ocsp/client/OcspClientOverrideTest.java +++ b/src/test/java/eu/webeid/ocsp/client/OcspClientOverrideTest.java @@ -4,6 +4,7 @@ package eu.webeid.ocsp.client; import eu.webeid.ocsp.OcspCertificateRevocationChecker; +import eu.webeid.ocsp.exceptions.OCSPClientException; import eu.webeid.security.exceptions.JceException; import eu.webeid.security.testutil.AbstractTestWithValidator; import eu.webeid.security.testutil.AuthTokenValidators; @@ -62,12 +63,12 @@ private static AuthTokenValidator getAuthTokenValidatorWithOverriddenOcspClient( private static class OcpClientThatThrows implements OcspClient { @Override - public OCSPResp request(URI url, OCSPReq request) throws IOException { + public OCSPResp request(URI url, OCSPReq request) throws OCSPClientException { throw new OcpClientThatThrowsException(); } } - private static class OcpClientThatThrowsException extends IOException { + private static class OcpClientThatThrowsException extends OCSPClientException { } } diff --git a/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java b/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java index 01f5daed..677bc8dd 100644 --- a/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java +++ b/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java @@ -5,6 +5,9 @@ import eu.webeid.ocsp.OcspCertificateRevocationChecker; import eu.webeid.ocsp.exceptions.UserCertificateOCSPCheckFailedException; +import eu.webeid.ocsp.exceptions.UserCertificateRevokedException; +import org.bouncycastle.cert.ocsp.BasicOCSPResp; +import org.bouncycastle.cert.ocsp.OCSPResp; import org.bouncycastle.cert.ocsp.SingleResp; import org.junit.jupiter.api.Test; @@ -14,7 +17,9 @@ import java.time.temporal.ChronoUnit; import java.util.Date; +import static eu.webeid.ocsp.OcspCertificateRevocationCheckerTest.getOcspResponseBytesFromResources; import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateCertificateStatusUpdateTime; +import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateSubjectCertificateStatus; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.mockito.Mockito.mock; @@ -99,8 +104,23 @@ void whenNextUpdateHalfHourBeforeNow_thenThrows() { + " (OCSP responder: https://example.org)"); } + @Test + void whenOcspResponseStatusIsUnknown_ThenThrowsUserCertificateOCSPCheckFailedException() throws Exception { + SingleResp unknownCertStatus = getUnknownCertStatusResponse(); + assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> + validateSubjectCertificateStatus(unknownCertStatus, OCSP_URL)) + .withMessage("User certificate revocation check has failed: Unknown status (OCSP responder: https://example.org)"); + } + private static Date getThisUpdateWithinAgeLimit(Instant now) { return Date.from(now.minus(THIS_UPDATE_AGE.minusSeconds(1))); } + private static SingleResp getUnknownCertStatusResponse() throws Exception { + final OCSPResp ocspRespUnknown = new OCSPResp(getOcspResponseBytesFromResources("ocsp_response_unknown.der")); + final BasicOCSPResp basicResponse = (BasicOCSPResp) ocspRespUnknown.getResponseObject(); + return basicResponse.getResponses()[0]; + } + } diff --git a/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceConfigurationTest.java b/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceConfigurationTest.java index cd939d3a..06d79020 100644 --- a/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceConfigurationTest.java +++ b/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceConfigurationTest.java @@ -4,9 +4,9 @@ package eu.webeid.ocsp.service; import eu.webeid.security.certificate.CertificateValidator; +import org.bouncycastle.asn1.x500.X500Name; import org.junit.jupiter.api.Test; -import java.net.URI; import java.security.cert.TrustAnchor; import java.util.HashSet; import java.util.List; @@ -20,19 +20,19 @@ class AiaOcspServiceConfigurationTest { @Test void whenCallerMutatesCollections_thenConfigurationRemainsUnchanged() throws Exception { - final URI responder = URI.create("http://ocsp.example"); - final Set nonceDisabledUrls = new HashSet<>(Set.of(responder)); + final X500Name issuerDN = new X500Name("CN=TEST of ESTEID2018, O=SK ID Solutions AS, C=EE"); + final Set nonceDisabledIssuerDNs = new HashSet<>(Set.of(issuerDN)); final TrustAnchor anchor = new TrustAnchor(getTestEsteid2018CA(), null); final Set anchors = new HashSet<>(Set.of(anchor)); final AiaOcspServiceConfiguration configuration = new AiaOcspServiceConfiguration( - nonceDisabledUrls, anchors, CertificateValidator.buildCertStoreFromCertificates(List.of(getTestEsteid2018CA()))); + nonceDisabledIssuerDNs, anchors, CertificateValidator.buildCertStoreFromCertificates(List.of(getTestEsteid2018CA()))); - nonceDisabledUrls.clear(); + nonceDisabledIssuerDNs.clear(); anchors.clear(); - assertThat(configuration.getNonceDisabledOcspUrls()).containsExactly(responder); + assertThat(configuration.getNonceDisabledIssuerDNs()).containsExactly(issuerDN); assertThat(configuration.getTrustedCACertificateAnchors()).containsExactly(anchor); - assertThatThrownBy(() -> configuration.getNonceDisabledOcspUrls().clear()).isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> configuration.getNonceDisabledIssuerDNs().clear()).isInstanceOf(UnsupportedOperationException.class); assertThatThrownBy(() -> configuration.getTrustedCACertificateAnchors().clear()).isInstanceOf(UnsupportedOperationException.class); } } diff --git a/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceConfigurationTest.java b/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceConfigurationTest.java new file mode 100644 index 00000000..819ea3a0 --- /dev/null +++ b/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceConfigurationTest.java @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.ocsp.service; + +import eu.webeid.security.certificate.CertificateValidator; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.security.cert.TrustAnchor; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static eu.webeid.security.testutil.Certificates.getTestEsteid2018CA; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class FallbackOcspServiceConfigurationTest { + + @Test + void whenCallerMutatesCollections_thenConfigurationRemainsUnchanged() throws Exception { + final TrustAnchor anchor = new TrustAnchor(getTestEsteid2018CA(), null); + final Set anchors = new HashSet<>(Set.of(anchor)); + final FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( + URI.create("http://fallback.ocsp.test"), + null, + true, + null, + getTestEsteid2018CA(), + anchors, + CertificateValidator.buildCertStoreFromCertificates(List.of(getTestEsteid2018CA()))); + + anchors.clear(); + + assertThat(configuration.getTrustedCACertificateAnchors()).containsExactly(anchor); + assertThatThrownBy(() -> configuration.getTrustedCACertificateAnchors().clear()).isInstanceOf(UnsupportedOperationException.class); + } +} diff --git a/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java b/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java index d7b6af1c..57417604 100644 --- a/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java +++ b/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java @@ -6,6 +6,7 @@ import eu.webeid.security.certificate.CertificateValidator; import eu.webeid.security.exceptions.JceException; import eu.webeid.ocsp.exceptions.OCSPCertificateException; +import org.bouncycastle.asn1.x500.X500Name; import java.io.IOException; import java.net.URI; @@ -22,7 +23,7 @@ public class OcspServiceMaker { private static final String TEST_OCSP_ACCESS_LOCATION = "http://demo.sk.ee/ocsp"; private static final List TRUSTED_CA_CERTIFICATES; - private static final URI TEST_ESTEID_2015 = URI.create("http://aia.demo.sk.ee/esteid2015"); + private static final X500Name ISSUER_DN = new X500Name("CN=TEST of ESTEID-SK 2015, OID.2.5.4.97=NTREE-10747013, O=AS Sertifitseerimiskeskus, C=EE"); static { try { @@ -50,7 +51,7 @@ public static OcspServiceProvider getDesignatedOcspServiceProvider(String ocspSe private static AiaOcspServiceConfiguration getAiaOcspServiceConfiguration() throws JceException { return new AiaOcspServiceConfiguration( - Set.of(TEST_ESTEID_2015), + Set.of(ISSUER_DN), CertificateValidator.buildTrustAnchorsFromCertificates(TRUSTED_CA_CERTIFICATES), CertificateValidator.buildCertStoreFromCertificates(TRUSTED_CA_CERTIFICATES)); } diff --git a/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java b/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java index 597ccdc3..e9be3911 100644 --- a/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java +++ b/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java @@ -5,6 +5,8 @@ import org.bouncycastle.cert.X509CertificateHolder; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import eu.webeid.ocsp.exceptions.OCSPCertificateException; import eu.webeid.security.certificate.CertificateValidator; import eu.webeid.security.testutil.LocalOcspResponder; @@ -86,6 +88,80 @@ void whenDifferentIssuersHaveSameName_thenDesignatedServiceAppliesOnlyToConfigur } } + @Test + void whenDifferentIssuersHaveSameName_thenFallbackServiceAppliesOnlyToConfiguredCertificate() throws Exception { + try (LocalOcspResponder first = new LocalOcspResponder(); + LocalOcspResponder second = new LocalOcspResponder()) { + first.start(); + second.start(); + final var authorities = List.of(first.issuer(), second.issuer()); + final var aia = new AiaOcspServiceConfiguration(Set.of(), + CertificateValidator.buildTrustAnchorsFromCertificates(authorities), + CertificateValidator.buildCertStoreFromCertificates(authorities)); + final var fallback = new FallbackOcspServiceConfiguration( + first.designatedUri(), + first.responderCertificate(), + true, + null, + first.issuer(), + CertificateValidator.buildTrustAnchorsFromCertificates(List.of(first.issuer())), + CertificateValidator.buildCertStoreFromCertificates(List.of(first.issuer()))); + final var provider = new OcspServiceProvider(null, aia, List.of(fallback)); + + assertThat(first.issuer().getSubjectX500Principal()).isEqualTo(second.issuer().getSubjectX500Principal()); + assertThat(first.issuer()).isNotEqualTo(second.issuer()); + assertThat(provider.getService(first.subject(), first.issuer()).getFallbackService()).isPresent(); + assertThat(provider.getService(second.subject(), second.issuer()).getFallbackService()).isEmpty(); + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void whenFallbackResponderIsDelegatedByAnotherTrustedCa_thenThrows(boolean sameIssuerName) throws Exception { + try (LocalOcspResponder responder = new LocalOcspResponder()) { + responder.start(); + responder.replaceResponderCertificateFromDifferentIssuer(sameIssuerName); + final var configuration = new FallbackOcspServiceConfiguration( + responder.designatedUri(), + null, + true, + null, + responder.issuer(), + CertificateValidator.buildTrustAnchorsFromCertificates(List.of(responder.issuer(), responder.otherIssuer())), + CertificateValidator.buildCertStoreFromCertificates(List.of(responder.issuer(), responder.otherIssuer()))); + final var service = new FallbackOcspService(configuration); + final var responderCertificate = new X509CertificateHolder(responder.responderCertificate().getEncoded()); + + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> service.validateResponderCertificate( + responderCertificate, responder.issuer(), Date.from(responder.now()))); + } + } + + @Test + void whenFallbackServiceIsUsedForDifferentIssuer_thenThrows() throws Exception { + try (LocalOcspResponder first = new LocalOcspResponder(); + LocalOcspResponder second = new LocalOcspResponder()) { + first.start(); + second.start(); + final var configuration = new FallbackOcspServiceConfiguration( + first.designatedUri(), + first.responderCertificate(), + true, + null, + first.issuer(), + CertificateValidator.buildTrustAnchorsFromCertificates(List.of(first.issuer())), + CertificateValidator.buildCertStoreFromCertificates(List.of(first.issuer()))); + final var service = new FallbackOcspService(configuration); + final var responderCertificate = new X509CertificateHolder(first.responderCertificate().getEncoded()); + + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> service.validateResponderCertificate( + responderCertificate, second.issuer(), Date.from(first.now()))) + .withMessage("Fallback OCSP service is not configured for the subject certificate's issuer"); + } + } + } // Old disabled example AuthTokenValidator test with designated OCSP check. @@ -99,4 +175,4 @@ void whenDifferentIssuersHaveSameName_thenDesignatedServiceAppliesOnlyToConfigur // assertThatThrownBy(() -> validatorWithOcspCheck // .validate(token, VALID_CHALLENGE_NONCE)) // .isInstanceOf(UserCertificateRevokedException.class); -// } \ No newline at end of file +// } diff --git a/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java b/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java new file mode 100644 index 00000000..e6fe74d1 --- /dev/null +++ b/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java @@ -0,0 +1,366 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.resilientocsp; + +import eu.webeid.ocsp.OcspCertificateRevocationChecker; +import eu.webeid.ocsp.client.OcspClient; +import eu.webeid.ocsp.exceptions.OCSPClientException; +import eu.webeid.ocsp.service.OcspService; +import eu.webeid.ocsp.service.OcspServiceProvider; +import eu.webeid.resilientocsp.exceptions.ResilientUserCertificateOCSPCheckFailedException; +import eu.webeid.resilientocsp.exceptions.ResilientUserCertificateRevokedException; +import eu.webeid.ocsp.service.FallbackOcspService; +import eu.webeid.security.authtoken.WebEidAuthToken; +import eu.webeid.security.validator.AuthTokenValidator; +import eu.webeid.security.validator.revocationcheck.RevocationInfo; +import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig; +import io.github.resilience4j.retry.RetryConfig; +import org.bouncycastle.cert.ocsp.BasicOCSPResp; +import org.bouncycastle.cert.ocsp.CertificateStatus; +import org.bouncycastle.cert.ocsp.OCSPResp; +import org.bouncycastle.cert.ocsp.RevokedStatus; +import org.bouncycastle.cert.ocsp.SingleResp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static eu.webeid.ocsp.OcspCertificateRevocationCheckerTest.getOcspResponseBytesFromResources; +import static eu.webeid.security.testutil.AbstractTestWithValidator.VALID_AUTH_TOKEN; +import static eu.webeid.security.testutil.AbstractTestWithValidator.VALID_CHALLENGE_NONCE; +import static eu.webeid.security.testutil.AuthTokenValidators.getDefaultAuthTokenValidatorBuilder; +import static eu.webeid.security.testutil.Certificates.getJaakKristjanEsteid2018Cert; +import static eu.webeid.security.testutil.Certificates.getTestEsteid2018CA; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class ResilientOcspCertificateRevocationCheckerTest { + + private static final URI PRIMARY_URI = URI.create("http://primary.ocsp.test"); + private static final URI FALLBACK_URI = URI.create("http://fallback.ocsp.test"); + private static final URI SECOND_FALLBACK_URI = URI.create("http://second-fallback.ocsp.test"); + + private static final Duration LONG_THIS_UPDATE_AGE = Duration.ofDays(365 * 10); + + private X509Certificate estEid2018Cert; + private X509Certificate testEsteid2018CA; + + private OCSPResp ocspRespGood; + private OCSPResp ocspRespRevoked; + + @BeforeEach + void setUp() throws Exception { + estEid2018Cert = getJaakKristjanEsteid2018Cert(); + testEsteid2018CA = getTestEsteid2018CA(); + ocspRespGood = new OCSPResp(getOcspResponseBytesFromResources("ocsp_response.der")); + ocspRespRevoked = new OCSPResp(getOcspResponseBytesFromResources("ocsp_response_revoked.der")); + } + + @Test + void whenMultipleValidationCalls_thenPreviousResultsAreNotModified() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())) + .thenThrow(new OCSPClientException("Primary OCSP service unavailable (call1)")) + .thenThrow(new OCSPClientException("Primary OCSP service unavailable (call2)")); + when(ocspClient.request(eq(FALLBACK_URI), any())) + .thenThrow(new OCSPClientException("Fallback OCSP service unavailable (call1)")) + .thenThrow(new OCSPClientException("Fallback OCSP service unavailable (call2)")); + when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())) + .thenThrow(new OCSPClientException("Secondary fallback OCSP service unavailable (call1)")) + .thenThrow(new OCSPClientException("Secondary fallback OCSP service unavailable (call2)")); + ResilientOcspCertificateRevocationChecker resilientChecker = buildChecker(ocspClient, null); + AuthTokenValidator validator = getDefaultAuthTokenValidatorBuilder() + .withCertificateRevocationChecker(resilientChecker) + .build(); + WebEidAuthToken authToken = validator.parse(VALID_AUTH_TOKEN); + + ResilientUserCertificateOCSPCheckFailedException ex1 = assertThrows(ResilientUserCertificateOCSPCheckFailedException.class, + () -> validator.validate(authToken, VALID_CHALLENGE_NONCE)); + List revocationInfo1 = ex1.getValidationInfo().revocationInfoList(); + assertThat(revocationInfo1).hasSize(3); + assertThat(revocationInfo1) + .extracting(ri -> ((OCSPClientException) ri.ocspResponseAttributes().get("OCSP_ERROR")).getMessage()) + .containsExactly( + "Primary OCSP service unavailable (call1)", + "Fallback OCSP service unavailable (call1)", + "Secondary fallback OCSP service unavailable (call1)" + ); + ResilientUserCertificateOCSPCheckFailedException ex2 = assertThrows(ResilientUserCertificateOCSPCheckFailedException.class, + () -> validator.validate(authToken, VALID_CHALLENGE_NONCE)); + List revocationInfo2 = ex2.getValidationInfo().revocationInfoList(); + assertThat(revocationInfo2).hasSize(3); + assertThat(revocationInfo2) + .extracting(ri -> ((OCSPClientException) ri.ocspResponseAttributes().get("OCSP_ERROR")).getMessage()) + .containsExactly( + "Primary OCSP service unavailable (call2)", + "Fallback OCSP service unavailable (call2)", + "Secondary fallback OCSP service unavailable (call2)" + ); + assertThat(revocationInfo1).hasSize(3); + assertThat(revocationInfo1) + .extracting(ri -> ((OCSPClientException) ri.ocspResponseAttributes().get("OCSP_ERROR")).getMessage()) + .containsExactly( + "Primary OCSP service unavailable (call1)", + "Fallback OCSP service unavailable (call1)", + "Secondary fallback OCSP service unavailable (call1)" + ); + } + + @Test + void whenFirstFallbackReturnsRevoked_thenRevocationPropagatesWithoutSecondFallback() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())) + .thenThrow(new OCSPClientException("Primary OCSP service unavailable")); + when(ocspClient.request(eq(FALLBACK_URI), any())) + .thenReturn(ocspRespRevoked); + when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())) + .thenReturn(ocspRespGood); + + ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, null); + + assertThatExceptionOfType(ResilientUserCertificateRevokedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .withMessage("User certificate has been revoked"); + + verify(ocspClient, never()).request(eq(SECOND_FALLBACK_URI), any()); + } + + @Test + void whenMaxAttemptsIsOneAndAllCallsFail_thenRevocationInfoListShouldHaveThreeElements() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())) + .thenThrow(new OCSPClientException()); + when(ocspClient.request(eq(FALLBACK_URI), any())) + .thenThrow(new OCSPClientException()); + when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())) + .thenThrow(new OCSPClientException()); + + RetryConfig retryConfig = RetryConfig.custom() + .maxAttempts(1) + .build(); + + ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, retryConfig); + ResilientUserCertificateOCSPCheckFailedException ex = assertThrows(ResilientUserCertificateOCSPCheckFailedException.class, () -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); + assertThat(ex.getValidationInfo().revocationInfoList().size()).isEqualTo(3); + } + + @Test + void whenMaxAttemptsIsTwoAndAllCallsFail_thenRevocationInfoListShouldHaveFourElements() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())) + .thenThrow(new OCSPClientException()); + when(ocspClient.request(eq(FALLBACK_URI), any())) + .thenThrow(new OCSPClientException()); + when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())) + .thenThrow(new OCSPClientException()); + + RetryConfig retryConfig = RetryConfig.custom() + .maxAttempts(2) + .build(); + + ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, retryConfig); + ResilientUserCertificateOCSPCheckFailedException ex = assertThrows(ResilientUserCertificateOCSPCheckFailedException.class, () -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); + assertThat(ex.getValidationInfo().revocationInfoList().size()).isEqualTo(4); + } + + @Test + void whenMaxAttemptsIsTwoAndFirstCallFails_thenTwoCallsToPrimaryShouldBeRecorded() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())) + .thenThrow(new OCSPClientException("Primary OCSP service unavailable (call1)")) + .thenReturn(ocspRespGood); + when(ocspClient.request(eq(FALLBACK_URI), any())) + .thenReturn(ocspRespRevoked); + when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())) + .thenReturn(ocspRespRevoked); + + RetryConfig retryConfig = RetryConfig.custom() + .maxAttempts(2) + .build(); + + ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, retryConfig); + List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + assertThat(revocationInfoList.size()).isEqualTo(2); + + Map firstResponseAttributes = revocationInfoList.get(0).ocspResponseAttributes(); + OCSPClientException ex1 = (OCSPClientException) firstResponseAttributes.get("OCSP_ERROR"); + assertThat(ex1.getMessage()).isEqualTo("Primary OCSP service unavailable (call1)"); + + Map secondResponseAttributes = revocationInfoList.get(1).ocspResponseAttributes(); + OCSPResp ocspResp = (OCSPResp) secondResponseAttributes.get("OCSP_RESPONSE"); + final BasicOCSPResp basicResponse = (BasicOCSPResp) ocspResp.getResponseObject(); + final SingleResp certStatusResponse = basicResponse.getResponses()[0]; + assertThat(certStatusResponse.getCertStatus()).isEqualTo(org.bouncycastle.cert.ocsp.CertificateStatus.GOOD); + } + + @Test + void whenFirstCallSucceeds_thenRevocationInfoListShouldHaveOneElementAndItShouldHaveGoodStatus() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())) + .thenReturn(ocspRespGood); + when(ocspClient.request(eq(FALLBACK_URI), any())) + .thenReturn(ocspRespRevoked); + when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())) + .thenReturn(ocspRespRevoked); + + ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, null); + + List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + assertThat(revocationInfoList.size()).isEqualTo(1); + Map responseAttributes = revocationInfoList.get(0).ocspResponseAttributes(); + OCSPResp ocspResp = (OCSPResp) responseAttributes.get("OCSP_RESPONSE"); + CertificateStatus status = getCertificateStatus(ocspResp); + assertThat(status).isEqualTo(org.bouncycastle.cert.ocsp.CertificateStatus.GOOD); + } + + @Test + void whenFirstCallResultsInRevoked_thenRevocationInfoListShouldHaveOneElementAndItShouldHaveRevokedStatus() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())) + .thenReturn(ocspRespRevoked); + when(ocspClient.request(eq(FALLBACK_URI), any())) + .thenReturn(ocspRespGood); + when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())) + .thenReturn(ocspRespGood); + + ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, null); + ResilientUserCertificateRevokedException ex = assertThrows(ResilientUserCertificateRevokedException.class, () -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); + List revocationInfoList = ex.getValidationInfo().revocationInfoList(); + assertThat(revocationInfoList.size()).isEqualTo(1); + Map responseAttributes = ex.getValidationInfo().revocationInfoList().get(0).ocspResponseAttributes(); + OCSPResp ocspResp = (OCSPResp) responseAttributes.get("OCSP_RESPONSE"); + CertificateStatus status = getCertificateStatus(ocspResp); + assertThat(status).isInstanceOf(RevokedStatus.class); + } + + @Test + void whenOneFallbackIsConfiguredAndPrimaryFails_thenRevocationInfoListShouldHaveTwoElements() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())) + .thenThrow(new OCSPClientException()); + when(ocspClient.request(eq(FALLBACK_URI), any())) + .thenThrow(new OCSPClientException()); + + FallbackOcspService fallbackService = mock(FallbackOcspService.class); + when(fallbackService.getAccessLocation()).thenReturn(FALLBACK_URI); + when(fallbackService.doesSupportNonce()).thenReturn(false); + when(fallbackService.getNextFallback()).thenReturn(null); + + OcspService primaryService = mock(OcspService.class); + when(primaryService.getAccessLocation()).thenReturn(PRIMARY_URI); + when(primaryService.doesSupportNonce()).thenReturn(false); + when(primaryService.getFallbackService()).thenReturn(Optional.of(fallbackService)); + + OcspServiceProvider ocspServiceProvider = mock(OcspServiceProvider.class); + when(ocspServiceProvider.getService(any(), any())).thenReturn(primaryService); + + ResilientOcspCertificateRevocationChecker checker = new ResilientOcspCertificateRevocationChecker( + ocspClient, + ocspServiceProvider, + CircuitBreakerConfig.ofDefaults(), + null, + OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW, + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, + LONG_THIS_UPDATE_AGE + ); + + ResilientUserCertificateOCSPCheckFailedException ex = assertThrows(ResilientUserCertificateOCSPCheckFailedException.class, () -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); + List revocationInfoList = ex.getValidationInfo().revocationInfoList(); + assertThat(revocationInfoList.size()).isEqualTo(2); + } + + @Test + void whenNoFallbacksAreConfigured_thenRevocationInfoListShouldHaveOneElement() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())) + .thenThrow(new OCSPClientException()); + when(ocspClient.request(eq(FALLBACK_URI), any())) + .thenThrow(new OCSPClientException()); + + OcspService primaryService = mock(OcspService.class); + when(primaryService.getAccessLocation()).thenReturn(PRIMARY_URI); + when(primaryService.doesSupportNonce()).thenReturn(false); + when(primaryService.getFallbackService()).thenReturn(Optional.empty()); + + OcspServiceProvider ocspServiceProvider = mock(OcspServiceProvider.class); + when(ocspServiceProvider.getService(any(), any())).thenReturn(primaryService); + + ResilientOcspCertificateRevocationChecker checker = new ResilientOcspCertificateRevocationChecker( + ocspClient, + ocspServiceProvider, + CircuitBreakerConfig.ofDefaults(), + null, + OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW, + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, + LONG_THIS_UPDATE_AGE + ); + + ResilientUserCertificateOCSPCheckFailedException ex = assertThrows(ResilientUserCertificateOCSPCheckFailedException.class, () -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); + List revocationInfoList = ex.getValidationInfo().revocationInfoList(); + assertThat(revocationInfoList.size()).isEqualTo(1); + } + + @Test + void whenOcspResponseStatusIsUnauthorized_thenThrows() throws Exception { + OCSPResp ocspRespStatusUnauthorized = new OCSPResp(getOcspResponseBytesFromResources("ocsp_response_unauthorized.der")); + + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())) + .thenReturn(ocspRespStatusUnauthorized); + + ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, null); + ResilientUserCertificateOCSPCheckFailedException ex = assertThrows(ResilientUserCertificateOCSPCheckFailedException.class, () -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); + + Map responseAttributes = ex.getValidationInfo().revocationInfoList().get(0).ocspResponseAttributes(); + ResilientUserCertificateOCSPCheckFailedException firstException = (ResilientUserCertificateOCSPCheckFailedException) responseAttributes.get(RevocationInfo.KEY_OCSP_ERROR); + assertThat(firstException.getMessage()).isEqualTo("Response status: unauthorized"); + } + + private ResilientOcspCertificateRevocationChecker buildChecker(OcspClient ocspClient, RetryConfig retryConfig) throws Exception { + FallbackOcspService secondFallbackService = mock(FallbackOcspService.class); + when(secondFallbackService.getAccessLocation()).thenReturn(SECOND_FALLBACK_URI); + when(secondFallbackService.doesSupportNonce()).thenReturn(false); + + FallbackOcspService fallbackService = mock(FallbackOcspService.class); + when(fallbackService.getAccessLocation()).thenReturn(FALLBACK_URI); + when(fallbackService.doesSupportNonce()).thenReturn(false); + when(fallbackService.getNextFallback()).thenReturn(secondFallbackService); + + OcspService primaryService = mock(OcspService.class); + when(primaryService.getAccessLocation()).thenReturn(PRIMARY_URI); + when(primaryService.doesSupportNonce()).thenReturn(false); + when(primaryService.getFallbackService()).thenReturn(Optional.of(fallbackService)); + + OcspServiceProvider ocspServiceProvider = mock(OcspServiceProvider.class); + when(ocspServiceProvider.getService(any(), any())).thenReturn(primaryService); + + return new ResilientOcspCertificateRevocationChecker( + ocspClient, + ocspServiceProvider, + CircuitBreakerConfig.ofDefaults(), + retryConfig, + OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW, + LONG_THIS_UPDATE_AGE, + LONG_THIS_UPDATE_AGE + ); + } + + private CertificateStatus getCertificateStatus(OCSPResp ocspResp) throws Exception { + final BasicOCSPResp basicResponse = (BasicOCSPResp) ocspResp.getResponseObject(); + final SingleResp certStatusResponse = basicResponse.getResponses()[0]; + return certStatusResponse.getCertStatus(); + } +} diff --git a/src/test/resources/ocsp_response_unauthorized.der b/src/test/resources/ocsp_response_unauthorized.der new file mode 100644 index 00000000..d6ea0659 --- /dev/null +++ b/src/test/resources/ocsp_response_unauthorized.der @@ -0,0 +1,2 @@ +0 + \ No newline at end of file