From 6765d3e6bf904ac441c8425a854d023afa1e36d0 Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Mon, 3 Nov 2025 18:31:30 +0200 Subject: [PATCH 1/5] Add circuit breaker for OCSP service requests --- pom.xml | 34 ++ .../AuthTokenValidationConfiguration.java | 19 + .../validator/AuthTokenValidatorBuilder.java | 40 ++ .../validator/AuthTokenValidatorImpl.java | 19 +- ...SubjectCertificateNotRevokedValidator.java | 176 +------- .../validator/ocsp/OcspResponseValidator.java | 94 +++++ .../validator/ocsp/OcspServiceProvider.java | 29 +- .../validator/ocsp/ResilientOcspService.java | 173 ++++++++ .../ocsp/service/AiaOcspService.java | 9 +- .../ocsp/service/DesignatedOcspService.java | 2 +- .../DesignatedOcspServiceConfiguration.java | 8 +- .../ocsp/service/FallbackOcspService.java | 74 ++++ .../FallbackOcspServiceConfiguration.java | 63 +++ .../validator/ocsp/service/OcspService.java | 3 + .../security/testutil/Certificates.java | 11 +- ...ectCertificateNotRevokedValidatorTest.java | 4 +- .../ocsp/ResilientOcspServiceTest.java | 375 ++++++++++++++++++ .../TEST_of_SK_OCSP_RESPONDER_2018.cer | Bin 0 -> 961 bytes 18 files changed, 942 insertions(+), 191 deletions(-) create mode 100644 src/main/java/eu/webeid/security/validator/ocsp/ResilientOcspService.java create mode 100644 src/main/java/eu/webeid/security/validator/ocsp/service/FallbackOcspService.java create mode 100644 src/main/java/eu/webeid/security/validator/ocsp/service/FallbackOcspServiceConfiguration.java create mode 100644 src/test/java/eu/webeid/security/validator/ocsp/ResilientOcspServiceTest.java create mode 100644 src/test/resources/TEST_of_SK_OCSP_RESPONDER_2018.cer diff --git a/pom.xml b/pom.xml index 2816da7c..e333bbd5 100644 --- a/pom.xml +++ b/pom.xml @@ -16,6 +16,7 @@ 1.81 2.19.1 2.0.17 + 1.7.0 5.13.3 3.27.3 5.18.0 @@ -65,6 +66,33 @@ bcpkix-jdk18on ${bouncycastle.version} + + io.github.resilience4j + resilience4j-all + ${resilience4j.version} + + + io.github.resilience4j + resilience4j-retry + + + io.github.resilience4j + resilience4j-bulkhead + + + io.github.resilience4j + resilience4j-cache + + + io.github.resilience4j + resilience4j-ratelimiter + + + io.github.resilience4j + resilience4j-timelimiter + + + org.junit.jupiter @@ -90,6 +118,12 @@ ${slf4j.version} test + + org.awaitility + awaitility + 4.3.0 + test + diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java index 6b943bd2..89d2bf6b 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java @@ -24,6 +24,8 @@ import eu.webeid.security.certificate.SubjectCertificatePolicies; import eu.webeid.security.validator.ocsp.service.DesignatedOcspServiceConfiguration; +import eu.webeid.security.validator.ocsp.service.FallbackOcspServiceConfiguration; +import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig; import org.bouncycastle.asn1.ASN1ObjectIdentifier; import java.net.MalformedURLException; @@ -51,6 +53,8 @@ public final class AuthTokenValidationConfiguration { private Duration allowedOcspResponseTimeSkew = Duration.ofMinutes(15); private Duration maxOcspResponseThisUpdateAge = Duration.ofMinutes(2); private DesignatedOcspServiceConfiguration designatedOcspServiceConfiguration; + private Collection fallbackOcspServiceConfigurations = new HashSet<>(); + private CircuitBreakerConfig circuitBreakerConfig; // Don't allow Estonian Mobile-ID policy by default. private Collection disallowedSubjectCertificatePolicies = newHashSet( SubjectCertificatePolicies.ESTEID_SK_2015_MOBILE_ID_POLICY_V1, @@ -71,6 +75,8 @@ private AuthTokenValidationConfiguration(AuthTokenValidationConfiguration other) this.allowedOcspResponseTimeSkew = other.allowedOcspResponseTimeSkew; this.maxOcspResponseThisUpdateAge = other.maxOcspResponseThisUpdateAge; this.designatedOcspServiceConfiguration = other.designatedOcspServiceConfiguration; + this.fallbackOcspServiceConfigurations = Set.copyOf(other.fallbackOcspServiceConfigurations); + this.circuitBreakerConfig = other.circuitBreakerConfig; this.disallowedSubjectCertificatePolicies = Set.copyOf(other.disallowedSubjectCertificatePolicies); this.nonceDisabledOcspUrls = Set.copyOf(other.nonceDisabledOcspUrls); } @@ -135,6 +141,18 @@ public Collection getNonceDisabledOcspUrls() { return nonceDisabledOcspUrls; } + public Collection getFallbackOcspServiceConfigurations() { + return fallbackOcspServiceConfigurations; + } + + public CircuitBreakerConfig getCircuitBreakerConfig() { + return circuitBreakerConfig; + } + + public void setCircuitBreakerConfig(CircuitBreakerConfig circuitBreakerConfig) { + this.circuitBreakerConfig = circuitBreakerConfig; + } + /** * Checks that the configuration parameters are valid. * @@ -150,6 +168,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/AuthTokenValidatorBuilder.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java index 9122ee67..9f889d5c 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java @@ -26,6 +26,9 @@ 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 org.bouncycastle.asn1.ASN1ObjectIdentifier; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -187,6 +190,43 @@ 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; + } + /** * 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..fec4b340 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java @@ -37,6 +37,7 @@ 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.ResilientOcspService; import eu.webeid.security.validator.ocsp.service.AiaOcspServiceConfiguration; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -65,9 +66,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 +88,15 @@ 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.getAllowedOcspResponseTimeSkew(), + configuration.getMaxOcspResponseThisUpdateAge()); } authTokenSignatureValidator = new AuthTokenSignatureValidator(configuration.getSiteOrigin()); @@ -182,11 +185,7 @@ private SubjectCertificateValidatorBatch getCertTrustValidators() { return SubjectCertificateValidatorBatch.createFrom( certTrustedValidator::validateCertificateTrusted ).addOptional(configuration.isUserCertificateRevocationCheckWithOcspEnabled(), - new SubjectCertificateNotRevokedValidator(certTrustedValidator, - ocspClient, ocspServiceProvider, - configuration.getAllowedOcspResponseTimeSkew(), - configuration.getMaxOcspResponseThisUpdateAge() - )::validateCertificateNotRevoked + new SubjectCertificateNotRevokedValidator(resilientOcspService, certTrustedValidator)::validateCertificateNotRevoked ); } 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..9453d518 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,25 @@ 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.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; } /** @@ -90,132 +51,7 @@ public SubjectCertificateNotRevokedValidator(SubjectCertificateTrustedValidator * @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); - } + final X509Certificate issuerCertificate = Objects.requireNonNull(trustValidator.getSubjectCertificateIssuerCertificate()); + 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..ad706111 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/OcspResponseValidator.java +++ b/src/main/java/eu/webeid/security/validator/ocsp/OcspResponseValidator.java @@ -22,14 +22,21 @@ 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.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; @@ -42,10 +49,67 @@ 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 void validateOcspResponse(BasicOCSPResp basicResponse, OcspService ocspService, Duration allowedOcspResponseTimeSkew, Duration maxOcspResponseThisUpdateAge, 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); + } + /** * Indicates that a X.509 Certificates corresponding private key may be used by an authority to sign OCSP responses. *

@@ -133,6 +197,36 @@ public static void validateSubjectCertificateStatus(SingleResp certStatusRespons } } + public static void validateNonce(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"); + } + } + + 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"; + } + } + private OcspResponseValidator() { throw new IllegalStateException("Utility class"); } 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/ResilientOcspService.java b/src/main/java/eu/webeid/security/validator/ocsp/ResilientOcspService.java new file mode 100644 index 00000000..a4e1d142 --- /dev/null +++ b/src/main/java/eu/webeid/security/validator/ocsp/ResilientOcspService.java @@ -0,0 +1,173 @@ +/* + * 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.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.vavr.CheckedFunction0; +import io.vavr.control.Try; +import org.bouncycastle.asn1.ocsp.OCSPResponseStatus; +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.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.cert.CertificateEncodingException; +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 CircuitBreakerRegistry circuitBreakerRegistry; + + public ResilientOcspService(OcspClient ocspClient, OcspServiceProvider ocspServiceProvider, CircuitBreakerConfig circuitBreakerConfig, Duration allowedOcspResponseTimeSkew, Duration maxOcspResponseThisUpdateAge) { + this.ocspClient = ocspClient; + this.ocspServiceProvider = ocspServiceProvider; + this.allowedOcspResponseTimeSkew = allowedOcspResponseTimeSkew; + this.maxOcspResponseThisUpdateAge = maxOcspResponseThisUpdateAge; + this.circuitBreakerRegistry = CircuitBreakerRegistry.custom() + .withCircuitBreakerConfig(getCircuitBreakerConfig(circuitBreakerConfig)) + .build(); + 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 OcspService 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); + CheckedFunction0 decoratedSupplier = Decorators.ofCheckedSupplier(primarySupplier) + .withCircuitBreaker(circuitBreaker) + .withFallback(List.of(UserCertificateOCSPCheckFailedException.class, CallNotPermittedException.class), e -> fallbackSupplier.apply()) // TODO: Any other exceptions to trigger fallback? Resilience4j does not support Predicate shouldFallback = e -> !(e instanceof UserCertificateRevokedException); in withFallback API. + .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 OcspService request(OcspService ocspService, X509Certificate subjectCertificate, X509Certificate issuerCertificate) throws AuthTokenException { + try { + final CertificateID certificateId = 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"); + final OCSPResp 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())); + } + + final BasicOCSPResp basicResponse = (BasicOCSPResp) response.getResponseObject(); + if (basicResponse == null) { + throw new UserCertificateOCSPCheckFailedException("Missing Basic OCSP Response"); + } + + OcspResponseValidator.validateOcspResponse(basicResponse, ocspService, allowedOcspResponseTimeSkew, maxOcspResponseThisUpdateAge, certificateId); + LOG.debug("OCSP check result is GOOD"); + + if (ocspService.doesSupportNonce()) { + OcspResponseValidator.validateNonce(request, basicResponse); + } + return ocspService; + } catch (OCSPException | CertificateException | OperatorCreationException | IOException e) { + throw new UserCertificateOCSPCheckFailedException(e); + } + } + + 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 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. 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(); + } + + 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/certvalidators/SubjectCertificateNotRevokedValidatorTest.java b/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidatorTest.java index 771c3018..b23dfe48 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; @@ -347,7 +348,8 @@ 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, CONFIGURATION.getAllowedOcspResponseTimeSkew(), CONFIGURATION.getMaxOcspResponseThisUpdateAge()); + 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/ResilientOcspServiceTest.java b/src/test/java/eu/webeid/security/validator/ocsp/ResilientOcspServiceTest.java new file mode 100644 index 00000000..3b033950 --- /dev/null +++ b/src/test/java/eu/webeid/security/validator/ocsp/ResilientOcspServiceTest.java @@ -0,0 +1,375 @@ +/* + * 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.AuthTokenException; +import eu.webeid.security.exceptions.UserCertificateRevokedException; +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 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, + ALLOWED_TIME_SKEW, + MAX_THIS_UPDATE_AGE + ); + 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, + ALLOWED_TIME_SKEW, + MAX_THIS_UPDATE_AGE + ); + 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(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, + ALLOWED_TIME_SKEW, + MAX_THIS_UPDATE_AGE + ); + 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"); + + 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, + ALLOWED_TIME_SKEW, + MAX_THIS_UPDATE_AGE + ); + 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"); + + 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 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, + 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")); + + 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, + ALLOWED_TIME_SKEW, + MAX_THIS_UPDATE_AGE + ); + try (var mockedClock = mockStatic(DateAndTime.DefaultClock.class)) { + mockDate("2021-09-17T18:25:24", mockedClock); + + assertThatCode(() -> + resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate) + ).isInstanceOf(AuthTokenException.class); + } + } + + private OcspServiceProvider createOcspServiceProviderWithFallback() throws Exception { + FallbackOcspServiceConfiguration fallbackConfig = new FallbackOcspServiceConfiguration( + PRIMARY_OCSP_URL, + FALLBACK_OCSP_URL, + getTestSkOcspResponder2018(), + false + ); + 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 0000000000000000000000000000000000000000..30ce32b07510e9f7318500588639903b83159727 GIT binary patch literal 961 zcmXqLV%}@e#4NvnnTe5!NucU&tHg}i59UNa+&xD?(x||Ii;Y98&EuRc3p0yBf+4p7 zCmVAp3!5;LtE-{3fh35-#Um8#t>EdR5S*V=T9TQcSFGR|Y$$FZ3X)9Z;qwa#a&^@; zG%z0;&)3ULh%QOHkI08*}=E=C5176x+SyoMHr2F8X)h6X?oCC+PXY+zwz z3FR6T8JZiIg3M*+(Q|S2^+#ye1-b!bgo2}|qk_M4aDYOPYjA+SpNne{Slp1;fE%Qd zn}-D?3v&i`$0K{wpovk*fR~LE7z%CQc^MfQSs9p{82K51;#^EkjEoFBMVnMFO{y|E z`Tq1V|Cc^BA-O#bH{#^Z<@O| zFYJpryUopmo8CV&?{FXC~>!qgZmlovYC#LA9W#*(7>w}V9 ze12LyBFX8cq!#HV7nKkfllsS`|W9Sp>*rjsa^X_|n&s(s0Rm~EXO7CQade_UsOQ#*H zSju^fyST-N>kyN3^{r+mN0~N*doBy!@_i4=U{Fx`{<2U)(8ctBWmemsLiNTTGuLIz lkFs~j{yd~?lW;!2^L@=}>(mZoH-Y Date: Thu, 6 Nov 2025 09:24:46 +0200 Subject: [PATCH 2/5] Add rejectUnknownOcspResponseStatus configuration option --- .../UserCertificateUnknownException.java | 33 +++++++ .../AuthTokenValidationConfiguration.java | 10 +++ .../validator/AuthTokenValidatorBuilder.java | 12 +++ .../validator/AuthTokenValidatorImpl.java | 7 +- .../validator/ocsp/OcspResponseValidator.java | 13 +-- .../validator/ocsp/ResilientOcspService.java | 11 ++- ...ectCertificateNotRevokedValidatorTest.java | 6 +- .../ocsp/ResilientOcspServiceTest.java | 90 +++++++++++++++++-- 8 files changed, 165 insertions(+), 17 deletions(-) create mode 100644 src/main/java/eu/webeid/security/exceptions/UserCertificateUnknownException.java diff --git a/src/main/java/eu/webeid/security/exceptions/UserCertificateUnknownException.java b/src/main/java/eu/webeid/security/exceptions/UserCertificateUnknownException.java new file mode 100644 index 00000000..0abd0554 --- /dev/null +++ b/src/main/java/eu/webeid/security/exceptions/UserCertificateUnknownException.java @@ -0,0 +1,33 @@ +/* + * 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.exceptions; + +/** + * Thrown when the user certificate has been revoked. + */ +public class UserCertificateUnknownException extends AuthTokenException { + + public UserCertificateUnknownException(String msg) { + super(msg); + } +} diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java index 89d2bf6b..2dc1a83c 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java @@ -52,6 +52,7 @@ public final class AuthTokenValidationConfiguration { private Duration ocspRequestTimeout = Duration.ofSeconds(5); private Duration allowedOcspResponseTimeSkew = Duration.ofMinutes(15); private Duration maxOcspResponseThisUpdateAge = Duration.ofMinutes(2); + private boolean rejectUnknownOcspResponseStatus; private DesignatedOcspServiceConfiguration designatedOcspServiceConfiguration; private Collection fallbackOcspServiceConfigurations = new HashSet<>(); private CircuitBreakerConfig circuitBreakerConfig; @@ -74,6 +75,7 @@ private AuthTokenValidationConfiguration(AuthTokenValidationConfiguration other) this.ocspRequestTimeout = other.ocspRequestTimeout; this.allowedOcspResponseTimeSkew = other.allowedOcspResponseTimeSkew; this.maxOcspResponseThisUpdateAge = other.maxOcspResponseThisUpdateAge; + this.rejectUnknownOcspResponseStatus = other.rejectUnknownOcspResponseStatus; this.designatedOcspServiceConfiguration = other.designatedOcspServiceConfiguration; this.fallbackOcspServiceConfigurations = Set.copyOf(other.fallbackOcspServiceConfigurations); this.circuitBreakerConfig = other.circuitBreakerConfig; @@ -153,6 +155,14 @@ public void setCircuitBreakerConfig(CircuitBreakerConfig circuitBreakerConfig) { this.circuitBreakerConfig = circuitBreakerConfig; } + public boolean isRejectUnknownOcspResponseStatus() { + return rejectUnknownOcspResponseStatus; + } + + public void setRejectUnknownOcspResponseStatus(boolean rejectUnknownOcspResponseStatus) { + this.rejectUnknownOcspResponseStatus = rejectUnknownOcspResponseStatus; + } + /** * Checks that the configuration parameters are valid. * diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java index 9f889d5c..3cb8f6ea 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java @@ -227,6 +227,18 @@ public AuthTokenValidatorBuilder withCircuitBreakerConfig(int slidingWindowSize, 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 fec4b340..9dcb036b 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java @@ -95,8 +95,11 @@ final class AuthTokenValidatorImpl implements AuthTokenValidator { trustedCACertificateAnchors, trustedCACertificateCertStore), configuration.getFallbackOcspServiceConfigurations()); - resilientOcspService = new ResilientOcspService(ocspClient, ocspServiceProvider, configuration.getCircuitBreakerConfig(), configuration.getAllowedOcspResponseTimeSkew(), - configuration.getMaxOcspResponseThisUpdateAge()); + resilientOcspService = new ResilientOcspService(ocspClient, ocspServiceProvider, + configuration.getCircuitBreakerConfig(), + configuration.getAllowedOcspResponseTimeSkew(), + configuration.getMaxOcspResponseThisUpdateAge(), + configuration.isRejectUnknownOcspResponseStatus()); } authTokenSignatureValidator = new AuthTokenSignatureValidator(configuration.getSiteOrigin()); 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 ad706111..5268c6a3 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/OcspResponseValidator.java +++ b/src/main/java/eu/webeid/security/validator/ocsp/OcspResponseValidator.java @@ -26,6 +26,7 @@ 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; @@ -54,7 +55,7 @@ public final class OcspResponseValidator { - public static void validateOcspResponse(BasicOCSPResp basicResponse, OcspService ocspService, Duration allowedOcspResponseTimeSkew, Duration maxOcspResponseThisUpdateAge, CertificateID requestCertificateId) throws AuthTokenException, OCSPException, CertificateException, OperatorCreationException { + public static void validateOcspResponse(BasicOCSPResp basicResponse, OcspService ocspService, Duration allowedOcspResponseTimeSkew, Duration maxOcspResponseThisUpdateAge, boolean rejectUnknownOcspResponseStatus, 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 @@ -107,7 +108,7 @@ public static void validateOcspResponse(BasicOCSPResp basicResponse, OcspService OcspResponseValidator.validateCertificateStatusUpdateTime(certStatusResponse, allowedOcspResponseTimeSkew, maxOcspResponseThisUpdateAge); // Now we can accept the signed response as valid and validate the certificate status. - OcspResponseValidator.validateSubjectCertificateStatus(certStatusResponse); + OcspResponseValidator.validateSubjectCertificateStatus(certStatusResponse, rejectUnknownOcspResponseStatus); } /** @@ -180,7 +181,7 @@ public static void validateCertificateStatusUpdateTime(SingleResp certStatusResp } } - public static void validateSubjectCertificateStatus(SingleResp certStatusResponse) throws UserCertificateRevokedException { + public static void validateSubjectCertificateStatus(SingleResp certStatusResponse, boolean rejectUnknownOcspResponseStatus) throws AuthTokenException { final CertificateStatus status = certStatusResponse.getCertStatus(); if (status == null) { return; @@ -191,9 +192,11 @@ public static void validateSubjectCertificateStatus(SingleResp certStatusRespons new UserCertificateRevokedException("Revocation reason: " + revokedStatus.getRevocationReason()) : new UserCertificateRevokedException()); } else if (status instanceof UnknownStatus) { - throw new UserCertificateRevokedException("Unknown status"); + throw rejectUnknownOcspResponseStatus ? new UserCertificateUnknownException("User certificate has been revoked: Unknown status") + : new UserCertificateRevokedException("Unknown status"); } else { - throw new UserCertificateRevokedException("Status is neither good, revoked nor unknown"); + throw rejectUnknownOcspResponseStatus ? new UserCertificateUnknownException("Status is neither good, revoked nor unknown") + : new UserCertificateRevokedException("Status is neither good, revoked nor unknown"); } } diff --git a/src/main/java/eu/webeid/security/validator/ocsp/ResilientOcspService.java b/src/main/java/eu/webeid/security/validator/ocsp/ResilientOcspService.java index a4e1d142..1e6c1689 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/ResilientOcspService.java +++ b/src/main/java/eu/webeid/security/validator/ocsp/ResilientOcspService.java @@ -25,6 +25,7 @@ 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; @@ -61,13 +62,15 @@ public class ResilientOcspService { private final OcspServiceProvider ocspServiceProvider; private final Duration allowedOcspResponseTimeSkew; private final Duration maxOcspResponseThisUpdateAge; + private final boolean rejectUnknownOcspResponseStatus; private final CircuitBreakerRegistry circuitBreakerRegistry; - public ResilientOcspService(OcspClient ocspClient, OcspServiceProvider ocspServiceProvider, CircuitBreakerConfig circuitBreakerConfig, Duration allowedOcspResponseTimeSkew, Duration maxOcspResponseThisUpdateAge) { + public ResilientOcspService(OcspClient ocspClient, OcspServiceProvider ocspServiceProvider, CircuitBreakerConfig circuitBreakerConfig, 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(); @@ -91,7 +94,7 @@ public OcspService validateSubjectCertificateNotRevoked(X509Certificate subjectC CheckedFunction0 fallbackSupplier = () -> request(ocspService.getFallbackService(), subjectCertificate, issuerCertificate); CheckedFunction0 decoratedSupplier = Decorators.ofCheckedSupplier(primarySupplier) .withCircuitBreaker(circuitBreaker) - .withFallback(List.of(UserCertificateOCSPCheckFailedException.class, CallNotPermittedException.class), e -> fallbackSupplier.apply()) // TODO: Any other exceptions to trigger fallback? Resilience4j does not support Predicate shouldFallback = e -> !(e instanceof UserCertificateRevokedException); in withFallback API. + .withFallback(List.of(UserCertificateOCSPCheckFailedException.class, CallNotPermittedException.class, UserCertificateUnknownException.class), e -> fallbackSupplier.apply()) // TODO: Any other exceptions to trigger fallback? Resilience4j does not support Predicate shouldFallback = e -> !(e instanceof UserCertificateRevokedException); in withFallback API. .decorate(); return Try.of(decoratedSupplier).getOrElseThrow(throwable -> { @@ -129,7 +132,7 @@ private OcspService request(OcspService ocspService, X509Certificate subjectCert throw new UserCertificateOCSPCheckFailedException("Missing Basic OCSP Response"); } - OcspResponseValidator.validateOcspResponse(basicResponse, ocspService, allowedOcspResponseTimeSkew, maxOcspResponseThisUpdateAge, certificateId); + OcspResponseValidator.validateOcspResponse(basicResponse, ocspService, allowedOcspResponseTimeSkew, maxOcspResponseThisUpdateAge, rejectUnknownOcspResponseStatus, certificateId); LOG.debug("OCSP check result is GOOD"); if (ocspService.doesSupportNonce()) { @@ -153,7 +156,7 @@ private static CircuitBreakerConfig getCircuitBreakerConfig(CircuitBreakerConfig .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED) .slidingWindowSize(100) .minimumNumberOfCalls(10) - .ignoreExceptions(UserCertificateRevokedException.class) // TODO: Revoked status is a valid response, not a failure. Any other exceptions to ignore? + .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? 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 b23dfe48..46dcc057 100644 --- a/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidatorTest.java +++ b/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidatorTest.java @@ -348,7 +348,11 @@ private SubjectCertificateNotRevokedValidator getSubjectCertificateNotRevokedVal } private SubjectCertificateNotRevokedValidator getSubjectCertificateNotRevokedValidator(OcspClient client, OcspServiceProvider ocspServiceProvider) { - ResilientOcspService resilientOcspService = new ResilientOcspService(client, ocspServiceProvider, null, CONFIGURATION.getAllowedOcspResponseTimeSkew(), CONFIGURATION.getMaxOcspResponseThisUpdateAge()); + ResilientOcspService resilientOcspService = new ResilientOcspService(client, ocspServiceProvider, + null, + CONFIGURATION.getAllowedOcspResponseTimeSkew(), + CONFIGURATION.getMaxOcspResponseThisUpdateAge(), + CONFIGURATION.isRejectUnknownOcspResponseStatus()); return new SubjectCertificateNotRevokedValidator(resilientOcspService, trustedValidator); } diff --git a/src/test/java/eu/webeid/security/validator/ocsp/ResilientOcspServiceTest.java b/src/test/java/eu/webeid/security/validator/ocsp/ResilientOcspServiceTest.java index 3b033950..a0a01687 100644 --- a/src/test/java/eu/webeid/security/validator/ocsp/ResilientOcspServiceTest.java +++ b/src/test/java/eu/webeid/security/validator/ocsp/ResilientOcspServiceTest.java @@ -24,7 +24,9 @@ import eu.webeid.security.certificate.CertificateValidator; 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.util.DateAndTime; import eu.webeid.security.validator.ocsp.service.AiaOcspServiceConfiguration; import eu.webeid.security.validator.ocsp.service.FallbackOcspServiceConfiguration; @@ -121,7 +123,8 @@ void whenFallbackConfigured_thenFallbackAndRecoverySucceeds() throws Exception { ocspServiceProvider, circuitBreakerConfig, ALLOWED_TIME_SKEW, - MAX_THIS_UPDATE_AGE + MAX_THIS_UPDATE_AGE, + false ); CircuitBreakerRegistry circuitBreakerRegistry = resilientOcspService.getCircuitBreakerRegistry(); CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(PRIMARY_OCSP_URL.toASCIIString()); @@ -205,7 +208,8 @@ void whenOcspResponseGood_thenNoFallbackAndSucceeds() throws Exception { ocspServiceProvider, null, ALLOWED_TIME_SKEW, - MAX_THIS_UPDATE_AGE + MAX_THIS_UPDATE_AGE, + false ); CircuitBreakerRegistry circuitBreakerRegistry = resilientOcspService.getCircuitBreakerRegistry(); CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(PRIMARY_OCSP_URL.toASCIIString()); @@ -235,7 +239,8 @@ void whenOcspResponseRevoked_thenNoFallbackAndThrows() throws Exception { ocspServiceProvider, null, ALLOWED_TIME_SKEW, - MAX_THIS_UPDATE_AGE + MAX_THIS_UPDATE_AGE, + false ); CircuitBreakerRegistry circuitBreakerRegistry = resilientOcspService.getCircuitBreakerRegistry(); CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(PRIMARY_OCSP_URL.toASCIIString()); @@ -266,7 +271,8 @@ void whenOcspResponseUnknown_thenNoFallbackAndThrows() throws Exception { ocspServiceProvider, null, ALLOWED_TIME_SKEW, - MAX_THIS_UPDATE_AGE + MAX_THIS_UPDATE_AGE, + false ); CircuitBreakerRegistry circuitBreakerRegistry = resilientOcspService.getCircuitBreakerRegistry(); CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(PRIMARY_OCSP_URL.toASCIIString()); @@ -284,6 +290,75 @@ void whenOcspResponseUnknown_thenNoFallbackAndThrows() throws Exception { } } + @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, + 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); + + 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); + } + } + + @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, + 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("User certificate has been revoked: Unknown status"); + + 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); @@ -328,7 +403,8 @@ void whenNoFallbackConfigured_thenPrimaryFailureThrows() throws Exception { ocspServiceProvider, null, ALLOWED_TIME_SKEW, - MAX_THIS_UPDATE_AGE + MAX_THIS_UPDATE_AGE, + false ); try (var mockedClock = mockStatic(DateAndTime.DefaultClock.class)) { mockDate("2021-09-17T18:25:24", mockedClock); @@ -346,6 +422,10 @@ private OcspServiceProvider createOcspServiceProviderWithFallback() throws Excep getTestSkOcspResponder2018(), false ); + return createOcspServiceProviderWithFallback(fallbackConfig); + } + + private OcspServiceProvider createOcspServiceProviderWithFallback(FallbackOcspServiceConfiguration fallbackConfig) throws Exception { List trustedCACertificates = Arrays.asList( getTestEsteid2018CA(), getTestSkOcspResponder2020(), From ad4ad48e914e58857839b53e4ca45f59552e03c0 Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Mon, 10 Nov 2025 14:21:56 +0200 Subject: [PATCH 3/5] Add ValidationInfo and OcspValidationInfo in validation response and exception --- .../AuthTokenDTOAuthenticationProvider.java | 4 +- ...erCertificateOCSPCheckFailedException.java | 20 +++- .../UserCertificateRevokedException.java | 14 ++- .../UserCertificateUnknownException.java | 10 +- .../validator/AuthTokenValidator.java | 2 +- .../validator/AuthTokenValidatorImpl.java | 23 +++-- .../security/validator/ValidationInfo.java | 45 +++++++++ ...SubjectCertificateNotRevokedValidator.java | 5 +- .../validator/ocsp/OcspResponseValidator.java | 98 +++++++++++++------ .../validator/ocsp/OcspValidationInfo.java | 52 ++++++++++ .../validator/ocsp/ResilientOcspService.java | 52 ++++------ .../validator/AuthTokenSignatureTest.java | 4 +- ...ectCertificateNotRevokedValidatorTest.java | 8 +- .../ocsp/OcspResponseValidatorTest.java | 27 +++-- .../ocsp/ResilientOcspServiceTest.java | 61 +++++++++--- 15 files changed, 317 insertions(+), 108 deletions(-) create mode 100644 src/main/java/eu/webeid/security/validator/ValidationInfo.java create mode 100644 src/main/java/eu/webeid/security/validator/ocsp/OcspValidationInfo.java diff --git a/example/src/main/java/eu/webeid/example/security/AuthTokenDTOAuthenticationProvider.java b/example/src/main/java/eu/webeid/example/security/AuthTokenDTOAuthenticationProvider.java index 274a47bf..c7259222 100644 --- a/example/src/main/java/eu/webeid/example/security/AuthTokenDTOAuthenticationProvider.java +++ b/example/src/main/java/eu/webeid/example/security/AuthTokenDTOAuthenticationProvider.java @@ -27,6 +27,7 @@ import eu.webeid.security.challenge.ChallengeNonceStore; import eu.webeid.security.exceptions.AuthTokenException; import eu.webeid.security.validator.AuthTokenValidator; +import eu.webeid.security.validator.ValidationInfo; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.security.authentication.AuthenticationProvider; @@ -72,7 +73,8 @@ public Authentication authenticate(Authentication auth) throws AuthenticationExc try { final String nonce = challengeNonceStore.getAndRemove().getBase64EncodedNonce(); - final X509Certificate userCertificate = tokenValidator.validate(authToken, nonce); + final ValidationInfo validationInfo = tokenValidator.validate(authToken, nonce); + final X509Certificate userCertificate = validationInfo.getSubjectCertificate(); return WebEidAuthentication.fromCertificate(userCertificate, authorities); } catch (AuthTokenException e) { throw new AuthenticationServiceException("Web eID token validation failed", e); diff --git a/src/main/java/eu/webeid/security/exceptions/UserCertificateOCSPCheckFailedException.java b/src/main/java/eu/webeid/security/exceptions/UserCertificateOCSPCheckFailedException.java index 5ca68dc5..b9c78c37 100644 --- a/src/main/java/eu/webeid/security/exceptions/UserCertificateOCSPCheckFailedException.java +++ b/src/main/java/eu/webeid/security/exceptions/UserCertificateOCSPCheckFailedException.java @@ -22,15 +22,33 @@ package eu.webeid.security.exceptions; +import eu.webeid.security.validator.ocsp.OcspValidationInfo; + /** * Thrown when user certificate revocation check with OCSP fails. */ public class UserCertificateOCSPCheckFailedException extends AuthTokenException { + private final OcspValidationInfo ocspValidationInfo; + public UserCertificateOCSPCheckFailedException(Throwable cause) { - super("User certificate revocation check has failed", cause); + this(cause, null); } public UserCertificateOCSPCheckFailedException(String message) { + this(message, null); + } + + public UserCertificateOCSPCheckFailedException(Throwable cause, OcspValidationInfo ocspValidationInfo) { + super("User certificate revocation check has failed", cause); + this.ocspValidationInfo = ocspValidationInfo; + } + + public UserCertificateOCSPCheckFailedException(String message, OcspValidationInfo ocspValidationInfo) { super("User certificate revocation check has failed: " + message); + this.ocspValidationInfo = ocspValidationInfo; + } + + public OcspValidationInfo getOcspValidationInfo() { + return ocspValidationInfo; } } diff --git a/src/main/java/eu/webeid/security/exceptions/UserCertificateRevokedException.java b/src/main/java/eu/webeid/security/exceptions/UserCertificateRevokedException.java index 83eb53ab..18d5065a 100644 --- a/src/main/java/eu/webeid/security/exceptions/UserCertificateRevokedException.java +++ b/src/main/java/eu/webeid/security/exceptions/UserCertificateRevokedException.java @@ -22,15 +22,25 @@ package eu.webeid.security.exceptions; +import eu.webeid.security.validator.ocsp.OcspValidationInfo; + /** * Thrown when the user certificate has been revoked. */ public class UserCertificateRevokedException extends AuthTokenException { - public UserCertificateRevokedException() { + private final OcspValidationInfo ocspValidationInfo; + + public UserCertificateRevokedException(OcspValidationInfo ocspValidationInfo) { super("User certificate has been revoked"); + this.ocspValidationInfo = ocspValidationInfo; } - public UserCertificateRevokedException(String msg) { + public UserCertificateRevokedException(String msg, OcspValidationInfo ocspValidationInfo) { super("User certificate has been revoked: " + msg); + this.ocspValidationInfo = ocspValidationInfo; + } + + public OcspValidationInfo getOcspValidationInfo() { + return ocspValidationInfo; } } diff --git a/src/main/java/eu/webeid/security/exceptions/UserCertificateUnknownException.java b/src/main/java/eu/webeid/security/exceptions/UserCertificateUnknownException.java index 0abd0554..7cd6ad81 100644 --- a/src/main/java/eu/webeid/security/exceptions/UserCertificateUnknownException.java +++ b/src/main/java/eu/webeid/security/exceptions/UserCertificateUnknownException.java @@ -22,12 +22,20 @@ package eu.webeid.security.exceptions; +import eu.webeid.security.validator.ocsp.OcspValidationInfo; + /** * Thrown when the user certificate has been revoked. */ public class UserCertificateUnknownException extends AuthTokenException { + private final OcspValidationInfo ocspValidationInfo; - public UserCertificateUnknownException(String msg) { + public UserCertificateUnknownException(String msg, OcspValidationInfo ocspValidationInfo) { super(msg); + this.ocspValidationInfo = ocspValidationInfo; + } + + public OcspValidationInfo getOcspValidationInfo() { + return ocspValidationInfo; } } 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/AuthTokenValidatorImpl.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java index 9dcb036b..55d5e14f 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java @@ -37,6 +37,7 @@ 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; @@ -119,7 +120,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); @@ -151,7 +152,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"); @@ -162,7 +163,7 @@ private X509Certificate validateToken(WebEidAuthToken token, String currentChall final X509Certificate subjectCertificate = CertificateLoader.decodeCertificateFromBase64(token.getUnverifiedCertificate()); simpleSubjectCertificateValidators.executeFor(subjectCertificate); - getCertTrustValidators().executeFor(subjectCertificate); + OcspValidationInfo ocspValidationInfo = 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. @@ -171,25 +172,23 @@ private X509Certificate validateToken(WebEidAuthToken token, String currentChall subjectCertificate.getPublicKey(), currentChallengeNonce); - return 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() { + private OcspValidationInfo validateCertificateTrust(X509Certificate subjectCertificate) throws AuthTokenException { final SubjectCertificateTrustedValidator certTrustedValidator = new SubjectCertificateTrustedValidator(trustedCACertificateAnchors, trustedCACertificateCertStore); - return SubjectCertificateValidatorBatch.createFrom( - certTrustedValidator::validateCertificateTrusted - ).addOptional(configuration.isUserCertificateRevocationCheckWithOcspEnabled(), - new SubjectCertificateNotRevokedValidator(resilientOcspService, certTrustedValidator)::validateCertificateNotRevoked - ); + certTrustedValidator.validateCertificateTrusted(subjectCertificate); + 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 9453d518..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,6 +23,7 @@ package eu.webeid.security.validator.certvalidators; import eu.webeid.security.exceptions.AuthTokenException; +import eu.webeid.security.validator.ocsp.OcspValidationInfo; import eu.webeid.security.validator.ocsp.ResilientOcspService; import org.bouncycastle.jce.provider.BouncyCastleProvider; @@ -50,8 +51,8 @@ public SubjectCertificateNotRevokedValidator(ResilientOcspService resilientOcspS * @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 { + public OcspValidationInfo validateCertificateNotRevoked(X509Certificate subjectCertificate) throws AuthTokenException { final X509Certificate issuerCertificate = Objects.requireNonNull(trustValidator.getSubjectCertificateIssuerCertificate()); - resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate); + return resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate); } } 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 5268c6a3..ecad7048 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/OcspResponseValidator.java +++ b/src/main/java/eu/webeid/security/validator/ocsp/OcspResponseValidator.java @@ -42,9 +42,13 @@ 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; @@ -55,7 +59,16 @@ public final class OcspResponseValidator { - public static void validateOcspResponse(BasicOCSPResp basicResponse, OcspService ocspService, Duration allowedOcspResponseTimeSkew, Duration maxOcspResponseThisUpdateAge, boolean rejectUnknownOcspResponseStatus, CertificateID requestCertificateId) throws AuthTokenException, OCSPException, CertificateException, OperatorCreationException { + 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 @@ -68,11 +81,12 @@ public static void validateOcspResponse(BasicOCSPResp basicResponse, OcspService // 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"); + + "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"); + throw new UserCertificateOCSPCheckFailedException("OCSP responded with certificate ID that differs from the requested ID", ocspValidationInfo); } // 2. The signature on the response is valid. @@ -82,11 +96,11 @@ public static void validateOcspResponse(BasicOCSPResp basicResponse, OcspService // is standard practice. if (basicResponse.getCerts().length < 1) { throw new UserCertificateOCSPCheckFailedException("OCSP response must contain the responder certificate, " - + "but none was provided"); + + "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); + OcspResponseValidator.validateResponseSignature(basicResponse, responderCert, ocspValidationInfo); // 3. The identity of the signer matches the intended recipient of the // request. @@ -96,7 +110,11 @@ public static void validateOcspResponse(BasicOCSPResp basicResponse, OcspService // Use the clock instance so that the date can be mocked in tests. final Date now = DateAndTime.DefaultClock.getInstance().now(); - ocspService.validateResponderCertificate(responderCert, 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. @@ -105,10 +123,31 @@ public static void validateOcspResponse(BasicOCSPResp basicResponse, OcspService // be available about the status of the certificate (nextUpdate) is // greater than the current time. - OcspResponseValidator.validateCertificateStatusUpdateTime(certStatusResponse, allowedOcspResponseTimeSkew, maxOcspResponseThisUpdateAge); + OcspResponseValidator.validateCertificateStatusUpdateTime(certStatusResponse, allowedOcspResponseTimeSkew, maxOcspResponseThisUpdateAge, ocspValidationInfo); // Now we can accept the signed response as valid and validate the certificate status. - OcspResponseValidator.validateSubjectCertificateStatus(certStatusResponse, rejectUnknownOcspResponseStatus); + 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); } /** @@ -131,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 @@ -153,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) { @@ -173,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, boolean rejectUnknownOcspResponseStatus) throws AuthTokenException { + private static void validateSubjectCertificateStatus(SingleResp certStatusResponse, boolean rejectUnknownOcspResponseStatus, OcspValidationInfo ocspValidationInfo) throws AuthTokenException { final CertificateStatus status = certStatusResponse.getCertStatus(); if (status == null) { return; @@ -189,27 +228,30 @@ 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 rejectUnknownOcspResponseStatus ? new UserCertificateUnknownException("User certificate has been revoked: Unknown status") - : new UserCertificateRevokedException("Unknown status"); + throw rejectUnknownOcspResponseStatus ? new UserCertificateUnknownException("Unknown status", ocspValidationInfo) + : new UserCertificateRevokedException("Unknown status", ocspValidationInfo); } else { - throw rejectUnknownOcspResponseStatus ? new UserCertificateUnknownException("Status is neither good, revoked nor unknown") - : 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); } } - public static void validateNonce(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); + 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"); + "possible replay attack", ocspValidationInfo); } if (!requestNonce.equals(responseNonce)) { throw new UserCertificateOCSPCheckFailedException("OCSP request and response nonces differ, " + - "possible replay attack"); + "possible replay attack", ocspValidationInfo); } } 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 index 1e6c1689..65865004 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/ResilientOcspService.java +++ b/src/main/java/eu/webeid/security/validator/ocsp/ResilientOcspService.java @@ -34,21 +34,18 @@ import io.github.resilience4j.decorators.Decorators; import io.vavr.CheckedFunction0; import io.vavr.control.Try; +import org.bouncycastle.asn1.ocsp.OCSPObjectIdentifiers; import org.bouncycastle.asn1.ocsp.OCSPResponseStatus; -import org.bouncycastle.cert.X509CertificateHolder; -import org.bouncycastle.cert.ocsp.BasicOCSPResp; +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.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.cert.CertificateEncodingException; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.time.Duration; @@ -85,16 +82,16 @@ public ResilientOcspService(OcspClient ocspClient, OcspServiceProvider ocspServi } } - public OcspService validateSubjectCertificateNotRevoked(X509Certificate subjectCertificate, X509Certificate issuerCertificate) throws AuthTokenException { + 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); - CheckedFunction0 decoratedSupplier = Decorators.ofCheckedSupplier(primarySupplier) + CheckedFunction0 primarySupplier = () -> request(ocspService, subjectCertificate, issuerCertificate); + CheckedFunction0 fallbackSupplier = () -> request(ocspService.getFallbackService(), subjectCertificate, issuerCertificate); + CheckedFunction0 decoratedSupplier = Decorators.ofCheckedSupplier(primarySupplier) .withCircuitBreaker(circuitBreaker) - .withFallback(List.of(UserCertificateOCSPCheckFailedException.class, CallNotPermittedException.class, UserCertificateUnknownException.class), e -> fallbackSupplier.apply()) // TODO: Any other exceptions to trigger fallback? Resilience4j does not support Predicate shouldFallback = e -> !(e instanceof UserCertificateRevokedException); in withFallback API. + .withFallback(List.of(UserCertificateOCSPCheckFailedException.class, CallNotPermittedException.class, UserCertificateUnknownException.class), e -> fallbackSupplier.apply()) .decorate(); return Try.of(decoratedSupplier).getOrElseThrow(throwable -> { @@ -108,10 +105,10 @@ public OcspService validateSubjectCertificateNotRevoked(X509Certificate subjectC } } - private OcspService request(OcspService ocspService, X509Certificate subjectCertificate, X509Certificate issuerCertificate) throws AuthTokenException { + private OcspValidationInfo request(OcspService ocspService, X509Certificate subjectCertificate, X509Certificate issuerCertificate) throws AuthTokenException { + OCSPResp response = null; try { - final CertificateID certificateId = getCertificateId(subjectCertificate, issuerCertificate); - + final CertificateID certificateId = OcspResponseValidator.getCertificateId(subjectCertificate, issuerCertificate); final OCSPReq request = new OcspRequestBuilder() .withCertificateId(certificateId) .enableOcspNonce(ocspService.doesSupportNonce()) @@ -122,35 +119,24 @@ private OcspService request(OcspService ocspService, X509Certificate subjectCert } LOG.debug("Sending OCSP request"); - final OCSPResp response = Objects.requireNonNull(ocspClient.request(ocspService.getAccessLocation(), request)); // TODO: This should trigger fallback? + 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())); - } - - final BasicOCSPResp basicResponse = (BasicOCSPResp) response.getResponseObject(); - if (basicResponse == null) { - throw new UserCertificateOCSPCheckFailedException("Missing Basic OCSP Response"); + throw new UserCertificateOCSPCheckFailedException("Response status: " + OcspResponseValidator.ocspStatusToString(response.getStatus()), + new OcspValidationInfo(subjectCertificate, ocspService.getAccessLocation(), response)); } - OcspResponseValidator.validateOcspResponse(basicResponse, ocspService, allowedOcspResponseTimeSkew, maxOcspResponseThisUpdateAge, rejectUnknownOcspResponseStatus, certificateId); + 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"); - if (ocspService.doesSupportNonce()) { - OcspResponseValidator.validateNonce(request, basicResponse); - } - return ocspService; + return ocspValidationInfo; } catch (OCSPException | CertificateException | OperatorCreationException | IOException e) { - throw new UserCertificateOCSPCheckFailedException(e); + throw new UserCertificateOCSPCheckFailedException(e, new OcspValidationInfo(subjectCertificate, ocspService.getAccessLocation(), response)); } } - 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 CircuitBreakerConfig getCircuitBreakerConfig(CircuitBreakerConfig circuitBreakerConfig) { CircuitBreakerConfig.Builder configurationBuilder = CircuitBreakerConfig.custom() // TODO: What are good default values here? .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED) 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 46dcc057..65e38503 100644 --- a/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidatorTest.java +++ b/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidatorTest.java @@ -259,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"); } } @@ -271,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"); } 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 index a0a01687..0ae0e2f4 100644 --- a/src/test/java/eu/webeid/security/validator/ocsp/ResilientOcspServiceTest.java +++ b/src/test/java/eu/webeid/security/validator/ocsp/ResilientOcspServiceTest.java @@ -23,7 +23,6 @@ package eu.webeid.security.validator.ocsp; import eu.webeid.security.certificate.CertificateValidator; -import eu.webeid.security.exceptions.AuthTokenException; import eu.webeid.security.exceptions.UserCertificateOCSPCheckFailedException; import eu.webeid.security.exceptions.UserCertificateRevokedException; import eu.webeid.security.exceptions.UserCertificateUnknownException; @@ -216,9 +215,14 @@ void whenOcspResponseGood_thenNoFallbackAndSucceeds() throws Exception { try (var mockedClock = mockStatic(DateAndTime.DefaultClock.class)) { mockDate("2021-09-17T18:25:24", mockedClock); - assertThatCode(() -> - resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate) - ).doesNotThrowAnyException(); + 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()); @@ -250,7 +254,12 @@ void whenOcspResponseRevoked_thenNoFallbackAndThrows() throws Exception { assertThatExceptionOfType(UserCertificateRevokedException.class) .isThrownBy(() -> resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate)) - .withMessage("User certificate has been revoked: Revocation reason: 0"); + .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()); @@ -282,7 +291,12 @@ void whenOcspResponseUnknown_thenNoFallbackAndThrows() throws Exception { assertThatExceptionOfType(UserCertificateRevokedException.class) .isThrownBy(() -> resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate)) - .withMessage("User certificate has been revoked: Unknown status"); + .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()); @@ -311,9 +325,14 @@ void whenPrimaryOcspResponseUnknownAndRejectUnknownOcspResponseStatusConfigurati try (var mockedClock = mockStatic(DateAndTime.DefaultClock.class)) { mockDate("2021-09-17T18:25:24", mockedClock); - assertThatCode(() -> - resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate) - ).doesNotThrowAnyException(); + 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()); @@ -351,7 +370,13 @@ void whenPrimaryAndFallbackRevocationStatusUnknownAndRejectUnknownOcspResponseSt assertThatExceptionOfType(UserCertificateUnknownException.class) .isThrownBy(() -> resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate)) - .withMessage("User certificate has been revoked: Unknown status"); + .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()); @@ -384,7 +409,10 @@ void whenPrimaryAndFallbackConnectionFail_thenThrows() throws Exception { .isThrownBy(() -> resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate)) .withMessage("User certificate revocation check has failed") - .withCause(new IOException("Mocked exception 2")); + .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()); @@ -409,9 +437,14 @@ void whenNoFallbackConfigured_thenPrimaryFailureThrows() throws Exception { try (var mockedClock = mockStatic(DateAndTime.DefaultClock.class)) { mockDate("2021-09-17T18:25:24", mockedClock); - assertThatCode(() -> - resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate) - ).isInstanceOf(AuthTokenException.class); + 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()); } } From 27121014b53a8e7af787b13723569cd4364ede2d Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Tue, 11 Nov 2025 10:34:50 +0200 Subject: [PATCH 4/5] Only perform OCSP check after signature validation to avoid unnecessary OCSP requests and require adding OcspValidationInfo on signature validation exceptions. --- .../validator/AuthTokenValidatorImpl.java | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java index 55d5e14f..6acd45f6 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java @@ -163,7 +163,7 @@ private ValidationInfo validateToken(WebEidAuthToken token, String currentChalle final X509Certificate subjectCertificate = CertificateLoader.decodeCertificateFromBase64(token.getUnverifiedCertificate()); simpleSubjectCertificateValidators.executeFor(subjectCertificate); - OcspValidationInfo ocspValidationInfo = validateCertificateTrust(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. @@ -172,6 +172,7 @@ private ValidationInfo validateToken(WebEidAuthToken token, String currentChalle subjectCertificate.getPublicKey(), currentChallengeNonce); + final OcspValidationInfo ocspValidationInfo = validateCertificateRevocationStatus(certTrustedValidator, subjectCertificate); return new ValidationInfo(subjectCertificate, ocspValidationInfo); } @@ -183,12 +184,16 @@ private ValidationInfo validateToken(WebEidAuthToken token, String currentChalle * * @return ocsp validation information if revocation check is performed, null otherwise */ - private OcspValidationInfo validateCertificateTrust(X509Certificate subjectCertificate) throws AuthTokenException { - final SubjectCertificateTrustedValidator certTrustedValidator = - new SubjectCertificateTrustedValidator(trustedCACertificateAnchors, trustedCACertificateCertStore); + private SubjectCertificateTrustedValidator validateCertificateTrust(X509Certificate subjectCertificate) throws AuthTokenException { + SubjectCertificateTrustedValidator certTrustedValidator = new SubjectCertificateTrustedValidator(trustedCACertificateAnchors, trustedCACertificateCertStore); certTrustedValidator.validateCertificateTrusted(subjectCertificate); - return configuration.isUserCertificateRevocationCheckWithOcspEnabled() ? new SubjectCertificateNotRevokedValidator(resilientOcspService, certTrustedValidator) - .validateCertificateNotRevoked(subjectCertificate) : null; + return certTrustedValidator; } + private OcspValidationInfo validateCertificateRevocationStatus(SubjectCertificateTrustedValidator certTrustedValidator, X509Certificate subjectCertificate) throws AuthTokenException { + return configuration.isUserCertificateRevocationCheckWithOcspEnabled() + ? new SubjectCertificateNotRevokedValidator(resilientOcspService, certTrustedValidator) + .validateCertificateNotRevoked(subjectCertificate) + : null; + } } From 09c25269db337a2b5c31a5e981f49b50e3ac9cf4 Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Thu, 13 Nov 2025 10:38:57 +0200 Subject: [PATCH 5/5] Add Retry option for primary OCSP service --- pom.xml | 4 -- .../AuthTokenValidationConfiguration.java | 11 +++++ .../validator/AuthTokenValidatorBuilder.java | 13 ++++- .../validator/AuthTokenValidatorImpl.java | 1 + .../validator/ocsp/ResilientOcspService.java | 28 +++++++++-- ...ectCertificateNotRevokedValidatorTest.java | 1 + .../ocsp/ResilientOcspServiceTest.java | 48 +++++++++++++++++++ 7 files changed, 96 insertions(+), 10 deletions(-) diff --git a/pom.xml b/pom.xml index e333bbd5..b63ccbb0 100644 --- a/pom.xml +++ b/pom.xml @@ -71,10 +71,6 @@ resilience4j-all ${resilience4j.version} - - io.github.resilience4j - resilience4j-retry - io.github.resilience4j resilience4j-bulkhead diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java index 2dc1a83c..5a6fe66d 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java @@ -26,6 +26,7 @@ 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.retry.RetryConfig; import org.bouncycastle.asn1.ASN1ObjectIdentifier; import java.net.MalformedURLException; @@ -56,6 +57,7 @@ public final class AuthTokenValidationConfiguration { private DesignatedOcspServiceConfiguration designatedOcspServiceConfiguration; private Collection fallbackOcspServiceConfigurations = new HashSet<>(); private CircuitBreakerConfig circuitBreakerConfig; + private RetryConfig circuitBreakerRetryConfig; // Don't allow Estonian Mobile-ID policy by default. private Collection disallowedSubjectCertificatePolicies = newHashSet( SubjectCertificatePolicies.ESTEID_SK_2015_MOBILE_ID_POLICY_V1, @@ -79,6 +81,7 @@ private AuthTokenValidationConfiguration(AuthTokenValidationConfiguration other) this.designatedOcspServiceConfiguration = other.designatedOcspServiceConfiguration; this.fallbackOcspServiceConfigurations = Set.copyOf(other.fallbackOcspServiceConfigurations); this.circuitBreakerConfig = other.circuitBreakerConfig; + this.circuitBreakerRetryConfig = other.circuitBreakerRetryConfig; this.disallowedSubjectCertificatePolicies = Set.copyOf(other.disallowedSubjectCertificatePolicies); this.nonceDisabledOcspUrls = Set.copyOf(other.nonceDisabledOcspUrls); } @@ -155,6 +158,14 @@ 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; } diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java index 3cb8f6ea..21752834 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java @@ -29,6 +29,7 @@ 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; @@ -212,7 +213,6 @@ public AuthTokenValidatorBuilder withFallbackOcspServiceConfiguration(FallbackOc * @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. @@ -227,6 +227,17 @@ public AuthTokenValidatorBuilder withCircuitBreakerConfig(int slidingWindowSize, 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 * diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java index 6acd45f6..7c408794 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java @@ -98,6 +98,7 @@ final class AuthTokenValidatorImpl implements AuthTokenValidator { configuration.getFallbackOcspServiceConfigurations()); resilientOcspService = new ResilientOcspService(ocspClient, ocspServiceProvider, configuration.getCircuitBreakerConfig(), + configuration.getCircuitBreakerRetryConfig(), configuration.getAllowedOcspResponseTimeSkew(), configuration.getMaxOcspResponseThisUpdateAge(), configuration.isRejectUnknownOcspResponseStatus()); diff --git a/src/main/java/eu/webeid/security/validator/ocsp/ResilientOcspService.java b/src/main/java/eu/webeid/security/validator/ocsp/ResilientOcspService.java index 65865004..1df16916 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/ResilientOcspService.java +++ b/src/main/java/eu/webeid/security/validator/ocsp/ResilientOcspService.java @@ -32,6 +32,9 @@ 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; @@ -61,8 +64,9 @@ public class ResilientOcspService { private final Duration maxOcspResponseThisUpdateAge; private final boolean rejectUnknownOcspResponseStatus; private final CircuitBreakerRegistry circuitBreakerRegistry; + private final RetryRegistry retryRegistry; - public ResilientOcspService(OcspClient ocspClient, OcspServiceProvider ocspServiceProvider, CircuitBreakerConfig circuitBreakerConfig, Duration allowedOcspResponseTimeSkew, Duration maxOcspResponseThisUpdateAge, boolean rejectUnknownOcspResponseStatus) { + 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; @@ -71,6 +75,9 @@ public ResilientOcspService(OcspClient ocspClient, OcspServiceProvider ocspServi 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 -> { @@ -89,10 +96,15 @@ public OcspValidationInfo validateSubjectCertificateNotRevoked(X509Certificate s CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(ocspService.getAccessLocation().toASCIIString()); CheckedFunction0 primarySupplier = () -> request(ocspService, subjectCertificate, issuerCertificate); CheckedFunction0 fallbackSupplier = () -> request(ocspService.getFallbackService(), subjectCertificate, issuerCertificate); - CheckedFunction0 decoratedSupplier = Decorators.ofCheckedSupplier(primarySupplier) - .withCircuitBreaker(circuitBreaker) - .withFallback(List.of(UserCertificateOCSPCheckFailedException.class, CallNotPermittedException.class, UserCertificateUnknownException.class), e -> fallbackSupplier.apply()) - .decorate(); + 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) { @@ -156,6 +168,12 @@ private static CircuitBreakerConfig getCircuitBreakerConfig(CircuitBreakerConfig 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/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidatorTest.java b/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidatorTest.java index 65e38503..f25daa4b 100644 --- a/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidatorTest.java +++ b/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidatorTest.java @@ -353,6 +353,7 @@ private SubjectCertificateNotRevokedValidator getSubjectCertificateNotRevokedVal private SubjectCertificateNotRevokedValidator getSubjectCertificateNotRevokedValidator(OcspClient client, OcspServiceProvider ocspServiceProvider) { ResilientOcspService resilientOcspService = new ResilientOcspService(client, ocspServiceProvider, + null, null, CONFIGURATION.getAllowedOcspResponseTimeSkew(), CONFIGURATION.getMaxOcspResponseThisUpdateAge(), diff --git a/src/test/java/eu/webeid/security/validator/ocsp/ResilientOcspServiceTest.java b/src/test/java/eu/webeid/security/validator/ocsp/ResilientOcspServiceTest.java index 0ae0e2f4..88b0457e 100644 --- a/src/test/java/eu/webeid/security/validator/ocsp/ResilientOcspServiceTest.java +++ b/src/test/java/eu/webeid/security/validator/ocsp/ResilientOcspServiceTest.java @@ -32,6 +32,7 @@ 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; @@ -121,6 +122,7 @@ void whenFallbackConfigured_thenFallbackAndRecoverySucceeds() throws Exception { ocspClient, ocspServiceProvider, circuitBreakerConfig, + null, ALLOWED_TIME_SKEW, MAX_THIS_UPDATE_AGE, false @@ -206,6 +208,7 @@ void whenOcspResponseGood_thenNoFallbackAndSucceeds() throws Exception { ocspClient, ocspServiceProvider, null, + null, ALLOWED_TIME_SKEW, MAX_THIS_UPDATE_AGE, false @@ -230,6 +233,45 @@ void whenOcspResponseGood_thenNoFallbackAndSucceeds() throws Exception { } } + @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); @@ -242,6 +284,7 @@ void whenOcspResponseRevoked_thenNoFallbackAndThrows() throws Exception { ocspClient, ocspServiceProvider, null, + null, ALLOWED_TIME_SKEW, MAX_THIS_UPDATE_AGE, false @@ -279,6 +322,7 @@ void whenOcspResponseUnknown_thenNoFallbackAndThrows() throws Exception { ocspClient, ocspServiceProvider, null, + null, ALLOWED_TIME_SKEW, MAX_THIS_UPDATE_AGE, false @@ -316,6 +360,7 @@ void whenPrimaryOcspResponseUnknownAndRejectUnknownOcspResponseStatusConfigurati ocspClient, ocspServiceProvider, null, + null, ALLOWED_TIME_SKEW, MAX_THIS_UPDATE_AGE, true // rejectUnknownOcspResponseStatus @@ -358,6 +403,7 @@ void whenPrimaryAndFallbackRevocationStatusUnknownAndRejectUnknownOcspResponseSt ocspClient, ocspServiceProvider, null, + null, ALLOWED_TIME_SKEW, MAX_THIS_UPDATE_AGE, true // rejectUnknownOcspResponseStatus @@ -396,6 +442,7 @@ void whenPrimaryAndFallbackConnectionFail_thenThrows() throws Exception { ocspClient, ocspServiceProvider, null, + null, ALLOWED_TIME_SKEW, MAX_THIS_UPDATE_AGE, false @@ -430,6 +477,7 @@ void whenNoFallbackConfigured_thenPrimaryFailureThrows() throws Exception { ocspClient, ocspServiceProvider, null, + null, ALLOWED_TIME_SKEW, MAX_THIS_UPDATE_AGE, false