diff --git a/pom.xml b/pom.xml index c5906759..a9072c5f 100644 --- a/pom.xml +++ b/pom.xml @@ -16,6 +16,7 @@ 1.81 2.19.1 2.0.17 + 2.3.0 5.13.3 3.27.3 5.18.0 @@ -65,6 +66,34 @@ bcpkix-jdk18on ${bouncycastle.version} + + io.github.resilience4j + resilience4j-all + ${resilience4j.version} + + + io.github.resilience4j + resilience4j-bulkhead + + + io.github.resilience4j + resilience4j-cache + + + io.github.resilience4j + resilience4j-ratelimiter + + + io.github.resilience4j + resilience4j-timelimiter + + + + + io.github.resilience4j + resilience4j-vavr + ${resilience4j.version} + org.junit.jupiter diff --git a/src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java b/src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java index cb1152c9..d57ed8d4 100644 --- a/src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java +++ b/src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java @@ -64,7 +64,7 @@ import static eu.webeid.security.util.DateAndTime.requirePositiveDuration; import static java.util.Objects.requireNonNull; -public final class OcspCertificateRevocationChecker implements CertificateRevocationChecker { +public class OcspCertificateRevocationChecker implements CertificateRevocationChecker { public static final Duration DEFAULT_TIME_SKEW = Duration.ofMinutes(15); public static final Duration DEFAULT_THIS_UPDATE_AGE = Duration.ofMinutes(2); @@ -131,7 +131,7 @@ public List validateCertificateNotRevoked(X509Certificate subjec } LOG.debug("OCSP response received successfully"); - verifyOcspResponse(basicResponse, ocspService, certificateId); + verifyOcspResponse(basicResponse, ocspService, certificateId, false, false); if (ocspService.doesSupportNonce()) { checkNonce(request, basicResponse, ocspResponderUri); } @@ -144,7 +144,7 @@ public List validateCertificateNotRevoked(X509Certificate subjec } } - private void verifyOcspResponse(BasicOCSPResp basicResponse, OcspService ocspService, CertificateID requestCertificateId) throws AuthTokenException, OCSPException, CertificateException, OperatorCreationException { + protected void verifyOcspResponse(BasicOCSPResp basicResponse, OcspService ocspService, CertificateID requestCertificateId, boolean rejectUnknownOcspResponseStatus, boolean allowThisUpdateInPast) throws AuthTokenException, OCSPException, CertificateException, OperatorCreationException { // The verification algorithm follows RFC 2560, https://www.ietf.org/rfc/rfc2560.txt. // // 3.2. Signed Response Acceptance Requirements @@ -195,14 +195,14 @@ private void verifyOcspResponse(BasicOCSPResp basicResponse, OcspService ocspSer // be available about the status of the certificate (nextUpdate) is // greater than the current time. - OcspResponseValidator.validateCertificateStatusUpdateTime(certStatusResponse, allowedOcspResponseTimeSkew, maxOcspResponseThisUpdateAge, ocspService.getAccessLocation()); + OcspResponseValidator.validateCertificateStatusUpdateTime(certStatusResponse, allowedOcspResponseTimeSkew, maxOcspResponseThisUpdateAge, ocspService.getAccessLocation(), allowThisUpdateInPast); // Now we can accept the signed response as valid and validate the certificate status. - OcspResponseValidator.validateSubjectCertificateStatus(certStatusResponse, ocspService.getAccessLocation()); + OcspResponseValidator.validateSubjectCertificateStatus(certStatusResponse, ocspService.getAccessLocation(), rejectUnknownOcspResponseStatus); LOG.debug("OCSP check result is GOOD"); } - private static void checkNonce(OCSPReq request, BasicOCSPResp response, URI ocspResponderUri) throws UserCertificateOCSPCheckFailedException { + protected static void checkNonce(OCSPReq request, BasicOCSPResp response, URI ocspResponderUri) throws UserCertificateOCSPCheckFailedException { final Extension requestNonce = request.getExtension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce); final Extension responseNonce = response.getExtension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce); if (requestNonce == null || responseNonce == null) { @@ -215,14 +215,14 @@ private static void checkNonce(OCSPReq request, BasicOCSPResp response, URI ocsp } } - private static CertificateID getCertificateId(X509Certificate subjectCertificate, X509Certificate issuerCertificate) throws CertificateEncodingException, IOException, OCSPException { + protected static CertificateID getCertificateId(X509Certificate subjectCertificate, X509Certificate issuerCertificate) throws CertificateEncodingException, IOException, OCSPException { final BigInteger serial = subjectCertificate.getSerialNumber(); final DigestCalculator digestCalculator = DigestCalculatorImpl.sha1(); return new CertificateID(digestCalculator, new X509CertificateHolder(issuerCertificate.getEncoded()), serial); } - private static String ocspStatusToString(int status) { + protected static String ocspStatusToString(int status) { return switch (status) { case OCSPResp.MALFORMED_REQUEST -> "malformed request"; case OCSPResp.INTERNAL_ERROR -> "internal error"; @@ -233,4 +233,11 @@ private static String ocspStatusToString(int status) { }; } + protected OcspClient getOcspClient() { + return ocspClient; + } + + protected OcspServiceProvider getOcspServiceProvider() { + return ocspServiceProvider; + } } diff --git a/src/main/java/eu/webeid/ocsp/client/OcspClient.java b/src/main/java/eu/webeid/ocsp/client/OcspClient.java index b0b83412..2e8524f0 100644 --- a/src/main/java/eu/webeid/ocsp/client/OcspClient.java +++ b/src/main/java/eu/webeid/ocsp/client/OcspClient.java @@ -22,14 +22,14 @@ package eu.webeid.ocsp.client; +import eu.webeid.ocsp.exceptions.OCSPClientException; import org.bouncycastle.cert.ocsp.OCSPReq; import org.bouncycastle.cert.ocsp.OCSPResp; -import java.io.IOException; import java.net.URI; public interface OcspClient { - OCSPResp request(URI url, OCSPReq request) throws IOException; + OCSPResp request(URI url, OCSPReq request) throws OCSPClientException; } diff --git a/src/main/java/eu/webeid/ocsp/client/OcspClientImpl.java b/src/main/java/eu/webeid/ocsp/client/OcspClientImpl.java index 2134d04c..bd5ec523 100644 --- a/src/main/java/eu/webeid/ocsp/client/OcspClientImpl.java +++ b/src/main/java/eu/webeid/ocsp/client/OcspClientImpl.java @@ -22,6 +22,7 @@ package eu.webeid.ocsp.client; +import eu.webeid.ocsp.exceptions.OCSPClientException; import org.bouncycastle.cert.ocsp.OCSPReq; import org.bouncycastle.cert.ocsp.OCSPResp; import org.slf4j.Logger; @@ -62,15 +63,21 @@ public static OcspClient build(Duration ocspRequestTimeout) { * @param uri OCSP server URL * @param ocspReq OCSP request * @return OCSP response from the server - * @throws IOException if the request could not be executed due to cancellation, a connectivity problem or timeout, + * @throws OCSPClientException if the request could not be executed due to cancellation, a connectivity problem or timeout, * or if the response status is not successful, or if response has wrong content type. */ @Override - public OCSPResp request(URI uri, OCSPReq ocspReq) throws IOException { + public OCSPResp request(URI uri, OCSPReq ocspReq) throws OCSPClientException { + byte[] encodedOcspReq; + try { + encodedOcspReq = ocspReq.getEncoded(); + } catch (IOException e) { + throw new OCSPClientException(e); + } final HttpRequest request = HttpRequest.newBuilder() .uri(uri) .header(CONTENT_TYPE, OCSP_REQUEST_TYPE) - .POST(HttpRequest.BodyPublishers.ofByteArray(ocspReq.getEncoded())) + .POST(HttpRequest.BodyPublishers.ofByteArray(encodedOcspReq)) .timeout(ocspRequestTimeout) .build(); @@ -79,19 +86,28 @@ public OCSPResp request(URI uri, OCSPReq ocspReq) throws IOException { response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - throw new IOException("Interrupted while sending OCSP request", e); + throw new OCSPClientException("Interrupted while sending OCSP request", e); + } catch (IOException e) { + throw new OCSPClientException(e); } if (response.statusCode() != 200) { - throw new IOException("OCSP request was not successful, response: " + response); + throw new OCSPClientException("OCSP request was not successful", response.body(), response.statusCode()); } else { LOG.debug("OCSP response: {}", response); } final String contentType = response.headers().firstValue(CONTENT_TYPE).orElse(""); if (!contentType.startsWith(OCSP_RESPONSE_TYPE)) { - throw new IOException("OCSP response content type is not " + OCSP_RESPONSE_TYPE); + throw new OCSPClientException("OCSP response content type is not " + OCSP_RESPONSE_TYPE); + } + + OCSPResp ocspResp; + try { + ocspResp = new OCSPResp(response.body()); + } catch (IOException e) { + throw new OCSPClientException(e); } - return new OCSPResp(response.body()); + return ocspResp; } public OcspClientImpl(HttpClient httpClient, Duration ocspRequestTimeout) { diff --git a/src/main/java/eu/webeid/ocsp/exceptions/OCSPClientException.java b/src/main/java/eu/webeid/ocsp/exceptions/OCSPClientException.java new file mode 100644 index 00000000..2003141b --- /dev/null +++ b/src/main/java/eu/webeid/ocsp/exceptions/OCSPClientException.java @@ -0,0 +1,59 @@ +/* + * 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.ocsp.exceptions; + +public class OCSPClientException extends RuntimeException { + + private byte[] responseBody; + + private Integer statusCode; + + public OCSPClientException() { + } + + public OCSPClientException(String message) { + super(message); + } + + public OCSPClientException(Throwable cause) { + super(cause); + } + + public OCSPClientException(String message, Throwable cause) { + super(message, cause); + } + + public OCSPClientException(String message, byte[] responseBody, int statusCode) { + super(message); + this.responseBody = responseBody; + this.statusCode = statusCode; + } + + public byte[] getResponseBody() { + return responseBody; + } + + public Integer getStatusCode() { + return statusCode; + } +} diff --git a/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateOCSPCheckFailedException.java b/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateOCSPCheckFailedException.java index e843fc1b..a523bde2 100644 --- a/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateOCSPCheckFailedException.java +++ b/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateOCSPCheckFailedException.java @@ -33,6 +33,10 @@ */ public class UserCertificateOCSPCheckFailedException extends AuthTokenException { + public UserCertificateOCSPCheckFailedException() { + super("User certificate revocation check has failed"); + } + public UserCertificateOCSPCheckFailedException(Throwable cause, URI ocspResponderUri) { super(withResponderUri("User certificate revocation check has failed", ocspResponderUri), cause); } diff --git a/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateRevokedException.java b/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateRevokedException.java index 9f9e55ae..336dd78c 100644 --- a/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateRevokedException.java +++ b/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateRevokedException.java @@ -33,6 +33,10 @@ */ public class UserCertificateRevokedException extends AuthTokenException { + public UserCertificateRevokedException() { + super("User certificate has been revoked"); + } + public UserCertificateRevokedException(URI ocspResponderUri) { super(withResponderUri("User certificate has been revoked", ocspResponderUri)); } diff --git a/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateUnknownException.java b/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateUnknownException.java new file mode 100644 index 00000000..66d1aad6 --- /dev/null +++ b/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateUnknownException.java @@ -0,0 +1,36 @@ +/* + * 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.ocsp.exceptions; + +import eu.webeid.security.exceptions.AuthTokenException; + +import java.net.URI; + +import static eu.webeid.ocsp.exceptions.OcspResponderUriMessage.withResponderUri; + +public class UserCertificateUnknownException extends AuthTokenException { + + public UserCertificateUnknownException(String msg, URI ocspResponderUri) { + super(withResponderUri("User certificate status is unknown: " + msg, ocspResponderUri)); + } +} diff --git a/src/main/java/eu/webeid/ocsp/protocol/IssuerCommonName.java b/src/main/java/eu/webeid/ocsp/protocol/IssuerCommonName.java new file mode 100644 index 00000000..8e1548ed --- /dev/null +++ b/src/main/java/eu/webeid/ocsp/protocol/IssuerCommonName.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.ocsp.protocol; + +import org.bouncycastle.asn1.x500.RDN; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x500.style.BCStyle; +import org.bouncycastle.asn1.x500.style.IETFUtils; +import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder; + +import java.security.cert.CertificateEncodingException; +import java.security.cert.X509Certificate; +import java.util.Objects; +import java.util.Optional; + +public class IssuerCommonName { + + public static Optional getIssuerCommonName(X509Certificate certificate) { + Objects.requireNonNull(certificate, "certificate"); + try { + X500Name x500Name = new JcaX509CertificateHolder(certificate).getIssuer(); + final RDN cn = x500Name.getRDNs(BCStyle.CN)[0]; + return Optional.of(IETFUtils.valueToString(cn.getFirst().getValue())); + } catch (CertificateEncodingException e) { + return Optional.empty(); + } + } + + private IssuerCommonName() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/src/main/java/eu/webeid/ocsp/protocol/OcspResponseValidator.java b/src/main/java/eu/webeid/ocsp/protocol/OcspResponseValidator.java index 6c7d69fa..1170f419 100644 --- a/src/main/java/eu/webeid/ocsp/protocol/OcspResponseValidator.java +++ b/src/main/java/eu/webeid/ocsp/protocol/OcspResponseValidator.java @@ -25,6 +25,8 @@ import eu.webeid.ocsp.exceptions.OCSPCertificateException; import eu.webeid.ocsp.exceptions.UserCertificateOCSPCheckFailedException; import eu.webeid.ocsp.exceptions.UserCertificateRevokedException; +import eu.webeid.ocsp.exceptions.UserCertificateUnknownException; +import eu.webeid.security.exceptions.AuthTokenException; import eu.webeid.security.util.DateAndTime; import org.bouncycastle.cert.X509CertificateHolder; import org.bouncycastle.cert.ocsp.BasicOCSPResp; @@ -77,7 +79,7 @@ public static void validateResponseSignature(BasicOCSPResp basicResponse, X509Ce } } - public static void validateCertificateStatusUpdateTime(SingleResp certStatusResponse, Duration allowedTimeSkew, Duration maxThisupdateAge, URI ocspResponderUri) throws UserCertificateOCSPCheckFailedException { + public static void validateCertificateStatusUpdateTime(SingleResp certStatusResponse, Duration allowedTimeSkew, Duration maxThisupdateAge, URI ocspResponderUri, boolean allowThisUpdateInPast) throws UserCertificateOCSPCheckFailedException { // From RFC 2560, https://www.ietf.org/rfc/rfc2560.txt: // 4.2.2. Notes on OCSP Responses // 4.2.2.1. Time @@ -98,7 +100,7 @@ public static void validateCertificateStatusUpdateTime(SingleResp certStatusResp "thisUpdate '" + thisUpdate + "' is too far in the future, " + "latest allowed: '" + latestAcceptableTimeSkew + "'", ocspResponderUri); } - if (thisUpdate.isBefore(minimumValidThisUpdateTime)) { + if (!allowThisUpdateInPast && thisUpdate.isBefore(minimumValidThisUpdateTime)) { throw new UserCertificateOCSPCheckFailedException(ERROR_PREFIX + "thisUpdate '" + thisUpdate + "' is too old, " + "minimum time allowed: '" + minimumValidThisUpdateTime + "'", ocspResponderUri); @@ -118,7 +120,7 @@ public static void validateCertificateStatusUpdateTime(SingleResp certStatusResp } } - public static void validateSubjectCertificateStatus(SingleResp certStatusResponse, URI ocspResponderUri) throws UserCertificateRevokedException { + public static void validateSubjectCertificateStatus(SingleResp certStatusResponse, URI ocspResponderUri, boolean rejectUnknownOcspResponseStatus) throws AuthTokenException { final CertificateStatus status = certStatusResponse.getCertStatus(); if (status == null) { return; @@ -128,9 +130,11 @@ public static void validateSubjectCertificateStatus(SingleResp certStatusRespons new UserCertificateRevokedException("Revocation reason: " + revokedStatus.getRevocationReason(), ocspResponderUri) : new UserCertificateRevokedException(ocspResponderUri)); } else if (status instanceof UnknownStatus) { - throw new UserCertificateRevokedException("Unknown status", ocspResponderUri); + throw rejectUnknownOcspResponseStatus ? new UserCertificateUnknownException("Unknown status", ocspResponderUri) + : new UserCertificateRevokedException("Unknown status", ocspResponderUri); } else { - throw new UserCertificateRevokedException("Status is neither good, revoked nor unknown", ocspResponderUri); + throw rejectUnknownOcspResponseStatus ? new UserCertificateUnknownException("Status is neither good, revoked nor unknown", ocspResponderUri) + : new UserCertificateRevokedException("Status is neither good, revoked nor unknown", ocspResponderUri); } } diff --git a/src/main/java/eu/webeid/ocsp/service/AiaOcspService.java b/src/main/java/eu/webeid/ocsp/service/AiaOcspService.java index 30157714..de130071 100644 --- a/src/main/java/eu/webeid/ocsp/service/AiaOcspService.java +++ b/src/main/java/eu/webeid/ocsp/service/AiaOcspService.java @@ -40,6 +40,7 @@ import java.util.Objects; import java.util.Set; +import static eu.webeid.ocsp.protocol.IssuerCommonName.getIssuerCommonName; import static eu.webeid.ocsp.protocol.OcspUrl.getOcspUri; /** @@ -52,13 +53,17 @@ 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; + String issuerCN = getIssuerCommonName(certificate).orElseThrow(() -> + new UserCertificateOCSPCheckFailedException("Getting the issuer common name failed")); + this.supportsNonce = !configuration.getNonceDisabledIssuerCNs().contains(issuerCN); } @Override @@ -71,6 +76,11 @@ public URI getAccessLocation() { return url; } + @Override + public FallbackOcspService getFallbackService() { + return fallbackOcspService; + } + @Override public void validateResponderCertificate(X509CertificateHolder cert, Date now) throws AuthTokenException { try { diff --git a/src/main/java/eu/webeid/ocsp/service/AiaOcspServiceConfiguration.java b/src/main/java/eu/webeid/ocsp/service/AiaOcspServiceConfiguration.java index 1a97f5d5..9c910a13 100644 --- a/src/main/java/eu/webeid/ocsp/service/AiaOcspServiceConfiguration.java +++ b/src/main/java/eu/webeid/ocsp/service/AiaOcspServiceConfiguration.java @@ -22,7 +22,6 @@ package eu.webeid.ocsp.service; -import java.net.URI; import java.security.cert.CertStore; import java.security.cert.TrustAnchor; import java.util.Collection; @@ -31,18 +30,18 @@ public class AiaOcspServiceConfiguration { - private final Collection nonceDisabledOcspUrls; + private final Collection nonceDisabledIssuerCNs; private final Set trustedCACertificateAnchors; private final CertStore trustedCACertificateCertStore; - public AiaOcspServiceConfiguration(Collection nonceDisabledOcspUrls, Set trustedCACertificateAnchors, CertStore trustedCACertificateCertStore) { - this.nonceDisabledOcspUrls = Objects.requireNonNull(nonceDisabledOcspUrls); + public AiaOcspServiceConfiguration(Collection nonceDisabledIssuerCNs, Set trustedCACertificateAnchors, CertStore trustedCACertificateCertStore) { + this.nonceDisabledIssuerCNs = Objects.requireNonNull(nonceDisabledIssuerCNs); this.trustedCACertificateAnchors = Objects.requireNonNull(trustedCACertificateAnchors); this.trustedCACertificateCertStore = Objects.requireNonNull(trustedCACertificateCertStore); } - public Collection getNonceDisabledOcspUrls() { - return nonceDisabledOcspUrls; + public Collection getNonceDisabledIssuerCNs() { + return nonceDisabledIssuerCNs; } public Set getTrustedCACertificateAnchors() { diff --git a/src/main/java/eu/webeid/ocsp/service/FallbackOcspService.java b/src/main/java/eu/webeid/ocsp/service/FallbackOcspService.java new file mode 100644 index 00000000..06c0903e --- /dev/null +++ b/src/main/java/eu/webeid/ocsp/service/FallbackOcspService.java @@ -0,0 +1,84 @@ +/* + * 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.ocsp.service; + +import eu.webeid.ocsp.exceptions.OCSPCertificateException; +import eu.webeid.security.exceptions.AuthTokenException; +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.requireCertificateIsValidOnDate; + +public class FallbackOcspService implements OcspService { + + private final JcaX509CertificateConverter certificateConverter = new JcaX509CertificateConverter(); + private final URI url; + private final boolean supportsNonce; + private final X509Certificate trustedResponderCertificate; + private final FallbackOcspService nextFallback; + + public FallbackOcspService(FallbackOcspServiceConfiguration configuration) { + this.url = configuration.getAccessLocation(); + this.supportsNonce = configuration.doesSupportNonce(); + this.trustedResponderCertificate = configuration.getResponderCertificate(); + this.nextFallback = configuration.getNextFallbackConfiguration() != null + ? new FallbackOcspService(configuration.getNextFallbackConfiguration()) + : null; + } + + @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"); + } + requireCertificateIsValidOnDate(responderCertificate, now, "Fallback OCSP responder"); + } catch (CertificateException e) { + throw new OCSPCertificateException("X509CertificateHolder conversion to X509Certificate failed", e); + } + } + + public FallbackOcspService getNextFallback() { + return nextFallback; + } +} diff --git a/src/main/java/eu/webeid/ocsp/service/FallbackOcspServiceConfiguration.java b/src/main/java/eu/webeid/ocsp/service/FallbackOcspServiceConfiguration.java new file mode 100644 index 00000000..1f5a1ed2 --- /dev/null +++ b/src/main/java/eu/webeid/ocsp/service/FallbackOcspServiceConfiguration.java @@ -0,0 +1,64 @@ +/* + * 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.ocsp.service; + +import eu.webeid.ocsp.exceptions.OCSPCertificateException; +import eu.webeid.ocsp.protocol.OcspResponseValidator; + +import java.net.URI; +import java.security.cert.X509Certificate; +import java.util.Objects; + +public class FallbackOcspServiceConfiguration { + + private final URI accessLocation; + private final X509Certificate responderCertificate; + private final boolean doesSupportNonce; + private final FallbackOcspServiceConfiguration nextFallbackConfiguration; + + public FallbackOcspServiceConfiguration(URI accessLocation, X509Certificate responderCertificate, + boolean doesSupportNonce, + FallbackOcspServiceConfiguration nextFallbackConfiguration) throws OCSPCertificateException { + this.accessLocation = Objects.requireNonNull(accessLocation, "Fallback OCSP service access location"); + this.responderCertificate = Objects.requireNonNull(responderCertificate, "Fallback OCSP responder certificate"); + OcspResponseValidator.validateHasSigningExtension(responderCertificate); + this.doesSupportNonce = doesSupportNonce; + this.nextFallbackConfiguration = nextFallbackConfiguration; + } + + public URI getAccessLocation() { + return accessLocation; + } + + public X509Certificate getResponderCertificate() { + return responderCertificate; + } + + public boolean doesSupportNonce() { + return doesSupportNonce; + } + + public FallbackOcspServiceConfiguration getNextFallbackConfiguration() { + return nextFallbackConfiguration; + } +} diff --git a/src/main/java/eu/webeid/ocsp/service/OcspService.java b/src/main/java/eu/webeid/ocsp/service/OcspService.java index 8d346e37..8b332786 100644 --- a/src/main/java/eu/webeid/ocsp/service/OcspService.java +++ b/src/main/java/eu/webeid/ocsp/service/OcspService.java @@ -36,4 +36,8 @@ public interface OcspService { void validateResponderCertificate(X509CertificateHolder cert, Date now) throws AuthTokenException; + default FallbackOcspService getFallbackService() { + return null; + } + } diff --git a/src/main/java/eu/webeid/ocsp/service/OcspServiceProvider.java b/src/main/java/eu/webeid/ocsp/service/OcspServiceProvider.java index 56deb1e6..bbf0cc69 100644 --- a/src/main/java/eu/webeid/ocsp/service/OcspServiceProvider.java +++ b/src/main/java/eu/webeid/ocsp/service/OcspServiceProvider.java @@ -22,22 +22,40 @@ package eu.webeid.ocsp.service; +import eu.webeid.ocsp.exceptions.UserCertificateOCSPCheckFailedException; import eu.webeid.security.exceptions.AuthTokenException; import java.security.cert.CertificateEncodingException; import java.security.cert.X509Certificate; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; import java.util.Objects; +import static eu.webeid.ocsp.protocol.IssuerCommonName.getIssuerCommonName; + public class OcspServiceProvider { private final DesignatedOcspService designatedOcspService; private final AiaOcspServiceConfiguration aiaOcspServiceConfiguration; + private final Map fallbackOcspServiceMap = new HashMap<>(); 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"); + if (fallbackOcspServiceConfigurations != null) { + for (FallbackOcspServiceConfiguration configuration : fallbackOcspServiceConfigurations) { + String issuerCN = getIssuerCommonName(configuration.getResponderCertificate()).orElseThrow(() -> + new RuntimeException("Certificate does not contain issuer CN")); + fallbackOcspServiceMap.put(issuerCN, new FallbackOcspService(configuration)); + } + } } /** @@ -46,14 +64,16 @@ public OcspServiceProvider(DesignatedOcspServiceConfiguration designatedOcspServ * * @param certificate subject certificate that is to be checked with OCSP * @return either the designated or AIA OCSP service instance - * @throws AuthTokenException when AIA URL is not found in certificate - * @throws CertificateEncodingException when certificate is invalid + * @throws UserCertificateOCSPCheckFailedException when issuer common name is not found in certificate + * @throws IllegalArgumentException when certificate is invalid */ public OcspService getService(X509Certificate certificate) throws AuthTokenException, CertificateEncodingException { if (designatedOcspService != null && designatedOcspService.supportsIssuerOf(certificate)) { return designatedOcspService; } - return new AiaOcspService(aiaOcspServiceConfiguration, certificate); + String issuerCommonName = getIssuerCommonName(certificate).orElseThrow(() -> + new UserCertificateOCSPCheckFailedException("Getting the issuer common name failed")); + FallbackOcspService fallbackOcspService = fallbackOcspServiceMap.get(issuerCommonName); + return new AiaOcspService(aiaOcspServiceConfiguration, certificate, fallbackOcspService); } - } diff --git a/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java b/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java new file mode 100644 index 00000000..7ba531ec --- /dev/null +++ b/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java @@ -0,0 +1,326 @@ +/* + * Copyright (c) 2020-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package eu.webeid.resilientocsp; + +import eu.webeid.ocsp.OcspCertificateRevocationChecker; +import eu.webeid.ocsp.client.OcspClient; +import eu.webeid.ocsp.exceptions.OCSPClientException; +import eu.webeid.ocsp.exceptions.UserCertificateRevokedException; +import eu.webeid.ocsp.protocol.OcspRequestBuilder; +import eu.webeid.ocsp.service.OcspService; +import eu.webeid.ocsp.service.OcspServiceProvider; +import eu.webeid.resilientocsp.exceptions.ResilientUserCertificateOCSPCheckFailedException; +import eu.webeid.resilientocsp.exceptions.ResilientUserCertificateRevokedException; +import eu.webeid.ocsp.service.FallbackOcspService; +import eu.webeid.security.exceptions.AuthTokenException; +import eu.webeid.security.validator.ValidationInfo; +import eu.webeid.security.validator.revocationcheck.RevocationInfo; +import io.github.resilience4j.circuitbreaker.CallNotPermittedException; +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +import io.github.resilience4j.core.functions.CheckedSupplier; +import io.github.resilience4j.decorators.Decorators; +import io.github.resilience4j.retry.Retry; +import io.github.resilience4j.retry.RetryConfig; +import io.github.resilience4j.retry.RetryRegistry; +import io.vavr.control.Try; +import org.bouncycastle.asn1.ocsp.OCSPResponseStatus; +import org.bouncycastle.cert.ocsp.BasicOCSPResp; +import org.bouncycastle.cert.ocsp.CertificateID; +import org.bouncycastle.cert.ocsp.OCSPReq; +import org.bouncycastle.cert.ocsp.OCSPResp; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URI; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static java.util.Objects.requireNonNull; + +public class ResilientOcspCertificateRevocationChecker extends OcspCertificateRevocationChecker { + + private static final Logger LOG = LoggerFactory.getLogger(ResilientOcspCertificateRevocationChecker.class); + + private final CircuitBreakerRegistry circuitBreakerRegistry; + private final RetryRegistry retryRegistry; + private final boolean rejectUnknownOcspResponseStatus; + + public ResilientOcspCertificateRevocationChecker(OcspClient ocspClient, + OcspServiceProvider ocspServiceProvider, + CircuitBreakerConfig circuitBreakerConfig, + RetryConfig retryConfig, + Duration allowedOcspResponseTimeSkew, + Duration maxOcspResponseThisUpdateAge, + boolean rejectUnknownOcspResponseStatus) { + super(ocspClient, ocspServiceProvider, allowedOcspResponseTimeSkew, maxOcspResponseThisUpdateAge); + this.rejectUnknownOcspResponseStatus = rejectUnknownOcspResponseStatus; + this.circuitBreakerRegistry = CircuitBreakerRegistry.custom() + .withCircuitBreakerConfig(getCircuitBreakerConfig(circuitBreakerConfig)) + .build(); + this.retryRegistry = retryConfig != null ? RetryRegistry.custom() + .withRetryConfig(getRetryConfig(retryConfig)) + .build() : null; + if (LOG.isDebugEnabled()) { + this.circuitBreakerRegistry.getEventPublisher() + .onEntryAdded(entryAddedEvent -> { + CircuitBreaker circuitBreaker = entryAddedEvent.getAddedEntry(); + LOG.debug("CircuitBreaker {} added", circuitBreaker.getName()); + circuitBreaker.getEventPublisher() + .onEvent(event -> LOG.debug(event.toString())); + }); + } + } + + @Override + public List validateCertificateNotRevoked(X509Certificate subjectCertificate, + X509Certificate issuerCertificate) throws AuthTokenException { + OcspService primaryService = resolvePrimaryOcspService(subjectCertificate); + + if (primaryService.getFallbackService() == null) { + return List.of(request(primaryService, subjectCertificate, issuerCertificate, false)); + } + + List revocationInfoList = new ArrayList<>(); + CheckedSupplier fallbackSupplier = buildFallbackSupplier(primaryService, subjectCertificate, + issuerCertificate, revocationInfoList); + CheckedSupplier decoratedSupplier = decorateWithResilience(primaryService, subjectCertificate, + issuerCertificate, revocationInfoList, fallbackSupplier); + + RevocationInfo revocationInfo = processResult(Try.of(decoratedSupplier::get), subjectCertificate, revocationInfoList); + revocationInfoList.add(revocationInfo); + return revocationInfoList; + } + + private OcspService resolvePrimaryOcspService(X509Certificate subjectCertificate) throws AuthTokenException { + try { + return getOcspServiceProvider().getService(subjectCertificate); + } catch (CertificateException e) { + throw new ResilientUserCertificateOCSPCheckFailedException(new ValidationInfo(subjectCertificate, List.of())); + } + } + + private CheckedSupplier buildFallbackSupplier(OcspService primaryService, + X509Certificate subjectCertificate, + X509Certificate issuerCertificate, + List revocationInfoList) { + final FallbackOcspService firstFallbackService = primaryService.getFallbackService(); + CheckedSupplier firstFallbackSupplier = () -> { + try { + return request(firstFallbackService, subjectCertificate, issuerCertificate, true); + } catch (Exception e) { + createAndAddRevocationInfoToList(e, revocationInfoList); + throw e; + } + }; + // NOTE: Up to two fallbacks are currently supported. To enable the full potential of recursive fallbacks + // with FallbackOcspService#getNextFallback, the fallback supplier creation needs to be changed. + OcspService secondFallbackService = firstFallbackService.getNextFallback(); + if (secondFallbackService == null) { + return firstFallbackSupplier; + } + CheckedSupplier secondFallbackSupplier = () -> { + try { + return request(secondFallbackService, subjectCertificate, issuerCertificate, true); + } catch (Exception e) { + createAndAddRevocationInfoToList(e, revocationInfoList); + throw e; + } + }; + return () -> { + try { + return firstFallbackSupplier.get(); + } catch (ResilientUserCertificateRevokedException e) { + // NOTE: ResilientUserCertificateRevokedException must be re-thrown before the generic + // catch (Exception) block. Without this, a "revoked" verdict from the first fallback would + // be swallowed, and the second fallback could silently override it with a "good" response. + throw e; + } catch (Exception e) { + return secondFallbackSupplier.get(); + } + }; + } + + private CheckedSupplier decorateWithResilience(OcspService primaryService, + X509Certificate subjectCertificate, + X509Certificate issuerCertificate, + List revocationInfoList, + CheckedSupplier fallbackSupplier + ) { + CheckedSupplier primarySupplier = () -> { + try { + return request(primaryService, subjectCertificate, issuerCertificate, false); + } catch (Exception e) { + createAndAddRevocationInfoToList(e, revocationInfoList); + throw e; + } + }; + Decorators.DecorateCheckedSupplier decorateCheckedSupplier = Decorators.ofCheckedSupplier(primarySupplier); + if (retryRegistry != null) { + Retry retry = retryRegistry.retry(primaryService.getAccessLocation().toASCIIString()); + decorateCheckedSupplier.withRetry(retry); + } + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(primaryService.getAccessLocation().toASCIIString()); + decorateCheckedSupplier.withCircuitBreaker(circuitBreaker) + .withFallback(List.of(ResilientUserCertificateOCSPCheckFailedException.class, CallNotPermittedException.class), e -> fallbackSupplier.get()); + + return decorateCheckedSupplier.decorate(); + } + + private RevocationInfo processResult(Try result, X509Certificate subjectCertificate, + List revocationInfoList) throws AuthTokenException { + return result.getOrElseThrow(throwable -> { + if (throwable instanceof ResilientUserCertificateOCSPCheckFailedException exception) { + exception.setValidationInfo(new ValidationInfo(subjectCertificate, revocationInfoList)); + return exception; + } + if (throwable instanceof ResilientUserCertificateRevokedException exception) { + exception.setValidationInfo(new ValidationInfo(subjectCertificate, revocationInfoList)); + return exception; + } + // TODO This should always be TaraUserCertificateOCSPCheckFailedException when reached? + return new ResilientUserCertificateOCSPCheckFailedException(new ValidationInfo(subjectCertificate, revocationInfoList)); + }); + } + + private void createAndAddRevocationInfoToList(Throwable throwable, List revocationInfoList) { + if (throwable instanceof ResilientUserCertificateOCSPCheckFailedException exception) { + revocationInfoList.addAll((exception.getValidationInfo().revocationInfoList())); + return; + } + if (throwable instanceof ResilientUserCertificateRevokedException exception) { + revocationInfoList.addAll((exception.getValidationInfo().revocationInfoList())); + return; + } + revocationInfoList.add(new RevocationInfo(null, Map.ofEntries( + Map.entry(RevocationInfo.KEY_OCSP_ERROR, throwable) + ))); + } + + private RevocationInfo request(OcspService ocspService, X509Certificate subjectCertificate, X509Certificate issuerCertificate, boolean allowThisUpdateInPast) throws ResilientUserCertificateOCSPCheckFailedException, ResilientUserCertificateRevokedException { + URI ocspResponderUri = null; + OCSPResp response = null; + OCSPReq request = null; + try { + ocspResponderUri = requireNonNull(ocspService.getAccessLocation(), "ocspResponderUri"); + + final CertificateID certificateId = getCertificateId(subjectCertificate, issuerCertificate); + request = new OcspRequestBuilder() + .withCertificateId(certificateId) + .enableOcspNonce(ocspService.doesSupportNonce()) + .build(); + + if (!ocspService.doesSupportNonce()) { + LOG.debug("Disabling OCSP nonce extension"); + } + + LOG.debug("Sending OCSP request"); + response = requireNonNull(getOcspClient().request(ocspResponderUri, request)); // TODO: This should trigger fallback? + if (response.getStatus() != OCSPResponseStatus.SUCCESSFUL) { + ResilientUserCertificateOCSPCheckFailedException exception = new ResilientUserCertificateOCSPCheckFailedException("Response status: " + ocspStatusToString(response.getStatus())); + RevocationInfo revocationInfo = new RevocationInfo(ocspService.getAccessLocation(), Map.ofEntries( + Map.entry(RevocationInfo.KEY_OCSP_ERROR, exception), + Map.entry(RevocationInfo.KEY_OCSP_REQUEST, request), + Map.entry(RevocationInfo.KEY_OCSP_RESPONSE, response) + )); + exception.setValidationInfo(new ValidationInfo(subjectCertificate, List.of(revocationInfo))); + throw exception; + } + + final BasicOCSPResp basicResponse = (BasicOCSPResp) response.getResponseObject(); + if (basicResponse == null) { + ResilientUserCertificateOCSPCheckFailedException exception = new ResilientUserCertificateOCSPCheckFailedException("Missing Basic OCSP Response"); + RevocationInfo revocationInfo = new RevocationInfo(ocspService.getAccessLocation(), Map.ofEntries( + Map.entry(RevocationInfo.KEY_OCSP_ERROR, exception), + Map.entry(RevocationInfo.KEY_OCSP_REQUEST, request), + Map.entry(RevocationInfo.KEY_OCSP_RESPONSE, response) + )); + exception.setValidationInfo(new ValidationInfo(subjectCertificate, List.of(revocationInfo))); + throw exception; + } + LOG.debug("OCSP response received successfully"); + + verifyOcspResponse(basicResponse, ocspService, certificateId, rejectUnknownOcspResponseStatus, allowThisUpdateInPast); + if (ocspService.doesSupportNonce()) { + checkNonce(request, basicResponse, ocspResponderUri); + } + LOG.debug("OCSP response verified successfully"); + + return new RevocationInfo(ocspResponderUri, Map.ofEntries( + Map.entry(RevocationInfo.KEY_OCSP_REQUEST, request), + Map.entry(RevocationInfo.KEY_OCSP_RESPONSE, response) + )); + } catch (UserCertificateRevokedException e) { + // NOTE: UserCertificateRevokedException covers both actual revocation and unknown status + // when rejectUnknownOcspResponseStatus=false (see OcspResponseValidator.validateSubjectCertificateStatus). + // When rejectUnknownOcspResponseStatus=true, unknown status throws UserCertificateUnknownException + // instead, which falls through to the generic catch (Exception) block below, gets wrapped as + // ResilientUserCertificateOCSPCheckFailedException, and triggers the circuit breaker fallback. + // Here, wrapping as ResilientUserCertificateRevokedException ensures the circuit breaker ignores it + // (a definitive OCSP answer, not a transient failure) and no fallback is attempted. + RevocationInfo revocationInfo = getRevocationInfo(ocspResponderUri, e, request, response); + throw new ResilientUserCertificateRevokedException(new ValidationInfo(subjectCertificate, List.of(revocationInfo))); + } catch (OCSPClientException e) { + RevocationInfo revocationInfo = getRevocationInfo(ocspResponderUri, e, request, response); + revocationInfo.ocspResponseAttributes().put(RevocationInfo.KEY_OCSP_RESPONSE, e.getResponseBody()); + revocationInfo.ocspResponseAttributes().put(RevocationInfo.KEY_HTTP_STATUS_CODE, e.getStatusCode()); + throw new ResilientUserCertificateOCSPCheckFailedException(new ValidationInfo(subjectCertificate, List.of(revocationInfo))); + } catch (Exception e) { + RevocationInfo revocationInfo = getRevocationInfo(ocspResponderUri, e, request, response); + throw new ResilientUserCertificateOCSPCheckFailedException(new ValidationInfo(subjectCertificate, List.of(revocationInfo))); + } + } + + private RevocationInfo getRevocationInfo(URI ocspResponderUri, Exception e, OCSPReq request, OCSPResp response) { + RevocationInfo revocationInfo = new RevocationInfo(ocspResponderUri, new HashMap<>(Map.of(RevocationInfo.KEY_OCSP_ERROR, e))); + if (request != null) { + revocationInfo.ocspResponseAttributes().put(RevocationInfo.KEY_OCSP_REQUEST, request); + } + if (response != null) { + revocationInfo.ocspResponseAttributes().put(RevocationInfo.KEY_OCSP_RESPONSE, response); + } + return revocationInfo; + } + + private static CircuitBreakerConfig getCircuitBreakerConfig(CircuitBreakerConfig circuitBreakerConfig) { + return CircuitBreakerConfig.from(circuitBreakerConfig) + // Users must not be able to modify these three values. + .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED) + .ignoreExceptions(ResilientUserCertificateRevokedException.class) + .automaticTransitionFromOpenToHalfOpenEnabled(true) + .build(); + } + + private static RetryConfig getRetryConfig(RetryConfig retryConfig) { + return RetryConfig.from(retryConfig) + // Users must not be able to modify this value. + .ignoreExceptions(ResilientUserCertificateRevokedException.class) + .build(); + } +} diff --git a/src/main/java/eu/webeid/resilientocsp/exceptions/ResilientUserCertificateOCSPCheckFailedException.java b/src/main/java/eu/webeid/resilientocsp/exceptions/ResilientUserCertificateOCSPCheckFailedException.java new file mode 100644 index 00000000..159de9c8 --- /dev/null +++ b/src/main/java/eu/webeid/resilientocsp/exceptions/ResilientUserCertificateOCSPCheckFailedException.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2020-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package eu.webeid.resilientocsp.exceptions; + +import eu.webeid.ocsp.exceptions.UserCertificateOCSPCheckFailedException; +import eu.webeid.security.validator.ValidationInfo; + +public class ResilientUserCertificateOCSPCheckFailedException extends UserCertificateOCSPCheckFailedException { + + private ValidationInfo validationInfo; + + public ResilientUserCertificateOCSPCheckFailedException(String message) { + this(message, null); + } + + public ResilientUserCertificateOCSPCheckFailedException(ValidationInfo validationInfo) { + super(); + this.validationInfo = validationInfo; + } + + public ResilientUserCertificateOCSPCheckFailedException(String message, ValidationInfo validationInfo) { + super(message); + this.validationInfo = validationInfo; + } + + public ValidationInfo getValidationInfo() { + return validationInfo; + } + + public void setValidationInfo(ValidationInfo validationInfo) { + this.validationInfo = validationInfo; + } +} diff --git a/src/main/java/eu/webeid/resilientocsp/exceptions/ResilientUserCertificateRevokedException.java b/src/main/java/eu/webeid/resilientocsp/exceptions/ResilientUserCertificateRevokedException.java new file mode 100644 index 00000000..27ec8f4e --- /dev/null +++ b/src/main/java/eu/webeid/resilientocsp/exceptions/ResilientUserCertificateRevokedException.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2020-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package eu.webeid.resilientocsp.exceptions; + +import eu.webeid.ocsp.exceptions.UserCertificateRevokedException; +import eu.webeid.security.validator.ValidationInfo; + +public class ResilientUserCertificateRevokedException extends UserCertificateRevokedException { + + private ValidationInfo validationInfo; + + public ResilientUserCertificateRevokedException(ValidationInfo validationInfo) { + this.validationInfo = validationInfo; + } + + public ValidationInfo getValidationInfo() { + return validationInfo; + } + + public void setValidationInfo(ValidationInfo validationInfo) { + this.validationInfo = validationInfo; + } +} diff --git a/src/main/java/eu/webeid/security/validator/revocationcheck/RevocationInfo.java b/src/main/java/eu/webeid/security/validator/revocationcheck/RevocationInfo.java index eda3a6e2..834d977e 100644 --- a/src/main/java/eu/webeid/security/validator/revocationcheck/RevocationInfo.java +++ b/src/main/java/eu/webeid/security/validator/revocationcheck/RevocationInfo.java @@ -26,7 +26,9 @@ public record RevocationInfo(URI ocspResponderUri, Map ocspResponseAttributes) { + public static final String KEY_OCSP_REQUEST = "OCSP_REQUEST"; public static final String KEY_OCSP_RESPONSE = "OCSP_RESPONSE"; public static final String KEY_OCSP_ERROR = "OCSP_ERROR"; + public static final String KEY_HTTP_STATUS_CODE = "HTTP_STATUS_CODE"; -} \ No newline at end of file +} diff --git a/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java b/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java index 50b31a9c..6c6348c3 100644 --- a/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java +++ b/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java @@ -22,6 +22,7 @@ package eu.webeid.ocsp; +import eu.webeid.ocsp.exceptions.OCSPClientException; import eu.webeid.security.exceptions.CertificateExpiredException; import eu.webeid.security.exceptions.CertificateNotTrustedException; import eu.webeid.security.exceptions.JceException; @@ -60,14 +61,16 @@ import static eu.webeid.security.testutil.DateMocker.mockDate; import static eu.webeid.ocsp.service.OcspServiceMaker.getAiaOcspServiceProvider; import static eu.webeid.ocsp.service.OcspServiceMaker.getDesignatedOcspServiceProvider; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.when; -class OcspCertificateRevocationCheckerTest extends AbstractTestWithValidator { +public class OcspCertificateRevocationCheckerTest extends AbstractTestWithValidator { private final OcspClient ocspClient = OcspClientImpl.build(Duration.ofSeconds(5)); private X509Certificate estEid2018Cert; @@ -120,7 +123,7 @@ void whenOcspUrlIsInvalid_thenThrows() throws Exception { final OcspCertificateRevocationChecker validator = getOcspCertificateRevocationChecker(ocspServiceProvider); assertThatCode(() -> validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) - .isInstanceOf(UserCertificateOCSPCheckFailedException.class) + .isInstanceOf(OCSPClientException.class) .cause() .isInstanceOf(ConnectException.class); } @@ -129,12 +132,10 @@ void whenOcspUrlIsInvalid_thenThrows() throws Exception { void whenOcspRequestFails_thenThrows() throws Exception { final OcspServiceProvider ocspServiceProvider = getDesignatedOcspServiceProvider("http://demo.sk.ee/ocsps"); final OcspCertificateRevocationChecker validator = getOcspCertificateRevocationChecker(ocspServiceProvider); - assertThatCode(() -> - validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) - .isInstanceOf(UserCertificateOCSPCheckFailedException.class) - .cause() - .isInstanceOf(IOException.class) - .hasMessageStartingWith("OCSP request was not successful, response: (POST http://demo.sk.ee/ocsps) 404"); + OCSPClientException ex = assertThrows(OCSPClientException.class, () -> + validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); + assertThat(ex).hasMessageStartingWith("OCSP request was not successful"); + assertThat(ex.getStatusCode()).isEqualTo(404); } @Test @@ -144,7 +145,7 @@ void whenOcspRequestHasInvalidBody_thenThrows() throws Exception { ); assertThatCode(() -> validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) - .isInstanceOf(UserCertificateOCSPCheckFailedException.class) + .isInstanceOf(OCSPClientException.class) .cause() .isInstanceOf(IOException.class) .hasMessage("DEF length 110 object truncated by 105"); @@ -364,7 +365,7 @@ private static byte[] getOcspResponseBytesFromResources() throws IOException { return getOcspResponseBytesFromResources("ocsp_response.der"); } - private static byte[] getOcspResponseBytesFromResources(String resource) throws IOException { + public static byte[] getOcspResponseBytesFromResources(String resource) throws IOException { try (final InputStream resourceAsStream = ClassLoader.getSystemResourceAsStream(resource)) { return toByteArray(resourceAsStream); } @@ -404,7 +405,13 @@ private HttpResponse getMockedResponse(byte[] bodyContent) throws URISyn } private OcspClient getMockClient(HttpResponse response) { - return (url, request) -> new OCSPResp(Objects.requireNonNull(response.body())); + return (url, request) -> { + try { + return new OCSPResp(Objects.requireNonNull(response.body())); + } catch (IOException e) { + throw new OCSPClientException(e); + } + }; } private static byte[] toByteArray(InputStream resourceAsStream) throws IOException { diff --git a/src/test/java/eu/webeid/ocsp/client/OcspClientOverrideTest.java b/src/test/java/eu/webeid/ocsp/client/OcspClientOverrideTest.java index eabe9b13..ea12b575 100644 --- a/src/test/java/eu/webeid/ocsp/client/OcspClientOverrideTest.java +++ b/src/test/java/eu/webeid/ocsp/client/OcspClientOverrideTest.java @@ -23,6 +23,7 @@ package eu.webeid.ocsp.client; import eu.webeid.ocsp.OcspCertificateRevocationChecker; +import eu.webeid.ocsp.exceptions.OCSPClientException; import eu.webeid.security.exceptions.JceException; import eu.webeid.security.testutil.AbstractTestWithValidator; import eu.webeid.security.testutil.AuthTokenValidators; @@ -47,7 +48,6 @@ class OcspClientOverrideTest extends AbstractTestWithValidator { void whenOcspClientIsOverridden_thenItIsUsed() throws JceException, CertificateException, IOException { final AuthTokenValidator validator = getAuthTokenValidatorWithOverriddenOcspClient(new OcpClientThatThrows()); assertThatThrownBy(() -> validator.validate(validAuthToken, VALID_CHALLENGE_NONCE)) - .cause() .isInstanceOf(OcpClientThatThrowsException.class); } @@ -82,12 +82,12 @@ private static AuthTokenValidator getAuthTokenValidatorWithOverriddenOcspClient( private static class OcpClientThatThrows implements OcspClient { @Override - public OCSPResp request(URI url, OCSPReq request) throws IOException { + public OCSPResp request(URI url, OCSPReq request) throws OCSPClientException { throw new OcpClientThatThrowsException(); } } - private static class OcpClientThatThrowsException extends IOException { + private static class OcpClientThatThrowsException extends OCSPClientException { } } diff --git a/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java b/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java index f681ac12..13084a56 100644 --- a/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java +++ b/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java @@ -24,6 +24,10 @@ import eu.webeid.ocsp.OcspCertificateRevocationChecker; import eu.webeid.ocsp.exceptions.UserCertificateOCSPCheckFailedException; +import eu.webeid.ocsp.exceptions.UserCertificateRevokedException; +import eu.webeid.ocsp.exceptions.UserCertificateUnknownException; +import org.bouncycastle.cert.ocsp.BasicOCSPResp; +import org.bouncycastle.cert.ocsp.OCSPResp; import org.bouncycastle.cert.ocsp.SingleResp; import org.junit.jupiter.api.Test; @@ -33,7 +37,9 @@ import java.time.temporal.ChronoUnit; import java.util.Date; +import static eu.webeid.ocsp.OcspCertificateRevocationCheckerTest.getOcspResponseBytesFromResources; import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateCertificateStatusUpdateTime; +import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateSubjectCertificateStatus; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.mockito.Mockito.mock; @@ -53,7 +59,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, OCSP_URL)) + assertThatCode(() -> validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, OCSP_URL, false)) .doesNotThrowAnyException(); } @@ -67,7 +73,7 @@ void whenNextUpdateBeforeThisUpdate_thenThrows() { when(mockResponse.getNextUpdate()).thenReturn(beforeThisUpdate); assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) .isThrownBy(() -> - validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, OCSP_URL)) + validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, OCSP_URL, false)) .withMessageStartingWith("User certificate revocation check has failed: " + "Certificate status update time check failed: " + "nextUpdate '" + beforeThisUpdate.toInstant() + "' is before thisUpdate '" + thisUpdateWithinAgeLimit.toInstant() + "'"); @@ -81,7 +87,7 @@ void whenThisUpdateHalfHourBeforeNow_thenThrows() { when(mockResponse.getThisUpdate()).thenReturn(halfHourBeforeNow); assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) .isThrownBy(() -> - validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, OCSP_URL)) + validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, OCSP_URL, false)) .withMessageStartingWith("User certificate revocation check has failed: " + "Certificate status update time check failed: " + "thisUpdate '" + halfHourBeforeNow.toInstant() + "' is too old, minimum time allowed: "); @@ -95,7 +101,7 @@ void whenThisUpdateHalfHourAfterNow_thenThrows() { when(mockResponse.getThisUpdate()).thenReturn(halfHourAfterNow); assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) .isThrownBy(() -> - validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, OCSP_URL)) + validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, OCSP_URL, false)) .withMessageStartingWith("User certificate revocation check has failed: " + "Certificate status update time check failed: " + "thisUpdate '" + halfHourAfterNow.toInstant() + "' is too far in the future, latest allowed: "); @@ -111,15 +117,39 @@ void whenNextUpdateHalfHourBeforeNow_thenThrows() { when(mockResponse.getNextUpdate()).thenReturn(halfHourBeforeNow); assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) .isThrownBy(() -> - validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, OCSP_URL)) + validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, OCSP_URL, false)) .withMessage("User certificate revocation check has failed: " + "Certificate status update time check failed: " + "nextUpdate '" + halfHourBeforeNow.toInstant() + "' is in the past" + " (OCSP responder: https://example.org)"); } + @Test + void whenRejectUnknownOcspResponseStatusIsFalse_ThenUnknownStatusThrowsUserCertificateRevokedException() throws Exception { + SingleResp unknownCertStatus = getUnknownCertStatusResponse(); + assertThatExceptionOfType(UserCertificateRevokedException.class) + .isThrownBy(() -> + validateSubjectCertificateStatus(unknownCertStatus, OCSP_URL, false)) + .withMessage("User certificate has been revoked: Unknown status (OCSP responder: https://example.org)"); + } + + @Test + void whenRejectUnknownOcspResponseStatusIsTrue_ThenUnknownStatusThrowsUserCertificateUnknownException() throws Exception { + SingleResp unknownCertStatus = getUnknownCertStatusResponse(); + assertThatExceptionOfType(UserCertificateUnknownException.class) + .isThrownBy(() -> + validateSubjectCertificateStatus(unknownCertStatus, OCSP_URL, true)) + .withMessage("User certificate status is unknown: Unknown status (OCSP responder: https://example.org)"); + } + private static Date getThisUpdateWithinAgeLimit(Instant now) { return Date.from(now.minus(THIS_UPDATE_AGE.minusSeconds(1))); } + private static SingleResp getUnknownCertStatusResponse() throws Exception { + final OCSPResp ocspRespUnknown = new OCSPResp(getOcspResponseBytesFromResources("ocsp_response_unknown.der")); + final BasicOCSPResp basicResponse = (BasicOCSPResp) ocspRespUnknown.getResponseObject(); + return basicResponse.getResponses()[0]; + } + } diff --git a/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java b/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java index 340764b7..91ef504a 100644 --- a/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java +++ b/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java @@ -41,7 +41,7 @@ public class OcspServiceMaker { private static final String TEST_OCSP_ACCESS_LOCATION = "http://demo.sk.ee/ocsp"; private static final List TRUSTED_CA_CERTIFICATES; - private static final URI TEST_ESTEID_2015 = URI.create("http://aia.demo.sk.ee/esteid2015"); + private static final String ISSUER_CN = "TEST of ESTEID-SK 2015"; static { try { @@ -69,7 +69,7 @@ public static OcspServiceProvider getDesignatedOcspServiceProvider(String ocspSe private static AiaOcspServiceConfiguration getAiaOcspServiceConfiguration() throws JceException { return new AiaOcspServiceConfiguration( - Set.of(TEST_ESTEID_2015), + Set.of(ISSUER_CN), CertificateValidator.buildTrustAnchorsFromCertificates(TRUSTED_CA_CERTIFICATES), CertificateValidator.buildCertStoreFromCertificates(TRUSTED_CA_CERTIFICATES)); } diff --git a/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java b/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java index 123f996c..0863a258 100644 --- a/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java +++ b/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java @@ -100,4 +100,4 @@ void whenAiaOcspServiceConfigurationDoesNotHaveResponderCertTrustedCA_thenThrows // assertThatThrownBy(() -> validatorWithOcspCheck // .validate(token, VALID_CHALLENGE_NONCE)) // .isInstanceOf(UserCertificateRevokedException.class); -// } \ No newline at end of file +// } diff --git a/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java b/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java new file mode 100644 index 00000000..14ab2f10 --- /dev/null +++ b/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java @@ -0,0 +1,305 @@ +/* + * Copyright (c) 2020-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package eu.webeid.resilientocsp; + +import eu.webeid.ocsp.OcspCertificateRevocationChecker; +import eu.webeid.ocsp.client.OcspClient; +import eu.webeid.ocsp.exceptions.OCSPClientException; +import eu.webeid.ocsp.service.OcspService; +import eu.webeid.ocsp.service.OcspServiceProvider; +import eu.webeid.resilientocsp.exceptions.ResilientUserCertificateOCSPCheckFailedException; +import eu.webeid.resilientocsp.exceptions.ResilientUserCertificateRevokedException; +import eu.webeid.ocsp.service.FallbackOcspService; +import eu.webeid.security.authtoken.WebEidAuthToken; +import eu.webeid.security.validator.AuthTokenValidator; +import eu.webeid.security.validator.revocationcheck.RevocationInfo; +import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig; +import io.github.resilience4j.retry.RetryConfig; +import org.bouncycastle.cert.ocsp.BasicOCSPResp; +import org.bouncycastle.cert.ocsp.CertificateStatus; +import org.bouncycastle.cert.ocsp.OCSPResp; +import org.bouncycastle.cert.ocsp.RevokedStatus; +import org.bouncycastle.cert.ocsp.SingleResp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.security.cert.X509Certificate; +import java.util.List; +import java.util.Map; + +import static eu.webeid.ocsp.OcspCertificateRevocationCheckerTest.getOcspResponseBytesFromResources; +import static eu.webeid.security.testutil.AbstractTestWithValidator.VALID_AUTH_TOKEN; +import static eu.webeid.security.testutil.AbstractTestWithValidator.VALID_CHALLENGE_NONCE; +import static eu.webeid.security.testutil.AuthTokenValidators.getDefaultAuthTokenValidatorBuilder; +import static eu.webeid.security.testutil.Certificates.getJaakKristjanEsteid2018Cert; +import static eu.webeid.security.testutil.Certificates.getTestEsteid2018CA; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class ResilientOcspCertificateRevocationCheckerTest { + + private static final URI PRIMARY_URI = URI.create("http://primary.ocsp.test"); + private static final URI FALLBACK_URI = URI.create("http://fallback.ocsp.test"); + private static final URI SECOND_FALLBACK_URI = URI.create("http://second-fallback.ocsp.test"); + + private X509Certificate estEid2018Cert; + private X509Certificate testEsteid2018CA; + + private OCSPResp ocspRespGood; + private OCSPResp ocspRespRevoked; + + @BeforeEach + void setUp() throws Exception { + estEid2018Cert = getJaakKristjanEsteid2018Cert(); + testEsteid2018CA = getTestEsteid2018CA(); + ocspRespGood = new OCSPResp(getOcspResponseBytesFromResources("ocsp_response.der")); + ocspRespRevoked = new OCSPResp(getOcspResponseBytesFromResources("ocsp_response_revoked.der")); + } + + @Test + void whenMultipleValidationCalls_thenPreviousResultsAreNotModified() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())) + .thenThrow(new OCSPClientException("Primary OCSP service unavailable (call1)")) + .thenThrow(new OCSPClientException("Primary OCSP service unavailable (call2)")); + when(ocspClient.request(eq(FALLBACK_URI), any())) + .thenThrow(new OCSPClientException("Fallback OCSP service unavailable (call1)")) + .thenThrow(new OCSPClientException("Fallback OCSP service unavailable (call2)")); + when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())) + .thenThrow(new OCSPClientException("Secondary fallback OCSP service unavailable (call1)")) + .thenThrow(new OCSPClientException("Secondary fallback OCSP service unavailable (call2)")); + ResilientOcspCertificateRevocationChecker resilientChecker = buildChecker(ocspClient, null, false); + AuthTokenValidator validator = getDefaultAuthTokenValidatorBuilder() + .withCertificateRevocationChecker(resilientChecker) + .build(); + WebEidAuthToken authToken = validator.parse(VALID_AUTH_TOKEN); + + ResilientUserCertificateOCSPCheckFailedException ex1 = assertThrows(ResilientUserCertificateOCSPCheckFailedException.class, + () -> validator.validate(authToken, VALID_CHALLENGE_NONCE)); + List revocationInfo1 = ex1.getValidationInfo().revocationInfoList(); + assertThat(revocationInfo1).hasSize(3); + assertThat(revocationInfo1) + .extracting(ri -> ((OCSPClientException) ri.ocspResponseAttributes().get("OCSP_ERROR")).getMessage()) + .containsExactly( + "Primary OCSP service unavailable (call1)", + "Fallback OCSP service unavailable (call1)", + "Secondary fallback OCSP service unavailable (call1)" + ); + ResilientUserCertificateOCSPCheckFailedException ex2 = assertThrows(ResilientUserCertificateOCSPCheckFailedException.class, + () -> validator.validate(authToken, VALID_CHALLENGE_NONCE)); + List revocationInfo2 = ex2.getValidationInfo().revocationInfoList(); + assertThat(revocationInfo2).hasSize(3); + assertThat(revocationInfo2) + .extracting(ri -> ((OCSPClientException) ri.ocspResponseAttributes().get("OCSP_ERROR")).getMessage()) + .containsExactly( + "Primary OCSP service unavailable (call2)", + "Fallback OCSP service unavailable (call2)", + "Secondary fallback OCSP service unavailable (call2)" + ); + assertThat(revocationInfo1).hasSize(3); + assertThat(revocationInfo1) + .extracting(ri -> ((OCSPClientException) ri.ocspResponseAttributes().get("OCSP_ERROR")).getMessage()) + .containsExactly( + "Primary OCSP service unavailable (call1)", + "Fallback OCSP service unavailable (call1)", + "Secondary fallback OCSP service unavailable (call1)" + ); + } + + @Test + void whenFirstFallbackReturnsRevoked_thenRevocationPropagatesWithoutSecondFallback() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())) + .thenThrow(new OCSPClientException("Primary OCSP service unavailable")); + when(ocspClient.request(eq(FALLBACK_URI), any())) + .thenReturn(ocspRespRevoked); + when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())) + .thenReturn(ocspRespGood); + + ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, null, false); + + assertThatExceptionOfType(ResilientUserCertificateRevokedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .withMessage("User certificate has been revoked"); + + verify(ocspClient, never()).request(eq(SECOND_FALLBACK_URI), any()); + } + + @Test + void whenMaxAttemptsIsOneAndAllCallsFail_thenRevocationInfoListShouldHaveThreeElements() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())) + .thenThrow(new OCSPClientException()); + when(ocspClient.request(eq(FALLBACK_URI), any())) + .thenThrow(new OCSPClientException()); + when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())) + .thenThrow(new OCSPClientException()); + + RetryConfig retryConfig = RetryConfig.custom() + .maxAttempts(1) + .build(); + + ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, retryConfig, false); + ResilientUserCertificateOCSPCheckFailedException ex = assertThrows(ResilientUserCertificateOCSPCheckFailedException.class, () -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); + assertThat(ex.getValidationInfo().revocationInfoList().size()).isEqualTo(3); + } + + @Test + void whenMaxAttemptsIsTwoAndAllCallsFail_thenRevocationInfoListShouldHaveFourElements() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())) + .thenThrow(new OCSPClientException()); + when(ocspClient.request(eq(FALLBACK_URI), any())) + .thenThrow(new OCSPClientException()); + when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())) + .thenThrow(new OCSPClientException()); + + RetryConfig retryConfig = RetryConfig.custom() + .maxAttempts(2) + .build(); + + ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, retryConfig, false); + ResilientUserCertificateOCSPCheckFailedException ex = assertThrows(ResilientUserCertificateOCSPCheckFailedException.class, () -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); + assertThat(ex.getValidationInfo().revocationInfoList().size()).isEqualTo(4); + } + + @Test + @Disabled("Primary supplier has allowThisUpdateInPast disabled and that is checked before revocation, " + + "which results in ResilientUserCertificateOCSPCheckFailedException") + void whenMaxAttemptsIsTwoAndFirstCallFails_thenTwoCallsToPrimaryShouldBeRecorded() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())) + .thenThrow(new OCSPClientException("Primary OCSP service unavailable (call1)")) + .thenReturn(ocspRespGood); + when(ocspClient.request(eq(FALLBACK_URI), any())) + .thenReturn(ocspRespRevoked); + when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())) + .thenReturn(ocspRespRevoked); + + RetryConfig retryConfig = RetryConfig.custom() + .maxAttempts(2) + .build(); + + ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, retryConfig, false); + List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + assertThat(revocationInfoList.size()).isEqualTo(2); + + Map firstResponseAttributes = revocationInfoList.get(0).ocspResponseAttributes(); + OCSPClientException ex1 = (OCSPClientException) firstResponseAttributes.get("OCSP_ERROR"); + assertThat(ex1.getMessage()).isEqualTo("Primary OCSP service unavailable (call1)"); + + Map secondResponseAttributes = revocationInfoList.get(1).ocspResponseAttributes(); + OCSPResp ocspResp = (OCSPResp) secondResponseAttributes.get("OCSP_RESPONSE"); + final BasicOCSPResp basicResponse = (BasicOCSPResp) ocspResp.getResponseObject(); + final SingleResp certStatusResponse = basicResponse.getResponses()[0]; + assertThat(certStatusResponse.getCertStatus()).isEqualTo(org.bouncycastle.cert.ocsp.CertificateStatus.GOOD); + } + + @Test + @Disabled("Primary supplier has allowThisUpdateInPast disabled and that is checked before revocation, " + + "which results in ResilientUserCertificateOCSPCheckFailedException") + void whenFirstCallSucceeds_thenRevocationInfoListShouldHaveOneElementAndItShouldHaveGoodStatus() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())) + .thenReturn(ocspRespGood); + when(ocspClient.request(eq(FALLBACK_URI), any())) + .thenReturn(ocspRespRevoked); + when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())) + .thenReturn(ocspRespRevoked); + + ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, null, false); + + List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + assertThat(revocationInfoList.size()).isEqualTo(1); + Map responseAttributes = revocationInfoList.get(0).ocspResponseAttributes(); + OCSPResp ocspResp = (OCSPResp) responseAttributes.get("OCSP_RESPONSE"); + CertificateStatus status = getCertificateStatus(ocspResp); + assertThat(status).isEqualTo(org.bouncycastle.cert.ocsp.CertificateStatus.GOOD); + } + + @Test + @Disabled("Primary supplier has allowThisUpdateInPast disabled and that is checked before revocation, " + + "which results in ResilientUserCertificateOCSPCheckFailedException") + void whenFirstCallResultsInRevoked_thenRevocationInfoListShouldHaveOneElementAndItShouldHaveRevokedStatus() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())) + .thenReturn(ocspRespRevoked); + when(ocspClient.request(eq(FALLBACK_URI), any())) + .thenReturn(ocspRespGood); + when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())) + .thenReturn(ocspRespGood); + + ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, null, false); + ResilientUserCertificateRevokedException ex = assertThrows(ResilientUserCertificateRevokedException.class, () -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); + List revocationInfoList = ex.getValidationInfo().revocationInfoList(); + assertThat(revocationInfoList.size()).isEqualTo(1); + Map responseAttributes = ex.getValidationInfo().revocationInfoList().get(0).ocspResponseAttributes(); + OCSPResp ocspResp = (OCSPResp) responseAttributes.get("OCSP_RESPONSE"); + CertificateStatus status = getCertificateStatus(ocspResp); + assertThat(status).isInstanceOf(RevokedStatus.class); + } + + private ResilientOcspCertificateRevocationChecker buildChecker(OcspClient ocspClient, RetryConfig retryConfig, boolean rejectUnknownOcspResponseStatus) throws Exception { + FallbackOcspService secondFallbackService = mock(FallbackOcspService.class); + when(secondFallbackService.getAccessLocation()).thenReturn(SECOND_FALLBACK_URI); + when(secondFallbackService.doesSupportNonce()).thenReturn(false); + + FallbackOcspService fallbackService = mock(FallbackOcspService.class); + when(fallbackService.getAccessLocation()).thenReturn(FALLBACK_URI); + when(fallbackService.doesSupportNonce()).thenReturn(false); + when(fallbackService.getNextFallback()).thenReturn(secondFallbackService); + + OcspService primaryService = mock(OcspService.class); + when(primaryService.getAccessLocation()).thenReturn(PRIMARY_URI); + when(primaryService.doesSupportNonce()).thenReturn(false); + when(primaryService.getFallbackService()).thenReturn(fallbackService); + + OcspServiceProvider ocspServiceProvider = mock(OcspServiceProvider.class); + when(ocspServiceProvider.getService(any())).thenReturn(primaryService); + + return new ResilientOcspCertificateRevocationChecker( + ocspClient, + ocspServiceProvider, + CircuitBreakerConfig.ofDefaults(), + retryConfig, + OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW, + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, + rejectUnknownOcspResponseStatus + ); + } + + private CertificateStatus getCertificateStatus(OCSPResp ocspResp) throws Exception { + final BasicOCSPResp basicResponse = (BasicOCSPResp) ocspResp.getResponseObject(); + final SingleResp certStatusResponse = basicResponse.getResponses()[0]; + return certStatusResponse.getCertStatus(); + } +}