getFallbackOcspServiceConfigurations() {
+ return fallbackOcspServiceConfigurations;
+ }
+
+ public CircuitBreakerConfig getCircuitBreakerConfig() {
+ return circuitBreakerConfig;
+ }
+
+ public void setCircuitBreakerConfig(CircuitBreakerConfig circuitBreakerConfig) {
+ this.circuitBreakerConfig = circuitBreakerConfig;
+ }
+
+ public RetryConfig getCircuitBreakerRetryConfig() {
+ return circuitBreakerRetryConfig;
+ }
+
+ public void setCircuitBreakerRetryConfig(RetryConfig circuitBreakerRetryConfig) {
+ this.circuitBreakerRetryConfig = circuitBreakerRetryConfig;
+ }
+
+ public boolean isRejectUnknownOcspResponseStatus() {
+ return rejectUnknownOcspResponseStatus;
+ }
+
+ public void setRejectUnknownOcspResponseStatus(boolean rejectUnknownOcspResponseStatus) {
+ this.rejectUnknownOcspResponseStatus = rejectUnknownOcspResponseStatus;
+ }
+
/**
* Checks that the configuration parameters are valid.
*
@@ -150,6 +189,7 @@ void validate() {
requirePositiveDuration(ocspRequestTimeout, "OCSP request timeout");
requirePositiveDuration(allowedOcspResponseTimeSkew, "Allowed OCSP response time-skew");
requirePositiveDuration(maxOcspResponseThisUpdateAge, "Max OCSP response thisUpdate age");
+ // TODO: Add OCSP fallback/response validation
}
AuthTokenValidationConfiguration copy() {
diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidator.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidator.java
index 3476ea41..8cb95a8b 100644
--- a/src/main/java/eu/webeid/security/validator/AuthTokenValidator.java
+++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidator.java
@@ -57,6 +57,6 @@ public interface AuthTokenValidator {
* @return validated subject certificate
* @throws AuthTokenException when validation fails
*/
- X509Certificate validate(WebEidAuthToken authToken, String currentChallengeNonce) throws AuthTokenException;
+ ValidationInfo validate(WebEidAuthToken authToken, String currentChallengeNonce) throws AuthTokenException;
}
diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java
index 9122ee67..21752834 100644
--- a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java
+++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java
@@ -26,6 +26,10 @@
import eu.webeid.security.validator.ocsp.OcspClient;
import eu.webeid.security.validator.ocsp.OcspClientImpl;
import eu.webeid.security.validator.ocsp.service.DesignatedOcspServiceConfiguration;
+import eu.webeid.security.validator.ocsp.service.FallbackOcspServiceConfiguration;
+import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
+import io.github.resilience4j.core.IntervalFunction;
+import io.github.resilience4j.retry.RetryConfig;
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -187,6 +191,65 @@ public AuthTokenValidatorBuilder withDesignatedOcspServiceConfiguration(Designat
return this;
}
+ /**
+ * // TODO: Describe the configuration option
+ *
+ * @param serviceConfiguration configurations of the fallback OCSP services
+ * @return the builder instance for method chaining
+ */
+ public AuthTokenValidatorBuilder withFallbackOcspServiceConfiguration(FallbackOcspServiceConfiguration... serviceConfiguration) {
+ // TODO: Validate that no two configurations have the same OCSP service access location
+ Collections.addAll(configuration.getFallbackOcspServiceConfigurations(), serviceConfiguration);
+ LOG.debug("Fallback OCSP services set to {}", configuration.getFallbackOcspServiceConfigurations());
+ return this;
+ }
+
+
+ /**
+ * // TODO: Describe the configuration option
+ *
+ * @param slidingWindowSize
+ * @param minimumNumberOfCalls
+ * @param failureRateThreshold
+ * @param permittedNumberOfCallsInHalfOpenState
+ * @param waitDurationInOpenState
+ * @return the builder instance for method chaining
+ */
+ public AuthTokenValidatorBuilder withCircuitBreakerConfig(int slidingWindowSize, int minimumNumberOfCalls, int failureRateThreshold, int permittedNumberOfCallsInHalfOpenState, Duration waitDurationInOpenState) { // TODO: What do we allow to configure? Use configuration builder.
+ configuration.setCircuitBreakerConfig(CircuitBreakerConfig.custom()
+ .slidingWindowSize(slidingWindowSize)
+ .minimumNumberOfCalls(minimumNumberOfCalls)
+ .failureRateThreshold(failureRateThreshold)
+ .permittedNumberOfCallsInHalfOpenState(permittedNumberOfCallsInHalfOpenState)
+ .waitIntervalFunctionInOpenState(IntervalFunction.of(waitDurationInOpenState))
+ .build());
+ LOG.debug("Using the OCSP circuit breaker configuration");
+ return this;
+ }
+
+ /**
+ * // TODO: Describe the configuration option
+ *
+ * @return the builder instance for method chaining
+ */
+ public AuthTokenValidatorBuilder withCircuitBreakerRetryConfig() { // TODO: What do we allow to configure? Use configuration builder.
+ configuration.setCircuitBreakerRetryConfig(RetryConfig.ofDefaults());
+ LOG.debug("Using the OCSP circuit breaker retry configuration");
+ return this;
+ }
+
+ /**
+ * // TODO: Describe the configuration option
+ *
+ * @param rejectUnknownOcspResponseStatus configures whether only GOOD or REVOKED are accepted as valid OCSP response statuses
+ * @return the builder instance for method chaining
+ */
+ public AuthTokenValidatorBuilder withRejectUnknownOcspResponseStatus(boolean rejectUnknownOcspResponseStatus) {
+ configuration.setRejectUnknownOcspResponseStatus(rejectUnknownOcspResponseStatus);
+ LOG.debug("Using the reject unknown OCSP response status validation configuration");
+ return this;
+ }
+
/**
* Uses the provided OCSP client instance during user certificate revocation check with OCSP.
* The provided client instance must be thread-safe.
diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java
index 14cf3e78..7c408794 100644
--- a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java
+++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java
@@ -37,6 +37,8 @@
import eu.webeid.security.validator.certvalidators.SubjectCertificateValidatorBatch;
import eu.webeid.security.validator.ocsp.OcspClient;
import eu.webeid.security.validator.ocsp.OcspServiceProvider;
+import eu.webeid.security.validator.ocsp.OcspValidationInfo;
+import eu.webeid.security.validator.ocsp.ResilientOcspService;
import eu.webeid.security.validator.ocsp.service.AiaOcspServiceConfiguration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -65,9 +67,8 @@ final class AuthTokenValidatorImpl implements AuthTokenValidator {
private final CertStore trustedCACertificateCertStore;
// OcspClient uses built-in HttpClient internally by default.
// A single HttpClient instance is reused for all HTTP calls to utilize connection and thread pools.
- private OcspClient ocspClient;
- private OcspServiceProvider ocspServiceProvider;
private final AuthTokenSignatureValidator authTokenSignatureValidator;
+ private ResilientOcspService resilientOcspService;
/**
* @param configuration configuration parameters for the token validator
@@ -88,12 +89,19 @@ final class AuthTokenValidatorImpl implements AuthTokenValidator {
if (configuration.isUserCertificateRevocationCheckWithOcspEnabled()) {
// The OCSP client may be provided by the API consumer.
- this.ocspClient = Objects.requireNonNull(ocspClient, "OCSP client must not be null when OCSP check is enabled");
- ocspServiceProvider = new OcspServiceProvider(
+ Objects.requireNonNull(ocspClient, "OCSP client must not be null when OCSP check is enabled");
+ OcspServiceProvider ocspServiceProvider = new OcspServiceProvider(
configuration.getDesignatedOcspServiceConfiguration(),
new AiaOcspServiceConfiguration(configuration.getNonceDisabledOcspUrls(),
trustedCACertificateAnchors,
- trustedCACertificateCertStore));
+ trustedCACertificateCertStore),
+ configuration.getFallbackOcspServiceConfigurations());
+ resilientOcspService = new ResilientOcspService(ocspClient, ocspServiceProvider,
+ configuration.getCircuitBreakerConfig(),
+ configuration.getCircuitBreakerRetryConfig(),
+ configuration.getAllowedOcspResponseTimeSkew(),
+ configuration.getMaxOcspResponseThisUpdateAge(),
+ configuration.isRejectUnknownOcspResponseStatus());
}
authTokenSignatureValidator = new AuthTokenSignatureValidator(configuration.getSiteOrigin());
@@ -113,7 +121,7 @@ public WebEidAuthToken parse(String authToken) throws AuthTokenException {
}
@Override
- public X509Certificate validate(WebEidAuthToken authToken, String currentChallengeNonce) throws AuthTokenException {
+ public ValidationInfo validate(WebEidAuthToken authToken, String currentChallengeNonce) throws AuthTokenException {
try {
LOG.info("Starting token validation");
return validateToken(authToken, currentChallengeNonce);
@@ -145,7 +153,7 @@ private WebEidAuthToken parseToken(String authToken) throws AuthTokenParseExcept
}
}
- private X509Certificate validateToken(WebEidAuthToken token, String currentChallengeNonce) throws AuthTokenException {
+ private ValidationInfo validateToken(WebEidAuthToken token, String currentChallengeNonce) throws AuthTokenException {
if (token.getFormat() == null || !token.getFormat().startsWith(CURRENT_TOKEN_FORMAT_VERSION)) {
throw new AuthTokenParseException("Only token format version '" + CURRENT_TOKEN_FORMAT_VERSION +
"' is currently supported");
@@ -156,7 +164,7 @@ private X509Certificate validateToken(WebEidAuthToken token, String currentChall
final X509Certificate subjectCertificate = CertificateLoader.decodeCertificateFromBase64(token.getUnverifiedCertificate());
simpleSubjectCertificateValidators.executeFor(subjectCertificate);
- getCertTrustValidators().executeFor(subjectCertificate);
+ final SubjectCertificateTrustedValidator certTrustedValidator = validateCertificateTrust(subjectCertificate);
// It is guaranteed that if the signature verification succeeds, then the origin and challenge
// have been implicitly and correctly verified without the need to implement any additional checks.
@@ -165,29 +173,28 @@ private X509Certificate validateToken(WebEidAuthToken token, String currentChall
subjectCertificate.getPublicKey(),
currentChallengeNonce);
- return subjectCertificate;
+ final OcspValidationInfo ocspValidationInfo = validateCertificateRevocationStatus(certTrustedValidator, subjectCertificate);
+ return new ValidationInfo(subjectCertificate, ocspValidationInfo);
}
/**
- * Creates the certificate trust validators batch.
+ * Validates the certificate trust and optionally the revocation status.
* As SubjectCertificateTrustedValidator has mutable state that SubjectCertificateNotRevokedValidator depends on,
* they cannot be reused/cached in an instance variable in a multi-threaded environment. Hence, they are
* re-created for each validation run for thread safety.
*
- * @return certificate trust validator batch
+ * @return ocsp validation information if revocation check is performed, null otherwise
*/
- private SubjectCertificateValidatorBatch getCertTrustValidators() {
- final SubjectCertificateTrustedValidator certTrustedValidator =
- new SubjectCertificateTrustedValidator(trustedCACertificateAnchors, trustedCACertificateCertStore);
- return SubjectCertificateValidatorBatch.createFrom(
- certTrustedValidator::validateCertificateTrusted
- ).addOptional(configuration.isUserCertificateRevocationCheckWithOcspEnabled(),
- new SubjectCertificateNotRevokedValidator(certTrustedValidator,
- ocspClient, ocspServiceProvider,
- configuration.getAllowedOcspResponseTimeSkew(),
- configuration.getMaxOcspResponseThisUpdateAge()
- )::validateCertificateNotRevoked
- );
+ private SubjectCertificateTrustedValidator validateCertificateTrust(X509Certificate subjectCertificate) throws AuthTokenException {
+ SubjectCertificateTrustedValidator certTrustedValidator = new SubjectCertificateTrustedValidator(trustedCACertificateAnchors, trustedCACertificateCertStore);
+ certTrustedValidator.validateCertificateTrusted(subjectCertificate);
+ return certTrustedValidator;
}
+ private OcspValidationInfo validateCertificateRevocationStatus(SubjectCertificateTrustedValidator certTrustedValidator, X509Certificate subjectCertificate) throws AuthTokenException {
+ return configuration.isUserCertificateRevocationCheckWithOcspEnabled()
+ ? new SubjectCertificateNotRevokedValidator(resilientOcspService, certTrustedValidator)
+ .validateCertificateNotRevoked(subjectCertificate)
+ : null;
+ }
}
diff --git a/src/main/java/eu/webeid/security/validator/ValidationInfo.java b/src/main/java/eu/webeid/security/validator/ValidationInfo.java
new file mode 100644
index 00000000..88949fd4
--- /dev/null
+++ b/src/main/java/eu/webeid/security/validator/ValidationInfo.java
@@ -0,0 +1,45 @@
+/*
+ * 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.security.validator;
+
+import eu.webeid.security.validator.ocsp.OcspValidationInfo;
+
+import java.security.cert.X509Certificate;
+
+public class ValidationInfo {
+ private final X509Certificate subjectCertificate;
+ private final OcspValidationInfo ocspValidationInfo;
+
+ public ValidationInfo(X509Certificate subjectCertificate, OcspValidationInfo ocspValidationInfo) {
+ this.subjectCertificate = subjectCertificate;
+ this.ocspValidationInfo = ocspValidationInfo;
+ }
+
+ public X509Certificate getSubjectCertificate() {
+ return subjectCertificate;
+ }
+
+ public OcspValidationInfo getOcspValidationInfo() {
+ return ocspValidationInfo;
+ }
+}
diff --git a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidator.java b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidator.java
index c460d405..5956637e 100644
--- a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidator.java
+++ b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidator.java
@@ -23,64 +23,26 @@
package eu.webeid.security.validator.certvalidators;
import eu.webeid.security.exceptions.AuthTokenException;
-import eu.webeid.security.exceptions.UserCertificateOCSPCheckFailedException;
-import eu.webeid.security.util.DateAndTime;
-import eu.webeid.security.validator.ocsp.DigestCalculatorImpl;
-import eu.webeid.security.validator.ocsp.OcspClient;
-import eu.webeid.security.validator.ocsp.OcspRequestBuilder;
-import eu.webeid.security.validator.ocsp.OcspResponseValidator;
-import eu.webeid.security.validator.ocsp.OcspServiceProvider;
-import eu.webeid.security.validator.ocsp.service.OcspService;
-import org.bouncycastle.asn1.ocsp.OCSPObjectIdentifiers;
-import org.bouncycastle.asn1.ocsp.OCSPResponseStatus;
-import org.bouncycastle.asn1.x509.Extension;
-import org.bouncycastle.cert.X509CertificateHolder;
-import org.bouncycastle.cert.ocsp.BasicOCSPResp;
-import org.bouncycastle.cert.ocsp.CertificateID;
-import org.bouncycastle.cert.ocsp.OCSPException;
-import org.bouncycastle.cert.ocsp.OCSPReq;
-import org.bouncycastle.cert.ocsp.OCSPResp;
-import org.bouncycastle.cert.ocsp.SingleResp;
+import eu.webeid.security.validator.ocsp.OcspValidationInfo;
+import eu.webeid.security.validator.ocsp.ResilientOcspService;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
-import org.bouncycastle.operator.DigestCalculator;
-import org.bouncycastle.operator.OperatorCreationException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import java.io.IOException;
-import java.math.BigInteger;
import java.security.Security;
-import java.security.cert.CertificateEncodingException;
-import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
-import java.time.Duration;
-import java.util.Date;
import java.util.Objects;
public final class SubjectCertificateNotRevokedValidator {
- private static final Logger LOG = LoggerFactory.getLogger(SubjectCertificateNotRevokedValidator.class);
-
private final SubjectCertificateTrustedValidator trustValidator;
- private final OcspClient ocspClient;
- private final OcspServiceProvider ocspServiceProvider;
- private final Duration allowedOcspResponseTimeSkew;
- private final Duration maxOcspResponseThisUpdateAge;
+ private final ResilientOcspService resilientOcspService;
static {
Security.addProvider(new BouncyCastleProvider());
}
- public SubjectCertificateNotRevokedValidator(SubjectCertificateTrustedValidator trustValidator,
- OcspClient ocspClient,
- OcspServiceProvider ocspServiceProvider,
- Duration allowedOcspResponseTimeSkew,
- Duration maxOcspResponseThisUpdateAge) {
+ public SubjectCertificateNotRevokedValidator(ResilientOcspService resilientOcspService, SubjectCertificateTrustedValidator trustValidator) {
+ this.resilientOcspService = resilientOcspService;
this.trustValidator = trustValidator;
- this.ocspClient = ocspClient;
- this.ocspServiceProvider = ocspServiceProvider;
- this.allowedOcspResponseTimeSkew = allowedOcspResponseTimeSkew;
- this.maxOcspResponseThisUpdateAge = maxOcspResponseThisUpdateAge;
}
/**
@@ -89,133 +51,8 @@ public SubjectCertificateNotRevokedValidator(SubjectCertificateTrustedValidator
* @param subjectCertificate user certificate to be validated
* @throws AuthTokenException when user certificate is revoked or revocation check fails.
*/
- public void validateCertificateNotRevoked(X509Certificate subjectCertificate) throws AuthTokenException {
- try {
- OcspService ocspService = ocspServiceProvider.getService(subjectCertificate);
-
- final CertificateID certificateId = getCertificateId(subjectCertificate,
- Objects.requireNonNull(trustValidator.getSubjectCertificateIssuerCertificate()));
-
- final OCSPReq request = new OcspRequestBuilder()
- .withCertificateId(certificateId)
- .enableOcspNonce(ocspService.doesSupportNonce())
- .build();
-
- if (!ocspService.doesSupportNonce()) {
- LOG.debug("Disabling OCSP nonce extension");
- }
-
- LOG.debug("Sending OCSP request");
- final OCSPResp response = Objects.requireNonNull(ocspClient.request(ocspService.getAccessLocation(), request));
- if (response.getStatus() != OCSPResponseStatus.SUCCESSFUL) {
- throw new UserCertificateOCSPCheckFailedException("Response status: " + ocspStatusToString(response.getStatus()));
- }
-
- final BasicOCSPResp basicResponse = (BasicOCSPResp) response.getResponseObject();
- if (basicResponse == null) {
- throw new UserCertificateOCSPCheckFailedException("Missing Basic OCSP Response");
- }
- verifyOcspResponse(basicResponse, ocspService, certificateId);
- if (ocspService.doesSupportNonce()) {
- checkNonce(request, basicResponse);
- }
- } catch (OCSPException | CertificateException | OperatorCreationException | IOException e) {
- throw new UserCertificateOCSPCheckFailedException(e);
- }
+ public OcspValidationInfo validateCertificateNotRevoked(X509Certificate subjectCertificate) throws AuthTokenException {
+ final X509Certificate issuerCertificate = Objects.requireNonNull(trustValidator.getSubjectCertificateIssuerCertificate());
+ return resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate);
}
-
- private void verifyOcspResponse(BasicOCSPResp basicResponse, OcspService ocspService, CertificateID requestCertificateId) throws AuthTokenException, OCSPException, CertificateException, OperatorCreationException {
- // The verification algorithm follows RFC 2560, https://www.ietf.org/rfc/rfc2560.txt.
- //
- // 3.2. Signed Response Acceptance Requirements
- // Prior to accepting a signed response for a particular certificate as
- // valid, OCSP clients SHALL confirm that:
- //
- // 1. The certificate identified in a received response corresponds to
- // the certificate that was identified in the corresponding request.
-
- // As we sent the request for only a single certificate, we expect only a single response.
- if (basicResponse.getResponses().length != 1) {
- throw new UserCertificateOCSPCheckFailedException("OCSP response must contain one response, "
- + "received " + basicResponse.getResponses().length + " responses instead");
- }
- final SingleResp certStatusResponse = basicResponse.getResponses()[0];
- if (!requestCertificateId.equals(certStatusResponse.getCertID())) {
- throw new UserCertificateOCSPCheckFailedException("OCSP responded with certificate ID that differs from the requested ID");
- }
-
- // 2. The signature on the response is valid.
-
- // We assume that the responder includes its certificate in the certs field of the response
- // that helps us to verify it. According to RFC 2560 this field is optional, but including it
- // is standard practice.
- if (basicResponse.getCerts().length < 1) {
- throw new UserCertificateOCSPCheckFailedException("OCSP response must contain the responder certificate, "
- + "but none was provided");
- }
- // The first certificate is the responder certificate, other certificates, if given, are the certificate's chain.
- final X509CertificateHolder responderCert = basicResponse.getCerts()[0];
- OcspResponseValidator.validateResponseSignature(basicResponse, responderCert);
-
- // 3. The identity of the signer matches the intended recipient of the
- // request.
- //
- // 4. The signer is currently authorized to provide a response for the
- // certificate in question.
-
- // Use the clock instance so that the date can be mocked in tests.
- final Date now = DateAndTime.DefaultClock.getInstance().now();
- ocspService.validateResponderCertificate(responderCert, now);
-
- // 5. The time at which the status being indicated is known to be
- // correct (thisUpdate) is sufficiently recent.
- //
- // 6. When available, the time at or before which newer information will
- // be available about the status of the certificate (nextUpdate) is
- // greater than the current time.
-
- OcspResponseValidator.validateCertificateStatusUpdateTime(certStatusResponse, allowedOcspResponseTimeSkew, maxOcspResponseThisUpdateAge);
-
- // Now we can accept the signed response as valid and validate the certificate status.
- OcspResponseValidator.validateSubjectCertificateStatus(certStatusResponse);
- LOG.debug("OCSP check result is GOOD");
- }
-
- private static void checkNonce(OCSPReq request, BasicOCSPResp response) 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) {
- throw new UserCertificateOCSPCheckFailedException("OCSP request or response nonce extension missing, " +
- "possible replay attack");
- }
- if (!requestNonce.equals(responseNonce)) {
- throw new UserCertificateOCSPCheckFailedException("OCSP request and response nonces differ, " +
- "possible replay attack");
- }
- }
-
- private 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) {
- switch (status) {
- case OCSPResp.MALFORMED_REQUEST:
- return "malformed request";
- case OCSPResp.INTERNAL_ERROR:
- return "internal error";
- case OCSPResp.TRY_LATER:
- return "service unavailable";
- case OCSPResp.SIG_REQUIRED:
- return "request signature missing";
- case OCSPResp.UNAUTHORIZED:
- return "unauthorized";
- default:
- return "unknown";
- }
- }
-
}
diff --git a/src/main/java/eu/webeid/security/validator/ocsp/OcspResponseValidator.java b/src/main/java/eu/webeid/security/validator/ocsp/OcspResponseValidator.java
index 0dc4fda5..ecad7048 100644
--- a/src/main/java/eu/webeid/security/validator/ocsp/OcspResponseValidator.java
+++ b/src/main/java/eu/webeid/security/validator/ocsp/OcspResponseValidator.java
@@ -22,30 +22,134 @@
package eu.webeid.security.validator.ocsp;
+import eu.webeid.security.exceptions.AuthTokenException;
import eu.webeid.security.exceptions.OCSPCertificateException;
import eu.webeid.security.exceptions.UserCertificateOCSPCheckFailedException;
import eu.webeid.security.exceptions.UserCertificateRevokedException;
+import eu.webeid.security.exceptions.UserCertificateUnknownException;
import eu.webeid.security.util.DateAndTime;
+import eu.webeid.security.validator.ocsp.service.OcspService;
+import org.bouncycastle.asn1.ocsp.OCSPObjectIdentifiers;
+import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.ocsp.BasicOCSPResp;
+import org.bouncycastle.cert.ocsp.CertificateID;
import org.bouncycastle.cert.ocsp.CertificateStatus;
import org.bouncycastle.cert.ocsp.OCSPException;
+import org.bouncycastle.cert.ocsp.OCSPReq;
+import org.bouncycastle.cert.ocsp.OCSPResp;
import org.bouncycastle.cert.ocsp.RevokedStatus;
import org.bouncycastle.cert.ocsp.SingleResp;
import org.bouncycastle.cert.ocsp.UnknownStatus;
import org.bouncycastle.operator.ContentVerifierProvider;
+import org.bouncycastle.operator.DigestCalculator;
import org.bouncycastle.operator.OperatorCreationException;
import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder;
+import java.io.IOException;
+import java.math.BigInteger;
+import java.security.cert.CertificateEncodingException;
import java.security.cert.CertificateException;
import java.security.cert.CertificateParsingException;
import java.security.cert.X509Certificate;
import java.time.Duration;
import java.time.Instant;
+import java.util.Date;
import java.util.Objects;
public final class OcspResponseValidator {
+ public static OcspValidationInfo validateOcspResponse(OCSPResp ocspResp, OcspService ocspService, Extension requestNonce,
+ X509Certificate subjectCertificate, X509Certificate issuerCertificate,
+ Duration allowedOcspResponseTimeSkew, Duration maxOcspResponseThisUpdateAge,
+ boolean rejectUnknownOcspResponseStatus) throws AuthTokenException, OCSPException, CertificateException, OperatorCreationException {
+ final OcspValidationInfo ocspValidationInfo = new OcspValidationInfo(subjectCertificate, ocspService.getAccessLocation(), ocspResp);
+ final BasicOCSPResp basicResponse = (BasicOCSPResp) ocspResp.getResponseObject();
+ if (basicResponse == null) {
+ throw new UserCertificateOCSPCheckFailedException("Missing Basic OCSP Response", ocspValidationInfo);
+ }
+
+ // The verification algorithm follows RFC 2560, https://www.ietf.org/rfc/rfc2560.txt.
+ //
+ // 3.2. Signed Response Acceptance Requirements
+ // Prior to accepting a signed response for a particular certificate as
+ // valid, OCSP clients SHALL confirm that:
+ //
+ // 1. The certificate identified in a received response corresponds to
+ // the certificate that was identified in the corresponding request.
+
+ // As we sent the request for only a single certificate, we expect only a single response.
+ if (basicResponse.getResponses().length != 1) {
+ throw new UserCertificateOCSPCheckFailedException("OCSP response must contain one response, "
+ + "received " + basicResponse.getResponses().length + " responses instead", ocspValidationInfo);
+ }
+ final CertificateID requestCertificateId = getCertificateId(subjectCertificate, issuerCertificate, ocspService, ocspResp);
+ final SingleResp certStatusResponse = basicResponse.getResponses()[0];
+ if (!requestCertificateId.equals(certStatusResponse.getCertID())) {
+ throw new UserCertificateOCSPCheckFailedException("OCSP responded with certificate ID that differs from the requested ID", ocspValidationInfo);
+ }
+
+ // 2. The signature on the response is valid.
+
+ // We assume that the responder includes its certificate in the certs field of the response
+ // that helps us to verify it. According to RFC 2560 this field is optional, but including it
+ // is standard practice.
+ if (basicResponse.getCerts().length < 1) {
+ throw new UserCertificateOCSPCheckFailedException("OCSP response must contain the responder certificate, "
+ + "but none was provided", ocspValidationInfo);
+ }
+ // The first certificate is the responder certificate, other certificates, if given, are the certificate's chain.
+ final X509CertificateHolder responderCert = basicResponse.getCerts()[0];
+ OcspResponseValidator.validateResponseSignature(basicResponse, responderCert, ocspValidationInfo);
+
+ // 3. The identity of the signer matches the intended recipient of the
+ // request.
+ //
+ // 4. The signer is currently authorized to provide a response for the
+ // certificate in question.
+
+ // Use the clock instance so that the date can be mocked in tests.
+ final Date now = DateAndTime.DefaultClock.getInstance().now();
+ try {
+ ocspService.validateResponderCertificate(responderCert, now);
+ } catch (AuthTokenException e) {
+ throw new UserCertificateOCSPCheckFailedException(e, ocspValidationInfo);
+ }
+
+ // 5. The time at which the status being indicated is known to be
+ // correct (thisUpdate) is sufficiently recent.
+ //
+ // 6. When available, the time at or before which newer information will
+ // be available about the status of the certificate (nextUpdate) is
+ // greater than the current time.
+
+ OcspResponseValidator.validateCertificateStatusUpdateTime(certStatusResponse, allowedOcspResponseTimeSkew, maxOcspResponseThisUpdateAge, ocspValidationInfo);
+
+ // Now we can accept the signed response as valid and validate the certificate status.
+ OcspResponseValidator.validateSubjectCertificateStatus(certStatusResponse, rejectUnknownOcspResponseStatus, ocspValidationInfo);
+
+ if (ocspService.doesSupportNonce()) {
+ OcspResponseValidator.validateNonce(requestNonce, ocspResp, ocspValidationInfo);
+ }
+
+ return ocspValidationInfo;
+ }
+
+ private static CertificateID getCertificateId(X509Certificate subjectCertificate, X509Certificate issuerCertificate, OcspService ocspService, OCSPResp ocspResp) throws AuthTokenException {
+ try {
+ return getCertificateId(subjectCertificate, issuerCertificate);
+ } catch (CertificateEncodingException | IOException | OCSPException e) {
+ throw new UserCertificateOCSPCheckFailedException(e, new OcspValidationInfo(subjectCertificate, ocspService.getAccessLocation(), ocspResp));
+ }
+ }
+
+ public 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);
+ }
+
/**
* Indicates that a X.509 Certificates corresponding private key may be used by an authority to sign OCSP responses.
*
@@ -66,16 +170,16 @@ public static void validateHasSigningExtension(X509Certificate certificate) thro
}
}
- public static void validateResponseSignature(BasicOCSPResp basicResponse, X509CertificateHolder responderCert) throws CertificateException, OperatorCreationException, OCSPException, UserCertificateOCSPCheckFailedException {
+ private static void validateResponseSignature(BasicOCSPResp basicResponse, X509CertificateHolder responderCert, OcspValidationInfo ocspValidationInfo) throws CertificateException, OperatorCreationException, OCSPException, UserCertificateOCSPCheckFailedException {
final ContentVerifierProvider verifierProvider = new JcaContentVerifierProviderBuilder()
.setProvider("BC")
.build(responderCert);
if (!basicResponse.isSignatureValid(verifierProvider)) {
- throw new UserCertificateOCSPCheckFailedException("OCSP response signature is invalid");
+ throw new UserCertificateOCSPCheckFailedException("OCSP response signature is invalid", ocspValidationInfo);
}
}
- public static void validateCertificateStatusUpdateTime(SingleResp certStatusResponse, Duration allowedTimeSkew, Duration maxThisupdateAge) throws UserCertificateOCSPCheckFailedException {
+ static void validateCertificateStatusUpdateTime(SingleResp certStatusResponse, Duration allowedTimeSkew, Duration maxThisUpdateAge, OcspValidationInfo ocspValidationInfo) throws UserCertificateOCSPCheckFailedException {
// From RFC 2560, https://www.ietf.org/rfc/rfc2560.txt:
// 4.2.2. Notes on OCSP Responses
// 4.2.2.1. Time
@@ -88,18 +192,18 @@ public static void validateCertificateStatusUpdateTime(SingleResp certStatusResp
final Instant now = DateAndTime.DefaultClock.getInstance().now().toInstant();
final Instant earliestAcceptableTimeSkew = now.minus(allowedTimeSkew);
final Instant latestAcceptableTimeSkew = now.plus(allowedTimeSkew);
- final Instant minimumValidThisUpdateTime = now.minus(maxThisupdateAge);
+ final Instant minimumValidThisUpdateTime = now.minus(maxThisUpdateAge);
final Instant thisUpdate = certStatusResponse.getThisUpdate().toInstant();
if (thisUpdate.isAfter(latestAcceptableTimeSkew)) {
throw new UserCertificateOCSPCheckFailedException(ERROR_PREFIX +
"thisUpdate '" + thisUpdate + "' is too far in the future, " +
- "latest allowed: '" + latestAcceptableTimeSkew + "'");
+ "latest allowed: '" + latestAcceptableTimeSkew + "'", ocspValidationInfo);
}
if (thisUpdate.isBefore(minimumValidThisUpdateTime)) {
throw new UserCertificateOCSPCheckFailedException(ERROR_PREFIX +
"thisUpdate '" + thisUpdate + "' is too old, " +
- "minimum time allowed: '" + minimumValidThisUpdateTime + "'");
+ "minimum time allowed: '" + minimumValidThisUpdateTime + "'", ocspValidationInfo);
}
if (certStatusResponse.getNextUpdate() == null) {
@@ -108,15 +212,15 @@ public static void validateCertificateStatusUpdateTime(SingleResp certStatusResp
final Instant nextUpdate = certStatusResponse.getNextUpdate().toInstant();
if (nextUpdate.isBefore(earliestAcceptableTimeSkew)) {
throw new UserCertificateOCSPCheckFailedException(ERROR_PREFIX +
- "nextUpdate '" + nextUpdate + "' is in the past");
+ "nextUpdate '" + nextUpdate + "' is in the past", ocspValidationInfo);
}
if (nextUpdate.isBefore(thisUpdate)) {
throw new UserCertificateOCSPCheckFailedException(ERROR_PREFIX +
- "nextUpdate '" + nextUpdate + "' is before thisUpdate '" + thisUpdate + "'");
+ "nextUpdate '" + nextUpdate + "' is before thisUpdate '" + thisUpdate + "'", ocspValidationInfo);
}
}
- public static void validateSubjectCertificateStatus(SingleResp certStatusResponse) throws UserCertificateRevokedException {
+ private static void validateSubjectCertificateStatus(SingleResp certStatusResponse, boolean rejectUnknownOcspResponseStatus, OcspValidationInfo ocspValidationInfo) throws AuthTokenException {
final CertificateStatus status = certStatusResponse.getCertStatus();
if (status == null) {
return;
@@ -124,12 +228,47 @@ public static void validateSubjectCertificateStatus(SingleResp certStatusRespons
if (status instanceof RevokedStatus) {
RevokedStatus revokedStatus = (RevokedStatus) status;
throw (revokedStatus.hasRevocationReason() ?
- new UserCertificateRevokedException("Revocation reason: " + revokedStatus.getRevocationReason()) :
- new UserCertificateRevokedException());
+ new UserCertificateRevokedException("Revocation reason: " + revokedStatus.getRevocationReason(), ocspValidationInfo) :
+ new UserCertificateRevokedException(ocspValidationInfo));
} else if (status instanceof UnknownStatus) {
- throw new UserCertificateRevokedException("Unknown status");
+ throw rejectUnknownOcspResponseStatus ? new UserCertificateUnknownException("Unknown status", ocspValidationInfo)
+ : new UserCertificateRevokedException("Unknown status", ocspValidationInfo);
} else {
- throw new UserCertificateRevokedException("Status is neither good, revoked nor unknown");
+ throw rejectUnknownOcspResponseStatus ? new UserCertificateUnknownException("Status is neither good, revoked nor unknown", ocspValidationInfo)
+ : new UserCertificateRevokedException("Status is neither good, revoked nor unknown", ocspValidationInfo);
+ }
+ }
+
+ private static void validateNonce(Extension requestNonce, OCSPResp ocspResp, OcspValidationInfo ocspValidationInfo) throws UserCertificateOCSPCheckFailedException, OCSPException {
+ final BasicOCSPResp basicResponse = (BasicOCSPResp) ocspResp.getResponseObject();
+ if (basicResponse == null) {
+ throw new UserCertificateOCSPCheckFailedException("Missing Basic OCSP Response", ocspValidationInfo);
+ }
+ final Extension responseNonce = basicResponse.getExtension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce);
+ if (requestNonce == null || responseNonce == null) {
+ throw new UserCertificateOCSPCheckFailedException("OCSP request or response nonce extension missing, " +
+ "possible replay attack", ocspValidationInfo);
+ }
+ if (!requestNonce.equals(responseNonce)) {
+ throw new UserCertificateOCSPCheckFailedException("OCSP request and response nonces differ, " +
+ "possible replay attack", ocspValidationInfo);
+ }
+ }
+
+ public static String ocspStatusToString(int status) {
+ switch (status) {
+ case OCSPResp.MALFORMED_REQUEST:
+ return "malformed request";
+ case OCSPResp.INTERNAL_ERROR:
+ return "internal error";
+ case OCSPResp.TRY_LATER:
+ return "service unavailable";
+ case OCSPResp.SIG_REQUIRED:
+ return "request signature missing";
+ case OCSPResp.UNAUTHORIZED:
+ return "unauthorized";
+ default:
+ return "unknown";
}
}
diff --git a/src/main/java/eu/webeid/security/validator/ocsp/OcspServiceProvider.java b/src/main/java/eu/webeid/security/validator/ocsp/OcspServiceProvider.java
index 5f83c1d9..796eef07 100644
--- a/src/main/java/eu/webeid/security/validator/ocsp/OcspServiceProvider.java
+++ b/src/main/java/eu/webeid/security/validator/ocsp/OcspServiceProvider.java
@@ -23,26 +23,42 @@
package eu.webeid.security.validator.ocsp;
import eu.webeid.security.exceptions.AuthTokenException;
+import eu.webeid.security.exceptions.UserCertificateOCSPCheckFailedException;
import eu.webeid.security.validator.ocsp.service.AiaOcspService;
import eu.webeid.security.validator.ocsp.service.AiaOcspServiceConfiguration;
import eu.webeid.security.validator.ocsp.service.DesignatedOcspService;
import eu.webeid.security.validator.ocsp.service.DesignatedOcspServiceConfiguration;
+import eu.webeid.security.validator.ocsp.service.FallbackOcspService;
+import eu.webeid.security.validator.ocsp.service.FallbackOcspServiceConfiguration;
import eu.webeid.security.validator.ocsp.service.OcspService;
-import java.security.cert.CertificateEncodingException;
+import java.net.URI;
import java.security.cert.X509Certificate;
+import java.util.Collection;
+import java.util.Map;
import java.util.Objects;
+import java.util.stream.Collectors;
+
+import static eu.webeid.security.validator.ocsp.OcspUrl.getOcspUri;
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 = fallbackOcspServiceConfigurations != null ? fallbackOcspServiceConfigurations.stream()
+ .collect(Collectors.toMap(FallbackOcspServiceConfiguration::getOcspServiceAccessLocation, FallbackOcspService::new))
+ : Map.of();
}
/**
@@ -51,14 +67,17 @@ 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 AuthTokenException when AIA URL is not found in certificate
+ * @throws IllegalArgumentException when certificate is invalid
*/
- public OcspService getService(X509Certificate certificate) throws AuthTokenException, CertificateEncodingException {
+ public OcspService getService(X509Certificate certificate) throws AuthTokenException {
if (designatedOcspService != null && designatedOcspService.supportsIssuerOf(certificate)) {
return designatedOcspService;
}
- return new AiaOcspService(aiaOcspServiceConfiguration, certificate);
+ URI ocspServiceUri = getOcspUri(certificate).orElseThrow(() ->
+ new UserCertificateOCSPCheckFailedException("Getting the AIA OCSP responder field from the certificate failed"));
+ FallbackOcspService fallbackOcspService = fallbackOcspServiceMap.get(ocspServiceUri);
+ return new AiaOcspService(aiaOcspServiceConfiguration, certificate, fallbackOcspService);
}
}
diff --git a/src/main/java/eu/webeid/security/validator/ocsp/OcspValidationInfo.java b/src/main/java/eu/webeid/security/validator/ocsp/OcspValidationInfo.java
new file mode 100644
index 00000000..ffc2e75a
--- /dev/null
+++ b/src/main/java/eu/webeid/security/validator/ocsp/OcspValidationInfo.java
@@ -0,0 +1,52 @@
+/*
+ * 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.security.validator.ocsp;
+
+import org.bouncycastle.cert.ocsp.OCSPResp;
+
+import java.net.URI;
+import java.security.cert.X509Certificate;
+
+public class OcspValidationInfo {
+ private final X509Certificate subjectCertificate;
+ private final URI ocspResponderUri;
+ private final OCSPResp ocspResponse;
+
+ public OcspValidationInfo(X509Certificate subjectCertificate, URI ocspResponderUri, OCSPResp ocspResponse) {
+ this.subjectCertificate = subjectCertificate;
+ this.ocspResponderUri = ocspResponderUri;
+ this.ocspResponse = ocspResponse;
+ }
+
+ public X509Certificate getSubjectCertificate() {
+ return subjectCertificate;
+ }
+
+ public URI getOcspResponderUri() {
+ return ocspResponderUri;
+ }
+
+ public OCSPResp getOcspResponse() {
+ return ocspResponse;
+ }
+}
diff --git a/src/main/java/eu/webeid/security/validator/ocsp/ResilientOcspService.java b/src/main/java/eu/webeid/security/validator/ocsp/ResilientOcspService.java
new file mode 100644
index 00000000..1df16916
--- /dev/null
+++ b/src/main/java/eu/webeid/security/validator/ocsp/ResilientOcspService.java
@@ -0,0 +1,180 @@
+/*
+ * 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.security.validator.ocsp;
+
+import eu.webeid.security.exceptions.AuthTokenException;
+import eu.webeid.security.exceptions.UserCertificateOCSPCheckFailedException;
+import eu.webeid.security.exceptions.UserCertificateRevokedException;
+import eu.webeid.security.exceptions.UserCertificateUnknownException;
+import eu.webeid.security.validator.ocsp.service.OcspService;
+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.decorators.Decorators;
+import io.github.resilience4j.retry.Retry;
+import io.github.resilience4j.retry.RetryConfig;
+import io.github.resilience4j.retry.RetryRegistry;
+import io.vavr.CheckedFunction0;
+import io.vavr.control.Try;
+import org.bouncycastle.asn1.ocsp.OCSPObjectIdentifiers;
+import org.bouncycastle.asn1.ocsp.OCSPResponseStatus;
+import org.bouncycastle.asn1.x509.Extension;
+import org.bouncycastle.cert.ocsp.CertificateID;
+import org.bouncycastle.cert.ocsp.OCSPException;
+import org.bouncycastle.cert.ocsp.OCSPReq;
+import org.bouncycastle.cert.ocsp.OCSPResp;
+import org.bouncycastle.operator.OperatorCreationException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.security.cert.CertificateException;
+import java.security.cert.X509Certificate;
+import java.time.Duration;
+import java.util.List;
+import java.util.Objects;
+
+public class ResilientOcspService {
+ private static final Logger LOG = LoggerFactory.getLogger(ResilientOcspService.class);
+
+ private final OcspClient ocspClient;
+ private final OcspServiceProvider ocspServiceProvider;
+ private final Duration allowedOcspResponseTimeSkew;
+ private final Duration maxOcspResponseThisUpdateAge;
+ private final boolean rejectUnknownOcspResponseStatus;
+ private final CircuitBreakerRegistry circuitBreakerRegistry;
+ private final RetryRegistry retryRegistry;
+
+ public ResilientOcspService(OcspClient ocspClient, OcspServiceProvider ocspServiceProvider, CircuitBreakerConfig circuitBreakerConfig, RetryConfig retryConfig, Duration allowedOcspResponseTimeSkew, Duration maxOcspResponseThisUpdateAge, boolean rejectUnknownOcspResponseStatus) {
+ this.ocspClient = ocspClient;
+ this.ocspServiceProvider = ocspServiceProvider;
+ this.allowedOcspResponseTimeSkew = allowedOcspResponseTimeSkew;
+ this.maxOcspResponseThisUpdateAge = maxOcspResponseThisUpdateAge;
+ this.rejectUnknownOcspResponseStatus = rejectUnknownOcspResponseStatus;
+ this.circuitBreakerRegistry = CircuitBreakerRegistry.custom()
+ .withCircuitBreakerConfig(getCircuitBreakerConfig(circuitBreakerConfig))
+ .build();
+ this.retryRegistry = retryConfig != null ? RetryRegistry.custom()
+ .withRetryConfig(getRetryConfigConfig(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()));
+ });
+ }
+ }
+
+ public OcspValidationInfo validateSubjectCertificateNotRevoked(X509Certificate subjectCertificate, X509Certificate issuerCertificate) throws AuthTokenException {
+ final OcspService ocspService = ocspServiceProvider.getService(subjectCertificate);
+ final OcspService fallbackOcspService = ocspService.getFallbackService();
+ if (fallbackOcspService != null) {
+ CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(ocspService.getAccessLocation().toASCIIString());
+ CheckedFunction0 primarySupplier = () -> request(ocspService, subjectCertificate, issuerCertificate);
+ CheckedFunction0 fallbackSupplier = () -> request(ocspService.getFallbackService(), subjectCertificate, issuerCertificate);
+ Decorators.DecorateCheckedSupplier decorateCheckedSupplier = Decorators.ofCheckedSupplier(primarySupplier);
+ if (retryRegistry != null) {
+ Retry retry = retryRegistry.retry(ocspService.getAccessLocation().toASCIIString());
+ decorateCheckedSupplier.withRetry(retry);
+ }
+ decorateCheckedSupplier.withCircuitBreaker(circuitBreaker)
+ .withFallback(List.of(UserCertificateOCSPCheckFailedException.class, CallNotPermittedException.class, UserCertificateUnknownException.class), e -> fallbackSupplier.apply());
+
+ CheckedFunction0 decoratedSupplier = decorateCheckedSupplier.decorate();
+
+ return Try.of(decoratedSupplier).getOrElseThrow(throwable -> {
+ if (throwable instanceof AuthTokenException) {
+ return (AuthTokenException) throwable;
+ }
+ return new UserCertificateOCSPCheckFailedException(throwable);
+ });
+ } else {
+ return request(ocspService, subjectCertificate, issuerCertificate);
+ }
+ }
+
+ private OcspValidationInfo request(OcspService ocspService, X509Certificate subjectCertificate, X509Certificate issuerCertificate) throws AuthTokenException {
+ OCSPResp response = null;
+ try {
+ final CertificateID certificateId = OcspResponseValidator.getCertificateId(subjectCertificate, issuerCertificate);
+ final OCSPReq request = new OcspRequestBuilder()
+ .withCertificateId(certificateId)
+ .enableOcspNonce(ocspService.doesSupportNonce())
+ .build();
+
+ if (!ocspService.doesSupportNonce()) {
+ LOG.debug("Disabling OCSP nonce extension");
+ }
+
+ LOG.debug("Sending OCSP request");
+ response = Objects.requireNonNull(ocspClient.request(ocspService.getAccessLocation(), request)); // TODO: This should trigger fallback?
+ if (response.getStatus() != OCSPResponseStatus.SUCCESSFUL) {
+ throw new UserCertificateOCSPCheckFailedException("Response status: " + OcspResponseValidator.ocspStatusToString(response.getStatus()),
+ new OcspValidationInfo(subjectCertificate, ocspService.getAccessLocation(), response));
+ }
+
+ final Extension requestNonce = request.getExtension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce);
+ OcspValidationInfo ocspValidationInfo = OcspResponseValidator.validateOcspResponse(response, ocspService,
+ requestNonce, subjectCertificate, issuerCertificate, allowedOcspResponseTimeSkew,
+ maxOcspResponseThisUpdateAge, rejectUnknownOcspResponseStatus);
+ LOG.debug("OCSP check result is GOOD");
+
+ return ocspValidationInfo;
+ } catch (OCSPException | CertificateException | OperatorCreationException | IOException e) {
+ throw new UserCertificateOCSPCheckFailedException(e, new OcspValidationInfo(subjectCertificate, ocspService.getAccessLocation(), response));
+ }
+ }
+
+ private static CircuitBreakerConfig getCircuitBreakerConfig(CircuitBreakerConfig circuitBreakerConfig) {
+ CircuitBreakerConfig.Builder configurationBuilder = CircuitBreakerConfig.custom() // TODO: What are good default values here?
+ .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
+ .slidingWindowSize(100)
+ .minimumNumberOfCalls(10)
+ .ignoreExceptions(UserCertificateRevokedException.class) // TODO: Revoked status is a valid response, not a failure and should be ignored. Any other exceptions to ignore?
+ .automaticTransitionFromOpenToHalfOpenEnabled(true);
+
+ if (circuitBreakerConfig != null) { // TODO: What do we allow to configure?
+ configurationBuilder.slidingWindowSize(circuitBreakerConfig.getSlidingWindowSize());
+ configurationBuilder.minimumNumberOfCalls(circuitBreakerConfig.getMinimumNumberOfCalls());
+ configurationBuilder.failureRateThreshold(circuitBreakerConfig.getFailureRateThreshold());
+ configurationBuilder.permittedNumberOfCallsInHalfOpenState(circuitBreakerConfig.getPermittedNumberOfCallsInHalfOpenState());
+ configurationBuilder.waitIntervalFunctionInOpenState(circuitBreakerConfig.getWaitIntervalFunctionInOpenState());
+ }
+
+ return configurationBuilder.build();
+ }
+
+ private static RetryConfig getRetryConfigConfig(RetryConfig retryConfig) {
+ return RetryConfig.from(retryConfig)
+ .ignoreExceptions(UserCertificateRevokedException.class) // TODO: Revoked status is a valid response, not a failure and should be ignored. Any other exceptions to ignore?
+ .build();
+ }
+
+ CircuitBreakerRegistry getCircuitBreakerRegistry() {
+ return circuitBreakerRegistry;
+ }
+}
diff --git a/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java b/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java
index e04823c3..4d813b4d 100644
--- a/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java
+++ b/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java
@@ -51,13 +51,15 @@ 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;
}
@Override
@@ -70,6 +72,11 @@ public URI getAccessLocation() {
return url;
}
+ @Override
+ public OcspService getFallbackService() {
+ return fallbackOcspService;
+ }
+
@Override
public void validateResponderCertificate(X509CertificateHolder cert, Date now) throws AuthTokenException {
try {
diff --git a/src/main/java/eu/webeid/security/validator/ocsp/service/DesignatedOcspService.java b/src/main/java/eu/webeid/security/validator/ocsp/service/DesignatedOcspService.java
index bafba269..aef18b1d 100644
--- a/src/main/java/eu/webeid/security/validator/ocsp/service/DesignatedOcspService.java
+++ b/src/main/java/eu/webeid/security/validator/ocsp/service/DesignatedOcspService.java
@@ -74,7 +74,7 @@ public void validateResponderCertificate(X509CertificateHolder cert, Date now) t
}
}
- public boolean supportsIssuerOf(X509Certificate certificate) throws CertificateEncodingException {
+ public boolean supportsIssuerOf(X509Certificate certificate) {
return configuration.supportsIssuerOf(certificate);
}
diff --git a/src/main/java/eu/webeid/security/validator/ocsp/service/DesignatedOcspServiceConfiguration.java b/src/main/java/eu/webeid/security/validator/ocsp/service/DesignatedOcspServiceConfiguration.java
index 0bc03193..140d740d 100644
--- a/src/main/java/eu/webeid/security/validator/ocsp/service/DesignatedOcspServiceConfiguration.java
+++ b/src/main/java/eu/webeid/security/validator/ocsp/service/DesignatedOcspServiceConfiguration.java
@@ -70,8 +70,12 @@ public boolean doesSupportNonce() {
return doesSupportNonce;
}
- public boolean supportsIssuerOf(X509Certificate certificate) throws CertificateEncodingException {
- return supportedIssuers.contains(new JcaX509CertificateHolder(Objects.requireNonNull(certificate)).getIssuer());
+ public boolean supportsIssuerOf(X509Certificate certificate) {
+ try {
+ return supportedIssuers.contains(new JcaX509CertificateHolder(Objects.requireNonNull(certificate)).getIssuer());
+ } catch (CertificateEncodingException e) {
+ throw new IllegalArgumentException(e);
+ }
}
private Collection getIssuerX500Names(Collection supportedIssuers) throws OCSPCertificateException {
diff --git a/src/main/java/eu/webeid/security/validator/ocsp/service/FallbackOcspService.java b/src/main/java/eu/webeid/security/validator/ocsp/service/FallbackOcspService.java
new file mode 100644
index 00000000..94dcdaa6
--- /dev/null
+++ b/src/main/java/eu/webeid/security/validator/ocsp/service/FallbackOcspService.java
@@ -0,0 +1,74 @@
+/*
+ * 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.security.validator.ocsp.service;
+
+import eu.webeid.security.exceptions.AuthTokenException;
+import eu.webeid.security.exceptions.OCSPCertificateException;
+import org.bouncycastle.cert.X509CertificateHolder;
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
+
+import java.net.URI;
+import java.security.cert.CertificateException;
+import java.security.cert.X509Certificate;
+import java.util.Date;
+
+import static eu.webeid.security.certificate.CertificateValidator.certificateIsValidOnDate;
+
+public class FallbackOcspService implements OcspService {
+ private final JcaX509CertificateConverter certificateConverter = new JcaX509CertificateConverter();
+ private final URI url;
+ private final boolean supportsNonce;
+ private final X509Certificate trustedResponderCertificate;
+
+ public FallbackOcspService(FallbackOcspServiceConfiguration configuration) {
+ this.url = configuration.getFallbackOcspServiceAccessLocation();
+ this.supportsNonce = configuration.doesSupportNonce();
+ this.trustedResponderCertificate = configuration.getResponderCertificate();
+ }
+
+ @Override
+ public boolean doesSupportNonce() {
+ return supportsNonce;
+ }
+
+ @Override
+ public URI getAccessLocation() {
+ return url;
+ }
+
+ @Override
+ public void validateResponderCertificate(X509CertificateHolder cert, Date now) throws AuthTokenException {
+ try {
+ final X509Certificate responderCertificate = certificateConverter.getCertificate(cert);
+ // 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");
+ }
+ certificateIsValidOnDate(responderCertificate, now, "Fallback OCSP responder");
+ } catch (CertificateException e) {
+ throw new OCSPCertificateException("X509CertificateHolder conversion to X509Certificate failed", e);
+ }
+ }
+}
diff --git a/src/main/java/eu/webeid/security/validator/ocsp/service/FallbackOcspServiceConfiguration.java b/src/main/java/eu/webeid/security/validator/ocsp/service/FallbackOcspServiceConfiguration.java
new file mode 100644
index 00000000..3d262169
--- /dev/null
+++ b/src/main/java/eu/webeid/security/validator/ocsp/service/FallbackOcspServiceConfiguration.java
@@ -0,0 +1,63 @@
+/*
+ * 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.security.validator.ocsp.service;
+
+import eu.webeid.security.exceptions.OCSPCertificateException;
+import eu.webeid.security.validator.ocsp.OcspResponseValidator;
+
+import java.net.URI;
+import java.security.cert.X509Certificate;
+import java.util.Objects;
+
+public class FallbackOcspServiceConfiguration {
+
+ private final URI ocspServiceAccessLocation;
+ private final URI fallbackOcspServiceAccessLocation;
+ private final X509Certificate responderCertificate;
+ private final boolean doesSupportNonce;
+
+ public FallbackOcspServiceConfiguration(URI ocspServiceAccessLocation, URI fallbackOcspServiceAccessLocation, X509Certificate responderCertificate, boolean doesSupportNonce) throws OCSPCertificateException {
+ this.ocspServiceAccessLocation = Objects.requireNonNull(ocspServiceAccessLocation, "Primary OCSP service access location");
+ this.fallbackOcspServiceAccessLocation = Objects.requireNonNull(fallbackOcspServiceAccessLocation, "Fallback OCSP service access location");
+ this.responderCertificate = Objects.requireNonNull(responderCertificate, "Fallback OCSP responder certificate");
+ OcspResponseValidator.validateHasSigningExtension(responderCertificate);
+ this.doesSupportNonce = doesSupportNonce;
+ }
+
+ public URI getOcspServiceAccessLocation() {
+ return ocspServiceAccessLocation;
+ }
+
+ public URI getFallbackOcspServiceAccessLocation() {
+ return fallbackOcspServiceAccessLocation;
+ }
+
+ public X509Certificate getResponderCertificate() {
+ return responderCertificate;
+ }
+
+ public boolean doesSupportNonce() {
+ return doesSupportNonce;
+ }
+
+}
diff --git a/src/main/java/eu/webeid/security/validator/ocsp/service/OcspService.java b/src/main/java/eu/webeid/security/validator/ocsp/service/OcspService.java
index 97bbdf2c..7129cc9b 100644
--- a/src/main/java/eu/webeid/security/validator/ocsp/service/OcspService.java
+++ b/src/main/java/eu/webeid/security/validator/ocsp/service/OcspService.java
@@ -36,4 +36,7 @@ public interface OcspService {
void validateResponderCertificate(X509CertificateHolder cert, Date now) throws AuthTokenException;
+ default OcspService getFallbackService() {
+ return null;
+ }
}
diff --git a/src/test/java/eu/webeid/security/testutil/Certificates.java b/src/test/java/eu/webeid/security/testutil/Certificates.java
index 215773b6..f0e43681 100644
--- a/src/test/java/eu/webeid/security/testutil/Certificates.java
+++ b/src/test/java/eu/webeid/security/testutil/Certificates.java
@@ -42,12 +42,14 @@ public class Certificates {
private static X509Certificate mariliisEsteid2015Cert;
private static X509Certificate organizationCert;
private static X509Certificate testSkOcspResponder2020;
+ private static X509Certificate testSkOcspResponder2018;
static void loadCertificates() throws CertificateException, IOException {
- X509Certificate[] certificates = CertificateLoader.loadCertificatesFromResources("TEST_of_ESTEID-SK_2015.cer", "TEST_of_ESTEID2018.cer", "TEST_of_SK_OCSP_RESPONDER_2020.cer");
+ X509Certificate[] certificates = CertificateLoader.loadCertificatesFromResources("TEST_of_ESTEID-SK_2015.cer", "TEST_of_ESTEID2018.cer", "TEST_of_SK_OCSP_RESPONDER_2020.cer", "TEST_of_SK_OCSP_RESPONDER_2018.cer");
testEsteid2015CA = certificates[0];
testEsteid2018CA = certificates[1];
testSkOcspResponder2020 = certificates[2];
+ testSkOcspResponder2018 = certificates[3];
}
public static X509Certificate getTestEsteid2018CA() throws CertificateException, IOException {
@@ -71,6 +73,13 @@ public static X509Certificate getTestSkOcspResponder2020() throws CertificateExc
return testSkOcspResponder2020;
}
+ public static X509Certificate getTestSkOcspResponder2018() throws CertificateException, IOException {
+ if (testSkOcspResponder2018 == null) {
+ loadCertificates();
+ }
+ return testSkOcspResponder2018;
+ }
+
public static X509Certificate getJaakKristjanEsteid2018Cert() throws CertificateDecodingException {
if (jaakKristjanEsteid2018Cert == null) {
jaakKristjanEsteid2018Cert = CertificateLoader.decodeCertificateFromBase64(JAAK_KRISTJAN_ESTEID2018_CERT);
diff --git a/src/test/java/eu/webeid/security/validator/AuthTokenSignatureTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenSignatureTest.java
index 3f596858..e1280851 100644
--- a/src/test/java/eu/webeid/security/validator/AuthTokenSignatureTest.java
+++ b/src/test/java/eu/webeid/security/validator/AuthTokenSignatureTest.java
@@ -48,8 +48,8 @@ class AuthTokenSignatureTest extends AbstractTestWithValidator {
@Test
void whenValidTokenAndNonce_thenValidationSucceeds() throws Exception {
- final X509Certificate result = validator.validate(validAuthToken, VALID_CHALLENGE_NONCE);
-
+ final ValidationInfo validationInfo = validator.validate(validAuthToken, VALID_CHALLENGE_NONCE);
+ final X509Certificate result = validationInfo.getSubjectCertificate();
assertThat(CertificateData.getSubjectCN(result).orElseThrow())
.isEqualTo("JÕEORG\\,JAAK-KRISTJAN\\,38001085718");
assertThat(toTitleCase(CertificateData.getSubjectGivenName(result).orElseThrow()))
diff --git a/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidatorTest.java b/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidatorTest.java
index 771c3018..f25daa4b 100644
--- a/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidatorTest.java
+++ b/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidatorTest.java
@@ -31,6 +31,7 @@
import eu.webeid.security.validator.ocsp.OcspClient;
import eu.webeid.security.validator.ocsp.OcspClientImpl;
import eu.webeid.security.validator.ocsp.OcspServiceProvider;
+import eu.webeid.security.validator.ocsp.ResilientOcspService;
import org.bouncycastle.asn1.ocsp.OCSPResponseStatus;
import org.bouncycastle.cert.ocsp.OCSPException;
import org.bouncycastle.cert.ocsp.OCSPResp;
@@ -258,9 +259,11 @@ void whenOcspResponseCACertNotTrusted_thenThrows() throws Exception {
);
try (var mockedClock = mockStatic(DateAndTime.DefaultClock.class)) {
mockDate("2021-09-18T00:16:25", mockedClock);
- assertThatExceptionOfType(CertificateNotTrustedException.class)
+ assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class)
.isThrownBy(() ->
validator.validateCertificateNotRevoked(estEid2018Cert))
+ .withCauseExactlyInstanceOf(CertificateNotTrustedException.class)
+ .havingCause()
.withMessage("Certificate EMAILADDRESS=pki@sk.ee, CN=TEST of SK OCSP RESPONDER 2020, OU=OCSP, O=AS Sertifitseerimiskeskus, C=EE is not trusted");
}
}
@@ -270,9 +273,11 @@ void whenOcspResponseCACertExpired_thenThrows() throws Exception {
final SubjectCertificateNotRevokedValidator validator = getSubjectCertificateNotRevokedValidatorWithAiaOcsp(
getMockedResponse(getOcspResponseBytesFromResources("ocsp_response_unknown.der"))
);
- assertThatExceptionOfType(CertificateExpiredException.class)
+ assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class)
.isThrownBy(() ->
validator.validateCertificateNotRevoked(estEid2018Cert))
+ .withCauseExactlyInstanceOf(CertificateExpiredException.class)
+ .havingCause()
.withMessage("AIA OCSP responder certificate has expired");
}
@@ -347,7 +352,13 @@ private SubjectCertificateNotRevokedValidator getSubjectCertificateNotRevokedVal
}
private SubjectCertificateNotRevokedValidator getSubjectCertificateNotRevokedValidator(OcspClient client, OcspServiceProvider ocspServiceProvider) {
- return new SubjectCertificateNotRevokedValidator(trustedValidator, client, ocspServiceProvider, CONFIGURATION.getAllowedOcspResponseTimeSkew(), CONFIGURATION.getMaxOcspResponseThisUpdateAge());
+ ResilientOcspService resilientOcspService = new ResilientOcspService(client, ocspServiceProvider,
+ null,
+ null,
+ CONFIGURATION.getAllowedOcspResponseTimeSkew(),
+ CONFIGURATION.getMaxOcspResponseThisUpdateAge(),
+ CONFIGURATION.isRejectUnknownOcspResponseStatus());
+ return new SubjectCertificateNotRevokedValidator(resilientOcspService, trustedValidator);
}
private static void setSubjectCertificateIssuerCertificate(SubjectCertificateTrustedValidator trustedValidator) throws NoSuchFieldException, IllegalAccessException, CertificateException, IOException {
diff --git a/src/test/java/eu/webeid/security/validator/ocsp/OcspResponseValidatorTest.java b/src/test/java/eu/webeid/security/validator/ocsp/OcspResponseValidatorTest.java
index a8e80ddf..a53cb651 100644
--- a/src/test/java/eu/webeid/security/validator/ocsp/OcspResponseValidatorTest.java
+++ b/src/test/java/eu/webeid/security/validator/ocsp/OcspResponseValidatorTest.java
@@ -35,6 +35,7 @@
import static eu.webeid.security.validator.ocsp.OcspResponseValidator.validateCertificateStatusUpdateTime;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@@ -51,7 +52,7 @@ void whenThisAndNextUpdateWithinSkew_thenValidationSucceeds() {
var nextUpdateWithinAgeLimit = Date.from(now.minus(THIS_UPDATE_AGE.minusSeconds(2)));
when(mockResponse.getThisUpdate()).thenReturn(thisUpdateWithinAgeLimit);
when(mockResponse.getNextUpdate()).thenReturn(nextUpdateWithinAgeLimit);
- assertThatCode(() -> validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE))
+ assertThatCode(() -> validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, null))
.doesNotThrowAnyException();
}
@@ -63,12 +64,14 @@ void whenNextUpdateBeforeThisUpdate_thenThrows() {
var beforeThisUpdate = new Date(thisUpdateWithinAgeLimit.getTime() - 1000);
when(mockResponse.getThisUpdate()).thenReturn(thisUpdateWithinAgeLimit);
when(mockResponse.getNextUpdate()).thenReturn(beforeThisUpdate);
+ OcspValidationInfo ocspValidationInfo = mock(OcspValidationInfo.class);
assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class)
.isThrownBy(() ->
- validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE))
+ validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, ocspValidationInfo))
.withMessageStartingWith("User certificate revocation check has failed: "
+ "Certificate status update time check failed: "
- + "nextUpdate '" + beforeThisUpdate.toInstant() + "' is before thisUpdate '" + thisUpdateWithinAgeLimit.toInstant() + "'");
+ + "nextUpdate '" + beforeThisUpdate.toInstant() + "' is before thisUpdate '" + thisUpdateWithinAgeLimit.toInstant() + "'")
+ .satisfies(e -> assertThat(e.getOcspValidationInfo()).isNotNull());
}
@Test
@@ -77,12 +80,14 @@ void whenThisUpdateHalfHourBeforeNow_thenThrows() {
var now = Instant.now();
var halfHourBeforeNow = Date.from(now.minus(30, ChronoUnit.MINUTES));
when(mockResponse.getThisUpdate()).thenReturn(halfHourBeforeNow);
+ OcspValidationInfo ocspValidationInfo = mock(OcspValidationInfo.class);
assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class)
.isThrownBy(() ->
- validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE))
+ validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, ocspValidationInfo))
.withMessageStartingWith("User certificate revocation check has failed: "
+ "Certificate status update time check failed: "
- + "thisUpdate '" + halfHourBeforeNow.toInstant() + "' is too old, minimum time allowed: ");
+ + "thisUpdate '" + halfHourBeforeNow.toInstant() + "' is too old, minimum time allowed: ")
+ .satisfies(e -> assertThat(e.getOcspValidationInfo()).isNotNull());
}
@Test
@@ -91,12 +96,14 @@ void whenThisUpdateHalfHourAfterNow_thenThrows() {
var now = Instant.now();
var halfHourAfterNow = Date.from(now.plus(30, ChronoUnit.MINUTES));
when(mockResponse.getThisUpdate()).thenReturn(halfHourAfterNow);
+ OcspValidationInfo ocspValidationInfo = mock(OcspValidationInfo.class);
assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class)
.isThrownBy(() ->
- validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE))
+ validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, ocspValidationInfo))
.withMessageStartingWith("User certificate revocation check has failed: "
+ "Certificate status update time check failed: "
- + "thisUpdate '" + halfHourAfterNow.toInstant() + "' is too far in the future, latest allowed: ");
+ + "thisUpdate '" + halfHourAfterNow.toInstant() + "' is too far in the future, latest allowed: ")
+ .satisfies(e -> assertThat(e.getOcspValidationInfo()).isNotNull());
}
@Test
@@ -107,12 +114,14 @@ void whenNextUpdateHalfHourBeforeNow_thenThrows() {
var halfHourBeforeNow = Date.from(now.minus(30, ChronoUnit.MINUTES));
when(mockResponse.getThisUpdate()).thenReturn(thisUpdateWithinAgeLimit);
when(mockResponse.getNextUpdate()).thenReturn(halfHourBeforeNow);
+ OcspValidationInfo ocspValidationInfo = mock(OcspValidationInfo.class);
assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class)
.isThrownBy(() ->
- validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE))
+ validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, ocspValidationInfo))
.withMessage("User certificate revocation check has failed: "
+ "Certificate status update time check failed: "
- + "nextUpdate '" + halfHourBeforeNow.toInstant() + "' is in the past");
+ + "nextUpdate '" + halfHourBeforeNow.toInstant() + "' is in the past")
+ .satisfies(e -> assertThat(e.getOcspValidationInfo()).isNotNull());
}
private static Date getThisUpdateWithinAgeLimit(Instant now) {
diff --git a/src/test/java/eu/webeid/security/validator/ocsp/ResilientOcspServiceTest.java b/src/test/java/eu/webeid/security/validator/ocsp/ResilientOcspServiceTest.java
new file mode 100644
index 00000000..88b0457e
--- /dev/null
+++ b/src/test/java/eu/webeid/security/validator/ocsp/ResilientOcspServiceTest.java
@@ -0,0 +1,536 @@
+/*
+ * 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.security.validator.ocsp;
+
+import eu.webeid.security.certificate.CertificateValidator;
+import eu.webeid.security.exceptions.UserCertificateOCSPCheckFailedException;
+import eu.webeid.security.exceptions.UserCertificateRevokedException;
+import eu.webeid.security.exceptions.UserCertificateUnknownException;
+import eu.webeid.security.util.DateAndTime;
+import eu.webeid.security.validator.ocsp.service.AiaOcspServiceConfiguration;
+import eu.webeid.security.validator.ocsp.service.FallbackOcspServiceConfiguration;
+import io.github.resilience4j.circuitbreaker.CircuitBreaker;
+import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
+import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
+import io.github.resilience4j.retry.RetryConfig;
+import org.bouncycastle.cert.ocsp.OCSPResp;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.security.Security;
+import java.security.cert.X509Certificate;
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+import static eu.webeid.security.testutil.Certificates.getJaakKristjanEsteid2018Cert;
+import static eu.webeid.security.testutil.Certificates.getTestEsteid2015CA;
+import static eu.webeid.security.testutil.Certificates.getTestEsteid2018CA;
+import static eu.webeid.security.testutil.Certificates.getTestSkOcspResponder2018;
+import static eu.webeid.security.testutil.Certificates.getTestSkOcspResponder2020;
+import static eu.webeid.security.testutil.DateMocker.mockDate;
+import static eu.webeid.security.testutil.OcspServiceMaker.getAiaOcspServiceProvider;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
+import static org.awaitility.Awaitility.await;
+import static org.hamcrest.Matchers.equalTo;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class ResilientOcspServiceTest {
+ private static final URI PRIMARY_OCSP_URL = URI.create("http://aia.demo.sk.ee/esteid2018");
+ private static final URI FALLBACK_OCSP_URL = URI.create("http://fallback.demo.sk.ee/ocsp");
+ private static final Duration ALLOWED_TIME_SKEW = Duration.ofMinutes(15);
+ private static final Duration MAX_THIS_UPDATE_AGE = Duration.ofMinutes(2);
+
+ private X509Certificate subjectCertificate;
+ private X509Certificate issuerCertificate;
+ private byte[] validOcspResponseBytes;
+ private byte[] revokedOcspResponseBytes;
+ private byte[] unknownOcspResponseBytes;
+
+ @BeforeAll
+ static void setUpClass() {
+ Security.addProvider(new BouncyCastleProvider());
+ }
+
+ @BeforeEach
+ void setUp() throws Exception {
+ subjectCertificate = getJaakKristjanEsteid2018Cert();
+ issuerCertificate = getTestEsteid2018CA();
+ validOcspResponseBytes = getSystemResource("ocsp_response.der");
+ revokedOcspResponseBytes = getSystemResource("ocsp_response_revoked.der");
+ unknownOcspResponseBytes = getSystemResource("ocsp_response_unknown.der");
+ }
+
+ @Test
+ void whenFallbackConfigured_thenFallbackAndRecoverySucceeds() throws Exception {
+ final OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_OCSP_URL), any()))
+ .thenThrow(new IOException("Mocked exception 1"))
+ .thenThrow(new IOException("Mocked exception 2"))
+ .thenThrow(new IOException("Mocked exception 3"))
+ .thenThrow(new IOException("Mocked exception 4"))
+ .thenReturn(new OCSPResp(validOcspResponseBytes))
+ .thenReturn(new OCSPResp(validOcspResponseBytes));
+ when(ocspClient.request(eq(FALLBACK_OCSP_URL), any()))
+ .thenReturn(new OCSPResp(validOcspResponseBytes));
+ CircuitBreakerConfig circuitBreakerConfig = CircuitBreakerConfig.custom()
+ .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
+ .slidingWindowSize(4)
+ .minimumNumberOfCalls(2)
+ .failureRateThreshold(50)
+ .permittedNumberOfCallsInHalfOpenState(2)
+ .waitDurationInOpenState(Duration.ofMillis(100)) // Short wait for testing
+ .automaticTransitionFromOpenToHalfOpenEnabled(true)
+ .build();
+ OcspServiceProvider ocspServiceProvider = createOcspServiceProviderWithFallback();
+ ResilientOcspService resilientOcspService = new ResilientOcspService(
+ ocspClient,
+ ocspServiceProvider,
+ circuitBreakerConfig,
+ null,
+ ALLOWED_TIME_SKEW,
+ MAX_THIS_UPDATE_AGE,
+ false
+ );
+ CircuitBreakerRegistry circuitBreakerRegistry = resilientOcspService.getCircuitBreakerRegistry();
+ CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(PRIMARY_OCSP_URL.toASCIIString());
+ try (var mockedClock = mockStatic(DateAndTime.DefaultClock.class)) {
+ mockDate("2021-09-17T18:25:24", mockedClock);
+
+ assertThatCode(() ->
+ resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate)
+ ).doesNotThrowAnyException();
+ verify(ocspClient, times(1)).request(eq(PRIMARY_OCSP_URL), any());
+ verify(ocspClient, times(1)).request(eq(FALLBACK_OCSP_URL), any());
+ assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.CLOSED);
+
+ assertThatCode(() ->
+ resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate)
+ ).doesNotThrowAnyException();
+ verify(ocspClient, times(2)).request(eq(PRIMARY_OCSP_URL), any());
+ verify(ocspClient, times(2)).request(eq(FALLBACK_OCSP_URL), any());
+ assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN);
+
+ assertThatCode(() ->
+ resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate)
+ ).doesNotThrowAnyException();
+ verify(ocspClient, times(2)).request(eq(PRIMARY_OCSP_URL), any());
+ verify(ocspClient, times(3)).request(eq(FALLBACK_OCSP_URL), any());
+ assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN);
+
+ await()
+ .until(circuitBreaker::getState, equalTo(CircuitBreaker.State.HALF_OPEN));
+
+ assertThatCode(() ->
+ resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate)
+ ).doesNotThrowAnyException();
+ verify(ocspClient, times(3)).request(eq(PRIMARY_OCSP_URL), any());
+ verify(ocspClient, times(4)).request(eq(FALLBACK_OCSP_URL), any());
+ assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.HALF_OPEN);
+
+ assertThatCode(() ->
+ resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate)
+ ).doesNotThrowAnyException();
+ verify(ocspClient, times(4)).request(eq(PRIMARY_OCSP_URL), any());
+ verify(ocspClient, times(5)).request(eq(FALLBACK_OCSP_URL), any());
+ assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN);
+
+ await()
+ .until(circuitBreaker::getState, equalTo(CircuitBreaker.State.HALF_OPEN));
+
+ assertThatCode(() ->
+ resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate)
+ ).doesNotThrowAnyException();
+ verify(ocspClient, times(5)).request(eq(PRIMARY_OCSP_URL), any());
+ verify(ocspClient, times(5)).request(eq(FALLBACK_OCSP_URL), any());
+ assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.HALF_OPEN);
+
+ assertThatCode(() ->
+ resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate)
+ ).doesNotThrowAnyException();
+ verify(ocspClient, times(6)).request(eq(PRIMARY_OCSP_URL), any());
+ verify(ocspClient, times(5)).request(eq(FALLBACK_OCSP_URL), any());
+ assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.CLOSED);
+
+ assertThatCode(() ->
+ resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate)
+ ).doesNotThrowAnyException();
+ verify(ocspClient, times(7)).request(eq(PRIMARY_OCSP_URL), any());
+ verify(ocspClient, times(5)).request(eq(FALLBACK_OCSP_URL), any());
+ assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.CLOSED);
+ }
+ }
+
+ @Test
+ void whenOcspResponseGood_thenNoFallbackAndSucceeds() throws Exception {
+ final OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_OCSP_URL), any()))
+ .thenReturn(new OCSPResp(validOcspResponseBytes));
+ when(ocspClient.request(eq(FALLBACK_OCSP_URL), any()))
+ .thenReturn(new OCSPResp(validOcspResponseBytes));
+ OcspServiceProvider ocspServiceProvider = createOcspServiceProviderWithFallback();
+ ResilientOcspService resilientOcspService = new ResilientOcspService(
+ ocspClient,
+ ocspServiceProvider,
+ null,
+ null,
+ ALLOWED_TIME_SKEW,
+ MAX_THIS_UPDATE_AGE,
+ false
+ );
+ CircuitBreakerRegistry circuitBreakerRegistry = resilientOcspService.getCircuitBreakerRegistry();
+ CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(PRIMARY_OCSP_URL.toASCIIString());
+ try (var mockedClock = mockStatic(DateAndTime.DefaultClock.class)) {
+ mockDate("2021-09-17T18:25:24", mockedClock);
+
+ OcspValidationInfo validationInfo = resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate);
+ assertThat(validationInfo).isNotNull();
+ assertThat(validationInfo).extracting(OcspValidationInfo::getSubjectCertificate)
+ .isEqualTo(subjectCertificate);
+ assertThat(validationInfo).extracting(OcspValidationInfo::getOcspResponderUri)
+ .isEqualTo(new URI("http://aia.demo.sk.ee/esteid2018"));
+ assertThat(validationInfo).extracting(OcspValidationInfo::getOcspResponse)
+ .isNotNull();
+
+ verify(ocspClient, times(1)).request(eq(PRIMARY_OCSP_URL), any());
+ verify(ocspClient, times(0)).request(eq(FALLBACK_OCSP_URL), any());
+ assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.CLOSED);
+ }
+ }
+
+ @Test
+ void whenRetryEnabledAndRetrySucceeds_thenNoFallbackAndSucceeds() throws Exception {
+ final OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_OCSP_URL), any()))
+ .thenThrow(new IOException("Mocked exception 1"))
+ .thenThrow(new IOException("Mocked exception 2"))
+ .thenReturn(new OCSPResp(validOcspResponseBytes));
+ when(ocspClient.request(eq(FALLBACK_OCSP_URL), any()))
+ .thenReturn(new OCSPResp(validOcspResponseBytes));
+ OcspServiceProvider ocspServiceProvider = createOcspServiceProviderWithFallback();
+ ResilientOcspService resilientOcspService = new ResilientOcspService(
+ ocspClient,
+ ocspServiceProvider,
+ null,
+ RetryConfig.ofDefaults(), // Retry enabled
+ ALLOWED_TIME_SKEW,
+ MAX_THIS_UPDATE_AGE,
+ false
+ );
+ CircuitBreakerRegistry circuitBreakerRegistry = resilientOcspService.getCircuitBreakerRegistry();
+ CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(PRIMARY_OCSP_URL.toASCIIString());
+ try (var mockedClock = mockStatic(DateAndTime.DefaultClock.class)) {
+ mockDate("2021-09-17T18:25:24", mockedClock);
+
+ OcspValidationInfo validationInfo = resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate);
+ assertThat(validationInfo).isNotNull();
+ assertThat(validationInfo).extracting(OcspValidationInfo::getSubjectCertificate)
+ .isEqualTo(subjectCertificate);
+ assertThat(validationInfo).extracting(OcspValidationInfo::getOcspResponderUri)
+ .isEqualTo(new URI("http://aia.demo.sk.ee/esteid2018"));
+ assertThat(validationInfo).extracting(OcspValidationInfo::getOcspResponse)
+ .isNotNull();
+
+ verify(ocspClient, times(3)).request(eq(PRIMARY_OCSP_URL), any());
+ verify(ocspClient, times(0)).request(eq(FALLBACK_OCSP_URL), any());
+ assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.CLOSED);
+ }
+ }
+
+ @Test
+ void whenOcspResponseRevoked_thenNoFallbackAndThrows() throws Exception {
+ final OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_OCSP_URL), any()))
+ .thenReturn(new OCSPResp(revokedOcspResponseBytes));
+ when(ocspClient.request(eq(FALLBACK_OCSP_URL), any()))
+ .thenReturn(new OCSPResp(validOcspResponseBytes));
+ OcspServiceProvider ocspServiceProvider = createOcspServiceProviderWithFallback();
+ ResilientOcspService resilientOcspService = new ResilientOcspService(
+ ocspClient,
+ ocspServiceProvider,
+ null,
+ null,
+ ALLOWED_TIME_SKEW,
+ MAX_THIS_UPDATE_AGE,
+ false
+ );
+ CircuitBreakerRegistry circuitBreakerRegistry = resilientOcspService.getCircuitBreakerRegistry();
+ CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(PRIMARY_OCSP_URL.toASCIIString());
+ try (var mockedClock = mockStatic(DateAndTime.DefaultClock.class)) {
+ mockDate("2021-09-18T00:00:00", mockedClock);
+
+ assertThatExceptionOfType(UserCertificateRevokedException.class)
+ .isThrownBy(() ->
+ resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate))
+ .withMessage("User certificate has been revoked: Revocation reason: 0")
+ .satisfies(e -> assertThat(e.getOcspValidationInfo()).isNotNull())
+ .satisfies(e -> assertThat(e.getOcspValidationInfo().getSubjectCertificate()).isNotNull())
+ .satisfies(e -> assertThat(e.getOcspValidationInfo().getOcspResponderUri()).isNotNull())
+ .satisfies(e -> assertThat(e.getOcspValidationInfo().getOcspResponderUri().toASCIIString()).isEqualTo("http://aia.demo.sk.ee/esteid2018"))
+ .satisfies(e -> assertThat(e.getOcspValidationInfo().getOcspResponse()).isNotNull());
+
+ verify(ocspClient, times(1)).request(eq(PRIMARY_OCSP_URL), any());
+ verify(ocspClient, times(0)).request(eq(FALLBACK_OCSP_URL), any());
+ assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.CLOSED);
+ }
+ }
+
+ @Test
+ void whenOcspResponseUnknown_thenNoFallbackAndThrows() throws Exception {
+ final OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_OCSP_URL), any()))
+ .thenReturn(new OCSPResp(unknownOcspResponseBytes));
+ when(ocspClient.request(eq(FALLBACK_OCSP_URL), any()))
+ .thenReturn(new OCSPResp(validOcspResponseBytes));
+ OcspServiceProvider ocspServiceProvider = createOcspServiceProviderWithFallback();
+ ResilientOcspService resilientOcspService = new ResilientOcspService(
+ ocspClient,
+ ocspServiceProvider,
+ null,
+ null,
+ ALLOWED_TIME_SKEW,
+ MAX_THIS_UPDATE_AGE,
+ false
+ );
+ CircuitBreakerRegistry circuitBreakerRegistry = resilientOcspService.getCircuitBreakerRegistry();
+ CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(PRIMARY_OCSP_URL.toASCIIString());
+ try (var mockedClock = mockStatic(DateAndTime.DefaultClock.class)) {
+ mockDate("2021-09-18T00:16:25", mockedClock);
+
+ assertThatExceptionOfType(UserCertificateRevokedException.class)
+ .isThrownBy(() ->
+ resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate))
+ .withMessage("User certificate has been revoked: Unknown status")
+ .satisfies(e -> assertThat(e.getOcspValidationInfo()).isNotNull())
+ .satisfies(e -> assertThat(e.getOcspValidationInfo().getSubjectCertificate()).isNotNull())
+ .satisfies(e -> assertThat(e.getOcspValidationInfo().getOcspResponderUri()).isNotNull())
+ .satisfies(e -> assertThat(e.getOcspValidationInfo().getOcspResponderUri().toASCIIString()).isEqualTo("http://aia.demo.sk.ee/esteid2018"))
+ .satisfies(e -> assertThat(e.getOcspValidationInfo().getOcspResponse()).isNotNull());
+
+ verify(ocspClient, times(1)).request(eq(PRIMARY_OCSP_URL), any());
+ verify(ocspClient, times(0)).request(eq(FALLBACK_OCSP_URL), any());
+ assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.CLOSED);
+ }
+ }
+
+ @Test
+ void whenPrimaryOcspResponseUnknownAndRejectUnknownOcspResponseStatusConfiguration_thenFallbackAndSucceeds() throws Exception {
+ final OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_OCSP_URL), any()))
+ .thenReturn(new OCSPResp(unknownOcspResponseBytes));
+ when(ocspClient.request(eq(FALLBACK_OCSP_URL), any()))
+ .thenReturn(new OCSPResp(validOcspResponseBytes));
+ OcspServiceProvider ocspServiceProvider = createOcspServiceProviderWithFallback();
+ ResilientOcspService resilientOcspService = new ResilientOcspService(
+ ocspClient,
+ ocspServiceProvider,
+ null,
+ null,
+ ALLOWED_TIME_SKEW,
+ MAX_THIS_UPDATE_AGE,
+ true // rejectUnknownOcspResponseStatus
+ );
+ CircuitBreakerRegistry circuitBreakerRegistry = resilientOcspService.getCircuitBreakerRegistry();
+ CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(PRIMARY_OCSP_URL.toASCIIString());
+ try (var mockedClock = mockStatic(DateAndTime.DefaultClock.class)) {
+ mockDate("2021-09-17T18:25:24", mockedClock);
+
+ OcspValidationInfo validationInfo = resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate);
+ assertThat(validationInfo).isNotNull();
+ assertThat(validationInfo).extracting(OcspValidationInfo::getSubjectCertificate)
+ .isEqualTo(subjectCertificate);
+ assertThat(validationInfo).extracting(OcspValidationInfo::getOcspResponderUri)
+ .isEqualTo(new URI("http://fallback.demo.sk.ee/ocsp"));
+ assertThat(validationInfo).extracting(OcspValidationInfo::getOcspResponse)
+ .isNotNull();
+
+ verify(ocspClient, times(1)).request(eq(PRIMARY_OCSP_URL), any());
+ verify(ocspClient, times(1)).request(eq(FALLBACK_OCSP_URL), any());
+ assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.CLOSED);
+ }
+ }
+
+ @Test
+ void whenPrimaryAndFallbackRevocationStatusUnknownAndRejectUnknownOcspResponseStatusConfiguration_thenThrows() throws Exception {
+ final OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_OCSP_URL), any()))
+ .thenReturn(new OCSPResp(unknownOcspResponseBytes));
+ when(ocspClient.request(eq(FALLBACK_OCSP_URL), any()))
+ .thenReturn(new OCSPResp(unknownOcspResponseBytes));
+ FallbackOcspServiceConfiguration fallbackConfig = new FallbackOcspServiceConfiguration(
+ PRIMARY_OCSP_URL,
+ FALLBACK_OCSP_URL,
+ getTestSkOcspResponder2020(),
+ false
+ );
+ OcspServiceProvider ocspServiceProvider = createOcspServiceProviderWithFallback(fallbackConfig);
+ ResilientOcspService resilientOcspService = new ResilientOcspService(
+ ocspClient,
+ ocspServiceProvider,
+ null,
+ null,
+ ALLOWED_TIME_SKEW,
+ MAX_THIS_UPDATE_AGE,
+ true // rejectUnknownOcspResponseStatus
+ );
+ CircuitBreakerRegistry circuitBreakerRegistry = resilientOcspService.getCircuitBreakerRegistry();
+ CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(PRIMARY_OCSP_URL.toASCIIString());
+ try (var mockedClock = mockStatic(DateAndTime.DefaultClock.class)) {
+ mockDate("2021-09-18T00:16:25", mockedClock);
+
+ assertThatExceptionOfType(UserCertificateUnknownException.class)
+ .isThrownBy(() ->
+ resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate))
+ .withMessage("Unknown status")
+ .satisfies(e -> assertThat(e.getOcspValidationInfo()).isNotNull())
+ .satisfies(e -> assertThat(e.getOcspValidationInfo().getSubjectCertificate()).isNotNull())
+ .satisfies(e -> assertThat(e.getOcspValidationInfo().getOcspResponderUri()).isNotNull())
+ .satisfies(e -> assertThat(e.getOcspValidationInfo().getOcspResponderUri().toASCIIString()).isEqualTo("http://fallback.demo.sk.ee/ocsp"))
+ .satisfies(e -> assertThat(e.getOcspValidationInfo().getOcspResponse()).isNotNull());
+
+
+ verify(ocspClient, times(1)).request(eq(PRIMARY_OCSP_URL), any());
+ verify(ocspClient, times(1)).request(eq(FALLBACK_OCSP_URL), any());
+ assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.CLOSED);
+ }
+ }
+
+ @Test
+ void whenPrimaryAndFallbackConnectionFail_thenThrows() throws Exception {
+ final OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_OCSP_URL), any()))
+ .thenThrow(new IOException("Mocked exception 1"));
+ when(ocspClient.request(eq(FALLBACK_OCSP_URL), any()))
+ .thenThrow(new IOException("Mocked exception 2"));
+ OcspServiceProvider ocspServiceProvider = createOcspServiceProviderWithFallback();
+ ResilientOcspService resilientOcspService = new ResilientOcspService(
+ ocspClient,
+ ocspServiceProvider,
+ null,
+ null,
+ ALLOWED_TIME_SKEW,
+ MAX_THIS_UPDATE_AGE,
+ false
+ );
+ CircuitBreakerRegistry circuitBreakerRegistry = resilientOcspService.getCircuitBreakerRegistry();
+ CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(PRIMARY_OCSP_URL.toASCIIString());
+ try (var mockedClock = mockStatic(DateAndTime.DefaultClock.class)) {
+ mockDate("2021-09-18T00:16:25", mockedClock);
+
+ assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() ->
+ resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate))
+ .withMessage("User certificate revocation check has failed")
+ .withCause(new IOException("Mocked exception 2"))
+ .satisfies(e -> assertThat(e.getOcspValidationInfo()).isNotNull())
+ .satisfies(e -> assertThat(e.getOcspValidationInfo().getSubjectCertificate()).isNotNull())
+ .satisfies(e -> assertThat(e.getOcspValidationInfo().getOcspResponse()).isNull());
+
+ verify(ocspClient, times(1)).request(eq(PRIMARY_OCSP_URL), any());
+ verify(ocspClient, times(1)).request(eq(FALLBACK_OCSP_URL), any());
+ assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.CLOSED);
+ }
+ }
+
+ @Test
+ void whenNoFallbackConfigured_thenPrimaryFailureThrows() throws Exception {
+ final OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_OCSP_URL), any()))
+ .thenThrow(new IOException("Mocked exception"));
+ OcspServiceProvider ocspServiceProvider = getAiaOcspServiceProvider();
+ ResilientOcspService resilientOcspService = new ResilientOcspService(
+ ocspClient,
+ ocspServiceProvider,
+ null,
+ null,
+ ALLOWED_TIME_SKEW,
+ MAX_THIS_UPDATE_AGE,
+ false
+ );
+ try (var mockedClock = mockStatic(DateAndTime.DefaultClock.class)) {
+ mockDate("2021-09-17T18:25:24", mockedClock);
+
+ assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() ->
+ resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate))
+ .withMessage("User certificate revocation check has failed")
+ .withCause(new IOException("Mocked exception"))
+ .satisfies(e -> assertThat(e.getOcspValidationInfo()).isNotNull())
+ .satisfies(e -> assertThat(e.getOcspValidationInfo().getSubjectCertificate()).isNotNull())
+ .satisfies(e -> assertThat(e.getOcspValidationInfo().getOcspResponse()).isNull());
+ }
+ }
+
+ private OcspServiceProvider createOcspServiceProviderWithFallback() throws Exception {
+ FallbackOcspServiceConfiguration fallbackConfig = new FallbackOcspServiceConfiguration(
+ PRIMARY_OCSP_URL,
+ FALLBACK_OCSP_URL,
+ getTestSkOcspResponder2018(),
+ false
+ );
+ return createOcspServiceProviderWithFallback(fallbackConfig);
+ }
+
+ private OcspServiceProvider createOcspServiceProviderWithFallback(FallbackOcspServiceConfiguration fallbackConfig) throws Exception {
+ List trustedCACertificates = Arrays.asList(
+ getTestEsteid2018CA(),
+ getTestSkOcspResponder2020(),
+ getTestEsteid2015CA()
+ );
+ AiaOcspServiceConfiguration aiaConfig =
+ new AiaOcspServiceConfiguration(
+ Set.of(PRIMARY_OCSP_URL),
+ CertificateValidator.buildTrustAnchorsFromCertificates(trustedCACertificates),
+ CertificateValidator.buildCertStoreFromCertificates(trustedCACertificates)
+ );
+ return new OcspServiceProvider(
+ null,
+ aiaConfig,
+ Collections.singletonList(fallbackConfig)
+ );
+ }
+
+ private static byte[] getSystemResource(String name) throws IOException {
+ try (InputStream resourceAsStream = ClassLoader.getSystemResourceAsStream(name)) {
+ if (resourceAsStream == null) {
+ throw new IOException("Resource not found: " + name);
+ }
+ return resourceAsStream.readAllBytes();
+ }
+ }
+}
diff --git a/src/test/resources/TEST_of_SK_OCSP_RESPONDER_2018.cer b/src/test/resources/TEST_of_SK_OCSP_RESPONDER_2018.cer
new file mode 100644
index 00000000..30ce32b0
Binary files /dev/null and b/src/test/resources/TEST_of_SK_OCSP_RESPONDER_2018.cer differ