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..6581ab9c 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 @@ -142,12 +148,18 @@ protected void verifyOcspResponse(BasicOCSPResp basicResponse, OcspService ocspS + "received " + basicResponse.getResponses().length + " responses instead", ocspService.getAccessLocation()); } final SingleResp certStatusResponse = basicResponse.getResponses()[0]; - if (!requestCertificateId.equals(certStatusResponse.getCertID())) { - throw new UserCertificateOCSPCheckFailedException("OCSP responded with certificate ID that differs from the requested ID", - ocspService.getAccessLocation()); - } + OcspResponseValidator.validateCertificateId(requestCertificateId, certStatusResponse.getCertID(), ocspService.getAccessLocation()); - // 2. The signature on the response is valid. + // Steps 3 and 4 are performed BEFORE step 2. The responder certificate is + // attacker-controlled until validated; verifying the response signature first lets + // an untrusted key drive expensive or unsafe crypto (e.g., a huge RSA public exponent) + // that later rejection can't undo. + + // 3. The identity of the signer matches the intended recipient of the + // request. + // + // 4. The signer is currently authorized to provide a response for the + // certificate in question. // We assume that the responder includes its certificate in the certs field of the response // that helps us to verify it. According to RFC 2560 this field is optional, but including it @@ -158,13 +170,6 @@ protected void verifyOcspResponse(BasicOCSPResp basicResponse, OcspService ocspS } // The first certificate is the responder certificate, other certificates, if given, are the certificate's chain. final X509CertificateHolder responderCert = basicResponse.getCerts()[0]; - OcspResponseValidator.validateResponseSignature(basicResponse, responderCert, ocspService.getAccessLocation()); - - // 3. The identity of the signer matches the intended recipient of the - // request. - // - // 4. The signer is currently authorized to provide a response for the - // certificate in question. // Use the clock instance so that the date can be mocked in tests. final Date now = DateAndTime.DefaultClock.getInstance().now(); @@ -175,6 +180,10 @@ protected void verifyOcspResponse(BasicOCSPResp basicResponse, OcspService ocspS throw new UserCertificateOCSPCheckFailedException(e, ocspService.getAccessLocation()); } + // 2. The signature on the response is valid. + + OcspResponseValidator.validateResponseSignature(basicResponse, responderCert, ocspService.getAccessLocation()); + // 5. The time at which the status being indicated is known to be // correct (thisUpdate) is sufficiently recent. // @@ -182,7 +191,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 +211,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 +240,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..75c64af9 100644 --- a/src/main/java/eu/webeid/ocsp/README.md +++ b/src/main/java/eu/webeid/ocsp/README.md @@ -9,8 +9,10 @@ Use `withCertificateRevocationChecker(...)` to supply your own implementation or ## Contents - [Choosing a revocation checker](#choosing-a-revocation-checker) +- [OCSP responder revocation check limitation](#ocsp-responder-revocation-check-limitation) - [Implementing CertificateRevocationChecker](#implementing-certificaterevocationchecker) - [Custom OCSP checker](#custom-ocsp-checker) +- [Resilient OCSP checker](#resilient-ocsp-checker) - [Custom PKIX revocation checker](#custom-pkix-revocation-checker) - [Platform OCSP timeouts](#platform-ocsp-timeouts) - [Platform OCSP nonce configuration](#platform-ocsp-nonce-configuration) @@ -35,6 +37,10 @@ The two custom checker options and `withoutUserCertificateRevocationCheck()` are Platform OCSP networking and response freshness are controlled by the JDK provider; see [Platform OCSP timeouts](#platform-ocsp-timeouts). The v3 API builder-level OCSP client, timeout, designated-service and response-age options are no longer available; use the custom OCSP checker below when those controls are needed. Its timing constants do not configure the platform checker. +## OCSP responder revocation check limitation + +The bundled custom OCSP checker and OpenJDK 17's OCSP implementation do not check delegated responder certificate revocation, even when `id-pkix-ocsp-nocheck` is absent. A revoked responder with a compromised key can therefore produce signed responses that are accepted. Applications requiring responder revocation checking must enforce it separately or supply a `CertificateRevocationChecker` that does so. + ## Implementing CertificateRevocationChecker Implement [CertificateRevocationChecker](../security/validator/revocationcheck/CertificateRevocationChecker.java) and supply it through `AuthTokenValidatorBuilder.withCertificateRevocationChecker(...)`. This extension point can use your own revocation service, transport or validation policy. @@ -75,14 +81,16 @@ List trustedCAs = List.of(trustedIntermediateCACertificates()); AiaOcspServiceConfiguration aiaConfiguration = new AiaOcspServiceConfiguration( Set.of(), // AIA responder URLs for which request and response nonce checks are disabled. CertificateValidator.buildTrustAnchorsFromCertificates(trustedCAs), - CertificateValidator.buildCertStoreFromCertificates(trustedCAs) + CertificateValidator.buildCertStoreFromCertificates(trustedCAs), + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, + OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, + true // Require the Key Usage extension in responder certificates. ); OcspServiceProvider services = new OcspServiceProvider(null, aiaConfiguration); OcspCertificateRevocationChecker checker = new OcspCertificateRevocationChecker( OcspClientImpl.build(Duration.ofSeconds(5)), services, - OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW, - OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE + OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW ); AuthTokenValidator validator = new AuthTokenValidatorBuilder() @@ -94,7 +102,16 @@ AuthTokenValidator validator = new AuthTokenValidatorBuilder() The five-second connection and response timeout above is an explicit example setting. For a custom Java `HttpClient`, use `new OcspClientImpl(httpClient, responseTimeout)` and configure the connection timeout on that client. Alternatively, supply your own `OcspClient` implementation. See [OcspClientOverrideTest](../../../../../test/java/eu/webeid/ocsp/client/OcspClientOverrideTest.java). -The custom checker's suggested constants are 15 minutes for clock/update skew and 2 minutes for maximum `thisUpdate` age; pass different positive durations to its constructor to change them. These checks are implemented by [OcspResponseValidator](protocol/OcspResponseValidator.java). +The custom checker's suggested constant for clock/update skew is 15 minutes; pass a different positive duration to its constructor to change it. + +The maximum ages of the OCSP response's `thisUpdate` and `nextUpdate` times are configured per OCSP service, via the constructor of `AiaOcspServiceConfiguration`, `DesignatedOcspServiceConfiguration` or `FallbackOcspServiceConfiguration`: + +- `maxThisUpdateAge` – the maximum age of the OCSP response's `thisUpdate` time before the response is too old to rely on. `OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE` is 2 minutes. +- `maxNextUpdateAge` – the maximum age of the OCSP response's `nextUpdate` time before the response is too old to rely on. `OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE` is 15 minutes, which is equal to the default allowed time skew. + +These checks are implemented by [OcspResponseValidator](protocol/OcspResponseValidator.java). + +The examples require OCSP responder certificates to have the Key Usage extension. To accept responder certificates that lack the Key Usage extension, pass `false` as the last `isKeyUsageRequired` argument of the `AiaOcspServiceConfiguration`, `DesignatedOcspServiceConfiguration` or `FallbackOcspServiceConfiguration` constructor. If the Key Usage extension is present, it must contain Digital Signature or Non Repudiation and must not contain Certificate Signing. The Extended Key Usage extension must still contain OCSP Signing, and the certificate must still not be a CA certificate. For AIA services, the setting applies only to delegated responder certificates, not to the issuer CA signing its own responses. The setting of a fallback configuration does not propagate to its `nextFallbackConfiguration`. For a designated responder, replace the `services` definition above with the following configuration. `responderCertificate` must be the service's trusted signing certificate and `supportedIssuers` the collection of issuer certificates served by it: @@ -106,15 +123,66 @@ DesignatedOcspServiceConfiguration designated = new DesignatedOcspServiceConfigu URI.create("https://ocsp.example.org"), responderCertificate, supportedIssuers, - true // This service supports nonces. + true, // This service supports nonces. + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, + OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, + true // Require the Key Usage extension in responder certificates. ); OcspServiceProvider services = new OcspServiceProvider(designated, aiaConfiguration); ``` The provider selects the designated service only when the validated direct issuer certificate equals one of its configured `supportedIssuers`. Supply the same encoded issuer certificates that validation may select as direct issuers; a reissued CA certificate with the same name and key does not match. On a mismatch, the provider uses the certificate's AIA OCSP URL. The designated responder certificate is pinned locally and need not be issued by the CAs it serves. +The AIA configuration is always required, so a designated service is always combined with AIA services: the designated service handles its `supportedIssuers` and AIA services handle all other issuers. Fallback OCSP services, passed as the third `OcspServiceProvider` constructor argument, are used only with AIA services. Configuring a fallback for an issuer that the designated service supports causes the `OcspServiceProvider` constructor to throw `IllegalArgumentException`. + For AIA services, the response may be signed by the certificate's direct issuer CA itself, or by a delegated responder certificate signed directly by that CA with the OCSP-signing extended key usage. Nonce support is enabled unless the URL appears in the first argument of `AiaOcspServiceConfiguration`. When nonce support is enabled, this custom checker requires a matching response nonce and rejects its absence. Its nonce policy is independent of the platform builder setting and JVM nonce property. +## Resilient OCSP checker + +`ResilientOcspCertificateRevocationChecker` in `eu.webeid.resilientocsp` extends the custom OCSP checker with retry, circuit breaker and fallback handling, built on [Resilience4j](https://resilience4j.readme.io/). When the primary OCSP service fails, the request is handed over to the fallback OCSP services configured for the issuer in `OcspServiceProvider`. Retry and circuit breaker handling apply only when a fallback is configured for the issuer; otherwise the primary service is used directly. + +Fallback OCSP services are configured per issuer with `FallbackOcspServiceConfiguration` and passed as the third `OcspServiceProvider` constructor argument. Pass at most one configuration per issuer: this is not checked, and if several are passed for the same issuer, only the last one in the collection's iteration order is used. In the example below, `issuerCertificate` must be the issuer CA certificate whose user certificates the fallback serves, and `trustedCAs` and `aiaConfiguration` are defined as in the [custom OCSP checker](#custom-ocsp-checker) example: + +```java +import eu.webeid.ocsp.service.FallbackOcspServiceConfiguration; +import eu.webeid.resilientocsp.ResilientOcspCertificateRevocationChecker; +import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig; +import io.github.resilience4j.retry.RetryConfig; + +... +FallbackOcspServiceConfiguration fallback = new FallbackOcspServiceConfiguration( + URI.create("https://fallback.ocsp.example.org"), + null, // No pinned responder certificate. + true, // This service supports nonces. + null, // No next fallback. + issuerCertificate, + CertificateValidator.buildTrustAnchorsFromCertificates(trustedCAs), + CertificateValidator.buildCertStoreFromCertificates(trustedCAs), + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, + OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, + true // Require the Key Usage extension in responder certificates. +); +OcspServiceProvider services = new OcspServiceProvider(null, aiaConfiguration, List.of(fallback)); +ResilientOcspCertificateRevocationChecker checker = new ResilientOcspCertificateRevocationChecker( + OcspClientImpl.build(Duration.ofSeconds(5)), + services, + CircuitBreakerConfig.ofDefaults(), + RetryConfig.custom().maxAttempts(2).build(), // Or null to disable retries. + OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW, + false // An unknown certificate status is final; pass true to retry it and use the fallback instead. +); +``` + +The fallback is used only when the validated direct issuer certificate equals its `issuerCertificate`. When the second `responderCertificate` argument is `null`, the fallback response must be signed by the issuer CA itself or by a delegated responder certificate issued directly by that CA with the OCSP-signing extended key usage. To pin the responder instead, pass its trusted signing certificate; the response must then be signed by exactly that certificate. + +To chain a second fallback, pass its configuration as the fourth `nextFallbackConfiguration` argument of the first one. It must be configured for the same `issuerCertificate`. At most two fallbacks per issuer are used: the configured one and its next fallback. Any further chained configurations are ignored. + +A *revoked* certificate status is always a final answer: it is not retried, it does not count as a failure for the circuit breaker, and no fallback is consulted. + +An *unknown* certificate status is final by default as well: no retry, circuit breaker failure accounting or fallback takes place. Passing `true` as the last `fallbackOnUnknownStatus` constructor argument treats an unknown status like any other failed check instead: it is retried according to the retry configuration, recorded as a circuit breaker failure, and handed over to the fallback OCSP services. The five-argument constructor is equivalent to passing `false`. + +A final unknown status fails the check with `ResilientUserCertificateUnknownStatusException`. It is a subclass of `UserCertificateUnknownStatusException`, so it can be caught the same way as with the custom OCSP checker, but it is not a `ResilientUserCertificateOCSPCheckFailedException`. Its message names the responder that returned the unknown status, and `getValidationInfo()` returns the revocation information of every OCSP request attempt that was made, so a retried service appears once per attempt. With `fallbackOnUnknownStatus` enabled, `ResilientUserCertificateUnknownStatusException` is never thrown: if no service establishes the status, the check fails with `ResilientUserCertificateOCSPCheckFailedException`, also when no fallback is configured for the issuer. In both modes, the revocation information of each responder that returned the unknown status contains `UserCertificateUnknownStatusException` under `RevocationInfo.KEY_OCSP_ERROR`. + ## Custom PKIX revocation checker Configure the checker explicitly, including its fallback policy. For example, to use a fixed responder while retaining OCSP-only checking of the subject certificate: @@ -205,7 +273,7 @@ The `validate()` method returns `ValidationInfo` on success. Certificate and tok | `CertificateRevokedException` | The checker reported that the certificate is revoked. | | `CertificateRevocationCheckFailedException` | The revocation status could not be established, for example because of an OCSP service or network failure. | -These classes are in `eu.webeid.security.exceptions`. The bundled custom checker's `UserCertificateRevokedException` and `UserCertificateOCSPCheckFailedException` extend the corresponding common classes. Other validation errors, including expired certificates and invalid token signatures, are documented in the [exception classes](../security/exceptions). +These classes are in `eu.webeid.security.exceptions`. The bundled custom checker's `UserCertificateRevokedException` and `UserCertificateOCSPCheckFailedException` extend the corresponding common classes. An *unknown* certificate status is reported as `UserCertificateUnknownStatusException`, a subclass of `UserCertificateOCSPCheckFailedException`. The resilient checker reports a final unknown status as `ResilientUserCertificateUnknownStatusException`, a subclass of `UserCertificateUnknownStatusException`; see [Resilient OCSP checker](#resilient-ocsp-checker). Other validation errors, including expired certificates and invalid token signatures, are documented in the [exception classes](../security/exceptions). During revocation checking, invalid responder certificates (including an unexpected designated responder, missing OCSP signing usage, expiry, or untrusted certificates) produce `UserCertificateOCSPCheckFailedException`, with the detailed certificate error retained as its cause. Invalid certificates supplied during designated-service configuration can instead throw `OCSPCertificateException` at application startup, before any revocation check runs. 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..d6a3eb71 --- /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.CertificateRevocationCheckFailedException; + +public class UserCertificateOCSPException extends CertificateRevocationCheckFailedException { + + public UserCertificateOCSPException(String message) { + super(message); + } + + public UserCertificateOCSPException(String message, Throwable exception) { + super(message, exception); + } + +} diff --git a/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateUnknownStatusException.java b/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateUnknownStatusException.java new file mode 100644 index 00000000..096de518 --- /dev/null +++ b/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateUnknownStatusException.java @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.ocsp.exceptions; + +import java.net.URI; + +/** + * Thrown when the OCSP responder returns the unknown status for the user certificate. + */ +public class UserCertificateUnknownStatusException extends UserCertificateOCSPCheckFailedException { + + public UserCertificateUnknownStatusException(URI ocspResponderUri) { + super("Unknown status", ocspResponderUri); + } +} diff --git a/src/main/java/eu/webeid/ocsp/protocol/OcspResponseValidator.java b/src/main/java/eu/webeid/ocsp/protocol/OcspResponseValidator.java index e0679e2e..72a893a2 100644 --- a/src/main/java/eu/webeid/ocsp/protocol/OcspResponseValidator.java +++ b/src/main/java/eu/webeid/ocsp/protocol/OcspResponseValidator.java @@ -6,9 +6,15 @@ import eu.webeid.ocsp.exceptions.OCSPCertificateException; import eu.webeid.ocsp.exceptions.UserCertificateOCSPCheckFailedException; import eu.webeid.ocsp.exceptions.UserCertificateRevokedException; +import eu.webeid.ocsp.exceptions.UserCertificateUnknownStatusException; import eu.webeid.security.util.DateAndTime; +import org.bouncycastle.asn1.ASN1Encodable; +import org.bouncycastle.asn1.DERNull; +import org.bouncycastle.asn1.ocsp.CertID; +import org.bouncycastle.asn1.x509.AlgorithmIdentifier; import org.bouncycastle.cert.X509CertificateHolder; import org.bouncycastle.cert.ocsp.BasicOCSPResp; +import org.bouncycastle.cert.ocsp.CertificateID; import org.bouncycastle.cert.ocsp.CertificateStatus; import org.bouncycastle.cert.ocsp.OCSPException; import org.bouncycastle.cert.ocsp.RevokedStatus; @@ -25,6 +31,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,20 +42,118 @@ 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_NON_REPUDIATION_BIT_INDEX = 1; + 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 { + /** + * Validates that the responder certificate is not a CA and does not include Certificate Signing. + * + *

For a non-CA certificate, RFC 5280 section 4.2.1.9 prohibits the Certificate Signing + * bit in Key Usage, including when the Basic Constraints extension is absent. + * + * @param certificate the OCSP responder certificate to validate + * @throws OCSPCertificateException if the certificate is a CA or asserts Certificate Signing + * @throws NullPointerException if {@code certificate} is null + * @see RFC 5280, section 4.2.1.9 + */ + 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)"); + } + final boolean[] keyUsage = certificate.getKeyUsage(); + if (keyUsage != null && 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 a non-CA OCSP responder certificate"); + } + } + + /** + * Validates that the Key Usage extension permits OCSP response signing. + * + *

When present, the extension must include Digital Signature or Non Repudiation, + * consistent with RFC 5280 section 4.2.1.12. + * + * @param certificate the OCSP responder certificate whose Key Usage is checked + * @param isKeyUsageRequired whether the Key Usage extension must be present; + * if false, an absent extension is accepted, but a present extension is still validated + * @throws OCSPCertificateException if the required extension is absent or its usages fail these checks + * @throws NullPointerException if {@code certificate} is null + * @see RFC 5280, section 4.2.1.12 + */ + public static void validateKeyUsageOcspSigning(X509Certificate certificate, boolean isKeyUsageRequired) throws OCSPCertificateException { + Objects.requireNonNull(certificate, "certificate"); + final boolean[] keyUsage = certificate.getKeyUsage(); + if (keyUsage == null) { + if (!isKeyUsageRequired) { + return; + } + throw new OCSPCertificateException("Certificate " + certificate.getSubjectX500Principal() + + " does not contain the Key Usage extension required for OCSP response signing"); + } + final boolean hasDigitalSignature = keyUsage.length > KEY_USAGE_DIGITAL_SIGNATURE_BIT_INDEX + && keyUsage[KEY_USAGE_DIGITAL_SIGNATURE_BIT_INDEX]; + final boolean hasNonRepudiation = keyUsage.length > KEY_USAGE_NON_REPUDIATION_BIT_INDEX + && keyUsage[KEY_USAGE_NON_REPUDIATION_BIT_INDEX]; + if (!hasDigitalSignature && !hasNonRepudiation) { + throw new OCSPCertificateException("Certificate " + certificate.getSubjectX500Principal() + + " Key Usage extension does not contain Digital Signature or Non Repudiation, which is required for OCSP response signing"); + } + } + + 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); } } + /** + * Validates that the certificate ID in the OCSP response identifies the same certificate as the request. + * + *

The hash algorithm, issuer name hash, issuer key hash and serial number must all match. The hash + * algorithm parameters are compared leniently: absent and NULL parameters are treated as equal, because + * both are valid encodings of the same algorithm identifier and responders differ in which one they use + * when rebuilding the certificate ID (e.g. OpenSSL encodes NULL). {@link CertificateID#equals(Object)} is + * not used, as it compares the encodings and would reject such a response. + * + * @param requestCertificateId the certificate ID sent in the OCSP request + * @param responseCertificateId the certificate ID received in the OCSP response + * @param ocspResponderUri the OCSP responder URI, used in the exception message + * @throws UserCertificateOCSPCheckFailedException if the certificate IDs differ + * @throws NullPointerException if {@code requestCertificateId} or {@code responseCertificateId} is null + */ + public static void validateCertificateId(CertificateID requestCertificateId, CertificateID responseCertificateId, URI ocspResponderUri) throws UserCertificateOCSPCheckFailedException { + Objects.requireNonNull(requestCertificateId, "requestCertificateId"); + Objects.requireNonNull(responseCertificateId, "responseCertificateId"); + final CertID requestId = requestCertificateId.toASN1Primitive(); + final CertID responseId = responseCertificateId.toASN1Primitive(); + if (!isSameHashAlgorithm(requestId.getHashAlgorithm(), responseId.getHashAlgorithm()) + || !requestId.getIssuerNameHash().equals(responseId.getIssuerNameHash()) + || !requestId.getIssuerKeyHash().equals(responseId.getIssuerKeyHash()) + || !requestId.getSerialNumber().equals(responseId.getSerialNumber())) { + throw new UserCertificateOCSPCheckFailedException("OCSP responded with certificate ID that differs from the requested ID", + ocspResponderUri); + } + } + public static void validateResponseSignature(BasicOCSPResp basicResponse, X509CertificateHolder responderCert, URI ocspResponderUri) throws CertificateException, OperatorCreationException, OCSPException, UserCertificateOCSPCheckFailedException { final ContentVerifierProvider verifierProvider = new JcaContentVerifierProviderBuilder() .setProvider(BouncyCastleProvider.PROVIDER_NAME) @@ -58,7 +163,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 +174,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 +194,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 + @@ -110,12 +216,22 @@ public static void validateSubjectCertificateStatus(SingleResp certStatusRespons new UserCertificateRevokedException("Revocation reason: " + revokedStatus.getRevocationReason(), ocspResponderUri) : new UserCertificateRevokedException(ocspResponderUri)); } else if (status instanceof UnknownStatus) { - throw new UserCertificateOCSPCheckFailedException("Unknown status", ocspResponderUri); + throw new UserCertificateUnknownStatusException(ocspResponderUri); } else { throw new UserCertificateOCSPCheckFailedException("Status is neither good, revoked nor unknown", ocspResponderUri); } } + private static boolean isSameHashAlgorithm(AlgorithmIdentifier requestAlgorithm, AlgorithmIdentifier responseAlgorithm) { + return requestAlgorithm.getAlgorithm().equals(responseAlgorithm.getAlgorithm()) + && Objects.equals(nullParametersAsAbsent(requestAlgorithm.getParameters()), + nullParametersAsAbsent(responseAlgorithm.getParameters())); + } + + private static ASN1Encodable nullParametersAsAbsent(ASN1Encodable parameters) { + return DERNull.INSTANCE.equals(parameters) ? null : parameters; + } + private OcspResponseValidator() { throw new IllegalStateException("Utility class"); } diff --git a/src/main/java/eu/webeid/ocsp/service/AiaOcspService.java b/src/main/java/eu/webeid/ocsp/service/AiaOcspService.java index 21980ba5..5f7453d2 100644 --- a/src/main/java/eu/webeid/ocsp/service/AiaOcspService.java +++ b/src/main/java/eu/webeid/ocsp/service/AiaOcspService.java @@ -6,7 +6,7 @@ import eu.webeid.security.certificate.CertificateValidator; import eu.webeid.security.exceptions.AuthTokenException; import eu.webeid.ocsp.exceptions.OCSPCertificateException; -import eu.webeid.ocsp.exceptions.UserCertificateOCSPCheckFailedException; +import eu.webeid.ocsp.exceptions.UserCertificateOCSPException; import eu.webeid.ocsp.protocol.OcspResponseValidator; import eu.webeid.security.validator.revocationcheck.RevocationMode; import org.bouncycastle.asn1.x500.X500Name; @@ -18,6 +18,7 @@ import java.security.cert.CertStore; import java.security.cert.TrustAnchor; import java.security.cert.X509Certificate; +import java.time.Duration; import java.util.Date; import java.util.Objects; import java.util.Optional; @@ -37,8 +38,11 @@ public class AiaOcspService implements OcspService { private final URI url; private final boolean supportsNonce; private final FallbackOcspService fallbackOcspService; + private final Duration maxThisUpdateAge; + private final Duration maxNextUpdateAge; + private final boolean isKeyUsageRequired; - public AiaOcspService(AiaOcspServiceConfiguration configuration, X509Certificate certificate, FallbackOcspService fallbackOcspService) throws AuthTokenException { + public AiaOcspService(AiaOcspServiceConfiguration configuration, X509Certificate certificate, FallbackOcspService fallbackOcspService) throws UserCertificateOCSPException { Objects.requireNonNull(configuration); this.trustedCACertificateAnchors = configuration.getTrustedCACertificateAnchors(); this.trustedCACertificateCertStore = configuration.getTrustedCACertificateCertStore(); @@ -46,6 +50,9 @@ public AiaOcspService(AiaOcspServiceConfiguration configuration, X509Certificate this.fallbackOcspService = fallbackOcspService; X500Name issuerDN = getIssuerDistinguishedName(certificate); this.supportsNonce = !configuration.getNonceDisabledIssuerDNs().contains(issuerDN); + this.maxThisUpdateAge = configuration.getMaxThisUpdateAge(); + this.maxNextUpdateAge = configuration.getMaxNextUpdateAge(); + this.isKeyUsageRequired = configuration.isKeyUsageRequired(); } @Override @@ -58,6 +65,16 @@ public URI getAccessLocation() { return url; } + @Override + public Duration getMaxThisUpdateAge() { + return maxThisUpdateAge; + } + + @Override + public Duration getMaxNextUpdateAge() { + return maxNextUpdateAge; + } + @Override public Optional getFallbackService() { return Optional.ofNullable(fallbackOcspService); @@ -69,7 +86,9 @@ 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.validateKeyUsageOcspSigning(certificate, isKeyUsageRequired); + 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 +110,9 @@ public void validateResponderCertificate(X509CertificateHolder cert, X509Certifi } } - private static URI getOcspAiaUrlFromCertificate(X509Certificate certificate) throws AuthTokenException { + private static URI getOcspAiaUrlFromCertificate(X509Certificate certificate) throws UserCertificateOCSPException { return getOcspUri(certificate).orElseThrow(() -> - new UserCertificateOCSPCheckFailedException("Getting the AIA OCSP responder field from the certificate failed") + new UserCertificateOCSPException("Getting the AIA OCSP responder field from the certificate failed") ); } diff --git a/src/main/java/eu/webeid/ocsp/service/AiaOcspServiceConfiguration.java b/src/main/java/eu/webeid/ocsp/service/AiaOcspServiceConfiguration.java index 44b601fd..240d3d0c 100644 --- a/src/main/java/eu/webeid/ocsp/service/AiaOcspServiceConfiguration.java +++ b/src/main/java/eu/webeid/ocsp/service/AiaOcspServiceConfiguration.java @@ -7,20 +7,29 @@ import java.security.cert.CertStore; import java.security.cert.TrustAnchor; +import java.time.Duration; import java.util.Collection; import java.util.Objects; import java.util.Set; +import static eu.webeid.security.util.DateAndTime.requirePositiveDuration; + public class AiaOcspServiceConfiguration { private final Collection nonceDisabledIssuerDNs; private final Set trustedCACertificateAnchors; private final CertStore trustedCACertificateCertStore; + private final Duration maxThisUpdateAge; + private final Duration maxNextUpdateAge; + private final boolean isKeyUsageRequired; - public AiaOcspServiceConfiguration(Collection nonceDisabledIssuerDNs, Set trustedCACertificateAnchors, CertStore trustedCACertificateCertStore) { + public AiaOcspServiceConfiguration(Collection nonceDisabledIssuerDNs, Set trustedCACertificateAnchors, CertStore trustedCACertificateCertStore, Duration maxThisUpdateAge, Duration maxNextUpdateAge, boolean isKeyUsageRequired) { this.nonceDisabledIssuerDNs = Set.copyOf(nonceDisabledIssuerDNs); this.trustedCACertificateAnchors = Set.copyOf(trustedCACertificateAnchors); this.trustedCACertificateCertStore = Objects.requireNonNull(trustedCACertificateCertStore); + this.maxThisUpdateAge = requirePositiveDuration(maxThisUpdateAge, "maxThisUpdateAge"); + this.maxNextUpdateAge = requirePositiveDuration(maxNextUpdateAge, "maxNextUpdateAge"); + this.isKeyUsageRequired = isKeyUsageRequired; } public Collection getNonceDisabledIssuerDNs() { @@ -35,4 +44,15 @@ public CertStore getTrustedCACertificateCertStore() { return trustedCACertificateCertStore; } + public Duration getMaxThisUpdateAge() { + return maxThisUpdateAge; + } + + public Duration getMaxNextUpdateAge() { + return maxNextUpdateAge; + } + + public boolean isKeyUsageRequired() { + return isKeyUsageRequired; + } } diff --git a/src/main/java/eu/webeid/ocsp/service/DesignatedOcspService.java b/src/main/java/eu/webeid/ocsp/service/DesignatedOcspService.java index 853a9cbe..163a197d 100644 --- a/src/main/java/eu/webeid/ocsp/service/DesignatedOcspService.java +++ b/src/main/java/eu/webeid/ocsp/service/DesignatedOcspService.java @@ -11,6 +11,7 @@ import java.net.URI; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; +import java.time.Duration; import java.util.Date; import java.util.Objects; @@ -38,10 +39,23 @@ public URI getAccessLocation() { return configuration.getOcspServiceAccessLocation(); } + @Override + public Duration getMaxThisUpdateAge() { + return configuration.getMaxThisUpdateAge(); + } + + @Override + public Duration getMaxNextUpdateAge() { + return configuration.getMaxNextUpdateAge(); + } + @Override public void validateResponderCertificate(X509CertificateHolder cert, X509Certificate issuerCertificate, Date now) throws AuthTokenException { try { final X509Certificate responderCertificate = certificateConverter.getCertificate(cert); + // Certificate extensions (Basic Constraints, Key Usage, Extended Key Usage) are validated at + // configuration time in DesignatedOcspServiceConfiguration. Since equals() compares the full DER + // encoding, a matching certificate is guaranteed to have the same validated extensions. // Certificate pinning is implemented simply by comparing the certificates or their public keys, // see https://owasp.org/www-community/controls/Certificate_and_Public_Key_Pinning. if (!configuration.getResponderCertificate().equals(responderCertificate)) { diff --git a/src/main/java/eu/webeid/ocsp/service/DesignatedOcspServiceConfiguration.java b/src/main/java/eu/webeid/ocsp/service/DesignatedOcspServiceConfiguration.java index 85397700..d39ebefb 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 + * @param isKeyUsageRequired true if the responder certificate must have the Key Usage extension * @throws OCSPCertificateException when the responder certificate lacks OCSP signing usage */ - public DesignatedOcspServiceConfiguration(URI ocspServiceAccessLocation, X509Certificate responderCertificate, Collection supportedCertificateIssuers, boolean doesSupportNonce) throws OCSPCertificateException { + public DesignatedOcspServiceConfiguration(URI ocspServiceAccessLocation, X509Certificate responderCertificate, Collection supportedCertificateIssuers, boolean doesSupportNonce, Duration maxThisUpdateAge, Duration maxNextUpdateAge, boolean isKeyUsageRequired) throws OCSPCertificateException { this.ocspServiceAccessLocation = Objects.requireNonNull(ocspServiceAccessLocation, "OCSP service access location"); this.responderCertificate = Objects.requireNonNull(responderCertificate, "OCSP responder certificate"); this.supportedIssuers = Set.copyOf(Objects.requireNonNull(supportedCertificateIssuers, "supported issuers")); - OcspResponseValidator.validateHasSigningExtension(responderCertificate); + OcspResponseValidator.validateBasicConstraintsNotCA(responderCertificate); + OcspResponseValidator.validateKeyUsageOcspSigning(responderCertificate, isKeyUsageRequired); + 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..d23e3da1 100644 --- a/src/main/java/eu/webeid/ocsp/service/FallbackOcspService.java +++ b/src/main/java/eu/webeid/ocsp/service/FallbackOcspService.java @@ -16,6 +16,7 @@ import java.security.cert.CertStore; import java.security.cert.TrustAnchor; import java.security.cert.X509Certificate; +import java.time.Duration; import java.util.Date; import java.util.Objects; import java.util.Set; @@ -33,6 +34,9 @@ public class FallbackOcspService implements OcspService { private final FallbackOcspService nextFallback; private final Set trustedCACertificateAnchors; private final CertStore trustedCACertificateCertStore; + private final Duration maxThisUpdateAge; + private final Duration maxNextUpdateAge; + private final boolean isKeyUsageRequired; public FallbackOcspService(FallbackOcspServiceConfiguration configuration) { this.url = configuration.getAccessLocation(); @@ -44,6 +48,9 @@ public FallbackOcspService(FallbackOcspServiceConfiguration configuration) { : null; this.trustedCACertificateAnchors = configuration.getTrustedCACertificateAnchors(); this.trustedCACertificateCertStore = configuration.getTrustedCACertificateCertStore(); + this.maxThisUpdateAge = configuration.getMaxThisUpdateAge(); + this.maxNextUpdateAge = configuration.getMaxNextUpdateAge(); + this.isKeyUsageRequired = configuration.isKeyUsageRequired(); } @Override @@ -56,6 +63,16 @@ public URI getAccessLocation() { return url; } + @Override + public Duration getMaxThisUpdateAge() { + return maxThisUpdateAge; + } + + @Override + public Duration getMaxNextUpdateAge() { + return maxNextUpdateAge; + } + @Override public void validateResponderCertificate(X509CertificateHolder cert, X509Certificate issuerCertificate, Date now) throws AuthTokenException { try { @@ -76,6 +93,9 @@ public void validateResponderCertificate(X509CertificateHolder cert, X509Certifi } private void validatePinnedResponderCertificate(X509Certificate responderCertificate) throws OCSPCertificateException { + // Certificate extensions (Basic Constraints, Key Usage, Extended Key Usage) are validated at + // configuration time in FallbackOcspServiceConfiguration. Since equals() compares the full DER + // encoding, a matching certificate is guaranteed to have the same validated extensions. // Certificate pinning is implemented simply by comparing the certificates or their public keys, // see https://owasp.org/www-community/controls/Certificate_and_Public_Key_Pinning. if (!trustedResponderCertificate.equals(responderCertificate)) { @@ -86,7 +106,9 @@ 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.validateKeyUsageOcspSigning(responderCertificate, isKeyUsageRequired); + 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..32223adf 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,32 @@ public class FallbackOcspServiceConfiguration { private final X509Certificate issuerCertificate; private final Set trustedCACertificateAnchors; private final CertStore trustedCACertificateCertStore; + private final Duration maxThisUpdateAge; + private final Duration maxNextUpdateAge; + private final boolean isKeyUsageRequired; public FallbackOcspServiceConfiguration(URI accessLocation, X509Certificate responderCertificate, boolean doesSupportNonce, FallbackOcspServiceConfiguration nextFallbackConfiguration, X509Certificate issuerCertificate, Set trustedCACertificateAnchors, - CertStore trustedCACertificateCertStore) throws OCSPCertificateException { + CertStore trustedCACertificateCertStore, + Duration maxThisUpdateAge, Duration maxNextUpdateAge, + boolean isKeyUsageRequired) throws OCSPCertificateException { this.accessLocation = Objects.requireNonNull(accessLocation, "Fallback OCSP service access location"); this.responderCertificate = responderCertificate; if (responderCertificate != null) { - OcspResponseValidator.validateHasSigningExtension(responderCertificate); + OcspResponseValidator.validateBasicConstraintsNotCA(responderCertificate); + OcspResponseValidator.validateKeyUsageOcspSigning(responderCertificate, isKeyUsageRequired); + OcspResponseValidator.validateExtendedKeyUsageOcspSigning(responderCertificate); } this.doesSupportNonce = doesSupportNonce; this.nextFallbackConfiguration = nextFallbackConfiguration; this.issuerCertificate = Objects.requireNonNull(issuerCertificate, "issuerCertificate"); this.trustedCACertificateAnchors = Set.copyOf(Objects.requireNonNull(trustedCACertificateAnchors, "trustedCACertificateAnchors")); this.trustedCACertificateCertStore = Objects.requireNonNull(trustedCACertificateCertStore, "trustedCACertificateCertStore"); + this.maxThisUpdateAge = requirePositiveDuration(maxThisUpdateAge, "maxThisUpdateAge"); + this.maxNextUpdateAge = requirePositiveDuration(maxNextUpdateAge, "maxNextUpdateAge"); + this.isKeyUsageRequired = isKeyUsageRequired; } public URI getAccessLocation() { @@ -67,4 +80,16 @@ public Set getTrustedCACertificateAnchors() { public CertStore getTrustedCACertificateCertStore() { return trustedCACertificateCertStore; } + + public Duration getMaxThisUpdateAge() { + return maxThisUpdateAge; + } + + public Duration getMaxNextUpdateAge() { + return maxNextUpdateAge; + } + + public boolean isKeyUsageRequired() { + return isKeyUsageRequired; + } } diff --git a/src/main/java/eu/webeid/ocsp/service/OcspService.java b/src/main/java/eu/webeid/ocsp/service/OcspService.java index 221737ab..cca38056 100644 --- a/src/main/java/eu/webeid/ocsp/service/OcspService.java +++ b/src/main/java/eu/webeid/ocsp/service/OcspService.java @@ -8,6 +8,7 @@ import java.net.URI; import java.security.cert.X509Certificate; +import java.time.Duration; import java.util.Date; import java.util.Optional; @@ -17,6 +18,10 @@ public interface OcspService { URI getAccessLocation(); + Duration getMaxThisUpdateAge(); + + Duration getMaxNextUpdateAge(); + void validateResponderCertificate(X509CertificateHolder cert, X509Certificate issuerCertificate, Date now) throws AuthTokenException; default Optional getFallbackService() { diff --git a/src/main/java/eu/webeid/ocsp/service/OcspServiceProvider.java b/src/main/java/eu/webeid/ocsp/service/OcspServiceProvider.java index 96fca916..079f1b89 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; @@ -26,13 +26,16 @@ public OcspServiceProvider(DesignatedOcspServiceConfiguration designatedOcspServ new DesignatedOcspService(designatedOcspServiceConfiguration) : null; this.aiaOcspServiceConfiguration = Objects.requireNonNull(aiaOcspServiceConfiguration, "aiaOcspServiceConfiguration"); - this.fallbackOcspServiceMap = buildFallbackOcspServiceMap(fallbackOcspServiceConfigurations); + this.fallbackOcspServiceMap = buildFallbackOcspServiceMap(designatedOcspServiceConfiguration, fallbackOcspServiceConfigurations); } - private static Map buildFallbackOcspServiceMap(Collection fallbackOcspServiceConfigurations) { + private static Map buildFallbackOcspServiceMap(DesignatedOcspServiceConfiguration designatedOcspServiceConfiguration, Collection fallbackOcspServiceConfigurations) { if (fallbackOcspServiceConfigurations != null) { Map fallbackOcspServices = new HashMap<>(); for (FallbackOcspServiceConfiguration configuration : fallbackOcspServiceConfigurations) { + if (designatedOcspServiceConfiguration != null && designatedOcspServiceConfiguration.supportsIssuer(configuration.getIssuerCertificate())) { + throw new IllegalArgumentException("Fallback OCSP service must not be configured for an issuer that is supported by the designated OCSP service"); + } fallbackOcspServices.put(configuration.getIssuerCertificate(), new FallbackOcspService(configuration)); } return Map.copyOf(fallbackOcspServices); @@ -47,9 +50,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..c85ebcc9 100644 --- a/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java +++ b/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java @@ -6,13 +6,16 @@ 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.exceptions.UserCertificateUnknownStatusException; +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.resilientocsp.exceptions.ResilientUserCertificateUnknownStatusException; import eu.webeid.security.exceptions.AuthTokenException; import eu.webeid.security.validator.ValidationInfo; import eu.webeid.security.validator.revocationcheck.RevocationInfo; @@ -44,7 +47,6 @@ import java.util.Map; import java.util.Optional; -import static eu.webeid.security.util.DateAndTime.requirePositiveDuration; import static java.util.Objects.requireNonNull; /** @@ -52,6 +54,12 @@ * *

Retry and circuit breaker handling are applied only when a fallback OCSP service is configured for the * certificate issuer. If no fallback is configured, validation is handled by the primary OCSP service directly. + * + *

A revoked status is a definitive answer: it is not retried, does not count as a circuit breaker failure + * and does not initiate a fallback. By default, the unknown status is final in the same way and fails the check + * with {@link ResilientUserCertificateUnknownStatusException}. See {@code fallbackOnUnknownStatus} of + * {@link #ResilientOcspCertificateRevocationChecker(OcspClient, OcspServiceProvider, CircuitBreakerConfig, RetryConfig, Duration, boolean)} + * to handle it as an OCSP service failure instead. */ public class ResilientOcspCertificateRevocationChecker extends OcspCertificateRevocationChecker { @@ -59,17 +67,37 @@ public class ResilientOcspCertificateRevocationChecker extends OcspCertificateRe private final CircuitBreakerRegistry circuitBreakerRegistry; private final RetryRegistry retryRegistry; - private final Duration fallbackMaxOcspResponseThisUpdateAge; + private final boolean fallbackOnUnknownStatus; + + /** + * Creates a checker in which the unknown status is final. + */ + public ResilientOcspCertificateRevocationChecker(OcspClient ocspClient, + OcspServiceProvider ocspServiceProvider, + CircuitBreakerConfig circuitBreakerConfig, + RetryConfig retryConfig, + Duration allowedOcspResponseTimeSkew) { + this(ocspClient, ocspServiceProvider, circuitBreakerConfig, retryConfig, allowedOcspResponseTimeSkew, false); + } + /** + * @param fallbackOnUnknownStatus if {@code false}, the unknown status is final: it is not retried, does not count + * as a circuit breaker failure and does not initiate a fallback, and the check + * fails with {@link ResilientUserCertificateUnknownStatusException}. If + * {@code true}, the unknown status is handled like any other OCSP service + * failure: when a fallback OCSP service is configured for the certificate + * issuer, it is retried, counts as a circuit breaker failure and initiates a + * fallback. If no service establishes the status, the check fails with + * {@link ResilientUserCertificateOCSPCheckFailedException}. + */ 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"); + boolean fallbackOnUnknownStatus) { + super(ocspClient, ocspServiceProvider, allowedOcspResponseTimeSkew); + this.fallbackOnUnknownStatus = fallbackOnUnknownStatus; this.circuitBreakerRegistry = CircuitBreakerRegistry.custom() .withCircuitBreakerConfig(getCircuitBreakerConfig(circuitBreakerConfig)) .build(); @@ -91,22 +119,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 +165,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 +183,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; @@ -156,10 +192,10 @@ private CheckedSupplier buildFallbackSupplier(FallbackOcspServic return () -> { try { return firstFallbackSupplier.get(); - } catch (ResilientUserCertificateRevokedException e) { - // NOTE: ResilientUserCertificateRevokedException must be re-thrown before the generic - // catch (Exception) block. Without this, a "revoked" verdict from the first fallback would - // be swallowed, and the second fallback could silently override it with a "good" response. + } catch (ResilientUserCertificateRevokedException | ResilientUserCertificateUnknownStatusException e) { + // NOTE: Final verdicts must be re-thrown before the generic catch (Exception) block. Without this, + // a "revoked" or final "unknown" verdict from the first fallback would be swallowed, and the second + // fallback could silently override it with a "good" response. throw e; } catch (Exception e) { return secondFallbackSupplier.get(); @@ -170,12 +206,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 +228,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(); } @@ -214,6 +260,10 @@ private RevocationInfo processResult(Try result, X509Certificate exception.setValidationInfo(new ValidationInfo(subjectCertificate, revocationInfoList)); throw exception; } + if (throwable instanceof ResilientUserCertificateUnknownStatusException exception) { + exception.setValidationInfo(new ValidationInfo(subjectCertificate, revocationInfoList)); + throw exception; + } throw new ResilientUserCertificateOCSPCheckFailedException(new ValidationInfo(subjectCertificate, revocationInfoList)); } @@ -232,30 +282,28 @@ private void createAndAddRevocationInfoToList(Throwable throwable, List(Map.ofEntries( Map.entry(RevocationInfo.KEY_OCSP_ERROR, throwable) )))); } - private RevocationInfo request(OcspService ocspService, X509Certificate subjectCertificate, X509Certificate issuerCertificate, Duration maxOcspResponseThisUpdateAge) throws ResilientUserCertificateOCSPCheckFailedException, ResilientUserCertificateRevokedException { - URI ocspResponderUri = null; + private RevocationInfo request(OcspService ocspService, X509Certificate subjectCertificate, X509Certificate issuerCertificate, CertificateID certificateId) throws UserCertificateOCSPCheckFailedException, ResilientUserCertificateRevokedException, UserCertificateOCSPException { + final URI ocspResponderUri = ocspService.getAccessLocation(); + final OCSPReq request = getOcspRequest(certificateId, ocspService); + + if (!ocspService.doesSupportNonce()) { + LOG.debug("Disabling OCSP nonce extension"); + } + + OCSPResp response = null; - OCSPReq request = null; Duration requestDuration = null; Instant responseTime = null; try { - ocspResponderUri = requireNonNull(ocspService.getAccessLocation(), "ocspResponderUri"); - - final CertificateID certificateId = getCertificateId(subjectCertificate, issuerCertificate); - request = new OcspRequestBuilder() - .withCertificateId(certificateId) - .enableOcspNonce(ocspService.doesSupportNonce()) - .build(); - - if (!ocspService.doesSupportNonce()) { - LOG.debug("Disabling OCSP nonce extension"); - } - LOG.debug("Sending OCSP request"); Instant requestTime = Instant.now(); try { @@ -282,7 +330,7 @@ private RevocationInfo request(OcspService ocspService, X509Certificate subjectC } LOG.debug("OCSP response received successfully"); - verifyOcspResponse(basicResponse, ocspService, certificateId, issuerCertificate, maxOcspResponseThisUpdateAge); + verifyOcspResponse(basicResponse, ocspService, certificateId, issuerCertificate); if (ocspService.doesSupportNonce()) { checkNonce(request, basicResponse, ocspResponderUri); } @@ -292,17 +340,17 @@ private RevocationInfo request(OcspService ocspService, X509Certificate subjectC } catch (ResilientUserCertificateOCSPCheckFailedException e) { throw e; } catch (UserCertificateRevokedException e) { - // NOTE: unknown status does not throw UserCertificateRevokedException, it throws - // UserCertificateOCSPCheckFailedException instead (see OcspResponseValidator.validateSubjectCertificateStatus), - // so it falls through to the generic catch (Exception) block below, gets wrapped as - // ResilientUserCertificateOCSPCheckFailedException, and triggers the circuit breaker fallback. - // Here, wrapping as ResilientUserCertificateRevokedException ensures the circuit breaker ignores it + // Wrapping as ResilientUserCertificateRevokedException ensures the circuit breaker ignores it // (a definitive OCSP answer, not a transient failure) and no fallback is attempted. RevocationInfo revocationInfo = getRevocationInfo(ocspResponderUri, e, request, response, requestDuration, responseTime); throw new ResilientUserCertificateRevokedException(new ValidationInfo(subjectCertificate, List.of(revocationInfo))); } catch (Exception e) { RevocationInfo revocationInfo = getRevocationInfo(ocspResponderUri, e, request, response, requestDuration, responseTime); - throw new ResilientUserCertificateOCSPCheckFailedException(new ValidationInfo(subjectCertificate, List.of(revocationInfo))); + ValidationInfo validationInfo = new ValidationInfo(subjectCertificate, List.of(revocationInfo)); + if (e instanceof UserCertificateUnknownStatusException && !fallbackOnUnknownStatus) { + throw new ResilientUserCertificateUnknownStatusException(ocspResponderUri, validationInfo); + } + throw new ResilientUserCertificateOCSPCheckFailedException(validationInfo); } } @@ -339,17 +387,22 @@ private RevocationInfo getRevocationInfo(URI ocspResponderUri, Exception e, OCSP private static CircuitBreakerConfig getCircuitBreakerConfig(CircuitBreakerConfig circuitBreakerConfig) { return CircuitBreakerConfig.from(circuitBreakerConfig) - // Users must not be able to modify these three values. - .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED) - .ignoreExceptions(ResilientUserCertificateRevokedException.class) - .automaticTransitionFromOpenToHalfOpenEnabled(true) + // Users must not be able to modify this value. + // Only ResilientUserCertificateOCSPCheckFailedException counts as a failure. + // ResilientUserCertificateRevokedException and ResilientUserCertificateUnknownStatusException are + // counted as a SUCCESS because they represent a definitive OCSP answer (the service is healthy), + // not a transient failure. + // Clear any recordExceptions list of the given configuration first, because it is combined + // with the predicate below by OR and would otherwise widen what counts as a failure. + .recordExceptions() + .recordException(throwable -> throwable instanceof ResilientUserCertificateOCSPCheckFailedException) .build(); } private static RetryConfig getRetryConfig(RetryConfig retryConfig) { return RetryConfig.from(retryConfig) // Users must not be able to modify this value. - .ignoreExceptions(ResilientUserCertificateRevokedException.class) + .ignoreExceptions(ResilientUserCertificateRevokedException.class, ResilientUserCertificateUnknownStatusException.class) .build(); } @@ -363,6 +416,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/resilientocsp/exceptions/ResilientUserCertificateUnknownStatusException.java b/src/main/java/eu/webeid/resilientocsp/exceptions/ResilientUserCertificateUnknownStatusException.java new file mode 100644 index 00000000..41d2ce43 --- /dev/null +++ b/src/main/java/eu/webeid/resilientocsp/exceptions/ResilientUserCertificateUnknownStatusException.java @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.resilientocsp.exceptions; + +import eu.webeid.ocsp.exceptions.UserCertificateUnknownStatusException; +import eu.webeid.security.validator.ValidationInfo; + +import java.net.URI; + +/** + * Thrown when an OCSP service returns the unknown status for the user certificate and the unknown status is final. + */ +public class ResilientUserCertificateUnknownStatusException extends UserCertificateUnknownStatusException { + + private ValidationInfo validationInfo; + + public ResilientUserCertificateUnknownStatusException(URI ocspResponderUri, ValidationInfo validationInfo) { + super(ocspResponderUri); + this.validationInfo = validationInfo; + } + + public ValidationInfo getValidationInfo() { + return validationInfo; + } + + public void setValidationInfo(ValidationInfo validationInfo) { + this.validationInfo = validationInfo; + } +} diff --git a/src/main/java/eu/webeid/security/exceptions/AuthTokenSignatureValidationException.java b/src/main/java/eu/webeid/security/exceptions/AuthTokenSignatureValidationException.java index 0c7ab83c..fbf1d371 100644 --- a/src/main/java/eu/webeid/security/exceptions/AuthTokenSignatureValidationException.java +++ b/src/main/java/eu/webeid/security/exceptions/AuthTokenSignatureValidationException.java @@ -8,12 +8,14 @@ */ public class AuthTokenSignatureValidationException extends AuthTokenException { + private static final String MESSAGE = "Token signature validation has failed. Check that the origin and nonce are correct."; + public AuthTokenSignatureValidationException() { - super("Token signature validation has failed. Check that the origin and nonce are correct."); + super(MESSAGE); } public AuthTokenSignatureValidationException(Throwable cause) { - super("Token signature validation has failed", cause); + super(MESSAGE, cause); } } diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenSignatureValidator.java b/src/main/java/eu/webeid/security/validator/AuthTokenSignatureValidator.java index 18397f94..362c3555 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenSignatureValidator.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenSignatureValidator.java @@ -7,6 +7,7 @@ import eu.webeid.security.exceptions.AuthTokenParseException; import eu.webeid.security.exceptions.AuthTokenSignatureValidationException; import eu.webeid.security.exceptions.ChallengeNullOrEmptyException; +import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.impl.security.DefaultVerifySecureDigestRequest; import io.jsonwebtoken.security.SignatureAlgorithm; @@ -88,13 +89,15 @@ public void validate(String algorithm, String signature, PublicKey publicKey, St new ByteArrayInputStream(concatSignedFields), null, null, publicKey, decodedSignature); + final boolean signatureIsValid; try { - if (!signatureAlgorithm.verify(verificationRequest)) { - throw new AuthTokenSignatureValidationException(); - } - } catch (SignatureException e) { + signatureIsValid = signatureAlgorithm.verify(verificationRequest); + } catch (JwtException e) { throw new AuthTokenSignatureValidationException(e); } + if (!signatureIsValid) { + throw new AuthTokenSignatureValidationException(); + } } private MessageDigest hashAlgorithmForName(String algorithm) throws NoSuchAlgorithmException { diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java index afae8093..1752387f 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java @@ -121,6 +121,10 @@ private ValidationInfo validateToken(WebEidAuthToken token, String currentChalle // Use the clock instance so that the date can be mocked in tests. final Date now = DateAndTime.DefaultClock.getInstance().now(); + // Validate the certificate chain, validity, and revocation BEFORE verifying the + // token signature. The certificate is attacker-controlled until validated; + // verifying first lets an untrusted key drive expensive or unsafe crypto + // (e.g., a huge RSA public exponent) that later rejection can't undo. final List revocationInfoList = CertificateValidator.validateCertificateTrustAndRevocation( subjectCertificate, trustedCACertificateAnchors, diff --git a/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerNetworkTest.java b/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerNetworkTest.java index 0916071f..b3c07b1f 100644 --- a/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerNetworkTest.java +++ b/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerNetworkTest.java @@ -16,6 +16,10 @@ import eu.webeid.security.testutil.LocalOcspResponder.Reply; import eu.webeid.security.validator.revocationcheck.RevocationInfo; import eu.webeid.security.validator.revocationcheck.RevocationMode; +import org.bouncycastle.asn1.DERNull; +import org.bouncycastle.cert.ocsp.BasicOCSPResp; +import org.bouncycastle.cert.ocsp.CertificateID; +import org.bouncycastle.cert.ocsp.OCSPResp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -60,6 +64,20 @@ void whenResponderReturnsGood_thenValidationSendsRequestAndSucceeds() throws Exc assertThat(info).singleElement().extracting(RevocationInfo::ocspResponderUri).isEqualTo(responder.designatedUri()); } + @Test + void whenResponderEncodesCertIdHashAlgorithmWithNullParameters_thenValidationSucceeds() throws Exception { + responder.setNullCertIdHashParameters(true); + + final List info = validate(); + + assertRequestReachedResponder(); + final OCSPResp response = (OCSPResp) info.get(0).ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_RESPONSE); + final CertificateID responseCertificateId = ((BasicOCSPResp) response.getResponseObject()).getResponses()[0].getCertID(); + final CertificateID requestCertificateId = responder.receivedRequest().getRequestList()[0].getCertID(); + assertThat(requestCertificateId.toASN1Primitive().getHashAlgorithm().getParameters()).isNull(); + assertThat(responseCertificateId.toASN1Primitive().getHashAlgorithm().getParameters()).isEqualTo(DERNull.INSTANCE); + } + @Test void whenResponderReturnsRevoked_thenValidationRejectsCertificate() { responder.setReply(Reply.REVOKED); @@ -116,7 +134,8 @@ void whenDesignatedResponderOmitsNonce_thenConfiguredPolicyIsEnforced(boolean no @Test void whenDesignatedResponderCertificateDiffers_thenFailurePreservesCertificateCause() throws Exception { final var checker = customChecker(new DesignatedOcspServiceConfiguration( - responder.designatedUri(), responder.responderCertificate(), List.of(responder.issuer()), true)); + responder.designatedUri(), responder.responderCertificate(), List.of(responder.issuer()), true, + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, true)); responder.replaceResponderCertificate(true); assertThatThrownBy(() -> checker.validateCertificateNotRevoked(responder.subject(), responder.issuer())) @@ -138,7 +157,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 +258,9 @@ private OcspCertificateRevocationChecker customChecker(DesignatedOcspServiceConf OcspClientImpl.build(Duration.ofSeconds(2)), new OcspServiceProvider(designated, new AiaOcspServiceConfiguration(Set.of(), CertificateValidator.buildTrustAnchorsFromCertificates(anchors), - CertificateValidator.buildCertStoreFromCertificates(intermediates))), - OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW, - OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE); + CertificateValidator.buildCertStoreFromCertificates(intermediates), + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, true)), + OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW); } private List validate() throws Exception { @@ -250,7 +269,8 @@ private List validate() throws Exception { private List validate(boolean nonceEnabled) throws Exception { final var checker = customChecker(new DesignatedOcspServiceConfiguration( - responder.designatedUri(), responder.responderCertificate(), List.of(responder.issuer()), nonceEnabled)); + responder.designatedUri(), responder.responderCertificate(), List.of(responder.issuer()), nonceEnabled, + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, true)); return CertificateValidator.validateCertificateTrustAndRevocation( responder.subject(), CertificateValidator.buildTrustAnchorsFromCertificates(List.of(responder.issuer())), diff --git a/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java b/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java index 9b90c70e..3711fed6 100644 --- a/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java +++ b/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java @@ -11,13 +11,13 @@ 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; import eu.webeid.ocsp.service.OcspServiceProvider; import eu.webeid.security.validator.AuthTokenValidator; import org.bouncycastle.asn1.ocsp.OCSPResponseStatus; -import org.bouncycastle.cert.CertIOException; import org.bouncycastle.cert.ocsp.OCSPException; import org.bouncycastle.cert.ocsp.OCSPResp; import org.junit.jupiter.api.BeforeEach; @@ -25,7 +25,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 +46,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 +114,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 @@ -181,7 +180,8 @@ void whenOcspResponseHasInvalidResponderCert_thenThrows() throws Exception { validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) .isInstanceOf(UserCertificateOCSPCheckFailedException.class) .cause() - .isExactlyInstanceOf(OCSPException.class); + .isExactlyInstanceOf(OCSPCertificateException.class) + .hasMessage("Invalid responder certificate"); } @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..facbcbc1 100644 --- a/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java +++ b/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java @@ -4,24 +4,55 @@ 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 eu.webeid.ocsp.exceptions.UserCertificateUnknownStatusException; +import org.bouncycastle.asn1.ASN1Integer; +import org.bouncycastle.asn1.DERNull; +import org.bouncycastle.asn1.DEROctetString; +import org.bouncycastle.asn1.DLBitString; +import org.bouncycastle.asn1.nist.NISTObjectIdentifiers; +import org.bouncycastle.asn1.ocsp.CertID; +import org.bouncycastle.asn1.oiw.OIWObjectIdentifiers; +import org.bouncycastle.asn1.x509.AlgorithmIdentifier; +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.CertificateID; +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 org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import java.math.BigInteger; 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 java.util.stream.Stream; -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.validateCertificateId; import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateCertificateStatusUpdateTime; +import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateExtendedKeyUsageOcspSigning; +import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateKeyUsageOcspSigning; 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.junit.jupiter.params.provider.Arguments.argumentSet; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -29,17 +60,57 @@ 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"); + private static final AlgorithmIdentifier SHA1_ABSENT_PARAMETERS = new AlgorithmIdentifier(OIWObjectIdentifiers.idSHA1); + private static final AlgorithmIdentifier SHA1_NULL_PARAMETERS = new AlgorithmIdentifier(OIWObjectIdentifiers.idSHA1, DERNull.INSTANCE); + private static final byte[] ISSUER_NAME_HASH = new byte[]{1, 2, 3}; + private static final byte[] ISSUER_KEY_HASH = new byte[]{4, 5, 6}; + private static final BigInteger SERIAL_NUMBER = BigInteger.valueOf(42); + + @ParameterizedTest + @MethodSource("matchingCertificateIds") + void whenCertificateIdsMatch_thenCertificateIdValidationSucceeds(CertificateID requestCertificateId, CertificateID responseCertificateId) { + assertThatCode(() -> validateCertificateId(requestCertificateId, responseCertificateId, OCSP_URL)) + .doesNotThrowAnyException(); + } + + @ParameterizedTest + @MethodSource("differingCertificateIds") + void whenCertificateIdsDiffer_thenCertificateIdValidationThrows(CertificateID requestCertificateId, CertificateID responseCertificateId) { + assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> validateCertificateId(requestCertificateId, responseCertificateId, OCSP_URL)) + .withMessage("User certificate revocation check has failed: " + + "OCSP responded with certificate ID that differs from the requested ID (OCSP responder: https://example.org)"); + } + + @Test + void whenRequestCertificateIdIsNull_thenCertificateIdValidationThrowsNullPointerException() { + assertThatNullPointerException() + .isThrownBy(() -> validateCertificateId(null, certificateId(SHA1_ABSENT_PARAMETERS), OCSP_URL)) + .withMessage("requestCertificateId"); + } + + @Test + void whenResponseCertificateIdIsNull_thenCertificateIdValidationThrowsNullPointerException() { + assertThatNullPointerException() + .isThrownBy(() -> validateCertificateId(certificateId(SHA1_ABSENT_PARAMETERS), null, OCSP_URL)) + .withMessage("responseCertificateId"); + } @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 +124,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 +138,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 +152,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,30 +178,303 @@ 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 whenOcspResponseStatusIsUnknown_ThenThrowsUserCertificateOCSPCheckFailedException() throws Exception { - SingleResp unknownCertStatus = getUnknownCertStatusResponse(); + 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 + void whenOcspResponseStatusIsUnknown_ThenThrowsUserCertificateUnknownStatusException() throws Exception { + SingleResp unknownCertStatus = getUnknownCertStatusResponse(); + assertThatExceptionOfType(UserCertificateUnknownStatusException.class) .isThrownBy(() -> validateSubjectCertificateStatus(unknownCertStatus, OCSP_URL)) .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(() -> validateKeyUsageOcspSigning(cert, true)).doesNotThrowAnyException(); + } + + @Test + void whenKeyUsageExtensionAbsent_thenKeyUsageValidationThrows() throws Exception { + final X509Certificate cert = buildCertificate(); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> validateKeyUsageOcspSigning(cert, true)) + .withMessage("Certificate " + cert.getSubjectX500Principal() + + " does not contain the Key Usage extension required for OCSP response signing"); + } + + @Test + void whenCertHasKeyUsageNonRepudiation_thenKeyUsageValidationSucceeds() throws Exception { + final X509Certificate cert = buildCertificate( + new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.nonRepudiation).getEncoded())); + assertThatCode(() -> validateKeyUsageOcspSigning(cert, true)).doesNotThrowAnyException(); + } + + @Test + void whenKeyUsageWithoutDigitalSignatureOrNonRepudiation_thenKeyUsageValidationThrows() throws Exception { + final X509Certificate cert = buildCertificate( + new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyEncipherment).getEncoded())); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> validateKeyUsageOcspSigning(cert, true)) + .withMessage("Certificate " + cert.getSubjectX500Principal() + + " Key Usage extension does not contain Digital Signature or Non Repudiation, 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(() -> validateKeyUsageOcspSigning(cert, true)) + .withMessage("Certificate " + cert.getSubjectX500Principal() + + " Key Usage extension does not contain Digital Signature or Non Repudiation, which is required for OCSP response signing"); + } + + @Test + void whenKeyUsageNotRequiredAndCertHasKeyUsageNonRepudiation_thenKeyUsageValidationSucceeds() throws Exception { + final X509Certificate cert = buildCertificate( + new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.nonRepudiation).getEncoded())); + assertThatCode(() -> validateKeyUsageOcspSigning(cert, false)).doesNotThrowAnyException(); + } + + @Test + void whenKeyUsageNotRequiredButPresentWithoutDigitalSignatureOrNonRepudiation_thenKeyUsageValidationThrows() throws Exception { + final X509Certificate cert = buildCertificate( + new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyEncipherment).getEncoded())); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> validateKeyUsageOcspSigning(cert, false)) + .withMessage("Certificate " + cert.getSubjectX500Principal() + + " Key Usage extension does not contain Digital Signature or Non Repudiation, which is required for OCSP response signing"); + } + + @Test + void whenKeyUsageNotRequiredAndKeyUsageExtensionAbsent_thenKeyUsageValidationSucceeds() throws Exception { + final X509Certificate cert = buildCertificate(); + assertThatCode(() -> validateKeyUsageOcspSigning(cert, false)).doesNotThrowAnyException(); + } + + @Test + void whenKeyUsageNotRequiredAndCertIsNull_thenKeyUsageValidationThrowsNullPointerException() { + assertThatNullPointerException().isThrownBy(() -> validateKeyUsageOcspSigning(null, false)) + .withMessage("certificate"); + } + + @Test + void whenCertIsNull_thenKeyUsageValidationThrowsNullPointerException() { + assertThatNullPointerException().isThrownBy(() -> validateKeyUsageOcspSigning(null, true)) + .withMessage("certificate"); + } + + @Test + void whenCertHasKeyUsageKeyCertSignCombinedWithDigitalSignature_thenBasicConstraintsValidationThrows() throws Exception { + final X509Certificate cert = buildCertificate( + new Extension(Extension.keyUsage, true, + new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyCertSign).getEncoded())); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> validateBasicConstraintsNotCA(cert)) + .withMessage("Certificate " + cert.getSubjectX500Principal() + + " Key Usage extension contains Certificate Signing, which is not allowed for a non-CA OCSP responder certificate"); + } + + @Test + void whenCertHasKeyUsageKeyCertSignCombinedWithNonRepudiation_thenBasicConstraintsValidationThrows() throws Exception { + final X509Certificate cert = buildCertificate( + new Extension(Extension.keyUsage, true, + new KeyUsage(KeyUsage.nonRepudiation | KeyUsage.keyCertSign).getEncoded())); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> validateBasicConstraintsNotCA(cert)) + .withMessage("Certificate " + cert.getSubjectX500Principal() + + " Key Usage extension contains Certificate Signing, which is not allowed for a non-CA OCSP responder certificate"); + } + + @Test + void whenCertIsNotCAAndHasKeyUsageKeyCertSign_thenBasicConstraintsValidationThrows() throws Exception { + final X509Certificate cert = buildCertificate( + new Extension(Extension.basicConstraints, true, new BasicConstraints(false).getEncoded()), + new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign).getEncoded())); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> validateBasicConstraintsNotCA(cert)) + .withMessage("Certificate " + cert.getSubjectX500Principal() + + " Key Usage extension contains Certificate Signing, which is not allowed for a non-CA OCSP responder certificate"); + } + + @Test + void whenCertHasKeyUsageWithoutKeyCertSign_thenBasicConstraintsValidationSucceeds() throws Exception { + final X509Certificate cert = buildCertificate( + new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature).getEncoded())); + assertThatCode(() -> validateBasicConstraintsNotCA(cert)).doesNotThrowAnyException(); + } + + @Test + void whenCertKeyUsageBitStringEmpty_thenBasicConstraintsValidationSucceeds() throws Exception { + final X509Certificate cert = buildCertificate( + new Extension(Extension.keyUsage, true, new DLBitString(new byte[0]).getEncoded())); + assertThatCode(() -> validateBasicConstraintsNotCA(cert)).doesNotThrowAnyException(); + } + + @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 Stream matchingCertificateIds() { + return Stream.of( + argumentSet("equal", certificateId(SHA1_ABSENT_PARAMETERS), certificateId(SHA1_ABSENT_PARAMETERS)), + argumentSet("hash algorithm parameters absent in request and NULL in response", + certificateId(SHA1_ABSENT_PARAMETERS), certificateId(SHA1_NULL_PARAMETERS)), + argumentSet("hash algorithm parameters NULL in request and absent in response", + certificateId(SHA1_NULL_PARAMETERS), certificateId(SHA1_ABSENT_PARAMETERS)) + ); + } + + private static Stream differingCertificateIds() { + return Stream.of( + argumentSet("hash algorithm differs", + certificateId(SHA1_ABSENT_PARAMETERS), certificateId(new AlgorithmIdentifier(NISTObjectIdentifiers.id_sha256))), + argumentSet("hash algorithm has non-NULL parameters", + certificateId(SHA1_NULL_PARAMETERS), certificateId(new AlgorithmIdentifier(OIWObjectIdentifiers.idSHA1, new DEROctetString(new byte[0])))), + argumentSet("issuer name hash differs", + certificateId(SHA1_ABSENT_PARAMETERS), certificateId(SHA1_NULL_PARAMETERS, new byte[]{9, 9, 9}, ISSUER_KEY_HASH, SERIAL_NUMBER)), + argumentSet("issuer key hash differs", + certificateId(SHA1_ABSENT_PARAMETERS), certificateId(SHA1_NULL_PARAMETERS, ISSUER_NAME_HASH, new byte[]{9, 9, 9}, SERIAL_NUMBER)), + argumentSet("serial number differs", + certificateId(SHA1_ABSENT_PARAMETERS), certificateId(SHA1_NULL_PARAMETERS, ISSUER_NAME_HASH, ISSUER_KEY_HASH, SERIAL_NUMBER.add(BigInteger.ONE))) + ); + } + + private static CertificateID certificateId(AlgorithmIdentifier hashAlgorithm) { + return certificateId(hashAlgorithm, ISSUER_NAME_HASH, ISSUER_KEY_HASH, SERIAL_NUMBER); + } + + private static CertificateID certificateId(AlgorithmIdentifier hashAlgorithm, byte[] issuerNameHash, byte[] issuerKeyHash, BigInteger serialNumber) { + return new CertificateID(new CertID(hashAlgorithm, new DEROctetString(issuerNameHash), + new DEROctetString(issuerKeyHash), new ASN1Integer(serialNumber))); + } + private static Date getThisUpdateWithinAgeLimit(Instant now) { return Date.from(now.minus(THIS_UPDATE_AGE.minusSeconds(1))); } private static SingleResp getUnknownCertStatusResponse() throws Exception { - final OCSPResp ocspRespUnknown = new OCSPResp(getOcspResponseBytesFromResources("ocsp_response_unknown.der")); + final OCSPResp ocspRespUnknown = new OCSPResp(bytesFromResource("ocsp_response_unknown.der")); final BasicOCSPResp basicResponse = (BasicOCSPResp) ocspRespUnknown.getResponseObject(); return basicResponse.getResponses()[0]; } + private static class UnexpectedCertificateStatus implements CertificateStatus { + } + } diff --git a/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceConfigurationTest.java b/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceConfigurationTest.java index 06d79020..5e5132a8 100644 --- a/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceConfigurationTest.java +++ b/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceConfigurationTest.java @@ -3,6 +3,7 @@ package eu.webeid.ocsp.service; +import eu.webeid.ocsp.OcspCertificateRevocationChecker; import eu.webeid.security.certificate.CertificateValidator; import org.bouncycastle.asn1.x500.X500Name; import org.junit.jupiter.api.Test; @@ -25,7 +26,7 @@ void whenCallerMutatesCollections_thenConfigurationRemainsUnchanged() throws Exc final TrustAnchor anchor = new TrustAnchor(getTestEsteid2018CA(), null); final Set anchors = new HashSet<>(Set.of(anchor)); final AiaOcspServiceConfiguration configuration = new AiaOcspServiceConfiguration( - nonceDisabledIssuerDNs, anchors, CertificateValidator.buildCertStoreFromCertificates(List.of(getTestEsteid2018CA()))); + nonceDisabledIssuerDNs, anchors, CertificateValidator.buildCertStoreFromCertificates(List.of(getTestEsteid2018CA())), OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, true); nonceDisabledIssuerDNs.clear(); anchors.clear(); diff --git a/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceTest.java b/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceTest.java new file mode 100644 index 00000000..92ee8b76 --- /dev/null +++ b/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceTest.java @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.ocsp.service; + +import eu.webeid.security.certificate.CertificateValidator; +import org.bouncycastle.asn1.x500.X500Name; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.security.cert.CertStore; +import java.security.cert.TrustAnchor; +import java.security.cert.X509Certificate; +import java.util.List; +import java.util.Set; + +import static eu.webeid.ocsp.OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE; +import static eu.webeid.ocsp.OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE; +import static eu.webeid.ocsp.protocol.IssuerDistinguishedName.getIssuerDistinguishedName; +import static eu.webeid.security.testutil.Certificates.getJaakKristjanEsteid2018Cert; +import static eu.webeid.security.testutil.Certificates.getMariliisEsteid2015Cert; +import static eu.webeid.security.testutil.Certificates.getTestEsteid2015CA; +import static eu.webeid.security.testutil.Certificates.getTestEsteid2018CA; +import static eu.webeid.security.testutil.Certificates.getDemoEsteidSk2018AiaOcspResponder; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Unit tests for the {@link AiaOcspService} behaviour introduced in commit e2bd57e3: + * nonce support is now keyed on the certificate's issuer distinguished name (previously the OCSP URL), + * and the service carries an optional {@link FallbackOcspService}. + */ +class AiaOcspServiceTest { + + private static final URI ESTEID2018_AIA_OCSP_URI = URI.create("http://aia.demo.sk.ee/esteid2018"); + private static final URI ESTEID2015_AIA_OCSP_URI = URI.create("http://aia.demo.sk.ee/esteid2015"); + private static final URI FALLBACK_URI = URI.create("http://fallback.ocsp.test"); + + private static Set trustedCaAnchors; + private static CertStore trustedCaCertStore; + private static X509Certificate esteid2018UserCert; + private static X509Certificate esteid2015UserCert; + private static X500Name esteid2018IssuerDN; + private static X500Name esteid2015IssuerDN; + + @BeforeAll + static void setUpFixtures() throws Exception { + List trustedCaCertificates = List.of(getTestEsteid2018CA(), getTestEsteid2015CA()); + trustedCaAnchors = CertificateValidator.buildTrustAnchorsFromCertificates(trustedCaCertificates); + trustedCaCertStore = CertificateValidator.buildCertStoreFromCertificates(trustedCaCertificates); + esteid2018UserCert = getJaakKristjanEsteid2018Cert(); + esteid2015UserCert = getMariliisEsteid2015Cert(); + esteid2018IssuerDN = getIssuerDistinguishedName(esteid2018UserCert); + esteid2015IssuerDN = getIssuerDistinguishedName(esteid2015UserCert); + } + + private static AiaOcspServiceConfiguration configurationWithNonceDisabledFor(X500Name... nonceDisabledIssuerDNs) { + return new AiaOcspServiceConfiguration(Set.of(nonceDisabledIssuerDNs), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true); + } + + @Test + void whenIssuerDnIsInNonceDisabledSet_thenDoesNotSupportNonce() throws Exception { + AiaOcspServiceConfiguration configuration = configurationWithNonceDisabledFor(esteid2015IssuerDN); + + AiaOcspService service = new AiaOcspService(configuration, esteid2015UserCert, null); + + assertThat(service.getAccessLocation()).isEqualTo(ESTEID2015_AIA_OCSP_URI); + assertThat(service.doesSupportNonce()).isFalse(); + } + + @Test + void whenIssuerDnIsNotInNonceDisabledSet_thenSupportsNonce() throws Exception { + // Only the ESTEID-SK 2015 issuer is nonce-disabled, so a certificate from the 2018 issuer must still support nonce. + AiaOcspServiceConfiguration configuration = configurationWithNonceDisabledFor(esteid2015IssuerDN); + + AiaOcspService service = new AiaOcspService(configuration, esteid2018UserCert, null); + + assertThat(service.getAccessLocation()).isEqualTo(ESTEID2018_AIA_OCSP_URI); + assertThat(service.doesSupportNonce()).isTrue(); + } + + @Test + void whenFallbackServiceProvided_thenGetFallbackServiceReturnsIt() throws Exception { + AiaOcspServiceConfiguration configuration = configurationWithNonceDisabledFor(); + FallbackOcspServiceConfiguration fallbackConfiguration = new FallbackOcspServiceConfiguration( + FALLBACK_URI, getDemoEsteidSk2018AiaOcspResponder(), true, + null, getTestEsteid2018CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true); + FallbackOcspService fallback = new FallbackOcspService(fallbackConfiguration); + + AiaOcspService service = new AiaOcspService(configuration, esteid2018UserCert, fallback); + + assertThat(service.getFallbackService()).containsSame(fallback); + assertThat(service.getFallbackService().get().getAccessLocation()).isEqualTo(FALLBACK_URI); + } + + @Test + void whenFallbackServiceIsNull_thenGetFallbackServiceIsEmpty() throws Exception { + AiaOcspServiceConfiguration configuration = configurationWithNonceDisabledFor(); + + AiaOcspService service = new AiaOcspService(configuration, esteid2018UserCert, null); + + assertThat(service.getFallbackService()).isEmpty(); + } + + @Test + void whenCertificateIsNull_thenThrows() { + AiaOcspServiceConfiguration configuration = configurationWithNonceDisabledFor(); + + assertThatExceptionOfType(NullPointerException.class) + .isThrownBy(() -> new AiaOcspService(configuration, null, null)); + } +} diff --git a/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceConfigurationTest.java b/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceConfigurationTest.java index 819ea3a0..7443eac0 100644 --- a/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceConfigurationTest.java +++ b/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceConfigurationTest.java @@ -3,33 +3,205 @@ package eu.webeid.ocsp.service; +import eu.webeid.ocsp.exceptions.OCSPCertificateException; import eu.webeid.security.certificate.CertificateValidator; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import java.net.URI; +import java.security.cert.CertStore; import java.security.cert.TrustAnchor; +import java.security.cert.X509Certificate; +import java.time.Duration; import java.util.HashSet; import java.util.List; import java.util.Set; +import static eu.webeid.ocsp.OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE; +import static eu.webeid.ocsp.OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE; + +import static eu.webeid.security.testutil.Certificates.getDemoEsteidSk2018AiaOcspResponder; +import static eu.webeid.security.testutil.Certificates.getJaakKristjanEsteid2018Cert; +import static eu.webeid.security.testutil.Certificates.getTestEsteid2015CA; import static eu.webeid.security.testutil.Certificates.getTestEsteid2018CA; +import static eu.webeid.security.testutil.Certificates.getTestSkOcspResponder2020; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; import static org.assertj.core.api.Assertions.assertThatThrownBy; class FallbackOcspServiceConfigurationTest { + private static final URI FALLBACK_URI = URI.create("http://fallback.ocsp.test"); + + private static Set trustedCaAnchors; + private static CertStore trustedCaCertStore; + private static X509Certificate aiaOcspResponderCert; + private static X509Certificate ocspResponder2020Cert; + private static X509Certificate nonSigningUserCert; + + @BeforeAll + static void setUpFixtures() throws Exception { + List trustedCaCertificates = List.of(getTestEsteid2018CA(), getTestEsteid2015CA()); + trustedCaAnchors = CertificateValidator.buildTrustAnchorsFromCertificates(trustedCaCertificates); + trustedCaCertStore = CertificateValidator.buildCertStoreFromCertificates(trustedCaCertificates); + // The DEMO AIA responder certificate satisfies every OCSP responder extension requirement. + aiaOcspResponderCert = getDemoEsteidSk2018AiaOcspResponder(); + // TEST of SK OCSP RESPONDER 2020 carries no Key Usage extension at all. + ocspResponder2020Cert = getTestSkOcspResponder2020(); + nonSigningUserCert = getJaakKristjanEsteid2018Cert(); + } + + @Test + void whenAccessLocationIsNull_thenThrows() { + assertThatNullPointerException() + .isThrownBy(() -> new FallbackOcspServiceConfiguration( + null, null, true, null, getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true)) + .withMessage("Fallback OCSP service access location"); + } + + @Test + void whenIssuerDnIsNull_thenThrows() { + assertThatNullPointerException() + .isThrownBy(() -> new FallbackOcspServiceConfiguration( + FALLBACK_URI, null, true, null, null, trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true)) + .withMessage("issuerCertificate"); + } + + @Test + void whenTrustedCaAnchorsIsNull_thenThrows() { + assertThatNullPointerException() + .isThrownBy(() -> new FallbackOcspServiceConfiguration( + FALLBACK_URI, null, true, null, getTestEsteid2015CA(), null, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true)) + .withMessage("trustedCACertificateAnchors"); + } + + @Test + void whenTrustedCaCertStoreIsNull_thenThrows() { + assertThatNullPointerException() + .isThrownBy(() -> new FallbackOcspServiceConfiguration( + FALLBACK_URI, null, true, null, getTestEsteid2015CA(), trustedCaAnchors, null, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true)) + .withMessage("trustedCACertificateCertStore"); + } + + @Test + void whenMaxThisUpdateAgeIsNull_thenThrows() { + assertThatNullPointerException() + .isThrownBy(() -> new FallbackOcspServiceConfiguration( + FALLBACK_URI, null, true, null, getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore, + null, DEFAULT_NEXT_UPDATE_AGE, true)) + .withMessage("maxThisUpdateAge must not be null"); + } + + @Test + void whenMaxNextUpdateAgeIsNull_thenThrows() { + assertThatNullPointerException() + .isThrownBy(() -> new FallbackOcspServiceConfiguration( + FALLBACK_URI, null, true, null, getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, null, true)) + .withMessage("maxNextUpdateAge must not be null"); + } + + @Test + void whenMaxThisUpdateAgeIsZero_thenThrows() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new FallbackOcspServiceConfiguration( + FALLBACK_URI, null, true, null, getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore, + Duration.ZERO, DEFAULT_NEXT_UPDATE_AGE, true)) + .withMessage("maxThisUpdateAge must be greater than zero"); + } + + @Test + void whenMaxThisUpdateAgeIsNegative_thenThrows() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new FallbackOcspServiceConfiguration( + FALLBACK_URI, null, true, null, getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore, + Duration.ofMinutes(-1), DEFAULT_NEXT_UPDATE_AGE, true)) + .withMessage("maxThisUpdateAge must be greater than zero"); + } + + @Test + void whenMaxNextUpdateAgeIsZero_thenThrows() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new FallbackOcspServiceConfiguration( + FALLBACK_URI, null, true, null, getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, Duration.ZERO, true)) + .withMessage("maxNextUpdateAge must be greater than zero"); + } + + @Test + void whenMaxNextUpdateAgeIsNegative_thenThrows() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new FallbackOcspServiceConfiguration( + FALLBACK_URI, null, true, null, getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, Duration.ofMinutes(-1), true)) + .withMessage("maxNextUpdateAge must be greater than zero"); + } + + @Test + void whenResponderCertificateLacksSigningExtension_thenThrows() { + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> new FallbackOcspServiceConfiguration( + FALLBACK_URI, nonSigningUserCert, true, null, getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true)) + .withMessageContaining("Extended Key Usage extension does not contain OCSP Signing, " + + "which is required for OCSP response signing"); + } + + @Test + void whenResponderCertificateLacksKeyUsageExtension_thenThrows() { + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> new FallbackOcspServiceConfiguration( + FALLBACK_URI, ocspResponder2020Cert, true, null, getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true)) + .withMessageContaining("does not contain the Key Usage extension required for OCSP response signing"); + } + + @Test + void whenResponderCertificateIsNull_thenConstructionSucceeds() { + assertThatCode(() -> new FallbackOcspServiceConfiguration( + FALLBACK_URI, null, true, null, getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true)) + .doesNotThrowAnyException(); + } + + @Test + void whenResponderCertificateHasSigningExtension_thenConstructionSucceedsAndAccessorsReturnConfiguredValues() throws Exception { + FallbackOcspServiceConfiguration nextFallback = new FallbackOcspServiceConfiguration( + URI.create("http://next.fallback.ocsp.test"), null, false, null, + getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true); + + FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( + FALLBACK_URI, aiaOcspResponderCert, true, nextFallback, + getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore, + Duration.ofMinutes(3), Duration.ofMinutes(4), true); + + assertThat(configuration.getAccessLocation()).isEqualTo(FALLBACK_URI); + assertThat(configuration.getResponderCertificate()).isEqualTo(aiaOcspResponderCert); + assertThat(configuration.doesSupportNonce()).isTrue(); + assertThat(configuration.getNextFallbackConfiguration()).isSameAs(nextFallback); + assertThat(configuration.getIssuerCertificate()).isEqualTo(getTestEsteid2015CA()); + assertThat(configuration.getTrustedCACertificateAnchors()).isSameAs(trustedCaAnchors); + assertThat(configuration.getTrustedCACertificateCertStore()).isSameAs(trustedCaCertStore); + assertThat(configuration.getMaxThisUpdateAge()).isEqualTo(Duration.ofMinutes(3)); + assertThat(configuration.getMaxNextUpdateAge()).isEqualTo(Duration.ofMinutes(4)); + } + @Test void whenCallerMutatesCollections_thenConfigurationRemainsUnchanged() throws Exception { final TrustAnchor anchor = new TrustAnchor(getTestEsteid2018CA(), null); final Set anchors = new HashSet<>(Set.of(anchor)); final FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( - URI.create("http://fallback.ocsp.test"), - null, - true, - null, - getTestEsteid2018CA(), - anchors, - CertificateValidator.buildCertStoreFromCertificates(List.of(getTestEsteid2018CA()))); + FALLBACK_URI, null, true, null, getTestEsteid2018CA(), anchors, + CertificateValidator.buildCertStoreFromCertificates(List.of(getTestEsteid2018CA())), + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true); anchors.clear(); diff --git a/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceTest.java b/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceTest.java new file mode 100644 index 00000000..81e4a770 --- /dev/null +++ b/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceTest.java @@ -0,0 +1,231 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.ocsp.service; + +import eu.webeid.ocsp.exceptions.OCSPCertificateException; +import eu.webeid.security.certificate.CertificateValidator; +import eu.webeid.security.exceptions.CertificateExpiredException; +import org.bouncycastle.cert.X509CertificateHolder; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.URI; +import java.security.cert.CertStore; +import java.security.cert.TrustAnchor; +import java.security.cert.X509Certificate; +import java.util.Date; +import java.util.List; +import java.util.Set; + +import static eu.webeid.ocsp.OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE; +import static eu.webeid.ocsp.OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE; +import static eu.webeid.security.testutil.Certificates.getJaakKristjanEsteid2018Cert; +import static eu.webeid.security.testutil.Certificates.getDemoEsteidSk2018AiaOcspResponder; +import static eu.webeid.security.testutil.Certificates.getTestEsteid2015CA; +import static eu.webeid.security.testutil.Certificates.getTestEsteid2018CA; +import static eu.webeid.security.testutil.Certificates.getTestSelfSignedOcspResponder; +import static eu.webeid.security.testutil.Certificates.getTestSkOcspResponder2020; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class FallbackOcspServiceTest { + + private static final URI PRIMARY_FALLBACK_URI = URI.create("http://primary-fallback.ocsp.test"); + private static final URI SECONDARY_FALLBACK_URI = URI.create("http://secondary-fallback.ocsp.test"); + private static final Date VALIDATION_DATE_WITHIN_RESPONDER_VALIDITY = new Date(1630000000000L); + + private static Set trustedCaAnchors; + private static CertStore trustedCaCertStore; + private static X509Certificate aiaOcspResponderCert; + private static X509Certificate selfSignedOcspResponderCert; + private static X509Certificate ocspResponder2020Cert; + private static X509Certificate esteid2018CaCert; + private static X509Certificate nonSigningUserCert; + + @BeforeAll + static void setUpFixtures() throws Exception { + List trustedCaCertificates = List.of(getTestEsteid2018CA(), getTestEsteid2015CA()); + trustedCaAnchors = CertificateValidator.buildTrustAnchorsFromCertificates(trustedCaCertificates); + trustedCaCertStore = CertificateValidator.buildCertStoreFromCertificates(trustedCaCertificates); + // The DEMO AIA responder certificate satisfies the OCSP responder extension requirements + // (not a CA, Key Usage Digital Signature, no Certificate Signing, Extended Key Usage OCSP Signing) + // and it chains to the TEST of ESTEID2018 CA, which is one of the trusted CA anchors above. + aiaOcspResponderCert = getDemoEsteidSk2018AiaOcspResponder(); + // The self-signed responder certificate satisfies the same extension requirements, but no + // trusted CA issued it. Use it when the PKIX trust check must be the failure. + selfSignedOcspResponderCert = getTestSelfSignedOcspResponder(); + // TEST of SK OCSP RESPONDER 2020 carries no Key Usage extension at all. Use it only where the + // Key Usage check must reject the certificate. + ocspResponder2020Cert = getTestSkOcspResponder2020(); + esteid2018CaCert = getTestEsteid2018CA(); + nonSigningUserCert = getJaakKristjanEsteid2018Cert(); + } + + @Test + void whenConfigurationIsProvided_thenAccessorsReturnConfiguredValues() throws Exception { + FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( + PRIMARY_FALLBACK_URI, aiaOcspResponderCert, true, + null, getTestEsteid2018CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true); + + FallbackOcspService service = new FallbackOcspService(configuration); + + assertThat(service.getAccessLocation()).isEqualTo(PRIMARY_FALLBACK_URI); + assertThat(service.doesSupportNonce()).isTrue(); + assertThat(service.getNextFallback()).isNull(); + } + + @Test + void whenNextFallbackConfigurationProvided_thenChainIsBuiltRecursively() throws Exception { + FallbackOcspServiceConfiguration secondaryConfiguration = new FallbackOcspServiceConfiguration( + SECONDARY_FALLBACK_URI, null, false, + null, getTestEsteid2018CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true); + FallbackOcspServiceConfiguration primaryConfiguration = new FallbackOcspServiceConfiguration( + PRIMARY_FALLBACK_URI, null, true, + secondaryConfiguration, getTestEsteid2018CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true); + + FallbackOcspService primary = new FallbackOcspService(primaryConfiguration); + + FallbackOcspService secondary = primary.getNextFallback(); + assertThat(secondary).isNotNull(); + assertThat(secondary.getAccessLocation()).isEqualTo(SECONDARY_FALLBACK_URI); + assertThat(secondary.doesSupportNonce()).isFalse(); + assertThat(secondary.getNextFallback()).isNull(); + } + + @Test + void whenResponderCertificateIsPinnedAndMatches_thenValidationSucceeds() throws Exception { + FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( + PRIMARY_FALLBACK_URI, aiaOcspResponderCert, true, + null, getTestEsteid2018CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true); + FallbackOcspService service = new FallbackOcspService(configuration); + X509CertificateHolder matchingHolder = new X509CertificateHolder(aiaOcspResponderCert.getEncoded()); + + assertThatCode(() -> + service.validateResponderCertificate(matchingHolder, getTestEsteid2018CA(), VALIDATION_DATE_WITHIN_RESPONDER_VALIDITY)) + .doesNotThrowAnyException(); + } + + @Test + void whenResponderCertificateIsPinnedAndDiffers_thenThrows() throws Exception { + FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( + PRIMARY_FALLBACK_URI, aiaOcspResponderCert, true, + null, getTestEsteid2018CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true); + FallbackOcspService service = new FallbackOcspService(configuration); + X509CertificateHolder differentHolder = new X509CertificateHolder(esteid2018CaCert.getEncoded()); + + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> + service.validateResponderCertificate(differentHolder, getTestEsteid2018CA(), VALIDATION_DATE_WITHIN_RESPONDER_VALIDITY)) + .withMessage("Responder certificate from the OCSP response is not equal to the configured fallback OCSP responder certificate"); + } + + @Test + void whenResponderCertificateIsNotPinnedButTrustedByCa_thenValidationSucceeds() throws Exception { + FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( + PRIMARY_FALLBACK_URI, null, true, + null, getTestEsteid2018CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true); + FallbackOcspService service = new FallbackOcspService(configuration); + // The DEMO AIA responder certificate chains to the TEST of ESTEID2018 CA, which is a trusted anchor. + X509CertificateHolder responderHolder = new X509CertificateHolder(aiaOcspResponderCert.getEncoded()); + + assertThatCode(() -> + service.validateResponderCertificate(responderHolder, getTestEsteid2018CA(), VALIDATION_DATE_WITHIN_RESPONDER_VALIDITY)) + .doesNotThrowAnyException(); + } + + @Test + void whenResponderCertificateIsNotPinnedAndLacksSigningExtension_thenThrows() throws Exception { + FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( + PRIMARY_FALLBACK_URI, null, true, + null, getTestEsteid2018CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true); + FallbackOcspService service = new FallbackOcspService(configuration); + X509CertificateHolder nonSigningHolder = new X509CertificateHolder(nonSigningUserCert.getEncoded()); + + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> + service.validateResponderCertificate(nonSigningHolder, getTestEsteid2018CA(), VALIDATION_DATE_WITHIN_RESPONDER_VALIDITY)) + .withMessageContaining("Extended Key Usage extension does not contain OCSP Signing, " + + "which is required for OCSP response signing"); + } + + @Test + void whenResponderCertificateLacksKeyUsageExtension_thenThrows() throws Exception { + // TEST of SK OCSP RESPONDER 2020 carries the OCSP-signing EKU, but it has no Key Usage extension. + // FallbackOcspService runs the certificate extension checks before the PKIX trust check, so the + // Key Usage check rejects this certificate whatever the configured trust anchors are. + FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( + PRIMARY_FALLBACK_URI, null, true, + null, getTestEsteid2018CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true); + FallbackOcspService service = new FallbackOcspService(configuration); + X509CertificateHolder noKeyUsageHolder = new X509CertificateHolder(ocspResponder2020Cert.getEncoded()); + + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> + service.validateResponderCertificate(noKeyUsageHolder, getTestEsteid2018CA(), VALIDATION_DATE_WITHIN_RESPONDER_VALIDITY)) + .withMessageContaining("does not contain the Key Usage extension required for OCSP response signing"); + } + + @Test + void whenResponderCertificateIsNotIssuedBySubjectIssuer_thenThrows() throws Exception { + FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( + PRIMARY_FALLBACK_URI, null, true, + null, getTestEsteid2018CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true); + FallbackOcspService service = new FallbackOcspService(configuration); + // The self-signed responder certificate satisfies every OCSP responder extension requirement, but it was + // not issued by the subject certificate's issuer, so it is rejected before PKIX path building. + X509CertificateHolder untrustedButEkuValidHolder = new X509CertificateHolder(selfSignedOcspResponderCert.getEncoded()); + + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> + service.validateResponderCertificate(untrustedButEkuValidHolder, getTestEsteid2018CA(), VALIDATION_DATE_WITHIN_RESPONDER_VALIDITY)) + .withMessage("Fallback OCSP responder is not issued by the subject certificate's issuer"); + } + + @Test + void whenResponderCertificateHolderConversionFails_thenThrows() throws Exception { + FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( + PRIMARY_FALLBACK_URI, aiaOcspResponderCert, true, + null, getTestEsteid2018CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true); + FallbackOcspService service = new FallbackOcspService(configuration); + // Make JcaX509CertificateConverter.getCertificate(holder) fail: the converter calls holder.getEncoded() + // and wraps the resulting IOException into a CertificateException, which the service catches and rewraps. + X509CertificateHolder unconvertibleHolder = mock(X509CertificateHolder.class); + when(unconvertibleHolder.getEncoded()).thenThrow(new IOException("simulated encoding failure")); + + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> + service.validateResponderCertificate(unconvertibleHolder, getTestEsteid2018CA(), VALIDATION_DATE_WITHIN_RESPONDER_VALIDITY)) + .withMessage("Invalid responder certificate") + .withCauseInstanceOf(java.security.cert.CertificateException.class); + } + + @Test + void whenResponderCertificateIsExpiredAtValidationDate_thenThrows() throws Exception { + FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( + PRIMARY_FALLBACK_URI, aiaOcspResponderCert, true, + null, getTestEsteid2018CA(), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true); + FallbackOcspService service = new FallbackOcspService(configuration); + X509CertificateHolder responderHolder = new X509CertificateHolder(aiaOcspResponderCert.getEncoded()); + Date farFuture = new Date(4102444800000L); // 2100-01-01 + + assertThatExceptionOfType(CertificateExpiredException.class) + .isThrownBy(() -> + service.validateResponderCertificate(responderHolder, getTestEsteid2018CA(), farFuture)); + } +} diff --git a/src/test/java/eu/webeid/ocsp/service/OcspServiceConfigurationTest.java b/src/test/java/eu/webeid/ocsp/service/OcspServiceConfigurationTest.java new file mode 100644 index 00000000..f5a3dd1a --- /dev/null +++ b/src/test/java/eu/webeid/ocsp/service/OcspServiceConfigurationTest.java @@ -0,0 +1,212 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.ocsp.service; + +import eu.webeid.ocsp.exceptions.OCSPCertificateException; +import eu.webeid.security.certificate.CertificateValidator; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.ExtendedKeyUsage; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.security.cert.CertStore; +import java.security.cert.TrustAnchor; +import java.security.cert.X509Certificate; +import java.util.List; +import java.util.Set; + +import static eu.webeid.ocsp.OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE; +import static eu.webeid.ocsp.OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE; +import static eu.webeid.security.testutil.Certificates.getTestEsteid2018CA; +import static eu.webeid.security.testutil.TestCertificateBuilder.buildCertificate; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +class OcspServiceConfigurationTest { + + private static final URI OCSP_URL = URI.create("http://demo.sk.ee/ocsp"); + + @Test + void whenDesignatedOcspServiceConfigurationResponderCertIsCA_thenConstructorThrows() throws Exception { + final X509Certificate caResponder = buildCertificate( + new Extension(Extension.basicConstraints, true, new BasicConstraints(0).getEncoded())); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> newDesignatedOcspServiceConfiguration(caResponder)) + .withMessage("Certificate " + caResponder.getSubjectX500Principal() + + " must not be a CA certificate (Basic Constraints CA:TRUE is not allowed for OCSP responder)"); + } + + @Test + void whenDesignatedOcspServiceConfigurationResponderCertMissingKeyUsageDigitalSignatureAndNonRepudiation_thenConstructorThrows() throws Exception { + final X509Certificate responder = buildCertificate( + new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyEncipherment).getEncoded())); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> newDesignatedOcspServiceConfiguration(responder)) + .withMessage("Certificate " + responder.getSubjectX500Principal() + + " Key Usage extension does not contain Digital Signature or Non Repudiation, 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 a non-CA OCSP responder certificate"); + } + + @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 whenFallbackOcspServiceConfigurationResponderCertMissingKeyUsageDigitalSignatureAndNonRepudiation_thenConstructorThrows() throws Exception { + final X509Certificate responder = buildCertificate( + new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyEncipherment).getEncoded())); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> newFallbackOcspServiceConfiguration(responder)) + .withMessage("Certificate " + responder.getSubjectX500Principal() + + " Key Usage extension does not contain Digital Signature or Non Repudiation, 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 a non-CA OCSP responder certificate"); + } + + @Test + void whenFallbackOcspServiceConfigurationResponderCertMissingOcspSigningEku_thenConstructorThrows() throws Exception { + final X509Certificate responder = buildCertificate( + new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature).getEncoded()), + new Extension(Extension.extendedKeyUsage, true, + new ExtendedKeyUsage(KeyPurposeId.id_kp_clientAuth).getEncoded())); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> newFallbackOcspServiceConfiguration(responder)) + .withMessage("Certificate " + responder.getSubjectX500Principal() + + " Extended Key Usage extension does not contain OCSP Signing, which is required for OCSP response signing"); + } + + @Test + void whenDesignatedOcspServiceConfigurationResponderCertHasKeyUsageNonRepudiation_thenResponderCertIsAccepted() throws Exception { + final X509Certificate responder = buildResponderCertificateWithKeyUsage(KeyUsage.nonRepudiation); + assertThatCode(() -> newDesignatedOcspServiceConfiguration(responder)) + .doesNotThrowAnyException(); + } + + @Test + void whenFallbackOcspServiceConfigurationResponderCertHasKeyUsageNonRepudiation_thenResponderCertIsAccepted() throws Exception { + final X509Certificate responder = buildResponderCertificateWithKeyUsage(KeyUsage.nonRepudiation); + final List trustedCAs = List.of(getTestEsteid2018CA()); + assertThatCode(() -> new FallbackOcspServiceConfiguration(OCSP_URL, responder, true, null, getTestEsteid2018CA(), + CertificateValidator.buildTrustAnchorsFromCertificates(trustedCAs), + CertificateValidator.buildCertStoreFromCertificates(trustedCAs), + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true)) + .doesNotThrowAnyException(); + } + + @Test + void whenDesignatedOcspServiceConfigurationKeyUsageNotRequired_thenResponderCertWithoutDigitalSignatureAndNonRepudiationIsRejected() throws Exception { + final X509Certificate responder = buildResponderCertificateWithKeyUsage(KeyUsage.keyEncipherment); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> new DesignatedOcspServiceConfiguration( + OCSP_URL, responder, List.of(getTestEsteid2018CA()), true, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, false)) + .withMessage("Certificate " + responder.getSubjectX500Principal() + + " Key Usage extension does not contain Digital Signature or Non Repudiation, which is required for OCSP response signing"); + } + + @Test + void whenFallbackOcspServiceConfigurationKeyUsageNotRequired_thenResponderCertWithoutDigitalSignatureAndNonRepudiationIsRejected() throws Exception { + final X509Certificate responder = buildResponderCertificateWithKeyUsage(KeyUsage.keyEncipherment); + final List trustedCAs = List.of(getTestEsteid2018CA()); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> new FallbackOcspServiceConfiguration(OCSP_URL, responder, true, null, getTestEsteid2018CA(), + CertificateValidator.buildTrustAnchorsFromCertificates(trustedCAs), + CertificateValidator.buildCertStoreFromCertificates(trustedCAs), + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, false)) + .withMessage("Certificate " + responder.getSubjectX500Principal() + + " Key Usage extension does not contain Digital Signature or Non Repudiation, which is required for OCSP response signing"); + } + + @Test + void whenDesignatedOcspServiceConfigurationKeyUsageNotRequired_thenResponderCertWithoutKeyUsageIsAccepted() throws Exception { + final X509Certificate responder = buildResponderCertificateWithoutKeyUsage(); + assertThatCode(() -> new DesignatedOcspServiceConfiguration( + OCSP_URL, responder, List.of(getTestEsteid2018CA()), true, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, false)) + .doesNotThrowAnyException(); + } + + @Test + void whenFallbackOcspServiceConfigurationKeyUsageNotRequired_thenResponderCertWithoutKeyUsageIsAccepted() throws Exception { + final X509Certificate responder = buildResponderCertificateWithoutKeyUsage(); + final List trustedCAs = List.of(getTestEsteid2018CA()); + assertThatCode(() -> new FallbackOcspServiceConfiguration(OCSP_URL, responder, true, null, getTestEsteid2018CA(), + CertificateValidator.buildTrustAnchorsFromCertificates(trustedCAs), + CertificateValidator.buildCertStoreFromCertificates(trustedCAs), + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, false)) + .doesNotThrowAnyException(); + } + + private static X509Certificate buildResponderCertificateWithKeyUsage(int keyUsage) throws Exception { + return buildCertificate( + new Extension(Extension.keyUsage, true, new KeyUsage(keyUsage).getEncoded()), + new Extension(Extension.extendedKeyUsage, true, + new ExtendedKeyUsage(KeyPurposeId.id_kp_OCSPSigning).getEncoded())); + } + + private static X509Certificate buildResponderCertificateWithoutKeyUsage() throws Exception { + return buildCertificate( + new Extension(Extension.extendedKeyUsage, true, + new ExtendedKeyUsage(KeyPurposeId.id_kp_OCSPSigning).getEncoded())); + } + + private static void newDesignatedOcspServiceConfiguration(X509Certificate responder) throws Exception { + new DesignatedOcspServiceConfiguration( + OCSP_URL, responder, List.of(getTestEsteid2018CA()), true, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true); + } + + private static void newFallbackOcspServiceConfiguration(X509Certificate responder) throws Exception { + final List trustedCAs = List.of(getTestEsteid2018CA()); + final Set trustAnchors = CertificateValidator.buildTrustAnchorsFromCertificates(trustedCAs); + final CertStore certStore = CertificateValidator.buildCertStoreFromCertificates(trustedCAs); + new FallbackOcspServiceConfiguration(OCSP_URL, responder, true, null, null, trustAnchors, certStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE, true); + } + +} diff --git a/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java b/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java index 57417604..5df321dd 100644 --- a/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java +++ b/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java @@ -12,15 +12,19 @@ import java.net.URI; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; +import java.time.Duration; import java.util.List; import java.util.Set; import static eu.webeid.security.testutil.Certificates.getTestEsteid2015CA; import static eu.webeid.security.testutil.Certificates.getTestEsteid2018CA; -import static eu.webeid.security.testutil.Certificates.getTestSkOcspResponder2020; +import static eu.webeid.security.testutil.Certificates.getTestSelfSignedOcspResponder; public class OcspServiceMaker { + public static final Duration MAX_THIS_UPDATE_AGE = Duration.ofMinutes(3); + public static final Duration MAX_NEXT_UPDATE_AGE = Duration.ofMinutes(20); + private static final String TEST_OCSP_ACCESS_LOCATION = "http://demo.sk.ee/ocsp"; private static final List TRUSTED_CA_CERTIFICATES; private static final X500Name ISSUER_DN = new X500Name("CN=TEST of ESTEID-SK 2015, OID.2.5.4.97=NTREE-10747013, O=AS Sertifitseerimiskeskus, C=EE"); @@ -53,7 +57,9 @@ private static AiaOcspServiceConfiguration getAiaOcspServiceConfiguration() thro return new AiaOcspServiceConfiguration( Set.of(ISSUER_DN), CertificateValidator.buildTrustAnchorsFromCertificates(TRUSTED_CA_CERTIFICATES), - CertificateValidator.buildCertStoreFromCertificates(TRUSTED_CA_CERTIFICATES)); + CertificateValidator.buildCertStoreFromCertificates(TRUSTED_CA_CERTIFICATES), + MAX_THIS_UPDATE_AGE, + MAX_NEXT_UPDATE_AGE, true); } public static DesignatedOcspServiceConfiguration getDesignatedOcspServiceConfiguration() throws CertificateException, IOException, OCSPCertificateException { @@ -67,9 +73,11 @@ private static DesignatedOcspServiceConfiguration getDesignatedOcspServiceConfig private static DesignatedOcspServiceConfiguration getDesignatedOcspServiceConfiguration(boolean doesSupportNonce, String ocspServiceAccessLocation) throws CertificateException, IOException, OCSPCertificateException { return new DesignatedOcspServiceConfiguration( URI.create(ocspServiceAccessLocation), - getTestSkOcspResponder2020(), + getTestSelfSignedOcspResponder(), TRUSTED_CA_CERTIFICATES, - doesSupportNonce); + doesSupportNonce, + MAX_THIS_UPDATE_AGE, + MAX_NEXT_UPDATE_AGE, true); } } diff --git a/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java b/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java index e9be3911..1537f5a3 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("does not contain the Extended Key Usage extension required for OCSP response signing"); } @Test @@ -75,10 +96,12 @@ void whenDifferentIssuersHaveSameName_thenDesignatedServiceAppliesOnlyToConfigur second.start(); final var authorities = List.of(first.issuer(), second.issuer()); final var designated = new DesignatedOcspServiceConfiguration( - first.designatedUri(), first.responderCertificate(), List.of(first.issuer()), true); + first.designatedUri(), first.responderCertificate(), List.of(first.issuer()), true, + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, true); final var aia = new AiaOcspServiceConfiguration(Set.of(), CertificateValidator.buildTrustAnchorsFromCertificates(authorities), - CertificateValidator.buildCertStoreFromCertificates(authorities)); + CertificateValidator.buildCertStoreFromCertificates(authorities), + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, true); final var provider = new OcspServiceProvider(designated, aia); assertThat(first.issuer().getSubjectX500Principal()).isEqualTo(second.issuer().getSubjectX500Principal()); @@ -97,7 +120,8 @@ void whenDifferentIssuersHaveSameName_thenFallbackServiceAppliesOnlyToConfigured final var authorities = List.of(first.issuer(), second.issuer()); final var aia = new AiaOcspServiceConfiguration(Set.of(), CertificateValidator.buildTrustAnchorsFromCertificates(authorities), - CertificateValidator.buildCertStoreFromCertificates(authorities)); + CertificateValidator.buildCertStoreFromCertificates(authorities), + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, true); final var fallback = new FallbackOcspServiceConfiguration( first.designatedUri(), first.responderCertificate(), @@ -105,7 +129,8 @@ void whenDifferentIssuersHaveSameName_thenFallbackServiceAppliesOnlyToConfigured null, first.issuer(), CertificateValidator.buildTrustAnchorsFromCertificates(List.of(first.issuer())), - CertificateValidator.buildCertStoreFromCertificates(List.of(first.issuer()))); + CertificateValidator.buildCertStoreFromCertificates(List.of(first.issuer())), + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, true); final var provider = new OcspServiceProvider(null, aia, List.of(fallback)); assertThat(first.issuer().getSubjectX500Principal()).isEqualTo(second.issuer().getSubjectX500Principal()); @@ -128,7 +153,8 @@ void whenFallbackResponderIsDelegatedByAnotherTrustedCa_thenThrows(boolean sameI null, responder.issuer(), CertificateValidator.buildTrustAnchorsFromCertificates(List.of(responder.issuer(), responder.otherIssuer())), - CertificateValidator.buildCertStoreFromCertificates(List.of(responder.issuer(), responder.otherIssuer()))); + CertificateValidator.buildCertStoreFromCertificates(List.of(responder.issuer(), responder.otherIssuer())), + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, true); final var service = new FallbackOcspService(configuration); final var responderCertificate = new X509CertificateHolder(responder.responderCertificate().getEncoded()); @@ -151,7 +177,8 @@ void whenFallbackServiceIsUsedForDifferentIssuer_thenThrows() throws Exception { null, first.issuer(), CertificateValidator.buildTrustAnchorsFromCertificates(List.of(first.issuer())), - CertificateValidator.buildCertStoreFromCertificates(List.of(first.issuer()))); + CertificateValidator.buildCertStoreFromCertificates(List.of(first.issuer())), + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, true); final var service = new FallbackOcspService(configuration); final var responderCertificate = new X509CertificateHolder(first.responderCertificate().getEncoded()); @@ -162,6 +189,274 @@ 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 whenAiaOcspResponderCertMissingKeyUsageDigitalSignatureAndNonRepudiation_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.keyEncipherment).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 or Non Repudiation, 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 whenFallbackOcspResponderCertMissingKeyUsageDigitalSignatureAndNonRepudiation_thenThrows() throws Exception { + final FallbackOcspService service = newFallbackOcspServiceWithoutPinnedResponder(); + final X509Certificate responderWithoutDigitalSignature = buildCertificate( + new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyEncipherment).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 or Non Repudiation, which is required for OCSP response signing"); + } + + @Test + void whenFallbackOcspResponderCertMissingOcspSigningEku_thenThrows() throws Exception { + final FallbackOcspService service = newFallbackOcspServiceWithoutPinnedResponder(); + final X509Certificate responderWithoutOcspSigning = buildCertificate( + new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature).getEncoded()), + new Extension(Extension.extendedKeyUsage, true, + new ExtendedKeyUsage(KeyPurposeId.id_kp_clientAuth).getEncoded())); + final X509CertificateHolder responderCert = new X509CertificateHolder(responderWithoutOcspSigning.getEncoded()); + + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> + service.validateResponderCertificate(responderCert, getTestEsteid2018CA(), new Date())) + .withMessage("Certificate " + responderWithoutOcspSigning.getSubjectX500Principal() + + " Extended Key Usage extension does not contain OCSP Signing, which is required for OCSP response signing"); + } + + private static FallbackOcspService newFallbackOcspServiceWithoutPinnedResponder() throws Exception { + final List trustedCAs = List.of(getTestEsteid2018CA()); + final Set trustAnchors = CertificateValidator.buildTrustAnchorsFromCertificates(trustedCAs); + final CertStore certStore = CertificateValidator.buildCertStoreFromCertificates(trustedCAs); + final FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( + URI.create("http://fallback.demo.sk.ee/ocsp"), null, true, null, getTestEsteid2018CA(), trustAnchors, certStore, + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, + OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, true); + return new FallbackOcspService(configuration); + } + + @Test + void whenFallbackOcspServiceConfigurationProvided_thenAiaServiceCarriesMatchingFallback() throws Exception { + X509Certificate userCert = getJaakKristjanEsteid2018Cert(); + List trustedCertificates = List.of(getTestEsteid2018CA(), getTestEsteid2015CA()); + Set trustedAnchors = + CertificateValidator.buildTrustAnchorsFromCertificates(trustedCertificates); + java.security.cert.CertStore trustedStore = + CertificateValidator.buildCertStoreFromCertificates(trustedCertificates); + URI fallbackUri = URI.create("http://fallback.test/ocsp"); + FallbackOcspServiceConfiguration fallbackConfiguration = new FallbackOcspServiceConfiguration( + fallbackUri, getDemoEsteidSk2018AiaOcspResponder(), true, + null, getTestEsteid2018CA(), trustedAnchors, trustedStore, + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, + OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, true); + + OcspServiceProvider provider = new OcspServiceProvider(null, getAiaOcspServiceProvider2018Configuration(), + List.of(fallbackConfiguration)); + OcspService service = provider.getService(userCert, getTestEsteid2018CA()); + + assertThat(service).isInstanceOf(AiaOcspService.class); + Optional fallbackOpt = service.getFallbackService(); + assertThat(fallbackOpt).isPresent(); + FallbackOcspService fallback = fallbackOpt.get(); + assertThat(fallback.getAccessLocation()).isEqualTo(fallbackUri); + assertThat(fallback.doesSupportNonce()).isTrue(); + Date validationDate = new Date(1630000000000L); + assertThatCode(() -> + fallback.validateResponderCertificate(new X509CertificateHolder(getDemoEsteidSk2018AiaOcspResponder().getEncoded()), getTestEsteid2018CA(), validationDate)) + .doesNotThrowAnyException(); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> + fallback.validateResponderCertificate(new X509CertificateHolder(getTestEsteid2018CA().getEncoded()), getTestEsteid2018CA(), validationDate)) + .withMessage("Responder certificate from the OCSP response is not equal to the configured fallback OCSP responder certificate"); + } + + @Test + void whenFallbackOcspServiceConfigurationDoesNotMatchIssuer_thenAiaServiceCarriesNoFallback() throws Exception { + X509Certificate userCert = getJaakKristjanEsteid2018Cert(); + List trustedCertificates = List.of(getTestEsteid2018CA(), getTestEsteid2015CA()); + Set trustedAnchors = + CertificateValidator.buildTrustAnchorsFromCertificates(trustedCertificates); + java.security.cert.CertStore trustedStore = + CertificateValidator.buildCertStoreFromCertificates(trustedCertificates); + FallbackOcspServiceConfiguration fallbackConfiguration = new FallbackOcspServiceConfiguration( + URI.create("http://fallback.test/ocsp"), null, true, + null, getTestEsteid2015CA(), trustedAnchors, trustedStore, + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, + OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, true); + + OcspServiceProvider provider = new OcspServiceProvider(null, getAiaOcspServiceProvider2018Configuration(), + List.of(fallbackConfiguration)); + OcspService service = provider.getService(userCert, getTestEsteid2018CA()); + + assertThat(service.getFallbackService()).isEmpty(); + } + + @Test + void whenFallbackConfigurationsIsNull_thenServiceHasNoFallback() throws Exception { + X509Certificate userCert = getJaakKristjanEsteid2018Cert(); + OcspServiceProvider provider = new OcspServiceProvider(null, getAiaOcspServiceProvider2018Configuration(), null); + + OcspService service = provider.getService(userCert, getTestEsteid2018CA()); + + assertThat(service).isInstanceOf(AiaOcspService.class); + assertThat(service.getAccessLocation()).isEqualTo(new URI("http://aia.demo.sk.ee/esteid2018")); + assertThat(service.getFallbackService()).isEmpty(); + } + + @Test + void whenFallbackConfigurationsIsEmpty_thenServiceHasNoFallback() throws Exception { + X509Certificate userCert = getJaakKristjanEsteid2018Cert(); + OcspServiceProvider provider = new OcspServiceProvider(null, getAiaOcspServiceProvider2018Configuration(), List.of()); + + OcspService service = provider.getService(userCert, getTestEsteid2018CA()); + + assertThat(service).isInstanceOf(AiaOcspService.class); + assertThat(service.getFallbackService()).isEmpty(); + } + + @Test + void whenFallbackConfiguredForIssuerSupportedByDesignatedService_thenThrows() throws Exception { + List trustedCertificates = List.of(getTestEsteid2018CA(), getTestEsteid2015CA()); + Set trustedAnchors = + CertificateValidator.buildTrustAnchorsFromCertificates(trustedCertificates); + java.security.cert.CertStore trustedStore = + CertificateValidator.buildCertStoreFromCertificates(trustedCertificates); + FallbackOcspServiceConfiguration fallbackConfiguration = new FallbackOcspServiceConfiguration( + URI.create("http://fallback.test/ocsp"), getDemoEsteidSk2018AiaOcspResponder(), true, + null, getTestEsteid2018CA(), trustedAnchors, trustedStore, + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, + OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, true); + DesignatedOcspServiceConfiguration designatedConfiguration = getDesignatedOcspServiceConfiguration(); + AiaOcspServiceConfiguration aiaConfiguration = getAiaOcspServiceProvider2018Configuration(); + + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> new OcspServiceProvider(designatedConfiguration, aiaConfiguration, List.of(fallbackConfiguration))) + .withMessage("Fallback OCSP service must not be configured for an issuer that is supported by the designated OCSP service"); + } + + @Test + void whenFallbackConfiguredForIssuerNotSupportedByDesignatedService_thenAiaServiceCarriesFallback() throws Exception { + X509Certificate userCert = getJaakKristjanEsteid2018Cert(); + List trustedCertificates = List.of(getTestEsteid2018CA(), getTestEsteid2015CA()); + Set trustedAnchors = + CertificateValidator.buildTrustAnchorsFromCertificates(trustedCertificates); + java.security.cert.CertStore trustedStore = + CertificateValidator.buildCertStoreFromCertificates(trustedCertificates); + URI fallbackUri = URI.create("http://fallback.test/ocsp"); + FallbackOcspServiceConfiguration fallbackConfiguration = new FallbackOcspServiceConfiguration( + fallbackUri, getDemoEsteidSk2018AiaOcspResponder(), true, + null, getTestEsteid2018CA(), trustedAnchors, trustedStore, + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, + OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, true); + DesignatedOcspServiceConfiguration designatedConfiguration = new DesignatedOcspServiceConfiguration( + URI.create("http://demo.sk.ee/ocsp"), getTestSelfSignedOcspResponder(), List.of(getTestEsteid2015CA()), true, + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, + OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, true); + + OcspServiceProvider provider = new OcspServiceProvider(designatedConfiguration, + getAiaOcspServiceProvider2018Configuration(), List.of(fallbackConfiguration)); + OcspService service = provider.getService(userCert, getTestEsteid2018CA()); + + assertThat(service).isInstanceOf(AiaOcspService.class); + assertThat(service.getFallbackService()).isPresent(); + assertThat(service.getFallbackService().get().getAccessLocation()).isEqualTo(fallbackUri); + } + + @Test + void whenDuplicateIssuerFallbackConfigurations_thenLastOneWins() throws Exception { + X509Certificate userCert = getJaakKristjanEsteid2018Cert(); + List trustedCertificates = List.of(getTestEsteid2018CA(), getTestEsteid2015CA()); + Set trustedAnchors = + CertificateValidator.buildTrustAnchorsFromCertificates(trustedCertificates); + java.security.cert.CertStore trustedStore = + CertificateValidator.buildCertStoreFromCertificates(trustedCertificates); + URI firstFallbackUri = URI.create("http://fallback-first.test/ocsp"); + URI lastFallbackUri = URI.create("http://fallback-last.test/ocsp"); + FallbackOcspServiceConfiguration firstConfiguration = new FallbackOcspServiceConfiguration( + firstFallbackUri, getDemoEsteidSk2018AiaOcspResponder(), true, + null, getTestEsteid2018CA(), trustedAnchors, trustedStore, + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, + OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, true); + FallbackOcspServiceConfiguration lastConfiguration = new FallbackOcspServiceConfiguration( + lastFallbackUri, getDemoEsteidSk2018AiaOcspResponder(), true, + null, getTestEsteid2018CA(), trustedAnchors, trustedStore, + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, + OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, true); + + OcspServiceProvider provider = new OcspServiceProvider(null, getAiaOcspServiceProvider2018Configuration(), + List.of(firstConfiguration, lastConfiguration)); + OcspService service = provider.getService(userCert, getTestEsteid2018CA()); + + Optional fallbackOpt = service.getFallbackService(); + assertThat(fallbackOpt).isPresent(); + assertThat(fallbackOpt.get().getAccessLocation()).isEqualTo(lastFallbackUri); + } + + private static AiaOcspServiceConfiguration getAiaOcspServiceProvider2018Configuration() throws Exception { + List trustedCertificates = List.of(getTestEsteid2018CA(), getTestEsteid2015CA()); + return new AiaOcspServiceConfiguration( + Set.of(), + CertificateValidator.buildTrustAnchorsFromCertificates(trustedCertificates), + CertificateValidator.buildCertStoreFromCertificates(trustedCertificates), + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, + OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE, true); + } } // Old disabled example AuthTokenValidator test with designated OCSP check. diff --git a/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java b/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java index e6fe74d1..49f4a8e1 100644 --- a/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java +++ b/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java @@ -6,67 +6,101 @@ 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.exceptions.UserCertificateUnknownStatusException; +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.resilientocsp.exceptions.ResilientUserCertificateUnknownStatusException; 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; import org.bouncycastle.cert.ocsp.RevokedStatus; import org.bouncycastle.cert.ocsp.SingleResp; +import org.bouncycastle.cert.ocsp.UnknownStatus; 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 +115,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 +147,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 +156,1325 @@ 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) + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withoutFallbacks().build(); + + assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> { + List revocationInfoList = ex.getValidationInfo().revocationInfoList(); + assertThat(revocationInfoList.size()).isEqualTo(1); + }); + } + + @Test + 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())) + .thenReturn(ocspRespStatusUnauthorized); + + 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(); + + List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + + assertThat(revocationInfoList).hasSize(1); + assertThat(revocationInfoList.get(0).ocspResponderUri()).isEqualTo(PRIMARY_URI); + assertThat(getCertificateStatus(revocationInfoList.get(0))).isEqualTo(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 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(); - ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, retryConfig); - ResilientUserCertificateOCSPCheckFailedException ex = assertThrows(ResilientUserCertificateOCSPCheckFailedException.class, () -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); - assertThat(ex.getValidationInfo().revocationInfoList().size()).isEqualTo(4); + 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 whenMaxAttemptsIsTwoAndFirstCallFails_thenTwoCallsToPrimaryShouldBeRecorded() throws Exception { + void whenPrimaryFailsAndFirstFallbackReturnsRevoked_thenListContainsBothEntries() throws Exception { OcspClient ocspClient = mock(OcspClient.class); when(ocspClient.request(eq(PRIMARY_URI), any())) - .thenThrow(new OCSPClientException("Primary OCSP service unavailable (call1)")) - .thenReturn(ocspRespGood); - when(ocspClient.request(eq(FALLBACK_URI), any())) - .thenReturn(ocspRespRevoked); - when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())) - .thenReturn(ocspRespRevoked); + .thenThrow(new OCSPClientException("Primary OCSP service unavailable")); + when(ocspClient.request(eq(FALLBACK_URI), any())).thenReturn(ocspRespRevoked); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build(); + + 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()); + } + + @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_thenUnknownStatusIsFinalAndFallbackIsNotCalled() 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 ignored = mockStaticClockAt(WITHIN_RESPONDER_CERT_VALIDITY)) { + assertThatExceptionOfType(ResilientUserCertificateUnknownStatusException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .withMessage("User certificate revocation check has failed: Unknown status (OCSP responder: " + PRIMARY_URI + ")") + .satisfies(ex -> { + List revocationInfoList = ex.getValidationInfo().revocationInfoList(); + assertThat(revocationInfoList).hasSize(1); + assertThat(revocationInfoList.get(0).ocspResponderUri()).isEqualTo(PRIMARY_URI); + assertThat(revocationInfoList.get(0).ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR)) + .isInstanceOf(UserCertificateUnknownStatusException.class); + assertThat(getCertificateStatus(revocationInfoList.get(0))).isInstanceOf(UnknownStatus.class); + assertThat(getCircuitBreakerStatistics(revocationInfoList.get(0))).isNotNull(); + }); + } + verify(ocspClient, never()).request(eq(FALLBACK_URI), any()); + verify(ocspClient, never()).request(eq(SECOND_FALLBACK_URI), any()); + } + + @Test + void whenPrimaryReturnsUnknown_thenNotRetried() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespUnknown); + RetryConfig retryConfig = RetryConfig.custom().maxAttempts(2).waitDuration(Duration.ZERO).build(); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withRetryConfig(retryConfig).build(); + + try (var ignored = mockStaticClockAt(WITHIN_RESPONDER_CERT_VALIDITY)) { + assertThatExceptionOfType(ResilientUserCertificateUnknownStatusException.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 whenPrimaryFailsAndRetryReturnsUnknown_thenUnknownStatusIsFinalAndFallbackIsNotCalled() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())) + .thenThrow(new OCSPClientException("Primary OCSP service unavailable")) + .thenReturn(ocspRespUnknown); + when(ocspClient.request(eq(FALLBACK_URI), any())).thenReturn(ocspRespGood); + RetryConfig retryConfig = RetryConfig.custom().maxAttempts(2).waitDuration(Duration.ZERO).build(); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withRetryConfig(retryConfig).build(); + + try (var ignored = mockStaticClockAt(WITHIN_RESPONDER_CERT_VALIDITY)) { + assertThatExceptionOfType(ResilientUserCertificateUnknownStatusException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> { + List revocationInfoList = ex.getValidationInfo().revocationInfoList(); + assertThat(revocationInfoList).extracting(RevocationInfo::ocspResponderUri) + .containsExactly(PRIMARY_URI, PRIMARY_URI); + assertThat(revocationInfoList.get(0).ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR)) + .isInstanceOf(OCSPClientException.class); + assertThat(getCertificateStatus(revocationInfoList.get(1))).isInstanceOf(UnknownStatus.class); + }); + } + verify(ocspClient, times(2)).request(eq(PRIMARY_URI), any()); + verify(ocspClient, never()).request(eq(FALLBACK_URI), any()); + } + + @Test + void whenPrimaryReturnsUnknown_thenCircuitBreakerDoesNotOpen() throws Exception { + // 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(ocspRespUnknown); + CircuitBreakerConfig tightCircuitBreakerConfig = CircuitBreakerConfig.custom() + .recordExceptions(Throwable.class) + .slidingWindowSize(2) + .minimumNumberOfCalls(2) + .failureRateThreshold(50) + .permittedNumberOfCallsInHalfOpenState(1) + .build(); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient) + .withFallbacks(FALLBACK_URI) + .withCircuitBreakerConfig(tightCircuitBreakerConfig) + .build(); + + try (var ignored = mockStaticClockAt(WITHIN_RESPONDER_CERT_VALIDITY)) { + for (int i = 0; i < 3; i++) { + assertThatExceptionOfType(ResilientUserCertificateUnknownStatusException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> assertThat(getCircuitBreakerStatistics(ex.getValidationInfo().revocationInfoList().get(0)).state()) + .isEqualTo(CircuitBreaker.State.CLOSED)); + } + } + verify(ocspClient, times(3)).request(eq(PRIMARY_URI), any()); + verify(ocspClient, never()).request(eq(FALLBACK_URI), any()); + } + + @Test + void whenPrimaryFailsAndFirstFallbackReturnsUnknown_thenSecondFallbackIsNotCalled() 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(ocspRespUnknown); + when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())).thenReturn(ocspRespGood); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build(); + try (var ignored = mockStaticClockAt(WITHIN_RESPONDER_CERT_VALIDITY)) { + assertThatExceptionOfType(ResilientUserCertificateUnknownStatusException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> { + List revocationInfoList = ex.getValidationInfo().revocationInfoList(); + assertThat(revocationInfoList).extracting(RevocationInfo::ocspResponderUri) + .containsExactly(PRIMARY_URI, FALLBACK_URI); + assertThat(getCertificateStatus(revocationInfoList.get(1))).isInstanceOf(UnknownStatus.class); + }); + } + verify(ocspClient, never()).request(eq(SECOND_FALLBACK_URI), any()); + } + + @Test + void whenFallbackOnUnknownStatusEnabledAndPrimaryReturnsUnknown_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).withFallbackOnUnknownStatus().build(); + + try (var mockedClock = mockStaticClockAt(WITHIN_RESPONDER_CERT_VALIDITY)) { + List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + + assertThat(revocationInfoList).hasSize(2); + assertThat(revocationInfoList.get(0).ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR)) + .isInstanceOf(UserCertificateUnknownStatusException.class); + verify(ocspClient).request(eq(FALLBACK_URI), any()); + } + } + + @Test + void whenFallbackOnUnknownStatusEnabledAndFirstFallbackReturnsUnknown_thenSecondFallbackHandlesTheCall() 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(ocspRespUnknown); + when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())).thenReturn(ocspRespGood); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withFallbackOnUnknownStatus().build(); + + try (var ignored = mockStaticClockAt(WITHIN_RESPONDER_CERT_VALIDITY)) { + List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + + assertThat(revocationInfoList).extracting(RevocationInfo::ocspResponderUri) + .containsExactly(PRIMARY_URI, FALLBACK_URI, SECOND_FALLBACK_URI); + assertThat(getCertificateStatus(revocationInfoList.get(2))).isEqualTo(CertificateStatus.GOOD); + } + } + + @Test + void whenFallbackOnUnknownStatusEnabledAndPrimaryReturnsUnknown_thenRetriedAndCountedByCircuitBreaker() 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); + RetryConfig retryConfig = RetryConfig.custom().maxAttempts(2).waitDuration(Duration.ZERO).build(); + CircuitBreakerConfig tightCircuitBreakerConfig = CircuitBreakerConfig.custom() + .slidingWindowSize(2) + .minimumNumberOfCalls(2) + .failureRateThreshold(50) + .permittedNumberOfCallsInHalfOpenState(1) + .waitDurationInOpenState(Duration.ofMinutes(10)) + .build(); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient) + .withFallbacks(FALLBACK_URI) + .withFallbackOnUnknownStatus() + .withRetryConfig(retryConfig) + .withCircuitBreakerConfig(tightCircuitBreakerConfig) + .build(); + + try (var ignored = mockStaticClockAt(WITHIN_RESPONDER_CERT_VALIDITY)) { + // First call: primary answers unknown twice (retried), then the fallback answers good. + List firstResult = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + assertThat(firstResult).extracting(RevocationInfo::ocspResponderUri) + .containsExactly(PRIMARY_URI, PRIMARY_URI, FALLBACK_URI); + // Both primary attempts failed because of the unknown status, not for an unrelated reason. + assertThat(firstResult.subList(0, 2)) + .extracting(revocationInfo -> revocationInfo.ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR)) + .allSatisfy(error -> assertThat(error).isInstanceOf(UserCertificateUnknownStatusException.class)); + verify(ocspClient, times(2)).request(eq(PRIMARY_URI), any()); + + // Second call: the retried call counted as one failure; after this call the breaker has two failures. + checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + verify(ocspClient, times(4)).request(eq(PRIMARY_URI), any()); + + // Third call: the breaker is open, the primary is not queried and the fallback handles the call. + List thirdResult = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + assertThat(thirdResult).extracting(RevocationInfo::ocspResponderUri).containsExactly(FALLBACK_URI); + assertThat(getCircuitBreakerStatistics(thirdResult.get(0)).state()).isEqualTo(CircuitBreaker.State.OPEN); + verify(ocspClient, times(4)).request(eq(PRIMARY_URI), any()); + } + } + + @Test + void whenFallbackOnUnknownStatusEnabledAndRetryConfigRetriesOnlyCheckFailedException_thenUnknownStatusIsRetried() throws Exception { + // With fallback on unknown status enabled, the unknown status is a ResilientUserCertificateOCSPCheckFailedException, + // so a retry configuration that names this exception type applies to it. + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespUnknown); + when(ocspClient.request(eq(FALLBACK_URI), any())).thenReturn(ocspRespGood); RetryConfig retryConfig = RetryConfig.custom() - .maxAttempts(2) + .maxAttempts(3) + .waitDuration(Duration.ZERO) + .retryExceptions(ResilientUserCertificateOCSPCheckFailedException.class) + .build(); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient) + .withFallbacks(FALLBACK_URI) + .withFallbackOnUnknownStatus() + .withRetryConfig(retryConfig) .build(); - ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, retryConfig); - List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); - assertThat(revocationInfoList.size()).isEqualTo(2); + try (var ignored = mockStaticClockAt(WITHIN_RESPONDER_CERT_VALIDITY)) { + List result = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + assertThat(result).extracting(RevocationInfo::ocspResponderUri) + .containsExactly(PRIMARY_URI, PRIMARY_URI, PRIMARY_URI, FALLBACK_URI); + verify(ocspClient, times(3)).request(eq(PRIMARY_URI), any()); + } + } - Map firstResponseAttributes = revocationInfoList.get(0).ocspResponseAttributes(); - OCSPClientException ex1 = (OCSPClientException) firstResponseAttributes.get("OCSP_ERROR"); - assertThat(ex1.getMessage()).isEqualTo("Primary OCSP service unavailable (call1)"); + @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(); - 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); + 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 whenFirstCallSucceeds_thenRevocationInfoListShouldHaveOneElementAndItShouldHaveGoodStatus() throws Exception { + void whenCircuitBreakerIsOpenAndRecoveryTimeElapses_thenPrimaryIsTriedAgainInHalfOpenState() throws Exception { OcspClient ocspClient = mock(OcspClient.class); when(ocspClient.request(eq(PRIMARY_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(ocspRespRevoked); - when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())) - .thenReturn(ocspRespRevoked); + 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(); - ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, null); + checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); - 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); + 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()); + + 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 whenFirstCallResultsInRevoked_thenRevocationInfoListShouldHaveOneElementAndItShouldHaveRevokedStatus() throws Exception { + void whenPrimaryAnswersInHalfOpenState_thenStatisticsReportHalfOpenState() 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(); + // 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); + + List revocationInfoList = + checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + + 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); + } + + @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); + + 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); - 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); + 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 whenOneFallbackIsConfiguredAndPrimaryFails_thenRevocationInfoListShouldHaveTwoElements() throws Exception { + void whenPrimaryReturnsUnknownInHalfOpenState_thenUnknownStatusIsFinalAndCircuitBreakerCloses() throws Exception { + // A final unknown status is a definitive answer from a healthy service: in half open state it counts + // as a success, so the breaker closes instead of re-opening, and no fallback is consulted. 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(ocspRespUnknown); + 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); + try (var ignored = mockStaticClockAt(WITHIN_RESPONDER_CERT_VALIDITY)) { + assertThatExceptionOfType(ResilientUserCertificateUnknownStatusException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> { + List revocationInfoList = ex.getValidationInfo().revocationInfoList(); + assertThat(revocationInfoList).extracting(RevocationInfo::ocspResponderUri).containsExactly(PRIMARY_URI); + assertThat(getCircuitBreakerStatistics(revocationInfoList.get(0)).state()) + .isEqualTo(CircuitBreaker.State.HALF_OPEN); + }); + // The half open call counted as a success and closed the breaker: the primary is queried again. + // Had it counted as a failure, the breaker would have re-opened and the fallback would answer. + assertThatExceptionOfType(ResilientUserCertificateUnknownStatusException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> assertThat(getCircuitBreakerStatistics(ex.getValidationInfo().revocationInfoList().get(0)).state()) + .isEqualTo(CircuitBreaker.State.CLOSED)); + } + verify(ocspClient, times(4)).request(eq(PRIMARY_URI), any()); + // Only the two calls that tripped the breaker reached the fallback. + verify(ocspClient, times(2)).request(eq(FALLBACK_URI), any()); + } - OcspService primaryService = mock(OcspService.class); - when(primaryService.getAccessLocation()).thenReturn(PRIMARY_URI); - when(primaryService.doesSupportNonce()).thenReturn(false); - when(primaryService.getFallbackService()).thenReturn(Optional.of(fallbackService)); + @Test + void whenPrimaryFailsInClosedState_thenStatisticsDoNotIncludeCurrentCallFailure() 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); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient) + .withFallbacks(FALLBACK_URI) + .build(); - OcspServiceProvider ocspServiceProvider = mock(OcspServiceProvider.class); - when(ocspServiceProvider.getService(any(), any())).thenReturn(primaryService); + List firstCallRevocationInfoList = + checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + List secondCallRevocationInfoList = + 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 - ); - - ResilientUserCertificateOCSPCheckFailedException ex = assertThrows(ResilientUserCertificateOCSPCheckFailedException.class, () -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); - List revocationInfoList = ex.getValidationInfo().revocationInfoList(); - assertThat(revocationInfoList.size()).isEqualTo(2); + // 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 whenNoFallbacksAreConfigured_thenRevocationInfoListShouldHaveOneElement() throws Exception { + 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(new OCSPClientException()); - when(ocspClient.request(eq(FALLBACK_URI), any())) - .thenThrow(new OCSPClientException()); + 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(); - ResilientOcspCertificateRevocationChecker checker = new ResilientOcspCertificateRevocationChecker( - ocspClient, - ocspServiceProvider, - CircuitBreakerConfig.ofDefaults(), - null, - OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW, - OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, - LONG_THIS_UPDATE_AGE - ); + List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + + 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(); + + List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + + 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); - ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, null); - ResilientUserCertificateOCSPCheckFailedException ex = assertThrows(ResilientUserCertificateOCSPCheckFailedException.class, () -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); + 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); - Map responseAttributes = ex.getValidationInfo().revocationInfoList().get(0).ocspResponseAttributes(); - ResilientUserCertificateOCSPCheckFailedException firstException = (ResilientUserCertificateOCSPCheckFailedException) responseAttributes.get(RevocationInfo.KEY_OCSP_ERROR); - assertThat(firstException.getMessage()).isEqualTo("Response status: unauthorized"); + 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(); - FallbackOcspService fallbackService = mock(FallbackOcspService.class); - when(fallbackService.getAccessLocation()).thenReturn(FALLBACK_URI); - when(fallbackService.doesSupportNonce()).thenReturn(false); - when(fallbackService.getNextFallback()).thenReturn(secondFallbackService); + assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> { + assertThat(ex.getValidationInfo()).isNotNull(); + List revocationInfoList = ex.getValidationInfo().revocationInfoList(); - 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).hasSize(3); - OcspServiceProvider ocspServiceProvider = mock(OcspServiceProvider.class); - when(ocspServiceProvider.getService(any(), any())).thenReturn(primaryService); + 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()) + .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_thenUnknownStatusPropagates() 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(ResilientUserCertificateUnknownStatusException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> assertThat(ex.getValidationInfo().revocationInfoList()).hasSize(1)); + } + } + + @Test + void whenNoFallbackConfiguredAndFallbackOnUnknownStatusEnabledAndPrimaryReturnsUnknown_thenCheckFailedPropagates() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespUnknown); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient) + .withoutFallbacks() + .withFallbackOnUnknownStatus() + .build(); + + try (var ignored = mockStaticClockAt(WITHIN_RESPONDER_CERT_VALIDITY)) { + assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .withMessage("User certificate revocation check has failed") + .satisfies(ex -> { + assertThat(ex).isExactlyInstanceOf(ResilientUserCertificateOCSPCheckFailedException.class); + List revocationInfoList = ex.getValidationInfo().revocationInfoList(); + assertThat(revocationInfoList).hasSize(1); + assertThat(revocationInfoList.get(0).ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR)) + .isInstanceOf(UserCertificateUnknownStatusException.class); + }); + } + } + + @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 whenPrimaryAndFirstFallbackFailAndSecondFallbackReturnsUnknown_thenUnknownStatusPropagates() 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(ocspRespUnknown); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build(); + + try (var ignored = mockStaticClockAt(WITHIN_RESPONDER_CERT_VALIDITY)) { + assertThatExceptionOfType(ResilientUserCertificateUnknownStatusException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> { + List revocationInfoList = ex.getValidationInfo().revocationInfoList(); + assertThat(revocationInfoList).extracting(RevocationInfo::ocspResponderUri) + .containsExactly(PRIMARY_URI, FALLBACK_URI, SECOND_FALLBACK_URI); + assertThat(getCertificateStatus(revocationInfoList.get(2))).isInstanceOf(UnknownStatus.class); + }); + } + } + + @Test + void whenFallbackOnUnknownStatusEnabledAndSecondFallbackReturnsUnknown_thenCheckFailedPropagates() 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(ocspRespUnknown); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withFallbackOnUnknownStatus().build(); + + try (var ignored = mockStaticClockAt(WITHIN_RESPONDER_CERT_VALIDITY)) { + assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> { + assertThat(ex).isExactlyInstanceOf(ResilientUserCertificateOCSPCheckFailedException.class); + List revocationInfoList = ex.getValidationInfo().revocationInfoList(); + assertThat(revocationInfoList).extracting(RevocationInfo::ocspResponderUri) + .containsExactly(PRIMARY_URI, FALLBACK_URI, SECOND_FALLBACK_URI); + assertThat(getCertificateStatus(revocationInfoList.get(2))).isInstanceOf(UnknownStatus.class); + }); + } + } + + @Test + void whenFallbackOnUnknownStatusEnabledAndAllServicesReturnUnknown_thenCheckFailedPropagates() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(any(), any())).thenReturn(ocspRespUnknown); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withFallbackOnUnknownStatus().build(); + + try (var ignored = mockStaticClockAt(WITHIN_RESPONDER_CERT_VALIDITY)) { + assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .withMessage("User certificate revocation check has failed") + .satisfies(ex -> { + assertThat(ex).isExactlyInstanceOf(ResilientUserCertificateOCSPCheckFailedException.class); + List revocationInfoList = ex.getValidationInfo().revocationInfoList(); + assertThat(revocationInfoList).extracting(RevocationInfo::ocspResponderUri) + .containsExactly(PRIMARY_URI, FALLBACK_URI, SECOND_FALLBACK_URI); + assertThat(revocationInfoList) + .extracting(revocationInfo -> revocationInfo.ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR)) + .allSatisfy(error -> assertThat(error).isInstanceOf(UserCertificateUnknownStatusException.class)); + }); + } + } + + @Test + void whenFallbackOnUnknownStatusEnabledAndPrimaryReturnsUnknownAndFallbackFails_thenCheckFailedPropagates() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespUnknown); + when(ocspClient.request(eq(FALLBACK_URI), any())).thenThrow(new OCSPClientException("Fallback OCSP service unavailable")); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient) + .withFallbacks(FALLBACK_URI) + .withFallbackOnUnknownStatus() + .build(); + + try (var ignored = mockStaticClockAt(WITHIN_RESPONDER_CERT_VALIDITY)) { + assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> { + assertThat(ex).isExactlyInstanceOf(ResilientUserCertificateOCSPCheckFailedException.class); + List revocationInfoList = ex.getValidationInfo().revocationInfoList(); + assertThat(revocationInfoList).extracting(RevocationInfo::ocspResponderUri) + .containsExactly(PRIMARY_URI, FALLBACK_URI); + assertThat(revocationInfoList.get(0).ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR)) + .isInstanceOf(UserCertificateUnknownStatusException.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 boolean fallbackOnUnknownStatus; + + 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 CheckerBuilder withFallbackOnUnknownStatus() { + this.fallbackOnUnknownStatus = true; + return this; + } + + private ResilientOcspCertificateRevocationChecker build() throws Exception { + OcspServiceProvider serviceProvider = ocspServiceProvider != null ? ocspServiceProvider : buildMockServiceProvider(); + return new ResilientOcspCertificateRevocationChecker( + ocspClient, + serviceProvider, + circuitBreakerConfig, + retryConfig, + OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW, + fallbackOnUnknownStatus + ); + } + + 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/LocalOcspResponder.java b/src/test/java/eu/webeid/security/testutil/LocalOcspResponder.java index 499b3dc0..13b780ae 100644 --- a/src/test/java/eu/webeid/security/testutil/LocalOcspResponder.java +++ b/src/test/java/eu/webeid/security/testutil/LocalOcspResponder.java @@ -7,13 +7,16 @@ import com.sun.net.httpserver.HttpServer; import org.bouncycastle.asn1.ASN1ObjectIdentifier; import org.bouncycastle.asn1.ASN1OctetString; +import org.bouncycastle.asn1.DERNull; import org.bouncycastle.asn1.DEROctetString; +import org.bouncycastle.asn1.ocsp.CertID; import org.bouncycastle.asn1.ocsp.OCSPObjectIdentifiers; import org.bouncycastle.asn1.ocsp.OCSPResponse; import org.bouncycastle.asn1.ocsp.OCSPResponseStatus; import org.bouncycastle.asn1.ocsp.ResponseBytes; import org.bouncycastle.asn1.x500.X500Name; import org.bouncycastle.asn1.x509.AccessDescription; +import org.bouncycastle.asn1.x509.AlgorithmIdentifier; import org.bouncycastle.asn1.x509.AuthorityInformationAccess; import org.bouncycastle.asn1.x509.BasicConstraints; import org.bouncycastle.asn1.x509.CRLReason; @@ -73,6 +76,7 @@ public final class LocalOcspResponder implements AutoCloseable { private KeyPair responderKeys; private volatile Reply reply = Reply.GOOD; private volatile boolean includeNonce = true; + private volatile boolean nullCertIdHashParameters; public enum Reply { GOOD, REVOKED, TRY_LATER, DISCONNECT, UNSUPPORTED_TYPE, MISSING_RESPONSE } @@ -192,6 +196,11 @@ public void setIncludeNonce(boolean includeNonce) { this.includeNonce = includeNonce; } + /** Rebuilds the response certificate ID with NULL hash algorithm parameters, as OpenSSL does. */ + public void setNullCertIdHashParameters(boolean nullCertIdHashParameters) { + this.nullCertIdHashParameters = nullCertIdHashParameters; + } + public void useIssuerAsResponder() { responder = issuer; responderKeys = issuerKeys; @@ -266,7 +275,7 @@ private OCSPResp response(OCSPReq request) throws Exception { new JcaDigestCalculatorProviderBuilder().build().get(CertificateID.HASH_SHA1)); final CertificateStatus status = reply == Reply.REVOKED ? new RevokedStatus(Date.from(now.minusSeconds(60)), CRLReason.keyCompromise) : CertificateStatus.GOOD; - builder.addResponse(requestedId, status, + builder.addResponse(nullCertIdHashParameters ? withNullHashParameters(requestedId) : requestedId, status, Date.from(now.minusSeconds(1)), Date.from(now.plusSeconds(60)), null); final Extension nonce = request.getExtension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce); if (includeNonce && nonce != null) { @@ -277,6 +286,12 @@ private OCSPResp response(OCSPReq request) throws Exception { new X509CertificateHolder[] {new JcaX509CertificateHolder(responder)}, Date.from(now))); } + private static CertificateID withNullHashParameters(CertificateID certificateId) { + final CertID certId = certificateId.toASN1Primitive(); + return new CertificateID(new CertID(new AlgorithmIdentifier(certId.getHashAlgorithm().getAlgorithm(), DERNull.INSTANCE), + certId.getIssuerNameHash(), certId.getIssuerKeyHash(), certId.getSerialNumber())); + } + private JcaX509v3CertificateBuilder certificateBuilder(X500Name issuerName, X500Name subjectName, KeyPair keys, int serial) { // Generate certificates around the test time so fixtures do not expire or depend on responder rotation. return new JcaX509v3CertificateBuilder(issuerName, BigInteger.valueOf(serial), 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..9f6c6f2d 100644 --- a/src/test/java/eu/webeid/security/validator/AuthTokenCertificateTest.java +++ b/src/test/java/eu/webeid/security/validator/AuthTokenCertificateTest.java @@ -47,8 +47,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; @@ -196,19 +194,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..155cb17e --- /dev/null +++ b/src/test/java/eu/webeid/security/validator/AuthTokenValidatorValidationOrderTest.java @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.security.validator; + +import eu.webeid.ocsp.exceptions.UserCertificateRevokedException; +import eu.webeid.security.authtoken.WebEidAuthToken; +import eu.webeid.security.certificate.CertificateData; +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.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 whenRevocationCheckFails_thenSignatureIsNotValidated() throws Exception { + CertificateRevocationChecker checker = mock(CertificateRevocationChecker.class); + when(checker.validateCertificateNotRevoked(any(), any())).thenThrow(new UserCertificateRevokedException()); + AuthTokenValidator validator = AuthTokenValidators.getDefaultAuthTokenValidatorBuilder() + .withCertificateRevocationChecker(checker) + .build(); + WebEidAuthToken token = validator.parse(VALID_AUTH_TOKEN); + + // An invalid challenge nonce would fail signature validation, so getting the revocation + // failure instead shows that revocation is checked first. + assertThatThrownBy(() -> validator.validate(token, "invalidToken")) + .isInstanceOf(UserCertificateRevokedException.class); + } + + @Test + void whenSignatureIsValid_thenRevocationCheckerIsInvoked() throws Exception { + CertificateRevocationChecker checker = mock(CertificateRevocationChecker.class); + when(checker.validateCertificateNotRevoked(any(), any())).thenReturn(List.of()); + AuthTokenValidator validator = AuthTokenValidators.getDefaultAuthTokenValidatorBuilder() + .withCertificateRevocationChecker(checker) + .build(); + WebEidAuthToken token = validator.parse(VALID_AUTH_TOKEN); + + assertThatCode(() -> validator.validate(token, VALID_CHALLENGE_NONCE)) + .doesNotThrowAnyException(); + ArgumentCaptor subjectCaptor = ArgumentCaptor.forClass(X509Certificate.class); + verify(checker).validateCertificateNotRevoked(subjectCaptor.capture(), any()); + assertThat(CertificateData.getSubjectCN(subjectCaptor.getValue()).orElseThrow()) + .isEqualTo("JÕEORG\\,JAAK-KRISTJAN\\,38001085718"); + } +} diff --git a/src/test/resources/DEMO_of_ESTEID-SK_2018_AIA_OCSP_RESPONDER_2018.cer b/src/test/resources/DEMO_of_ESTEID-SK_2018_AIA_OCSP_RESPONDER_2018.cer new file mode 100644 index 00000000..30ce32b0 Binary files /dev/null and b/src/test/resources/DEMO_of_ESTEID-SK_2018_AIA_OCSP_RESPONDER_2018.cer differ diff --git a/src/test/resources/TEST_of_EE_Certification_Centre_Root_CA.cer b/src/test/resources/TEST_of_EE_Certification_Centre_Root_CA.cer new file mode 100644 index 00000000..f51a8580 Binary files /dev/null and b/src/test/resources/TEST_of_EE_Certification_Centre_Root_CA.cer differ diff --git a/src/test/resources/TEST_of_self-signed_OCSP_RESPONDER.cer b/src/test/resources/TEST_of_self-signed_OCSP_RESPONDER.cer new file mode 100644 index 00000000..86fbcab1 Binary files /dev/null and b/src/test/resources/TEST_of_self-signed_OCSP_RESPONDER.cer differ diff --git a/src/test/resources/ocsp_response_unknown_self_signed.der b/src/test/resources/ocsp_response_unknown_self_signed.der new file mode 100644 index 00000000..9dee6fc4 Binary files /dev/null and b/src/test/resources/ocsp_response_unknown_self_signed.der differ