diff --git a/README.md b/README.md index f4c5105a..527d2f6d 100644 --- a/README.md +++ b/README.md @@ -322,6 +322,8 @@ The default validator uses the platform OCSP implementation to check certificate Use `withDisallowedCertificatePolicies(ASN1ObjectIdentifier... policies)` to add disallowed certificate policies. Estonian Mobile-ID policies are disallowed by default because smart-card authentication must not accept Mobile-ID certificates. +The user certificate must have the Digital Signature key usage by default. Use `withDigitalSignatureKeyUsageRequired(false)` to accept authentication certificates that do not assert it; the Key Usage extension must still be present, and an Extended Key Usage extension, if present, must still contain client authentication. + For more advanced revocation requirements, supply a `CertificateRevocationChecker` with `withCertificateRevocationChecker(...)`. The [OCSP configuration guide](src/main/java/eu/webeid/ocsp/README.md) covers custom implementations, the bundled OCSP checker, custom PKIX checkers, responder selection, HTTP settings, and nonce policies. ## Possible validation errors 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..84482abf 100644 --- a/src/main/java/eu/webeid/ocsp/README.md +++ b/src/main/java/eu/webeid/ocsp/README.md @@ -75,14 +75,15 @@ 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 ); 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 +95,14 @@ 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). 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 +114,9 @@ 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 ); 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..0f0f23ff 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,57 @@ 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) throws OCSPCertificateException { + Objects.requireNonNull(certificate, "certificate"); + 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 +102,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 +113,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 +133,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..230dd5fa 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,10 @@ 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; - 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 +49,8 @@ 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(); } @Override @@ -58,6 +63,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 +84,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); + 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 +109,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..90687639 100644 --- a/src/main/java/eu/webeid/ocsp/service/AiaOcspServiceConfiguration.java +++ b/src/main/java/eu/webeid/ocsp/service/AiaOcspServiceConfiguration.java @@ -7,20 +7,27 @@ 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; - public AiaOcspServiceConfiguration(Collection nonceDisabledIssuerDNs, Set trustedCACertificateAnchors, CertStore trustedCACertificateCertStore) { + public AiaOcspServiceConfiguration(Collection nonceDisabledIssuerDNs, Set trustedCACertificateAnchors, CertStore trustedCACertificateCertStore, Duration maxThisUpdateAge, Duration maxNextUpdateAge) { 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"); } public Collection getNonceDisabledIssuerDNs() { @@ -35,4 +42,11 @@ public CertStore getTrustedCACertificateCertStore() { return trustedCACertificateCertStore; } + public Duration getMaxThisUpdateAge() { + return maxThisUpdateAge; + } + + public Duration getMaxNextUpdateAge() { + return maxNextUpdateAge; + } } 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..642fc722 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,21 @@ 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 * @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) 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); + OcspResponseValidator.validateKeyUsageNotCertificateSigning(responderCertificate); + OcspResponseValidator.validateExtendedKeyUsageOcspSigning(responderCertificate); this.doesSupportNonce = doesSupportNonce; + this.maxThisUpdateAge = requirePositiveDuration(maxThisUpdateAge, "maxThisUpdateAge"); + this.maxNextUpdateAge = requirePositiveDuration(maxNextUpdateAge, "maxNextUpdateAge"); } public URI getOcspServiceAccessLocation() { @@ -48,6 +60,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..d341fa45 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,8 @@ 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; public FallbackOcspService(FallbackOcspServiceConfiguration configuration) { this.url = configuration.getAccessLocation(); @@ -44,6 +47,8 @@ public FallbackOcspService(FallbackOcspServiceConfiguration configuration) { : null; this.trustedCACertificateAnchors = configuration.getTrustedCACertificateAnchors(); this.trustedCACertificateCertStore = configuration.getTrustedCACertificateCertStore(); + this.maxThisUpdateAge = configuration.getMaxThisUpdateAge(); + this.maxNextUpdateAge = configuration.getMaxNextUpdateAge(); } @Override @@ -56,6 +61,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 +91,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 +104,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); + 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..f9c2e732 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,30 @@ public class FallbackOcspServiceConfiguration { private final X509Certificate issuerCertificate; private final Set trustedCACertificateAnchors; private final CertStore trustedCACertificateCertStore; + private final Duration maxThisUpdateAge; + private final Duration maxNextUpdateAge; public FallbackOcspServiceConfiguration(URI accessLocation, X509Certificate responderCertificate, boolean doesSupportNonce, FallbackOcspServiceConfiguration nextFallbackConfiguration, X509Certificate issuerCertificate, Set trustedCACertificateAnchors, - CertStore trustedCACertificateCertStore) throws OCSPCertificateException { + CertStore trustedCACertificateCertStore, + Duration maxThisUpdateAge, Duration maxNextUpdateAge) 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); + 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"); } public URI getAccessLocation() { @@ -67,4 +78,12 @@ public Set getTrustedCACertificateAnchors() { public CertStore getTrustedCACertificateCertStore() { return trustedCACertificateCertStore; } + + public Duration getMaxThisUpdateAge() { + return maxThisUpdateAge; + } + + public Duration getMaxNextUpdateAge() { + return maxNextUpdateAge; + } } 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/AuthTokenValidationConfiguration.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java index 4dbfcb54..786ba375 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java @@ -31,6 +31,7 @@ public final class AuthTokenValidationConfiguration { SubjectCertificatePolicies.ESTEID_SK_2015_MOBILE_ID_POLICY_V3, SubjectCertificatePolicies.ESTEID_SK_2015_MOBILE_ID_POLICY )); + private boolean isDigitalSignatureKeyUsageRequired = true; private boolean isUserCertificateRevocationCheckEnabled = true; private boolean platformOcspNonceEnabled = true; private CertificateRevocationChecker certificateRevocationChecker; @@ -44,6 +45,7 @@ private AuthTokenValidationConfiguration(AuthTokenValidationConfiguration other) this.siteOrigin = other.siteOrigin; this.trustedCACertificates = Set.copyOf(other.trustedCACertificates); this.disallowedSubjectCertificatePolicies = Set.copyOf(other.disallowedSubjectCertificatePolicies); + this.isDigitalSignatureKeyUsageRequired = other.isDigitalSignatureKeyUsageRequired; this.isUserCertificateRevocationCheckEnabled = other.isUserCertificateRevocationCheckEnabled; this.platformOcspNonceEnabled = other.platformOcspNonceEnabled; this.certificateRevocationChecker = other.certificateRevocationChecker; @@ -67,6 +69,14 @@ public Collection getDisallowedSubjectCertificatePolicies( return disallowedSubjectCertificatePolicies; } + boolean isDigitalSignatureKeyUsageRequired() { + return isDigitalSignatureKeyUsageRequired; + } + + void setDigitalSignatureKeyUsageRequired(boolean required) { + isDigitalSignatureKeyUsageRequired = required; + } + boolean isUserCertificateRevocationCheckEnabled() { return isUserCertificateRevocationCheckEnabled; } diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java index d072979f..8418a5a3 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java @@ -74,6 +74,25 @@ public AuthTokenValidatorBuilder withDisallowedCertificatePolicies(ASN1ObjectIde return this; } + /** + * Controls whether the user certificate must have the Digital Signature bit set in its Key Usage extension. + * Enabled by default. + *

+ * Disable this only for eID schemes whose authentication certificates do not assert Digital Signature key usage. + * The Key Usage extension must still be present, and if the Extended Key Usage extension is present, + * it must still contain client authentication. + * + * @param required whether the Digital Signature key usage is required + * @return the builder instance for method chaining + */ + public AuthTokenValidatorBuilder withDigitalSignatureKeyUsageRequired(boolean required) { + configuration.setDigitalSignatureKeyUsageRequired(required); + if (!required) { + LOG.warn("Digital Signature key usage requirement for the user certificate is disabled"); + } + return this; + } + /** * Turns off user certificate revocation check (with OCSP and/or CRL). *

diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java index afae8093..3d6aa8e0 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java @@ -41,6 +41,7 @@ final class AuthTokenValidatorImpl implements AuthTokenValidator { private final Set trustedCACertificateAnchors; private final CertStore trustedCACertificateCertStore; private final AuthTokenSignatureValidator authTokenSignatureValidator; + private final SubjectCertificatePurposeValidator subjectCertificatePurposeValidator; private final SubjectCertificatePolicyValidator subjectCertificatePolicyValidator; /** @@ -54,6 +55,7 @@ final class AuthTokenValidatorImpl implements AuthTokenValidator { trustedCACertificateAnchors = CertificateValidator.buildTrustAnchorsFromCertificates(configuration.getTrustedCACertificates()); trustedCACertificateCertStore = CertificateValidator.buildCertStoreFromCertificates(configuration.getTrustedCACertificates()); + subjectCertificatePurposeValidator = new SubjectCertificatePurposeValidator(configuration.isDigitalSignatureKeyUsageRequired()); subjectCertificatePolicyValidator = new SubjectCertificatePolicyValidator(configuration.getDisallowedSubjectCertificatePolicies()); authTokenSignatureValidator = new AuthTokenSignatureValidator(configuration.getSiteOrigin()); @@ -115,12 +117,22 @@ private ValidationInfo validateToken(WebEidAuthToken token, String currentChalle } final X509Certificate subjectCertificate = CertificateLoader.decodeCertificateFromBase64(token.unverifiedCertificate()); - SubjectCertificatePurposeValidator.validateCertificatePurpose(subjectCertificate); + subjectCertificatePurposeValidator.validateCertificatePurpose(subjectCertificate); subjectCertificatePolicyValidator.validateCertificatePolicies(subjectCertificate); // 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 +145,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/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePurposeValidator.java b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePurposeValidator.java index ffacf2b0..6910e3b7 100644 --- a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePurposeValidator.java +++ b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePurposeValidator.java @@ -19,19 +19,26 @@ public final class SubjectCertificatePurposeValidator { private static final Logger LOG = LoggerFactory.getLogger(SubjectCertificatePurposeValidator.class); private static final int KEY_USAGE_DIGITAL_SIGNATURE = 0; private static final String EXTENDED_KEY_USAGE_CLIENT_AUTHENTICATION = "1.3.6.1.5.5.7.3.2"; + + private final boolean isDigitalSignatureKeyUsageRequired; + + public SubjectCertificatePurposeValidator(boolean isDigitalSignatureKeyUsageRequired) { + this.isDigitalSignatureKeyUsageRequired = isDigitalSignatureKeyUsageRequired; + } + /** * Validates that the purpose of the user certificate from the authentication token contains client authentication. * * @param subjectCertificate user certificate to be validated * @throws AuthTokenException when the purpose of certificate does not contain client authentication */ - public static void validateCertificatePurpose(X509Certificate subjectCertificate) throws AuthTokenException { + public void validateCertificatePurpose(X509Certificate subjectCertificate) throws AuthTokenException { try { final boolean[] keyUsage = subjectCertificate.getKeyUsage(); if (keyUsage == null) { throw new UserCertificateMissingPurposeException(); } - if (!keyUsage[KEY_USAGE_DIGITAL_SIGNATURE]) { + if (isDigitalSignatureKeyUsageRequired && !keyUsage[KEY_USAGE_DIGITAL_SIGNATURE]) { throw new UserCertificateWrongPurposeException(); } final List usages = subjectCertificate.getExtendedKeyUsage(); @@ -49,9 +56,4 @@ public static void validateCertificatePurpose(X509Certificate subjectCertificate } LOG.debug("User certificate can be used for client authentication."); } - - private SubjectCertificatePurposeValidator() { - throw new IllegalStateException("Functional class"); - } - } diff --git a/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerNetworkTest.java b/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerNetworkTest.java index 0916071f..2c6b301d 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)); 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)), + 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)); 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..8a53653b 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 in the past" - + " (OCSP responder: https://example.org)"); + + "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 '" + 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,189 @@ 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)).doesNotThrowAnyException(); + } + + @Test + void whenKeyUsageExtensionAbsent_thenKeyUsageValidationThrows() throws Exception { + final X509Certificate cert = buildCertificate(); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> validateKeyUsageDigitalSignature(cert)) + .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)) + .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)) + .withMessage("Certificate " + cert.getSubjectX500Principal() + + " Key Usage extension does not contain Digital Signature, which is required for OCSP response signing"); + } + + @Test + void whenCertIsNull_thenKeyUsageValidationThrowsNullPointerException() { + assertThatNullPointerException().isThrownBy(() -> validateKeyUsageDigitalSignature(null)) + .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..e94703f6 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); 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..e010ad08 --- /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); + } + + @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); + 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..960f1a72 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)) + .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)) + .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)) + .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)) + .withMessage("trustedCACertificateCertStore"); + } + + @Test + void whenMaxThisUpdateAgeIsNull_thenThrows() { + assertThatNullPointerException() + .isThrownBy(() -> new FallbackOcspServiceConfiguration( + FALLBACK_URI, null, true, null, getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore, + null, DEFAULT_NEXT_UPDATE_AGE)) + .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)) + .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)) + .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)) + .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)) + .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))) + .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)) + .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)) + .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)) + .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); + + FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( + FALLBACK_URI, aiaOcspResponderCert, true, nextFallback, + getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore, + Duration.ofMinutes(3), Duration.ofMinutes(4)); + + 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); 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..eae6696a --- /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); + + 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); + FallbackOcspServiceConfiguration primaryConfiguration = new FallbackOcspServiceConfiguration( + PRIMARY_FALLBACK_URI, null, true, + secondaryConfiguration, getTestEsteid2018CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE); + + 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); + 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); + 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); + 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); + 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); + 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); + 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); + 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); + 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..7470a4a5 --- /dev/null +++ b/src/test/java/eu/webeid/ocsp/service/OcspServiceConfigurationTest.java @@ -0,0 +1,136 @@ +// 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.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"); + } + + 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); + } + + 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); + } + +} diff --git a/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java b/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java index 57417604..46a4b2b5 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); } 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); } } diff --git a/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java b/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java index e9be3911..d3126803 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); 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); 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); 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); 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); 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); 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); + 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); + + 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); + + 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); + + 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); + FallbackOcspServiceConfiguration lastConfiguration = new FallbackOcspServiceConfiguration( + lastFallbackUri, getDemoEsteidSk2018AiaOcspResponder(), true, + null, getTestEsteid2018CA(), trustedAnchors, trustedStore, + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, + OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE); + + 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); + } } // 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/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePurposeValidatorTest.java b/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePurposeValidatorTest.java new file mode 100644 index 00000000..20404132 --- /dev/null +++ b/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePurposeValidatorTest.java @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.security.validator.certvalidators; + +import eu.webeid.security.exceptions.UserCertificateMissingPurposeException; +import eu.webeid.security.exceptions.UserCertificateWrongPurposeException; +import org.junit.jupiter.api.Test; + +import java.security.cert.X509Certificate; +import java.util.List; + +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 SubjectCertificatePurposeValidatorTest { + + private static final String EXTENDED_KEY_USAGE_CLIENT_AUTHENTICATION = "1.3.6.1.5.5.7.3.2"; + private static final String EXTENDED_KEY_USAGE_EMAIL_PROTECTION = "1.3.6.1.5.5.7.3.4"; + // Key Usage bits: digitalSignature(0), keyAgreement(4). + private static final boolean[] DIGITAL_SIGNATURE_KEY_USAGE = {true, false, false, false, false, false, false, false, false}; + private static final boolean[] KEY_AGREEMENT_KEY_USAGE = {false, false, false, false, true, false, false, false, false}; + + private final X509Certificate certificate = mock(X509Certificate.class); + + @Test + void whenDigitalSignatureKeyUsageIsRequiredAndPresent_thenValidationSucceeds() throws Exception { + when(certificate.getKeyUsage()).thenReturn(DIGITAL_SIGNATURE_KEY_USAGE); + when(certificate.getExtendedKeyUsage()).thenReturn(List.of(EXTENDED_KEY_USAGE_CLIENT_AUTHENTICATION)); + assertThatCode(() -> new SubjectCertificatePurposeValidator(true).validateCertificatePurpose(certificate)) + .doesNotThrowAnyException(); + } + + @Test + void whenDigitalSignatureKeyUsageIsRequiredAndMissing_thenValidationFails() throws Exception { + when(certificate.getKeyUsage()).thenReturn(KEY_AGREEMENT_KEY_USAGE); + when(certificate.getExtendedKeyUsage()).thenReturn(List.of(EXTENDED_KEY_USAGE_CLIENT_AUTHENTICATION)); + assertThatExceptionOfType(UserCertificateWrongPurposeException.class) + .isThrownBy(() -> new SubjectCertificatePurposeValidator(true).validateCertificatePurpose(certificate)); + } + + @Test + void whenDigitalSignatureKeyUsageIsNotRequiredAndMissing_thenValidationSucceeds() throws Exception { + when(certificate.getKeyUsage()).thenReturn(KEY_AGREEMENT_KEY_USAGE); + when(certificate.getExtendedKeyUsage()).thenReturn(List.of(EXTENDED_KEY_USAGE_CLIENT_AUTHENTICATION)); + assertThatCode(() -> new SubjectCertificatePurposeValidator(false).validateCertificatePurpose(certificate)) + .doesNotThrowAnyException(); + } + + @Test + void whenDigitalSignatureKeyUsageIsNotRequiredAndKeyUsageExtensionIsMissing_thenValidationFails() { + when(certificate.getKeyUsage()).thenReturn(null); + assertThatExceptionOfType(UserCertificateMissingPurposeException.class) + .isThrownBy(() -> new SubjectCertificatePurposeValidator(false).validateCertificatePurpose(certificate)); + } + + @Test + void whenDigitalSignatureKeyUsageIsNotRequiredAndClientAuthenticationIsMissing_thenValidationFails() throws Exception { + when(certificate.getKeyUsage()).thenReturn(KEY_AGREEMENT_KEY_USAGE); + when(certificate.getExtendedKeyUsage()).thenReturn(List.of(EXTENDED_KEY_USAGE_EMAIL_PROTECTION)); + assertThatExceptionOfType(UserCertificateWrongPurposeException.class) + .isThrownBy(() -> new SubjectCertificatePurposeValidator(false).validateCertificatePurpose(certificate)); + } + +} 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