fallbackOcspServiceConfigurations) {
designatedOcspService = designatedOcspServiceConfiguration != null ?
new DesignatedOcspService(designatedOcspServiceConfiguration)
: null;
this.aiaOcspServiceConfiguration = Objects.requireNonNull(aiaOcspServiceConfiguration, "aiaOcspServiceConfiguration");
+ if (fallbackOcspServiceConfigurations != null) {
+ for (FallbackOcspServiceConfiguration configuration : fallbackOcspServiceConfigurations) {
+ fallbackOcspServiceMap.put(configuration.getIssuerDN(), new FallbackOcspService(configuration));
+ }
+ }
}
/**
@@ -46,14 +63,15 @@ public OcspServiceProvider(DesignatedOcspServiceConfiguration designatedOcspServ
*
* @param certificate subject certificate that is to be checked with OCSP
* @return either the designated or AIA OCSP service instance
- * @throws AuthTokenException when AIA URL is not found in certificate
- * @throws CertificateEncodingException when certificate is invalid
+ * @throws UserCertificateOCSPCheckFailedException when issuer common name is not found in certificate
+ * @throws IllegalArgumentException when certificate is invalid
*/
public OcspService getService(X509Certificate certificate) throws AuthTokenException, CertificateEncodingException {
if (designatedOcspService != null && designatedOcspService.supportsIssuerOf(certificate)) {
return designatedOcspService;
}
- return new AiaOcspService(aiaOcspServiceConfiguration, certificate);
+ X500Name issuerDistinguishedName = getIssuerDistinguishedName(certificate);
+ FallbackOcspService fallbackOcspService = fallbackOcspServiceMap.get(issuerDistinguishedName);
+ 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..4c5d4a22
--- /dev/null
+++ b/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java
@@ -0,0 +1,412 @@
+/*
+ * Copyright (c) 2020-2025 Estonian Information System Authority
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+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.CertificateException;
+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 boolean rejectUnknownOcspResponseStatus;
+ private final Duration fallbackMaxOcspResponseThisUpdateAge;
+
+ public ResilientOcspCertificateRevocationChecker(OcspClient ocspClient,
+ OcspServiceProvider ocspServiceProvider,
+ CircuitBreakerConfig circuitBreakerConfig,
+ RetryConfig retryConfig,
+ Duration allowedOcspResponseTimeSkew,
+ Duration primaryMaxOcspResponseThisUpdateAge,
+ Duration fallbackMaxOcspResponseThisUpdateAge,
+ boolean rejectUnknownOcspResponseStatus) {
+ super(ocspClient, ocspServiceProvider, allowedOcspResponseTimeSkew, primaryMaxOcspResponseThisUpdateAge);
+ this.fallbackMaxOcspResponseThisUpdateAge = requirePositiveDuration(fallbackMaxOcspResponseThisUpdateAge, "fallbackMaxOcspResponseThisUpdateAge");
+ this.rejectUnknownOcspResponseStatus = rejectUnknownOcspResponseStatus;
+ 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 = resolvePrimaryOcspService(subjectCertificate);
+ CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(primaryService.getAccessLocation().toASCIIString());
+
+ 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()));
+ }
+
+ 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 OcspService resolvePrimaryOcspService(X509Certificate subjectCertificate) throws AuthTokenException {
+ try {
+ return getOcspServiceProvider().getService(subjectCertificate);
+ } catch (CertificateException e) {
+ throw new ResilientUserCertificateOCSPCheckFailedException(new ValidationInfo(subjectCertificate, List.of()));
+ }
+ }
+
+ 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));
+ 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
+ );
+ }
+
+ final BasicOCSPResp basicResponse = (BasicOCSPResp) response.getResponseObject();
+ if (basicResponse == null) {
+ throw createException("Missing Basic OCSP Response", subjectCertificate,
+ ocspResponderUri, request, response, requestDuration, responseTime
+ );
+ }
+ LOG.debug("OCSP response received successfully");
+
+ verifyOcspResponse(basicResponse, ocspService, certificateId, rejectUnknownOcspResponseStatus, 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: UserCertificateRevokedException covers both actual revocation and unknown status
+ // when rejectUnknownOcspResponseStatus=false (see OcspResponseValidator.validateSubjectCertificateStatus).
+ // When rejectUnknownOcspResponseStatus=true, unknown status throws UserCertificateUnknownException
+ // instead, which 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..159de9c8
--- /dev/null
+++ b/src/main/java/eu/webeid/resilientocsp/exceptions/ResilientUserCertificateOCSPCheckFailedException.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) 2020-2025 Estonian Information System Authority
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+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..27ec8f4e
--- /dev/null
+++ b/src/main/java/eu/webeid/resilientocsp/exceptions/ResilientUserCertificateRevokedException.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2020-2025 Estonian Information System Authority
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+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/ValidationInfo.java b/src/main/java/eu/webeid/security/validator/ValidationInfo.java
index 11a61561..7bbe9cef 100644
--- a/src/main/java/eu/webeid/security/validator/ValidationInfo.java
+++ b/src/main/java/eu/webeid/security/validator/ValidationInfo.java
@@ -8,8 +8,9 @@
import static java.util.Objects.requireNonNull;
public record ValidationInfo(X509Certificate subjectCertificate, List revocationInfoList) {
- public ValidationInfo {
- requireNonNull(subjectCertificate, "subjectCertificate");
- requireNonNull(revocationInfoList, "revocationInfoList");
+
+ public ValidationInfo(X509Certificate subjectCertificate, List revocationInfoList) {
+ this.subjectCertificate = requireNonNull(subjectCertificate, "subjectCertificate");
+ this.revocationInfoList = List.copyOf(requireNonNull(revocationInfoList, "revocationInfoList"));
}
}
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 eda3a6e2..1c910d25 100644
--- a/src/main/java/eu/webeid/security/validator/revocationcheck/RevocationInfo.java
+++ b/src/main/java/eu/webeid/security/validator/revocationcheck/RevocationInfo.java
@@ -22,11 +22,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 50b31a9c..abd85cc4 100644
--- a/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java
+++ b/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java
@@ -22,6 +22,7 @@
package eu.webeid.ocsp;
+import eu.webeid.ocsp.exceptions.OCSPClientException;
import eu.webeid.security.exceptions.CertificateExpiredException;
import eu.webeid.security.exceptions.CertificateNotTrustedException;
import eu.webeid.security.exceptions.JceException;
@@ -60,14 +61,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;
@@ -122,6 +126,8 @@ void whenOcspUrlIsInvalid_thenThrows() throws Exception {
validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
.isInstanceOf(UserCertificateOCSPCheckFailedException.class)
.cause()
+ .isInstanceOf(OCSPClientException.class)
+ .cause()
.isInstanceOf(ConnectException.class);
}
@@ -129,12 +135,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
@@ -146,6 +151,8 @@ void whenOcspRequestHasInvalidBody_thenThrows() throws Exception {
validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
.isInstanceOf(UserCertificateOCSPCheckFailedException.class)
.cause()
+ .isInstanceOf(OCSPClientException.class)
+ .cause()
.isInstanceOf(IOException.class)
.hasMessage("DEF length 110 object truncated by 105");
}
@@ -364,7 +371,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);
}
@@ -404,7 +411,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 eabe9b13..27933768 100644
--- a/src/test/java/eu/webeid/ocsp/client/OcspClientOverrideTest.java
+++ b/src/test/java/eu/webeid/ocsp/client/OcspClientOverrideTest.java
@@ -23,6 +23,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;
@@ -82,12 +83,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 f681ac12..29256ad4 100644
--- a/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java
+++ b/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java
@@ -24,6 +24,10 @@
import eu.webeid.ocsp.OcspCertificateRevocationChecker;
import eu.webeid.ocsp.exceptions.UserCertificateOCSPCheckFailedException;
+import eu.webeid.ocsp.exceptions.UserCertificateRevokedException;
+import eu.webeid.ocsp.exceptions.UserCertificateUnknownException;
+import org.bouncycastle.cert.ocsp.BasicOCSPResp;
+import org.bouncycastle.cert.ocsp.OCSPResp;
import org.bouncycastle.cert.ocsp.SingleResp;
import org.junit.jupiter.api.Test;
@@ -33,7 +37,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;
@@ -118,8 +124,32 @@ void whenNextUpdateHalfHourBeforeNow_thenThrows() {
+ " (OCSP responder: https://example.org)");
}
+ @Test
+ void whenRejectUnknownOcspResponseStatusIsFalse_ThenUnknownStatusThrowsUserCertificateRevokedException() throws Exception {
+ SingleResp unknownCertStatus = getUnknownCertStatusResponse();
+ assertThatExceptionOfType(UserCertificateRevokedException.class)
+ .isThrownBy(() ->
+ validateSubjectCertificateStatus(unknownCertStatus, OCSP_URL, false))
+ .withMessage("User certificate has been revoked: Unknown status (OCSP responder: https://example.org)");
+ }
+
+ @Test
+ void whenRejectUnknownOcspResponseStatusIsTrue_ThenUnknownStatusThrowsUserCertificateUnknownException() throws Exception {
+ SingleResp unknownCertStatus = getUnknownCertStatusResponse();
+ assertThatExceptionOfType(UserCertificateUnknownException.class)
+ .isThrownBy(() ->
+ validateSubjectCertificateStatus(unknownCertStatus, OCSP_URL, true))
+ .withMessage("User certificate status is unknown: 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/OcspServiceMaker.java b/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java
index 340764b7..8c723c84 100644
--- a/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java
+++ b/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java
@@ -25,6 +25,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;
@@ -41,7 +42,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 {
@@ -69,7 +70,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 123f996c..0863a258 100644
--- a/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java
+++ b/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java
@@ -100,4 +100,4 @@ void whenAiaOcspServiceConfigurationDoesNotHaveResponderCertTrustedCA_thenThrows
// 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..f6af5cfb
--- /dev/null
+++ b/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java
@@ -0,0 +1,388 @@
+/*
+ * Copyright (c) 2020-2025 Estonian Information System Authority
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+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, false);
+ 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, false);
+
+ 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, false);
+ 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, false);
+ 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, false);
+ 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, false);
+
+ 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, false);
+ 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())).thenReturn(primaryService);
+
+ ResilientOcspCertificateRevocationChecker checker = new ResilientOcspCertificateRevocationChecker(
+ ocspClient,
+ ocspServiceProvider,
+ CircuitBreakerConfig.ofDefaults(),
+ null,
+ OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW,
+ OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE,
+ LONG_THIS_UPDATE_AGE,
+ false
+ );
+
+ 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())).thenReturn(primaryService);
+
+ ResilientOcspCertificateRevocationChecker checker = new ResilientOcspCertificateRevocationChecker(
+ ocspClient,
+ ocspServiceProvider,
+ CircuitBreakerConfig.ofDefaults(),
+ null,
+ OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW,
+ OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE,
+ LONG_THIS_UPDATE_AGE,
+ false
+ );
+
+ 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, false);
+ 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, boolean rejectUnknownOcspResponseStatus) 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())).thenReturn(primaryService);
+
+ return new ResilientOcspCertificateRevocationChecker(
+ ocspClient,
+ ocspServiceProvider,
+ CircuitBreakerConfig.ofDefaults(),
+ retryConfig,
+ OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW,
+ LONG_THIS_UPDATE_AGE,
+ LONG_THIS_UPDATE_AGE,
+ rejectUnknownOcspResponseStatus
+ );
+ }
+
+ 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