diff --git a/pom.xml b/pom.xml index 3682952f..790dd9ee 100644 --- a/pom.xml +++ b/pom.xml @@ -22,6 +22,7 @@ 5.14.4 3.27.7 5.23.0 + 4.3.0 3.6.0 3.15.0 3.4.0 @@ -115,6 +116,12 @@ ${mockito.version} test + + org.awaitility + awaitility + ${awaitility.version} + test + org.slf4j slf4j-simple diff --git a/src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java b/src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java index 80696e3a..18ef6e76 100644 --- a/src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java +++ b/src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java @@ -10,6 +10,7 @@ import eu.webeid.ocsp.protocol.OcspResponseValidator; import eu.webeid.security.exceptions.AuthTokenException; import eu.webeid.ocsp.exceptions.UserCertificateOCSPCheckFailedException; +import eu.webeid.ocsp.exceptions.UserCertificateOCSPException; import eu.webeid.security.util.DateAndTime; import eu.webeid.ocsp.service.OcspServiceProvider; import eu.webeid.ocsp.service.OcspService; @@ -50,13 +51,13 @@ public class OcspCertificateRevocationChecker implements CertificateRevocationCh public static final Duration DEFAULT_TIME_SKEW = Duration.ofMinutes(15); public static final Duration DEFAULT_THIS_UPDATE_AGE = Duration.ofMinutes(2); + public static final Duration DEFAULT_NEXT_UPDATE_AGE = Duration.ofMinutes(15); private static final Logger LOG = LoggerFactory.getLogger(OcspCertificateRevocationChecker.class); private final OcspClient ocspClient; private final OcspServiceProvider ocspServiceProvider; private final Duration allowedOcspResponseTimeSkew; - private final Duration maxOcspResponseThisUpdateAge; static { if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { @@ -66,12 +67,10 @@ public class OcspCertificateRevocationChecker implements CertificateRevocationCh public OcspCertificateRevocationChecker(OcspClient ocspClient, OcspServiceProvider ocspServiceProvider, - Duration allowedOcspResponseTimeSkew, - Duration maxOcspResponseThisUpdateAge) { + Duration allowedOcspResponseTimeSkew) { this.ocspClient = requireNonNull(ocspClient, "ocspClient"); this.ocspServiceProvider = requireNonNull(ocspServiceProvider, "ocspServiceProvider"); this.allowedOcspResponseTimeSkew = requirePositiveDuration(allowedOcspResponseTimeSkew, "allowedOcspResponseTimeSkew"); - this.maxOcspResponseThisUpdateAge = requirePositiveDuration(maxOcspResponseThisUpdateAge, "maxOcspResponseThisUpdateAge"); } /** @@ -86,22 +85,16 @@ public List validateCertificateNotRevoked(X509Certificate subjec requireNonNull(subjectCertificate, "subjectCertificate"); requireNonNull(issuerCertificate, "issuerCertificate"); - URI ocspResponderUri = null; - try { - OcspService ocspService = ocspServiceProvider.getService(subjectCertificate, issuerCertificate); - ocspResponderUri = requireNonNull(ocspService.getAccessLocation(), "ocspResponderUri"); - - final CertificateID certificateId = getCertificateId(subjectCertificate, issuerCertificate); - - final OCSPReq request = new OcspRequestBuilder() - .withCertificateId(certificateId) - .enableOcspNonce(ocspService.doesSupportNonce()) - .build(); + final OcspService ocspService = ocspServiceProvider.getService(subjectCertificate, issuerCertificate); + final CertificateID certificateId = getCertificateId(subjectCertificate, issuerCertificate); + final URI ocspResponderUri = ocspService.getAccessLocation(); + final OCSPReq request = getOcspRequest(certificateId, ocspService); - if (!ocspService.doesSupportNonce()) { - LOG.debug("Disabling OCSP nonce extension"); - } + if (!ocspService.doesSupportNonce()) { + LOG.debug("Disabling OCSP nonce extension"); + } + try { LOG.debug("Sending OCSP request"); final OCSPResp response = requireNonNull(ocspClient.request(ocspResponderUri, request), "OCSPResp"); if (response.getStatus() != OCSPResponseStatus.SUCCESSFUL) { @@ -113,7 +106,7 @@ public List validateCertificateNotRevoked(X509Certificate subjec } LOG.debug("OCSP response received successfully"); - verifyOcspResponse(basicResponse, ocspService, certificateId, issuerCertificate, maxOcspResponseThisUpdateAge); + verifyOcspResponse(basicResponse, ocspService, certificateId, issuerCertificate); if (ocspService.doesSupportNonce()) { checkNonce(request, basicResponse, ocspResponderUri); } @@ -121,12 +114,25 @@ public List validateCertificateNotRevoked(X509Certificate subjec return List.of(new RevocationInfo(ocspResponderUri, Map.of(RevocationInfo.KEY_OCSP_RESPONSE, response))); - } catch (OCSPException | CertificateException | OperatorCreationException | IOException | OCSPClientException e) { + } catch (OCSPException | CertificateException | OperatorCreationException | OCSPClientException e) { throw new UserCertificateOCSPCheckFailedException(e, ocspResponderUri); } } - protected void verifyOcspResponse(BasicOCSPResp basicResponse, OcspService ocspService, CertificateID requestCertificateId, X509Certificate issuerCertificate, Duration maxOcspResponseThisUpdateAge) throws AuthTokenException, OCSPException, CertificateException, OperatorCreationException { + protected static OCSPReq getOcspRequest(CertificateID certificateId, OcspService ocspService) throws UserCertificateOCSPException { + final OCSPReq request; + try { + request = new OcspRequestBuilder() + .withCertificateId(certificateId) + .enableOcspNonce(ocspService.doesSupportNonce()) + .build(); + } catch (OCSPException e) { + throw new UserCertificateOCSPException("Unable to create OCSP request", e); + } + return request; + } + + protected void verifyOcspResponse(BasicOCSPResp basicResponse, OcspService ocspService, CertificateID requestCertificateId, X509Certificate issuerCertificate) throws AuthTokenException, OCSPException, CertificateException, OperatorCreationException { // The verification algorithm follows RFC 2560, https://www.ietf.org/rfc/rfc2560.txt. // // 3.2. Signed Response Acceptance Requirements @@ -182,7 +188,7 @@ protected void verifyOcspResponse(BasicOCSPResp basicResponse, OcspService ocspS // 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, ocspService.getMaxThisUpdateAge(), ocspService.getMaxNextUpdateAge(), ocspService.getAccessLocation()); // Now we can accept the signed response as valid and validate the certificate status. OcspResponseValidator.validateSubjectCertificateStatus(certStatusResponse, ocspService.getAccessLocation()); @@ -202,11 +208,15 @@ protected static void checkNonce(OCSPReq request, BasicOCSPResp response, URI oc } } - 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); + protected static CertificateID getCertificateId(X509Certificate subjectCertificate, X509Certificate issuerCertificate) throws UserCertificateOCSPException { + try { + final BigInteger serial = subjectCertificate.getSerialNumber(); + final DigestCalculator digestCalculator = DigestCalculatorImpl.sha1(); + return new CertificateID(digestCalculator, + new X509CertificateHolder(issuerCertificate.getEncoded()), serial); + } catch (CertificateEncodingException | IOException | OCSPException e) { + throw new UserCertificateOCSPException("Unable to compute certificateId for subject certificate", e); + } } protected static String ocspStatusToString(int status) { @@ -227,8 +237,4 @@ protected OcspClient getOcspClient() { protected OcspServiceProvider getOcspServiceProvider() { return ocspServiceProvider; } - - protected Duration getMaxOcspResponseThisUpdateAge() { - return maxOcspResponseThisUpdateAge; - } } diff --git a/src/main/java/eu/webeid/ocsp/README.md b/src/main/java/eu/webeid/ocsp/README.md index 7d0ffacc..fa73a21a 100644 --- a/src/main/java/eu/webeid/ocsp/README.md +++ b/src/main/java/eu/webeid/ocsp/README.md @@ -75,14 +75,16 @@ List trustedCAs = List.of(trustedIntermediateCACertificates()); AiaOcspServiceConfiguration aiaConfiguration = new AiaOcspServiceConfiguration( Set.of(), // AIA responder URLs for which request and response nonce checks are disabled. CertificateValidator.buildTrustAnchorsFromCertificates(trustedCAs), - CertificateValidator.buildCertStoreFromCertificates(trustedCAs) + CertificateValidator.buildCertStoreFromCertificates(trustedCAs), + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, + OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, + true // Require the Key Usage extension with Digital Signature in responder certificates. ); OcspServiceProvider services = new OcspServiceProvider(null, aiaConfiguration); OcspCertificateRevocationChecker checker = new OcspCertificateRevocationChecker( OcspClientImpl.build(Duration.ofSeconds(5)), services, - OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW, - OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE + OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW ); AuthTokenValidator validator = new AuthTokenValidatorBuilder() @@ -94,7 +96,16 @@ AuthTokenValidator validator = new AuthTokenValidatorBuilder() The five-second connection and response timeout above is an explicit example setting. For a custom Java `HttpClient`, use `new OcspClientImpl(httpClient, responseTimeout)` and configure the connection timeout on that client. Alternatively, supply your own `OcspClient` implementation. See [OcspClientOverrideTest](../../../../../test/java/eu/webeid/ocsp/client/OcspClientOverrideTest.java). -The custom checker's suggested constants are 15 minutes for clock/update skew and 2 minutes for maximum `thisUpdate` age; pass different positive durations to its constructor to change them. These checks are implemented by [OcspResponseValidator](protocol/OcspResponseValidator.java). +The custom checker's suggested constant for clock/update skew is 15 minutes; pass a different positive duration to its constructor to change it. + +The maximum ages of the OCSP response's `thisUpdate` and `nextUpdate` times are configured per OCSP service, via the constructor of `AiaOcspServiceConfiguration`, `DesignatedOcspServiceConfiguration` or `FallbackOcspServiceConfiguration`: + +- `maxThisUpdateAge` – the maximum age of the OCSP response's `thisUpdate` time before the response is too old to rely on. `OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE` is 2 minutes. +- `maxNextUpdateAge` – the maximum age of the OCSP response's `nextUpdate` time before the response is too old to rely on. `OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE` is 15 minutes, which is equal to the default allowed time skew. + +These checks are implemented by [OcspResponseValidator](protocol/OcspResponseValidator.java). + +By default, OCSP responder certificates must have the Key Usage extension with Digital Signature. To accept responder certificates that lack the Key Usage extension or do not assert Digital Signature in it, pass `false` as the last `isKeyUsageRequired` argument of the `AiaOcspServiceConfiguration`, `DesignatedOcspServiceConfiguration` or `FallbackOcspServiceConfiguration` constructor. If the Key Usage extension is present, it must still not contain Certificate Signing, the Extended Key Usage extension must still contain OCSP Signing, and the certificate must still not be a CA certificate. For AIA services, the setting applies only to delegated responder certificates, not to the issuer CA signing its own responses. The setting of a fallback configuration does not propagate to its `nextFallbackConfiguration`. For a designated responder, replace the `services` definition above with the following configuration. `responderCertificate` must be the service's trusted signing certificate and `supportedIssuers` the collection of issuer certificates served by it: @@ -106,7 +117,10 @@ DesignatedOcspServiceConfiguration designated = new DesignatedOcspServiceConfigu URI.create("https://ocsp.example.org"), responderCertificate, supportedIssuers, - true // This service supports nonces. + true, // This service supports nonces. + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, + OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, + true // Require the Key Usage extension with Digital Signature in responder certificates. ); OcspServiceProvider services = new OcspServiceProvider(designated, aiaConfiguration); ``` diff --git a/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateOCSPException.java b/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateOCSPException.java new file mode 100644 index 00000000..1fe94acd --- /dev/null +++ b/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateOCSPException.java @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.ocsp.exceptions; + +import eu.webeid.security.exceptions.AuthTokenException; + +public class UserCertificateOCSPException extends AuthTokenException { + + public UserCertificateOCSPException(String message) { + super(message); + } + + public UserCertificateOCSPException(String message, Throwable exception) { + super(message, exception); + } + +} diff --git a/src/main/java/eu/webeid/ocsp/protocol/OcspResponseValidator.java b/src/main/java/eu/webeid/ocsp/protocol/OcspResponseValidator.java index e0679e2e..e31360b0 100644 --- a/src/main/java/eu/webeid/ocsp/protocol/OcspResponseValidator.java +++ b/src/main/java/eu/webeid/ocsp/protocol/OcspResponseValidator.java @@ -25,6 +25,7 @@ import java.security.cert.X509Certificate; import java.time.Duration; import java.time.Instant; +import java.util.List; import java.util.Objects; public final class OcspResponseValidator { @@ -35,14 +36,60 @@ public final class OcspResponseValidator { * https://oidref.com/1.3.6.1.5.5.7.3.9 */ private static final String OID_OCSP_SIGNING = "1.3.6.1.5.5.7.3.9"; + private static final int KEY_USAGE_DIGITAL_SIGNATURE_BIT_INDEX = 0; + private static final int KEY_USAGE_KEY_CERT_SIGN_BIT_INDEX = 5; private static final String ERROR_PREFIX = "Certificate status update time check failed: "; - public static void validateHasSigningExtension(X509Certificate certificate) throws OCSPCertificateException { + public static void validateBasicConstraintsNotCA(X509Certificate certificate) throws OCSPCertificateException { + Objects.requireNonNull(certificate, "certificate"); + // X509Certificate.getBasicConstraints() returns -1 when the Basic Constraints extension is absent + // or when cA=FALSE, and a non-negative value only when cA=TRUE (the pathLenConstraint, or + // Integer.MAX_VALUE when cA=TRUE without pathLenConstraint). + if (certificate.getBasicConstraints() >= 0) { + throw new OCSPCertificateException("Certificate " + certificate.getSubjectX500Principal() + + " must not be a CA certificate (Basic Constraints CA:TRUE is not allowed for OCSP responder)"); + } + } + + public static void validateKeyUsageDigitalSignature(X509Certificate certificate, boolean isKeyUsageRequired) throws OCSPCertificateException { + Objects.requireNonNull(certificate, "certificate"); + if (!isKeyUsageRequired) { + return; + } + final boolean[] keyUsage = certificate.getKeyUsage(); + if (keyUsage == null) { + throw new OCSPCertificateException("Certificate " + certificate.getSubjectX500Principal() + + " does not contain the Key Usage extension required for OCSP response signing"); + } + if (keyUsage.length <= KEY_USAGE_DIGITAL_SIGNATURE_BIT_INDEX || !keyUsage[KEY_USAGE_DIGITAL_SIGNATURE_BIT_INDEX]) { + throw new OCSPCertificateException("Certificate " + certificate.getSubjectX500Principal() + + " Key Usage extension does not contain Digital Signature, which is required for OCSP response signing"); + } + } + + public static void validateKeyUsageNotCertificateSigning(X509Certificate certificate) throws OCSPCertificateException { + Objects.requireNonNull(certificate, "certificate"); + final boolean[] keyUsage = certificate.getKeyUsage(); + if (keyUsage == null) { + return; + } + if (keyUsage.length > KEY_USAGE_KEY_CERT_SIGN_BIT_INDEX && keyUsage[KEY_USAGE_KEY_CERT_SIGN_BIT_INDEX]) { + throw new OCSPCertificateException("Certificate " + certificate.getSubjectX500Principal() + + " Key Usage extension contains Certificate Signing, which is not allowed for OCSP responder"); + } + } + + public static void validateExtendedKeyUsageOcspSigning(X509Certificate certificate) throws OCSPCertificateException { Objects.requireNonNull(certificate, "certificate"); try { - if (certificate.getExtendedKeyUsage() == null || !certificate.getExtendedKeyUsage().contains(OID_OCSP_SIGNING)) { + final List extendedKeyUsage = certificate.getExtendedKeyUsage(); + if (extendedKeyUsage == null) { + throw new OCSPCertificateException("Certificate " + certificate.getSubjectX500Principal() + + " does not contain the Extended Key Usage extension required for OCSP response signing"); + } + if (!extendedKeyUsage.contains(OID_OCSP_SIGNING)) { throw new OCSPCertificateException("Certificate " + certificate.getSubjectX500Principal() + - " does not contain the key usage extension for OCSP response signing"); + " Extended Key Usage extension does not contain OCSP Signing, which is required for OCSP response signing"); } } catch (CertificateParsingException e) { throw new OCSPCertificateException("Certificate parsing failed:", e); @@ -58,7 +105,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, Duration maxNextUpdateAge, URI ocspResponderUri) throws UserCertificateOCSPCheckFailedException { // From RFC 2560, https://www.ietf.org/rfc/rfc2560.txt: // 4.2.2. Notes on OCSP Responses // 4.2.2.1. Time @@ -69,9 +116,9 @@ public static void validateCertificateStatusUpdateTime(SingleResp certStatusResp // If nextUpdate is not set, the responder is indicating that newer // revocation information is available all the time. 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 minimumValidNextUpdateTime = now.minus(maxNextUpdateAge); final Instant thisUpdate = certStatusResponse.getThisUpdate().toInstant(); if (thisUpdate.isAfter(latestAcceptableTimeSkew)) { @@ -89,9 +136,10 @@ public static void validateCertificateStatusUpdateTime(SingleResp certStatusResp return; } final Instant nextUpdate = certStatusResponse.getNextUpdate().toInstant(); - if (nextUpdate.isBefore(earliestAcceptableTimeSkew)) { + if (nextUpdate.isBefore(minimumValidNextUpdateTime)) { throw new UserCertificateOCSPCheckFailedException(ERROR_PREFIX + - "nextUpdate '" + nextUpdate + "' is in the past", ocspResponderUri); + "nextUpdate '" + nextUpdate + "' is too old, " + + "minimum time allowed: '" + minimumValidNextUpdateTime + "'", ocspResponderUri); } if (nextUpdate.isBefore(thisUpdate)) { throw new UserCertificateOCSPCheckFailedException(ERROR_PREFIX + diff --git a/src/main/java/eu/webeid/ocsp/service/AiaOcspService.java b/src/main/java/eu/webeid/ocsp/service/AiaOcspService.java index 21980ba5..e52ed4b4 100644 --- a/src/main/java/eu/webeid/ocsp/service/AiaOcspService.java +++ b/src/main/java/eu/webeid/ocsp/service/AiaOcspService.java @@ -6,7 +6,7 @@ import eu.webeid.security.certificate.CertificateValidator; import eu.webeid.security.exceptions.AuthTokenException; import eu.webeid.ocsp.exceptions.OCSPCertificateException; -import eu.webeid.ocsp.exceptions.UserCertificateOCSPCheckFailedException; +import eu.webeid.ocsp.exceptions.UserCertificateOCSPException; import eu.webeid.ocsp.protocol.OcspResponseValidator; import eu.webeid.security.validator.revocationcheck.RevocationMode; import org.bouncycastle.asn1.x500.X500Name; @@ -18,6 +18,7 @@ import java.security.cert.CertStore; import java.security.cert.TrustAnchor; import java.security.cert.X509Certificate; +import java.time.Duration; import java.util.Date; import java.util.Objects; import java.util.Optional; @@ -37,8 +38,11 @@ public class AiaOcspService implements OcspService { private final URI url; private final boolean supportsNonce; private final FallbackOcspService fallbackOcspService; + private final Duration maxThisUpdateAge; + private final Duration maxNextUpdateAge; + private final boolean isKeyUsageRequired; - public AiaOcspService(AiaOcspServiceConfiguration configuration, X509Certificate certificate, FallbackOcspService fallbackOcspService) throws AuthTokenException { + public AiaOcspService(AiaOcspServiceConfiguration configuration, X509Certificate certificate, FallbackOcspService fallbackOcspService) throws UserCertificateOCSPException { Objects.requireNonNull(configuration); this.trustedCACertificateAnchors = configuration.getTrustedCACertificateAnchors(); this.trustedCACertificateCertStore = configuration.getTrustedCACertificateCertStore(); @@ -46,6 +50,9 @@ public AiaOcspService(AiaOcspServiceConfiguration configuration, X509Certificate this.fallbackOcspService = fallbackOcspService; X500Name issuerDN = getIssuerDistinguishedName(certificate); this.supportsNonce = !configuration.getNonceDisabledIssuerDNs().contains(issuerDN); + this.maxThisUpdateAge = configuration.getMaxThisUpdateAge(); + this.maxNextUpdateAge = configuration.getMaxNextUpdateAge(); + this.isKeyUsageRequired = configuration.isKeyUsageRequired(); } @Override @@ -58,6 +65,16 @@ public URI getAccessLocation() { return url; } + @Override + public Duration getMaxThisUpdateAge() { + return maxThisUpdateAge; + } + + @Override + public Duration getMaxNextUpdateAge() { + return maxNextUpdateAge; + } + @Override public Optional getFallbackService() { return Optional.ofNullable(fallbackOcspService); @@ -69,7 +86,10 @@ public void validateResponderCertificate(X509CertificateHolder cert, X509Certifi final X509Certificate certificate = certificateConverter.getCertificate(cert); CertificateValidator.requireCertificateIsValidOnDate(certificate, now, "AIA OCSP responder"); if (!certificate.equals(issuerCertificate)) { - OcspResponseValidator.validateHasSigningExtension(certificate); + OcspResponseValidator.validateBasicConstraintsNotCA(certificate); + OcspResponseValidator.validateKeyUsageDigitalSignature(certificate, isKeyUsageRequired); + OcspResponseValidator.validateKeyUsageNotCertificateSigning(certificate); + OcspResponseValidator.validateExtendedKeyUsageOcspSigning(certificate); // A delegated OCSP signer must be issued directly by the CA whose certificate status was requested. if (!certificate.getIssuerX500Principal().equals(issuerCertificate.getSubjectX500Principal())) { throw new OCSPCertificateException("AIA OCSP responder is not issued by the subject certificate's issuer"); @@ -91,9 +111,9 @@ public void validateResponderCertificate(X509CertificateHolder cert, X509Certifi } } - private static URI getOcspAiaUrlFromCertificate(X509Certificate certificate) throws AuthTokenException { + private static URI getOcspAiaUrlFromCertificate(X509Certificate certificate) throws UserCertificateOCSPException { return getOcspUri(certificate).orElseThrow(() -> - new UserCertificateOCSPCheckFailedException("Getting the AIA OCSP responder field from the certificate failed") + new UserCertificateOCSPException("Getting the AIA OCSP responder field from the certificate failed") ); } diff --git a/src/main/java/eu/webeid/ocsp/service/AiaOcspServiceConfiguration.java b/src/main/java/eu/webeid/ocsp/service/AiaOcspServiceConfiguration.java index 44b601fd..240d3d0c 100644 --- a/src/main/java/eu/webeid/ocsp/service/AiaOcspServiceConfiguration.java +++ b/src/main/java/eu/webeid/ocsp/service/AiaOcspServiceConfiguration.java @@ -7,20 +7,29 @@ import java.security.cert.CertStore; import java.security.cert.TrustAnchor; +import java.time.Duration; import java.util.Collection; import java.util.Objects; import java.util.Set; +import static eu.webeid.security.util.DateAndTime.requirePositiveDuration; + public class AiaOcspServiceConfiguration { private final Collection nonceDisabledIssuerDNs; private final Set trustedCACertificateAnchors; private final CertStore trustedCACertificateCertStore; + private final Duration maxThisUpdateAge; + private final Duration maxNextUpdateAge; + private final boolean isKeyUsageRequired; - public AiaOcspServiceConfiguration(Collection nonceDisabledIssuerDNs, Set trustedCACertificateAnchors, CertStore trustedCACertificateCertStore) { + public AiaOcspServiceConfiguration(Collection nonceDisabledIssuerDNs, Set trustedCACertificateAnchors, CertStore trustedCACertificateCertStore, Duration maxThisUpdateAge, Duration maxNextUpdateAge, boolean isKeyUsageRequired) { this.nonceDisabledIssuerDNs = Set.copyOf(nonceDisabledIssuerDNs); this.trustedCACertificateAnchors = Set.copyOf(trustedCACertificateAnchors); this.trustedCACertificateCertStore = Objects.requireNonNull(trustedCACertificateCertStore); + this.maxThisUpdateAge = requirePositiveDuration(maxThisUpdateAge, "maxThisUpdateAge"); + this.maxNextUpdateAge = requirePositiveDuration(maxNextUpdateAge, "maxNextUpdateAge"); + this.isKeyUsageRequired = isKeyUsageRequired; } public Collection getNonceDisabledIssuerDNs() { @@ -35,4 +44,15 @@ public CertStore getTrustedCACertificateCertStore() { return trustedCACertificateCertStore; } + public Duration getMaxThisUpdateAge() { + return maxThisUpdateAge; + } + + public Duration getMaxNextUpdateAge() { + return maxNextUpdateAge; + } + + public boolean isKeyUsageRequired() { + return isKeyUsageRequired; + } } diff --git a/src/main/java/eu/webeid/ocsp/service/DesignatedOcspService.java b/src/main/java/eu/webeid/ocsp/service/DesignatedOcspService.java index 853a9cbe..163a197d 100644 --- a/src/main/java/eu/webeid/ocsp/service/DesignatedOcspService.java +++ b/src/main/java/eu/webeid/ocsp/service/DesignatedOcspService.java @@ -11,6 +11,7 @@ import java.net.URI; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; +import java.time.Duration; import java.util.Date; import java.util.Objects; @@ -38,10 +39,23 @@ public URI getAccessLocation() { return configuration.getOcspServiceAccessLocation(); } + @Override + public Duration getMaxThisUpdateAge() { + return configuration.getMaxThisUpdateAge(); + } + + @Override + public Duration getMaxNextUpdateAge() { + return configuration.getMaxNextUpdateAge(); + } + @Override public void validateResponderCertificate(X509CertificateHolder cert, X509Certificate issuerCertificate, Date now) throws AuthTokenException { try { final X509Certificate responderCertificate = certificateConverter.getCertificate(cert); + // Certificate extensions (Basic Constraints, Key Usage, Extended Key Usage) are validated at + // configuration time in DesignatedOcspServiceConfiguration. Since equals() compares the full DER + // encoding, a matching certificate is guaranteed to have the same validated extensions. // 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 (!configuration.getResponderCertificate().equals(responderCertificate)) { diff --git a/src/main/java/eu/webeid/ocsp/service/DesignatedOcspServiceConfiguration.java b/src/main/java/eu/webeid/ocsp/service/DesignatedOcspServiceConfiguration.java index 85397700..0229d979 100644 --- a/src/main/java/eu/webeid/ocsp/service/DesignatedOcspServiceConfiguration.java +++ b/src/main/java/eu/webeid/ocsp/service/DesignatedOcspServiceConfiguration.java @@ -8,16 +8,21 @@ import java.net.URI; import java.security.cert.X509Certificate; +import java.time.Duration; import java.util.Collection; import java.util.Objects; import java.util.Set; +import static eu.webeid.security.util.DateAndTime.requirePositiveDuration; + public class DesignatedOcspServiceConfiguration { private final URI ocspServiceAccessLocation; private final X509Certificate responderCertificate; private final boolean doesSupportNonce; private final Set supportedIssuers; + private final Duration maxThisUpdateAge; + private final Duration maxNextUpdateAge; /** * Configuration of a designated OCSP service. @@ -26,14 +31,22 @@ public class DesignatedOcspServiceConfiguration { * @param responderCertificate the service's OCSP responder certificate * @param supportedCertificateIssuers the certificate issuers supported by the service * @param doesSupportNonce true if the service supports the OCSP protocol nonce extension + * @param maxThisUpdateAge the maximum age of the OCSP response's {@code thisUpdate} time, must be greater than zero + * @param maxNextUpdateAge the maximum age of the OCSP response's {@code nextUpdate} time, must be greater than zero + * @param isKeyUsageRequired true if the responder certificate must have the Key Usage extension with Digital Signature * @throws OCSPCertificateException when the responder certificate lacks OCSP signing usage */ - public DesignatedOcspServiceConfiguration(URI ocspServiceAccessLocation, X509Certificate responderCertificate, Collection supportedCertificateIssuers, boolean doesSupportNonce) throws OCSPCertificateException { + public DesignatedOcspServiceConfiguration(URI ocspServiceAccessLocation, X509Certificate responderCertificate, Collection supportedCertificateIssuers, boolean doesSupportNonce, Duration maxThisUpdateAge, Duration maxNextUpdateAge, boolean isKeyUsageRequired) throws OCSPCertificateException { this.ocspServiceAccessLocation = Objects.requireNonNull(ocspServiceAccessLocation, "OCSP service access location"); this.responderCertificate = Objects.requireNonNull(responderCertificate, "OCSP responder certificate"); this.supportedIssuers = Set.copyOf(Objects.requireNonNull(supportedCertificateIssuers, "supported issuers")); - OcspResponseValidator.validateHasSigningExtension(responderCertificate); + OcspResponseValidator.validateBasicConstraintsNotCA(responderCertificate); + OcspResponseValidator.validateKeyUsageDigitalSignature(responderCertificate, isKeyUsageRequired); + OcspResponseValidator.validateKeyUsageNotCertificateSigning(responderCertificate); + OcspResponseValidator.validateExtendedKeyUsageOcspSigning(responderCertificate); this.doesSupportNonce = doesSupportNonce; + this.maxThisUpdateAge = requirePositiveDuration(maxThisUpdateAge, "maxThisUpdateAge"); + this.maxNextUpdateAge = requirePositiveDuration(maxNextUpdateAge, "maxNextUpdateAge"); } public URI getOcspServiceAccessLocation() { @@ -48,6 +61,14 @@ public boolean doesSupportNonce() { return doesSupportNonce; } + public Duration getMaxThisUpdateAge() { + return maxThisUpdateAge; + } + + public Duration getMaxNextUpdateAge() { + return maxNextUpdateAge; + } + public boolean supportsIssuer(X509Certificate issuerCertificate) { return supportedIssuers.contains(Objects.requireNonNull(issuerCertificate, "issuerCertificate")); } diff --git a/src/main/java/eu/webeid/ocsp/service/FallbackOcspService.java b/src/main/java/eu/webeid/ocsp/service/FallbackOcspService.java index c272c903..ab1fde9f 100644 --- a/src/main/java/eu/webeid/ocsp/service/FallbackOcspService.java +++ b/src/main/java/eu/webeid/ocsp/service/FallbackOcspService.java @@ -16,6 +16,7 @@ import java.security.cert.CertStore; import java.security.cert.TrustAnchor; import java.security.cert.X509Certificate; +import java.time.Duration; import java.util.Date; import java.util.Objects; import java.util.Set; @@ -33,6 +34,9 @@ public class FallbackOcspService implements OcspService { private final FallbackOcspService nextFallback; private final Set trustedCACertificateAnchors; private final CertStore trustedCACertificateCertStore; + private final Duration maxThisUpdateAge; + private final Duration maxNextUpdateAge; + private final boolean isKeyUsageRequired; public FallbackOcspService(FallbackOcspServiceConfiguration configuration) { this.url = configuration.getAccessLocation(); @@ -44,6 +48,9 @@ public FallbackOcspService(FallbackOcspServiceConfiguration configuration) { : null; this.trustedCACertificateAnchors = configuration.getTrustedCACertificateAnchors(); this.trustedCACertificateCertStore = configuration.getTrustedCACertificateCertStore(); + this.maxThisUpdateAge = configuration.getMaxThisUpdateAge(); + this.maxNextUpdateAge = configuration.getMaxNextUpdateAge(); + this.isKeyUsageRequired = configuration.isKeyUsageRequired(); } @Override @@ -56,6 +63,16 @@ public URI getAccessLocation() { return url; } + @Override + public Duration getMaxThisUpdateAge() { + return maxThisUpdateAge; + } + + @Override + public Duration getMaxNextUpdateAge() { + return maxNextUpdateAge; + } + @Override public void validateResponderCertificate(X509CertificateHolder cert, X509Certificate issuerCertificate, Date now) throws AuthTokenException { try { @@ -76,6 +93,9 @@ public void validateResponderCertificate(X509CertificateHolder cert, X509Certifi } private void validatePinnedResponderCertificate(X509Certificate responderCertificate) throws OCSPCertificateException { + // Certificate extensions (Basic Constraints, Key Usage, Extended Key Usage) are validated at + // configuration time in FallbackOcspServiceConfiguration. Since equals() compares the full DER + // encoding, a matching certificate is guaranteed to have the same validated extensions. // 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)) { @@ -86,7 +106,10 @@ private void validatePinnedResponderCertificate(X509Certificate responderCertifi private void validateResponderCertificateAgainstTrustedCa(X509Certificate responderCertificate, X509Certificate issuerCertificate, Date now) throws AuthTokenException, GeneralSecurityException { if (!responderCertificate.equals(issuerCertificate)) { - OcspResponseValidator.validateHasSigningExtension(responderCertificate); + OcspResponseValidator.validateBasicConstraintsNotCA(responderCertificate); + OcspResponseValidator.validateKeyUsageDigitalSignature(responderCertificate, isKeyUsageRequired); + OcspResponseValidator.validateKeyUsageNotCertificateSigning(responderCertificate); + OcspResponseValidator.validateExtendedKeyUsageOcspSigning(responderCertificate); // A delegated OCSP signer must be issued directly by the CA whose certificate status was requested. if (!responderCertificate.getIssuerX500Principal().equals(issuerCertificate.getSubjectX500Principal())) { throw new OCSPCertificateException("Fallback OCSP responder is not issued by the subject certificate's issuer"); diff --git a/src/main/java/eu/webeid/ocsp/service/FallbackOcspServiceConfiguration.java b/src/main/java/eu/webeid/ocsp/service/FallbackOcspServiceConfiguration.java index ead8e3e7..1a315beb 100644 --- a/src/main/java/eu/webeid/ocsp/service/FallbackOcspServiceConfiguration.java +++ b/src/main/java/eu/webeid/ocsp/service/FallbackOcspServiceConfiguration.java @@ -10,9 +10,12 @@ import java.security.cert.CertStore; import java.security.cert.TrustAnchor; import java.security.cert.X509Certificate; +import java.time.Duration; import java.util.Objects; import java.util.Set; +import static eu.webeid.security.util.DateAndTime.requirePositiveDuration; + public class FallbackOcspServiceConfiguration { private final URI accessLocation; @@ -22,22 +25,33 @@ public class FallbackOcspServiceConfiguration { private final X509Certificate issuerCertificate; private final Set trustedCACertificateAnchors; private final CertStore trustedCACertificateCertStore; + private final Duration maxThisUpdateAge; + private final Duration maxNextUpdateAge; + private final boolean isKeyUsageRequired; public FallbackOcspServiceConfiguration(URI accessLocation, X509Certificate responderCertificate, boolean doesSupportNonce, FallbackOcspServiceConfiguration nextFallbackConfiguration, X509Certificate issuerCertificate, Set trustedCACertificateAnchors, - CertStore trustedCACertificateCertStore) throws OCSPCertificateException { + CertStore trustedCACertificateCertStore, + Duration maxThisUpdateAge, Duration maxNextUpdateAge, + boolean isKeyUsageRequired) throws OCSPCertificateException { this.accessLocation = Objects.requireNonNull(accessLocation, "Fallback OCSP service access location"); this.responderCertificate = responderCertificate; if (responderCertificate != null) { - OcspResponseValidator.validateHasSigningExtension(responderCertificate); + OcspResponseValidator.validateBasicConstraintsNotCA(responderCertificate); + OcspResponseValidator.validateKeyUsageDigitalSignature(responderCertificate, isKeyUsageRequired); + OcspResponseValidator.validateKeyUsageNotCertificateSigning(responderCertificate); + OcspResponseValidator.validateExtendedKeyUsageOcspSigning(responderCertificate); } this.doesSupportNonce = doesSupportNonce; this.nextFallbackConfiguration = nextFallbackConfiguration; this.issuerCertificate = Objects.requireNonNull(issuerCertificate, "issuerCertificate"); this.trustedCACertificateAnchors = Set.copyOf(Objects.requireNonNull(trustedCACertificateAnchors, "trustedCACertificateAnchors")); this.trustedCACertificateCertStore = Objects.requireNonNull(trustedCACertificateCertStore, "trustedCACertificateCertStore"); + this.maxThisUpdateAge = requirePositiveDuration(maxThisUpdateAge, "maxThisUpdateAge"); + this.maxNextUpdateAge = requirePositiveDuration(maxNextUpdateAge, "maxNextUpdateAge"); + this.isKeyUsageRequired = isKeyUsageRequired; } public URI getAccessLocation() { @@ -67,4 +81,16 @@ public Set getTrustedCACertificateAnchors() { public CertStore getTrustedCACertificateCertStore() { return trustedCACertificateCertStore; } + + public Duration getMaxThisUpdateAge() { + return maxThisUpdateAge; + } + + public Duration getMaxNextUpdateAge() { + return maxNextUpdateAge; + } + + public boolean isKeyUsageRequired() { + return isKeyUsageRequired; + } } diff --git a/src/main/java/eu/webeid/ocsp/service/OcspService.java b/src/main/java/eu/webeid/ocsp/service/OcspService.java index 221737ab..cca38056 100644 --- a/src/main/java/eu/webeid/ocsp/service/OcspService.java +++ b/src/main/java/eu/webeid/ocsp/service/OcspService.java @@ -8,6 +8,7 @@ import java.net.URI; import java.security.cert.X509Certificate; +import java.time.Duration; import java.util.Date; import java.util.Optional; @@ -17,6 +18,10 @@ public interface OcspService { URI getAccessLocation(); + Duration getMaxThisUpdateAge(); + + Duration getMaxNextUpdateAge(); + void validateResponderCertificate(X509CertificateHolder cert, X509Certificate issuerCertificate, Date now) throws AuthTokenException; default Optional getFallbackService() { diff --git a/src/main/java/eu/webeid/ocsp/service/OcspServiceProvider.java b/src/main/java/eu/webeid/ocsp/service/OcspServiceProvider.java index 96fca916..237ad700 100644 --- a/src/main/java/eu/webeid/ocsp/service/OcspServiceProvider.java +++ b/src/main/java/eu/webeid/ocsp/service/OcspServiceProvider.java @@ -3,7 +3,7 @@ package eu.webeid.ocsp.service; -import eu.webeid.security.exceptions.AuthTokenException; +import eu.webeid.ocsp.exceptions.UserCertificateOCSPException; import java.security.cert.X509Certificate; import java.util.Collection; @@ -47,9 +47,9 @@ private static Map buildFallbackOcspServic * @param certificate subject certificate that is to be checked with OCSP * @param issuerCertificate direct issuer from the validated certification path * @return either the designated or AIA OCSP service instance - * @throws AuthTokenException when AIA URL is not found in certificate + * @throws UserCertificateOCSPException when the AIA OCSP responder URL cannot be resolved from the certificate */ - public OcspService getService(X509Certificate certificate, X509Certificate issuerCertificate) throws AuthTokenException { + public OcspService getService(X509Certificate certificate, X509Certificate issuerCertificate) throws UserCertificateOCSPException { if (designatedOcspService != null && designatedOcspService.supportsIssuer(issuerCertificate)) { return designatedOcspService; } diff --git a/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java b/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java index 4eeaf2ef..98b28032 100644 --- a/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java +++ b/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java @@ -6,13 +6,14 @@ import eu.webeid.ocsp.OcspCertificateRevocationChecker; import eu.webeid.ocsp.client.OcspClient; import eu.webeid.ocsp.exceptions.OCSPClientException; +import eu.webeid.ocsp.exceptions.UserCertificateOCSPCheckFailedException; +import eu.webeid.ocsp.exceptions.UserCertificateOCSPException; import eu.webeid.ocsp.exceptions.UserCertificateRevokedException; -import eu.webeid.ocsp.protocol.OcspRequestBuilder; +import eu.webeid.ocsp.service.FallbackOcspService; 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; @@ -44,7 +45,6 @@ import java.util.Map; import java.util.Optional; -import static eu.webeid.security.util.DateAndTime.requirePositiveDuration; import static java.util.Objects.requireNonNull; /** @@ -59,17 +59,13 @@ public class ResilientOcspCertificateRevocationChecker extends OcspCertificateRe private final CircuitBreakerRegistry circuitBreakerRegistry; private final RetryRegistry retryRegistry; - private final Duration fallbackMaxOcspResponseThisUpdateAge; public ResilientOcspCertificateRevocationChecker(OcspClient ocspClient, OcspServiceProvider ocspServiceProvider, CircuitBreakerConfig circuitBreakerConfig, RetryConfig retryConfig, - Duration allowedOcspResponseTimeSkew, - Duration primaryMaxOcspResponseThisUpdateAge, - Duration fallbackMaxOcspResponseThisUpdateAge) { - super(ocspClient, ocspServiceProvider, allowedOcspResponseTimeSkew, primaryMaxOcspResponseThisUpdateAge); - this.fallbackMaxOcspResponseThisUpdateAge = requirePositiveDuration(fallbackMaxOcspResponseThisUpdateAge, "fallbackMaxOcspResponseThisUpdateAge"); + Duration allowedOcspResponseTimeSkew) { + super(ocspClient, ocspServiceProvider, allowedOcspResponseTimeSkew); this.circuitBreakerRegistry = CircuitBreakerRegistry.custom() .withCircuitBreakerConfig(getCircuitBreakerConfig(circuitBreakerConfig)) .build(); @@ -91,22 +87,29 @@ public ResilientOcspCertificateRevocationChecker(OcspClient ocspClient, public List validateCertificateNotRevoked(X509Certificate subjectCertificate, X509Certificate issuerCertificate) throws AuthTokenException { OcspService primaryService = getOcspServiceProvider().getService(subjectCertificate, issuerCertificate); + CertificateID certificateId = getCertificateId(subjectCertificate, issuerCertificate); + Optional firstFallbackServiceOpt = primaryService.getFallbackService(); if (firstFallbackServiceOpt.isEmpty()) { // Without a configured fallback, use the primary service directly without retry or circuit breaker. - return List.of(request(primaryService, subjectCertificate, issuerCertificate, getMaxOcspResponseThisUpdateAge())); + return List.of(request(primaryService, subjectCertificate, issuerCertificate, certificateId)); } CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(primaryService.getAccessLocation().toASCIIString()); List revocationInfoList = new ArrayList<>(); + // Requesting circuit breaker permission may change its state, for example from an expired OPEN + // state to HALF_OPEN when automatic transition is disabled (the default). To report the state + // that actually governs the request, the snapshot is captured inside the decorated call chain + // rather than here. + CircuitBreakerStatisticsSnapshot statisticsSnapshot = new CircuitBreakerStatisticsSnapshot(circuitBreaker); CheckedSupplier fallbackSupplier = buildFallbackSupplier(firstFallbackServiceOpt.get(), subjectCertificate, - issuerCertificate, revocationInfoList); + issuerCertificate, certificateId, revocationInfoList); CheckedSupplier decoratedSupplier = decorateWithResilience(primaryService, subjectCertificate, - issuerCertificate, revocationInfoList, fallbackSupplier, circuitBreaker); + issuerCertificate, certificateId, revocationInfoList, fallbackSupplier, circuitBreaker, statisticsSnapshot); - // Take a snapshot of circuit breaker statistics right before the first request. - CircuitBreakerStatistics circuitBreakerStatistics = createCircuitBreakerStatistics(circuitBreaker); - RevocationInfo revocationInfo = processResult(Try.of(decoratedSupplier::get), subjectCertificate, revocationInfoList, circuitBreakerStatistics); + Try result = Try.of(decoratedSupplier::get); + RevocationInfo revocationInfo = processResult(result, subjectCertificate, revocationInfoList, + statisticsSnapshot.get()); revocationInfoList.add(revocationInfo); return revocationInfoList; } @@ -130,10 +133,11 @@ private CircuitBreakerStatistics createCircuitBreakerStatistics(CircuitBreaker c private CheckedSupplier buildFallbackSupplier(FallbackOcspService firstFallbackService, X509Certificate subjectCertificate, X509Certificate issuerCertificate, + CertificateID certificateId, List revocationInfoList) { CheckedSupplier firstFallbackSupplier = () -> { try { - return request(firstFallbackService, subjectCertificate, issuerCertificate, fallbackMaxOcspResponseThisUpdateAge); + return request(firstFallbackService, subjectCertificate, issuerCertificate, certificateId); } catch (Exception e) { createAndAddRevocationInfoToList(e, revocationInfoList); throw e; @@ -147,7 +151,7 @@ private CheckedSupplier buildFallbackSupplier(FallbackOcspServic } CheckedSupplier secondFallbackSupplier = () -> { try { - return request(secondFallbackService, subjectCertificate, issuerCertificate, fallbackMaxOcspResponseThisUpdateAge); + return request(secondFallbackService, subjectCertificate, issuerCertificate, certificateId); } catch (Exception e) { createAndAddRevocationInfoToList(e, revocationInfoList); throw e; @@ -170,12 +174,17 @@ private CheckedSupplier buildFallbackSupplier(FallbackOcspServic private CheckedSupplier decorateWithResilience(OcspService primaryService, X509Certificate subjectCertificate, X509Certificate issuerCertificate, + CertificateID certificateId, List revocationInfoList, CheckedSupplier fallbackSupplier, - CircuitBreaker circuitBreaker) { + CircuitBreaker circuitBreaker, + CircuitBreakerStatisticsSnapshot statisticsSnapshot) { CheckedSupplier primarySupplier = () -> { + // The circuit breaker has just permitted this call, so its current state is the one that + // governs the request. + statisticsSnapshot.capture(); try { - return request(primaryService, subjectCertificate, issuerCertificate, getMaxOcspResponseThisUpdateAge()); + return request(primaryService, subjectCertificate, issuerCertificate, certificateId); } catch (Exception e) { createAndAddRevocationInfoToList(e, revocationInfoList); throw e; @@ -187,7 +196,12 @@ private CheckedSupplier decorateWithResilience(OcspService prima decorateCheckedSupplier.withRetry(retry); } decorateCheckedSupplier.withCircuitBreaker(circuitBreaker) - .withFallback(List.of(ResilientUserCertificateOCSPCheckFailedException.class, CallNotPermittedException.class), e -> fallbackSupplier.get()); + .withFallback(List.of(ResilientUserCertificateOCSPCheckFailedException.class, CallNotPermittedException.class), e -> { + // No-op if the primary request ran; otherwise the circuit breaker rejected the call and + // this captures the rejecting state before the fallback request starts. + statisticsSnapshot.capture(); + return fallbackSupplier.get(); + }); return decorateCheckedSupplier.decorate(); } @@ -237,25 +251,19 @@ private void createAndAddRevocationInfoToList(Throwable throwable, List throwable instanceof ResilientUserCertificateOCSPCheckFailedException) .build(); } @@ -363,6 +375,28 @@ private static RevocationInfo withOCSPClientException(RevocationInfo revocationI .withAdditionalOcspResponseAttribute(RevocationInfo.KEY_HTTP_STATUS_CODE, e.getStatusCode()); } + private final class CircuitBreakerStatisticsSnapshot { + + private final CircuitBreaker circuitBreaker; + private CircuitBreakerStatistics statistics; + + private CircuitBreakerStatisticsSnapshot(CircuitBreaker circuitBreaker) { + this.circuitBreaker = circuitBreaker; + } + + private void capture() { + if (statistics == null) { + statistics = createCircuitBreakerStatistics(circuitBreaker); + } + } + + private CircuitBreakerStatistics get() { + // Safety net: if neither the primary supplier nor the fallback ran, capture the statistics now. + capture(); + return statistics; + } + } + public record CircuitBreakerStatistics( CircuitBreaker.State state, float failureRate, diff --git a/src/main/java/eu/webeid/security/exceptions/AuthTokenSignatureValidationException.java b/src/main/java/eu/webeid/security/exceptions/AuthTokenSignatureValidationException.java index 0c7ab83c..fbf1d371 100644 --- a/src/main/java/eu/webeid/security/exceptions/AuthTokenSignatureValidationException.java +++ b/src/main/java/eu/webeid/security/exceptions/AuthTokenSignatureValidationException.java @@ -8,12 +8,14 @@ */ public class AuthTokenSignatureValidationException extends AuthTokenException { + private static final String MESSAGE = "Token signature validation has failed. Check that the origin and nonce are correct."; + public AuthTokenSignatureValidationException() { - super("Token signature validation has failed. Check that the origin and nonce are correct."); + super(MESSAGE); } public AuthTokenSignatureValidationException(Throwable cause) { - super("Token signature validation has failed", cause); + super(MESSAGE, cause); } } diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenSignatureValidator.java b/src/main/java/eu/webeid/security/validator/AuthTokenSignatureValidator.java index 18397f94..362c3555 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenSignatureValidator.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenSignatureValidator.java @@ -7,6 +7,7 @@ import eu.webeid.security.exceptions.AuthTokenParseException; import eu.webeid.security.exceptions.AuthTokenSignatureValidationException; import eu.webeid.security.exceptions.ChallengeNullOrEmptyException; +import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.impl.security.DefaultVerifySecureDigestRequest; import io.jsonwebtoken.security.SignatureAlgorithm; @@ -88,13 +89,15 @@ public void validate(String algorithm, String signature, PublicKey publicKey, St new ByteArrayInputStream(concatSignedFields), null, null, publicKey, decodedSignature); + final boolean signatureIsValid; try { - if (!signatureAlgorithm.verify(verificationRequest)) { - throw new AuthTokenSignatureValidationException(); - } - } catch (SignatureException e) { + signatureIsValid = signatureAlgorithm.verify(verificationRequest); + } catch (JwtException e) { throw new AuthTokenSignatureValidationException(e); } + if (!signatureIsValid) { + throw new AuthTokenSignatureValidationException(); + } } private MessageDigest hashAlgorithmForName(String algorithm) throws NoSuchAlgorithmException { diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java index afae8093..9a51ffed 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java @@ -121,6 +121,16 @@ private ValidationInfo validateToken(WebEidAuthToken token, String currentChalle // Use the clock instance so that the date can be mocked in tests. final Date now = DateAndTime.DefaultClock.getInstance().now(); + // 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. + authTokenSignatureValidator.validate(token.algorithm(), + token.signature(), + subjectCertificate.getPublicKey(), + currentChallengeNonce + ); + + // Revocation validation is the last step to ensure that all non-network checks + // are completed before any OCSP requests are made. final List revocationInfoList = CertificateValidator.validateCertificateTrustAndRevocation( subjectCertificate, trustedCACertificateAnchors, @@ -133,14 +143,6 @@ private ValidationInfo validateToken(WebEidAuthToken token, String currentChalle ); LOG.debug("Subject certificate is valid and signed by a trusted CA"); - // 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. - authTokenSignatureValidator.validate(token.algorithm(), - token.signature(), - subjectCertificate.getPublicKey(), - currentChallengeNonce - ); - return new ValidationInfo(subjectCertificate, revocationInfoList); } diff --git a/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerNetworkTest.java b/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerNetworkTest.java index 0916071f..bb96e91a 100644 --- a/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerNetworkTest.java +++ b/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerNetworkTest.java @@ -116,7 +116,8 @@ void whenDesignatedResponderOmitsNonce_thenConfiguredPolicyIsEnforced(boolean no @Test void whenDesignatedResponderCertificateDiffers_thenFailurePreservesCertificateCause() throws Exception { final var checker = customChecker(new DesignatedOcspServiceConfiguration( - responder.designatedUri(), responder.responderCertificate(), List.of(responder.issuer()), true)); + responder.designatedUri(), responder.responderCertificate(), List.of(responder.issuer()), true, + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, true)); responder.replaceResponderCertificate(true); assertThatThrownBy(() -> checker.validateCertificateNotRevoked(responder.subject(), responder.issuer())) @@ -138,7 +139,7 @@ void whenAiaResponderLacksSigningUsage_thenFailurePreservesCertificateCause() th .hasMessageContaining(responder.aiaUri().toString()) .cause() .isExactlyInstanceOf(OCSPCertificateException.class) - .hasMessageContaining("does not contain the key usage extension for OCSP response signing"); + .hasMessageContaining("does not contain the Key Usage extension required for OCSP response signing"); assertThat(responder.requestCount()).isEqualTo(1); assertThat(responder.receivedPath()).isEqualTo("/aia"); } @@ -239,9 +240,9 @@ private OcspCertificateRevocationChecker customChecker(DesignatedOcspServiceConf OcspClientImpl.build(Duration.ofSeconds(2)), new OcspServiceProvider(designated, new AiaOcspServiceConfiguration(Set.of(), CertificateValidator.buildTrustAnchorsFromCertificates(anchors), - CertificateValidator.buildCertStoreFromCertificates(intermediates))), - OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW, - OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE); + CertificateValidator.buildCertStoreFromCertificates(intermediates), + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, true)), + OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW); } private List validate() throws Exception { @@ -250,7 +251,8 @@ private List validate() throws Exception { private List validate(boolean nonceEnabled) throws Exception { final var checker = customChecker(new DesignatedOcspServiceConfiguration( - responder.designatedUri(), responder.responderCertificate(), List.of(responder.issuer()), nonceEnabled)); + responder.designatedUri(), responder.responderCertificate(), List.of(responder.issuer()), nonceEnabled, + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, true)); return CertificateValidator.validateCertificateTrustAndRevocation( responder.subject(), CertificateValidator.buildTrustAnchorsFromCertificates(List.of(responder.issuer())), diff --git a/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java b/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java index 9b90c70e..48fe5067 100644 --- a/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java +++ b/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java @@ -11,6 +11,7 @@ import eu.webeid.ocsp.exceptions.UserCertificateRevokedException; import eu.webeid.security.testutil.AbstractTestWithValidator; import eu.webeid.security.testutil.AuthTokenValidators; +import eu.webeid.security.testutil.ResourceUtil; import eu.webeid.security.util.DateAndTime; import eu.webeid.ocsp.client.OcspClient; import eu.webeid.ocsp.client.OcspClientImpl; @@ -25,7 +26,6 @@ import org.junit.jupiter.api.Test; import java.io.IOException; -import java.io.InputStream; import java.net.ConnectException; import java.net.URI; import java.net.URISyntaxException; @@ -47,13 +47,11 @@ import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.when; -public class OcspCertificateRevocationCheckerTest extends AbstractTestWithValidator { +class OcspCertificateRevocationCheckerTest extends AbstractTestWithValidator { private final OcspClient ocspClient = OcspClientImpl.build(Duration.ofSeconds(5)); private X509Certificate estEid2018Cert; @@ -117,11 +115,13 @@ void whenOcspUrlIsInvalid_thenThrows() throws Exception { void whenOcspRequestFails_thenThrows() throws Exception { final OcspServiceProvider ocspServiceProvider = getDesignatedOcspServiceProvider("http://demo.sk.ee/ocsps"); final OcspCertificateRevocationChecker validator = getOcspCertificateRevocationChecker(ocspServiceProvider); - UserCertificateOCSPCheckFailedException ex = assertThrows(UserCertificateOCSPCheckFailedException.class, () -> - validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); - OCSPClientException ocspClientException = assertInstanceOf(OCSPClientException.class, ex.getCause()); - assertThat(ocspClientException).hasMessageStartingWith("OCSP request was not successful"); - assertThat(ocspClientException.getStatusCode()).isEqualTo(404); + assertThatThrownBy(() -> + validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .isInstanceOf(UserCertificateOCSPCheckFailedException.class) + .cause() + .isInstanceOf(OCSPClientException.class) + .hasMessageStartingWith("OCSP request was not successful") + .satisfies(cause -> assertThat(((OCSPClientException) cause).getStatusCode()).isEqualTo(404)); } @Test @@ -236,7 +236,7 @@ void whenOcspResponseRevoked_thenThrows() throws Exception { @Test void whenOcspResponseUnknown_thenThrows() throws Exception { final OcspServiceProvider ocspServiceProvider = getDesignatedOcspServiceProvider("https://web-eid-test.free.beeceptor.com"); - final HttpResponse response = getMockedResponse(getOcspResponseBytesFromResources("ocsp_response_unknown.der")); + final HttpResponse response = getMockedResponse(getOcspResponseBytesFromResources("ocsp_response_unknown_self_signed.der")); final OcspCertificateRevocationChecker validator = getOcspCertificateRevocationChecker(getMockClient(response), ocspServiceProvider); try (var mockedClock = mockStatic(DateAndTime.DefaultClock.class)) { mockDate("2021-09-18T00:16:25", mockedClock); @@ -250,7 +250,7 @@ void whenOcspResponseUnknown_thenThrows() throws Exception { @Test void whenOcspResponseSignerIsNotIssuedBySubjectIssuer_thenThrows() throws Exception { final OcspCertificateRevocationChecker validator = getOcspCertificateRevocationCheckerWithAiaOcsp( - getMockedResponse(getOcspResponseBytesFromResources("ocsp_response_unknown.der")) + getMockedResponse(getOcspResponseBytesFromResources("ocsp_response_unknown_self_signed.der")) ); try (var mockedClock = mockStatic(DateAndTime.DefaultClock.class)) { mockDate("2021-09-18T00:16:25", mockedClock); @@ -266,7 +266,7 @@ void whenOcspResponseSignerIsNotIssuedBySubjectIssuer_thenThrows() throws Except @Test void whenOcspResponseCACertExpired_thenThrows() throws Exception { final OcspCertificateRevocationChecker validator = getOcspCertificateRevocationCheckerWithAiaOcsp( - getMockedResponse(getOcspResponseBytesFromResources("ocsp_response_unknown.der")) + getMockedResponse(getOcspResponseBytesFromResources("ocsp_response_unknown_self_signed.der")) ); assertThatThrownBy(() -> validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) .isInstanceOf(UserCertificateOCSPCheckFailedException.class) @@ -292,25 +292,17 @@ void whenNonceDiffers_thenThrows() throws Exception { @Test void whenInvalidOcspResponseTimeSkew_thenThrows() { - assertThatThrownBy(() -> getOcspCertificateRevocationCheckerWithTimeSkewAndUpdateAge(Duration.ofMinutes(-1), Duration.ofMinutes(1))) + assertThatThrownBy(() -> new OcspCertificateRevocationChecker(ocspClient, getAiaOcspServiceProvider(), Duration.ofMinutes(-1))) .isInstanceOf(IllegalArgumentException.class) .hasMessageStartingWith("allowedOcspResponseTimeSkew must be greater than zero"); } - @Test - void whenInvalidMaxOcspResponseThisUpdateAge_thenThrows() { - assertThatThrownBy(() -> getOcspCertificateRevocationCheckerWithTimeSkewAndUpdateAge(Duration.ofMinutes(1), Duration.ZERO)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageStartingWith("maxOcspResponseThisUpdateAge must be greater than zero"); - } - private static AuthTokenValidator getAuthTokenValidatorWithOcspCertificateRevocationChecker() throws CertificateException, JceException, IOException { return AuthTokenValidators.getDefaultAuthTokenValidatorBuilder() .withCertificateRevocationChecker(new OcspCertificateRevocationChecker( OcspClientImpl.build(Duration.ofSeconds(5)), getAiaOcspServiceProvider(), - OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW, - OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE + OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW )).build(); } @@ -356,10 +348,8 @@ private static byte[] getOcspResponseBytesFromResources() throws IOException { return getOcspResponseBytesFromResources("ocsp_response.der"); } - public static byte[] getOcspResponseBytesFromResources(String resource) throws IOException { - try (final InputStream resourceAsStream = ClassLoader.getSystemResourceAsStream(resource)) { - return toByteArray(resourceAsStream); - } + private static byte[] getOcspResponseBytesFromResources(String resource) throws IOException { + return ResourceUtil.bytesFromResource(resource); } private OcspCertificateRevocationChecker getOcspCertificateRevocationCheckerWithAiaOcsp(HttpResponse response) throws JceException { @@ -371,11 +361,7 @@ private OcspCertificateRevocationChecker getOcspCertificateRevocationChecker(Ocs } private OcspCertificateRevocationChecker getOcspCertificateRevocationChecker(OcspClient client, OcspServiceProvider ocspServiceProvider) { - return new OcspCertificateRevocationChecker(client, ocspServiceProvider, OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW, OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE); - } - - private void getOcspCertificateRevocationCheckerWithTimeSkewAndUpdateAge(Duration timeSkew, Duration updateAge) throws JceException { - new OcspCertificateRevocationChecker(ocspClient, getAiaOcspServiceProvider(), timeSkew, updateAge); + return new OcspCertificateRevocationChecker(client, ocspServiceProvider, OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW); } private HttpResponse getMockedResponse(byte[] bodyContent) throws URISyntaxException { @@ -405,15 +391,4 @@ private OcspClient getMockClient(HttpResponse response) { }; } - private static byte[] toByteArray(InputStream resourceAsStream) throws IOException { - Objects.requireNonNull(resourceAsStream); - int bytesAvailable = resourceAsStream.available(); - byte[] result = new byte[bytesAvailable]; - int bytesRead = resourceAsStream.read(result); - if (bytesRead != bytesAvailable) { - throw new RuntimeException("Short read while loading resources"); - } - return result; - } - } diff --git a/src/test/java/eu/webeid/ocsp/client/OcspClientImplTest.java b/src/test/java/eu/webeid/ocsp/client/OcspClientImplTest.java new file mode 100644 index 00000000..c735923e --- /dev/null +++ b/src/test/java/eu/webeid/ocsp/client/OcspClientImplTest.java @@ -0,0 +1,207 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.ocsp.client; + +import eu.webeid.ocsp.exceptions.OCSPClientException; +import eu.webeid.security.testutil.ResourceUtil; +import org.bouncycastle.cert.ocsp.OCSPReq; +import org.bouncycastle.cert.ocsp.OCSPResp; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.ByteBuffer; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Flow; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class OcspClientImplTest { + + private static final URI OCSP_URI = URI.create("http://ocsp.test/"); + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + @Test + void whenHttpResponseStatusIsNot200_thenThrowsWithStatusCodeAndBody() throws Exception { + byte[] body = "not-found".getBytes(); + HttpClient httpClient = mockHttpClient(mockResponse(404, body, "text/plain")); + OcspClientImpl client = new OcspClientImpl(httpClient, TIMEOUT); + + assertThatExceptionOfType(OCSPClientException.class) + .isThrownBy(() -> client.request(OCSP_URI, encodableOcspReq())) + .withMessageStartingWith("OCSP request was not successful") + .satisfies(ex -> { + assertThat(ex.getStatusCode()).isEqualTo(404); + assertThat(ex.getResponseBody()).isEqualTo(body); + }); + } + + @Test + void whenContentTypeIsNotOcspResponse_thenThrows() throws Exception { + HttpClient httpClient = mockHttpClient(mockResponse(200, new byte[]{0x01}, "text/html")); + OcspClientImpl client = new OcspClientImpl(httpClient, TIMEOUT); + + assertThatExceptionOfType(OCSPClientException.class) + .isThrownBy(() -> client.request(OCSP_URI, encodableOcspReq())) + .withMessage("OCSP response content type is not application/ocsp-response"); + } + + @Test + void whenHttpClientThrowsInterruptedException_thenRestoresInterruptFlagAndThrows() throws Exception { + HttpClient httpClient = mock(HttpClient.class); + when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenThrow(new InterruptedException("interrupted")); + OcspClientImpl client = new OcspClientImpl(httpClient, TIMEOUT); + + try { + assertThatExceptionOfType(OCSPClientException.class) + .isThrownBy(() -> client.request(OCSP_URI, encodableOcspReq())) + .withMessage("Interrupted while sending OCSP request") + .withCauseInstanceOf(InterruptedException.class); + } finally { + // Always clear so a failing assertion above doesn't leak the interrupt flag to other tests. + assertThat(Thread.interrupted()).as("interrupt flag must be set by InterruptedException handling").isTrue(); + } + } + + @Test + void whenHttpClientThrowsIOException_thenThrows() throws Exception { + HttpClient httpClient = mock(HttpClient.class); + when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenThrow(new IOException("network down")); + OcspClientImpl client = new OcspClientImpl(httpClient, TIMEOUT); + + assertThatExceptionOfType(OCSPClientException.class) + .isThrownBy(() -> client.request(OCSP_URI, encodableOcspReq())) + .withCauseInstanceOf(IOException.class); + } + + @Test + void whenOcspReqGetEncodedThrowsIOException_thenThrows() throws Exception { + OCSPReq ocspReq = mock(OCSPReq.class); + when(ocspReq.getEncoded()).thenThrow(new IOException("encoding failed")); + OcspClientImpl client = new OcspClientImpl(mock(HttpClient.class), TIMEOUT); + + assertThatExceptionOfType(OCSPClientException.class) + .isThrownBy(() -> client.request(OCSP_URI, ocspReq)) + .withCauseInstanceOf(IOException.class); + } + + @Test + void whenResponseBodyIsInvalidOcsp_thenThrowsWithIoExceptionCause() throws Exception { + HttpClient httpClient = mockHttpClient(mockResponse(200, "not-an-ocsp-response".getBytes(), "application/ocsp-response")); + OcspClientImpl client = new OcspClientImpl(httpClient, TIMEOUT); + + assertThatExceptionOfType(OCSPClientException.class) + .isThrownBy(() -> client.request(OCSP_URI, encodableOcspReq())) + .withCauseInstanceOf(IOException.class); + } + + @Test + void whenRequestIsSent_thenOcspRequestIsPostedWithCorrectUriHeaderAndBody() throws Exception { + byte[] responseBody = ResourceUtil.bytesFromResource("ocsp_response.der"); + HttpClient httpClient = mockHttpClient(mockResponse(200, responseBody, "application/ocsp-response")); + OcspClientImpl client = new OcspClientImpl(httpClient, TIMEOUT); + OCSPReq ocspReq = encodableOcspReq(); + + client.request(OCSP_URI, ocspReq); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + verify(httpClient).send(requestCaptor.capture(), any(HttpResponse.BodyHandler.class)); + HttpRequest request = requestCaptor.getValue(); + + assertThat(request.uri()).isEqualTo(OCSP_URI); + assertThat(request.method()).isEqualTo("POST"); + assertThat(request.headers().firstValue("Content-Type")).contains("application/ocsp-request"); + assertThat(request.bodyPublisher()).isPresent(); + assertThat(request.bodyPublisher().get().contentLength()).isEqualTo(ocspReq.getEncoded().length); + assertThat(readBodyPublisher(request.bodyPublisher().get())).isEqualTo(ocspReq.getEncoded()); + } + + @Test + void whenResponseIsValidOcsp_thenReturnsParsedOcspResp() throws Exception { + byte[] responseBody = ResourceUtil.bytesFromResource("ocsp_response.der"); + HttpClient httpClient = mockHttpClient(mockResponse(200, responseBody, "application/ocsp-response")); + OcspClientImpl client = new OcspClientImpl(httpClient, TIMEOUT); + + OCSPResp ocspResp = client.request(OCSP_URI, encodableOcspReq()); + + assertThat(ocspResp).isNotNull(); + assertThat(ocspResp.getStatus()).isEqualTo(OCSPResp.SUCCESSFUL); + } + + @Test + void whenHttpClientIsNull_thenThrows() { + assertThatExceptionOfType(NullPointerException.class) + .isThrownBy(() -> new OcspClientImpl(null, TIMEOUT)); + } + + @SuppressWarnings("unchecked") + private static HttpClient mockHttpClient(HttpResponse response) throws Exception { + HttpClient httpClient = mock(HttpClient.class); + when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))).thenReturn(response); + return httpClient; + } + + @SuppressWarnings("unchecked") + private static HttpResponse mockResponse(int statusCode, byte[] body, String contentType) { + HttpResponse response = mock(HttpResponse.class); + when(response.statusCode()).thenReturn(statusCode); + when(response.body()).thenReturn(body); + HttpHeaders headers = HttpHeaders.of(Map.of("Content-Type", List.of(contentType)), (k, v) -> true); + when(response.headers()).thenReturn(headers); + return response; + } + + private static OCSPReq encodableOcspReq() throws IOException { + OCSPReq ocspReq = mock(OCSPReq.class); + when(ocspReq.getEncoded()).thenReturn(new byte[]{0x30, 0x00}); + return ocspReq; + } + + private static byte[] readBodyPublisher(HttpRequest.BodyPublisher bodyPublisher) { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + CompletableFuture result = new CompletableFuture<>(); + + bodyPublisher.subscribe(new Flow.Subscriber<>() { + @Override + public void onSubscribe(Flow.Subscription subscription) { + subscription.request(Long.MAX_VALUE); + } + + @Override + public void onNext(ByteBuffer item) { + byte[] chunk = new byte[item.remaining()]; + item.get(chunk); + output.write(chunk, 0, chunk.length); + } + + @Override + public void onError(Throwable throwable) { + result.completeExceptionally(throwable); + } + + @Override + public void onComplete() { + result.complete(output.toByteArray()); + } + }); + + return result.join(); + } +} diff --git a/src/test/java/eu/webeid/ocsp/client/OcspClientOverrideTest.java b/src/test/java/eu/webeid/ocsp/client/OcspClientOverrideTest.java index 828d2915..f09aca39 100644 --- a/src/test/java/eu/webeid/ocsp/client/OcspClientOverrideTest.java +++ b/src/test/java/eu/webeid/ocsp/client/OcspClientOverrideTest.java @@ -56,8 +56,7 @@ private static AuthTokenValidator getAuthTokenValidatorWithOverriddenOcspClient( .withCertificateRevocationChecker(new OcspCertificateRevocationChecker( ocspClient, getAiaOcspServiceProvider(), - OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW, - OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE + OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW )).build(); } diff --git a/src/test/java/eu/webeid/ocsp/protocol/IssuerDistinguishedNameTest.java b/src/test/java/eu/webeid/ocsp/protocol/IssuerDistinguishedNameTest.java new file mode 100644 index 00000000..9f42d5be --- /dev/null +++ b/src/test/java/eu/webeid/ocsp/protocol/IssuerDistinguishedNameTest.java @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.ocsp.protocol; + +import org.bouncycastle.asn1.x500.X500Name; +import org.junit.jupiter.api.Test; + +import static eu.webeid.ocsp.protocol.IssuerDistinguishedName.getIssuerDistinguishedName; +import static eu.webeid.security.testutil.Certificates.getMariliisEsteid2015Cert; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +class IssuerDistinguishedNameTest { + + private static final X500Name ISSUER_DN = new X500Name("CN=TEST of ESTEID-SK 2015, OID.2.5.4.97=NTREE-10747013, O=AS Sertifitseerimiskeskus, C=EE"); + + @Test + void whenCertificateGiven_thenReturnsIssuerDistinguishedName() throws Exception { + assertThat(getIssuerDistinguishedName(getMariliisEsteid2015Cert())).isEqualTo(ISSUER_DN); + } + + @Test + void whenCertificateIsNull_thenThrows() { + assertThatNullPointerException().isThrownBy(() -> getIssuerDistinguishedName(null)); + } +} diff --git a/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java b/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java index 677bc8dd..9b079b27 100644 --- a/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java +++ b/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java @@ -4,24 +4,40 @@ package eu.webeid.ocsp.protocol; import eu.webeid.ocsp.OcspCertificateRevocationChecker; +import eu.webeid.ocsp.exceptions.OCSPCertificateException; import eu.webeid.ocsp.exceptions.UserCertificateOCSPCheckFailedException; import eu.webeid.ocsp.exceptions.UserCertificateRevokedException; +import org.bouncycastle.asn1.DLBitString; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.ExtendedKeyUsage; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.bouncycastle.asn1.x509.KeyUsage; 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.Test; import java.net.URI; +import java.security.cert.X509Certificate; import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Date; -import static eu.webeid.ocsp.OcspCertificateRevocationCheckerTest.getOcspResponseBytesFromResources; +import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateBasicConstraintsNotCA; +import static eu.webeid.security.testutil.ResourceUtil.bytesFromResource; import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateCertificateStatusUpdateTime; +import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateExtendedKeyUsageOcspSigning; +import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateKeyUsageDigitalSignature; +import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateKeyUsageNotCertificateSigning; import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateSubjectCertificateStatus; +import static eu.webeid.security.testutil.TestCertificateBuilder.buildCertificate; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -29,17 +45,22 @@ class OcspResponseValidatorTest { private static final Duration TIME_SKEW = OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW; private static final Duration THIS_UPDATE_AGE = OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE; + private static final Duration NEXT_UPDATE_AGE = OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE; + private static final Duration LONG_THIS_UPDATE_AGE = Duration.ofDays(365); + private static final Duration LONG_NEXT_UPDATE_AGE = Duration.ofDays(365); + /** Shorter than {@link #TIME_SKEW}, to show that the nextUpdate age check is independent of the time skew. */ + private static final Duration SHORT_NEXT_UPDATE_AGE = Duration.ofMinutes(2); private static final URI OCSP_URL = URI.create("https://example.org"); @Test - void whenThisAndNextUpdateWithinSkew_thenValidationSucceeds() { + void whenThisAndNextUpdateWithinAgeLimits_thenValidationSucceeds() { final SingleResp mockResponse = mock(SingleResp.class); var now = Instant.now(); var thisUpdateWithinAgeLimit = getThisUpdateWithinAgeLimit(now); 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, NEXT_UPDATE_AGE, OCSP_URL)) .doesNotThrowAnyException(); } @@ -53,7 +74,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, LONG_NEXT_UPDATE_AGE, OCSP_URL)) .withMessageStartingWith("User certificate revocation check has failed: " + "Certificate status update time check failed: " + "nextUpdate '" + beforeThisUpdate.toInstant() + "' is before thisUpdate '" + thisUpdateWithinAgeLimit.toInstant() + "'"); @@ -67,7 +88,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, NEXT_UPDATE_AGE, OCSP_URL)) .withMessageStartingWith("User certificate revocation check has failed: " + "Certificate status update time check failed: " + "thisUpdate '" + halfHourBeforeNow.toInstant() + "' is too old, minimum time allowed: "); @@ -81,12 +102,22 @@ 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, NEXT_UPDATE_AGE, OCSP_URL)) .withMessageStartingWith("User certificate revocation check has failed: " + "Certificate status update time check failed: " + "thisUpdate '" + halfHourAfterNow.toInstant() + "' is too far in the future, latest allowed: "); } + @Test + void whenNextUpdateIsNull_thenValidationSucceeds() { + final SingleResp mockResponse = mock(SingleResp.class); + var now = Instant.now(); + when(mockResponse.getThisUpdate()).thenReturn(getThisUpdateWithinAgeLimit(now)); + when(mockResponse.getNextUpdate()).thenReturn(null); + assertThatCode(() -> validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, NEXT_UPDATE_AGE, OCSP_URL)) + .doesNotThrowAnyException(); + } + @Test void whenNextUpdateHalfHourBeforeNow_thenThrows() { final SingleResp mockResponse = mock(SingleResp.class); @@ -97,11 +128,39 @@ void whenNextUpdateHalfHourBeforeNow_thenThrows() { when(mockResponse.getNextUpdate()).thenReturn(halfHourBeforeNow); assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) .isThrownBy(() -> - validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, OCSP_URL)) - .withMessage("User certificate revocation check has failed: " + validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, NEXT_UPDATE_AGE, OCSP_URL)) + .withMessageStartingWith("User certificate revocation check has failed: " + + "Certificate status update time check failed: " + + "nextUpdate '" + halfHourBeforeNow.toInstant() + "' is too old, minimum time allowed: '"); + } + + @Test + void whenNextUpdateOlderThanMaxNextUpdateAgeButWithinTimeSkew_thenThrows() { + final SingleResp mockResponse = mock(SingleResp.class); + var now = Instant.now(); + var thisUpdateBeforeNextUpdate = Date.from(now.minus(14, ChronoUnit.MINUTES)); + var nextUpdateWithinTimeSkewButTooOld = Date.from(now.minus(10, ChronoUnit.MINUTES)); + when(mockResponse.getThisUpdate()).thenReturn(thisUpdateBeforeNextUpdate); + when(mockResponse.getNextUpdate()).thenReturn(nextUpdateWithinTimeSkewButTooOld); + assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> + validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, TIME_SKEW, SHORT_NEXT_UPDATE_AGE, OCSP_URL)) + .withMessageStartingWith("User certificate revocation check has failed: " + "Certificate status update time check failed: " - + "nextUpdate '" + halfHourBeforeNow.toInstant() + "' is in the past" - + " (OCSP responder: https://example.org)"); + + "nextUpdate '" + nextUpdateWithinTimeSkewButTooOld.toInstant() + "' is too old, minimum time allowed: '"); + } + + @Test + void whenNextUpdateOlderThanTimeSkewButWithinMaxNextUpdateAge_thenValidationSucceeds() { + final SingleResp mockResponse = mock(SingleResp.class); + var now = Instant.now(); + var thisUpdateOlderThanTimeSkew = Date.from(now.minus(25, ChronoUnit.MINUTES)); + var nextUpdateOlderThanTimeSkew = Date.from(now.minus(20, ChronoUnit.MINUTES)); + when(mockResponse.getThisUpdate()).thenReturn(thisUpdateOlderThanTimeSkew); + when(mockResponse.getNextUpdate()).thenReturn(nextUpdateOlderThanTimeSkew); + assertThatCode(() -> + validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, LONG_THIS_UPDATE_AGE, LONG_NEXT_UPDATE_AGE, OCSP_URL)) + .doesNotThrowAnyException(); } @Test @@ -113,14 +172,208 @@ void whenOcspResponseStatusIsUnknown_ThenThrowsUserCertificateOCSPCheckFailedExc .withMessage("User certificate revocation check has failed: Unknown status (OCSP responder: https://example.org)"); } + @Test + void whenCertIsNotCA_thenBasicConstraintsValidationSucceeds() throws Exception { + final X509Certificate cert = buildCertificate( + new Extension(Extension.basicConstraints, true, new BasicConstraints(false).getEncoded())); + assertThatCode(() -> validateBasicConstraintsNotCA(cert)).doesNotThrowAnyException(); + } + + @Test + void whenBasicConstraintsExtensionAbsent_thenBasicConstraintsValidationSucceeds() throws Exception { + final X509Certificate cert = buildCertificate(); + assertThatCode(() -> validateBasicConstraintsNotCA(cert)).doesNotThrowAnyException(); + } + + @Test + void whenCertIsCA_thenBasicConstraintsValidationThrows() throws Exception { + final X509Certificate cert = buildCertificate( + new Extension(Extension.basicConstraints, true, new BasicConstraints(0).getEncoded())); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> validateBasicConstraintsNotCA(cert)) + .withMessage("Certificate " + cert.getSubjectX500Principal() + + " must not be a CA certificate (Basic Constraints CA:TRUE is not allowed for OCSP responder)"); + } + + @Test + void whenCertIsNull_thenBasicConstraintsValidationThrowsNullPointerException() { + assertThatNullPointerException().isThrownBy(() -> validateBasicConstraintsNotCA(null)) + .withMessage("certificate"); + } + + @Test + void whenCertHasKeyUsageDigitalSignature_thenKeyUsageValidationSucceeds() throws Exception { + final X509Certificate cert = buildCertificate( + new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature).getEncoded())); + assertThatCode(() -> validateKeyUsageDigitalSignature(cert, true)).doesNotThrowAnyException(); + } + + @Test + void whenKeyUsageExtensionAbsent_thenKeyUsageValidationThrows() throws Exception { + final X509Certificate cert = buildCertificate(); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> validateKeyUsageDigitalSignature(cert, true)) + .withMessage("Certificate " + cert.getSubjectX500Principal() + + " does not contain the Key Usage extension required for OCSP response signing"); + } + + @Test + void whenCertMissingKeyUsageDigitalSignature_thenKeyUsageValidationThrows() throws Exception { + final X509Certificate cert = buildCertificate( + new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.nonRepudiation).getEncoded())); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> validateKeyUsageDigitalSignature(cert, true)) + .withMessage("Certificate " + cert.getSubjectX500Principal() + + " Key Usage extension does not contain Digital Signature, which is required for OCSP response signing"); + } + + @Test + void whenCertKeyUsageBitStringEmpty_thenKeyUsageValidationThrows() throws Exception { + final X509Certificate cert = buildCertificate( + new Extension(Extension.keyUsage, true, new DLBitString(new byte[0]).getEncoded())); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> validateKeyUsageDigitalSignature(cert, true)) + .withMessage("Certificate " + cert.getSubjectX500Principal() + + " Key Usage extension does not contain Digital Signature, which is required for OCSP response signing"); + } + + @Test + void whenKeyUsageNotRequiredAndCertMissingKeyUsageDigitalSignature_thenKeyUsageValidationSucceeds() throws Exception { + final X509Certificate cert = buildCertificate( + new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.nonRepudiation).getEncoded())); + assertThatCode(() -> validateKeyUsageDigitalSignature(cert, false)).doesNotThrowAnyException(); + } + + @Test + void whenKeyUsageNotRequiredAndKeyUsageExtensionAbsent_thenKeyUsageValidationSucceeds() throws Exception { + final X509Certificate cert = buildCertificate(); + assertThatCode(() -> validateKeyUsageDigitalSignature(cert, false)).doesNotThrowAnyException(); + } + + @Test + void whenKeyUsageNotRequiredAndCertIsNull_thenKeyUsageValidationThrowsNullPointerException() { + assertThatNullPointerException().isThrownBy(() -> validateKeyUsageDigitalSignature(null, false)) + .withMessage("certificate"); + } + + @Test + void whenCertIsNull_thenKeyUsageValidationThrowsNullPointerException() { + assertThatNullPointerException().isThrownBy(() -> validateKeyUsageDigitalSignature(null, true)) + .withMessage("certificate"); + } + + @Test + void whenCertHasNoKeyUsageKeyCertSign_thenKeyCertSignValidationSucceeds() throws Exception { + final X509Certificate cert = buildCertificate( + new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature).getEncoded())); + assertThatCode(() -> validateKeyUsageNotCertificateSigning(cert)).doesNotThrowAnyException(); + } + + @Test + void whenKeyUsageExtensionAbsent_thenKeyCertSignValidationSucceeds() throws Exception { + final X509Certificate cert = buildCertificate(); + assertThatCode(() -> validateKeyUsageNotCertificateSigning(cert)).doesNotThrowAnyException(); + } + + @Test + void whenCertKeyUsageBitStringEmpty_thenKeyCertSignValidationSucceeds() throws Exception { + final X509Certificate cert = buildCertificate( + new Extension(Extension.keyUsage, true, new DLBitString(new byte[0]).getEncoded())); + assertThatCode(() -> validateKeyUsageNotCertificateSigning(cert)).doesNotThrowAnyException(); + } + + @Test + void whenCertHasKeyUsageKeyCertSign_thenKeyCertSignValidationThrows() throws Exception { + final X509Certificate cert = buildCertificate( + new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign).getEncoded())); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> validateKeyUsageNotCertificateSigning(cert)) + .withMessage("Certificate " + cert.getSubjectX500Principal() + + " Key Usage extension contains Certificate Signing, which is not allowed for OCSP responder"); + } + + @Test + void whenCertHasKeyUsageKeyCertSignCombinedWithDigitalSignature_thenKeyCertSignValidationThrows() throws Exception { + final X509Certificate cert = buildCertificate( + new Extension(Extension.keyUsage, true, + new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyCertSign).getEncoded())); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> validateKeyUsageNotCertificateSigning(cert)) + .withMessage("Certificate " + cert.getSubjectX500Principal() + + " Key Usage extension contains Certificate Signing, which is not allowed for OCSP responder"); + } + + @Test + void whenCertIsNull_thenKeyCertSignValidationThrowsNullPointerException() { + assertThatNullPointerException().isThrownBy(() -> validateKeyUsageNotCertificateSigning(null)) + .withMessage("certificate"); + } + + @Test + void whenCertHasOcspSigningEku_thenExtendedKeyUsageValidationSucceeds() throws Exception { + final X509Certificate cert = buildCertificate( + new Extension(Extension.extendedKeyUsage, true, + new ExtendedKeyUsage(KeyPurposeId.id_kp_OCSPSigning).getEncoded())); + assertThatCode(() -> validateExtendedKeyUsageOcspSigning(cert)).doesNotThrowAnyException(); + } + + @Test + void whenExtendedKeyUsageExtensionAbsent_thenExtendedKeyUsageValidationThrows() throws Exception { + final X509Certificate cert = buildCertificate(); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> validateExtendedKeyUsageOcspSigning(cert)) + .withMessage("Certificate " + cert.getSubjectX500Principal() + + " does not contain the Extended Key Usage extension required for OCSP response signing"); + } + + @Test + void whenCertMissingOcspSigningEku_thenExtendedKeyUsageValidationThrows() throws Exception { + final X509Certificate cert = buildCertificate( + new Extension(Extension.extendedKeyUsage, true, + new ExtendedKeyUsage(KeyPurposeId.id_kp_clientAuth).getEncoded())); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> validateExtendedKeyUsageOcspSigning(cert)) + .withMessage("Certificate " + cert.getSubjectX500Principal() + + " Extended Key Usage extension does not contain OCSP Signing, which is required for OCSP response signing"); + } + + @Test + void whenCertIsNull_thenExtendedKeyUsageValidationThrowsNullPointerException() { + assertThatNullPointerException().isThrownBy(() -> validateExtendedKeyUsageOcspSigning(null)) + .withMessage("certificate"); + } + + @Test + void whenRevokedStatusHasNoReason_thenThrows() { + final SingleResp mockResponse = mock(SingleResp.class); + when(mockResponse.getCertStatus()).thenReturn(new RevokedStatus(new Date())); + assertThatExceptionOfType(UserCertificateRevokedException.class) + .isThrownBy(() -> + validateSubjectCertificateStatus(mockResponse, OCSP_URL)) + .withMessage("User certificate has been revoked (OCSP responder: https://example.org)"); + } + + @Test + void whenStatusIsNeitherGoodRevokedNorUnknown_thenThrowsUserCertificateOCSPCheckFailedException() { + final SingleResp mockResponse = mock(SingleResp.class); + when(mockResponse.getCertStatus()).thenReturn(new UnexpectedCertificateStatus()); + assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> + validateSubjectCertificateStatus(mockResponse, OCSP_URL)) + .withMessage("User certificate revocation check has failed: Status is neither good, revoked nor unknown (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 OCSPResp ocspRespUnknown = new OCSPResp(bytesFromResource("ocsp_response_unknown.der")); final BasicOCSPResp basicResponse = (BasicOCSPResp) ocspRespUnknown.getResponseObject(); return basicResponse.getResponses()[0]; } + private static class UnexpectedCertificateStatus implements CertificateStatus { + } + } diff --git a/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceConfigurationTest.java b/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceConfigurationTest.java index 06d79020..5e5132a8 100644 --- a/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceConfigurationTest.java +++ b/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceConfigurationTest.java @@ -3,6 +3,7 @@ package eu.webeid.ocsp.service; +import eu.webeid.ocsp.OcspCertificateRevocationChecker; import eu.webeid.security.certificate.CertificateValidator; import org.bouncycastle.asn1.x500.X500Name; import org.junit.jupiter.api.Test; @@ -25,7 +26,7 @@ void whenCallerMutatesCollections_thenConfigurationRemainsUnchanged() throws Exc final TrustAnchor anchor = new TrustAnchor(getTestEsteid2018CA(), null); final Set anchors = new HashSet<>(Set.of(anchor)); final AiaOcspServiceConfiguration configuration = new AiaOcspServiceConfiguration( - nonceDisabledIssuerDNs, anchors, CertificateValidator.buildCertStoreFromCertificates(List.of(getTestEsteid2018CA()))); + nonceDisabledIssuerDNs, anchors, CertificateValidator.buildCertStoreFromCertificates(List.of(getTestEsteid2018CA())), OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, true); nonceDisabledIssuerDNs.clear(); anchors.clear(); diff --git a/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceTest.java b/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceTest.java new file mode 100644 index 00000000..92ee8b76 --- /dev/null +++ b/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceTest.java @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.ocsp.service; + +import eu.webeid.security.certificate.CertificateValidator; +import org.bouncycastle.asn1.x500.X500Name; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.security.cert.CertStore; +import java.security.cert.TrustAnchor; +import java.security.cert.X509Certificate; +import java.util.List; +import java.util.Set; + +import static eu.webeid.ocsp.OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE; +import static eu.webeid.ocsp.OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE; +import static eu.webeid.ocsp.protocol.IssuerDistinguishedName.getIssuerDistinguishedName; +import static eu.webeid.security.testutil.Certificates.getJaakKristjanEsteid2018Cert; +import static eu.webeid.security.testutil.Certificates.getMariliisEsteid2015Cert; +import static eu.webeid.security.testutil.Certificates.getTestEsteid2015CA; +import static eu.webeid.security.testutil.Certificates.getTestEsteid2018CA; +import static eu.webeid.security.testutil.Certificates.getDemoEsteidSk2018AiaOcspResponder; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Unit tests for the {@link AiaOcspService} behaviour introduced in commit e2bd57e3: + * nonce support is now keyed on the certificate's issuer distinguished name (previously the OCSP URL), + * and the service carries an optional {@link FallbackOcspService}. + */ +class AiaOcspServiceTest { + + private static final URI ESTEID2018_AIA_OCSP_URI = URI.create("http://aia.demo.sk.ee/esteid2018"); + private static final URI ESTEID2015_AIA_OCSP_URI = URI.create("http://aia.demo.sk.ee/esteid2015"); + private static final URI FALLBACK_URI = URI.create("http://fallback.ocsp.test"); + + private static Set trustedCaAnchors; + private static CertStore trustedCaCertStore; + private static X509Certificate esteid2018UserCert; + private static X509Certificate esteid2015UserCert; + private static X500Name esteid2018IssuerDN; + private static X500Name esteid2015IssuerDN; + + @BeforeAll + static void setUpFixtures() throws Exception { + List trustedCaCertificates = List.of(getTestEsteid2018CA(), getTestEsteid2015CA()); + trustedCaAnchors = CertificateValidator.buildTrustAnchorsFromCertificates(trustedCaCertificates); + trustedCaCertStore = CertificateValidator.buildCertStoreFromCertificates(trustedCaCertificates); + esteid2018UserCert = getJaakKristjanEsteid2018Cert(); + esteid2015UserCert = getMariliisEsteid2015Cert(); + esteid2018IssuerDN = getIssuerDistinguishedName(esteid2018UserCert); + esteid2015IssuerDN = getIssuerDistinguishedName(esteid2015UserCert); + } + + private static AiaOcspServiceConfiguration configurationWithNonceDisabledFor(X500Name... nonceDisabledIssuerDNs) { + return new AiaOcspServiceConfiguration(Set.of(nonceDisabledIssuerDNs), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true); + } + + @Test + void whenIssuerDnIsInNonceDisabledSet_thenDoesNotSupportNonce() throws Exception { + AiaOcspServiceConfiguration configuration = configurationWithNonceDisabledFor(esteid2015IssuerDN); + + AiaOcspService service = new AiaOcspService(configuration, esteid2015UserCert, null); + + assertThat(service.getAccessLocation()).isEqualTo(ESTEID2015_AIA_OCSP_URI); + assertThat(service.doesSupportNonce()).isFalse(); + } + + @Test + void whenIssuerDnIsNotInNonceDisabledSet_thenSupportsNonce() throws Exception { + // Only the ESTEID-SK 2015 issuer is nonce-disabled, so a certificate from the 2018 issuer must still support nonce. + AiaOcspServiceConfiguration configuration = configurationWithNonceDisabledFor(esteid2015IssuerDN); + + AiaOcspService service = new AiaOcspService(configuration, esteid2018UserCert, null); + + assertThat(service.getAccessLocation()).isEqualTo(ESTEID2018_AIA_OCSP_URI); + assertThat(service.doesSupportNonce()).isTrue(); + } + + @Test + void whenFallbackServiceProvided_thenGetFallbackServiceReturnsIt() throws Exception { + AiaOcspServiceConfiguration configuration = configurationWithNonceDisabledFor(); + FallbackOcspServiceConfiguration fallbackConfiguration = new FallbackOcspServiceConfiguration( + FALLBACK_URI, getDemoEsteidSk2018AiaOcspResponder(), true, + null, getTestEsteid2018CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true); + FallbackOcspService fallback = new FallbackOcspService(fallbackConfiguration); + + AiaOcspService service = new AiaOcspService(configuration, esteid2018UserCert, fallback); + + assertThat(service.getFallbackService()).containsSame(fallback); + assertThat(service.getFallbackService().get().getAccessLocation()).isEqualTo(FALLBACK_URI); + } + + @Test + void whenFallbackServiceIsNull_thenGetFallbackServiceIsEmpty() throws Exception { + AiaOcspServiceConfiguration configuration = configurationWithNonceDisabledFor(); + + AiaOcspService service = new AiaOcspService(configuration, esteid2018UserCert, null); + + assertThat(service.getFallbackService()).isEmpty(); + } + + @Test + void whenCertificateIsNull_thenThrows() { + AiaOcspServiceConfiguration configuration = configurationWithNonceDisabledFor(); + + assertThatExceptionOfType(NullPointerException.class) + .isThrownBy(() -> new AiaOcspService(configuration, null, null)); + } +} diff --git a/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceConfigurationTest.java b/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceConfigurationTest.java index 819ea3a0..7443eac0 100644 --- a/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceConfigurationTest.java +++ b/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceConfigurationTest.java @@ -3,33 +3,205 @@ package eu.webeid.ocsp.service; +import eu.webeid.ocsp.exceptions.OCSPCertificateException; import eu.webeid.security.certificate.CertificateValidator; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import java.net.URI; +import java.security.cert.CertStore; import java.security.cert.TrustAnchor; +import java.security.cert.X509Certificate; +import java.time.Duration; import java.util.HashSet; import java.util.List; import java.util.Set; +import static eu.webeid.ocsp.OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE; +import static eu.webeid.ocsp.OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE; + +import static eu.webeid.security.testutil.Certificates.getDemoEsteidSk2018AiaOcspResponder; +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.getTestSkOcspResponder2020; 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.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; import static org.assertj.core.api.Assertions.assertThatThrownBy; class FallbackOcspServiceConfigurationTest { + private static final URI FALLBACK_URI = URI.create("http://fallback.ocsp.test"); + + private static Set trustedCaAnchors; + private static CertStore trustedCaCertStore; + private static X509Certificate aiaOcspResponderCert; + private static X509Certificate ocspResponder2020Cert; + private static X509Certificate nonSigningUserCert; + + @BeforeAll + static void setUpFixtures() throws Exception { + List trustedCaCertificates = List.of(getTestEsteid2018CA(), getTestEsteid2015CA()); + trustedCaAnchors = CertificateValidator.buildTrustAnchorsFromCertificates(trustedCaCertificates); + trustedCaCertStore = CertificateValidator.buildCertStoreFromCertificates(trustedCaCertificates); + // The DEMO AIA responder certificate satisfies every OCSP responder extension requirement. + aiaOcspResponderCert = getDemoEsteidSk2018AiaOcspResponder(); + // TEST of SK OCSP RESPONDER 2020 carries no Key Usage extension at all. + ocspResponder2020Cert = getTestSkOcspResponder2020(); + nonSigningUserCert = getJaakKristjanEsteid2018Cert(); + } + + @Test + void whenAccessLocationIsNull_thenThrows() { + assertThatNullPointerException() + .isThrownBy(() -> new FallbackOcspServiceConfiguration( + null, null, true, null, getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true)) + .withMessage("Fallback OCSP service access location"); + } + + @Test + void whenIssuerDnIsNull_thenThrows() { + assertThatNullPointerException() + .isThrownBy(() -> new FallbackOcspServiceConfiguration( + FALLBACK_URI, null, true, null, null, trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true)) + .withMessage("issuerCertificate"); + } + + @Test + void whenTrustedCaAnchorsIsNull_thenThrows() { + assertThatNullPointerException() + .isThrownBy(() -> new FallbackOcspServiceConfiguration( + FALLBACK_URI, null, true, null, getTestEsteid2015CA(), null, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true)) + .withMessage("trustedCACertificateAnchors"); + } + + @Test + void whenTrustedCaCertStoreIsNull_thenThrows() { + assertThatNullPointerException() + .isThrownBy(() -> new FallbackOcspServiceConfiguration( + FALLBACK_URI, null, true, null, getTestEsteid2015CA(), trustedCaAnchors, null, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true)) + .withMessage("trustedCACertificateCertStore"); + } + + @Test + void whenMaxThisUpdateAgeIsNull_thenThrows() { + assertThatNullPointerException() + .isThrownBy(() -> new FallbackOcspServiceConfiguration( + FALLBACK_URI, null, true, null, getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore, + null, DEFAULT_NEXT_UPDATE_AGE, true)) + .withMessage("maxThisUpdateAge must not be null"); + } + + @Test + void whenMaxNextUpdateAgeIsNull_thenThrows() { + assertThatNullPointerException() + .isThrownBy(() -> new FallbackOcspServiceConfiguration( + FALLBACK_URI, null, true, null, getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, null, true)) + .withMessage("maxNextUpdateAge must not be null"); + } + + @Test + void whenMaxThisUpdateAgeIsZero_thenThrows() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new FallbackOcspServiceConfiguration( + FALLBACK_URI, null, true, null, getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore, + Duration.ZERO, DEFAULT_NEXT_UPDATE_AGE, true)) + .withMessage("maxThisUpdateAge must be greater than zero"); + } + + @Test + void whenMaxThisUpdateAgeIsNegative_thenThrows() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new FallbackOcspServiceConfiguration( + FALLBACK_URI, null, true, null, getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore, + Duration.ofMinutes(-1), DEFAULT_NEXT_UPDATE_AGE, true)) + .withMessage("maxThisUpdateAge must be greater than zero"); + } + + @Test + void whenMaxNextUpdateAgeIsZero_thenThrows() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new FallbackOcspServiceConfiguration( + FALLBACK_URI, null, true, null, getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, Duration.ZERO, true)) + .withMessage("maxNextUpdateAge must be greater than zero"); + } + + @Test + void whenMaxNextUpdateAgeIsNegative_thenThrows() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new FallbackOcspServiceConfiguration( + FALLBACK_URI, null, true, null, getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, Duration.ofMinutes(-1), true)) + .withMessage("maxNextUpdateAge must be greater than zero"); + } + + @Test + void whenResponderCertificateLacksSigningExtension_thenThrows() { + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> new FallbackOcspServiceConfiguration( + FALLBACK_URI, nonSigningUserCert, true, null, getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true)) + .withMessageContaining("Extended Key Usage extension does not contain OCSP Signing, " + + "which is required for OCSP response signing"); + } + + @Test + void whenResponderCertificateLacksKeyUsageExtension_thenThrows() { + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> new FallbackOcspServiceConfiguration( + FALLBACK_URI, ocspResponder2020Cert, true, null, getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true)) + .withMessageContaining("does not contain the Key Usage extension required for OCSP response signing"); + } + + @Test + void whenResponderCertificateIsNull_thenConstructionSucceeds() { + assertThatCode(() -> new FallbackOcspServiceConfiguration( + FALLBACK_URI, null, true, null, getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true)) + .doesNotThrowAnyException(); + } + + @Test + void whenResponderCertificateHasSigningExtension_thenConstructionSucceedsAndAccessorsReturnConfiguredValues() throws Exception { + FallbackOcspServiceConfiguration nextFallback = new FallbackOcspServiceConfiguration( + URI.create("http://next.fallback.ocsp.test"), null, false, null, + getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true); + + FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( + FALLBACK_URI, aiaOcspResponderCert, true, nextFallback, + getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore, + Duration.ofMinutes(3), Duration.ofMinutes(4), true); + + assertThat(configuration.getAccessLocation()).isEqualTo(FALLBACK_URI); + assertThat(configuration.getResponderCertificate()).isEqualTo(aiaOcspResponderCert); + assertThat(configuration.doesSupportNonce()).isTrue(); + assertThat(configuration.getNextFallbackConfiguration()).isSameAs(nextFallback); + assertThat(configuration.getIssuerCertificate()).isEqualTo(getTestEsteid2015CA()); + assertThat(configuration.getTrustedCACertificateAnchors()).isSameAs(trustedCaAnchors); + assertThat(configuration.getTrustedCACertificateCertStore()).isSameAs(trustedCaCertStore); + assertThat(configuration.getMaxThisUpdateAge()).isEqualTo(Duration.ofMinutes(3)); + assertThat(configuration.getMaxNextUpdateAge()).isEqualTo(Duration.ofMinutes(4)); + } + @Test void whenCallerMutatesCollections_thenConfigurationRemainsUnchanged() throws Exception { final TrustAnchor anchor = new TrustAnchor(getTestEsteid2018CA(), null); final Set anchors = new HashSet<>(Set.of(anchor)); final FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( - URI.create("http://fallback.ocsp.test"), - null, - true, - null, - getTestEsteid2018CA(), - anchors, - CertificateValidator.buildCertStoreFromCertificates(List.of(getTestEsteid2018CA()))); + FALLBACK_URI, null, true, null, getTestEsteid2018CA(), anchors, + CertificateValidator.buildCertStoreFromCertificates(List.of(getTestEsteid2018CA())), + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true); anchors.clear(); diff --git a/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceTest.java b/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceTest.java new file mode 100644 index 00000000..81e4a770 --- /dev/null +++ b/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceTest.java @@ -0,0 +1,231 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.ocsp.service; + +import eu.webeid.ocsp.exceptions.OCSPCertificateException; +import eu.webeid.security.certificate.CertificateValidator; +import eu.webeid.security.exceptions.CertificateExpiredException; +import org.bouncycastle.cert.X509CertificateHolder; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.URI; +import java.security.cert.CertStore; +import java.security.cert.TrustAnchor; +import java.security.cert.X509Certificate; +import java.util.Date; +import java.util.List; +import java.util.Set; + +import static eu.webeid.ocsp.OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE; +import static eu.webeid.ocsp.OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE; +import static eu.webeid.security.testutil.Certificates.getJaakKristjanEsteid2018Cert; +import static eu.webeid.security.testutil.Certificates.getDemoEsteidSk2018AiaOcspResponder; +import static eu.webeid.security.testutil.Certificates.getTestEsteid2015CA; +import static eu.webeid.security.testutil.Certificates.getTestEsteid2018CA; +import static eu.webeid.security.testutil.Certificates.getTestSelfSignedOcspResponder; +import static eu.webeid.security.testutil.Certificates.getTestSkOcspResponder2020; +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.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class FallbackOcspServiceTest { + + private static final URI PRIMARY_FALLBACK_URI = URI.create("http://primary-fallback.ocsp.test"); + private static final URI SECONDARY_FALLBACK_URI = URI.create("http://secondary-fallback.ocsp.test"); + private static final Date VALIDATION_DATE_WITHIN_RESPONDER_VALIDITY = new Date(1630000000000L); + + private static Set trustedCaAnchors; + private static CertStore trustedCaCertStore; + private static X509Certificate aiaOcspResponderCert; + private static X509Certificate selfSignedOcspResponderCert; + private static X509Certificate ocspResponder2020Cert; + private static X509Certificate esteid2018CaCert; + private static X509Certificate nonSigningUserCert; + + @BeforeAll + static void setUpFixtures() throws Exception { + List trustedCaCertificates = List.of(getTestEsteid2018CA(), getTestEsteid2015CA()); + trustedCaAnchors = CertificateValidator.buildTrustAnchorsFromCertificates(trustedCaCertificates); + trustedCaCertStore = CertificateValidator.buildCertStoreFromCertificates(trustedCaCertificates); + // The DEMO AIA responder certificate satisfies the OCSP responder extension requirements + // (not a CA, Key Usage Digital Signature, no Certificate Signing, Extended Key Usage OCSP Signing) + // and it chains to the TEST of ESTEID2018 CA, which is one of the trusted CA anchors above. + aiaOcspResponderCert = getDemoEsteidSk2018AiaOcspResponder(); + // The self-signed responder certificate satisfies the same extension requirements, but no + // trusted CA issued it. Use it when the PKIX trust check must be the failure. + selfSignedOcspResponderCert = getTestSelfSignedOcspResponder(); + // TEST of SK OCSP RESPONDER 2020 carries no Key Usage extension at all. Use it only where the + // Key Usage check must reject the certificate. + ocspResponder2020Cert = getTestSkOcspResponder2020(); + esteid2018CaCert = getTestEsteid2018CA(); + nonSigningUserCert = getJaakKristjanEsteid2018Cert(); + } + + @Test + void whenConfigurationIsProvided_thenAccessorsReturnConfiguredValues() throws Exception { + FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( + PRIMARY_FALLBACK_URI, aiaOcspResponderCert, true, + null, getTestEsteid2018CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true); + + FallbackOcspService service = new FallbackOcspService(configuration); + + assertThat(service.getAccessLocation()).isEqualTo(PRIMARY_FALLBACK_URI); + assertThat(service.doesSupportNonce()).isTrue(); + assertThat(service.getNextFallback()).isNull(); + } + + @Test + void whenNextFallbackConfigurationProvided_thenChainIsBuiltRecursively() throws Exception { + FallbackOcspServiceConfiguration secondaryConfiguration = new FallbackOcspServiceConfiguration( + SECONDARY_FALLBACK_URI, null, false, + null, getTestEsteid2018CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true); + FallbackOcspServiceConfiguration primaryConfiguration = new FallbackOcspServiceConfiguration( + PRIMARY_FALLBACK_URI, null, true, + secondaryConfiguration, getTestEsteid2018CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true); + + FallbackOcspService primary = new FallbackOcspService(primaryConfiguration); + + FallbackOcspService secondary = primary.getNextFallback(); + assertThat(secondary).isNotNull(); + assertThat(secondary.getAccessLocation()).isEqualTo(SECONDARY_FALLBACK_URI); + assertThat(secondary.doesSupportNonce()).isFalse(); + assertThat(secondary.getNextFallback()).isNull(); + } + + @Test + void whenResponderCertificateIsPinnedAndMatches_thenValidationSucceeds() throws Exception { + FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( + PRIMARY_FALLBACK_URI, aiaOcspResponderCert, true, + null, getTestEsteid2018CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true); + FallbackOcspService service = new FallbackOcspService(configuration); + X509CertificateHolder matchingHolder = new X509CertificateHolder(aiaOcspResponderCert.getEncoded()); + + assertThatCode(() -> + service.validateResponderCertificate(matchingHolder, getTestEsteid2018CA(), VALIDATION_DATE_WITHIN_RESPONDER_VALIDITY)) + .doesNotThrowAnyException(); + } + + @Test + void whenResponderCertificateIsPinnedAndDiffers_thenThrows() throws Exception { + FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( + PRIMARY_FALLBACK_URI, aiaOcspResponderCert, true, + null, getTestEsteid2018CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true); + FallbackOcspService service = new FallbackOcspService(configuration); + X509CertificateHolder differentHolder = new X509CertificateHolder(esteid2018CaCert.getEncoded()); + + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> + service.validateResponderCertificate(differentHolder, getTestEsteid2018CA(), VALIDATION_DATE_WITHIN_RESPONDER_VALIDITY)) + .withMessage("Responder certificate from the OCSP response is not equal to the configured fallback OCSP responder certificate"); + } + + @Test + void whenResponderCertificateIsNotPinnedButTrustedByCa_thenValidationSucceeds() throws Exception { + FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( + PRIMARY_FALLBACK_URI, null, true, + null, getTestEsteid2018CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true); + FallbackOcspService service = new FallbackOcspService(configuration); + // The DEMO AIA responder certificate chains to the TEST of ESTEID2018 CA, which is a trusted anchor. + X509CertificateHolder responderHolder = new X509CertificateHolder(aiaOcspResponderCert.getEncoded()); + + assertThatCode(() -> + service.validateResponderCertificate(responderHolder, getTestEsteid2018CA(), VALIDATION_DATE_WITHIN_RESPONDER_VALIDITY)) + .doesNotThrowAnyException(); + } + + @Test + void whenResponderCertificateIsNotPinnedAndLacksSigningExtension_thenThrows() throws Exception { + FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( + PRIMARY_FALLBACK_URI, null, true, + null, getTestEsteid2018CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true); + FallbackOcspService service = new FallbackOcspService(configuration); + X509CertificateHolder nonSigningHolder = new X509CertificateHolder(nonSigningUserCert.getEncoded()); + + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> + service.validateResponderCertificate(nonSigningHolder, getTestEsteid2018CA(), VALIDATION_DATE_WITHIN_RESPONDER_VALIDITY)) + .withMessageContaining("Extended Key Usage extension does not contain OCSP Signing, " + + "which is required for OCSP response signing"); + } + + @Test + void whenResponderCertificateLacksKeyUsageExtension_thenThrows() throws Exception { + // TEST of SK OCSP RESPONDER 2020 carries the OCSP-signing EKU, but it has no Key Usage extension. + // FallbackOcspService runs the certificate extension checks before the PKIX trust check, so the + // Key Usage check rejects this certificate whatever the configured trust anchors are. + FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( + PRIMARY_FALLBACK_URI, null, true, + null, getTestEsteid2018CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true); + FallbackOcspService service = new FallbackOcspService(configuration); + X509CertificateHolder noKeyUsageHolder = new X509CertificateHolder(ocspResponder2020Cert.getEncoded()); + + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> + service.validateResponderCertificate(noKeyUsageHolder, getTestEsteid2018CA(), VALIDATION_DATE_WITHIN_RESPONDER_VALIDITY)) + .withMessageContaining("does not contain the Key Usage extension required for OCSP response signing"); + } + + @Test + void whenResponderCertificateIsNotIssuedBySubjectIssuer_thenThrows() throws Exception { + FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( + PRIMARY_FALLBACK_URI, null, true, + null, getTestEsteid2018CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true); + FallbackOcspService service = new FallbackOcspService(configuration); + // The self-signed responder certificate satisfies every OCSP responder extension requirement, but it was + // not issued by the subject certificate's issuer, so it is rejected before PKIX path building. + X509CertificateHolder untrustedButEkuValidHolder = new X509CertificateHolder(selfSignedOcspResponderCert.getEncoded()); + + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> + service.validateResponderCertificate(untrustedButEkuValidHolder, getTestEsteid2018CA(), VALIDATION_DATE_WITHIN_RESPONDER_VALIDITY)) + .withMessage("Fallback OCSP responder is not issued by the subject certificate's issuer"); + } + + @Test + void whenResponderCertificateHolderConversionFails_thenThrows() throws Exception { + FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( + PRIMARY_FALLBACK_URI, aiaOcspResponderCert, true, + null, getTestEsteid2018CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true); + FallbackOcspService service = new FallbackOcspService(configuration); + // Make JcaX509CertificateConverter.getCertificate(holder) fail: the converter calls holder.getEncoded() + // and wraps the resulting IOException into a CertificateException, which the service catches and rewraps. + X509CertificateHolder unconvertibleHolder = mock(X509CertificateHolder.class); + when(unconvertibleHolder.getEncoded()).thenThrow(new IOException("simulated encoding failure")); + + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> + service.validateResponderCertificate(unconvertibleHolder, getTestEsteid2018CA(), VALIDATION_DATE_WITHIN_RESPONDER_VALIDITY)) + .withMessage("Invalid responder certificate") + .withCauseInstanceOf(java.security.cert.CertificateException.class); + } + + @Test + void whenResponderCertificateIsExpiredAtValidationDate_thenThrows() throws Exception { + FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( + PRIMARY_FALLBACK_URI, aiaOcspResponderCert, true, + null, getTestEsteid2018CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true); + FallbackOcspService service = new FallbackOcspService(configuration); + X509CertificateHolder responderHolder = new X509CertificateHolder(aiaOcspResponderCert.getEncoded()); + Date farFuture = new Date(4102444800000L); // 2100-01-01 + + assertThatExceptionOfType(CertificateExpiredException.class) + .isThrownBy(() -> + service.validateResponderCertificate(responderHolder, getTestEsteid2018CA(), farFuture)); + } +} diff --git a/src/test/java/eu/webeid/ocsp/service/OcspServiceConfigurationTest.java b/src/test/java/eu/webeid/ocsp/service/OcspServiceConfigurationTest.java new file mode 100644 index 00000000..07bdaa5d --- /dev/null +++ b/src/test/java/eu/webeid/ocsp/service/OcspServiceConfigurationTest.java @@ -0,0 +1,190 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.ocsp.service; + +import eu.webeid.ocsp.exceptions.OCSPCertificateException; +import eu.webeid.security.certificate.CertificateValidator; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.ExtendedKeyUsage; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.security.cert.CertStore; +import java.security.cert.TrustAnchor; +import java.security.cert.X509Certificate; +import java.util.List; +import java.util.Set; + +import static eu.webeid.ocsp.OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE; +import static eu.webeid.ocsp.OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE; +import static eu.webeid.security.testutil.Certificates.getTestEsteid2018CA; +import static eu.webeid.security.testutil.TestCertificateBuilder.buildCertificate; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +class OcspServiceConfigurationTest { + + private static final URI OCSP_URL = URI.create("http://demo.sk.ee/ocsp"); + + @Test + void whenDesignatedOcspServiceConfigurationResponderCertIsCA_thenConstructorThrows() throws Exception { + final X509Certificate caResponder = buildCertificate( + new Extension(Extension.basicConstraints, true, new BasicConstraints(0).getEncoded())); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> newDesignatedOcspServiceConfiguration(caResponder)) + .withMessage("Certificate " + caResponder.getSubjectX500Principal() + + " must not be a CA certificate (Basic Constraints CA:TRUE is not allowed for OCSP responder)"); + } + + @Test + void whenDesignatedOcspServiceConfigurationResponderCertMissingKeyUsageDigitalSignature_thenConstructorThrows() throws Exception { + final X509Certificate responder = buildCertificate( + new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.nonRepudiation).getEncoded())); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> newDesignatedOcspServiceConfiguration(responder)) + .withMessage("Certificate " + responder.getSubjectX500Principal() + + " Key Usage extension does not contain Digital Signature, which is required for OCSP response signing"); + } + + @Test + void whenDesignatedOcspServiceConfigurationResponderCertHasKeyCertSign_thenConstructorThrows() throws Exception { + final X509Certificate responder = buildCertificate( + new Extension(Extension.keyUsage, true, + new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyCertSign).getEncoded()), + new Extension(Extension.extendedKeyUsage, true, + new ExtendedKeyUsage(KeyPurposeId.id_kp_OCSPSigning).getEncoded())); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> newDesignatedOcspServiceConfiguration(responder)) + .withMessage("Certificate " + responder.getSubjectX500Principal() + + " Key Usage extension contains Certificate Signing, which is not allowed for OCSP responder"); + } + + @Test + void whenDesignatedOcspServiceConfigurationResponderCertMissingOcspSigningEku_thenConstructorThrows() throws Exception { + final X509Certificate responder = buildCertificate( + new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature).getEncoded()), + new Extension(Extension.extendedKeyUsage, true, + new ExtendedKeyUsage(KeyPurposeId.id_kp_clientAuth).getEncoded())); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> newDesignatedOcspServiceConfiguration(responder)) + .withMessage("Certificate " + responder.getSubjectX500Principal() + + " Extended Key Usage extension does not contain OCSP Signing, which is required for OCSP response signing"); + } + + @Test + void whenFallbackOcspServiceConfigurationResponderCertIsCA_thenConstructorThrows() throws Exception { + final X509Certificate caResponder = buildCertificate( + new Extension(Extension.basicConstraints, true, new BasicConstraints(0).getEncoded())); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> newFallbackOcspServiceConfiguration(caResponder)) + .withMessage("Certificate " + caResponder.getSubjectX500Principal() + + " must not be a CA certificate (Basic Constraints CA:TRUE is not allowed for OCSP responder)"); + } + + @Test + void whenFallbackOcspServiceConfigurationResponderCertMissingKeyUsageDigitalSignature_thenConstructorThrows() throws Exception { + final X509Certificate responder = buildCertificate( + new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.nonRepudiation).getEncoded())); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> newFallbackOcspServiceConfiguration(responder)) + .withMessage("Certificate " + responder.getSubjectX500Principal() + + " Key Usage extension does not contain Digital Signature, which is required for OCSP response signing"); + } + + @Test + void whenFallbackOcspServiceConfigurationResponderCertHasKeyCertSign_thenConstructorThrows() throws Exception { + final X509Certificate responder = buildCertificate( + new Extension(Extension.keyUsage, true, + new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyCertSign).getEncoded()), + new Extension(Extension.extendedKeyUsage, true, + new ExtendedKeyUsage(KeyPurposeId.id_kp_OCSPSigning).getEncoded())); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> newFallbackOcspServiceConfiguration(responder)) + .withMessage("Certificate " + responder.getSubjectX500Principal() + + " Key Usage extension contains Certificate Signing, which is not allowed for OCSP responder"); + } + + @Test + void whenFallbackOcspServiceConfigurationResponderCertMissingOcspSigningEku_thenConstructorThrows() throws Exception { + final X509Certificate responder = buildCertificate( + new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature).getEncoded()), + new Extension(Extension.extendedKeyUsage, true, + new ExtendedKeyUsage(KeyPurposeId.id_kp_clientAuth).getEncoded())); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> newFallbackOcspServiceConfiguration(responder)) + .withMessage("Certificate " + responder.getSubjectX500Principal() + + " Extended Key Usage extension does not contain OCSP Signing, which is required for OCSP response signing"); + } + + @Test + void whenDesignatedOcspServiceConfigurationKeyUsageNotRequired_thenResponderCertWithoutDigitalSignatureIsAccepted() throws Exception { + final X509Certificate responder = buildResponderCertificateWithoutDigitalSignature(); + assertThatCode(() -> new DesignatedOcspServiceConfiguration( + OCSP_URL, responder, List.of(getTestEsteid2018CA()), true, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, false)) + .doesNotThrowAnyException(); + } + + @Test + void whenFallbackOcspServiceConfigurationKeyUsageNotRequired_thenResponderCertWithoutDigitalSignatureIsAccepted() throws Exception { + final X509Certificate responder = buildResponderCertificateWithoutDigitalSignature(); + final List trustedCAs = List.of(getTestEsteid2018CA()); + assertThatCode(() -> new FallbackOcspServiceConfiguration(OCSP_URL, responder, true, null, getTestEsteid2018CA(), + CertificateValidator.buildTrustAnchorsFromCertificates(trustedCAs), + CertificateValidator.buildCertStoreFromCertificates(trustedCAs), + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, false)) + .doesNotThrowAnyException(); + } + + @Test + void whenDesignatedOcspServiceConfigurationKeyUsageNotRequired_thenResponderCertWithoutKeyUsageIsAccepted() throws Exception { + final X509Certificate responder = buildResponderCertificateWithoutKeyUsage(); + assertThatCode(() -> new DesignatedOcspServiceConfiguration( + OCSP_URL, responder, List.of(getTestEsteid2018CA()), true, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, false)) + .doesNotThrowAnyException(); + } + + @Test + void whenFallbackOcspServiceConfigurationKeyUsageNotRequired_thenResponderCertWithoutKeyUsageIsAccepted() throws Exception { + final X509Certificate responder = buildResponderCertificateWithoutKeyUsage(); + final List trustedCAs = List.of(getTestEsteid2018CA()); + assertThatCode(() -> new FallbackOcspServiceConfiguration(OCSP_URL, responder, true, null, getTestEsteid2018CA(), + CertificateValidator.buildTrustAnchorsFromCertificates(trustedCAs), + CertificateValidator.buildCertStoreFromCertificates(trustedCAs), + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, false)) + .doesNotThrowAnyException(); + } + + private static X509Certificate buildResponderCertificateWithoutDigitalSignature() throws Exception { + return buildCertificate( + new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.nonRepudiation).getEncoded()), + new Extension(Extension.extendedKeyUsage, true, + new ExtendedKeyUsage(KeyPurposeId.id_kp_OCSPSigning).getEncoded())); + } + + private static X509Certificate buildResponderCertificateWithoutKeyUsage() throws Exception { + return buildCertificate( + new Extension(Extension.extendedKeyUsage, true, + new ExtendedKeyUsage(KeyPurposeId.id_kp_OCSPSigning).getEncoded())); + } + + private static void newDesignatedOcspServiceConfiguration(X509Certificate responder) throws Exception { + new DesignatedOcspServiceConfiguration( + OCSP_URL, responder, List.of(getTestEsteid2018CA()), true, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true); + } + + private static void newFallbackOcspServiceConfiguration(X509Certificate responder) throws Exception { + final List trustedCAs = List.of(getTestEsteid2018CA()); + final Set trustAnchors = CertificateValidator.buildTrustAnchorsFromCertificates(trustedCAs); + final CertStore certStore = CertificateValidator.buildCertStoreFromCertificates(trustedCAs); + new FallbackOcspServiceConfiguration(OCSP_URL, responder, true, null, null, trustAnchors, certStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true); + } + +} diff --git a/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java b/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java index 57417604..5df321dd 100644 --- a/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java +++ b/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java @@ -12,15 +12,19 @@ import java.net.URI; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; +import java.time.Duration; import java.util.List; import java.util.Set; import static eu.webeid.security.testutil.Certificates.getTestEsteid2015CA; import static eu.webeid.security.testutil.Certificates.getTestEsteid2018CA; -import static eu.webeid.security.testutil.Certificates.getTestSkOcspResponder2020; +import static eu.webeid.security.testutil.Certificates.getTestSelfSignedOcspResponder; public class OcspServiceMaker { + public static final Duration MAX_THIS_UPDATE_AGE = Duration.ofMinutes(3); + public static final Duration MAX_NEXT_UPDATE_AGE = Duration.ofMinutes(20); + private static final String TEST_OCSP_ACCESS_LOCATION = "http://demo.sk.ee/ocsp"; private static final List TRUSTED_CA_CERTIFICATES; private static final X500Name ISSUER_DN = new X500Name("CN=TEST of ESTEID-SK 2015, OID.2.5.4.97=NTREE-10747013, O=AS Sertifitseerimiskeskus, C=EE"); @@ -53,7 +57,9 @@ private static AiaOcspServiceConfiguration getAiaOcspServiceConfiguration() thro return new AiaOcspServiceConfiguration( Set.of(ISSUER_DN), CertificateValidator.buildTrustAnchorsFromCertificates(TRUSTED_CA_CERTIFICATES), - CertificateValidator.buildCertStoreFromCertificates(TRUSTED_CA_CERTIFICATES)); + CertificateValidator.buildCertStoreFromCertificates(TRUSTED_CA_CERTIFICATES), + MAX_THIS_UPDATE_AGE, + MAX_NEXT_UPDATE_AGE, true); } public static DesignatedOcspServiceConfiguration getDesignatedOcspServiceConfiguration() throws CertificateException, IOException, OCSPCertificateException { @@ -67,9 +73,11 @@ private static DesignatedOcspServiceConfiguration getDesignatedOcspServiceConfig private static DesignatedOcspServiceConfiguration getDesignatedOcspServiceConfiguration(boolean doesSupportNonce, String ocspServiceAccessLocation) throws CertificateException, IOException, OCSPCertificateException { return new DesignatedOcspServiceConfiguration( URI.create(ocspServiceAccessLocation), - getTestSkOcspResponder2020(), + getTestSelfSignedOcspResponder(), TRUSTED_CA_CERTIFICATES, - doesSupportNonce); + doesSupportNonce, + MAX_THIS_UPDATE_AGE, + MAX_NEXT_UPDATE_AGE, true); } } diff --git a/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java b/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java index e9be3911..b565a730 100644 --- a/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java +++ b/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java @@ -3,26 +3,40 @@ package eu.webeid.ocsp.service; +import eu.webeid.ocsp.OcspCertificateRevocationChecker; +import eu.webeid.ocsp.exceptions.OCSPCertificateException; +import eu.webeid.security.certificate.CertificateValidator; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.ExtendedKeyUsage; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.bouncycastle.asn1.x509.KeyUsage; import org.bouncycastle.cert.X509CertificateHolder; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; -import eu.webeid.ocsp.exceptions.OCSPCertificateException; -import eu.webeid.security.certificate.CertificateValidator; import eu.webeid.security.testutil.LocalOcspResponder; import java.net.URI; +import java.security.cert.CertStore; +import java.security.cert.TrustAnchor; +import java.security.cert.X509Certificate; +import java.time.Duration; import java.util.Date; import java.util.List; +import java.util.Optional; import java.util.Set; import static eu.webeid.ocsp.service.OcspServiceMaker.getAiaOcspServiceProvider; +import static eu.webeid.ocsp.service.OcspServiceMaker.getDesignatedOcspServiceConfiguration; import static eu.webeid.ocsp.service.OcspServiceMaker.getDesignatedOcspServiceProvider; +import static eu.webeid.security.testutil.Certificates.getDemoEsteidSk2018AiaOcspResponder; import static eu.webeid.security.testutil.Certificates.getJaakKristjanEsteid2018Cert; +import static eu.webeid.security.testutil.Certificates.getTestEsteid2018CA; import static eu.webeid.security.testutil.Certificates.getMariliisEsteid2015Cert; import static eu.webeid.security.testutil.Certificates.getTestEsteid2015CA; -import static eu.webeid.security.testutil.Certificates.getTestEsteid2018CA; -import static eu.webeid.security.testutil.Certificates.getTestSkOcspResponder2020; +import static eu.webeid.security.testutil.Certificates.getTestSelfSignedOcspResponder; +import static eu.webeid.security.testutil.TestCertificateBuilder.buildCertificate; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; @@ -35,8 +49,10 @@ void whenDesignatedOcspServiceConfigurationProvided_thenCreatesDesignatedOcspSer final OcspService service = ocspServiceProvider.getService(getJaakKristjanEsteid2018Cert(), getTestEsteid2018CA()); assertThat(service.getAccessLocation()).isEqualTo(new URI("http://demo.sk.ee/ocsp")); assertThat(service.doesSupportNonce()).isTrue(); + assertThat(service.getMaxThisUpdateAge()).isEqualTo(Duration.ofMinutes(3)); + assertThat(service.getMaxNextUpdateAge()).isEqualTo(Duration.ofMinutes(20)); assertThatCode(() -> - service.validateResponderCertificate(new X509CertificateHolder(getTestSkOcspResponder2020().getEncoded()), getTestEsteid2018CA(), new Date(1630000000000L))) + service.validateResponderCertificate(new X509CertificateHolder(getTestSelfSignedOcspResponder().getEncoded()), getTestEsteid2018CA(), new Date(1630000000000L))) .doesNotThrowAnyException(); assertThatCode(() -> service.validateResponderCertificate(new X509CertificateHolder(getTestEsteid2018CA().getEncoded()), getTestEsteid2018CA(), new Date(1630000000000L))) @@ -54,6 +70,11 @@ void whenAiaOcspServiceConfigurationProvided_thenCreatesAiaOcspService() throws final OcspService service2015 = ocspServiceProvider.getService(getMariliisEsteid2015Cert(), getTestEsteid2015CA()); assertThat(service2015.getAccessLocation()).isEqualTo(new URI("http://aia.demo.sk.ee/esteid2015")); assertThat(service2015.doesSupportNonce()).isFalse(); + assertThat(service2018.getMaxThisUpdateAge()).isEqualTo(Duration.ofMinutes(3)); + assertThat(service2018.getMaxNextUpdateAge()).isEqualTo(Duration.ofMinutes(20)); + assertThatCode(() -> + service2018.validateResponderCertificate(new X509CertificateHolder(getDemoEsteidSk2018AiaOcspResponder().getEncoded()), getTestEsteid2018CA(), new Date(1630000000000L))) + .doesNotThrowAnyException(); } @Test @@ -64,7 +85,7 @@ void whenAiaResponderCertificateLacksOcspSigningUsage_thenThrows() throws Except assertThatExceptionOfType(OCSPCertificateException.class) .isThrownBy(() -> service2018.validateResponderCertificate(wrongResponderCert, getTestEsteid2018CA(), new Date(1630000000000L))) - .withMessageContaining("does not contain the key usage extension for OCSP response signing"); + .withMessageContaining("Key Usage extension does not contain Digital Signature, which is required for OCSP response signing"); } @Test @@ -75,10 +96,12 @@ void whenDifferentIssuersHaveSameName_thenDesignatedServiceAppliesOnlyToConfigur second.start(); final var authorities = List.of(first.issuer(), second.issuer()); final var designated = new DesignatedOcspServiceConfiguration( - first.designatedUri(), first.responderCertificate(), List.of(first.issuer()), true); + first.designatedUri(), first.responderCertificate(), List.of(first.issuer()), true, + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, true); final var aia = new AiaOcspServiceConfiguration(Set.of(), CertificateValidator.buildTrustAnchorsFromCertificates(authorities), - CertificateValidator.buildCertStoreFromCertificates(authorities)); + CertificateValidator.buildCertStoreFromCertificates(authorities), + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, true); final var provider = new OcspServiceProvider(designated, aia); assertThat(first.issuer().getSubjectX500Principal()).isEqualTo(second.issuer().getSubjectX500Principal()); @@ -97,7 +120,8 @@ void whenDifferentIssuersHaveSameName_thenFallbackServiceAppliesOnlyToConfigured final var authorities = List.of(first.issuer(), second.issuer()); final var aia = new AiaOcspServiceConfiguration(Set.of(), CertificateValidator.buildTrustAnchorsFromCertificates(authorities), - CertificateValidator.buildCertStoreFromCertificates(authorities)); + CertificateValidator.buildCertStoreFromCertificates(authorities), + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, true); final var fallback = new FallbackOcspServiceConfiguration( first.designatedUri(), first.responderCertificate(), @@ -105,7 +129,8 @@ void whenDifferentIssuersHaveSameName_thenFallbackServiceAppliesOnlyToConfigured null, first.issuer(), CertificateValidator.buildTrustAnchorsFromCertificates(List.of(first.issuer())), - CertificateValidator.buildCertStoreFromCertificates(List.of(first.issuer()))); + CertificateValidator.buildCertStoreFromCertificates(List.of(first.issuer())), + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, true); final var provider = new OcspServiceProvider(null, aia, List.of(fallback)); assertThat(first.issuer().getSubjectX500Principal()).isEqualTo(second.issuer().getSubjectX500Principal()); @@ -128,7 +153,8 @@ void whenFallbackResponderIsDelegatedByAnotherTrustedCa_thenThrows(boolean sameI null, responder.issuer(), CertificateValidator.buildTrustAnchorsFromCertificates(List.of(responder.issuer(), responder.otherIssuer())), - CertificateValidator.buildCertStoreFromCertificates(List.of(responder.issuer(), responder.otherIssuer()))); + CertificateValidator.buildCertStoreFromCertificates(List.of(responder.issuer(), responder.otherIssuer())), + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, true); final var service = new FallbackOcspService(configuration); final var responderCertificate = new X509CertificateHolder(responder.responderCertificate().getEncoded()); @@ -151,7 +177,8 @@ void whenFallbackServiceIsUsedForDifferentIssuer_thenThrows() throws Exception { null, first.issuer(), CertificateValidator.buildTrustAnchorsFromCertificates(List.of(first.issuer())), - CertificateValidator.buildCertStoreFromCertificates(List.of(first.issuer()))); + CertificateValidator.buildCertStoreFromCertificates(List.of(first.issuer())), + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, true); final var service = new FallbackOcspService(configuration); final var responderCertificate = new X509CertificateHolder(first.responderCertificate().getEncoded()); @@ -162,6 +189,249 @@ void whenFallbackServiceIsUsedForDifferentIssuer_thenThrows() throws Exception { } } + @Test + void whenAiaOcspResponderCertIsCA_thenThrows() throws Exception { + final OcspServiceProvider ocspServiceProvider = getAiaOcspServiceProvider(); + final OcspService service2018 = ocspServiceProvider.getService(getJaakKristjanEsteid2018Cert(), getTestEsteid2018CA()); + final X509Certificate caResponder = buildCertificate( + new Extension(Extension.basicConstraints, true, new BasicConstraints(0).getEncoded())); + final X509CertificateHolder caResponderCert = new X509CertificateHolder(caResponder.getEncoded()); + + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> + service2018.validateResponderCertificate(caResponderCert, getTestEsteid2018CA(), new Date())) + .withMessage("Certificate " + caResponder.getSubjectX500Principal() + + " must not be a CA certificate (Basic Constraints CA:TRUE is not allowed for OCSP responder)"); + } + + @Test + void whenAiaOcspResponderCertMissingKeyUsageDigitalSignature_thenThrows() throws Exception { + final OcspServiceProvider ocspServiceProvider = getAiaOcspServiceProvider(); + final OcspService service2018 = ocspServiceProvider.getService(getJaakKristjanEsteid2018Cert(), getTestEsteid2018CA()); + final X509Certificate responderWithoutDigitalSignature = buildCertificate( + new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.nonRepudiation).getEncoded())); + final X509CertificateHolder responderCert = new X509CertificateHolder(responderWithoutDigitalSignature.getEncoded()); + + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> + service2018.validateResponderCertificate(responderCert, getTestEsteid2018CA(), new Date())) + .withMessage("Certificate " + responderWithoutDigitalSignature.getSubjectX500Principal() + + " Key Usage extension does not contain Digital Signature, which is required for OCSP response signing"); + } + + @Test + void whenAiaOcspResponderCertMissingOcspSigningEku_thenThrows() throws Exception { + final OcspServiceProvider ocspServiceProvider = getAiaOcspServiceProvider(); + final OcspService service2018 = ocspServiceProvider.getService(getJaakKristjanEsteid2018Cert(), getTestEsteid2018CA()); + final X509Certificate responderWithoutOcspSigning = buildCertificate( + new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature).getEncoded()), + new Extension(Extension.extendedKeyUsage, true, + new ExtendedKeyUsage(KeyPurposeId.id_kp_clientAuth).getEncoded())); + final X509CertificateHolder responderCert = new X509CertificateHolder(responderWithoutOcspSigning.getEncoded()); + + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> + service2018.validateResponderCertificate(responderCert, getTestEsteid2018CA(), new Date())) + .withMessage("Certificate " + responderWithoutOcspSigning.getSubjectX500Principal() + + " Extended Key Usage extension does not contain OCSP Signing, which is required for OCSP response signing"); + } + + @Test + void whenFallbackOcspResponderCertIsCA_thenThrows() throws Exception { + final FallbackOcspService service = newFallbackOcspServiceWithoutPinnedResponder(); + final X509Certificate caResponder = buildCertificate( + new Extension(Extension.basicConstraints, true, new BasicConstraints(0).getEncoded())); + final X509CertificateHolder caResponderCert = new X509CertificateHolder(caResponder.getEncoded()); + + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> + service.validateResponderCertificate(caResponderCert, getTestEsteid2018CA(), new Date())) + .withMessage("Certificate " + caResponder.getSubjectX500Principal() + + " must not be a CA certificate (Basic Constraints CA:TRUE is not allowed for OCSP responder)"); + } + + @Test + void whenFallbackOcspResponderCertMissingKeyUsageDigitalSignature_thenThrows() throws Exception { + final FallbackOcspService service = newFallbackOcspServiceWithoutPinnedResponder(); + final X509Certificate responderWithoutDigitalSignature = buildCertificate( + new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.nonRepudiation).getEncoded())); + final X509CertificateHolder responderCert = new X509CertificateHolder(responderWithoutDigitalSignature.getEncoded()); + + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> + service.validateResponderCertificate(responderCert, getTestEsteid2018CA(), new Date())) + .withMessage("Certificate " + responderWithoutDigitalSignature.getSubjectX500Principal() + + " Key Usage extension does not contain Digital Signature, which is required for OCSP response signing"); + } + + @Test + void whenFallbackOcspResponderCertMissingOcspSigningEku_thenThrows() throws Exception { + final FallbackOcspService service = newFallbackOcspServiceWithoutPinnedResponder(); + final X509Certificate responderWithoutOcspSigning = buildCertificate( + new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature).getEncoded()), + new Extension(Extension.extendedKeyUsage, true, + new ExtendedKeyUsage(KeyPurposeId.id_kp_clientAuth).getEncoded())); + final X509CertificateHolder responderCert = new X509CertificateHolder(responderWithoutOcspSigning.getEncoded()); + + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> + service.validateResponderCertificate(responderCert, getTestEsteid2018CA(), new Date())) + .withMessage("Certificate " + responderWithoutOcspSigning.getSubjectX500Principal() + + " Extended Key Usage extension does not contain OCSP Signing, which is required for OCSP response signing"); + } + + private static FallbackOcspService newFallbackOcspServiceWithoutPinnedResponder() throws Exception { + final List trustedCAs = List.of(getTestEsteid2018CA()); + final Set trustAnchors = CertificateValidator.buildTrustAnchorsFromCertificates(trustedCAs); + final CertStore certStore = CertificateValidator.buildCertStoreFromCertificates(trustedCAs); + final FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( + URI.create("http://fallback.demo.sk.ee/ocsp"), null, true, null, getTestEsteid2018CA(), trustAnchors, certStore, + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, + OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, true); + return new FallbackOcspService(configuration); + } + + @Test + void whenFallbackOcspServiceConfigurationProvided_thenAiaServiceCarriesMatchingFallback() throws Exception { + X509Certificate userCert = getJaakKristjanEsteid2018Cert(); + List trustedCertificates = List.of(getTestEsteid2018CA(), getTestEsteid2015CA()); + Set trustedAnchors = + CertificateValidator.buildTrustAnchorsFromCertificates(trustedCertificates); + java.security.cert.CertStore trustedStore = + CertificateValidator.buildCertStoreFromCertificates(trustedCertificates); + URI fallbackUri = URI.create("http://fallback.test/ocsp"); + FallbackOcspServiceConfiguration fallbackConfiguration = new FallbackOcspServiceConfiguration( + fallbackUri, getDemoEsteidSk2018AiaOcspResponder(), true, + null, getTestEsteid2018CA(), trustedAnchors, trustedStore, + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, + OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, true); + + OcspServiceProvider provider = new OcspServiceProvider(null, getAiaOcspServiceProvider2018Configuration(), + List.of(fallbackConfiguration)); + OcspService service = provider.getService(userCert, getTestEsteid2018CA()); + + assertThat(service).isInstanceOf(AiaOcspService.class); + Optional fallbackOpt = service.getFallbackService(); + assertThat(fallbackOpt).isPresent(); + FallbackOcspService fallback = fallbackOpt.get(); + assertThat(fallback.getAccessLocation()).isEqualTo(fallbackUri); + assertThat(fallback.doesSupportNonce()).isTrue(); + Date validationDate = new Date(1630000000000L); + assertThatCode(() -> + fallback.validateResponderCertificate(new X509CertificateHolder(getDemoEsteidSk2018AiaOcspResponder().getEncoded()), getTestEsteid2018CA(), validationDate)) + .doesNotThrowAnyException(); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> + fallback.validateResponderCertificate(new X509CertificateHolder(getTestEsteid2018CA().getEncoded()), getTestEsteid2018CA(), validationDate)) + .withMessage("Responder certificate from the OCSP response is not equal to the configured fallback OCSP responder certificate"); + } + + @Test + void whenFallbackOcspServiceConfigurationDoesNotMatchIssuer_thenAiaServiceCarriesNoFallback() throws Exception { + X509Certificate userCert = getJaakKristjanEsteid2018Cert(); + List trustedCertificates = List.of(getTestEsteid2018CA(), getTestEsteid2015CA()); + Set trustedAnchors = + CertificateValidator.buildTrustAnchorsFromCertificates(trustedCertificates); + java.security.cert.CertStore trustedStore = + CertificateValidator.buildCertStoreFromCertificates(trustedCertificates); + FallbackOcspServiceConfiguration fallbackConfiguration = new FallbackOcspServiceConfiguration( + URI.create("http://fallback.test/ocsp"), null, true, + null, getTestEsteid2015CA(), trustedAnchors, trustedStore, + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, + OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, true); + + OcspServiceProvider provider = new OcspServiceProvider(null, getAiaOcspServiceProvider2018Configuration(), + List.of(fallbackConfiguration)); + OcspService service = provider.getService(userCert, getTestEsteid2018CA()); + + assertThat(service.getFallbackService()).isEmpty(); + } + + @Test + void whenFallbackConfigurationsIsNull_thenServiceHasNoFallback() throws Exception { + X509Certificate userCert = getJaakKristjanEsteid2018Cert(); + OcspServiceProvider provider = new OcspServiceProvider(null, getAiaOcspServiceProvider2018Configuration(), null); + + OcspService service = provider.getService(userCert, getTestEsteid2018CA()); + + assertThat(service).isInstanceOf(AiaOcspService.class); + assertThat(service.getAccessLocation()).isEqualTo(new URI("http://aia.demo.sk.ee/esteid2018")); + assertThat(service.getFallbackService()).isEmpty(); + } + + @Test + void whenFallbackConfigurationsIsEmpty_thenServiceHasNoFallback() throws Exception { + X509Certificate userCert = getJaakKristjanEsteid2018Cert(); + OcspServiceProvider provider = new OcspServiceProvider(null, getAiaOcspServiceProvider2018Configuration(), List.of()); + + OcspService service = provider.getService(userCert, getTestEsteid2018CA()); + + assertThat(service).isInstanceOf(AiaOcspService.class); + assertThat(service.getFallbackService()).isEmpty(); + } + + @Test + void whenDesignatedServiceSupportsIssuer_thenDesignatedTakesPrecedenceOverFallback() throws Exception { + X509Certificate userCert = getJaakKristjanEsteid2018Cert(); + List trustedCertificates = List.of(getTestEsteid2018CA(), getTestEsteid2015CA()); + Set trustedAnchors = + CertificateValidator.buildTrustAnchorsFromCertificates(trustedCertificates); + java.security.cert.CertStore trustedStore = + CertificateValidator.buildCertStoreFromCertificates(trustedCertificates); + FallbackOcspServiceConfiguration fallbackConfiguration = new FallbackOcspServiceConfiguration( + URI.create("http://fallback.test/ocsp"), getDemoEsteidSk2018AiaOcspResponder(), true, + null, getTestEsteid2018CA(), trustedAnchors, trustedStore, + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, + OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, true); + + OcspServiceProvider provider = new OcspServiceProvider(getDesignatedOcspServiceConfiguration(), + getAiaOcspServiceProvider2018Configuration(), List.of(fallbackConfiguration)); + OcspService service = provider.getService(userCert, getTestEsteid2018CA()); + + assertThat(service).isInstanceOf(DesignatedOcspService.class); + assertThat(service.getAccessLocation()).isEqualTo(new URI("http://demo.sk.ee/ocsp")); + assertThat(service.getFallbackService()).isEmpty(); + } + + @Test + void whenDuplicateIssuerFallbackConfigurations_thenLastOneWins() throws Exception { + X509Certificate userCert = getJaakKristjanEsteid2018Cert(); + List trustedCertificates = List.of(getTestEsteid2018CA(), getTestEsteid2015CA()); + Set trustedAnchors = + CertificateValidator.buildTrustAnchorsFromCertificates(trustedCertificates); + java.security.cert.CertStore trustedStore = + CertificateValidator.buildCertStoreFromCertificates(trustedCertificates); + URI firstFallbackUri = URI.create("http://fallback-first.test/ocsp"); + URI lastFallbackUri = URI.create("http://fallback-last.test/ocsp"); + FallbackOcspServiceConfiguration firstConfiguration = new FallbackOcspServiceConfiguration( + firstFallbackUri, getDemoEsteidSk2018AiaOcspResponder(), true, + null, getTestEsteid2018CA(), trustedAnchors, trustedStore, + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, + OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, true); + FallbackOcspServiceConfiguration lastConfiguration = new FallbackOcspServiceConfiguration( + lastFallbackUri, getDemoEsteidSk2018AiaOcspResponder(), true, + null, getTestEsteid2018CA(), trustedAnchors, trustedStore, + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, + OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, true); + + OcspServiceProvider provider = new OcspServiceProvider(null, getAiaOcspServiceProvider2018Configuration(), + List.of(firstConfiguration, lastConfiguration)); + OcspService service = provider.getService(userCert, getTestEsteid2018CA()); + + Optional fallbackOpt = service.getFallbackService(); + assertThat(fallbackOpt).isPresent(); + assertThat(fallbackOpt.get().getAccessLocation()).isEqualTo(lastFallbackUri); + } + + private static AiaOcspServiceConfiguration getAiaOcspServiceProvider2018Configuration() throws Exception { + List trustedCertificates = List.of(getTestEsteid2018CA(), getTestEsteid2015CA()); + return new AiaOcspServiceConfiguration( + Set.of(), + CertificateValidator.buildTrustAnchorsFromCertificates(trustedCertificates), + CertificateValidator.buildCertStoreFromCertificates(trustedCertificates), + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, + OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, true); + } } // Old disabled example AuthTokenValidator test with designated OCSP check. diff --git a/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java b/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java index e6fe74d1..7743e32e 100644 --- a/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java +++ b/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java @@ -6,16 +6,22 @@ import eu.webeid.ocsp.OcspCertificateRevocationChecker; import eu.webeid.ocsp.client.OcspClient; import eu.webeid.ocsp.exceptions.OCSPClientException; +import eu.webeid.ocsp.exceptions.UserCertificateOCSPException; +import eu.webeid.ocsp.service.FallbackOcspService; import eu.webeid.ocsp.service.OcspService; +import eu.webeid.security.util.DateAndTime; import eu.webeid.ocsp.service.OcspServiceProvider; +import eu.webeid.resilientocsp.ResilientOcspCertificateRevocationChecker.CircuitBreakerStatistics; 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.util.DateAndTime; import eu.webeid.security.validator.AuthTokenValidator; import eu.webeid.security.validator.revocationcheck.RevocationInfo; +import io.github.resilience4j.circuitbreaker.CircuitBreaker; import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig; import io.github.resilience4j.retry.RetryConfig; +import org.bouncycastle.asn1.ocsp.OCSPResponseStatus; import org.bouncycastle.cert.ocsp.BasicOCSPResp; import org.bouncycastle.cert.ocsp.CertificateStatus; import org.bouncycastle.cert.ocsp.OCSPResp; @@ -23,50 +29,75 @@ import org.bouncycastle.cert.ocsp.SingleResp; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; import java.net.URI; +import java.security.cert.CertificateEncodingException; import java.security.cert.X509Certificate; import java.time.Duration; +import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Optional; -import static eu.webeid.ocsp.OcspCertificateRevocationCheckerTest.getOcspResponseBytesFromResources; import static eu.webeid.security.testutil.AbstractTestWithValidator.VALID_AUTH_TOKEN; import static eu.webeid.security.testutil.AbstractTestWithValidator.VALID_CHALLENGE_NONCE; import static eu.webeid.security.testutil.AuthTokenValidators.getDefaultAuthTokenValidatorBuilder; import static eu.webeid.security.testutil.Certificates.getJaakKristjanEsteid2018Cert; import static eu.webeid.security.testutil.Certificates.getTestEsteid2018CA; +import static eu.webeid.security.testutil.DateMocker.mockDate; 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 eu.webeid.security.testutil.ResourceUtil.bytesFromResource; +import static org.awaitility.Awaitility.await; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -public class ResilientOcspCertificateRevocationCheckerTest { +class ResilientOcspCertificateRevocationCheckerTest { private static final URI PRIMARY_URI = URI.create("http://primary.ocsp.test"); private static final URI FALLBACK_URI = URI.create("http://fallback.ocsp.test"); private static final URI SECOND_FALLBACK_URI = URI.create("http://second-fallback.ocsp.test"); private static final Duration LONG_THIS_UPDATE_AGE = Duration.ofDays(365 * 10); + private static final Duration LONG_NEXT_UPDATE_AGE = Duration.ofDays(365 * 10); + + // The OCSP DER fixtures do not share one thisUpdate. Each fixture carries its own: + // ocsp_response.der 2021-09-17T18:25:24 + // ocsp_response_revoked.der 2021-09-18T00:13:43 + // ocsp_response_unknown.der 2021-09-18T00:16:25 + // The two constants below belong to ocsp_response.der, and every test that uses them pairs them with + // that fixture. Do not pair them with the other two fixtures: a clock set from these values is earlier + // than their thisUpdate by more than the allowed time skew, so the library rejects the response as + // issued too far in the future and the test fails. The unknown-status tests use + // WITHIN_RESPONDER_CERT_VALIDITY instead, and the revoked-status tests do not mock the clock at all. + private static final String DER_THIS_UPDATE = "2021-09-17T18:25:24"; + private static final String FIVE_MIN_AFTER_THIS_UPDATE = "2021-09-17T18:30:24"; + // Used by the unknown-status tests, where the age limit is the relaxed LONG_THIS_UPDATE_AGE, so only the + // OCSP responder certificate validity window matters (this value sits within it). + private static final String WITHIN_RESPONDER_CERT_VALIDITY = "2021-09-18T00:16:25"; private X509Certificate estEid2018Cert; private X509Certificate testEsteid2018CA; private OCSPResp ocspRespGood; private OCSPResp ocspRespRevoked; + private OCSPResp ocspRespUnknown; @BeforeEach void setUp() throws Exception { estEid2018Cert = getJaakKristjanEsteid2018Cert(); testEsteid2018CA = getTestEsteid2018CA(); - ocspRespGood = new OCSPResp(getOcspResponseBytesFromResources("ocsp_response.der")); - ocspRespRevoked = new OCSPResp(getOcspResponseBytesFromResources("ocsp_response_revoked.der")); + ocspRespGood = new OCSPResp(bytesFromResource("ocsp_response.der")); + ocspRespRevoked = new OCSPResp(bytesFromResource("ocsp_response_revoked.der")); + ocspRespUnknown = new OCSPResp(bytesFromResource("ocsp_response_unknown.der")); } @Test @@ -81,29 +112,31 @@ void whenMultipleValidationCalls_thenPreviousResultsAreNotModified() throws Exce 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); + ResilientOcspCertificateRevocationChecker resilientChecker = checkerBuilder(ocspClient).build(); AuthTokenValidator validator = getDefaultAuthTokenValidatorBuilder() .withCertificateRevocationChecker(resilientChecker) .build(); WebEidAuthToken authToken = validator.parse(VALID_AUTH_TOKEN); - ResilientUserCertificateOCSPCheckFailedException ex1 = assertThrows(ResilientUserCertificateOCSPCheckFailedException.class, - () -> validator.validate(authToken, VALID_CHALLENGE_NONCE)); + ResilientUserCertificateOCSPCheckFailedException ex1 = assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> validator.validate(authToken, VALID_CHALLENGE_NONCE)) + .actual(); List revocationInfo1 = ex1.getValidationInfo().revocationInfoList(); assertThat(revocationInfo1).hasSize(3); assertThat(revocationInfo1) - .extracting(ri -> ((OCSPClientException) ri.ocspResponseAttributes().get("OCSP_ERROR")).getMessage()) + .extracting(ri -> ((OCSPClientException) ri.ocspResponseAttributes().get(RevocationInfo.KEY_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)); + ResilientUserCertificateOCSPCheckFailedException ex2 = assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> validator.validate(authToken, VALID_CHALLENGE_NONCE)) + .actual(); List revocationInfo2 = ex2.getValidationInfo().revocationInfoList(); assertThat(revocationInfo2).hasSize(3); assertThat(revocationInfo2) - .extracting(ri -> ((OCSPClientException) ri.ocspResponseAttributes().get("OCSP_ERROR")).getMessage()) + .extracting(ri -> ((OCSPClientException) ri.ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR)).getMessage()) .containsExactly( "Primary OCSP service unavailable (call2)", "Fallback OCSP service unavailable (call2)", @@ -111,7 +144,7 @@ void whenMultipleValidationCalls_thenPreviousResultsAreNotModified() throws Exce ); assertThat(revocationInfo1).hasSize(3); assertThat(revocationInfo1) - .extracting(ri -> ((OCSPClientException) ri.ocspResponseAttributes().get("OCSP_ERROR")).getMessage()) + .extracting(ri -> ((OCSPClientException) ri.ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR)).getMessage()) .containsExactly( "Primary OCSP service unavailable (call1)", "Fallback OCSP service unavailable (call1)", @@ -120,245 +153,953 @@ void whenMultipleValidationCalls_thenPreviousResultsAreNotModified() throws Exce } @Test - void whenFirstFallbackReturnsRevoked_thenRevocationPropagatesWithoutSecondFallback() throws Exception { + void whenMaxAttemptsIsTwoAndAllCallsFail_thenRevocationInfoListRecordsRetriedPrimaryThenBothFallbacks() throws Exception { + // The Retry decorator wraps only the primary supplier, so maxAttempts(2) records two primary attempts + // before the two fallbacks. Asserting the responder order (primary, primary, fallback, second fallback) + // and the two distinct primary error messages proves the fourth element comes from the retried primary, + // not just that the list happens to have four elements. OcspClient ocspClient = mock(OcspClient.class); when(ocspClient.request(eq(PRIMARY_URI), any())) - .thenThrow(new OCSPClientException("Primary OCSP service unavailable")); + .thenThrow(new OCSPClientException("primary attempt 1")) + .thenThrow(new OCSPClientException("primary attempt 2")); when(ocspClient.request(eq(FALLBACK_URI), any())) - .thenReturn(ocspRespRevoked); + .thenThrow(new OCSPClientException()); when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())) + .thenThrow(new OCSPClientException()); + + RetryConfig retryConfig = RetryConfig.custom() + .maxAttempts(2) + .build(); + + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withRetryConfig(retryConfig).build(); + assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> { + List revocationInfoList = ex.getValidationInfo().revocationInfoList(); + assertThat(revocationInfoList).hasSize(4); + assertThat(revocationInfoList).extracting(RevocationInfo::ocspResponderUri) + .containsExactly(PRIMARY_URI, PRIMARY_URI, FALLBACK_URI, SECOND_FALLBACK_URI); + assertThat(((OCSPClientException) revocationInfoList.get(0).ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR)).getMessage()) + .isEqualTo("primary attempt 1"); + assertThat(((OCSPClientException) revocationInfoList.get(1).ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR)).getMessage()) + .isEqualTo("primary attempt 2"); + }); + } + + @Test + void whenMaxAttemptsIsTwoAndFirstCallFails_thenTwoCallsToPrimaryShouldBeRecorded() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())) + .thenThrow(new OCSPClientException("Primary OCSP service unavailable (call1)")) .thenReturn(ocspRespGood); - ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, null); + RetryConfig retryConfig = RetryConfig.custom() + .maxAttempts(2) + .build(); + + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withRetryConfig(retryConfig).build(); + List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + assertThat(revocationInfoList.size()).isEqualTo(2); + + Map firstResponseAttributes = revocationInfoList.get(0).ocspResponseAttributes(); + OCSPClientException ex1 = (OCSPClientException) firstResponseAttributes.get(RevocationInfo.KEY_OCSP_ERROR); + assertThat(ex1.getMessage()).isEqualTo("Primary OCSP service unavailable (call1)"); + + assertThat(getCertificateStatus(revocationInfoList.get(1))).isEqualTo(CertificateStatus.GOOD); + } + @Test + void whenPrimaryReturnsRevoked_thenRevocationInfoListShouldHaveOneElementAndItShouldHaveRevokedStatus() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())) + .thenReturn(ocspRespRevoked); + + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build(); assertThatExceptionOfType(ResilientUserCertificateRevokedException.class) .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) - .withMessage("User certificate has been revoked"); - - verify(ocspClient, never()).request(eq(SECOND_FALLBACK_URI), any()); + .satisfies(ex -> { + List revocationInfoList = ex.getValidationInfo().revocationInfoList(); + assertThat(revocationInfoList.size()).isEqualTo(1); + assertThat(getCertificateStatus(revocationInfoList.get(0))).isInstanceOf(RevokedStatus.class); + }); } @Test - void whenMaxAttemptsIsOneAndAllCallsFail_thenRevocationInfoListShouldHaveThreeElements() throws Exception { + void whenCallerConfigRecordsAllExceptions_thenRevokedVerdictDoesNotOpenCircuitBreaker() throws Exception { + // A revoked verdict is a definitive OCSP answer, so it must never count as a circuit breaker failure. + // CircuitBreakerConfig.from() copies both the record predicate and the recordExceptions array of the + // caller configuration, and the array is combined with our own predicate by OR. A caller that records + // every exception must therefore not be able to make a revoked verdict open the circuit breaker. OcspClient ocspClient = mock(OcspClient.class); when(ocspClient.request(eq(PRIMARY_URI), any())) - .thenThrow(new OCSPClientException()); + .thenReturn(ocspRespRevoked); when(ocspClient.request(eq(FALLBACK_URI), any())) - .thenThrow(new OCSPClientException()); - when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())) - .thenThrow(new OCSPClientException()); + .thenReturn(ocspRespGood); + CircuitBreakerConfig callerConfig = CircuitBreakerConfig.custom() + .recordExceptions(Throwable.class) + .slidingWindowSize(2) + .minimumNumberOfCalls(2) + .failureRateThreshold(50) + .permittedNumberOfCallsInHalfOpenState(1) + .build(); - RetryConfig retryConfig = RetryConfig.custom() - .maxAttempts(1) + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient) + .withFallbacks(FALLBACK_URI) + .withCircuitBreakerConfig(callerConfig) .build(); - ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, retryConfig); - ResilientUserCertificateOCSPCheckFailedException ex = assertThrows(ResilientUserCertificateOCSPCheckFailedException.class, () -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); - assertThat(ex.getValidationInfo().revocationInfoList().size()).isEqualTo(3); + // The configuration above would open the circuit breaker after two recorded failures. All three calls + // must still report revocation from the primary service, and no call must reach the fallback service. + for (int i = 0; i < 3; i++) { + assertThatExceptionOfType(ResilientUserCertificateRevokedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); + } + verify(ocspClient, times(3)).request(eq(PRIMARY_URI), any()); + verify(ocspClient, never()).request(eq(FALLBACK_URI), any()); } @Test - void whenMaxAttemptsIsTwoAndAllCallsFail_thenRevocationInfoListShouldHaveFourElements() throws Exception { + void whenOneFallbackIsConfiguredAndPrimaryAndFallbackFail_thenRevocationInfoListShouldHaveTwoElements() throws Exception { OcspClient ocspClient = mock(OcspClient.class); when(ocspClient.request(eq(PRIMARY_URI), any())) .thenThrow(new OCSPClientException()); when(ocspClient.request(eq(FALLBACK_URI), any())) .thenThrow(new OCSPClientException()); - when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())) + + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withFallbacks(FALLBACK_URI).build(); + + assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> { + List revocationInfoList = ex.getValidationInfo().revocationInfoList(); + assertThat(revocationInfoList.size()).isEqualTo(2); + }); + } + + @Test + void whenNoFallbacksAreConfigured_thenRevocationInfoListShouldHaveOneElement() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())) .thenThrow(new OCSPClientException()); - RetryConfig retryConfig = RetryConfig.custom() - .maxAttempts(2) - .build(); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withoutFallbacks().build(); - ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, retryConfig); - ResilientUserCertificateOCSPCheckFailedException ex = assertThrows(ResilientUserCertificateOCSPCheckFailedException.class, () -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); - assertThat(ex.getValidationInfo().revocationInfoList().size()).isEqualTo(4); + assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> { + List revocationInfoList = ex.getValidationInfo().revocationInfoList(); + assertThat(revocationInfoList.size()).isEqualTo(1); + }); } @Test - void whenMaxAttemptsIsTwoAndFirstCallFails_thenTwoCallsToPrimaryShouldBeRecorded() throws Exception { + void whenPrimaryReturnsUnauthorizedOcspResponseStatus_thenWrapsResponseStatusError() throws Exception { + OCSPResp ocspRespStatusUnauthorized = new OCSPResp(bytesFromResource("ocsp_response_unauthorized.der")); + 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); + .thenReturn(ocspRespStatusUnauthorized); - RetryConfig retryConfig = RetryConfig.custom() - .maxAttempts(2) - .build(); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build(); + assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> { + Map responseAttributes = ex.getValidationInfo().revocationInfoList().get(0).ocspResponseAttributes(); + ResilientUserCertificateOCSPCheckFailedException firstException = (ResilientUserCertificateOCSPCheckFailedException) responseAttributes.get(RevocationInfo.KEY_OCSP_ERROR); + assertThat(firstException.getMessage()).isEqualTo("Response status: unauthorized"); + }); + } + + @Test + void whenCertificateIdComputationFails_thenThrows() throws Exception { + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(mock(OcspClient.class)).build(); + X509Certificate badIssuer = mock(X509Certificate.class); + CertificateEncodingException encodingException = new CertificateEncodingException("bad issuer"); + when(badIssuer.getEncoded()).thenThrow(encodingException); + + assertThatExceptionOfType(UserCertificateOCSPException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, badIssuer)) + .isExactlyInstanceOf(UserCertificateOCSPException.class) + .withMessage("Unable to compute certificateId for subject certificate") + .withCause(encodingException); + } + + @Test + void whenNoFallbackConfiguredAndPrimarySucceeds_thenPrimaryResponseIsReturned() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespGood); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withoutFallbacks().build(); - ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, retryConfig); 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)"); + assertThat(revocationInfoList).hasSize(1); + assertThat(revocationInfoList.get(0).ocspResponderUri()).isEqualTo(PRIMARY_URI); + assertThat(getCertificateStatus(revocationInfoList.get(0))).isEqualTo(CertificateStatus.GOOD); + } - Map secondResponseAttributes = revocationInfoList.get(1).ocspResponseAttributes(); - OCSPResp ocspResp = (OCSPResp) secondResponseAttributes.get("OCSP_RESPONSE"); - final BasicOCSPResp basicResponse = (BasicOCSPResp) ocspResp.getResponseObject(); - final SingleResp certStatusResponse = basicResponse.getResponses()[0]; - assertThat(certStatusResponse.getCertStatus()).isEqualTo(org.bouncycastle.cert.ocsp.CertificateStatus.GOOD); + @Test + void whenPrimaryReturnsRevoked_thenNotRetried() throws Exception { + // A revoked verdict is a definitive answer; the Retry config ignores + // ResilientUserCertificateRevokedException, so the primary is queried exactly once even with maxAttempts(2). + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespRevoked); + RetryConfig retryConfig = RetryConfig.custom().maxAttempts(2).waitDuration(Duration.ZERO).build(); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withRetryConfig(retryConfig).build(); + + assertThatExceptionOfType(ResilientUserCertificateRevokedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); + verify(ocspClient, times(1)).request(eq(PRIMARY_URI), any()); + verify(ocspClient, never()).request(eq(FALLBACK_URI), any()); } @Test - void whenFirstCallSucceeds_thenRevocationInfoListShouldHaveOneElementAndItShouldHaveGoodStatus() throws Exception { + void whenPrimaryReturnsRevoked_thenCircuitBreakerDoesNotOpen() throws Exception { + // The CircuitBreaker config ignores ResilientUserCertificateRevokedException, so repeated revoked + // verdicts are not counted as failures and the breaker stays closed. With a config that would trip + // after two real failures, the primary is still queried on the third call. + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespRevoked); + CircuitBreakerConfig tightCircuitBreakerConfig = CircuitBreakerConfig.custom() + .slidingWindowSize(2) + .minimumNumberOfCalls(2) + .failureRateThreshold(50) + .permittedNumberOfCallsInHalfOpenState(1) + .build(); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient) + .withFallbacks(FALLBACK_URI) + .withCircuitBreakerConfig(tightCircuitBreakerConfig) + .build(); + + assertThatExceptionOfType(ResilientUserCertificateRevokedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); + assertThatExceptionOfType(ResilientUserCertificateRevokedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); + assertThatExceptionOfType(ResilientUserCertificateRevokedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); + + verify(ocspClient, times(3)).request(eq(PRIMARY_URI), any()); + verify(ocspClient, never()).request(eq(FALLBACK_URI), any()); + } + + @Test + void whenPrimaryFailsAndFirstFallbackReturnsRevoked_thenListContainsBothEntries() 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); + .thenThrow(new OCSPClientException("Primary OCSP service unavailable")); + when(ocspClient.request(eq(FALLBACK_URI), any())).thenReturn(ocspRespRevoked); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build(); - ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, null); + assertThatExceptionOfType(ResilientUserCertificateRevokedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .withMessage("User certificate has been revoked") + .satisfies(ex -> { + List revocationInfoList = ex.getValidationInfo().revocationInfoList(); + assertThat(revocationInfoList).hasSize(2); + assertThat(revocationInfoList).extracting(RevocationInfo::ocspResponderUri) + .containsExactly(PRIMARY_URI, FALLBACK_URI); + assertThat(revocationInfoList.get(0).ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR)) + .isInstanceOf(OCSPClientException.class); + assertThat(getCertificateStatus(revocationInfoList.get(1))).isInstanceOf(RevokedStatus.class); + }); + verify(ocspClient, never()).request(eq(SECOND_FALLBACK_URI), any()); + } - List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); - assertThat(revocationInfoList.size()).isEqualTo(1); - Map responseAttributes = revocationInfoList.get(0).ocspResponseAttributes(); - OCSPResp ocspResp = (OCSPResp) responseAttributes.get("OCSP_RESPONSE"); - CertificateStatus status = getCertificateStatus(ocspResp); - assertThat(status).isEqualTo(org.bouncycastle.cert.ocsp.CertificateStatus.GOOD); + @Test + void whenPrimaryReturnsMissingBasicOcspResponse_thenThrows() throws Exception { + OCSPResp response = mock(OCSPResp.class); + when(response.getStatus()).thenReturn(OCSPResponseStatus.SUCCESSFUL); + when(response.getResponseObject()).thenReturn(null); + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(response); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build(); + + assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> { + Map primaryAttributes = ex.getValidationInfo().revocationInfoList().get(0).ocspResponseAttributes(); + ResilientUserCertificateOCSPCheckFailedException primaryError = + (ResilientUserCertificateOCSPCheckFailedException) primaryAttributes.get(RevocationInfo.KEY_OCSP_ERROR); + assertThat(primaryError.getMessage()).isEqualTo("Missing or unsupported Basic OCSP Response"); + }); + } + + @Test + void whenPrimaryReturnsUnknown_thenFallbackHandlesTheCall() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespUnknown); + when(ocspClient.request(eq(FALLBACK_URI), any())).thenReturn(ocspRespGood); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build(); + + try (var mockedClock = mockStaticClockAt(WITHIN_RESPONDER_CERT_VALIDITY)) { + List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + + assertThat(revocationInfoList).hasSize(2); + verify(ocspClient).request(eq(FALLBACK_URI), any()); + } + } + + @Test + void whenNonceEnabledAndResponseNonceDiffers_thenThrows() throws Exception { + // primaryService advertises nonce support; ocspRespGood was signed with a different nonce. + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespGood); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient) + .withoutFallbacks() + .withPrimaryNonceSupport() + .build(); + + try (var ignored = mockStaticClockAt(DER_THIS_UPDATE)) { + assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> { + Throwable originalError = (Throwable) ex.getValidationInfo().revocationInfoList().get(0) + .ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR); + assertThat(originalError).hasMessageContaining("OCSP request and response nonces differ"); + }); + } } @Test - void whenFirstCallResultsInRevoked_thenRevocationInfoListShouldHaveOneElementAndItShouldHaveRevokedStatus() throws Exception { + void whenCircuitBreakerIsOpenAndRecoveryTimeElapses_thenPrimaryIsTriedAgainInHalfOpenState() 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())) + .thenThrow(new OCSPClientException("Primary OCSP service unavailable")) + .thenThrow(new OCSPClientException("Primary OCSP service unavailable")) .thenReturn(ocspRespGood); + when(ocspClient.request(eq(FALLBACK_URI), any())).thenReturn(ocspRespGood); + CircuitBreakerConfig recoverableCircuitBreakerConfig = CircuitBreakerConfig.custom() + .slidingWindowSize(2) + .minimumNumberOfCalls(2) + .failureRateThreshold(50) + .waitDurationInOpenState(Duration.ofSeconds(1)) + .permittedNumberOfCallsInHalfOpenState(1) + .build(); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient) + .withFallbacks(FALLBACK_URI) + .withCircuitBreakerConfig(recoverableCircuitBreakerConfig) + .build(); + + checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + + List openStateRevocationInfoList = + checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + + assertThat(openStateRevocationInfoList).hasSize(1); + assertThat(openStateRevocationInfoList.get(0).ocspResponderUri()).isEqualTo(FALLBACK_URI); + verify(ocspClient, times(2)).request(eq(PRIMARY_URI), any()); - ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, null); - 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); + await().atMost(Duration.ofSeconds(5)) + .pollInterval(Duration.ofMillis(50)) + .untilAsserted(() -> { + List halfOpenRevocationInfoList = + checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + assertThat(halfOpenRevocationInfoList).hasSize(1); + assertThat(halfOpenRevocationInfoList.get(0).ocspResponderUri()).isEqualTo(PRIMARY_URI); + }); + verify(ocspClient, times(3)).request(eq(PRIMARY_URI), any()); } @Test - void whenOneFallbackIsConfiguredAndPrimaryFails_thenRevocationInfoListShouldHaveTwoElements() throws Exception { + void whenPrimaryAnswersInHalfOpenState_thenStatisticsReportHalfOpenState() 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()); + .thenThrow(new OCSPClientException("Primary OCSP service unavailable")) + .thenThrow(new OCSPClientException("Primary OCSP service unavailable")) + .thenReturn(ocspRespGood); + when(ocspClient.request(eq(FALLBACK_URI), any())).thenReturn(ocspRespGood); + CircuitBreakerConfig recoverableCircuitBreakerConfig = CircuitBreakerConfig.custom() + .slidingWindowSize(2) + .minimumNumberOfCalls(2) + .failureRateThreshold(50) + .waitDurationInOpenState(Duration.ofSeconds(1)) + .permittedNumberOfCallsInHalfOpenState(1) + .build(); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient) + .withFallbacks(FALLBACK_URI) + .withCircuitBreakerConfig(recoverableCircuitBreakerConfig) + .build(); + // The first two calls fail on the primary and trip the breaker. + checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + // Let the open state expire without calling the checker, so that the next call is the one that the + // circuit breaker permits in half open state. + await().pollDelay(waitLongerThan(Duration.ofSeconds(1))) + .atMost(Duration.ofSeconds(10)) + .until(() -> true); - FallbackOcspService fallbackService = mock(FallbackOcspService.class); - when(fallbackService.getAccessLocation()).thenReturn(FALLBACK_URI); - when(fallbackService.doesSupportNonce()).thenReturn(false); - when(fallbackService.getNextFallback()).thenReturn(null); + List revocationInfoList = + checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); - OcspService primaryService = mock(OcspService.class); - when(primaryService.getAccessLocation()).thenReturn(PRIMARY_URI); - when(primaryService.doesSupportNonce()).thenReturn(false); - when(primaryService.getFallbackService()).thenReturn(Optional.of(fallbackService)); + assertThat(revocationInfoList.get(0).ocspResponderUri()).isEqualTo(PRIMARY_URI); + // The primary answered, so the circuit breaker permitted the call in half open state. + // The reported state must not contradict which responder answered. + assertThat(getCircuitBreakerStatistics(revocationInfoList.get(0)).state()) + .isEqualTo(CircuitBreaker.State.HALF_OPEN); + } - OcspServiceProvider ocspServiceProvider = mock(OcspServiceProvider.class); - when(ocspServiceProvider.getService(any(), any())).thenReturn(primaryService); + @Test + void whenPrimaryFailsInHalfOpenStateAndFallbackAnswers_thenStatisticsReportHalfOpenState() 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(ocspRespGood); + CircuitBreakerConfig recoverableCircuitBreakerConfig = CircuitBreakerConfig.custom() + .slidingWindowSize(2) + .minimumNumberOfCalls(2) + .failureRateThreshold(50) + .waitDurationInOpenState(Duration.ofSeconds(1)) + .permittedNumberOfCallsInHalfOpenState(1) + .build(); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient) + .withFallbacks(FALLBACK_URI) + .withCircuitBreakerConfig(recoverableCircuitBreakerConfig) + .build(); + // The first two calls fail on the primary and trip the breaker. + checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + // Let the open state expire without calling the checker, so that the next call is the one that the + // circuit breaker permits in half open state. + await().pollDelay(waitLongerThan(Duration.ofSeconds(1))) + .atMost(Duration.ofSeconds(10)) + .until(() -> true); - ResilientOcspCertificateRevocationChecker checker = new ResilientOcspCertificateRevocationChecker( - ocspClient, - ocspServiceProvider, - CircuitBreakerConfig.ofDefaults(), - null, - OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW, - OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, - LONG_THIS_UPDATE_AGE - ); - - ResilientUserCertificateOCSPCheckFailedException ex = assertThrows(ResilientUserCertificateOCSPCheckFailedException.class, () -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); - List revocationInfoList = ex.getValidationInfo().revocationInfoList(); - assertThat(revocationInfoList.size()).isEqualTo(2); + List revocationInfoList = + checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + // The next call finds the freshly re-opened breaker: the primary is not called and the statistics + // are a new snapshot that reports the OPEN state that rejected the call. + List reopenedRevocationInfoList = + checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + + assertThat(revocationInfoList).hasSize(2); + assertThat(revocationInfoList.get(0).ocspResponderUri()).isEqualTo(PRIMARY_URI); + assertThat(revocationInfoList.get(1).ocspResponderUri()).isEqualTo(FALLBACK_URI); + // The breaker permitted the primary call in half open state and re-opened when the call failed, + // before the fallback ran. The statistics must keep the snapshot taken when the primary request + // started (HALF_OPEN): neither the expired OPEN state that a snapshot taken before the call would + // report, nor the re-opened OPEN state that a capture in the fallback path would take. + assertThat(getCircuitBreakerStatistics(revocationInfoList.get(0)).state()) + .isEqualTo(CircuitBreaker.State.HALF_OPEN); + assertThat(reopenedRevocationInfoList).hasSize(1); + assertThat(reopenedRevocationInfoList.get(0).ocspResponderUri()).isEqualTo(FALLBACK_URI); + assertThat(getCircuitBreakerStatistics(reopenedRevocationInfoList.get(0)).state()) + .isEqualTo(CircuitBreaker.State.OPEN); + verify(ocspClient, times(3)).request(eq(PRIMARY_URI), any()); } @Test - void whenNoFallbacksAreConfigured_thenRevocationInfoListShouldHaveOneElement() throws Exception { + void whenPrimaryFailsInClosedState_thenStatisticsDoNotIncludeCurrentCallFailure() 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(PRIMARY_URI), any())).thenThrow(new OCSPClientException("Primary OCSP service unavailable")); + when(ocspClient.request(eq(FALLBACK_URI), any())).thenReturn(ocspRespGood); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient) + .withFallbacks(FALLBACK_URI) + .build(); + + List firstCallRevocationInfoList = + checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + List secondCallRevocationInfoList = + checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + // The statistics are captured when the primary request starts, so the outcome of the very call they + // are attached to is not yet included: the first call reports no recorded calls at all and the + // second call reports only the first call's failure. + CircuitBreakerStatistics firstCallStatistics = + getCircuitBreakerStatistics(firstCallRevocationInfoList.get(0)); + assertThat(firstCallStatistics.state()).isEqualTo(CircuitBreaker.State.CLOSED); + assertThat(firstCallStatistics.numberOfBufferedCalls()).isZero(); + assertThat(firstCallStatistics.numberOfFailedCalls()).isZero(); + CircuitBreakerStatistics secondCallStatistics = + getCircuitBreakerStatistics(secondCallRevocationInfoList.get(0)); + assertThat(secondCallStatistics.state()).isEqualTo(CircuitBreaker.State.CLOSED); + assertThat(secondCallStatistics.numberOfBufferedCalls()).isEqualTo(1); + assertThat(secondCallStatistics.numberOfFailedCalls()).isEqualTo(1); + } + + @Test + void whenOcspRequestFailsWithStatusCode_thenRevocationInfoContainsHttpStatusCodeAndResponseBody() throws Exception { + byte[] responseBody = "error".getBytes(); + OCSPClientException ocspClientException = new OCSPClientException("OCSP request was not successful", responseBody, 503); + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())).thenThrow(ocspClientException); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withoutFallbacks().build(); + + assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> { + Map attributes = ex.getValidationInfo().revocationInfoList().get(0).ocspResponseAttributes(); + assertThat(attributes.get(RevocationInfo.KEY_HTTP_STATUS_CODE)).isEqualTo(503); + assertThat(attributes.get(RevocationInfo.KEY_OCSP_RESPONSE)).isEqualTo(responseBody); + }); + } + + @Test + void whenPrimaryThrowsRuntimeExceptionThatIsNotOCSPClientException_thenWrapsAsResilientUserCertificateOCSPCheckFailedException() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())).thenThrow(new NullPointerException()); + when(ocspClient.request(eq(FALLBACK_URI), any())).thenThrow(new NullPointerException()); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withFallbacks(FALLBACK_URI).build(); + + assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> { + assertThat(ex.getValidationInfo().revocationInfoList()).hasSize(2); + Throwable primaryError = (Throwable) ex.getValidationInfo().revocationInfoList().get(0) + .ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR); + assertThat(primaryError).isInstanceOf(NullPointerException.class); + }); + } + + @Test + void whenOcspClientReturnsNullResponse_thenWrapsAsResilientUserCertificateOCSPCheckFailedException() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(null); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withoutFallbacks().build(); + + assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> { + Throwable primaryError = (Throwable) ex.getValidationInfo().revocationInfoList().get(0) + .ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR); + assertThat(primaryError).isInstanceOf(NullPointerException.class); + }); + } + + @Test + void whenPrimaryOcspServiceAccessLocationIsNull_thenWrapsAsResilientUserCertificateOCSPCheckFailedException() throws Exception { + NullPointerException nullUriRejectedByClient = new NullPointerException("uri"); + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(isNull(), any())).thenThrow(nullUriRejectedByClient); OcspService primaryService = mock(OcspService.class); - when(primaryService.getAccessLocation()).thenReturn(PRIMARY_URI); + when(primaryService.getAccessLocation()).thenReturn(null); when(primaryService.doesSupportNonce()).thenReturn(false); when(primaryService.getFallbackService()).thenReturn(Optional.empty()); - OcspServiceProvider ocspServiceProvider = mock(OcspServiceProvider.class); when(ocspServiceProvider.getService(any(), any())).thenReturn(primaryService); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient) + .withOcspServiceProvider(ocspServiceProvider) + .build(); + + assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> { + RevocationInfo revocationInfo = ex.getValidationInfo().revocationInfoList().get(0); + assertThat(revocationInfo.ocspResponderUri()).isNull(); + Map attributes = revocationInfo.ocspResponseAttributes(); + assertThat(attributes.get(RevocationInfo.KEY_OCSP_ERROR)).isSameAs(nullUriRejectedByClient); + // getOcspRequest() builds the request before the responder is contacted, so the request + // is recorded even when the call itself fails. + assertThat(attributes).containsKey(RevocationInfo.KEY_OCSP_REQUEST); + assertThat(attributes).doesNotContainKey(RevocationInfo.KEY_OCSP_RESPONSE); + }); + verify(ocspClient).request(isNull(), any()); + } + + @Test + void whenPrimarySucceeds_thenRevocationInfoListContainsExpectedResponderUrisAndAttributes() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespGood); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build(); + + List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); - ResilientOcspCertificateRevocationChecker checker = new ResilientOcspCertificateRevocationChecker( - ocspClient, - ocspServiceProvider, - CircuitBreakerConfig.ofDefaults(), - null, - OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW, - OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, - LONG_THIS_UPDATE_AGE - ); + assertThat(revocationInfoList).hasSize(1); - ResilientUserCertificateOCSPCheckFailedException ex = assertThrows(ResilientUserCertificateOCSPCheckFailedException.class, () -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); - List revocationInfoList = ex.getValidationInfo().revocationInfoList(); - assertThat(revocationInfoList.size()).isEqualTo(1); + RevocationInfo primary = revocationInfoList.get(0); + assertThat(primary.ocspResponderUri()).isEqualTo(PRIMARY_URI); + assertThat(getCertificateStatus(primary)).isEqualTo(CertificateStatus.GOOD); + assertThat(primary.ocspResponseAttributes()) + .doesNotContainKey(RevocationInfo.KEY_OCSP_ERROR) + .containsKey(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS) + .containsKey(RevocationInfo.KEY_REQUEST_DURATION) + .containsKey(RevocationInfo.KEY_OCSP_RESPONSE_TIME); + assertThat(primary.ocspResponseAttributes().get(RevocationInfo.KEY_REQUEST_DURATION)).isInstanceOf(Duration.class); + assertThat((Duration) primary.ocspResponseAttributes().get(RevocationInfo.KEY_REQUEST_DURATION)) + .isGreaterThanOrEqualTo(Duration.ZERO); + assertThat(primary.ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_RESPONSE_TIME)).isInstanceOf(Instant.class); } @Test - void whenOcspResponseStatusIsUnauthorized_thenThrows() throws Exception { - OCSPResp ocspRespStatusUnauthorized = new OCSPResp(getOcspResponseBytesFromResources("ocsp_response_unauthorized.der")); + void whenPrimaryFailsAndFirstFallbackSucceeds_thenRevocationInfoListContainsExpectedResponderUrisAndAttributes() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())).thenThrow(new OCSPClientException("primary")); + when(ocspClient.request(eq(FALLBACK_URI), any())).thenReturn(ocspRespGood); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build(); + + List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + + assertThat(revocationInfoList).hasSize(2); + + RevocationInfo primary = revocationInfoList.get(0); + assertThat(primary.ocspResponderUri()).isEqualTo(PRIMARY_URI); + assertThat(primary.ocspResponseAttributes()) + .containsKey(RevocationInfo.KEY_OCSP_ERROR) + .containsKey(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS); + + RevocationInfo fallback = revocationInfoList.get(1); + assertThat(fallback.ocspResponderUri()).isEqualTo(FALLBACK_URI); + assertThat(getCertificateStatus(fallback)).isEqualTo(CertificateStatus.GOOD); + assertThat(fallback.ocspResponseAttributes()) + .doesNotContainKey(RevocationInfo.KEY_OCSP_ERROR) + .doesNotContainKey(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS) + .containsKey(RevocationInfo.KEY_REQUEST_DURATION) + .containsKey(RevocationInfo.KEY_OCSP_RESPONSE_TIME); + assertThat(fallback.ocspResponseAttributes().get(RevocationInfo.KEY_REQUEST_DURATION)).isInstanceOf(Duration.class); + assertThat(fallback.ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_RESPONSE_TIME)).isInstanceOf(Instant.class); + + verify(ocspClient, never()).request(eq(SECOND_FALLBACK_URI), any()); + } + @Test + void whenPrimaryAndFirstFallbackFailAndSecondFallbackSucceeds_thenRevocationInfoListContainsExpectedResponderUrisAndAttributes() throws Exception { OcspClient ocspClient = mock(OcspClient.class); - when(ocspClient.request(eq(PRIMARY_URI), any())) - .thenReturn(ocspRespStatusUnauthorized); + when(ocspClient.request(eq(PRIMARY_URI), any())).thenThrow(new OCSPClientException("Primary OCSP service unavailable")); + when(ocspClient.request(eq(FALLBACK_URI), any())).thenThrow(new OCSPClientException("Fallback OCSP service unavailable")); + when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())).thenReturn(ocspRespGood); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build(); - ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, null); - ResilientUserCertificateOCSPCheckFailedException ex = assertThrows(ResilientUserCertificateOCSPCheckFailedException.class, () -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); + List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); - Map responseAttributes = ex.getValidationInfo().revocationInfoList().get(0).ocspResponseAttributes(); - ResilientUserCertificateOCSPCheckFailedException firstException = (ResilientUserCertificateOCSPCheckFailedException) responseAttributes.get(RevocationInfo.KEY_OCSP_ERROR); - assertThat(firstException.getMessage()).isEqualTo("Response status: unauthorized"); + assertThat(revocationInfoList).hasSize(3); + + RevocationInfo primary = revocationInfoList.get(0); + assertThat(primary.ocspResponderUri()).isEqualTo(PRIMARY_URI); + assertThat(primary.ocspResponseAttributes()) + .containsKey(RevocationInfo.KEY_OCSP_ERROR) + .containsKey(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS); + + RevocationInfo firstFallback = revocationInfoList.get(1); + assertThat(firstFallback.ocspResponderUri()).isEqualTo(FALLBACK_URI); + assertThat(firstFallback.ocspResponseAttributes()) + .containsKey(RevocationInfo.KEY_OCSP_ERROR) + .doesNotContainKey(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS); + + RevocationInfo secondFallback = revocationInfoList.get(2); + assertThat(secondFallback.ocspResponderUri()).isEqualTo(SECOND_FALLBACK_URI); + assertThat(secondFallback.ocspResponseAttributes()) + .doesNotContainKey(RevocationInfo.KEY_OCSP_ERROR) + .doesNotContainKey(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS); } - private ResilientOcspCertificateRevocationChecker buildChecker(OcspClient ocspClient, RetryConfig retryConfig) throws Exception { - FallbackOcspService secondFallbackService = mock(FallbackOcspService.class); - when(secondFallbackService.getAccessLocation()).thenReturn(SECOND_FALLBACK_URI); - when(secondFallbackService.doesSupportNonce()).thenReturn(false); + @Test + void whenAllFail_thenRevocationInfoListContainsExpectedResponderUrisAndAttributes() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())).thenThrow(new OCSPClientException("primary")); + when(ocspClient.request(eq(FALLBACK_URI), any())).thenThrow(new OCSPClientException("fallback")); + when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())).thenThrow(new OCSPClientException("second")); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build(); + + assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> { + assertThat(ex.getValidationInfo()).isNotNull(); + List revocationInfoList = ex.getValidationInfo().revocationInfoList(); - FallbackOcspService fallbackService = mock(FallbackOcspService.class); - when(fallbackService.getAccessLocation()).thenReturn(FALLBACK_URI); - when(fallbackService.doesSupportNonce()).thenReturn(false); - when(fallbackService.getNextFallback()).thenReturn(secondFallbackService); + assertThat(revocationInfoList).hasSize(3); - OcspService primaryService = mock(OcspService.class); - when(primaryService.getAccessLocation()).thenReturn(PRIMARY_URI); - when(primaryService.doesSupportNonce()).thenReturn(false); - when(primaryService.getFallbackService()).thenReturn(Optional.of(fallbackService)); + RevocationInfo primary = revocationInfoList.get(0); + assertThat(primary.ocspResponderUri()).isEqualTo(PRIMARY_URI); + assertThat(primary.ocspResponseAttributes()) + .containsKey(RevocationInfo.KEY_OCSP_ERROR) + .containsKey(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS); - OcspServiceProvider ocspServiceProvider = mock(OcspServiceProvider.class); - when(ocspServiceProvider.getService(any(), any())).thenReturn(primaryService); + RevocationInfo firstFallback = revocationInfoList.get(1); + assertThat(firstFallback.ocspResponderUri()).isEqualTo(FALLBACK_URI); + assertThat(firstFallback.ocspResponseAttributes()) + .containsKey(RevocationInfo.KEY_OCSP_ERROR) + .doesNotContainKey(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS); + + RevocationInfo secondFallback = revocationInfoList.get(2); + assertThat(secondFallback.ocspResponderUri()).isEqualTo(SECOND_FALLBACK_URI); + assertThat(secondFallback.ocspResponseAttributes()) + .containsKey(RevocationInfo.KEY_OCSP_ERROR) + .doesNotContainKey(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS); + }); + } + + @Test + void whenPrimaryResponseIsTooOldForPrimaryAgeLimit_thenFallbackAcceptsItUnderFallbackAgeLimit() throws Exception { + // The same response (thisUpdate 2021-09-17T18:25:24) is served by both responders and the clock is mocked + // 5 minutes later. The primary applies the stricter 2-minute limit and rejects it as too old, while the + // fallback applies the more lenient 10-minute limit and accepts it. This proves that each responder + // applies the maxThisUpdateAge of its own OCSP service; swapping the two values would flip the outcome + // and fail this test. + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespGood); + when(ocspClient.request(eq(FALLBACK_URI), any())).thenReturn(ocspRespGood); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient) + .withFallbacks(FALLBACK_URI) + .withPrimaryMaxThisUpdateAge(Duration.ofMinutes(2)) + .withFallbackMaxThisUpdateAge(Duration.ofMinutes(10)) + .build(); + + try (var ignored = mockStaticClockAt(FIVE_MIN_AFTER_THIS_UPDATE)) { + List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + + assertThat(revocationInfoList).hasSize(2); + + RevocationInfo primary = revocationInfoList.get(0); + assertThat(primary.ocspResponderUri()).isEqualTo(PRIMARY_URI); + Throwable primaryError = (Throwable) primary.ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR); + assertThat(primaryError).hasMessageContaining("thisUpdate").hasMessageContaining("is too old"); + + RevocationInfo fallback = revocationInfoList.get(1); + assertThat(fallback.ocspResponderUri()).isEqualTo(FALLBACK_URI); + assertThat(getCertificateStatus(fallback)).isEqualTo(CertificateStatus.GOOD); + } + } + + @Test + void whenCircuitBreakerOpens_thenFallbackHandlesCallAndStatisticsReflectOpenState() 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(ocspRespGood); + CircuitBreakerConfig tightCircuitBreakerConfig = CircuitBreakerConfig.custom() + .slidingWindowSize(2) + .minimumNumberOfCalls(2) + .failureRateThreshold(50) + .permittedNumberOfCallsInHalfOpenState(1) + .build(); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient) + .withFallbacks(FALLBACK_URI) + .withCircuitBreakerConfig(tightCircuitBreakerConfig) + .build(); + + // The first two calls fail on the primary and trip the breaker; the third call sees it already open. + checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + + assertThat(revocationInfoList).hasSize(1); + assertThat(revocationInfoList.get(0).ocspResponderUri()).isEqualTo(FALLBACK_URI); + CircuitBreakerStatistics statistics = + (CircuitBreakerStatistics) + revocationInfoList.get(0).ocspResponseAttributes().get(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS); + assertThat(statistics).isNotNull(); + assertThat(statistics.state()).isEqualTo(CircuitBreaker.State.OPEN); + assertThat(statistics.numberOfFailedCalls()).isEqualTo(2); + // The circuit breaker rejected the primary call, so the snapshot is taken when the fallback request + // starts and includes the rejection of this very call. + assertThat(statistics.numberOfNotPermittedCalls()).isEqualTo(1); + } + + @Test + void whenNoFallbackConfiguredAndPrimaryReturnsRevoked_thenRevokedPropagatesWithSingleEntry() throws Exception { + // The no-fallback branch returns directly from request() without going through processResult, so this + // exercises ResilientUserCertificateRevokedException propagating straight out of validateCertificateNotRevoked. + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespRevoked); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withoutFallbacks().build(); + + assertThatExceptionOfType(ResilientUserCertificateRevokedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> { + List revocationInfoList = ex.getValidationInfo().revocationInfoList(); + assertThat(revocationInfoList).hasSize(1); + assertThat(revocationInfoList.get(0).ocspResponderUri()).isEqualTo(PRIMARY_URI); + assertThat(getCertificateStatus(revocationInfoList.get(0))).isInstanceOf(RevokedStatus.class); + }); + } + + @Test + void whenNoFallbackConfiguredAndPrimaryReturnsUnknown_thenCheckFailedPropagates() throws Exception { + // The unknown status fails the OCSP check. + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespUnknown); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withoutFallbacks().build(); + + try (var ignored = mockStaticClockAt(WITHIN_RESPONDER_CERT_VALIDITY)) { + assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> assertThat(ex.getValidationInfo().revocationInfoList()).hasSize(1)); + } + } + + @Test + void whenNoFallbackConfigured_thenRetryAndCircuitBreakerAreNotApplied() throws Exception { + // The class contract states retry and circuit breaker apply only when a fallback is configured. With no + // fallback the primary must be queried exactly once (no retry) and no circuit breaker statistics attached. + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())).thenThrow(new OCSPClientException("Primary OCSP service unavailable")); + RetryConfig retryConfig = RetryConfig.custom().maxAttempts(2).waitDuration(Duration.ZERO).build(); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient) + .withoutFallbacks() + .withRetryConfig(retryConfig) + .build(); + + assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> { + List revocationInfoList = ex.getValidationInfo().revocationInfoList(); + assertThat(revocationInfoList).hasSize(1); + assertThat(revocationInfoList.get(0).ocspResponseAttributes()) + .doesNotContainKey(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS); + }); + verify(ocspClient, times(1)).request(eq(PRIMARY_URI), any()); + } + + @Test + void whenPrimaryAndFirstFallbackFailAndSecondFallbackReturnsRevoked_thenRevokedPropagates() 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())).thenThrow(new OCSPClientException("Fallback OCSP service unavailable")); + when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())).thenReturn(ocspRespRevoked); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build(); + + assertThatExceptionOfType(ResilientUserCertificateRevokedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> { + List revocationInfoList = ex.getValidationInfo().revocationInfoList(); + assertThat(revocationInfoList).hasSize(3); + assertThat(revocationInfoList).extracting(RevocationInfo::ocspResponderUri) + .containsExactly(PRIMARY_URI, FALLBACK_URI, SECOND_FALLBACK_URI); + assertThat(getCertificateStatus(revocationInfoList.get(2))).isInstanceOf(RevokedStatus.class); + }); + } + + @Test + void whenFallbackResponseIsTooOldForFallbackAgeLimit_thenOcspCheckFails() throws Exception { + // The response thisUpdate is 2021-09-17T18:25:24 and the clock is mocked 5 minutes later, while the fallback + // age limit is only 2 minutes, so the fallback rejects the response as too old. This exercises the failure + // direction of the fallback service maxThisUpdateAge (the accepting direction is covered elsewhere). + 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(ocspRespGood); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient) + .withFallbacks(FALLBACK_URI) + .withFallbackMaxThisUpdateAge(Duration.ofMinutes(2)) + .build(); + + try (var ignored = mockStaticClockAt(FIVE_MIN_AFTER_THIS_UPDATE)) { + assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> { + List revocationInfoList = ex.getValidationInfo().revocationInfoList(); + assertThat(revocationInfoList).hasSize(2); + Throwable fallbackError = (Throwable) revocationInfoList.get(1) + .ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR); + assertThat(fallbackError).hasMessageContaining("thisUpdate").hasMessageContaining("is too old"); + }); + } + } + + private static CheckerBuilder checkerBuilder(OcspClient ocspClient) { + return new CheckerBuilder(ocspClient); + } + + /** + * Builds a {@link ResilientOcspCertificateRevocationChecker} with, by default, a primary OCSP service + * with two chained fallbacks: PRIMARY_URI -> FALLBACK_URI -> SECOND_FALLBACK_URI. The age limits are + * per OCSP service, so they are stubbed on the mocked services and are relaxed by default. + */ + private static final class CheckerBuilder { + + private final OcspClient ocspClient; + private OcspServiceProvider ocspServiceProvider; + private URI[] fallbackUris = {FALLBACK_URI, SECOND_FALLBACK_URI}; + private boolean primarySupportsNonce; + private CircuitBreakerConfig circuitBreakerConfig = CircuitBreakerConfig.ofDefaults(); + private RetryConfig retryConfig; + private Duration primaryMaxThisUpdateAge = LONG_THIS_UPDATE_AGE; + private Duration fallbackMaxThisUpdateAge = LONG_THIS_UPDATE_AGE; + + private CheckerBuilder(OcspClient ocspClient) { + this.ocspClient = ocspClient; + } + + private CheckerBuilder withFallbacks(URI... fallbackUris) { + this.fallbackUris = fallbackUris; + return this; + } + + private CheckerBuilder withoutFallbacks() { + return withFallbacks(); + } + + private CheckerBuilder withPrimaryNonceSupport() { + this.primarySupportsNonce = true; + return this; + } + + private CheckerBuilder withCircuitBreakerConfig(CircuitBreakerConfig circuitBreakerConfig) { + this.circuitBreakerConfig = circuitBreakerConfig; + return this; + } + + private CheckerBuilder withRetryConfig(RetryConfig retryConfig) { + this.retryConfig = retryConfig; + return this; + } + + private CheckerBuilder withOcspServiceProvider(OcspServiceProvider ocspServiceProvider) { + this.ocspServiceProvider = ocspServiceProvider; + return this; + } + + private CheckerBuilder withPrimaryMaxThisUpdateAge(Duration primaryMaxThisUpdateAge) { + this.primaryMaxThisUpdateAge = primaryMaxThisUpdateAge; + return this; + } + + private CheckerBuilder withFallbackMaxThisUpdateAge(Duration fallbackMaxThisUpdateAge) { + this.fallbackMaxThisUpdateAge = fallbackMaxThisUpdateAge; + return this; + } + + private ResilientOcspCertificateRevocationChecker build() throws Exception { + OcspServiceProvider serviceProvider = ocspServiceProvider != null ? ocspServiceProvider : buildMockServiceProvider(); + return new ResilientOcspCertificateRevocationChecker( + ocspClient, + serviceProvider, + circuitBreakerConfig, + retryConfig, + OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW + ); + } + + private OcspServiceProvider buildMockServiceProvider() throws Exception { + FallbackOcspService nextFallback = null; + for (int i = fallbackUris.length - 1; i >= 0; i--) { + FallbackOcspService fallbackService = mock(FallbackOcspService.class); + when(fallbackService.getAccessLocation()).thenReturn(fallbackUris[i]); + when(fallbackService.doesSupportNonce()).thenReturn(false); + when(fallbackService.getNextFallback()).thenReturn(nextFallback); + when(fallbackService.getMaxThisUpdateAge()).thenReturn(fallbackMaxThisUpdateAge); + when(fallbackService.getMaxNextUpdateAge()).thenReturn(LONG_NEXT_UPDATE_AGE); + nextFallback = fallbackService; + } + + OcspService primaryService = mock(OcspService.class); + when(primaryService.getAccessLocation()).thenReturn(PRIMARY_URI); + when(primaryService.doesSupportNonce()).thenReturn(primarySupportsNonce); + when(primaryService.getFallbackService()).thenReturn(Optional.ofNullable(nextFallback)); + when(primaryService.getMaxThisUpdateAge()).thenReturn(primaryMaxThisUpdateAge); + when(primaryService.getMaxNextUpdateAge()).thenReturn(LONG_NEXT_UPDATE_AGE); + + OcspServiceProvider serviceProvider = mock(OcspServiceProvider.class); + when(serviceProvider.getService(any(), any())).thenReturn(primaryService); + return serviceProvider; + } + } + + private static MockedStatic mockStaticClockAt(String isoDateTime) { + MockedStatic mockedClock = Mockito.mockStatic(DateAndTime.DefaultClock.class); + mockDate(isoDateTime, mockedClock); + return mockedClock; + } + + // Overshooting the open-state duration is safe: the breaker transitions to HALF_OPEN lazily, when the + // next call asks for permission, not on a timer. + private static Duration waitLongerThan(Duration waitDurationInOpenState) { + return waitDurationInOpenState.plusMillis(500); + } - return new ResilientOcspCertificateRevocationChecker( - ocspClient, - ocspServiceProvider, - CircuitBreakerConfig.ofDefaults(), - retryConfig, - OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW, - LONG_THIS_UPDATE_AGE, - LONG_THIS_UPDATE_AGE - ); + private static CircuitBreakerStatistics getCircuitBreakerStatistics(RevocationInfo revocationInfo) { + return (CircuitBreakerStatistics) + revocationInfo.ocspResponseAttributes().get(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS); } - private CertificateStatus getCertificateStatus(OCSPResp ocspResp) throws Exception { + private static CertificateStatus getCertificateStatus(RevocationInfo revocationInfo) throws Exception { + OCSPResp ocspResp = (OCSPResp) revocationInfo.ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_RESPONSE); final BasicOCSPResp basicResponse = (BasicOCSPResp) ocspResp.getResponseObject(); final SingleResp certStatusResponse = basicResponse.getResponses()[0]; return certStatusResponse.getCertStatus(); diff --git a/src/test/java/eu/webeid/security/testutil/AbstractTestWithValidator.java b/src/test/java/eu/webeid/security/testutil/AbstractTestWithValidator.java index 0a5588a2..c8cb25f3 100644 --- a/src/test/java/eu/webeid/security/testutil/AbstractTestWithValidator.java +++ b/src/test/java/eu/webeid/security/testutil/AbstractTestWithValidator.java @@ -3,16 +3,14 @@ package eu.webeid.security.testutil; -import org.junit.jupiter.api.BeforeEach; import eu.webeid.security.authtoken.WebEidAuthToken; import eu.webeid.security.exceptions.AuthTokenException; import eu.webeid.security.validator.AuthTokenValidator; +import org.junit.jupiter.api.BeforeEach; import java.io.IOException; import java.security.cert.CertificateException; -import static eu.webeid.security.testutil.AuthTokenValidators.getAuthTokenValidator; - public abstract class AbstractTestWithValidator { /* @@ -25,7 +23,7 @@ public abstract class AbstractTestWithValidator { "\"signature\":\"pHkO+vRxBkP/zZLFvXcwR9kme/HT/DBRLk5RJDp7lPrfr6Qlb5Fu3/C3Up6Qw8P0KE2992as1lG9L3tbvqwa3dUCUz0osfRNEUXgkx1oPJrfII50/6L3mNnmexRnVSl2\"," + "\"format\":\"web-eid:1.0\"}"; - /* + /* * notBefore Time UTCTime 2021-07-22 12:43:08 UTC * notAfter Time UTCTime 2026-07-09 21:59:59 UTC */ @@ -35,6 +33,11 @@ public abstract class AbstractTestWithValidator { "\"signature\":\"0Ov7ME6pTY1K2GXMj8Wxov/o2fGIMEds8OMY5dKdkB0nrqQX7fG1E5mnsbvyHpMDecMUH6Yg+p1HXdgB/lLqOcFZjt/OVXPjAAApC5d1YgRYATDcxsR1zqQwiNcHdmWn\"," + "\"format\":\"web-eid:1.0\"}"; public static final String VALID_AUTH_TOKEN_TEST_DATE = "2026-01-01"; + public static final String VALID_RS256_AUTH_TOKEN = "{\"algorithm\":\"RS256\"," + + "\"unverifiedCertificate\":\"MIIGvjCCBKagAwIBAgIQT7aXeR+zWlBb2Gbar+AFaTANBgkqhkiG9w0BAQsFADCBgzELMAkGA1UEBhMCTFYxOTA3BgNVBAoMMFZBUyBMYXR2aWphcyBWYWxzdHMgcmFkaW8gdW4gdGVsZXbEq3ppamFzIGNlbnRyczEaMBgGA1UEYQwRTlRSTFYtNDAwMDMwMTEyMDMxHTAbBgNVBAMMFERFTU8gTFYgZUlEIElDQSAyMDE3MB4XDTE4MTAzMDE0MTI0MloXDTIzMTAzMDE0MTI0MlowcDELMAkGA1UEBhMCTFYxHDAaBgNVBAMME0FORFJJUyBQQVJBVURaScWFxaAxFTATBgNVBAQMDFBBUkFVRFpJxYXFoDEPMA0GA1UEKgwGQU5EUklTMRswGQYDVQQFExJQTk9MVi0zMjE5MjItMzMwMzIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDXkra3rDOOt5K6OnJcg/Xt6JOogPAUBX2kT9zWelze7WSuPx2Ofs//0JoBQ575IVdh3JpLhfh7g60YYi41M6vNACVSNaFOxiEvE9amSFizMiLk5+dp+79rymqOsVQG8CSu8/RjGGlDsALeb3N/4pUSTGXUwSB64QuFhOWjAcmKPhHeYtry0hK3MbwwHzFhYfGpo/w+PL14PEdJlpL1UX/aPyT0Zq76Z4T/Z3PqbTmQp09+2b0thC0JIacSkyJuTu8fVRQvse+8UtYC6Kt3TBLZbPtqfAFSXWbuE47Lc2o840NkVlMHVAesoRAfiQxsK35YWFT0rHPWbLjX6ySiaL25AgMBAAGjggI+MIICOjAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAjAdBgNVHQ4EFgQUHZWimPze2GXULNaP4EFVdF+MWKQwHwYDVR0jBBgwFoAUj2jOvOLHQCFTCUK75Z4djEvNvTgwgfsGA1UdIASB8zCB8DA7BgYEAI96AQIwMTAvBggrBgEFBQcCARYjaHR0cHM6Ly93d3cuZXBhcmFrc3RzLmx2L3JlcG9zaXRvcnkwgbAGDCsGAQQBgfo9AgECATCBnzAvBggrBgEFBQcCARYjaHR0cHM6Ly93d3cuZXBhcmFrc3RzLmx2L3JlcG9zaXRvcnkwbAYIKwYBBQUHAgIwYAxexaBpcyBzZXJ0aWZpa8SBdHMgaXIgaWVrxLxhdXRzIExhdHZpamFzIFJlcHVibGlrYXMgaXpzbmllZ3TEgSBwZXJzb251IGFwbGllY2lub8WhxIEgZG9rdW1lbnTEgTB9BggrBgEFBQcBAQRxMG8wQgYIKwYBBQUHMAKGNmh0dHA6Ly9kZW1vLmVwYXJha3N0cy5sdi9jZXJ0L2RlbW9fTFZfZUlEX0lDQV8yMDE3LmNydDApBggrBgEFBQcwAYYdaHR0cDovL29jc3AucHJlcC5lcGFyYWtzdHMubHYwSAYDVR0fBEEwPzA9oDugOYY3aHR0cDovL2RlbW8uZXBhcmFrc3RzLmx2L2NybC9kZW1vX0xWX2VJRF9JQ0FfMjAxN18zLmNybDANBgkqhkiG9w0BAQsFAAOCAgEAAOVoRbnMv2UXWYHgnmO9Zg9u8F1YvJiZPMeTYE2CVaiq0nXe4Mq0X5tWcsEiRpGQF9e0dWC6V5m6EmAsHxIRL4chZKRrIrPEiWtP3zyRI1/X2y5GwSUyZmgxkuSOHHw3UjzjrnOoI9izpC0OSNeumqpjT/tLAi35sktGkK0onEUPWGQnZLqd/hzykm+H/dmD27nOnfCJOSqbegLSbhV2w/WAII+IUD3vJ06F6rf9ZN8xbrGkPO8VMCIDIt0eBKFxBdSOgpsTfbERbjQJ+nFEDYhD0bFNYMsFSGnZiWpNaCcZSkk4mtNUa8sNXyaFQGIZk6NjQ/fsBANhUoxFz7rUKrRYqk356i8KFDZ+MJqUyodKKyW9oz+IO5eJxnL78zRbxD+EfAUmrLXOjmGIzU95RR1smS4cirrrPHqGAWojBk8hKbjNTJl9Tfbnsbc9/FUBJLVZAkCi631KfRLQ66bn8N0mbtKlNtdX0G47PXTy7SJtWwDtKQ8+qVpduc8xHLntbdAzie3mWyxA1SBhQuZ9BPf5SPBImWCNpmZNCTmI2e+4yyCnmG/kVNilUAaODH/fgQXFGdsKO/XATFohiies28twkEzqtlVZvZbpBhbJCHYVnQXMhMKcnblkDqXWcSWd3QAKig2yMH95uz/wZhiV+7tZ7cTgwcbCzIDCfpwBC3E=\"," + + "\"issuerApp\":\"https://web-eid.eu/web-eid-app/releases/2.0.0+0\"," + + "\"signature\":\"xsjXsQvVYXWcdV0YPhxLthJxtf0//R8p9WFFlYJGRARrl1ruyoAUwl0xeHgeZOKeJtwiCYCNWJzCG3VM3ydgt92bKhhk1u0JXIPVqvOkmDY72OCN4q73Y8iGSPVTgjk93TgquHlodf7YcqZNhutwNNf3oldHEWJD5zmkdwdpBFXgeOwTAdFwGljDQZbHr3h1Dr+apUDuloS0WuIzUuu8YXN2b8lh8FCTlF0G0DEjhHd/MGx8dbe3UTLHmD7K9DXv4zLJs6EF9i2v/C10SIBQDkPBSVPqMxCDPECjbEPi2+ds94eU7ThOhOQlFFtJ4KjQNTUa2crSixH7cYZF2rNNmA==\"," + + "\"format\":\"web-eid:1.0\"}"; public static final String VALID_CHALLENGE_NONCE = "12345678123456781234567812345678912356789123"; protected AuthTokenValidator validator; diff --git a/src/test/java/eu/webeid/security/testutil/Certificates.java b/src/test/java/eu/webeid/security/testutil/Certificates.java index 737a3ae1..f09dad3d 100644 --- a/src/test/java/eu/webeid/security/testutil/Certificates.java +++ b/src/test/java/eu/webeid/security/testutil/Certificates.java @@ -19,18 +19,30 @@ public class Certificates { private static X509Certificate testEsteid2018CA; private static X509Certificate testEsteid2015CA; + private static X509Certificate testEeCertCentreRootCA; private static X509Certificate jaakKristjanEsteid2018Cert; private static X509Certificate mariliisEsteid2015Cert; private static X509Certificate organizationCert; private static X509Certificate certificateWithoutCertificatePolicies; private static X509Certificate testSkOcspResponder2020; + private static X509Certificate testSelfSignedOcspResponder; + private static X509Certificate demoEsteidSk2018AiaOcspResponder; 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_self-signed_OCSP_RESPONDER.cer", + "DEMO_of_ESTEID-SK_2018_AIA_OCSP_RESPONDER_2018.cer", + "TEST_of_EE_Certification_Centre_Root_CA.cer"); testEsteid2015CA = certificates[0]; testEsteid2018CA = certificates[1]; testSkOcspResponder2020 = certificates[2]; + testSelfSignedOcspResponder = certificates[3]; + demoEsteidSk2018AiaOcspResponder = certificates[4]; + testEeCertCentreRootCA = certificates[5]; } public static X509Certificate getTestEsteid2018CA() throws CertificateException, IOException { @@ -54,6 +66,27 @@ public static X509Certificate getTestSkOcspResponder2020() throws CertificateExc return testSkOcspResponder2020; } + public static X509Certificate getTestSelfSignedOcspResponder() throws CertificateException, IOException { + if (testSelfSignedOcspResponder == null) { + loadCertificates(); + } + return testSelfSignedOcspResponder; + } + + public static X509Certificate getDemoEsteidSk2018AiaOcspResponder() throws CertificateException, IOException { + if (demoEsteidSk2018AiaOcspResponder == null) { + loadCertificates(); + } + return demoEsteidSk2018AiaOcspResponder; + } + + public static X509Certificate getTestEeCertCentreRootCA() throws CertificateException, IOException { + if (testEeCertCentreRootCA == null) { + loadCertificates(); + } + return testEeCertCentreRootCA; + } + 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/testutil/ResourceUtil.java b/src/test/java/eu/webeid/security/testutil/ResourceUtil.java new file mode 100644 index 00000000..cd6f29fc --- /dev/null +++ b/src/test/java/eu/webeid/security/testutil/ResourceUtil.java @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.security.testutil; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Objects; + +public final class ResourceUtil { + + private ResourceUtil() { + } + + public static byte[] bytesFromResource(String resource) throws IOException { + try (final InputStream resourceAsStream = ClassLoader.getSystemResourceAsStream(resource)) { + Objects.requireNonNull(resourceAsStream, () -> "Resource not found: " + resource); + return resourceAsStream.readAllBytes(); + } + } +} diff --git a/src/test/java/eu/webeid/security/testutil/TestCertificateBuilder.java b/src/test/java/eu/webeid/security/testutil/TestCertificateBuilder.java new file mode 100644 index 00000000..cf3a842a --- /dev/null +++ b/src/test/java/eu/webeid/security/testutil/TestCertificateBuilder.java @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.security.testutil; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; + +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.util.Date; + +public final class TestCertificateBuilder { + + private static final KeyPair TEST_KEY_PAIR = generateKeyPair(); + + public static X509Certificate buildCertificate(Extension... extensions) throws Exception { + final X500Name name = new X500Name("CN=Test OCSP Responder"); + final Instant now = Instant.now(); + final X509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( + name, BigInteger.ONE, Date.from(now.minusSeconds(60)), Date.from(now.plusSeconds(3600)), + name, TEST_KEY_PAIR.getPublic()); + for (final Extension extension : extensions) { + builder.addExtension(extension); + } + final ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(TEST_KEY_PAIR.getPrivate()); + return new JcaX509CertificateConverter().getCertificate(builder.build(signer)); + } + + private static KeyPair generateKeyPair() { + try { + final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + return keyPairGenerator.generateKeyPair(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private TestCertificateBuilder() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/src/test/java/eu/webeid/security/validator/AuthTokenCertificateTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenCertificateTest.java index c5043a2d..e5be8f46 100644 --- a/src/test/java/eu/webeid/security/validator/AuthTokenCertificateTest.java +++ b/src/test/java/eu/webeid/security/validator/AuthTokenCertificateTest.java @@ -4,8 +4,10 @@ package eu.webeid.security.validator; import com.fasterxml.jackson.databind.exc.MismatchedInputException; +import eu.webeid.ocsp.exceptions.UserCertificateRevokedException; import eu.webeid.security.authtoken.WebEidAuthToken; import eu.webeid.security.exceptions.AuthTokenException; +import eu.webeid.security.exceptions.AuthTokenSignatureValidationException; import eu.webeid.security.exceptions.AuthTokenParseException; import eu.webeid.security.exceptions.CertificateDecodingException; import eu.webeid.security.exceptions.CertificateExpiredException; @@ -47,8 +49,6 @@ class AuthTokenCertificateTest extends AbstractTestWithValidator { private static final String OLD_MOBILE_ID_CERT = "MIIE/TCCAuWgAwIBAgIQKbCN+05vfp1XOXVu6HMXRTANBgkqhkiG9w0BAQsFADBjMQswCQYDVQQGEwJFRTEiMCAGA1UECgwZQVMgU2VydGlmaXRzZWVyaW1pc2tlc2t1czEXMBUGA1UEYQwOTlRSRUUtMTA3NDcwMTMxFzAVBgNVBAMMDkVTVEVJRC1TSyAyMDE1MB4XDTE2MDUxNjA3MjMyNloXDTIxMDUxNjIwNTk1OVowgZsxCzAJBgNVBAYTAkVFMRswGQYDVQQKDBJFU1RFSUQgKE1PQklJTC1JRCkxFzAVBgNVBAsMDmF1dGhlbnRpY2F0aW9uMSAwHgYDVQQDDBdLQVNTLEFSVFVSSSwzNjAxMjM0NTY3ODENMAsGA1UEBAwES0FTUzEPMA0GA1UEKgwGQVJUVVJJMRQwEgYDVQQFEwszNjAxMjM0NTY3ODBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABBMstpyGLcAEXkctwF8TkyPUl1IizQb6nBvfEIaayMmNzZFRCNLZfV6z4AXJN58Mjs4d4RXsARU1vjBsi8yJMaCjggE9MIIBOTAJBgNVHRMEAjAAMA4GA1UdDwEB/wQEAwIEsDBbBgNVHSAEVDBSMFAGCisGAQQBzh8BAwMwQjAdBggrBgEFBQcCAjARDA9Db250cmFjdCAxLjExLTkwIQYIKwYBBQUHAgEWFWh0dHBzOi8vd3d3LnNrLmVlL2NwczAfBgNVHREEGDAWgRRoZWlra2kua2l0dEBlZXN0aS5lZTAdBgNVHQ4EFgQUkNR/r9m77o9Gsp04JFIBqEATVGQwIAYDVR0lAQH/BBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMEMB8GA1UdIwQYMBaAFLOriLyZ1WKkhSoIzbQdcjuDckdRMDwGA1UdHwQ1MDMwMaAvoC2GK2h0dHA6Ly93d3cuc2suZWUvY3Jscy9lc3RlaWQvZXN0ZWlkMjAxNS5jcmwwDQYJKoZIhvcNAQELBQADggIBAEJCCMR6IQ/Xncjyj9jtlFm1UBuf4g71GfSQMtL1g3ZxLBc77aKwBBtTuOTmPDUdY5+xA5dDfj6nFRdZZwWedps9Tm5T98G+y0477TCfP29UlnpQ55EtdXGdgzeDMlCpmRpsu2YceajdpQncp6A8tJsG+hW733O6W2dqrQV2e7Dnofm20KVHHlUa+ma+pGvaHDYXHgYAD1o58Ro+JHy7Bw3jo4Lg+j/CuBzTk4T6D05Ybnvv58/PBrPjASwKVhjNNvHYwgDmeGzQKocmDWUSRjTiWAxP9PYxwuiP2epoypyv9VPJEe3dy3EWgY+iPfMN2BuFdmZtKSHdWSeqCK+jro4kzjWrGYY+JbxSYmfF3GWWAj5sq/+/S2SgiFWGHdvtr1kVr7DaLDmz8N/QyrNjVVz4bYkzj9OPM7ofJs1QDE/2yGCkpJ628zB1ATpEjtVsit5hti0d3k1cIBTbtiUuSrOGHTyxwRenvIZ/MckvLFmTKZ6m255ASjtwqTf2Z+Jo13Adr5zhRvQvY+qGfZb/E4KhVSrcUI8OgTXzkPGIdm6tYETKqmdgevG16noPxzmzf6DJsjnbrOwuEhRykUzSRWO+h0pA7lvjLN8SKbCn/uWARN1XsbarA0yAnW484Gsu8psOaGpZ+VzM7z5/yv/BY2aTPP0hIeI0cqRtko7Zs6vH"; private static final String NEW_MOBILE_ID_CERT = "MIIFmDCCA4CgAwIBAgIQMwcy8Yggv2lfTTKLQOhNYTANBgkqhkiG9w0BAQsFADBjMQswCQYDVQQGEwJFRTEiMCAGA1UECgwZQVMgU2VydGlmaXRzZWVyaW1pc2tlc2t1czEXMBUGA1UEYQwOTlRSRUUtMTA3NDcwMTMxFzAVBgNVBAMMDkVTVEVJRC1TSyAyMDE1MB4XDTIwMDgzMTE3MjUzMVoXDTI1MDgzMTIwNTk1OVowcTELMAkGA1UEBhMCRUUxIzAhBgNVBAMMGkvDlVZFUlNBUixNQVRJLDM4MzA5MTQwNDIwMRIwEAYDVQQEDAlLw5VWRVJTQVIxDTALBgNVBCoMBE1BVEkxGjAYBgNVBAUTEVBOT0VFLTM4MzA5MTQwNDIwMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEJcgIjZEEIyIMGVli1xp88YlS8PnWBCcXdBiBTDNiVT/4OlTTteBBIePn2vKWOgUNDOWyFsBQRPy93Pig2thAzqOCAgMwggH/MAkGA1UdEwQCMAAwDgYDVR0PAQH/BAQDAgOIMHIGA1UdIARrMGkwXQYJKwYBBAHOHwEDMFAwLwYIKwYBBQUHAgEWI2h0dHBzOi8vd3d3LnNrLmVlL3JlcG9zaXRvb3JpdW0vQ1BTMB0GCCsGAQUFBwICMBEaD0NvbnRyYWN0IDEuMTEtOTAIBgYEAI96AQIwIQYDVR0RBBowGIEWbWF0aS5rb3ZlcnNhckBlZXN0aS5lZTAdBgNVHQ4EFgQUaNW6n29aMRbULPtIyvmRq50g8g8wHwYDVR0jBBgwFoAUs6uIvJnVYqSFKgjNtB1yO4NyR1EwagYIKwYBBQUHAQEEXjBcMCcGCCsGAQUFBzABhhtodHRwOi8vYWlhLnNrLmVlL2VzdGVpZDIwMTUwMQYIKwYBBQUHMAKGJWh0dHA6Ly9jLnNrLmVlL0VTVEVJRC1TS18yMDE1LmRlci5jcnQwYQYIKwYBBQUHAQMEVTBTMFEGBgQAjkYBBTBHMEUWP2h0dHBzOi8vc2suZWUvZW4vcmVwb3NpdG9yeS9jb25kaXRpb25zLWZvci11c2Utb2YtY2VydGlmaWNhdGVzLxMCRU4wPAYDVR0fBDUwMzAxoC+gLYYraHR0cDovL3d3dy5zay5lZS9jcmxzL2VzdGVpZC9lc3RlaWQyMDE1LmNybDANBgkqhkiG9w0BAQsFAAOCAgEAFvYmmaeNvh6vakizkv6HvXYXXDvTIVA7Z3WsXN9ZffvqiqM6wg9iIBiWjUE5qKh0sVa6qlxTHJ90keltgWsLkPbyCeJvsl7npI+/3NvHjwR83dHzmi8V21zLAVwg3nGpbklg2uTPAagQfRwa91D3+SPu/2Uo6a/vwJVTaMZP+PEHEkCmNuwPAECgnniNgcwlCK0mhNK9urDWewsDcZqRIi61QyQzHde9l0IUlTcI8nPzIma5f831xVXGqi98WQZpqfHsdPL14wwAP5UjszvDe3DOvx0eARhoSrm8MLj3Y9oN82oM0XBIc6uRw0KNp8lunHMIAL2b30ULJkXMLLdA/FK4KS2Mvlt+dO3x+tqKUGX4wrxPtNmWvvLFKfPpzjLKDl/JA6fBcD2LHcKSDMK8Jgcokl3tzI8zG0RKy3yDCpA+c3CP7pIDLBb0fpNzjUAtTS72mgAzRbFNXctN05uekYmThU2Z71MvUCw0JixN6G7DmiOe3cp9kA01f0RBlM76f2x6YmZ7XCdI0JNQm8SpyctVX/2Sbed0kbjpOV05CFEtWWYlBO3oHf7Sf0jqYrs3SN999MHHCg4wdsWUJiGuILyMJi+gQphID/PgjDW9qxLd0kqK1cBLKybwgBScdt5KZjrTKYWOSTwvh5FhFKVVwsCMeOh/+ojKWhX6uKhwMOQ="; - private static final String EXPIRED_RSA_CERT = "MIIE3DCCA8SgAwIBAgIQbLnhZj25xUtSW9CfBn46KjANBgkqhkiG9w0BAQUFADBkMQswCQYDVQQGEwJFRTEiMCAGA1UECgwZQVMgU2VydGlmaXRzZWVyaW1pc2tlc2t1czEXMBUGA1UEAwwORVNURUlELVNLIDIwMTExGDAWBgkqhkiG9w0BCQEWCXBraUBzay5lZTAeFw0xMzEwMTQxMTA4MTVaFw0xODEwMTEyMDU5NTlaMIGPMQswCQYDVQQGEwJFRTEPMA0GA1UECgwGRVNURUlEMRcwFQYDVQQLDA5hdXRoZW50aWNhdGlvbjEgMB4GA1UEAwwXS0lUVCxIRUlLS0ksMzc3MTIzMDAyNTUxDTALBgNVBAQMBEtJVFQxDzANBgNVBCoMBkhFSUtLSTEUMBIGA1UEBRMLMzc3MTIzMDAyNTUwggEjMA0GCSqGSIb3DQEBAQUAA4IBEAAwggELAoIBAQBopcNoApF/o+YyVcHaonVhCbUYfUhDtoP2VDOKXNytBNIFO5uEL86mMOcfTURfOssrpvQBVgKWgQ0wjhq09qkfPJM9NbPz0VytcsGARKSNcPh1BKgnUnfd0M6SwSl1rFl2zvbDBfZTMDtQbROS4eV1wBXwa8XeHqQmTOZK/4mv+6fj0q/LzPmxUHP/LJbyjm07MAVzTAGFvanICPdTY9YQUyNCtp+r8RxjNEk/FjVDi9zgER7Tg/v/VEnjUdZG4pLZXnV+4EsBcH2Y/XoPq3Ou0ts3IG02iz83UFR0o3TYQnHnW9fMwToJRQzS3Bnd+NZee+yZZNKOUvxmn8f4dsDdAgR3CME3o4IBWzCCAVcwCQYDVR0TBAIwADAOBgNVHQ8BAf8EBAMCBLAwUQYDVR0gBEowSDBGBgsrBgEEAc4fAQEDAzA3MBIGCCsGAQUFBwICMAYaBG5vbmUwIQYIKwYBBQUHAgEWFWh0dHA6Ly93d3cuc2suZWUvY3BzLzAfBgNVHREEGDAWgRRoZWlra2kua2l0dEBlZXN0aS5lZTAdBgNVHQ4EFgQUC8nhz1ziuRJnO6hJIBYthupzYkYwIAYDVR0lAQH/BBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMEMCIGCCsGAQUFBwEDBBYwFDAIBgYEAI5GAQEwCAYGBACORgEEMB8GA1UdIwQYMBaAFHtq8lVQXLjZegiHQa76ois9W1d2MEAGA1UdHwQ5MDcwNaAzoDGGL2h0dHA6Ly93d3cuc2suZWUvcmVwb3NpdG9yeS9jcmxzL2VzdGVpZDIwMTEuY3JsMA0GCSqGSIb3DQEBBQUAA4IBAQBV7ohEG05MXcxHEeXOb2hNuLrVVT2dhOVwp21M13sOsG9l/GN8KEUR4JQcJo4zEK49zHCaA1qKg+4/mubWfMKz5eUS9njs7cuit2FjlTJUrm7ye9dKndGiv5o4T4ycvbUF4NJ6AvxXZLolfLTaF6Ge/c15Jz1WmBv0x+0C00d0qWkE3VVjwqYxUw9gJlWfbLLxqsT1pUXaf9JcsxdKXkhKKr9eQ7r00PwbARkKyeU/ylHGfOQlZeGXfyWxX1q1ZALicwJe6/UbQTqQeLn5Mviw/49H2rLb9BImFIJ30QYBlj9SGSHSZ5k11XPRaw2GfLrgoBqOjMUyKhfRxqJwb/xL"; - private static final String EXPIRED_ECDSA_CERT = "MIIF0TCCA7mgAwIBAgIQMBVFXroEt3hZ8FHcKKE65TANBgkqhkiG9w0BAQsFADBjMQswCQYDVQQGEwJFRTEiMCAGA1UECgwZQVMgU2VydGlmaXRzZWVyaW1pc2tlc2t1czEXMBUGA1UEYQwOTlRSRUUtMTA3NDcwMTMxFzAVBgNVBAMMDkVTVEVJRC1TSyAyMDE1MB4XDTE3MTAyNTA4NTcwMFoXDTIxMDIxMDIxNTk1OVowgYsxCzAJBgNVBAYTAkVFMQ8wDQYDVQQKDAZFU1RFSUQxFzAVBgNVBAsMDmF1dGhlbnRpY2F0aW9uMR4wHAYDVQQDDBVUT09NLE1BUlQsMzc2MDIwNDAzMzQxDTALBgNVBAQMBFRPT00xDTALBgNVBCoMBE1BUlQxFDASBgNVBAUTCzM3NjAyMDQwMzM0MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAExS1YQQBDLVvOi0a2GA5Y34AXODpx0AL8eKDOB7BjwBc/FAyVExhfb6O+lT5Tnaec3GnT4JNRyeV8d82L8cyOgFn4PWc+5cjFdmcZjJbtCvgyBOQQ831tteIDL2XSrvZEo4ICBDCCAgAwCQYDVR0TBAIwADAOBgNVHQ8BAf8EBAMCA4gwUwYDVR0gBEwwSjA+BgkrBgEEAc4fAQEwMTAvBggrBgEFBQcCARYjaHR0cHM6Ly93d3cuc2suZWUvcmVwb3NpdG9vcml1bS9DUFMwCAYGBACPegECMB8GA1UdEQQYMBaBFG1hcnQudG9vbS4zQGVlc3RpLmVlMB0GA1UdDgQWBBSzneoLqtqbvHvJ19cjhp2XR5ovQTAgBgNVHSUBAf8EFjAUBggrBgEFBQcDAgYIKwYBBQUHAwQwHwYDVR0jBBgwFoAUs6uIvJnVYqSFKgjNtB1yO4NyR1EwYQYIKwYBBQUHAQMEVTBTMFEGBgQAjkYBBTBHMEUWP2h0dHBzOi8vc2suZWUvZW4vcmVwb3NpdG9yeS9jb25kaXRpb25zLWZvci11c2Utb2YtY2VydGlmaWNhdGVzLxMCRU4wagYIKwYBBQUHAQEEXjBcMCcGCCsGAQUFBzABhhtodHRwOi8vYWlhLnNrLmVlL2VzdGVpZDIwMTUwMQYIKwYBBQUHMAKGJWh0dHA6Ly9jLnNrLmVlL0VTVEVJRC1TS18yMDE1LmRlci5jcnQwPAYDVR0fBDUwMzAxoC+gLYYraHR0cDovL3d3dy5zay5lZS9jcmxzL2VzdGVpZC9lc3RlaWQyMDE1LmNybDANBgkqhkiG9w0BAQsFAAOCAgEAOXTvktUXqPgaK/uxzgH0xSEYClBAWIQaNgpqY5lwsQtgQnpfKlsADqMZxCp7UuuMvQmpDbBxv1kIr0oG1uUXrUtPw81XOH1ClwPPXWpg9VRTAetNbHTBbHDyzuXQMNeDmrntChs+BteletejGD+aYG39HGMlrMbGQZOgvQrpYHMDek0ckCPEsZRXqUP0g7Ie7uBQhz5At7l4EDAeOW8xGoI6t+Ke4GedccXKef60w2ZIIDzvOFHPTc6POCsIlFtF/nCKwVi7GoQKjbUbM5OdBLZ0jyLq2LvzZuT86Jo8wObziuSzApGlBexHAqLrR83q+/Xl61yPnFf3w2kAfS9kBjeunzTH7Jm3pNT3Zq9JRLvEDqtpOPqr4zm9nG6OSghFU6tySkpQ5HiakGpMcnt5o5KuXhQ+Dg317tdXPyQkSiuJ9NfEBW0ijrwO12SVRzYo/jRl4ZQUkAEEUSMEsC6gTsZypPdIsLDVoQWTytHDU89s1xJDn4HulPl12dFnrhlLeX4RxOjDxppZxdjBU0FoJoDB0qwEAN2TMAPJWh+Pp9mFuS/u0dht9sKvAkpx+o0Z7v7QMz03XlzCHOLTIK+f81Rjokl8f+wiog5Ojj0wZkDe6DuQC9L5uDey3PJHv3naVovhs7jrEJu+yrsLue/OHhAgWRh2S75/wlVPHPEE44k="; private static final String REVOKED_CERT = "MIIERDCCA6agAwIBAgIQSs8/WoDixVxbKRhNnF/GEzAKBggqhkjOPQQDBDBgMQswCQYDVQQGEwJFRTEbMBkGA1UECgwSU0sgSUQgU29sdXRpb25zIEFTMRcwFQYDVQRhDA5OVFJFRS0xMDc0NzAxMzEbMBkGA1UEAwwSVEVTVCBvZiBFU1RFSUQyMDE4MB4XDTE4MDYxOTE0NTA1M1oXDTIwMDEwMjIxNTk1OVowfzELMAkGA1UEBhMCRUUxKjAoBgNVBAMMIUrDlUVPUkcsSkFBSy1LUklTVEpBTiwzODAwMTA4NTcxODEQMA4GA1UEBAwHSsOVRU9SRzEWMBQGA1UEKgwNSkFBSy1LUklTVEpBTjEaMBgGA1UEBRMRUE5PRUUtMzgwMDEwODU3MTgwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAR/jopNG3KL0ZQUvO4OGSvcaqUtFDm3azOtsM2VRp666r0d36Zh0Zx/xej8f+SzEfWvvDT1HQLo3USiSbYn1FyNHTNxifV+Zvf6StXJAkdu24d1UvKbf+LylglO/yS7o4ijggIEMIICADAJBgNVHRMEAjAAMA4GA1UdDwEB/wQEAwIDiDBHBgNVHSAEQDA+MDIGCysGAQQBg5F/AQIBMCMwIQYIKwYBBQUHAgEWFWh0dHBzOi8vd3d3LnNrLmVlL0NQUzAIBgYEAI96AQIwHwYDVR0RBBgwFoEUMzgwMDEwODU3MThAZWVzdGkuZWUwHQYDVR0OBBYEFEQA6/1GXJtp+6czUzorhEJ7B95pMGEGCCsGAQUFBwEDBFUwUzBRBgYEAI5GAQUwRzBFFj9odHRwczovL3NrLmVlL2VuL3JlcG9zaXRvcnkvY29uZGl0aW9ucy1mb3ItdXNlLW9mLWNlcnRpZmljYXRlcy8TAkVOMCAGA1UdJQEB/wQWMBQGCCsGAQUFBwMCBggrBgEFBQcDBDAfBgNVHSMEGDAWgBTAhJkpxE6fOwI09pnhClYACCk+ezB/BggrBgEFBQcBAQRzMHEwLAYIKwYBBQUHMAGGIGh0dHA6Ly9haWEuZGVtby5zay5lZS9lc3RlaWQyMDE4MEEGCCsGAQUFBzAChjVodHRwczovL3NrLmVlL3VwbG9hZC9maWxlcy9URVNUX29mX0VTVEVJRDIwMTguZGVyLmNydDAzBgNVHR8ELDAqMCigJqAkhiJodHRwOi8vYy5zay5lZS90ZXN0X2VzdGVpZDIwMTguY3JsMAoGCCqGSM49BAMEA4GLADCBhwJBcmcfLC+HcSJ6BuRrDGL+K+7BAW8BfAiiWWAuBV4ebLkbbAWmkc9dSKgr4BEGEt90xDTQ85yW4SjGulFXu9C3yQsCQgETaXTs3Hp6vDAcQYL8Bx4BO3DwJbDuD4BUJyT0+9HQiFCQmTQ4xrNjeaeOwRWyMOM9z5ORMeJCiQUyil1x4YPIbg=="; private MockedStatic mockedClock; @@ -161,13 +161,13 @@ void whenCertificatePolicyIsWrong_thenValidationFails() throws AuthTokenExceptio } @Test - void whenCertificatePoliciesExtensionIsMissing_thenValidationContinuesToTrustCheck() throws Exception { + void whenCertificatePoliciesExtensionIsMissing_thenValidationContinuesToSignatureCheck() throws Exception { final String certificateWithoutPolicies = Base64.getEncoder() .encodeToString(getCertificateWithoutCertificatePolicies().getEncoded()); final WebEidAuthToken token = replaceTokenField(AUTH_TOKEN, "X5C", certificateWithoutPolicies); assertThatThrownBy(() -> validator .validate(token, VALID_CHALLENGE_NONCE)) - .isInstanceOf(CertificateNotTrustedException.class); + .isInstanceOf(AuthTokenSignatureValidationException.class); } @Test @@ -196,19 +196,18 @@ void whenUsingNewMobileIdCertificate_thenValidationFails() throws AuthTokenExcep } @Test - void whenCertificateIsExpiredRsa_thenValidationFails() throws AuthTokenException { - final WebEidAuthToken token = replaceTokenField(AUTH_TOKEN, "X5C", EXPIRED_RSA_CERT); - assertThatThrownBy(() -> validator - .validate(token, VALID_CHALLENGE_NONCE)) + void whenCertificateIsExpiredRsa_thenValidationFails() throws Exception { + mockDate("2099-01-01", mockedClock); + final WebEidAuthToken token = validator.parse(VALID_RS256_AUTH_TOKEN); + assertThatThrownBy(() -> validator.validate(token, VALID_CHALLENGE_NONCE)) .isInstanceOf(CertificateExpiredException.class) .hasMessage("User certificate has expired"); } @Test - void whenCertificateIsExpiredEcdsa_thenValidationFails() throws AuthTokenException { - final WebEidAuthToken token = replaceTokenField(AUTH_TOKEN, "X5C", EXPIRED_ECDSA_CERT); - assertThatThrownBy(() -> validator - .validate(token, VALID_CHALLENGE_NONCE)) + void whenCertificateIsExpiredEcdsa_thenValidationFails() throws Exception { + mockDate("2099-01-01", mockedClock); + assertThatThrownBy(() -> validator.validate(validAuthToken, VALID_CHALLENGE_NONCE)) .isInstanceOf(CertificateExpiredException.class) .hasMessage("User certificate has expired"); } diff --git a/src/test/java/eu/webeid/security/validator/AuthTokenSignatureValidatorTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenSignatureValidatorTest.java index 5e294d53..dcbd0baa 100644 --- a/src/test/java/eu/webeid/security/validator/AuthTokenSignatureValidatorTest.java +++ b/src/test/java/eu/webeid/security/validator/AuthTokenSignatureValidatorTest.java @@ -5,20 +5,25 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectReader; +import eu.webeid.security.authtoken.WebEidAuthToken; import eu.webeid.security.certificate.CertificateLoader; +import eu.webeid.security.exceptions.AuthTokenException; import eu.webeid.security.exceptions.AuthTokenParseException; import eu.webeid.security.exceptions.AuthTokenSignatureValidationException; import io.jsonwebtoken.security.SignatureException; import org.junit.jupiter.api.Test; -import eu.webeid.security.authtoken.WebEidAuthToken; import java.net.URI; +import java.security.KeyPairGenerator; +import java.security.PublicKey; import java.security.cert.X509Certificate; +import java.security.spec.ECGenParameterSpec; +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.AbstractTestWithValidator.VALID_RS256_AUTH_TOKEN; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static eu.webeid.security.validator.AuthTokenSignatureTest.VALID_AUTH_TOKEN; -import static eu.webeid.security.validator.AuthTokenSignatureTest.VALID_CHALLENGE_NONCE; class AuthTokenSignatureValidatorTest { @@ -46,12 +51,6 @@ void whenRsaSignatureHasInvalidLength_thenThrowsSignatureValidationExceptionWith .hasCauseInstanceOf(SignatureException.class); } - private static final String VALID_RS256_AUTH_TOKEN = "{\"algorithm\":\"RS256\"," + - "\"unverifiedCertificate\":\"MIIGvjCCBKagAwIBAgIQT7aXeR+zWlBb2Gbar+AFaTANBgkqhkiG9w0BAQsFADCBgzELMAkGA1UEBhMCTFYxOTA3BgNVBAoMMFZBUyBMYXR2aWphcyBWYWxzdHMgcmFkaW8gdW4gdGVsZXbEq3ppamFzIGNlbnRyczEaMBgGA1UEYQwRTlRSTFYtNDAwMDMwMTEyMDMxHTAbBgNVBAMMFERFTU8gTFYgZUlEIElDQSAyMDE3MB4XDTE4MTAzMDE0MTI0MloXDTIzMTAzMDE0MTI0MlowcDELMAkGA1UEBhMCTFYxHDAaBgNVBAMME0FORFJJUyBQQVJBVURaScWFxaAxFTATBgNVBAQMDFBBUkFVRFpJxYXFoDEPMA0GA1UEKgwGQU5EUklTMRswGQYDVQQFExJQTk9MVi0zMjE5MjItMzMwMzIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDXkra3rDOOt5K6OnJcg/Xt6JOogPAUBX2kT9zWelze7WSuPx2Ofs//0JoBQ575IVdh3JpLhfh7g60YYi41M6vNACVSNaFOxiEvE9amSFizMiLk5+dp+79rymqOsVQG8CSu8/RjGGlDsALeb3N/4pUSTGXUwSB64QuFhOWjAcmKPhHeYtry0hK3MbwwHzFhYfGpo/w+PL14PEdJlpL1UX/aPyT0Zq76Z4T/Z3PqbTmQp09+2b0thC0JIacSkyJuTu8fVRQvse+8UtYC6Kt3TBLZbPtqfAFSXWbuE47Lc2o840NkVlMHVAesoRAfiQxsK35YWFT0rHPWbLjX6ySiaL25AgMBAAGjggI+MIICOjAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAjAdBgNVHQ4EFgQUHZWimPze2GXULNaP4EFVdF+MWKQwHwYDVR0jBBgwFoAUj2jOvOLHQCFTCUK75Z4djEvNvTgwgfsGA1UdIASB8zCB8DA7BgYEAI96AQIwMTAvBggrBgEFBQcCARYjaHR0cHM6Ly93d3cuZXBhcmFrc3RzLmx2L3JlcG9zaXRvcnkwgbAGDCsGAQQBgfo9AgECATCBnzAvBggrBgEFBQcCARYjaHR0cHM6Ly93d3cuZXBhcmFrc3RzLmx2L3JlcG9zaXRvcnkwbAYIKwYBBQUHAgIwYAxexaBpcyBzZXJ0aWZpa8SBdHMgaXIgaWVrxLxhdXRzIExhdHZpamFzIFJlcHVibGlrYXMgaXpzbmllZ3TEgSBwZXJzb251IGFwbGllY2lub8WhxIEgZG9rdW1lbnTEgTB9BggrBgEFBQcBAQRxMG8wQgYIKwYBBQUHMAKGNmh0dHA6Ly9kZW1vLmVwYXJha3N0cy5sdi9jZXJ0L2RlbW9fTFZfZUlEX0lDQV8yMDE3LmNydDApBggrBgEFBQcwAYYdaHR0cDovL29jc3AucHJlcC5lcGFyYWtzdHMubHYwSAYDVR0fBEEwPzA9oDugOYY3aHR0cDovL2RlbW8uZXBhcmFrc3RzLmx2L2NybC9kZW1vX0xWX2VJRF9JQ0FfMjAxN18zLmNybDANBgkqhkiG9w0BAQsFAAOCAgEAAOVoRbnMv2UXWYHgnmO9Zg9u8F1YvJiZPMeTYE2CVaiq0nXe4Mq0X5tWcsEiRpGQF9e0dWC6V5m6EmAsHxIRL4chZKRrIrPEiWtP3zyRI1/X2y5GwSUyZmgxkuSOHHw3UjzjrnOoI9izpC0OSNeumqpjT/tLAi35sktGkK0onEUPWGQnZLqd/hzykm+H/dmD27nOnfCJOSqbegLSbhV2w/WAII+IUD3vJ06F6rf9ZN8xbrGkPO8VMCIDIt0eBKFxBdSOgpsTfbERbjQJ+nFEDYhD0bFNYMsFSGnZiWpNaCcZSkk4mtNUa8sNXyaFQGIZk6NjQ/fsBANhUoxFz7rUKrRYqk356i8KFDZ+MJqUyodKKyW9oz+IO5eJxnL78zRbxD+EfAUmrLXOjmGIzU95RR1smS4cirrrPHqGAWojBk8hKbjNTJl9Tfbnsbc9/FUBJLVZAkCi631KfRLQ66bn8N0mbtKlNtdX0G47PXTy7SJtWwDtKQ8+qVpduc8xHLntbdAzie3mWyxA1SBhQuZ9BPf5SPBImWCNpmZNCTmI2e+4yyCnmG/kVNilUAaODH/fgQXFGdsKO/XATFohiies28twkEzqtlVZvZbpBhbJCHYVnQXMhMKcnblkDqXWcSWd3QAKig2yMH95uz/wZhiV+7tZ7cTgwcbCzIDCfpwBC3E=\"," + - "\"issuerApp\":\"https://web-eid.eu/web-eid-app/releases/2.0.0+0\"," + - "\"signature\":\"xsjXsQvVYXWcdV0YPhxLthJxtf0//R8p9WFFlYJGRARrl1ruyoAUwl0xeHgeZOKeJtwiCYCNWJzCG3VM3ydgt92bKhhk1u0JXIPVqvOkmDY72OCN4q73Y8iGSPVTgjk93TgquHlodf7YcqZNhutwNNf3oldHEWJD5zmkdwdpBFXgeOwTAdFwGljDQZbHr3h1Dr+apUDuloS0WuIzUuu8YXN2b8lh8FCTlF0G0DEjhHd/MGx8dbe3UTLHmD7K9DXv4zLJs6EF9i2v/C10SIBQDkPBSVPqMxCDPECjbEPi2+ds94eU7ThOhOQlFFtJ4KjQNTUa2crSixH7cYZF2rNNmA==\"," + - "\"format\":\"web-eid:1.0\"}"; - @Test void whenValidES384Signature_thenSucceeds() throws Exception { final AuthTokenSignatureValidator signatureValidator = @@ -78,4 +77,39 @@ void whenValidRS256Signature_thenSucceeds() throws Exception { .doesNotThrowAnyException(); } + @Test + void whenRsaKeyIsTooShortForAlgorithm_thenThrowsAuthTokenSignatureValidationException() throws Exception { + final AuthTokenSignatureValidator signatureValidator = + new AuthTokenSignatureValidator(URI.create("https://ria.ee")); + + final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(1024); + final PublicKey weakPublicKey = keyPairGenerator.generateKeyPair().getPublic(); + + final WebEidAuthToken authToken = OBJECT_READER.readValue(VALID_RS256_AUTH_TOKEN); + + // The public key comes from the unverified certificate of the token, so a key that JJWT refuses + // to use must fail with a checked AuthTokenException, not with an unchecked JJWT exception. + assertThatThrownBy(() -> signatureValidator + .validate("RS256", authToken.signature(), weakPublicKey, VALID_CHALLENGE_NONCE)) + .isInstanceOf(AuthTokenSignatureValidationException.class); + } + + @Test + void whenEcKeyDoesNotMatchAlgorithm_thenThrowsAuthTokenSignatureValidationException() throws Exception { + final AuthTokenSignatureValidator signatureValidator = + new AuthTokenSignatureValidator(URI.create("https://ria.ee")); + + final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC"); + keyPairGenerator.initialize(new ECGenParameterSpec("secp256r1")); + final PublicKey p256PublicKey = keyPairGenerator.generateKeyPair().getPublic(); + + final WebEidAuthToken authToken = OBJECT_READER.readValue(VALID_AUTH_TOKEN); + + // ES512 requires a P-521 key, so JJWT rejects the P-256 key of the unverified certificate. + assertThatThrownBy(() -> signatureValidator + .validate("ES512", authToken.signature(), p256PublicKey, VALID_CHALLENGE_NONCE)) + .isInstanceOf(AuthTokenException.class); + } + } diff --git a/src/test/java/eu/webeid/security/validator/AuthTokenValidatorValidationOrderTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenValidatorValidationOrderTest.java new file mode 100644 index 00000000..a9b3515e --- /dev/null +++ b/src/test/java/eu/webeid/security/validator/AuthTokenValidatorValidationOrderTest.java @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.security.validator; + +import eu.webeid.security.authtoken.WebEidAuthToken; +import eu.webeid.security.certificate.CertificateData; +import eu.webeid.security.exceptions.AuthTokenSignatureValidationException; +import eu.webeid.security.testutil.AuthTokenValidators; +import eu.webeid.security.util.DateAndTime; +import eu.webeid.security.validator.revocationcheck.CertificateRevocationChecker; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +import java.security.cert.X509Certificate; +import java.util.List; + +import static eu.webeid.security.testutil.AbstractTestWithValidator.VALID_AUTH_TOKEN; +import static eu.webeid.security.testutil.AbstractTestWithValidator.VALID_AUTH_TOKEN_TEST_DATE; +import static eu.webeid.security.testutil.AbstractTestWithValidator.VALID_CHALLENGE_NONCE; +import static eu.webeid.security.testutil.DateMocker.mockDate; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class AuthTokenValidatorValidationOrderTest { + + private MockedStatic mockedClock; + + @BeforeEach + void setUp() { + mockedClock = mockStatic(DateAndTime.DefaultClock.class); + // Ensure that the certificates do not expire. + mockDate(VALID_AUTH_TOKEN_TEST_DATE, mockedClock); + } + + @AfterEach + void tearDown() { + mockedClock.close(); + } + + @Test + void whenSignatureIsInvalid_thenRevocationCheckerIsNotInvoked() throws Exception { + CertificateRevocationChecker checker = mock(CertificateRevocationChecker.class); + AuthTokenValidator validator = AuthTokenValidators.getDefaultAuthTokenValidatorBuilder() + .withCertificateRevocationChecker(checker) + .build(); + WebEidAuthToken token = validator.parse(VALID_AUTH_TOKEN); + + assertThatThrownBy(() -> validator.validate(token, "invalidToken")) + .isInstanceOf(AuthTokenSignatureValidationException.class); + verify(checker, never()).validateCertificateNotRevoked(any(), any()); + } + + @Test + void whenSignatureIsValid_thenRevocationCheckerIsInvoked() throws Exception { + CertificateRevocationChecker checker = mock(CertificateRevocationChecker.class); + when(checker.validateCertificateNotRevoked(any(), any())).thenReturn(List.of()); + AuthTokenValidator validator = AuthTokenValidators.getDefaultAuthTokenValidatorBuilder() + .withCertificateRevocationChecker(checker) + .build(); + WebEidAuthToken token = validator.parse(VALID_AUTH_TOKEN); + + assertThatCode(() -> validator.validate(token, VALID_CHALLENGE_NONCE)) + .doesNotThrowAnyException(); + ArgumentCaptor subjectCaptor = ArgumentCaptor.forClass(X509Certificate.class); + verify(checker).validateCertificateNotRevoked(subjectCaptor.capture(), any()); + assertThat(CertificateData.getSubjectCN(subjectCaptor.getValue()).orElseThrow()) + .isEqualTo("JÕEORG\\,JAAK-KRISTJAN\\,38001085718"); + } +} diff --git a/src/test/resources/DEMO_of_ESTEID-SK_2018_AIA_OCSP_RESPONDER_2018.cer b/src/test/resources/DEMO_of_ESTEID-SK_2018_AIA_OCSP_RESPONDER_2018.cer new file mode 100644 index 00000000..30ce32b0 Binary files /dev/null and b/src/test/resources/DEMO_of_ESTEID-SK_2018_AIA_OCSP_RESPONDER_2018.cer differ diff --git a/src/test/resources/TEST_of_EE_Certification_Centre_Root_CA.cer b/src/test/resources/TEST_of_EE_Certification_Centre_Root_CA.cer new file mode 100644 index 00000000..f51a8580 Binary files /dev/null and b/src/test/resources/TEST_of_EE_Certification_Centre_Root_CA.cer differ diff --git a/src/test/resources/TEST_of_self-signed_OCSP_RESPONDER.cer b/src/test/resources/TEST_of_self-signed_OCSP_RESPONDER.cer new file mode 100644 index 00000000..86fbcab1 Binary files /dev/null and b/src/test/resources/TEST_of_self-signed_OCSP_RESPONDER.cer differ diff --git a/src/test/resources/ocsp_response_unknown_self_signed.der b/src/test/resources/ocsp_response_unknown_self_signed.der new file mode 100644 index 00000000..9dee6fc4 Binary files /dev/null and b/src/test/resources/ocsp_response_unknown_self_signed.der differ