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/pom.xml b/pom.xml index 2816da7c..b63ccbb0 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,29 @@ bcpkix-jdk18on ${bouncycastle.version} + + io.github.resilience4j + resilience4j-all + ${resilience4j.version} + + + io.github.resilience4j + resilience4j-bulkhead + + + io.github.resilience4j + resilience4j-cache + + + io.github.resilience4j + resilience4j-ratelimiter + + + io.github.resilience4j + resilience4j-timelimiter + + + org.junit.jupiter @@ -90,6 +114,12 @@ ${slf4j.version} test + + org.awaitility + awaitility + 4.3.0 + test + 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 new file mode 100644 index 00000000..7cd6ad81 --- /dev/null +++ b/src/main/java/eu/webeid/security/exceptions/UserCertificateUnknownException.java @@ -0,0 +1,41 @@ +/* + * 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; + +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, OcspValidationInfo ocspValidationInfo) { + super(msg); + this.ocspValidationInfo = ocspValidationInfo; + } + + public OcspValidationInfo getOcspValidationInfo() { + return ocspValidationInfo; + } +} diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java index 6b943bd2..5a6fe66d 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java @@ -24,6 +24,9 @@ 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 io.github.resilience4j.retry.RetryConfig; import org.bouncycastle.asn1.ASN1ObjectIdentifier; import java.net.MalformedURLException; @@ -50,7 +53,11 @@ 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; + private RetryConfig circuitBreakerRetryConfig; // Don't allow Estonian Mobile-ID policy by default. private Collection disallowedSubjectCertificatePolicies = newHashSet( SubjectCertificatePolicies.ESTEID_SK_2015_MOBILE_ID_POLICY_V1, @@ -70,7 +77,11 @@ 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; + this.circuitBreakerRetryConfig = other.circuitBreakerRetryConfig; this.disallowedSubjectCertificatePolicies = Set.copyOf(other.disallowedSubjectCertificatePolicies); this.nonceDisabledOcspUrls = Set.copyOf(other.nonceDisabledOcspUrls); } @@ -135,6 +146,34 @@ public Collection getNonceDisabledOcspUrls() { return nonceDisabledOcspUrls; } + public Collection getFallbackOcspServiceConfigurations() { + return fallbackOcspServiceConfigurations; + } + + public CircuitBreakerConfig getCircuitBreakerConfig() { + return circuitBreakerConfig; + } + + public void setCircuitBreakerConfig(CircuitBreakerConfig circuitBreakerConfig) { + this.circuitBreakerConfig = circuitBreakerConfig; + } + + public RetryConfig getCircuitBreakerRetryConfig() { + return circuitBreakerRetryConfig; + } + + public void setCircuitBreakerRetryConfig(RetryConfig circuitBreakerRetryConfig) { + this.circuitBreakerRetryConfig = circuitBreakerRetryConfig; + } + + public boolean isRejectUnknownOcspResponseStatus() { + return rejectUnknownOcspResponseStatus; + } + + public void setRejectUnknownOcspResponseStatus(boolean rejectUnknownOcspResponseStatus) { + this.rejectUnknownOcspResponseStatus = rejectUnknownOcspResponseStatus; + } + /** * Checks that the configuration parameters are valid. * @@ -150,6 +189,7 @@ void validate() { requirePositiveDuration(ocspRequestTimeout, "OCSP request timeout"); requirePositiveDuration(allowedOcspResponseTimeSkew, "Allowed OCSP response time-skew"); requirePositiveDuration(maxOcspResponseThisUpdateAge, "Max OCSP response thisUpdate age"); + // TODO: Add OCSP fallback/response validation } AuthTokenValidationConfiguration copy() { diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidator.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidator.java index 3476ea41..8cb95a8b 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenValidator.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidator.java @@ -57,6 +57,6 @@ public interface AuthTokenValidator { * @return validated subject certificate * @throws AuthTokenException when validation fails */ - X509Certificate validate(WebEidAuthToken authToken, String currentChallengeNonce) throws AuthTokenException; + ValidationInfo validate(WebEidAuthToken authToken, String currentChallengeNonce) throws AuthTokenException; } diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java index 9122ee67..21752834 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java @@ -26,6 +26,10 @@ import eu.webeid.security.validator.ocsp.OcspClient; import eu.webeid.security.validator.ocsp.OcspClientImpl; import eu.webeid.security.validator.ocsp.service.DesignatedOcspServiceConfiguration; +import eu.webeid.security.validator.ocsp.service.FallbackOcspServiceConfiguration; +import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig; +import io.github.resilience4j.core.IntervalFunction; +import io.github.resilience4j.retry.RetryConfig; import org.bouncycastle.asn1.ASN1ObjectIdentifier; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -187,6 +191,65 @@ public AuthTokenValidatorBuilder withDesignatedOcspServiceConfiguration(Designat return this; } + /** + * // TODO: Describe the configuration option + * + * @param serviceConfiguration configurations of the fallback OCSP services + * @return the builder instance for method chaining + */ + public AuthTokenValidatorBuilder withFallbackOcspServiceConfiguration(FallbackOcspServiceConfiguration... serviceConfiguration) { + // TODO: Validate that no two configurations have the same OCSP service access location + Collections.addAll(configuration.getFallbackOcspServiceConfigurations(), serviceConfiguration); + LOG.debug("Fallback OCSP services set to {}", configuration.getFallbackOcspServiceConfigurations()); + return this; + } + + + /** + * // TODO: Describe the configuration option + * + * @param slidingWindowSize + * @param minimumNumberOfCalls + * @param failureRateThreshold + * @param permittedNumberOfCallsInHalfOpenState + * @param waitDurationInOpenState + * @return the builder instance for method chaining + */ + public AuthTokenValidatorBuilder withCircuitBreakerConfig(int slidingWindowSize, int minimumNumberOfCalls, int failureRateThreshold, int permittedNumberOfCallsInHalfOpenState, Duration waitDurationInOpenState) { // TODO: What do we allow to configure? Use configuration builder. + configuration.setCircuitBreakerConfig(CircuitBreakerConfig.custom() + .slidingWindowSize(slidingWindowSize) + .minimumNumberOfCalls(minimumNumberOfCalls) + .failureRateThreshold(failureRateThreshold) + .permittedNumberOfCallsInHalfOpenState(permittedNumberOfCallsInHalfOpenState) + .waitIntervalFunctionInOpenState(IntervalFunction.of(waitDurationInOpenState)) + .build()); + LOG.debug("Using the OCSP circuit breaker configuration"); + return this; + } + + /** + * // TODO: Describe the configuration option + * + * @return the builder instance for method chaining + */ + public AuthTokenValidatorBuilder withCircuitBreakerRetryConfig() { // TODO: What do we allow to configure? Use configuration builder. + configuration.setCircuitBreakerRetryConfig(RetryConfig.ofDefaults()); + LOG.debug("Using the OCSP circuit breaker retry configuration"); + return this; + } + + /** + * // TODO: Describe the configuration option + * + * @param rejectUnknownOcspResponseStatus configures whether only GOOD or REVOKED are accepted as valid OCSP response statuses + * @return the builder instance for method chaining + */ + public AuthTokenValidatorBuilder withRejectUnknownOcspResponseStatus(boolean rejectUnknownOcspResponseStatus) { + configuration.setRejectUnknownOcspResponseStatus(rejectUnknownOcspResponseStatus); + LOG.debug("Using the reject unknown OCSP response status validation configuration"); + return this; + } + /** * Uses the provided OCSP client instance during user certificate revocation check with OCSP. * The provided client instance must be thread-safe. diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java index 14cf3e78..7c408794 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java @@ -37,6 +37,8 @@ import eu.webeid.security.validator.certvalidators.SubjectCertificateValidatorBatch; import eu.webeid.security.validator.ocsp.OcspClient; import eu.webeid.security.validator.ocsp.OcspServiceProvider; +import eu.webeid.security.validator.ocsp.OcspValidationInfo; +import eu.webeid.security.validator.ocsp.ResilientOcspService; import eu.webeid.security.validator.ocsp.service.AiaOcspServiceConfiguration; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -65,9 +67,8 @@ final class AuthTokenValidatorImpl implements AuthTokenValidator { private final CertStore trustedCACertificateCertStore; // OcspClient uses built-in HttpClient internally by default. // A single HttpClient instance is reused for all HTTP calls to utilize connection and thread pools. - private OcspClient ocspClient; - private OcspServiceProvider ocspServiceProvider; private final AuthTokenSignatureValidator authTokenSignatureValidator; + private ResilientOcspService resilientOcspService; /** * @param configuration configuration parameters for the token validator @@ -88,12 +89,19 @@ final class AuthTokenValidatorImpl implements AuthTokenValidator { if (configuration.isUserCertificateRevocationCheckWithOcspEnabled()) { // The OCSP client may be provided by the API consumer. - this.ocspClient = Objects.requireNonNull(ocspClient, "OCSP client must not be null when OCSP check is enabled"); - ocspServiceProvider = new OcspServiceProvider( + Objects.requireNonNull(ocspClient, "OCSP client must not be null when OCSP check is enabled"); + OcspServiceProvider ocspServiceProvider = new OcspServiceProvider( configuration.getDesignatedOcspServiceConfiguration(), new AiaOcspServiceConfiguration(configuration.getNonceDisabledOcspUrls(), trustedCACertificateAnchors, - trustedCACertificateCertStore)); + trustedCACertificateCertStore), + configuration.getFallbackOcspServiceConfigurations()); + resilientOcspService = new ResilientOcspService(ocspClient, ocspServiceProvider, + configuration.getCircuitBreakerConfig(), + configuration.getCircuitBreakerRetryConfig(), + configuration.getAllowedOcspResponseTimeSkew(), + configuration.getMaxOcspResponseThisUpdateAge(), + configuration.isRejectUnknownOcspResponseStatus()); } authTokenSignatureValidator = new AuthTokenSignatureValidator(configuration.getSiteOrigin()); @@ -113,7 +121,7 @@ public WebEidAuthToken parse(String authToken) throws AuthTokenException { } @Override - public X509Certificate validate(WebEidAuthToken authToken, String currentChallengeNonce) throws AuthTokenException { + public ValidationInfo validate(WebEidAuthToken authToken, String currentChallengeNonce) throws AuthTokenException { try { LOG.info("Starting token validation"); return validateToken(authToken, currentChallengeNonce); @@ -145,7 +153,7 @@ private WebEidAuthToken parseToken(String authToken) throws AuthTokenParseExcept } } - private X509Certificate validateToken(WebEidAuthToken token, String currentChallengeNonce) throws AuthTokenException { + private ValidationInfo validateToken(WebEidAuthToken token, String currentChallengeNonce) throws AuthTokenException { if (token.getFormat() == null || !token.getFormat().startsWith(CURRENT_TOKEN_FORMAT_VERSION)) { throw new AuthTokenParseException("Only token format version '" + CURRENT_TOKEN_FORMAT_VERSION + "' is currently supported"); @@ -156,7 +164,7 @@ private X509Certificate validateToken(WebEidAuthToken token, String currentChall final X509Certificate subjectCertificate = CertificateLoader.decodeCertificateFromBase64(token.getUnverifiedCertificate()); simpleSubjectCertificateValidators.executeFor(subjectCertificate); - getCertTrustValidators().executeFor(subjectCertificate); + final SubjectCertificateTrustedValidator certTrustedValidator = validateCertificateTrust(subjectCertificate); // It is guaranteed that if the signature verification succeeds, then the origin and challenge // have been implicitly and correctly verified without the need to implement any additional checks. @@ -165,29 +173,28 @@ private X509Certificate validateToken(WebEidAuthToken token, String currentChall subjectCertificate.getPublicKey(), currentChallengeNonce); - return subjectCertificate; + final OcspValidationInfo ocspValidationInfo = validateCertificateRevocationStatus(certTrustedValidator, subjectCertificate); + return new ValidationInfo(subjectCertificate, ocspValidationInfo); } /** - * Creates the certificate trust validators batch. + * Validates the certificate trust and optionally the revocation status. * As SubjectCertificateTrustedValidator has mutable state that SubjectCertificateNotRevokedValidator depends on, * they cannot be reused/cached in an instance variable in a multi-threaded environment. Hence, they are * re-created for each validation run for thread safety. * - * @return certificate trust validator batch + * @return ocsp validation information if revocation check is performed, null otherwise */ - private SubjectCertificateValidatorBatch getCertTrustValidators() { - final SubjectCertificateTrustedValidator certTrustedValidator = - new SubjectCertificateTrustedValidator(trustedCACertificateAnchors, trustedCACertificateCertStore); - return SubjectCertificateValidatorBatch.createFrom( - certTrustedValidator::validateCertificateTrusted - ).addOptional(configuration.isUserCertificateRevocationCheckWithOcspEnabled(), - new SubjectCertificateNotRevokedValidator(certTrustedValidator, - ocspClient, ocspServiceProvider, - configuration.getAllowedOcspResponseTimeSkew(), - configuration.getMaxOcspResponseThisUpdateAge() - )::validateCertificateNotRevoked - ); + private SubjectCertificateTrustedValidator validateCertificateTrust(X509Certificate subjectCertificate) throws AuthTokenException { + SubjectCertificateTrustedValidator certTrustedValidator = new SubjectCertificateTrustedValidator(trustedCACertificateAnchors, trustedCACertificateCertStore); + certTrustedValidator.validateCertificateTrusted(subjectCertificate); + return certTrustedValidator; } + private OcspValidationInfo validateCertificateRevocationStatus(SubjectCertificateTrustedValidator certTrustedValidator, X509Certificate subjectCertificate) throws AuthTokenException { + return configuration.isUserCertificateRevocationCheckWithOcspEnabled() + ? new SubjectCertificateNotRevokedValidator(resilientOcspService, certTrustedValidator) + .validateCertificateNotRevoked(subjectCertificate) + : null; + } } diff --git a/src/main/java/eu/webeid/security/validator/ValidationInfo.java b/src/main/java/eu/webeid/security/validator/ValidationInfo.java new file mode 100644 index 00000000..88949fd4 --- /dev/null +++ b/src/main/java/eu/webeid/security/validator/ValidationInfo.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2020-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package eu.webeid.security.validator; + +import eu.webeid.security.validator.ocsp.OcspValidationInfo; + +import java.security.cert.X509Certificate; + +public class ValidationInfo { + private final X509Certificate subjectCertificate; + private final OcspValidationInfo ocspValidationInfo; + + public ValidationInfo(X509Certificate subjectCertificate, OcspValidationInfo ocspValidationInfo) { + this.subjectCertificate = subjectCertificate; + this.ocspValidationInfo = ocspValidationInfo; + } + + public X509Certificate getSubjectCertificate() { + return subjectCertificate; + } + + public OcspValidationInfo getOcspValidationInfo() { + return ocspValidationInfo; + } +} diff --git a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidator.java b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidator.java index c460d405..5956637e 100644 --- a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidator.java +++ b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidator.java @@ -23,64 +23,26 @@ package eu.webeid.security.validator.certvalidators; import eu.webeid.security.exceptions.AuthTokenException; -import eu.webeid.security.exceptions.UserCertificateOCSPCheckFailedException; -import eu.webeid.security.util.DateAndTime; -import eu.webeid.security.validator.ocsp.DigestCalculatorImpl; -import eu.webeid.security.validator.ocsp.OcspClient; -import eu.webeid.security.validator.ocsp.OcspRequestBuilder; -import eu.webeid.security.validator.ocsp.OcspResponseValidator; -import eu.webeid.security.validator.ocsp.OcspServiceProvider; -import eu.webeid.security.validator.ocsp.service.OcspService; -import org.bouncycastle.asn1.ocsp.OCSPObjectIdentifiers; -import org.bouncycastle.asn1.ocsp.OCSPResponseStatus; -import org.bouncycastle.asn1.x509.Extension; -import org.bouncycastle.cert.X509CertificateHolder; -import org.bouncycastle.cert.ocsp.BasicOCSPResp; -import org.bouncycastle.cert.ocsp.CertificateID; -import org.bouncycastle.cert.ocsp.OCSPException; -import org.bouncycastle.cert.ocsp.OCSPReq; -import org.bouncycastle.cert.ocsp.OCSPResp; -import org.bouncycastle.cert.ocsp.SingleResp; +import eu.webeid.security.validator.ocsp.OcspValidationInfo; +import eu.webeid.security.validator.ocsp.ResilientOcspService; import org.bouncycastle.jce.provider.BouncyCastleProvider; -import org.bouncycastle.operator.DigestCalculator; -import org.bouncycastle.operator.OperatorCreationException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import java.io.IOException; -import java.math.BigInteger; import java.security.Security; -import java.security.cert.CertificateEncodingException; -import java.security.cert.CertificateException; import java.security.cert.X509Certificate; -import java.time.Duration; -import java.util.Date; import java.util.Objects; public final class SubjectCertificateNotRevokedValidator { - private static final Logger LOG = LoggerFactory.getLogger(SubjectCertificateNotRevokedValidator.class); - private final SubjectCertificateTrustedValidator trustValidator; - private final OcspClient ocspClient; - private final OcspServiceProvider ocspServiceProvider; - private final Duration allowedOcspResponseTimeSkew; - private final Duration maxOcspResponseThisUpdateAge; + private final ResilientOcspService resilientOcspService; static { Security.addProvider(new BouncyCastleProvider()); } - public SubjectCertificateNotRevokedValidator(SubjectCertificateTrustedValidator trustValidator, - OcspClient ocspClient, - OcspServiceProvider ocspServiceProvider, - Duration allowedOcspResponseTimeSkew, - Duration maxOcspResponseThisUpdateAge) { + public SubjectCertificateNotRevokedValidator(ResilientOcspService resilientOcspService, SubjectCertificateTrustedValidator trustValidator) { + this.resilientOcspService = resilientOcspService; this.trustValidator = trustValidator; - this.ocspClient = ocspClient; - this.ocspServiceProvider = ocspServiceProvider; - this.allowedOcspResponseTimeSkew = allowedOcspResponseTimeSkew; - this.maxOcspResponseThisUpdateAge = maxOcspResponseThisUpdateAge; } /** @@ -89,133 +51,8 @@ public SubjectCertificateNotRevokedValidator(SubjectCertificateTrustedValidator * @param subjectCertificate user certificate to be validated * @throws AuthTokenException when user certificate is revoked or revocation check fails. */ - public void validateCertificateNotRevoked(X509Certificate subjectCertificate) throws AuthTokenException { - try { - OcspService ocspService = ocspServiceProvider.getService(subjectCertificate); - - final CertificateID certificateId = getCertificateId(subjectCertificate, - Objects.requireNonNull(trustValidator.getSubjectCertificateIssuerCertificate())); - - final OCSPReq request = new OcspRequestBuilder() - .withCertificateId(certificateId) - .enableOcspNonce(ocspService.doesSupportNonce()) - .build(); - - if (!ocspService.doesSupportNonce()) { - LOG.debug("Disabling OCSP nonce extension"); - } - - LOG.debug("Sending OCSP request"); - final OCSPResp response = Objects.requireNonNull(ocspClient.request(ocspService.getAccessLocation(), request)); - if (response.getStatus() != OCSPResponseStatus.SUCCESSFUL) { - throw new UserCertificateOCSPCheckFailedException("Response status: " + ocspStatusToString(response.getStatus())); - } - - final BasicOCSPResp basicResponse = (BasicOCSPResp) response.getResponseObject(); - if (basicResponse == null) { - throw new UserCertificateOCSPCheckFailedException("Missing Basic OCSP Response"); - } - verifyOcspResponse(basicResponse, ocspService, certificateId); - if (ocspService.doesSupportNonce()) { - checkNonce(request, basicResponse); - } - } catch (OCSPException | CertificateException | OperatorCreationException | IOException e) { - throw new UserCertificateOCSPCheckFailedException(e); - } + public OcspValidationInfo validateCertificateNotRevoked(X509Certificate subjectCertificate) throws AuthTokenException { + final X509Certificate issuerCertificate = Objects.requireNonNull(trustValidator.getSubjectCertificateIssuerCertificate()); + return resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate); } - - private void verifyOcspResponse(BasicOCSPResp basicResponse, OcspService ocspService, CertificateID requestCertificateId) throws AuthTokenException, OCSPException, CertificateException, OperatorCreationException { - // The verification algorithm follows RFC 2560, https://www.ietf.org/rfc/rfc2560.txt. - // - // 3.2. Signed Response Acceptance Requirements - // Prior to accepting a signed response for a particular certificate as - // valid, OCSP clients SHALL confirm that: - // - // 1. The certificate identified in a received response corresponds to - // the certificate that was identified in the corresponding request. - - // As we sent the request for only a single certificate, we expect only a single response. - if (basicResponse.getResponses().length != 1) { - throw new UserCertificateOCSPCheckFailedException("OCSP response must contain one response, " - + "received " + basicResponse.getResponses().length + " responses instead"); - } - final SingleResp certStatusResponse = basicResponse.getResponses()[0]; - if (!requestCertificateId.equals(certStatusResponse.getCertID())) { - throw new UserCertificateOCSPCheckFailedException("OCSP responded with certificate ID that differs from the requested ID"); - } - - // 2. The signature on the response is valid. - - // We assume that the responder includes its certificate in the certs field of the response - // that helps us to verify it. According to RFC 2560 this field is optional, but including it - // is standard practice. - if (basicResponse.getCerts().length < 1) { - throw new UserCertificateOCSPCheckFailedException("OCSP response must contain the responder certificate, " - + "but none was provided"); - } - // The first certificate is the responder certificate, other certificates, if given, are the certificate's chain. - final X509CertificateHolder responderCert = basicResponse.getCerts()[0]; - OcspResponseValidator.validateResponseSignature(basicResponse, responderCert); - - // 3. The identity of the signer matches the intended recipient of the - // request. - // - // 4. The signer is currently authorized to provide a response for the - // certificate in question. - - // Use the clock instance so that the date can be mocked in tests. - final Date now = DateAndTime.DefaultClock.getInstance().now(); - ocspService.validateResponderCertificate(responderCert, now); - - // 5. The time at which the status being indicated is known to be - // correct (thisUpdate) is sufficiently recent. - // - // 6. When available, the time at or before which newer information will - // be available about the status of the certificate (nextUpdate) is - // greater than the current time. - - OcspResponseValidator.validateCertificateStatusUpdateTime(certStatusResponse, allowedOcspResponseTimeSkew, maxOcspResponseThisUpdateAge); - - // Now we can accept the signed response as valid and validate the certificate status. - OcspResponseValidator.validateSubjectCertificateStatus(certStatusResponse); - LOG.debug("OCSP check result is GOOD"); - } - - private static void checkNonce(OCSPReq request, BasicOCSPResp response) throws UserCertificateOCSPCheckFailedException { - final Extension requestNonce = request.getExtension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce); - final Extension responseNonce = response.getExtension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce); - if (requestNonce == null || responseNonce == null) { - throw new UserCertificateOCSPCheckFailedException("OCSP request or response nonce extension missing, " + - "possible replay attack"); - } - if (!requestNonce.equals(responseNonce)) { - throw new UserCertificateOCSPCheckFailedException("OCSP request and response nonces differ, " + - "possible replay attack"); - } - } - - private static CertificateID getCertificateId(X509Certificate subjectCertificate, X509Certificate issuerCertificate) throws CertificateEncodingException, IOException, OCSPException { - final BigInteger serial = subjectCertificate.getSerialNumber(); - final DigestCalculator digestCalculator = DigestCalculatorImpl.sha1(); - return new CertificateID(digestCalculator, - new X509CertificateHolder(issuerCertificate.getEncoded()), serial); - } - - private static String ocspStatusToString(int status) { - switch (status) { - case OCSPResp.MALFORMED_REQUEST: - return "malformed request"; - case OCSPResp.INTERNAL_ERROR: - return "internal error"; - case OCSPResp.TRY_LATER: - return "service unavailable"; - case OCSPResp.SIG_REQUIRED: - return "request signature missing"; - case OCSPResp.UNAUTHORIZED: - return "unauthorized"; - default: - return "unknown"; - } - } - } diff --git a/src/main/java/eu/webeid/security/validator/ocsp/OcspResponseValidator.java b/src/main/java/eu/webeid/security/validator/ocsp/OcspResponseValidator.java index 0dc4fda5..ecad7048 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/OcspResponseValidator.java +++ b/src/main/java/eu/webeid/security/validator/ocsp/OcspResponseValidator.java @@ -22,30 +22,134 @@ package eu.webeid.security.validator.ocsp; +import eu.webeid.security.exceptions.AuthTokenException; import eu.webeid.security.exceptions.OCSPCertificateException; import eu.webeid.security.exceptions.UserCertificateOCSPCheckFailedException; import eu.webeid.security.exceptions.UserCertificateRevokedException; +import eu.webeid.security.exceptions.UserCertificateUnknownException; import eu.webeid.security.util.DateAndTime; +import eu.webeid.security.validator.ocsp.service.OcspService; +import org.bouncycastle.asn1.ocsp.OCSPObjectIdentifiers; +import org.bouncycastle.asn1.x509.Extension; import org.bouncycastle.cert.X509CertificateHolder; import org.bouncycastle.cert.ocsp.BasicOCSPResp; +import org.bouncycastle.cert.ocsp.CertificateID; import org.bouncycastle.cert.ocsp.CertificateStatus; import org.bouncycastle.cert.ocsp.OCSPException; +import org.bouncycastle.cert.ocsp.OCSPReq; +import org.bouncycastle.cert.ocsp.OCSPResp; import org.bouncycastle.cert.ocsp.RevokedStatus; import org.bouncycastle.cert.ocsp.SingleResp; import org.bouncycastle.cert.ocsp.UnknownStatus; import org.bouncycastle.operator.ContentVerifierProvider; +import org.bouncycastle.operator.DigestCalculator; import org.bouncycastle.operator.OperatorCreationException; import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder; +import java.io.IOException; +import java.math.BigInteger; +import java.security.cert.CertificateEncodingException; import java.security.cert.CertificateException; import java.security.cert.CertificateParsingException; import java.security.cert.X509Certificate; import java.time.Duration; import java.time.Instant; +import java.util.Date; import java.util.Objects; public final class OcspResponseValidator { + public static OcspValidationInfo validateOcspResponse(OCSPResp ocspResp, OcspService ocspService, Extension requestNonce, + X509Certificate subjectCertificate, X509Certificate issuerCertificate, + Duration allowedOcspResponseTimeSkew, Duration maxOcspResponseThisUpdateAge, + boolean rejectUnknownOcspResponseStatus) throws AuthTokenException, OCSPException, CertificateException, OperatorCreationException { + final OcspValidationInfo ocspValidationInfo = new OcspValidationInfo(subjectCertificate, ocspService.getAccessLocation(), ocspResp); + final BasicOCSPResp basicResponse = (BasicOCSPResp) ocspResp.getResponseObject(); + if (basicResponse == null) { + throw new UserCertificateOCSPCheckFailedException("Missing Basic OCSP Response", ocspValidationInfo); + } + + // The verification algorithm follows RFC 2560, https://www.ietf.org/rfc/rfc2560.txt. + // + // 3.2. Signed Response Acceptance Requirements + // Prior to accepting a signed response for a particular certificate as + // valid, OCSP clients SHALL confirm that: + // + // 1. The certificate identified in a received response corresponds to + // the certificate that was identified in the corresponding request. + + // As we sent the request for only a single certificate, we expect only a single response. + if (basicResponse.getResponses().length != 1) { + throw new UserCertificateOCSPCheckFailedException("OCSP response must contain one response, " + + "received " + basicResponse.getResponses().length + " responses instead", ocspValidationInfo); + } + final CertificateID requestCertificateId = getCertificateId(subjectCertificate, issuerCertificate, ocspService, ocspResp); + final SingleResp certStatusResponse = basicResponse.getResponses()[0]; + if (!requestCertificateId.equals(certStatusResponse.getCertID())) { + throw new UserCertificateOCSPCheckFailedException("OCSP responded with certificate ID that differs from the requested ID", ocspValidationInfo); + } + + // 2. The signature on the response is valid. + + // We assume that the responder includes its certificate in the certs field of the response + // that helps us to verify it. According to RFC 2560 this field is optional, but including it + // is standard practice. + if (basicResponse.getCerts().length < 1) { + throw new UserCertificateOCSPCheckFailedException("OCSP response must contain the responder certificate, " + + "but none was provided", ocspValidationInfo); + } + // The first certificate is the responder certificate, other certificates, if given, are the certificate's chain. + final X509CertificateHolder responderCert = basicResponse.getCerts()[0]; + OcspResponseValidator.validateResponseSignature(basicResponse, responderCert, ocspValidationInfo); + + // 3. The identity of the signer matches the intended recipient of the + // request. + // + // 4. The signer is currently authorized to provide a response for the + // certificate in question. + + // Use the clock instance so that the date can be mocked in tests. + final Date now = DateAndTime.DefaultClock.getInstance().now(); + try { + ocspService.validateResponderCertificate(responderCert, now); + } catch (AuthTokenException e) { + throw new UserCertificateOCSPCheckFailedException(e, ocspValidationInfo); + } + + // 5. The time at which the status being indicated is known to be + // correct (thisUpdate) is sufficiently recent. + // + // 6. When available, the time at or before which newer information will + // be available about the status of the certificate (nextUpdate) is + // greater than the current time. + + OcspResponseValidator.validateCertificateStatusUpdateTime(certStatusResponse, allowedOcspResponseTimeSkew, maxOcspResponseThisUpdateAge, ocspValidationInfo); + + // Now we can accept the signed response as valid and validate the certificate status. + OcspResponseValidator.validateSubjectCertificateStatus(certStatusResponse, rejectUnknownOcspResponseStatus, ocspValidationInfo); + + if (ocspService.doesSupportNonce()) { + OcspResponseValidator.validateNonce(requestNonce, ocspResp, ocspValidationInfo); + } + + return ocspValidationInfo; + } + + private static CertificateID getCertificateId(X509Certificate subjectCertificate, X509Certificate issuerCertificate, OcspService ocspService, OCSPResp ocspResp) throws AuthTokenException { + try { + return getCertificateId(subjectCertificate, issuerCertificate); + } catch (CertificateEncodingException | IOException | OCSPException e) { + throw new UserCertificateOCSPCheckFailedException(e, new OcspValidationInfo(subjectCertificate, ocspService.getAccessLocation(), ocspResp)); + } + } + + public static CertificateID getCertificateId(X509Certificate subjectCertificate, X509Certificate issuerCertificate) throws CertificateEncodingException, IOException, OCSPException { + final BigInteger serial = subjectCertificate.getSerialNumber(); + final DigestCalculator digestCalculator = DigestCalculatorImpl.sha1(); + return new CertificateID(digestCalculator, + new X509CertificateHolder(issuerCertificate.getEncoded()), serial); + } + /** * Indicates that a X.509 Certificates corresponding private key may be used by an authority to sign OCSP responses. *

@@ -66,16 +170,16 @@ public static void validateHasSigningExtension(X509Certificate certificate) thro } } - public static void validateResponseSignature(BasicOCSPResp basicResponse, X509CertificateHolder responderCert) throws CertificateException, OperatorCreationException, OCSPException, UserCertificateOCSPCheckFailedException { + private static void validateResponseSignature(BasicOCSPResp basicResponse, X509CertificateHolder responderCert, OcspValidationInfo ocspValidationInfo) throws CertificateException, OperatorCreationException, OCSPException, UserCertificateOCSPCheckFailedException { final ContentVerifierProvider verifierProvider = new JcaContentVerifierProviderBuilder() .setProvider("BC") .build(responderCert); if (!basicResponse.isSignatureValid(verifierProvider)) { - throw new UserCertificateOCSPCheckFailedException("OCSP response signature is invalid"); + throw new UserCertificateOCSPCheckFailedException("OCSP response signature is invalid", ocspValidationInfo); } } - public static void validateCertificateStatusUpdateTime(SingleResp certStatusResponse, Duration allowedTimeSkew, Duration maxThisupdateAge) throws UserCertificateOCSPCheckFailedException { + static void validateCertificateStatusUpdateTime(SingleResp certStatusResponse, Duration allowedTimeSkew, Duration maxThisUpdateAge, OcspValidationInfo ocspValidationInfo) throws UserCertificateOCSPCheckFailedException { // From RFC 2560, https://www.ietf.org/rfc/rfc2560.txt: // 4.2.2. Notes on OCSP Responses // 4.2.2.1. Time @@ -88,18 +192,18 @@ public static void validateCertificateStatusUpdateTime(SingleResp certStatusResp final Instant now = DateAndTime.DefaultClock.getInstance().now().toInstant(); final Instant earliestAcceptableTimeSkew = now.minus(allowedTimeSkew); final Instant latestAcceptableTimeSkew = now.plus(allowedTimeSkew); - final Instant minimumValidThisUpdateTime = now.minus(maxThisupdateAge); + final Instant minimumValidThisUpdateTime = now.minus(maxThisUpdateAge); final Instant thisUpdate = certStatusResponse.getThisUpdate().toInstant(); if (thisUpdate.isAfter(latestAcceptableTimeSkew)) { throw new UserCertificateOCSPCheckFailedException(ERROR_PREFIX + "thisUpdate '" + thisUpdate + "' is too far in the future, " + - "latest allowed: '" + latestAcceptableTimeSkew + "'"); + "latest allowed: '" + latestAcceptableTimeSkew + "'", ocspValidationInfo); } if (thisUpdate.isBefore(minimumValidThisUpdateTime)) { throw new UserCertificateOCSPCheckFailedException(ERROR_PREFIX + "thisUpdate '" + thisUpdate + "' is too old, " + - "minimum time allowed: '" + minimumValidThisUpdateTime + "'"); + "minimum time allowed: '" + minimumValidThisUpdateTime + "'", ocspValidationInfo); } if (certStatusResponse.getNextUpdate() == null) { @@ -108,15 +212,15 @@ public static void validateCertificateStatusUpdateTime(SingleResp certStatusResp final Instant nextUpdate = certStatusResponse.getNextUpdate().toInstant(); if (nextUpdate.isBefore(earliestAcceptableTimeSkew)) { throw new UserCertificateOCSPCheckFailedException(ERROR_PREFIX + - "nextUpdate '" + nextUpdate + "' is in the past"); + "nextUpdate '" + nextUpdate + "' is in the past", ocspValidationInfo); } if (nextUpdate.isBefore(thisUpdate)) { throw new UserCertificateOCSPCheckFailedException(ERROR_PREFIX + - "nextUpdate '" + nextUpdate + "' is before thisUpdate '" + thisUpdate + "'"); + "nextUpdate '" + nextUpdate + "' is before thisUpdate '" + thisUpdate + "'", ocspValidationInfo); } } - public static void validateSubjectCertificateStatus(SingleResp certStatusResponse) throws UserCertificateRevokedException { + private static void validateSubjectCertificateStatus(SingleResp certStatusResponse, boolean rejectUnknownOcspResponseStatus, OcspValidationInfo ocspValidationInfo) throws AuthTokenException { final CertificateStatus status = certStatusResponse.getCertStatus(); if (status == null) { return; @@ -124,12 +228,47 @@ public static void validateSubjectCertificateStatus(SingleResp certStatusRespons if (status instanceof RevokedStatus) { RevokedStatus revokedStatus = (RevokedStatus) status; throw (revokedStatus.hasRevocationReason() ? - new UserCertificateRevokedException("Revocation reason: " + revokedStatus.getRevocationReason()) : - new UserCertificateRevokedException()); + new UserCertificateRevokedException("Revocation reason: " + revokedStatus.getRevocationReason(), ocspValidationInfo) : + new UserCertificateRevokedException(ocspValidationInfo)); } else if (status instanceof UnknownStatus) { - throw new UserCertificateRevokedException("Unknown status"); + throw rejectUnknownOcspResponseStatus ? new UserCertificateUnknownException("Unknown status", ocspValidationInfo) + : new UserCertificateRevokedException("Unknown status", ocspValidationInfo); } else { - throw new UserCertificateRevokedException("Status is neither good, revoked nor unknown"); + throw rejectUnknownOcspResponseStatus ? new UserCertificateUnknownException("Status is neither good, revoked nor unknown", ocspValidationInfo) + : new UserCertificateRevokedException("Status is neither good, revoked nor unknown", ocspValidationInfo); + } + } + + private static void validateNonce(Extension requestNonce, OCSPResp ocspResp, OcspValidationInfo ocspValidationInfo) throws UserCertificateOCSPCheckFailedException, OCSPException { + final BasicOCSPResp basicResponse = (BasicOCSPResp) ocspResp.getResponseObject(); + if (basicResponse == null) { + throw new UserCertificateOCSPCheckFailedException("Missing Basic OCSP Response", ocspValidationInfo); + } + final Extension responseNonce = basicResponse.getExtension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce); + if (requestNonce == null || responseNonce == null) { + throw new UserCertificateOCSPCheckFailedException("OCSP request or response nonce extension missing, " + + "possible replay attack", ocspValidationInfo); + } + if (!requestNonce.equals(responseNonce)) { + throw new UserCertificateOCSPCheckFailedException("OCSP request and response nonces differ, " + + "possible replay attack", ocspValidationInfo); + } + } + + public static String ocspStatusToString(int status) { + switch (status) { + case OCSPResp.MALFORMED_REQUEST: + return "malformed request"; + case OCSPResp.INTERNAL_ERROR: + return "internal error"; + case OCSPResp.TRY_LATER: + return "service unavailable"; + case OCSPResp.SIG_REQUIRED: + return "request signature missing"; + case OCSPResp.UNAUTHORIZED: + return "unauthorized"; + default: + return "unknown"; } } diff --git a/src/main/java/eu/webeid/security/validator/ocsp/OcspServiceProvider.java b/src/main/java/eu/webeid/security/validator/ocsp/OcspServiceProvider.java index 5f83c1d9..796eef07 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/OcspServiceProvider.java +++ b/src/main/java/eu/webeid/security/validator/ocsp/OcspServiceProvider.java @@ -23,26 +23,42 @@ package eu.webeid.security.validator.ocsp; import eu.webeid.security.exceptions.AuthTokenException; +import eu.webeid.security.exceptions.UserCertificateOCSPCheckFailedException; import eu.webeid.security.validator.ocsp.service.AiaOcspService; import eu.webeid.security.validator.ocsp.service.AiaOcspServiceConfiguration; import eu.webeid.security.validator.ocsp.service.DesignatedOcspService; import eu.webeid.security.validator.ocsp.service.DesignatedOcspServiceConfiguration; +import eu.webeid.security.validator.ocsp.service.FallbackOcspService; +import eu.webeid.security.validator.ocsp.service.FallbackOcspServiceConfiguration; import eu.webeid.security.validator.ocsp.service.OcspService; -import java.security.cert.CertificateEncodingException; +import java.net.URI; import java.security.cert.X509Certificate; +import java.util.Collection; +import java.util.Map; import java.util.Objects; +import java.util.stream.Collectors; + +import static eu.webeid.security.validator.ocsp.OcspUrl.getOcspUri; public class OcspServiceProvider { private final DesignatedOcspService designatedOcspService; private final AiaOcspServiceConfiguration aiaOcspServiceConfiguration; + private final Map fallbackOcspServiceMap; public OcspServiceProvider(DesignatedOcspServiceConfiguration designatedOcspServiceConfiguration, AiaOcspServiceConfiguration aiaOcspServiceConfiguration) { + this(designatedOcspServiceConfiguration, aiaOcspServiceConfiguration, null); + } + + public OcspServiceProvider(DesignatedOcspServiceConfiguration designatedOcspServiceConfiguration, AiaOcspServiceConfiguration aiaOcspServiceConfiguration, Collection fallbackOcspServiceConfigurations) { designatedOcspService = designatedOcspServiceConfiguration != null ? new DesignatedOcspService(designatedOcspServiceConfiguration) : null; this.aiaOcspServiceConfiguration = Objects.requireNonNull(aiaOcspServiceConfiguration, "aiaOcspServiceConfiguration"); + this.fallbackOcspServiceMap = fallbackOcspServiceConfigurations != null ? fallbackOcspServiceConfigurations.stream() + .collect(Collectors.toMap(FallbackOcspServiceConfiguration::getOcspServiceAccessLocation, FallbackOcspService::new)) + : Map.of(); } /** @@ -51,14 +67,17 @@ public OcspServiceProvider(DesignatedOcspServiceConfiguration designatedOcspServ * * @param certificate subject certificate that is to be checked with OCSP * @return either the designated or AIA OCSP service instance - * @throws AuthTokenException when AIA URL is not found in certificate - * @throws CertificateEncodingException when certificate is invalid + * @throws AuthTokenException when AIA URL is not found in certificate + * @throws IllegalArgumentException when certificate is invalid */ - public OcspService getService(X509Certificate certificate) throws AuthTokenException, CertificateEncodingException { + public OcspService getService(X509Certificate certificate) throws AuthTokenException { if (designatedOcspService != null && designatedOcspService.supportsIssuerOf(certificate)) { return designatedOcspService; } - return new AiaOcspService(aiaOcspServiceConfiguration, certificate); + URI ocspServiceUri = getOcspUri(certificate).orElseThrow(() -> + new UserCertificateOCSPCheckFailedException("Getting the AIA OCSP responder field from the certificate failed")); + FallbackOcspService fallbackOcspService = fallbackOcspServiceMap.get(ocspServiceUri); + return new AiaOcspService(aiaOcspServiceConfiguration, certificate, fallbackOcspService); } } diff --git a/src/main/java/eu/webeid/security/validator/ocsp/OcspValidationInfo.java b/src/main/java/eu/webeid/security/validator/ocsp/OcspValidationInfo.java new file mode 100644 index 00000000..ffc2e75a --- /dev/null +++ b/src/main/java/eu/webeid/security/validator/ocsp/OcspValidationInfo.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2020-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package eu.webeid.security.validator.ocsp; + +import org.bouncycastle.cert.ocsp.OCSPResp; + +import java.net.URI; +import java.security.cert.X509Certificate; + +public class OcspValidationInfo { + private final X509Certificate subjectCertificate; + private final URI ocspResponderUri; + private final OCSPResp ocspResponse; + + public OcspValidationInfo(X509Certificate subjectCertificate, URI ocspResponderUri, OCSPResp ocspResponse) { + this.subjectCertificate = subjectCertificate; + this.ocspResponderUri = ocspResponderUri; + this.ocspResponse = ocspResponse; + } + + public X509Certificate getSubjectCertificate() { + return subjectCertificate; + } + + public URI getOcspResponderUri() { + return ocspResponderUri; + } + + public OCSPResp getOcspResponse() { + return ocspResponse; + } +} diff --git a/src/main/java/eu/webeid/security/validator/ocsp/ResilientOcspService.java b/src/main/java/eu/webeid/security/validator/ocsp/ResilientOcspService.java new file mode 100644 index 00000000..1df16916 --- /dev/null +++ b/src/main/java/eu/webeid/security/validator/ocsp/ResilientOcspService.java @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2020-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package eu.webeid.security.validator.ocsp; + +import eu.webeid.security.exceptions.AuthTokenException; +import eu.webeid.security.exceptions.UserCertificateOCSPCheckFailedException; +import eu.webeid.security.exceptions.UserCertificateRevokedException; +import eu.webeid.security.exceptions.UserCertificateUnknownException; +import eu.webeid.security.validator.ocsp.service.OcspService; +import io.github.resilience4j.circuitbreaker.CallNotPermittedException; +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +import io.github.resilience4j.decorators.Decorators; +import io.github.resilience4j.retry.Retry; +import io.github.resilience4j.retry.RetryConfig; +import io.github.resilience4j.retry.RetryRegistry; +import io.vavr.CheckedFunction0; +import io.vavr.control.Try; +import org.bouncycastle.asn1.ocsp.OCSPObjectIdentifiers; +import org.bouncycastle.asn1.ocsp.OCSPResponseStatus; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.cert.ocsp.CertificateID; +import org.bouncycastle.cert.ocsp.OCSPException; +import org.bouncycastle.cert.ocsp.OCSPReq; +import org.bouncycastle.cert.ocsp.OCSPResp; +import org.bouncycastle.operator.OperatorCreationException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.List; +import java.util.Objects; + +public class ResilientOcspService { + private static final Logger LOG = LoggerFactory.getLogger(ResilientOcspService.class); + + private final OcspClient ocspClient; + private final OcspServiceProvider ocspServiceProvider; + private final Duration allowedOcspResponseTimeSkew; + private final Duration maxOcspResponseThisUpdateAge; + private final boolean rejectUnknownOcspResponseStatus; + private final CircuitBreakerRegistry circuitBreakerRegistry; + private final RetryRegistry retryRegistry; + + public ResilientOcspService(OcspClient ocspClient, OcspServiceProvider ocspServiceProvider, CircuitBreakerConfig circuitBreakerConfig, RetryConfig retryConfig, Duration allowedOcspResponseTimeSkew, Duration maxOcspResponseThisUpdateAge, boolean rejectUnknownOcspResponseStatus) { + this.ocspClient = ocspClient; + this.ocspServiceProvider = ocspServiceProvider; + this.allowedOcspResponseTimeSkew = allowedOcspResponseTimeSkew; + this.maxOcspResponseThisUpdateAge = maxOcspResponseThisUpdateAge; + this.rejectUnknownOcspResponseStatus = rejectUnknownOcspResponseStatus; + this.circuitBreakerRegistry = CircuitBreakerRegistry.custom() + .withCircuitBreakerConfig(getCircuitBreakerConfig(circuitBreakerConfig)) + .build(); + this.retryRegistry = retryConfig != null ? RetryRegistry.custom() + .withRetryConfig(getRetryConfigConfig(retryConfig)) + .build() : null; + if (LOG.isDebugEnabled()) { + this.circuitBreakerRegistry.getEventPublisher() + .onEntryAdded(entryAddedEvent -> { + CircuitBreaker circuitBreaker = entryAddedEvent.getAddedEntry(); + LOG.debug("CircuitBreaker {} added", circuitBreaker.getName()); + circuitBreaker.getEventPublisher() + .onEvent(event -> LOG.debug(event.toString())); + }); + } + } + + public OcspValidationInfo validateSubjectCertificateNotRevoked(X509Certificate subjectCertificate, X509Certificate issuerCertificate) throws AuthTokenException { + final OcspService ocspService = ocspServiceProvider.getService(subjectCertificate); + final OcspService fallbackOcspService = ocspService.getFallbackService(); + if (fallbackOcspService != null) { + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(ocspService.getAccessLocation().toASCIIString()); + CheckedFunction0 primarySupplier = () -> request(ocspService, subjectCertificate, issuerCertificate); + CheckedFunction0 fallbackSupplier = () -> request(ocspService.getFallbackService(), subjectCertificate, issuerCertificate); + Decorators.DecorateCheckedSupplier decorateCheckedSupplier = Decorators.ofCheckedSupplier(primarySupplier); + if (retryRegistry != null) { + Retry retry = retryRegistry.retry(ocspService.getAccessLocation().toASCIIString()); + decorateCheckedSupplier.withRetry(retry); + } + decorateCheckedSupplier.withCircuitBreaker(circuitBreaker) + .withFallback(List.of(UserCertificateOCSPCheckFailedException.class, CallNotPermittedException.class, UserCertificateUnknownException.class), e -> fallbackSupplier.apply()); + + CheckedFunction0 decoratedSupplier = decorateCheckedSupplier.decorate(); + + return Try.of(decoratedSupplier).getOrElseThrow(throwable -> { + if (throwable instanceof AuthTokenException) { + return (AuthTokenException) throwable; + } + return new UserCertificateOCSPCheckFailedException(throwable); + }); + } else { + return request(ocspService, subjectCertificate, issuerCertificate); + } + } + + private OcspValidationInfo request(OcspService ocspService, X509Certificate subjectCertificate, X509Certificate issuerCertificate) throws AuthTokenException { + OCSPResp response = null; + try { + final CertificateID certificateId = OcspResponseValidator.getCertificateId(subjectCertificate, issuerCertificate); + final OCSPReq request = new OcspRequestBuilder() + .withCertificateId(certificateId) + .enableOcspNonce(ocspService.doesSupportNonce()) + .build(); + + if (!ocspService.doesSupportNonce()) { + LOG.debug("Disabling OCSP nonce extension"); + } + + LOG.debug("Sending OCSP request"); + response = Objects.requireNonNull(ocspClient.request(ocspService.getAccessLocation(), request)); // TODO: This should trigger fallback? + if (response.getStatus() != OCSPResponseStatus.SUCCESSFUL) { + throw new UserCertificateOCSPCheckFailedException("Response status: " + OcspResponseValidator.ocspStatusToString(response.getStatus()), + new OcspValidationInfo(subjectCertificate, ocspService.getAccessLocation(), response)); + } + + final Extension requestNonce = request.getExtension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce); + OcspValidationInfo ocspValidationInfo = OcspResponseValidator.validateOcspResponse(response, ocspService, + requestNonce, subjectCertificate, issuerCertificate, allowedOcspResponseTimeSkew, + maxOcspResponseThisUpdateAge, rejectUnknownOcspResponseStatus); + LOG.debug("OCSP check result is GOOD"); + + return ocspValidationInfo; + } catch (OCSPException | CertificateException | OperatorCreationException | IOException e) { + throw new UserCertificateOCSPCheckFailedException(e, new OcspValidationInfo(subjectCertificate, ocspService.getAccessLocation(), response)); + } + } + + private static CircuitBreakerConfig getCircuitBreakerConfig(CircuitBreakerConfig circuitBreakerConfig) { + CircuitBreakerConfig.Builder configurationBuilder = CircuitBreakerConfig.custom() // TODO: What are good default values here? + .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED) + .slidingWindowSize(100) + .minimumNumberOfCalls(10) + .ignoreExceptions(UserCertificateRevokedException.class) // TODO: Revoked status is a valid response, not a failure and should be ignored. Any other exceptions to ignore? + .automaticTransitionFromOpenToHalfOpenEnabled(true); + + if (circuitBreakerConfig != null) { // TODO: What do we allow to configure? + configurationBuilder.slidingWindowSize(circuitBreakerConfig.getSlidingWindowSize()); + configurationBuilder.minimumNumberOfCalls(circuitBreakerConfig.getMinimumNumberOfCalls()); + configurationBuilder.failureRateThreshold(circuitBreakerConfig.getFailureRateThreshold()); + configurationBuilder.permittedNumberOfCallsInHalfOpenState(circuitBreakerConfig.getPermittedNumberOfCallsInHalfOpenState()); + configurationBuilder.waitIntervalFunctionInOpenState(circuitBreakerConfig.getWaitIntervalFunctionInOpenState()); + } + + return configurationBuilder.build(); + } + + private static RetryConfig getRetryConfigConfig(RetryConfig retryConfig) { + return RetryConfig.from(retryConfig) + .ignoreExceptions(UserCertificateRevokedException.class) // TODO: Revoked status is a valid response, not a failure and should be ignored. Any other exceptions to ignore? + .build(); + } + + CircuitBreakerRegistry getCircuitBreakerRegistry() { + return circuitBreakerRegistry; + } +} diff --git a/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java b/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java index e04823c3..4d813b4d 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java +++ b/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java @@ -51,13 +51,15 @@ public class AiaOcspService implements OcspService { private final CertStore trustedCACertificateCertStore; private final URI url; private final boolean supportsNonce; + private final FallbackOcspService fallbackOcspService; - public AiaOcspService(AiaOcspServiceConfiguration configuration, X509Certificate certificate) throws AuthTokenException { + public AiaOcspService(AiaOcspServiceConfiguration configuration, X509Certificate certificate, FallbackOcspService fallbackOcspService) throws AuthTokenException { Objects.requireNonNull(configuration); this.trustedCACertificateAnchors = configuration.getTrustedCACertificateAnchors(); this.trustedCACertificateCertStore = configuration.getTrustedCACertificateCertStore(); this.url = getOcspAiaUrlFromCertificate(Objects.requireNonNull(certificate)); this.supportsNonce = !configuration.getNonceDisabledOcspUrls().contains(this.url); + this.fallbackOcspService = fallbackOcspService; } @Override @@ -70,6 +72,11 @@ public URI getAccessLocation() { return url; } + @Override + public OcspService getFallbackService() { + return fallbackOcspService; + } + @Override public void validateResponderCertificate(X509CertificateHolder cert, Date now) throws AuthTokenException { try { diff --git a/src/main/java/eu/webeid/security/validator/ocsp/service/DesignatedOcspService.java b/src/main/java/eu/webeid/security/validator/ocsp/service/DesignatedOcspService.java index bafba269..aef18b1d 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/service/DesignatedOcspService.java +++ b/src/main/java/eu/webeid/security/validator/ocsp/service/DesignatedOcspService.java @@ -74,7 +74,7 @@ public void validateResponderCertificate(X509CertificateHolder cert, Date now) t } } - public boolean supportsIssuerOf(X509Certificate certificate) throws CertificateEncodingException { + public boolean supportsIssuerOf(X509Certificate certificate) { return configuration.supportsIssuerOf(certificate); } diff --git a/src/main/java/eu/webeid/security/validator/ocsp/service/DesignatedOcspServiceConfiguration.java b/src/main/java/eu/webeid/security/validator/ocsp/service/DesignatedOcspServiceConfiguration.java index 0bc03193..140d740d 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/service/DesignatedOcspServiceConfiguration.java +++ b/src/main/java/eu/webeid/security/validator/ocsp/service/DesignatedOcspServiceConfiguration.java @@ -70,8 +70,12 @@ public boolean doesSupportNonce() { return doesSupportNonce; } - public boolean supportsIssuerOf(X509Certificate certificate) throws CertificateEncodingException { - return supportedIssuers.contains(new JcaX509CertificateHolder(Objects.requireNonNull(certificate)).getIssuer()); + public boolean supportsIssuerOf(X509Certificate certificate) { + try { + return supportedIssuers.contains(new JcaX509CertificateHolder(Objects.requireNonNull(certificate)).getIssuer()); + } catch (CertificateEncodingException e) { + throw new IllegalArgumentException(e); + } } private Collection getIssuerX500Names(Collection supportedIssuers) throws OCSPCertificateException { diff --git a/src/main/java/eu/webeid/security/validator/ocsp/service/FallbackOcspService.java b/src/main/java/eu/webeid/security/validator/ocsp/service/FallbackOcspService.java new file mode 100644 index 00000000..94dcdaa6 --- /dev/null +++ b/src/main/java/eu/webeid/security/validator/ocsp/service/FallbackOcspService.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2020-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package eu.webeid.security.validator.ocsp.service; + +import eu.webeid.security.exceptions.AuthTokenException; +import eu.webeid.security.exceptions.OCSPCertificateException; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; + +import java.net.URI; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.Date; + +import static eu.webeid.security.certificate.CertificateValidator.certificateIsValidOnDate; + +public class FallbackOcspService implements OcspService { + private final JcaX509CertificateConverter certificateConverter = new JcaX509CertificateConverter(); + private final URI url; + private final boolean supportsNonce; + private final X509Certificate trustedResponderCertificate; + + public FallbackOcspService(FallbackOcspServiceConfiguration configuration) { + this.url = configuration.getFallbackOcspServiceAccessLocation(); + this.supportsNonce = configuration.doesSupportNonce(); + this.trustedResponderCertificate = configuration.getResponderCertificate(); + } + + @Override + public boolean doesSupportNonce() { + return supportsNonce; + } + + @Override + public URI getAccessLocation() { + return url; + } + + @Override + public void validateResponderCertificate(X509CertificateHolder cert, Date now) throws AuthTokenException { + try { + final X509Certificate responderCertificate = certificateConverter.getCertificate(cert); + // Certificate pinning is implemented simply by comparing the certificates or their public keys, + // see https://owasp.org/www-community/controls/Certificate_and_Public_Key_Pinning. + if (!trustedResponderCertificate.equals(responderCertificate)) { + throw new OCSPCertificateException("Responder certificate from the OCSP response is not equal to " + + "the configured fallback OCSP responder certificate"); + } + certificateIsValidOnDate(responderCertificate, now, "Fallback OCSP responder"); + } catch (CertificateException e) { + throw new OCSPCertificateException("X509CertificateHolder conversion to X509Certificate failed", e); + } + } +} diff --git a/src/main/java/eu/webeid/security/validator/ocsp/service/FallbackOcspServiceConfiguration.java b/src/main/java/eu/webeid/security/validator/ocsp/service/FallbackOcspServiceConfiguration.java new file mode 100644 index 00000000..3d262169 --- /dev/null +++ b/src/main/java/eu/webeid/security/validator/ocsp/service/FallbackOcspServiceConfiguration.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2020-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package eu.webeid.security.validator.ocsp.service; + +import eu.webeid.security.exceptions.OCSPCertificateException; +import eu.webeid.security.validator.ocsp.OcspResponseValidator; + +import java.net.URI; +import java.security.cert.X509Certificate; +import java.util.Objects; + +public class FallbackOcspServiceConfiguration { + + private final URI ocspServiceAccessLocation; + private final URI fallbackOcspServiceAccessLocation; + private final X509Certificate responderCertificate; + private final boolean doesSupportNonce; + + public FallbackOcspServiceConfiguration(URI ocspServiceAccessLocation, URI fallbackOcspServiceAccessLocation, X509Certificate responderCertificate, boolean doesSupportNonce) throws OCSPCertificateException { + this.ocspServiceAccessLocation = Objects.requireNonNull(ocspServiceAccessLocation, "Primary OCSP service access location"); + this.fallbackOcspServiceAccessLocation = Objects.requireNonNull(fallbackOcspServiceAccessLocation, "Fallback OCSP service access location"); + this.responderCertificate = Objects.requireNonNull(responderCertificate, "Fallback OCSP responder certificate"); + OcspResponseValidator.validateHasSigningExtension(responderCertificate); + this.doesSupportNonce = doesSupportNonce; + } + + public URI getOcspServiceAccessLocation() { + return ocspServiceAccessLocation; + } + + public URI getFallbackOcspServiceAccessLocation() { + return fallbackOcspServiceAccessLocation; + } + + public X509Certificate getResponderCertificate() { + return responderCertificate; + } + + public boolean doesSupportNonce() { + return doesSupportNonce; + } + +} diff --git a/src/main/java/eu/webeid/security/validator/ocsp/service/OcspService.java b/src/main/java/eu/webeid/security/validator/ocsp/service/OcspService.java index 97bbdf2c..7129cc9b 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/service/OcspService.java +++ b/src/main/java/eu/webeid/security/validator/ocsp/service/OcspService.java @@ -36,4 +36,7 @@ public interface OcspService { void validateResponderCertificate(X509CertificateHolder cert, Date now) throws AuthTokenException; + default OcspService getFallbackService() { + return null; + } } diff --git a/src/test/java/eu/webeid/security/testutil/Certificates.java b/src/test/java/eu/webeid/security/testutil/Certificates.java index 215773b6..f0e43681 100644 --- a/src/test/java/eu/webeid/security/testutil/Certificates.java +++ b/src/test/java/eu/webeid/security/testutil/Certificates.java @@ -42,12 +42,14 @@ public class Certificates { private static X509Certificate mariliisEsteid2015Cert; private static X509Certificate organizationCert; private static X509Certificate testSkOcspResponder2020; + private static X509Certificate testSkOcspResponder2018; static void loadCertificates() throws CertificateException, IOException { - X509Certificate[] certificates = CertificateLoader.loadCertificatesFromResources("TEST_of_ESTEID-SK_2015.cer", "TEST_of_ESTEID2018.cer", "TEST_of_SK_OCSP_RESPONDER_2020.cer"); + X509Certificate[] certificates = CertificateLoader.loadCertificatesFromResources("TEST_of_ESTEID-SK_2015.cer", "TEST_of_ESTEID2018.cer", "TEST_of_SK_OCSP_RESPONDER_2020.cer", "TEST_of_SK_OCSP_RESPONDER_2018.cer"); testEsteid2015CA = certificates[0]; testEsteid2018CA = certificates[1]; testSkOcspResponder2020 = certificates[2]; + testSkOcspResponder2018 = certificates[3]; } public static X509Certificate getTestEsteid2018CA() throws CertificateException, IOException { @@ -71,6 +73,13 @@ public static X509Certificate getTestSkOcspResponder2020() throws CertificateExc return testSkOcspResponder2020; } + public static X509Certificate getTestSkOcspResponder2018() throws CertificateException, IOException { + if (testSkOcspResponder2018 == null) { + loadCertificates(); + } + return testSkOcspResponder2018; + } + public static X509Certificate getJaakKristjanEsteid2018Cert() throws CertificateDecodingException { if (jaakKristjanEsteid2018Cert == null) { jaakKristjanEsteid2018Cert = CertificateLoader.decodeCertificateFromBase64(JAAK_KRISTJAN_ESTEID2018_CERT); diff --git a/src/test/java/eu/webeid/security/validator/AuthTokenSignatureTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenSignatureTest.java index 3f596858..e1280851 100644 --- a/src/test/java/eu/webeid/security/validator/AuthTokenSignatureTest.java +++ b/src/test/java/eu/webeid/security/validator/AuthTokenSignatureTest.java @@ -48,8 +48,8 @@ class AuthTokenSignatureTest extends AbstractTestWithValidator { @Test void whenValidTokenAndNonce_thenValidationSucceeds() throws Exception { - final X509Certificate result = validator.validate(validAuthToken, VALID_CHALLENGE_NONCE); - + final ValidationInfo validationInfo = validator.validate(validAuthToken, VALID_CHALLENGE_NONCE); + final X509Certificate result = validationInfo.getSubjectCertificate(); assertThat(CertificateData.getSubjectCN(result).orElseThrow()) .isEqualTo("JÕEORG\\,JAAK-KRISTJAN\\,38001085718"); assertThat(toTitleCase(CertificateData.getSubjectGivenName(result).orElseThrow())) diff --git a/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidatorTest.java b/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidatorTest.java index 771c3018..f25daa4b 100644 --- a/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidatorTest.java +++ b/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidatorTest.java @@ -31,6 +31,7 @@ import eu.webeid.security.validator.ocsp.OcspClient; import eu.webeid.security.validator.ocsp.OcspClientImpl; import eu.webeid.security.validator.ocsp.OcspServiceProvider; +import eu.webeid.security.validator.ocsp.ResilientOcspService; import org.bouncycastle.asn1.ocsp.OCSPResponseStatus; import org.bouncycastle.cert.ocsp.OCSPException; import org.bouncycastle.cert.ocsp.OCSPResp; @@ -258,9 +259,11 @@ void whenOcspResponseCACertNotTrusted_thenThrows() throws Exception { ); try (var mockedClock = mockStatic(DateAndTime.DefaultClock.class)) { mockDate("2021-09-18T00:16:25", mockedClock); - assertThatExceptionOfType(CertificateNotTrustedException.class) + assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) .isThrownBy(() -> validator.validateCertificateNotRevoked(estEid2018Cert)) + .withCauseExactlyInstanceOf(CertificateNotTrustedException.class) + .havingCause() .withMessage("Certificate EMAILADDRESS=pki@sk.ee, CN=TEST of SK OCSP RESPONDER 2020, OU=OCSP, O=AS Sertifitseerimiskeskus, C=EE is not trusted"); } } @@ -270,9 +273,11 @@ void whenOcspResponseCACertExpired_thenThrows() throws Exception { final SubjectCertificateNotRevokedValidator validator = getSubjectCertificateNotRevokedValidatorWithAiaOcsp( getMockedResponse(getOcspResponseBytesFromResources("ocsp_response_unknown.der")) ); - assertThatExceptionOfType(CertificateExpiredException.class) + assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) .isThrownBy(() -> validator.validateCertificateNotRevoked(estEid2018Cert)) + .withCauseExactlyInstanceOf(CertificateExpiredException.class) + .havingCause() .withMessage("AIA OCSP responder certificate has expired"); } @@ -347,7 +352,13 @@ private SubjectCertificateNotRevokedValidator getSubjectCertificateNotRevokedVal } private SubjectCertificateNotRevokedValidator getSubjectCertificateNotRevokedValidator(OcspClient client, OcspServiceProvider ocspServiceProvider) { - return new SubjectCertificateNotRevokedValidator(trustedValidator, client, ocspServiceProvider, CONFIGURATION.getAllowedOcspResponseTimeSkew(), CONFIGURATION.getMaxOcspResponseThisUpdateAge()); + ResilientOcspService resilientOcspService = new ResilientOcspService(client, ocspServiceProvider, + null, + null, + CONFIGURATION.getAllowedOcspResponseTimeSkew(), + CONFIGURATION.getMaxOcspResponseThisUpdateAge(), + CONFIGURATION.isRejectUnknownOcspResponseStatus()); + return new SubjectCertificateNotRevokedValidator(resilientOcspService, trustedValidator); } private static void setSubjectCertificateIssuerCertificate(SubjectCertificateTrustedValidator trustedValidator) throws NoSuchFieldException, IllegalAccessException, CertificateException, IOException { diff --git a/src/test/java/eu/webeid/security/validator/ocsp/OcspResponseValidatorTest.java b/src/test/java/eu/webeid/security/validator/ocsp/OcspResponseValidatorTest.java index a8e80ddf..a53cb651 100644 --- a/src/test/java/eu/webeid/security/validator/ocsp/OcspResponseValidatorTest.java +++ b/src/test/java/eu/webeid/security/validator/ocsp/OcspResponseValidatorTest.java @@ -35,6 +35,7 @@ import static eu.webeid.security.validator.ocsp.OcspResponseValidator.validateCertificateStatusUpdateTime; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -51,7 +52,7 @@ void whenThisAndNextUpdateWithinSkew_thenValidationSucceeds() { var nextUpdateWithinAgeLimit = Date.from(now.minus(THIS_UPDATE_AGE.minusSeconds(2))); when(mockResponse.getThisUpdate()).thenReturn(thisUpdateWithinAgeLimit); when(mockResponse.getNextUpdate()).thenReturn(nextUpdateWithinAgeLimit); - assertThatCode(() -> validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE)) + assertThatCode(() -> validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, null)) .doesNotThrowAnyException(); } @@ -63,12 +64,14 @@ void whenNextUpdateBeforeThisUpdate_thenThrows() { var beforeThisUpdate = new Date(thisUpdateWithinAgeLimit.getTime() - 1000); when(mockResponse.getThisUpdate()).thenReturn(thisUpdateWithinAgeLimit); when(mockResponse.getNextUpdate()).thenReturn(beforeThisUpdate); + OcspValidationInfo ocspValidationInfo = mock(OcspValidationInfo.class); assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) .isThrownBy(() -> - validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE)) + validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, ocspValidationInfo)) .withMessageStartingWith("User certificate revocation check has failed: " + "Certificate status update time check failed: " - + "nextUpdate '" + beforeThisUpdate.toInstant() + "' is before thisUpdate '" + thisUpdateWithinAgeLimit.toInstant() + "'"); + + "nextUpdate '" + beforeThisUpdate.toInstant() + "' is before thisUpdate '" + thisUpdateWithinAgeLimit.toInstant() + "'") + .satisfies(e -> assertThat(e.getOcspValidationInfo()).isNotNull()); } @Test @@ -77,12 +80,14 @@ void whenThisUpdateHalfHourBeforeNow_thenThrows() { var now = Instant.now(); var halfHourBeforeNow = Date.from(now.minus(30, ChronoUnit.MINUTES)); when(mockResponse.getThisUpdate()).thenReturn(halfHourBeforeNow); + OcspValidationInfo ocspValidationInfo = mock(OcspValidationInfo.class); assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) .isThrownBy(() -> - validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE)) + validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, ocspValidationInfo)) .withMessageStartingWith("User certificate revocation check has failed: " + "Certificate status update time check failed: " - + "thisUpdate '" + halfHourBeforeNow.toInstant() + "' is too old, minimum time allowed: "); + + "thisUpdate '" + halfHourBeforeNow.toInstant() + "' is too old, minimum time allowed: ") + .satisfies(e -> assertThat(e.getOcspValidationInfo()).isNotNull()); } @Test @@ -91,12 +96,14 @@ void whenThisUpdateHalfHourAfterNow_thenThrows() { var now = Instant.now(); var halfHourAfterNow = Date.from(now.plus(30, ChronoUnit.MINUTES)); when(mockResponse.getThisUpdate()).thenReturn(halfHourAfterNow); + OcspValidationInfo ocspValidationInfo = mock(OcspValidationInfo.class); assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) .isThrownBy(() -> - validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE)) + validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, ocspValidationInfo)) .withMessageStartingWith("User certificate revocation check has failed: " + "Certificate status update time check failed: " - + "thisUpdate '" + halfHourAfterNow.toInstant() + "' is too far in the future, latest allowed: "); + + "thisUpdate '" + halfHourAfterNow.toInstant() + "' is too far in the future, latest allowed: ") + .satisfies(e -> assertThat(e.getOcspValidationInfo()).isNotNull()); } @Test @@ -107,12 +114,14 @@ void whenNextUpdateHalfHourBeforeNow_thenThrows() { var halfHourBeforeNow = Date.from(now.minus(30, ChronoUnit.MINUTES)); when(mockResponse.getThisUpdate()).thenReturn(thisUpdateWithinAgeLimit); when(mockResponse.getNextUpdate()).thenReturn(halfHourBeforeNow); + OcspValidationInfo ocspValidationInfo = mock(OcspValidationInfo.class); assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) .isThrownBy(() -> - validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE)) + validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, ocspValidationInfo)) .withMessage("User certificate revocation check has failed: " + "Certificate status update time check failed: " - + "nextUpdate '" + halfHourBeforeNow.toInstant() + "' is in the past"); + + "nextUpdate '" + halfHourBeforeNow.toInstant() + "' is in the past") + .satisfies(e -> assertThat(e.getOcspValidationInfo()).isNotNull()); } private static Date getThisUpdateWithinAgeLimit(Instant now) { diff --git a/src/test/java/eu/webeid/security/validator/ocsp/ResilientOcspServiceTest.java b/src/test/java/eu/webeid/security/validator/ocsp/ResilientOcspServiceTest.java new file mode 100644 index 00000000..88b0457e --- /dev/null +++ b/src/test/java/eu/webeid/security/validator/ocsp/ResilientOcspServiceTest.java @@ -0,0 +1,536 @@ +/* + * Copyright (c) 2020-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package eu.webeid.security.validator.ocsp; + +import eu.webeid.security.certificate.CertificateValidator; +import eu.webeid.security.exceptions.UserCertificateOCSPCheckFailedException; +import eu.webeid.security.exceptions.UserCertificateRevokedException; +import eu.webeid.security.exceptions.UserCertificateUnknownException; +import eu.webeid.security.util.DateAndTime; +import eu.webeid.security.validator.ocsp.service.AiaOcspServiceConfiguration; +import eu.webeid.security.validator.ocsp.service.FallbackOcspServiceConfiguration; +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +import io.github.resilience4j.retry.RetryConfig; +import org.bouncycastle.cert.ocsp.OCSPResp; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.security.Security; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import static eu.webeid.security.testutil.Certificates.getJaakKristjanEsteid2018Cert; +import static eu.webeid.security.testutil.Certificates.getTestEsteid2015CA; +import static eu.webeid.security.testutil.Certificates.getTestEsteid2018CA; +import static eu.webeid.security.testutil.Certificates.getTestSkOcspResponder2018; +import static eu.webeid.security.testutil.Certificates.getTestSkOcspResponder2020; +import static eu.webeid.security.testutil.DateMocker.mockDate; +import static eu.webeid.security.testutil.OcspServiceMaker.getAiaOcspServiceProvider; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.awaitility.Awaitility.await; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ResilientOcspServiceTest { + private static final URI PRIMARY_OCSP_URL = URI.create("http://aia.demo.sk.ee/esteid2018"); + private static final URI FALLBACK_OCSP_URL = URI.create("http://fallback.demo.sk.ee/ocsp"); + private static final Duration ALLOWED_TIME_SKEW = Duration.ofMinutes(15); + private static final Duration MAX_THIS_UPDATE_AGE = Duration.ofMinutes(2); + + private X509Certificate subjectCertificate; + private X509Certificate issuerCertificate; + private byte[] validOcspResponseBytes; + private byte[] revokedOcspResponseBytes; + private byte[] unknownOcspResponseBytes; + + @BeforeAll + static void setUpClass() { + Security.addProvider(new BouncyCastleProvider()); + } + + @BeforeEach + void setUp() throws Exception { + subjectCertificate = getJaakKristjanEsteid2018Cert(); + issuerCertificate = getTestEsteid2018CA(); + validOcspResponseBytes = getSystemResource("ocsp_response.der"); + revokedOcspResponseBytes = getSystemResource("ocsp_response_revoked.der"); + unknownOcspResponseBytes = getSystemResource("ocsp_response_unknown.der"); + } + + @Test + void whenFallbackConfigured_thenFallbackAndRecoverySucceeds() throws Exception { + final OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_OCSP_URL), any())) + .thenThrow(new IOException("Mocked exception 1")) + .thenThrow(new IOException("Mocked exception 2")) + .thenThrow(new IOException("Mocked exception 3")) + .thenThrow(new IOException("Mocked exception 4")) + .thenReturn(new OCSPResp(validOcspResponseBytes)) + .thenReturn(new OCSPResp(validOcspResponseBytes)); + when(ocspClient.request(eq(FALLBACK_OCSP_URL), any())) + .thenReturn(new OCSPResp(validOcspResponseBytes)); + CircuitBreakerConfig circuitBreakerConfig = CircuitBreakerConfig.custom() + .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED) + .slidingWindowSize(4) + .minimumNumberOfCalls(2) + .failureRateThreshold(50) + .permittedNumberOfCallsInHalfOpenState(2) + .waitDurationInOpenState(Duration.ofMillis(100)) // Short wait for testing + .automaticTransitionFromOpenToHalfOpenEnabled(true) + .build(); + OcspServiceProvider ocspServiceProvider = createOcspServiceProviderWithFallback(); + ResilientOcspService resilientOcspService = new ResilientOcspService( + ocspClient, + ocspServiceProvider, + circuitBreakerConfig, + null, + ALLOWED_TIME_SKEW, + MAX_THIS_UPDATE_AGE, + false + ); + CircuitBreakerRegistry circuitBreakerRegistry = resilientOcspService.getCircuitBreakerRegistry(); + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(PRIMARY_OCSP_URL.toASCIIString()); + try (var mockedClock = mockStatic(DateAndTime.DefaultClock.class)) { + mockDate("2021-09-17T18:25:24", mockedClock); + + assertThatCode(() -> + resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate) + ).doesNotThrowAnyException(); + verify(ocspClient, times(1)).request(eq(PRIMARY_OCSP_URL), any()); + verify(ocspClient, times(1)).request(eq(FALLBACK_OCSP_URL), any()); + assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.CLOSED); + + assertThatCode(() -> + resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate) + ).doesNotThrowAnyException(); + verify(ocspClient, times(2)).request(eq(PRIMARY_OCSP_URL), any()); + verify(ocspClient, times(2)).request(eq(FALLBACK_OCSP_URL), any()); + assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); + + assertThatCode(() -> + resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate) + ).doesNotThrowAnyException(); + verify(ocspClient, times(2)).request(eq(PRIMARY_OCSP_URL), any()); + verify(ocspClient, times(3)).request(eq(FALLBACK_OCSP_URL), any()); + assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); + + await() + .until(circuitBreaker::getState, equalTo(CircuitBreaker.State.HALF_OPEN)); + + assertThatCode(() -> + resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate) + ).doesNotThrowAnyException(); + verify(ocspClient, times(3)).request(eq(PRIMARY_OCSP_URL), any()); + verify(ocspClient, times(4)).request(eq(FALLBACK_OCSP_URL), any()); + assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.HALF_OPEN); + + assertThatCode(() -> + resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate) + ).doesNotThrowAnyException(); + verify(ocspClient, times(4)).request(eq(PRIMARY_OCSP_URL), any()); + verify(ocspClient, times(5)).request(eq(FALLBACK_OCSP_URL), any()); + assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); + + await() + .until(circuitBreaker::getState, equalTo(CircuitBreaker.State.HALF_OPEN)); + + assertThatCode(() -> + resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate) + ).doesNotThrowAnyException(); + verify(ocspClient, times(5)).request(eq(PRIMARY_OCSP_URL), any()); + verify(ocspClient, times(5)).request(eq(FALLBACK_OCSP_URL), any()); + assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.HALF_OPEN); + + assertThatCode(() -> + resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate) + ).doesNotThrowAnyException(); + verify(ocspClient, times(6)).request(eq(PRIMARY_OCSP_URL), any()); + verify(ocspClient, times(5)).request(eq(FALLBACK_OCSP_URL), any()); + assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.CLOSED); + + assertThatCode(() -> + resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate) + ).doesNotThrowAnyException(); + verify(ocspClient, times(7)).request(eq(PRIMARY_OCSP_URL), any()); + verify(ocspClient, times(5)).request(eq(FALLBACK_OCSP_URL), any()); + assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.CLOSED); + } + } + + @Test + void whenOcspResponseGood_thenNoFallbackAndSucceeds() throws Exception { + final OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_OCSP_URL), any())) + .thenReturn(new OCSPResp(validOcspResponseBytes)); + when(ocspClient.request(eq(FALLBACK_OCSP_URL), any())) + .thenReturn(new OCSPResp(validOcspResponseBytes)); + OcspServiceProvider ocspServiceProvider = createOcspServiceProviderWithFallback(); + ResilientOcspService resilientOcspService = new ResilientOcspService( + ocspClient, + ocspServiceProvider, + null, + null, + ALLOWED_TIME_SKEW, + MAX_THIS_UPDATE_AGE, + false + ); + CircuitBreakerRegistry circuitBreakerRegistry = resilientOcspService.getCircuitBreakerRegistry(); + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(PRIMARY_OCSP_URL.toASCIIString()); + try (var mockedClock = mockStatic(DateAndTime.DefaultClock.class)) { + mockDate("2021-09-17T18:25:24", mockedClock); + + OcspValidationInfo validationInfo = resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate); + assertThat(validationInfo).isNotNull(); + assertThat(validationInfo).extracting(OcspValidationInfo::getSubjectCertificate) + .isEqualTo(subjectCertificate); + assertThat(validationInfo).extracting(OcspValidationInfo::getOcspResponderUri) + .isEqualTo(new URI("http://aia.demo.sk.ee/esteid2018")); + assertThat(validationInfo).extracting(OcspValidationInfo::getOcspResponse) + .isNotNull(); + + verify(ocspClient, times(1)).request(eq(PRIMARY_OCSP_URL), any()); + verify(ocspClient, times(0)).request(eq(FALLBACK_OCSP_URL), any()); + assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.CLOSED); + } + } + + @Test + void whenRetryEnabledAndRetrySucceeds_thenNoFallbackAndSucceeds() throws Exception { + final OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_OCSP_URL), any())) + .thenThrow(new IOException("Mocked exception 1")) + .thenThrow(new IOException("Mocked exception 2")) + .thenReturn(new OCSPResp(validOcspResponseBytes)); + when(ocspClient.request(eq(FALLBACK_OCSP_URL), any())) + .thenReturn(new OCSPResp(validOcspResponseBytes)); + OcspServiceProvider ocspServiceProvider = createOcspServiceProviderWithFallback(); + ResilientOcspService resilientOcspService = new ResilientOcspService( + ocspClient, + ocspServiceProvider, + null, + RetryConfig.ofDefaults(), // Retry enabled + ALLOWED_TIME_SKEW, + MAX_THIS_UPDATE_AGE, + false + ); + CircuitBreakerRegistry circuitBreakerRegistry = resilientOcspService.getCircuitBreakerRegistry(); + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(PRIMARY_OCSP_URL.toASCIIString()); + try (var mockedClock = mockStatic(DateAndTime.DefaultClock.class)) { + mockDate("2021-09-17T18:25:24", mockedClock); + + OcspValidationInfo validationInfo = resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate); + assertThat(validationInfo).isNotNull(); + assertThat(validationInfo).extracting(OcspValidationInfo::getSubjectCertificate) + .isEqualTo(subjectCertificate); + assertThat(validationInfo).extracting(OcspValidationInfo::getOcspResponderUri) + .isEqualTo(new URI("http://aia.demo.sk.ee/esteid2018")); + assertThat(validationInfo).extracting(OcspValidationInfo::getOcspResponse) + .isNotNull(); + + verify(ocspClient, times(3)).request(eq(PRIMARY_OCSP_URL), any()); + verify(ocspClient, times(0)).request(eq(FALLBACK_OCSP_URL), any()); + assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.CLOSED); + } + } + + @Test + void whenOcspResponseRevoked_thenNoFallbackAndThrows() throws Exception { + final OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_OCSP_URL), any())) + .thenReturn(new OCSPResp(revokedOcspResponseBytes)); + when(ocspClient.request(eq(FALLBACK_OCSP_URL), any())) + .thenReturn(new OCSPResp(validOcspResponseBytes)); + OcspServiceProvider ocspServiceProvider = createOcspServiceProviderWithFallback(); + ResilientOcspService resilientOcspService = new ResilientOcspService( + ocspClient, + ocspServiceProvider, + null, + null, + ALLOWED_TIME_SKEW, + MAX_THIS_UPDATE_AGE, + false + ); + CircuitBreakerRegistry circuitBreakerRegistry = resilientOcspService.getCircuitBreakerRegistry(); + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(PRIMARY_OCSP_URL.toASCIIString()); + try (var mockedClock = mockStatic(DateAndTime.DefaultClock.class)) { + mockDate("2021-09-18T00:00:00", mockedClock); + + assertThatExceptionOfType(UserCertificateRevokedException.class) + .isThrownBy(() -> + resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate)) + .withMessage("User certificate has been revoked: Revocation reason: 0") + .satisfies(e -> assertThat(e.getOcspValidationInfo()).isNotNull()) + .satisfies(e -> assertThat(e.getOcspValidationInfo().getSubjectCertificate()).isNotNull()) + .satisfies(e -> assertThat(e.getOcspValidationInfo().getOcspResponderUri()).isNotNull()) + .satisfies(e -> assertThat(e.getOcspValidationInfo().getOcspResponderUri().toASCIIString()).isEqualTo("http://aia.demo.sk.ee/esteid2018")) + .satisfies(e -> assertThat(e.getOcspValidationInfo().getOcspResponse()).isNotNull()); + + verify(ocspClient, times(1)).request(eq(PRIMARY_OCSP_URL), any()); + verify(ocspClient, times(0)).request(eq(FALLBACK_OCSP_URL), any()); + assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.CLOSED); + } + } + + @Test + void whenOcspResponseUnknown_thenNoFallbackAndThrows() throws Exception { + final OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_OCSP_URL), any())) + .thenReturn(new OCSPResp(unknownOcspResponseBytes)); + when(ocspClient.request(eq(FALLBACK_OCSP_URL), any())) + .thenReturn(new OCSPResp(validOcspResponseBytes)); + OcspServiceProvider ocspServiceProvider = createOcspServiceProviderWithFallback(); + ResilientOcspService resilientOcspService = new ResilientOcspService( + ocspClient, + ocspServiceProvider, + null, + null, + ALLOWED_TIME_SKEW, + MAX_THIS_UPDATE_AGE, + false + ); + CircuitBreakerRegistry circuitBreakerRegistry = resilientOcspService.getCircuitBreakerRegistry(); + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(PRIMARY_OCSP_URL.toASCIIString()); + try (var mockedClock = mockStatic(DateAndTime.DefaultClock.class)) { + mockDate("2021-09-18T00:16:25", mockedClock); + + assertThatExceptionOfType(UserCertificateRevokedException.class) + .isThrownBy(() -> + resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate)) + .withMessage("User certificate has been revoked: Unknown status") + .satisfies(e -> assertThat(e.getOcspValidationInfo()).isNotNull()) + .satisfies(e -> assertThat(e.getOcspValidationInfo().getSubjectCertificate()).isNotNull()) + .satisfies(e -> assertThat(e.getOcspValidationInfo().getOcspResponderUri()).isNotNull()) + .satisfies(e -> assertThat(e.getOcspValidationInfo().getOcspResponderUri().toASCIIString()).isEqualTo("http://aia.demo.sk.ee/esteid2018")) + .satisfies(e -> assertThat(e.getOcspValidationInfo().getOcspResponse()).isNotNull()); + + verify(ocspClient, times(1)).request(eq(PRIMARY_OCSP_URL), any()); + verify(ocspClient, times(0)).request(eq(FALLBACK_OCSP_URL), any()); + assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.CLOSED); + } + } + + @Test + void whenPrimaryOcspResponseUnknownAndRejectUnknownOcspResponseStatusConfiguration_thenFallbackAndSucceeds() throws Exception { + final OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_OCSP_URL), any())) + .thenReturn(new OCSPResp(unknownOcspResponseBytes)); + when(ocspClient.request(eq(FALLBACK_OCSP_URL), any())) + .thenReturn(new OCSPResp(validOcspResponseBytes)); + OcspServiceProvider ocspServiceProvider = createOcspServiceProviderWithFallback(); + ResilientOcspService resilientOcspService = new ResilientOcspService( + ocspClient, + ocspServiceProvider, + null, + null, + ALLOWED_TIME_SKEW, + MAX_THIS_UPDATE_AGE, + true // rejectUnknownOcspResponseStatus + ); + CircuitBreakerRegistry circuitBreakerRegistry = resilientOcspService.getCircuitBreakerRegistry(); + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(PRIMARY_OCSP_URL.toASCIIString()); + try (var mockedClock = mockStatic(DateAndTime.DefaultClock.class)) { + mockDate("2021-09-17T18:25:24", mockedClock); + + OcspValidationInfo validationInfo = resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate); + assertThat(validationInfo).isNotNull(); + assertThat(validationInfo).extracting(OcspValidationInfo::getSubjectCertificate) + .isEqualTo(subjectCertificate); + assertThat(validationInfo).extracting(OcspValidationInfo::getOcspResponderUri) + .isEqualTo(new URI("http://fallback.demo.sk.ee/ocsp")); + assertThat(validationInfo).extracting(OcspValidationInfo::getOcspResponse) + .isNotNull(); + + verify(ocspClient, times(1)).request(eq(PRIMARY_OCSP_URL), any()); + verify(ocspClient, times(1)).request(eq(FALLBACK_OCSP_URL), any()); + assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.CLOSED); + } + } + + @Test + void whenPrimaryAndFallbackRevocationStatusUnknownAndRejectUnknownOcspResponseStatusConfiguration_thenThrows() throws Exception { + final OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_OCSP_URL), any())) + .thenReturn(new OCSPResp(unknownOcspResponseBytes)); + when(ocspClient.request(eq(FALLBACK_OCSP_URL), any())) + .thenReturn(new OCSPResp(unknownOcspResponseBytes)); + FallbackOcspServiceConfiguration fallbackConfig = new FallbackOcspServiceConfiguration( + PRIMARY_OCSP_URL, + FALLBACK_OCSP_URL, + getTestSkOcspResponder2020(), + false + ); + OcspServiceProvider ocspServiceProvider = createOcspServiceProviderWithFallback(fallbackConfig); + ResilientOcspService resilientOcspService = new ResilientOcspService( + ocspClient, + ocspServiceProvider, + null, + null, + ALLOWED_TIME_SKEW, + MAX_THIS_UPDATE_AGE, + true // rejectUnknownOcspResponseStatus + ); + CircuitBreakerRegistry circuitBreakerRegistry = resilientOcspService.getCircuitBreakerRegistry(); + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(PRIMARY_OCSP_URL.toASCIIString()); + try (var mockedClock = mockStatic(DateAndTime.DefaultClock.class)) { + mockDate("2021-09-18T00:16:25", mockedClock); + + assertThatExceptionOfType(UserCertificateUnknownException.class) + .isThrownBy(() -> + resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate)) + .withMessage("Unknown status") + .satisfies(e -> assertThat(e.getOcspValidationInfo()).isNotNull()) + .satisfies(e -> assertThat(e.getOcspValidationInfo().getSubjectCertificate()).isNotNull()) + .satisfies(e -> assertThat(e.getOcspValidationInfo().getOcspResponderUri()).isNotNull()) + .satisfies(e -> assertThat(e.getOcspValidationInfo().getOcspResponderUri().toASCIIString()).isEqualTo("http://fallback.demo.sk.ee/ocsp")) + .satisfies(e -> assertThat(e.getOcspValidationInfo().getOcspResponse()).isNotNull()); + + + verify(ocspClient, times(1)).request(eq(PRIMARY_OCSP_URL), any()); + verify(ocspClient, times(1)).request(eq(FALLBACK_OCSP_URL), any()); + assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.CLOSED); + } + } + + @Test + void whenPrimaryAndFallbackConnectionFail_thenThrows() throws Exception { + final OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_OCSP_URL), any())) + .thenThrow(new IOException("Mocked exception 1")); + when(ocspClient.request(eq(FALLBACK_OCSP_URL), any())) + .thenThrow(new IOException("Mocked exception 2")); + OcspServiceProvider ocspServiceProvider = createOcspServiceProviderWithFallback(); + ResilientOcspService resilientOcspService = new ResilientOcspService( + ocspClient, + ocspServiceProvider, + null, + null, + ALLOWED_TIME_SKEW, + MAX_THIS_UPDATE_AGE, + false + ); + CircuitBreakerRegistry circuitBreakerRegistry = resilientOcspService.getCircuitBreakerRegistry(); + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(PRIMARY_OCSP_URL.toASCIIString()); + try (var mockedClock = mockStatic(DateAndTime.DefaultClock.class)) { + mockDate("2021-09-18T00:16:25", mockedClock); + + assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> + resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate)) + .withMessage("User certificate revocation check has failed") + .withCause(new IOException("Mocked exception 2")) + .satisfies(e -> assertThat(e.getOcspValidationInfo()).isNotNull()) + .satisfies(e -> assertThat(e.getOcspValidationInfo().getSubjectCertificate()).isNotNull()) + .satisfies(e -> assertThat(e.getOcspValidationInfo().getOcspResponse()).isNull()); + + verify(ocspClient, times(1)).request(eq(PRIMARY_OCSP_URL), any()); + verify(ocspClient, times(1)).request(eq(FALLBACK_OCSP_URL), any()); + assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.CLOSED); + } + } + + @Test + void whenNoFallbackConfigured_thenPrimaryFailureThrows() throws Exception { + final OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_OCSP_URL), any())) + .thenThrow(new IOException("Mocked exception")); + OcspServiceProvider ocspServiceProvider = getAiaOcspServiceProvider(); + ResilientOcspService resilientOcspService = new ResilientOcspService( + ocspClient, + ocspServiceProvider, + null, + null, + ALLOWED_TIME_SKEW, + MAX_THIS_UPDATE_AGE, + false + ); + try (var mockedClock = mockStatic(DateAndTime.DefaultClock.class)) { + mockDate("2021-09-17T18:25:24", mockedClock); + + assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> + resilientOcspService.validateSubjectCertificateNotRevoked(subjectCertificate, issuerCertificate)) + .withMessage("User certificate revocation check has failed") + .withCause(new IOException("Mocked exception")) + .satisfies(e -> assertThat(e.getOcspValidationInfo()).isNotNull()) + .satisfies(e -> assertThat(e.getOcspValidationInfo().getSubjectCertificate()).isNotNull()) + .satisfies(e -> assertThat(e.getOcspValidationInfo().getOcspResponse()).isNull()); + } + } + + private OcspServiceProvider createOcspServiceProviderWithFallback() throws Exception { + FallbackOcspServiceConfiguration fallbackConfig = new FallbackOcspServiceConfiguration( + PRIMARY_OCSP_URL, + FALLBACK_OCSP_URL, + getTestSkOcspResponder2018(), + false + ); + return createOcspServiceProviderWithFallback(fallbackConfig); + } + + private OcspServiceProvider createOcspServiceProviderWithFallback(FallbackOcspServiceConfiguration fallbackConfig) throws Exception { + List trustedCACertificates = Arrays.asList( + getTestEsteid2018CA(), + getTestSkOcspResponder2020(), + getTestEsteid2015CA() + ); + AiaOcspServiceConfiguration aiaConfig = + new AiaOcspServiceConfiguration( + Set.of(PRIMARY_OCSP_URL), + CertificateValidator.buildTrustAnchorsFromCertificates(trustedCACertificates), + CertificateValidator.buildCertStoreFromCertificates(trustedCACertificates) + ); + return new OcspServiceProvider( + null, + aiaConfig, + Collections.singletonList(fallbackConfig) + ); + } + + private static byte[] getSystemResource(String name) throws IOException { + try (InputStream resourceAsStream = ClassLoader.getSystemResourceAsStream(name)) { + if (resourceAsStream == null) { + throw new IOException("Resource not found: " + name); + } + return resourceAsStream.readAllBytes(); + } + } +} diff --git a/src/test/resources/TEST_of_SK_OCSP_RESPONDER_2018.cer b/src/test/resources/TEST_of_SK_OCSP_RESPONDER_2018.cer new file mode 100644 index 00000000..30ce32b0 Binary files /dev/null and b/src/test/resources/TEST_of_SK_OCSP_RESPONDER_2018.cer differ