From 43832a8a61ed46a94ea92aa27969e1080a5cc073 Mon Sep 17 00:00:00 2001 From: Mart Somermaa Date: Fri, 12 Dec 2025 17:37:47 +0200 Subject: [PATCH 01/19] Use plaform OCSP implementation by default, move custom OCSP implementation to eu.webeid.ocsp and make it optional WE2-1030 Signed-off-by: Mart Somermaa Co-authored-by: Sven Mitt --- .github/workflows/coverity-analysis.yml | 2 +- .github/workflows/maven-build.yml | 2 +- .github/workflows/maven-deploy.yml | 2 +- README.md | 103 ++-- example/pom.xml | 14 +- .../config/ValidationConfiguration.java | 14 +- .../eu/webeid/example/config/YAMLConfig.java | 11 - .../AuthTokenDTOAuthenticationProvider.java | 17 +- .../WebEidAjaxLoginProcessingFilter.java | 13 +- .../security/WebEidAuthentication.java | 6 +- .../AjaxAuthenticationFailureHandler.java | 5 +- .../AjaxAuthenticationSuccessHandler.java | 13 +- .../example/security/dto/AuthTokenDTO.java | 12 +- .../example/service/SigningService.java | 8 +- .../example/service/dto/CertificateDTO.java | 9 +- .../example/service/dto/ChallengeDTO.java | 11 +- .../webeid/example/service/dto/DigestDTO.java | 20 +- .../webeid/example/service/dto/FileDTO.java | 13 +- .../service/dto/SignatureAlgorithmDTO.java | 20 +- .../example/service/dto/SignatureDTO.java | 13 +- .../example/web/rest/ChallengeController.java | 4 +- .../AuthenticationRestControllerTest.java | 2 +- .../eu/webeid/example/WebApplicationTest.java | 121 ++-- .../config/TestValidatorConfiguration.java | 38 ++ .../WebEidAjaxLoginProcessingFilterTest.java | 48 +- .../security/WebEidAuthenticationTest.java | 15 +- .../example/service/dto/FileDTOTest.java | 44 ++ .../eu/webeid/example/testutil/Dates.java | 23 - .../webeid/example/testutil/ObjectMother.java | 8 +- pom.xml | 15 +- .../OcspCertificateRevocationChecker.java} | 128 +++-- src/main/java/eu/webeid/ocsp/README.md | 204 +++++++ .../ocsp => ocsp/client}/OcspClient.java | 2 +- .../ocsp => ocsp/client}/OcspClientImpl.java | 10 +- .../exceptions/OCSPCertificateException.java | 4 +- .../OcspResponderUriMessageAppender.java | 23 + ...erCertificateOCSPCheckFailedException.java | 29 + .../UserCertificateRevokedException.java | 43 ++ .../protocol}/DigestCalculatorImpl.java | 2 +- .../ocsp/protocol/OcspNonceFactory.java | 31 ++ .../protocol}/OcspRequestBuilder.java | 26 +- .../protocol}/OcspResponseValidator.java | 40 +- .../ocsp => ocsp/protocol}/OcspUrl.java | 2 +- .../ocsp/service/AiaOcspService.java | 24 +- .../service/AiaOcspServiceConfiguration.java | 6 +- .../ocsp/service/DesignatedOcspService.java | 8 +- .../DesignatedOcspServiceConfiguration.java | 9 +- .../ocsp/service/OcspService.java | 2 +- .../service}/OcspServiceProvider.java | 7 +- .../security/authtoken/WebEidAuthToken.java | 45 +- .../certificate/CertificateLoader.java | 2 +- .../certificate/CertificateValidator.java | 220 +++++++- .../certificate/OcspNonceExtension.java | 55 ++ ...AuthTokenSignatureValidationException.java | 4 + ...ificateRevocationCheckFailedException.java | 25 + .../CertificateRevokedException.java | 20 + ...erCertificateOCSPCheckFailedException.java | 17 - .../UserCertificateRevokedException.java | 17 - .../eu/webeid/security/util/Collections.java | 29 - .../eu/webeid/security/util/DateAndTime.java | 3 +- .../AuthTokenSignatureValidator.java | 28 +- .../AuthTokenValidationConfiguration.java | 130 +++-- .../validator/AuthTokenValidator.java | 8 +- .../validator/AuthTokenValidatorBuilder.java | 123 ++--- .../validator/AuthTokenValidatorImpl.java | 99 ++-- .../security/validator/ValidationInfo.java | 18 + .../SubjectCertificatePolicyValidator.java | 25 +- .../SubjectCertificateTrustedValidator.java | 59 -- .../SubjectCertificateValidator.java | 18 - .../SubjectCertificateValidatorBatch.java | 39 -- .../CertificateRevocationChecker.java | 15 + .../revocationcheck/RevocationInfo.java | 13 + .../revocationcheck/RevocationMode.java | 8 + ...OcspCertificateRevocationCheckerTest.java} | 166 +++--- .../client}/OcspClientOverrideTest.java | 52 +- .../ocsp/protocol/OcspNonceFactoryTest.java | 33 ++ .../protocol}/OcspResponseValidatorTest.java | 27 +- .../ocsp => ocsp/protocol}/OcspUrlTest.java | 4 +- .../AiaOcspServiceConfigurationTest.java | 38 ++ .../service}/OcspServiceMaker.java | 14 +- .../service}/OcspServiceProviderTest.java | 32 +- .../certificate/CertificateValidatorTest.java | 516 ++++++++++++++++++ .../security/certificate/OcspNetworkTest.java | 309 +++++++++++ .../certificate/OcspNonceExtensionTest.java | 55 ++ .../testutil/AbstractTestWithValidator.java | 13 +- .../testutil/AuthTokenValidators.java | 24 +- .../validator/AuthTokenAlgorithmTest.java | 2 +- .../validator/AuthTokenCertificateTest.java | 20 +- .../validator/AuthTokenSignatureTest.java | 10 +- .../AuthTokenSignatureValidatorTest.java | 34 +- .../AuthTokenValidationConfigurationTest.java | 80 +++ .../AuthTokenValidationInfoTest.java | 84 +++ .../AuthTokenValidatorBuilderTest.java | 84 ++- ...SubjectCertificatePolicyValidatorTest.java | 61 +++ 94 files changed, 2845 insertions(+), 1059 deletions(-) create mode 100644 example/src/test/java/eu/webeid/example/config/TestValidatorConfiguration.java create mode 100644 example/src/test/java/eu/webeid/example/service/dto/FileDTOTest.java rename src/main/java/eu/webeid/{security/validator/certvalidators/SubjectCertificateNotRevokedValidator.java => ocsp/OcspCertificateRevocationChecker.java} (62%) create mode 100644 src/main/java/eu/webeid/ocsp/README.md rename src/main/java/eu/webeid/{security/validator/ocsp => ocsp/client}/OcspClient.java (88%) rename src/main/java/eu/webeid/{security/validator/ocsp => ocsp/client}/OcspClientImpl.java (87%) rename src/main/java/eu/webeid/{security => ocsp}/exceptions/OCSPCertificateException.java (79%) create mode 100644 src/main/java/eu/webeid/ocsp/exceptions/OcspResponderUriMessageAppender.java create mode 100644 src/main/java/eu/webeid/ocsp/exceptions/UserCertificateOCSPCheckFailedException.java create mode 100644 src/main/java/eu/webeid/ocsp/exceptions/UserCertificateRevokedException.java rename src/main/java/eu/webeid/{security/validator/ocsp => ocsp/protocol}/DigestCalculatorImpl.java (97%) create mode 100644 src/main/java/eu/webeid/ocsp/protocol/OcspNonceFactory.java rename src/main/java/eu/webeid/{security/validator/ocsp => ocsp/protocol}/OcspRequestBuilder.java (57%) rename src/main/java/eu/webeid/{security/validator/ocsp => ocsp/protocol}/OcspResponseValidator.java (78%) rename src/main/java/eu/webeid/{security/validator/ocsp => ocsp/protocol}/OcspUrl.java (97%) rename src/main/java/eu/webeid/{security/validator => }/ocsp/service/AiaOcspService.java (76%) rename src/main/java/eu/webeid/{security/validator => }/ocsp/service/AiaOcspServiceConfiguration.java (82%) rename src/main/java/eu/webeid/{security/validator => }/ocsp/service/DesignatedOcspService.java (90%) rename src/main/java/eu/webeid/{security/validator => }/ocsp/service/DesignatedOcspServiceConfiguration.java (92%) rename src/main/java/eu/webeid/{security/validator => }/ocsp/service/OcspService.java (89%) rename src/main/java/eu/webeid/{security/validator/ocsp => ocsp/service}/OcspServiceProvider.java (81%) create mode 100644 src/main/java/eu/webeid/security/certificate/OcspNonceExtension.java create mode 100644 src/main/java/eu/webeid/security/exceptions/CertificateRevocationCheckFailedException.java create mode 100644 src/main/java/eu/webeid/security/exceptions/CertificateRevokedException.java delete mode 100644 src/main/java/eu/webeid/security/exceptions/UserCertificateOCSPCheckFailedException.java delete mode 100644 src/main/java/eu/webeid/security/exceptions/UserCertificateRevokedException.java delete mode 100644 src/main/java/eu/webeid/security/util/Collections.java create mode 100644 src/main/java/eu/webeid/security/validator/ValidationInfo.java delete mode 100644 src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateTrustedValidator.java delete mode 100644 src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateValidator.java delete mode 100644 src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateValidatorBatch.java create mode 100644 src/main/java/eu/webeid/security/validator/revocationcheck/CertificateRevocationChecker.java create mode 100644 src/main/java/eu/webeid/security/validator/revocationcheck/RevocationInfo.java create mode 100644 src/main/java/eu/webeid/security/validator/revocationcheck/RevocationMode.java rename src/test/java/eu/webeid/{security/validator/certvalidators/SubjectCertificateNotRevokedValidatorTest.java => ocsp/OcspCertificateRevocationCheckerTest.java} (67%) rename src/test/java/eu/webeid/{security/validator/ocsp => ocsp/client}/OcspClientOverrideTest.java (56%) create mode 100644 src/test/java/eu/webeid/ocsp/protocol/OcspNonceFactoryTest.java rename src/test/java/eu/webeid/{security/validator/ocsp => ocsp/protocol}/OcspResponseValidatorTest.java (84%) rename src/test/java/eu/webeid/{security/validator/ocsp => ocsp/protocol}/OcspUrlTest.java (93%) create mode 100644 src/test/java/eu/webeid/ocsp/service/AiaOcspServiceConfigurationTest.java rename src/test/java/eu/webeid/{security/testutil => ocsp/service}/OcspServiceMaker.java (86%) rename src/test/java/eu/webeid/{security/validator/ocsp => ocsp/service}/OcspServiceProviderTest.java (66%) create mode 100644 src/test/java/eu/webeid/security/certificate/CertificateValidatorTest.java create mode 100644 src/test/java/eu/webeid/security/certificate/OcspNetworkTest.java create mode 100644 src/test/java/eu/webeid/security/certificate/OcspNonceExtensionTest.java create mode 100644 src/test/java/eu/webeid/security/validator/AuthTokenValidationConfigurationTest.java create mode 100644 src/test/java/eu/webeid/security/validator/AuthTokenValidationInfoTest.java create mode 100644 src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePolicyValidatorTest.java diff --git a/.github/workflows/coverity-analysis.yml b/.github/workflows/coverity-analysis.yml index 2e8e2285..9f3758c1 100644 --- a/.github/workflows/coverity-analysis.yml +++ b/.github/workflows/coverity-analysis.yml @@ -23,7 +23,7 @@ jobs: - uses: actions/setup-java@v4 with: distribution: zulu - java-version: 11 + java-version: 17 - name: Cache Maven packages uses: actions/cache@v4 diff --git a/.github/workflows/maven-build.yml b/.github/workflows/maven-build.yml index 850ad585..a89b2777 100644 --- a/.github/workflows/maven-build.yml +++ b/.github/workflows/maven-build.yml @@ -23,7 +23,7 @@ jobs: - uses: actions/setup-java@v4 with: distribution: zulu - java-version: 11 + java-version: 17 - name: Cache Maven packages uses: actions/cache@v4 diff --git a/.github/workflows/maven-deploy.yml b/.github/workflows/maven-deploy.yml index d2c3af53..573938af 100644 --- a/.github/workflows/maven-deploy.yml +++ b/.github/workflows/maven-deploy.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/setup-java@v4 with: distribution: zulu - java-version: 11 + java-version: 17 - name: Cache Maven packages uses: actions/cache@v4 diff --git a/README.md b/README.md index e66db524..f4c5105a 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ More information about the Web eID project is available on the project [website] Complete the steps below to add support for secure authentication with eID cards to your Java web application back end. Instructions for the front end are available [here](https://github.com/web-eid/web-eid.js). -A Java web application that uses Maven or Gradle to manage packages is needed for running this quickstart. Examples are for Maven, but they are straightforward to translate to Gradle. +A Java 17 or newer web application that uses Maven or Gradle to manage packages is needed for running this quickstart. Examples are for Maven, but they are straightforward to translate to Gradle. In the following example we are using the [Spring Framework](https://spring.io/), but the examples can be easily ported to other Java web application frameworks. @@ -48,7 +48,7 @@ Implement the session-backed challenge nonce store as follows: import org.springframework.beans.factory.ObjectFactory; import eu.webeid.security.challenge.ChallengeNonce; import eu.webeid.security.challenge.ChallengeNonceStore; -import javax.servlet.http.HttpSession; +import jakarta.servlet.http.HttpSession; public class SessionBackedChallengeNonceStore implements ChallengeNonceStore { @@ -99,16 +99,18 @@ import eu.webeid.security.challenge.ChallengeNonceStore; ## 4. Add trusted certificate authority certificates -You must explicitly specify which **intermediate** certificate authorities (CAs) are trusted to issue the eID authentication and OCSP responder certificates. CA certificates can be loaded from either the truststore file, resources or any stream source. We use the [`CertificateLoader`](https://github.com/web-eid/web-eid-authtoken-validation-java/blob/main/src/main/java/eu/webeid/security/certificate/CertificateLoader.java) helper class to load CA certificates from resources here, but consider using [the truststore file](./blob/example/main/src/main/java/eu/webeid/example/config/ValidationConfiguration.java#L104-L123) instead. +You must explicitly specify which **intermediate** certificate authorities (CAs) are trusted to issue the eID authentication and OCSP responder certificates. CA certificates can be loaded from either the truststore file, resources or any stream source. We use the [`CertificateLoader`](src/main/java/eu/webeid/security/certificate/CertificateLoader.java) helper class to load CA certificates from resources here, but consider using [the truststore file](example/src/main/java/eu/webeid/example/config/ValidationConfiguration.java) instead. First, copy the trusted certificates, for example `ESTEID2018.cer`, to `resources/cacerts/`, then load the certificates as follows: ```java +import java.io.IOException; +import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import eu.webeid.security.certificate.CertificateLoader; ... - private X509Certificate[] trustedIntermediateCACertificates() { + private X509Certificate[] trustedIntermediateCACertificates() throws CertificateException, IOException { return CertificateLoader.loadCertificatesFromResources("cacerts/ESTEID2018.cer"); } ... @@ -120,14 +122,20 @@ Once the prerequisites have been met, the authentication token validator itself The mandatory parameters are the website origin (the URL serving the web application, see section [_Basic usage_](#basic-usage) below) and trusted certificate authorities. The authentication token validator will be used in the login processing component of your web application authentication framework; it is thread-safe and should be scoped as a singleton. +Certificate revocation checking is enabled automatically using the platform OCSP implementation. No additional OCSP configuration is needed for normal use. + ```java +import java.io.IOException; +import java.net.URI; +import java.security.cert.CertificateException; +import eu.webeid.security.exceptions.JceException; import eu.webeid.security.validator.AuthTokenValidator; import eu.webeid.security.validator.AuthTokenValidatorBuilder; ... - public AuthTokenValidator tokenValidator() throws JceException { + public AuthTokenValidator tokenValidator() throws JceException, CertificateException, IOException { return new AuthTokenValidatorBuilder() - .withSiteOrigin("https://example.org") + .withSiteOrigin(URI.create("https://example.org")) .withTrustedCertificateAuthorities(trustedIntermediateCACertificates()) .build(); } @@ -146,7 +154,7 @@ For internationalized domain names, configure the Punycode form, for example A REST endpoint that issues challenge nonces is required for authentication. The endpoint must support `GET` requests. -In the following example, we are using the [Spring RESTful Web Services framework](https://spring.io/guides/gs/rest-service/) to implement the endpoint, see also the full implementation [here](example/blob/main/src/main/java/eu/webeid/example/web/rest/ChallengeController.java). +In the following example, we are using the [Spring RESTful Web Services framework](https://spring.io/guides/gs/rest-service/) to implement the endpoint, see also the full implementation [here](example/src/main/java/eu/webeid/example/web/rest/ChallengeController.java). ```java import org.springframework.web.bind.annotation.GetMapping; @@ -180,19 +188,22 @@ Authentication consists of calling the `validate()` method of the authentication When using [Spring Security](https://spring.io/guides/topicals/spring-security-architecture) with standard cookie-based authentication, -- implement a custom authentication provider that uses the authentication token validator for authentication as shown [here](example/blob/main/src/main/java/eu/webeid/example/security/AuthTokenDTOAuthenticationProvider.java), -- implement an AJAX authentication processing filter that extracts the authentication token and passes it to the authentication manager as shown [here](example/blob/main/src/main/java/eu/webeid/example/security/WebEidAjaxLoginProcessingFilter.java), -- configure the authentication provider and authentication processing filter in the application configuration as shown [here](example/blob/main/src/main/java/eu/webeid/example/config/ApplicationConfiguration.java). +- implement a custom authentication provider that uses the authentication token validator for authentication as shown [here](example/src/main/java/eu/webeid/example/security/AuthTokenDTOAuthenticationProvider.java), +- implement an AJAX authentication processing filter that extracts the authentication token and passes it to the authentication manager as shown [here](example/src/main/java/eu/webeid/example/security/WebEidAjaxLoginProcessingFilter.java), +- configure the authentication provider and authentication processing filter in the application configuration as shown [here](example/src/main/java/eu/webeid/example/config/ApplicationConfiguration.java). -The gist of the validation is [in the `authenticate()` method](example/blob/main/src/main/java/eu/webeid/example/security/AuthTokenDTOAuthenticationProvider.java#L74-L76) of the authentication provider: +The gist of the validation is [in the `authenticate()` method](example/src/main/java/eu/webeid/example/security/AuthTokenDTOAuthenticationProvider.java) of the authentication provider: ```java try { String nonce = challengeNonceStore.getAndRemove().getBase64EncodedNonce(); - X509Certificate userCertificate = tokenValidator.validate(authToken, nonce); - return WebEidAuthentication.fromCertificate(userCertificate, authorities); + ValidationInfo validationInfo = tokenValidator.validate(authToken, nonce); + return WebEidAuthentication.fromCertificate(validationInfo.subjectCertificate(), authorities); } catch (AuthTokenException e) { - ... + throw new AuthenticationServiceException("Web eID token validation failed", e); +} catch (CertificateEncodingException e) { + throw new AuthenticationServiceException("Invalid certificate subject fields", e); +} ``` # Table of contents @@ -203,7 +214,7 @@ try { - [Authentication token validation](#authentication-token-validation) - [Basic usage](#basic-usage) - [Extended configuration](#extended-configuration) - - [Certificates' Authority Information Access (AIA) extension](#certificates-authority-information-access-aia-extension) + - [Advanced OCSP configuration](src/main/java/eu/webeid/ocsp/README.md) - [Possible validation errors](#possible-validation-errors) - [Stateful and stateless authentication](#stateful-and-stateless-authentication) - [Challenge nonce generation](#challenge-nonce-generation) @@ -281,13 +292,18 @@ The authentication token validator configuration and construction is described i ```java String challengeNonce = challengeNonceStore.getAndRemove().getBase64EncodedNonce(); WebEidAuthToken token = tokenValidator.parse(tokenString); -X509Certificate userCertificate = tokenValidator.validate(token, challengeNonce); +ValidationInfo validationInfo = tokenValidator.validate(token, challengeNonce); +X509Certificate userCertificate = validationInfo.subjectCertificate(); ``` -The `validate()` method returns the validated user certificate object if validation is successful or throws an exception as described in section *[Possible validation errors](#possible-validation-errors)* below if validation fails. The `CertificateData` and `TitleCase` classes can be used for extracting user information from the user certificate object: +The `validate()` method returns a `ValidationInfo` object on success. Use `subjectCertificate()` to obtain the validated certificate. Validation failures throw an exception, as described in [Possible validation errors](#possible-validation-errors). + +Additional revocation information is available to custom integrations through `revocationInfoList()`; see the [OCSP guide](src/main/java/eu/webeid/ocsp/README.md#revocation-information). + +The `CertificateData` and `Strings` classes provide helpers for extracting and formatting user information: ```java -import eu.webeid.security.certificate; +import eu.webeid.security.certificate.CertificateData; import static eu.webeid.security.util.Strings.toTitleCase; ... @@ -300,42 +316,19 @@ toTitleCase(CertificateData.getSubjectGivenName(userCertificate).orElseThrow()); toTitleCase(CertificateData.getSubjectSurname(userCertificate).orElseThrow()); // "Jõeorg" ``` -## Extended configuration +## Extended configuration -The following additional configuration options are available in `AuthTokenValidatorBuilder`: +The default validator uses the platform OCSP implementation to check certificate revocation. A revoked certificate or an unsuccessful revocation check causes authentication to fail. -- `withoutUserCertificateRevocationCheckWithOcsp()` – turns off user certificate revocation check with OCSP. OCSP check is enabled by default and the OCSP responder access location URL is extracted from the user certificate AIA extension unless a designated OCSP service is activated. -- `withDesignatedOcspServiceConfiguration(DesignatedOcspServiceConfiguration serviceConfiguration)` – activates the provided designated OCSP responder service configuration for user certificate revocation check with OCSP. The designated service is only used for checking the status of the certificates whose issuers are supported by the service, for other certificates the default AIA extension service access location will be used. See configuration examples in `testutil.OcspServiceMaker.getDesignatedOcspServiceConfiguration()`. -- `withOcspClient(OcspClient ocspClient)` - uses the provided OCSP client instance during user certificate revocation check with OCSP. The provided client instance must be thread-safe. This gives the possibility to configure the request timeouts, proxies etc of the `HttpClient` instance or provide an implementation that uses an altogether different HTTP client. See examples in `OcspClientOverrideTest`. -- `withOcspRequestTimeout(Duration ocspRequestTimeout)` – sets both the connection and response timeout of user certificate revocation check OCSP requests. Default is 5 seconds. -- `withDisallowedCertificatePolicies(ASN1ObjectIdentifier... policies)` – adds the given policies to the list of disallowed user certificate policies. In order for the user certificate to be considered valid, it must not contain any policies present in this list. Contains the Estonian Mobile-ID policies by default as it must not be possible to authenticate with a Mobile-ID certificate when an eID smart card is expected. -- `withNonceDisabledOcspUrls(URI... urls)` – adds the given URLs to the list of OCSP responder access location URLs for which the nonce protocol extension will be disabled. Some OCSP responders don't support the nonce extension. -- `withAllowedOcspResponseTimeSkew(Duration allowedTimeSkew)` – sets the allowed time skew for OCSP response's `thisUpdate` and `nextUpdate` times to allow discrepancies between the system clock and the OCSP responder's clock or revocation updates that are not published in real time. The default allowed time skew is 15 minutes. The relatively long default is specifically chosen to account for one particular OCSP responder that used CRLs for authoritative revocation info, these CRLs were updated every 15 minutes. -- `withMaxOcspResponseThisUpdateAge(Duration maxThisUpdateAge)` – sets the maximum age for the OCSP response's `thisUpdate` time before it is considered too old to rely on. The default maximum age is 2 minutes. +Use `withDisallowedCertificatePolicies(ASN1ObjectIdentifier... policies)` to add disallowed certificate policies. Estonian Mobile-ID policies are disallowed by default because smart-card authentication must not accept Mobile-ID certificates. -Extended configuration example: +For more advanced revocation requirements, supply a `CertificateRevocationChecker` with `withCertificateRevocationChecker(...)`. The [OCSP configuration guide](src/main/java/eu/webeid/ocsp/README.md) covers custom implementations, the bundled OCSP checker, custom PKIX checkers, responder selection, HTTP settings, and nonce policies. -```java -AuthTokenValidator validator = new AuthTokenValidatorBuilder() - .withSiteOrigin("https://example.org") - .withTrustedCertificateAuthorities(trustedCertificateAuthorities()) - .withoutUserCertificateRevocationCheckWithOcsp() - .withDisallowedCertificatePolicies(new ASN1ObjectIdentifier("1.2.3")) - .withNonceDisabledOcspUrls(URI.create("http://aia.example.org/cert")) - .withAllowedOcspResponseTimeSkew(Duration.ofMinutes(10)) - .withMaxOcspResponseThisUpdateAge(Duration.ofMinutes(5)) - .build(); -``` - -### Certificates' *Authority Information Access* (AIA) extension - -Unless a designated OCSP responder service is in use, it is required that the AIA extension that contains the certificate’s OCSP responder access location is present in the user certificate. The AIA OCSP URL will be used to check the certificate revocation status with OCSP. +## Possible validation errors -Note that there may be limitations to using AIA URLs as the services behind these URLs provide different security and SLA guarantees than dedicated OCSP responder services. In case you need a SLA guarantee, use a designated OCSP responder service. +Certificate and token validation failures are reported through `AuthTokenException` subclasses. `CertificateRevokedException` means the certificate is revoked; `CertificateRevocationCheckFailedException` means its status could not be established. Other failures are documented in the [exception classes](src/main/java/eu/webeid/security/exceptions/). -## Possible validation errors - -The `validate()` method of `AuthTokenValidator` returns the validated user certificate object if validation is successful or throws an exception if validation fails. All exceptions that can occur during validation derive from `AuthTokenException`, the list of available exceptions is available [here](src/main/java/eu/webeid/security/exceptions/). Each exception file contains a documentation comment that describes under which conditions the exception is thrown. +Log the exception itself, for example `LOG.warn("Web eID authentication failed", e)`, to preserve its cause chain. When wrapping it, retain the cause as shown in the authentication example above. Return a generic authentication failure to the client; keep diagnostic details in server logs. See the [OCSP diagnostics guide](src/main/java/eu/webeid/ocsp/README.md#errors-and-diagnostics) for revocation-specific details. ## Stateful and stateless authentication @@ -348,7 +341,11 @@ A common alternative to stateful authentication is stateless authentication with The authentication protocol requires support for generating challenge nonces, large random numbers that can be used only once, and storing them for later use during token validation. The validation library uses the *java.security.SecureRandom* API as the secure random source and the `ChallengeNonceStore` interface for storing issued challenge nonces. -The `-Djava.security.egd=file:/dev/./urandom` command line argument is added to `pom.xml` to avoid the risk of having the code execution blocked unexpectedly during random generation. Without this, the JVM uses `/dev/random`, which can block, to seed the `SecureRandom` class. +No additional JVM configuration is normally required. The selected random-number generator and its entropy source depend on the JDK, operating system and security-provider configuration; some implementations may block while gathering entropy. See the [JDK's `SecureRandom` documentation](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/security/SecureRandom.html). + +If nonce generation stalls, inspect the application's thread dump and selected `SecureRandom` implementation before changing its configuration. On Linux with the OpenJDK SUN provider, `-Djava.security.egd=file:/dev/urandom` can be supplied when starting the application to select `/dev/urandom` as the entropy source for implementations that honor this property. This setting affects the whole JVM. Alternatively, configure the challenge nonce generator with a suitable `SecureRandom` instance using `withSecureRandom(...)`. + +This repository's `pom.xml` supplies `-Djava.security.egd=file:/dev/urandom` to the test JVM. That test setting is not automatically applied to applications using the library. The authentication protocol requires a REST endpoint that issues challenge nonces as described in section *[6. Add a REST endpoint for issuing challenge nonces](#6-add-a-rest-endpoint-for-issuing-challenge-nonces)*. @@ -356,11 +353,11 @@ Nonce usage is described in more detail in the [Web eID system architecture docu ## Basic usage -As described in section *[3. Configure the nonce generator](#3-configure-the-nonce-generator)*, the only mandatory configuration parameter of the challenge nonce generator is the challenge nonce store. +As described in section *[3. Configure the challenge nonce generator](#3-configure-the-challenge-nonce-generator)*, the only mandatory configuration parameter of the challenge nonce generator is the challenge nonce store. The challenge nonce store is used to save the nonce value along with the nonce expiry time. It must be possible to look up the challenge nonce data structure from the store using an identifier specific to the browser session. The values from the store are used by the token validator as described in the section *[Authentication token validation > Basic usage](#basic-usage)* that also contains recommendations for store usage and configuration. -The nonce generator configuration and construction is described in more detail in section *[3. Configure the nonce generator](#3-configure-the-nonce-generator)*. Once the generator object has been constructed, it can be used for generating nonces as follows: +The nonce generator configuration and construction is described in more detail in section *[3. Configure the challenge nonce generator](#3-configure-the-challenge-nonce-generator)*. Once the generator object has been constructed, it can be used for generating nonces as follows: ```java ChallengeNonce challengeNonce = nonceGenerator.generateAndStoreNonce(); @@ -370,14 +367,14 @@ The `generateAndStoreNonce()` method both generates the nonce and saves it in th ## Extended configuration -The following additional configuration options are available in `NonceGeneratorBuilder`: +The following additional configuration options are available in `ChallengeNonceGeneratorBuilder`: - `withNonceTtl(Duration duration)` – overrides the default challenge nonce time-to-live duration. When the time-to-live passes, the nonce is considered to be expired. Default challenge nonce time-to-live is 5 minutes. - `withSecureRandom(SecureRandom)` - allows to specify a custom `SecureRandom` instance. Extended configuration example: ```java -NonceGenerator generator = new NonceGeneratorBuilder() +ChallengeNonceGenerator generator = new ChallengeNonceGeneratorBuilder() .withChallengeNonceStore(store) .withNonceTtl(Duration.ofMinutes(5)) .withSecureRandom(customSecureRandom) diff --git a/example/pom.xml b/example/pom.xml index 6f848dcc..9f204ab9 100644 --- a/example/pom.xml +++ b/example/pom.xml @@ -12,7 +12,7 @@ eu.webeid.example web-eid-springboot-example - 3.2.2 + 4.0.0-SNAPSHOT web-eid-springboot-example Example Spring Boot application that demonstrates how to use Web eID for authentication and digital signing @@ -21,9 +21,8 @@ 17 3.5.3 - 3.2.1 + 4.0.0-SNAPSHOT 6.1.1 - 1.44 3.5.1 @@ -74,12 +73,6 @@ spring-security-test test - - org.jmockit - jmockit - ${jmockit.version} - test - @@ -93,9 +86,6 @@ maven-surefire-plugin ${maven-surefire-plugin.version} - - -javaagent:${settings.localRepository}/org/jmockit/jmockit/${jmockit.version}/jmockit-${jmockit.version}.jar - true diff --git a/example/src/main/java/eu/webeid/example/config/ValidationConfiguration.java b/example/src/main/java/eu/webeid/example/config/ValidationConfiguration.java index 540d97f9..65908017 100644 --- a/example/src/main/java/eu/webeid/example/config/ValidationConfiguration.java +++ b/example/src/main/java/eu/webeid/example/config/ValidationConfiguration.java @@ -65,7 +65,6 @@ public AuthTokenValidator validator(YAMLConfig yamlConfig) { .withSiteOrigin(URI.create(yamlConfig.getLocalOrigin())) .withTrustedCertificateAuthorities(loadTrustedCACertificatesFromCerFiles()) .withTrustedCertificateAuthorities(loadTrustedCACertificatesFromTrustStore(yamlConfig)) - .withOcspRequestTimeout(yamlConfig.getOcspRequestTimeout()) .build(); } catch (JceException e) { throw new RuntimeException("Error building the Web eID auth token validator.", e); @@ -77,7 +76,7 @@ public YAMLConfig yamlConfig() { return new YAMLConfig(); } - private X509Certificate[] loadTrustedCACertificatesFromCerFiles() { + X509Certificate[] loadTrustedCACertificatesFromCerFiles() { List caCertificates = new ArrayList<>(); try { @@ -87,18 +86,19 @@ private X509Certificate[] loadTrustedCACertificatesFromCerFiles() { Resource[] resources = resolver.getResources(CERTS_RESOURCE_PATH + activeProfile + "/*.cer"); for (Resource resource : resources) { - X509Certificate caCertificate = (X509Certificate) certFactory.generateCertificate(resource.getInputStream()); - caCertificates.add(caCertificate); + try (InputStream stream = resource.getInputStream()) { + caCertificates.add((X509Certificate) certFactory.generateCertificate(stream)); + } } } catch (CertificateException | IOException e) { throw new RuntimeException("Error initializing trusted CA certificates.", e); } - return caCertificates.toArray(new X509Certificate[0]); + return caCertificates.toArray(X509Certificate[]::new); } - private X509Certificate[] loadTrustedCACertificatesFromTrustStore(YAMLConfig yamlConfig) { + X509Certificate[] loadTrustedCACertificatesFromTrustStore(YAMLConfig yamlConfig) { List caCertificates = new ArrayList<>(); try (InputStream is = ValidationConfiguration.class.getResourceAsStream(CERTS_RESOURCE_PATH + activeProfile + "/" + TRUSTED_CERTIFICATES_JKS)) { @@ -118,7 +118,7 @@ private X509Certificate[] loadTrustedCACertificatesFromTrustStore(YAMLConfig yam throw new RuntimeException("Error initializing trusted CA certificates from trust store.", e); } - return caCertificates.toArray(new X509Certificate[0]); + return caCertificates.toArray(X509Certificate[]::new); } diff --git a/example/src/main/java/eu/webeid/example/config/YAMLConfig.java b/example/src/main/java/eu/webeid/example/config/YAMLConfig.java index 4cf7c660..b8734c3a 100644 --- a/example/src/main/java/eu/webeid/example/config/YAMLConfig.java +++ b/example/src/main/java/eu/webeid/example/config/YAMLConfig.java @@ -3,7 +3,6 @@ package eu.webeid.example.config; -import java.time.Duration; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -23,8 +22,6 @@ public class YAMLConfig { @Value("truststore-password") private String trustStorePassword; - private Duration ocspRequestTimeout = Duration.ofSeconds(5L); - @Value("#{new Boolean('${web-eid-auth-token.validation.use-digidoc4j-prod-configuration}'.trim())}") private Boolean useDigiDoc4jProdConfiguration; @@ -59,12 +56,4 @@ public boolean getUseDigiDoc4jProdConfiguration() { public void setUseDigiDoc4jProdConfiguration(boolean useDigiDoc4jProdConfiguration) { this.useDigiDoc4jProdConfiguration = useDigiDoc4jProdConfiguration; } - - public Duration getOcspRequestTimeout() { - return ocspRequestTimeout; - } - - public void setOcspRequestTimeout(Duration ocspRequestTimeout) { - this.ocspRequestTimeout = ocspRequestTimeout; - } } diff --git a/example/src/main/java/eu/webeid/example/security/AuthTokenDTOAuthenticationProvider.java b/example/src/main/java/eu/webeid/example/security/AuthTokenDTOAuthenticationProvider.java index 7104b110..86ed45eb 100644 --- a/example/src/main/java/eu/webeid/example/security/AuthTokenDTOAuthenticationProvider.java +++ b/example/src/main/java/eu/webeid/example/security/AuthTokenDTOAuthenticationProvider.java @@ -8,6 +8,7 @@ import eu.webeid.security.challenge.ChallengeNonceStore; import eu.webeid.security.exceptions.AuthTokenException; import eu.webeid.security.validator.AuthTokenValidator; +import eu.webeid.security.validator.ValidationInfo; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.security.authentication.AuthenticationProvider; @@ -20,12 +21,10 @@ import org.springframework.stereotype.Component; import java.security.cert.CertificateEncodingException; -import java.security.cert.X509Certificate; -import java.util.Collections; import java.util.List; /** - * Parses JWT from token string inside AuthTokenDTO and attempts authentication. + * Validates the Web eID authentication token supplied in AuthTokenDTO. */ @Component public class AuthTokenDTOAuthenticationProvider implements AuthenticationProvider { @@ -46,15 +45,17 @@ public AuthTokenDTOAuthenticationProvider(AuthTokenValidator tokenValidator, Cha public Authentication authenticate(Authentication auth) throws AuthenticationException { LOG.info("authenticate(): {}", auth); - final PreAuthenticatedAuthenticationToken authentication = (PreAuthenticatedAuthenticationToken) auth; - final WebEidAuthToken authToken = ((AuthTokenDTO) authentication.getCredentials()).getToken(); + if (!(auth.getCredentials() instanceof AuthTokenDTO credentials) || credentials.token() == null) { + throw new AuthenticationServiceException("Authentication token is missing"); + } + final WebEidAuthToken authToken = credentials.token(); - final List authorities = Collections.singletonList(USER_ROLE); + final List authorities = List.of(USER_ROLE); try { final String nonce = challengeNonceStore.getAndRemove().getBase64EncodedNonce(); - final X509Certificate userCertificate = tokenValidator.validate(authToken, nonce); - return WebEidAuthentication.fromCertificate(userCertificate, authorities); + final ValidationInfo validationInfo = tokenValidator.validate(authToken, nonce); + return WebEidAuthentication.fromCertificate(validationInfo.subjectCertificate(), authorities); } catch (AuthTokenException e) { throw new AuthenticationServiceException("Web eID token validation failed", e); } catch (CertificateEncodingException e) { diff --git a/example/src/main/java/eu/webeid/example/security/WebEidAjaxLoginProcessingFilter.java b/example/src/main/java/eu/webeid/example/security/WebEidAjaxLoginProcessingFilter.java index 2af96cfc..3f503c37 100644 --- a/example/src/main/java/eu/webeid/example/security/WebEidAjaxLoginProcessingFilter.java +++ b/example/src/main/java/eu/webeid/example/security/WebEidAjaxLoginProcessingFilter.java @@ -3,6 +3,7 @@ package eu.webeid.example.security; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectReader; import eu.webeid.example.security.ajax.AjaxAuthenticationFailureHandler; @@ -30,7 +31,7 @@ public class WebEidAjaxLoginProcessingFilter extends AbstractAuthenticationProcessingFilter { private static final Logger LOG = LoggerFactory.getLogger(WebEidAjaxLoginProcessingFilter.class); - private final ObjectReader OBJECT_READER = new ObjectMapper().readerFor(AuthTokenDTO.class); + private static final ObjectReader OBJECT_READER = new ObjectMapper().readerFor(AuthTokenDTO.class); private final SecurityContextRepository securityContextRepository; public WebEidAjaxLoginProcessingFilter( @@ -59,7 +60,15 @@ public Authentication attemptAuthentication(HttpServletRequest request, HttpServ } LOG.info("attemptAuthentication(): Reading request body"); - final AuthTokenDTO authTokenDTO = OBJECT_READER.readValue(request.getReader()); + final AuthTokenDTO authTokenDTO; + try { + authTokenDTO = OBJECT_READER.readValue(request.getReader()); + } catch (JsonProcessingException e) { + throw new AuthenticationServiceException("Invalid authentication request", e); + } + if (authTokenDTO == null || authTokenDTO.token() == null) { + throw new AuthenticationServiceException("Authentication token is missing"); + } LOG.info("attemptAuthentication(): Creating token"); final PreAuthenticatedAuthenticationToken token = new PreAuthenticatedAuthenticationToken(null, authTokenDTO); LOG.info("attemptAuthentication(): Calling authentication manager"); diff --git a/example/src/main/java/eu/webeid/example/security/WebEidAuthentication.java b/example/src/main/java/eu/webeid/example/security/WebEidAuthentication.java index e83c16f6..dc23d944 100644 --- a/example/src/main/java/eu/webeid/example/security/WebEidAuthentication.java +++ b/example/src/main/java/eu/webeid/example/security/WebEidAuthentication.java @@ -49,9 +49,9 @@ private static String getPrincipalNameFromCertificate(X509Certificate userCertif @Override public boolean equals(Object o) { - if (!super.equals(o)) return false; - WebEidAuthentication that = (WebEidAuthentication) o; - return Objects.equals(idCode, that.idCode); + return o instanceof WebEidAuthentication that + && super.equals(that) + && Objects.equals(idCode, that.idCode); } @Override diff --git a/example/src/main/java/eu/webeid/example/security/ajax/AjaxAuthenticationFailureHandler.java b/example/src/main/java/eu/webeid/example/security/ajax/AjaxAuthenticationFailureHandler.java index 29061dab..5925b4c5 100644 --- a/example/src/main/java/eu/webeid/example/security/ajax/AjaxAuthenticationFailureHandler.java +++ b/example/src/main/java/eu/webeid/example/security/ajax/AjaxAuthenticationFailureHandler.java @@ -22,10 +22,7 @@ public class AjaxAuthenticationFailureHandler extends SimpleUrlAuthenticationFai public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException { final String message = AUTHENTICATION_FAILED + exception.getMessage(); - LOG.warn("onAuthenticationFailure(): exception {}, returning {} {}", - exception, - HttpServletResponse.SC_UNAUTHORIZED, - message); + LOG.warn("Authentication failed; returning HTTP 401", exception); final HttpSession session = request.getSession(false); if (session != null) { LOG.info("Invalidating session"); diff --git a/example/src/main/java/eu/webeid/example/security/ajax/AjaxAuthenticationSuccessHandler.java b/example/src/main/java/eu/webeid/example/security/ajax/AjaxAuthenticationSuccessHandler.java index 38ef6e16..cabcf48f 100644 --- a/example/src/main/java/eu/webeid/example/security/ajax/AjaxAuthenticationSuccessHandler.java +++ b/example/src/main/java/eu/webeid/example/security/ajax/AjaxAuthenticationSuccessHandler.java @@ -3,7 +3,6 @@ package eu.webeid.example.security.ajax; -import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectWriter; @@ -42,19 +41,11 @@ public void onAuthenticationSuccess( response.getWriter().write(AuthSuccessDTO.asJson(authentication)); } - public static class AuthSuccessDTO { + public record AuthSuccessDTO(String sub, String auth) { private static final ObjectWriter OBJECT_WRITER = new ObjectMapper().writerFor(AuthSuccessDTO.class); - @JsonProperty("sub") - private String sub; - - @JsonProperty("auth") - private String auth; - public static String asJson(Authentication authentication) throws JsonProcessingException { - final AuthSuccessDTO dto = new AuthSuccessDTO(); - dto.sub = authentication.getName(); - dto.auth = authentication.getAuthorities().toString(); + final AuthSuccessDTO dto = new AuthSuccessDTO(authentication.getName(), authentication.getAuthorities().toString()); return OBJECT_WRITER.writeValueAsString(dto); } } diff --git a/example/src/main/java/eu/webeid/example/security/dto/AuthTokenDTO.java b/example/src/main/java/eu/webeid/example/security/dto/AuthTokenDTO.java index 07dae682..78664488 100644 --- a/example/src/main/java/eu/webeid/example/security/dto/AuthTokenDTO.java +++ b/example/src/main/java/eu/webeid/example/security/dto/AuthTokenDTO.java @@ -6,15 +6,5 @@ import com.fasterxml.jackson.annotation.JsonProperty; import eu.webeid.security.authtoken.WebEidAuthToken; -public class AuthTokenDTO { - @JsonProperty("auth-token") - private WebEidAuthToken token; - - public WebEidAuthToken getToken() { - return token; - } - - public void setToken(WebEidAuthToken token) { - this.token = token; - } +public record AuthTokenDTO(@JsonProperty("auth-token") WebEidAuthToken token) { } diff --git a/example/src/main/java/eu/webeid/example/service/SigningService.java b/example/src/main/java/eu/webeid/example/service/SigningService.java index cd2b5a78..e2b3ad71 100644 --- a/example/src/main/java/eu/webeid/example/service/SigningService.java +++ b/example/src/main/java/eu/webeid/example/service/SigningService.java @@ -108,11 +108,7 @@ public DigestDTO prepareContainer(CertificateDTO certificateDTO, WebEidAuthentic final byte[] digest = signatureDigestAlgorithm.getDssDigestAlgorithm().getMessageDigest() .digest(dataToSign.getDataToSign()); - final DigestDTO digestDTO = new DigestDTO(); - digestDTO.setHash(DatatypeConverter.printBase64Binary(digest)); - digestDTO.setHashFunction(digestAlgorithmName); - - return digestDTO; + return new DigestDTO(DatatypeConverter.printBase64Binary(digest), digestAlgorithmName); } /** @@ -127,7 +123,7 @@ public FileDTO signContainer(SignatureDTO signatureDTO) { Container containerToSign = (Container) Objects.requireNonNull(currentSession().getAttribute(SESSION_ATTR_CONTAINER)); DataToSign dataToSign = (DataToSign) Objects.requireNonNull(currentSession().getAttribute(SESSION_ATTR_DATA)); - byte[] signatureBytes = DatatypeConverter.parseBase64Binary(signatureDTO.getBase64Signature()); + byte[] signatureBytes = DatatypeConverter.parseBase64Binary(signatureDTO.base64Signature()); Signature signature = dataToSign.finalize(signatureBytes); containerToSign.addSignature(signature); currentSession().setAttribute(SESSION_ATTR_CONTAINER, containerToSign); diff --git a/example/src/main/java/eu/webeid/example/service/dto/CertificateDTO.java b/example/src/main/java/eu/webeid/example/service/dto/CertificateDTO.java index 8bbf1ff8..1e36c6d9 100644 --- a/example/src/main/java/eu/webeid/example/service/dto/CertificateDTO.java +++ b/example/src/main/java/eu/webeid/example/service/dto/CertificateDTO.java @@ -3,15 +3,15 @@ package eu.webeid.example.service.dto; +import com.fasterxml.jackson.annotation.JsonProperty; + import java.io.ByteArrayInputStream; import java.io.InputStream; import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; -import java.util.ArrayList; import java.util.Base64; import java.util.List; -import java.util.stream.Collectors; public class CertificateDTO { @@ -41,11 +41,12 @@ public X509Certificate toX509Certificate() throws CertificateException { return (X509Certificate) cf.generateCertificate(inStream); } + @JsonProperty(access = JsonProperty.Access.READ_ONLY) public List getSupportedHashFunctionNames() { - return supportedSignatureAlgorithms == null ? new ArrayList<>() : supportedSignatureAlgorithms + return supportedSignatureAlgorithms == null ? List.of() : supportedSignatureAlgorithms .stream() .map(SignatureAlgorithmDTO::getHashFunction) .distinct() - .collect(Collectors.toList()); + .toList(); } } diff --git a/example/src/main/java/eu/webeid/example/service/dto/ChallengeDTO.java b/example/src/main/java/eu/webeid/example/service/dto/ChallengeDTO.java index 53a2bd0e..c80ee496 100644 --- a/example/src/main/java/eu/webeid/example/service/dto/ChallengeDTO.java +++ b/example/src/main/java/eu/webeid/example/service/dto/ChallengeDTO.java @@ -3,14 +3,5 @@ package eu.webeid.example.service.dto; -public class ChallengeDTO { - private String nonce; - - public String getNonce() { - return nonce; - } - - public void setNonce(String nonce) { - this.nonce = nonce; - } +public record ChallengeDTO(String nonce) { } diff --git a/example/src/main/java/eu/webeid/example/service/dto/DigestDTO.java b/example/src/main/java/eu/webeid/example/service/dto/DigestDTO.java index 20f68dd2..0eb97c80 100644 --- a/example/src/main/java/eu/webeid/example/service/dto/DigestDTO.java +++ b/example/src/main/java/eu/webeid/example/service/dto/DigestDTO.java @@ -3,23 +3,5 @@ package eu.webeid.example.service.dto; -public class DigestDTO { - private String hash; - private String hashFunction; - - public String getHash() { - return hash; - } - - public void setHash(String hash) { - this.hash = hash; - } - - public String getHashFunction() { - return hashFunction; - } - - public void setHashFunction(String hashFunction) { - this.hashFunction = hashFunction; - } +public record DigestDTO(String hash, String hashFunction) { } diff --git a/example/src/main/java/eu/webeid/example/service/dto/FileDTO.java b/example/src/main/java/eu/webeid/example/service/dto/FileDTO.java index 44888758..d82f2060 100644 --- a/example/src/main/java/eu/webeid/example/service/dto/FileDTO.java +++ b/example/src/main/java/eu/webeid/example/service/dto/FileDTO.java @@ -8,10 +8,8 @@ import org.springframework.web.multipart.MultipartFile; import java.io.IOException; +import java.io.InputStream; import java.io.Serializable; -import java.net.URI; -import java.nio.file.Files; -import java.nio.file.Paths; import java.util.Objects; public class FileDTO implements Serializable { @@ -40,12 +38,9 @@ public static FileDTO fromMultipartFile(MultipartFile file) throws IOException { } public static FileDTO getExampleForSigningFromResources() throws IOException { - final URI resourceUri = new ClassPathResource("/static/files/" + EXAMPLE_FILENAME).getURI(); - return new FileDTO( - EXAMPLE_FILENAME, - MimeTypeUtils.TEXT_PLAIN_VALUE, - Files.readAllBytes(Paths.get(resourceUri)) - ); + try (InputStream stream = new ClassPathResource("/static/files/" + EXAMPLE_FILENAME).getInputStream()) { + return new FileDTO(EXAMPLE_FILENAME, MimeTypeUtils.TEXT_PLAIN_VALUE, stream.readAllBytes()); + } } public String getName() { diff --git a/example/src/main/java/eu/webeid/example/service/dto/SignatureAlgorithmDTO.java b/example/src/main/java/eu/webeid/example/service/dto/SignatureAlgorithmDTO.java index 1e5d2e4f..1df2e358 100644 --- a/example/src/main/java/eu/webeid/example/service/dto/SignatureAlgorithmDTO.java +++ b/example/src/main/java/eu/webeid/example/service/dto/SignatureAlgorithmDTO.java @@ -3,24 +3,22 @@ package eu.webeid.example.service.dto; -import java.util.Arrays; -import java.util.HashSet; import java.util.Set; public class SignatureAlgorithmDTO { // See https://github.com/web-eid/web-eid-app/blob/main/src/controller/command-handlers/signauthutils.cpp#L121-L127 - private static final Set SUPPORTED_CRYPTO_ALGOS = new HashSet<>(Arrays.asList( + private static final Set SUPPORTED_CRYPTO_ALGOS = Set.of( "ECC", "RSA" - )); - private static final Set SUPPORTED_PADDING_SCHEMES = new HashSet<>(Arrays.asList( + ); + private static final Set SUPPORTED_PADDING_SCHEMES = Set.of( "NONE", "PKCS1.5", "PSS" - )); + ); // See https://github.com/web-eid/libelectronic-id/tree/main/src/electronic-id.cpp#L131 - private static final Set SUPPORTED_HASH_FUNCTIONS = new HashSet<>(Arrays.asList( + private static final Set SUPPORTED_HASH_FUNCTIONS = Set.of( "SHA-224", "SHA-256", "SHA-384", "SHA-512", "SHA3-224", "SHA3-256", "SHA3-384", "SHA3-512" - )); + ); private String cryptoAlgorithm; @@ -33,7 +31,7 @@ public String getCryptoAlgorithm() { } public void setCryptoAlgorithm(String cryptoAlgorithm) { - if (!SUPPORTED_CRYPTO_ALGOS.contains(cryptoAlgorithm)) { + if (cryptoAlgorithm == null || !SUPPORTED_CRYPTO_ALGOS.contains(cryptoAlgorithm)) { throw new IllegalArgumentException("The provided crypto algorithm is not supported"); } this.cryptoAlgorithm = cryptoAlgorithm; @@ -44,7 +42,7 @@ public String getHashFunction() { } public void setHashFunction(String hashFunction) { - if (!SUPPORTED_HASH_FUNCTIONS.contains(hashFunction)) { + if (hashFunction == null || !SUPPORTED_HASH_FUNCTIONS.contains(hashFunction)) { throw new IllegalArgumentException("The provided hash function is not supported"); } this.hashFunction = hashFunction; @@ -55,7 +53,7 @@ public String getPaddingScheme() { } public void setPaddingScheme(String paddingScheme) { - if (!SUPPORTED_PADDING_SCHEMES.contains(paddingScheme)) { + if (paddingScheme == null || !SUPPORTED_PADDING_SCHEMES.contains(paddingScheme)) { throw new IllegalArgumentException("The provided padding scheme is not supported"); } this.paddingScheme = paddingScheme; diff --git a/example/src/main/java/eu/webeid/example/service/dto/SignatureDTO.java b/example/src/main/java/eu/webeid/example/service/dto/SignatureDTO.java index 03b641b0..2953c176 100644 --- a/example/src/main/java/eu/webeid/example/service/dto/SignatureDTO.java +++ b/example/src/main/java/eu/webeid/example/service/dto/SignatureDTO.java @@ -7,16 +7,5 @@ import com.fasterxml.jackson.annotation.JsonProperty; @JsonIgnoreProperties(ignoreUnknown = true) -public class SignatureDTO { - - @JsonProperty("signature") - private String base64Signature; - - public String getBase64Signature() { - return base64Signature; - } - - public void setBase64Signature(String base64Signature) { - this.base64Signature = base64Signature; - } +public record SignatureDTO(@JsonProperty("signature") String base64Signature) { } diff --git a/example/src/main/java/eu/webeid/example/web/rest/ChallengeController.java b/example/src/main/java/eu/webeid/example/web/rest/ChallengeController.java index be3a4a42..9c161166 100644 --- a/example/src/main/java/eu/webeid/example/web/rest/ChallengeController.java +++ b/example/src/main/java/eu/webeid/example/web/rest/ChallengeController.java @@ -21,8 +21,6 @@ public ChallengeController(ChallengeNonceGenerator challengeNonceGenerator) { @GetMapping("challenge") public ChallengeDTO challenge() { - final ChallengeDTO challenge = new ChallengeDTO(); - challenge.setNonce(challengeNonceGenerator.generateAndStoreNonce().getBase64EncodedNonce()); - return challenge; + return new ChallengeDTO(challengeNonceGenerator.generateAndStoreNonce().getBase64EncodedNonce()); } } diff --git a/example/src/test/java/eu/webeid/example/AuthenticationRestControllerTest.java b/example/src/test/java/eu/webeid/example/AuthenticationRestControllerTest.java index b9ac7a85..04cf0fc4 100644 --- a/example/src/test/java/eu/webeid/example/AuthenticationRestControllerTest.java +++ b/example/src/test/java/eu/webeid/example/AuthenticationRestControllerTest.java @@ -19,7 +19,7 @@ class AuthenticationRestControllerTest { @Test void testChallengeNonceLength() { - assertThat(authRestController.challenge().getNonce().length()) + assertThat(authRestController.challenge().nonce().length()) .isEqualTo(nonceGeneratorNonceBase64Length()); } diff --git a/example/src/test/java/eu/webeid/example/WebApplicationTest.java b/example/src/test/java/eu/webeid/example/WebApplicationTest.java index b842a3af..cd7e49bd 100644 --- a/example/src/test/java/eu/webeid/example/WebApplicationTest.java +++ b/example/src/test/java/eu/webeid/example/WebApplicationTest.java @@ -3,17 +3,22 @@ package eu.webeid.example; +import eu.webeid.example.config.TestValidatorConfiguration; import eu.webeid.example.testutil.Dates; import eu.webeid.example.testutil.HttpHelper; import eu.webeid.example.testutil.ObjectMother; -import mockit.Mock; -import mockit.MockUp; -import org.digidoc4j.impl.asic.AsicSignatureFinalizer; -import org.digidoc4j.impl.asic.xades.XadesSignature; +import io.jsonwebtoken.Clock; +import org.digidoc4j.Container; +import org.digidoc4j.SignatureBuilder; +import org.digidoc4j.SignatureProfile; +import org.digidoc4j.impl.asic.asice.AsicESignatureBuilder; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; import org.springframework.http.HttpStatus; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.mock.web.MockHttpSession; @@ -25,14 +30,19 @@ import eu.webeid.example.service.dto.DigestDTO; import eu.webeid.security.challenge.ChallengeNonce; import eu.webeid.security.util.DateAndTime; -import eu.webeid.security.validator.certvalidators.SubjectCertificateNotRevokedValidator; -import java.security.cert.X509Certificate; +import java.util.Date; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mockStatic; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest +@Import(TestValidatorConfiguration.class) @WebAppConfiguration public class WebApplicationTest { @@ -62,56 +72,75 @@ public void testRoot() throws Exception { System.out.println(response.getContentAsString()); } + @ParameterizedTest + @ValueSource(strings = {"{", "null", "{}", "{\"auth-token\":null}"}) + void whenAuthenticationRequestIsMalformedOrMissingToken_thenReturnsUnauthorized(String body) throws Exception { + mvcBuilder.build().perform(post("/auth/login") + .with(csrf()) + .contentType("application/json") + .content(body)) + .andExpect(status().isUnauthorized()); + } + @Test public void testHappyFlow_LoginPrepareSignDownload() throws Exception { - // Arrange - new MockUp() { - @Mock - public void validateCertificateNotRevoked(X509Certificate subjectCertificate) { - // Do not call real OCSP service in tests. - } - }; - - new MockUp() { - @Mock - public void validateOcspResponse(XadesSignature xadesSignature) { - // Do not call real OCSP service in tests. + try (var mockedClock = mockStatic(DateAndTime.DefaultClock.class); + var mockedSignatureBuilder = mockStatic(SignatureBuilder.class)) { + mockedClock.when(DateAndTime.DefaultClock::getInstance) + .thenReturn(() -> Date.from(Dates.getAuthTokenValidationDateTime().toInstant())); + mockedSignatureBuilder.when(() -> SignatureBuilder.aSignature(any(Container.class))) + .thenAnswer(invocation -> new TestSignatureBuilder(invocation.getArgument(0))); + + MockHttpSession session = new MockHttpSession(); + session.setAttribute("challenge-nonce", new ChallengeNonce(ObjectMother.VALID_CHALLENGE_NONCE, DateAndTime.utcNow().plusMinutes(1))); + + // Act and assert + mvcBuilder.build().perform(get("/auth/challenge")); + + MvcResult result = HttpHelper.login(mvcBuilder, session, ObjectMother.mockAuthToken()); + session = (MockHttpSession) result.getRequest().getSession(); + MockHttpServletResponse response = result.getResponse(); + assertEquals("{\"sub\":\"JAAK-KRISTJAN JÕEORG\",\"auth\":\"[ROLE_USER]\"}", response.getContentAsString()); + + /* Example how to test file upload. + response = HttpHelper.upload(mvcBuilder, session, mockMultipartFile()); + assertEquals(HttpStatus.OK.value(), response.getStatus()); + public static MockMultipartFile mockMultipartFile() { + return new MockMultipartFile("file", "test-file.txt", "text/plain", "some xml".getBytes()); } - }; + */ - MockHttpSession session = new MockHttpSession(); - session.setAttribute("challenge-nonce", new ChallengeNonce(ObjectMother.VALID_CHALLENGE_NONCE, DateAndTime.utcNow().plusMinutes(1))); + response = HttpHelper.prepare(mvcBuilder, session, ObjectMother.mockPrepareRequest()); + assertEquals(HttpStatus.OK.value(), response.getStatus()); - Dates.setMockedSignatureDate(Dates.getSigningDateTime()); - Dates.setMockedAuthTokenValidationDate(Dates.getAuthTokenValidationDateTime()); + DigestDTO digestDTO = ObjectMother.jsonStringToBean(response.getContentAsString(), DigestDTO.class); - // Act and assert - mvcBuilder.build().perform(get("/auth/challenge")); + response = HttpHelper.sign(mvcBuilder, session, ObjectMother.mockSignRequest(digestDTO.hash())); + assertEquals(HttpStatus.OK.value(), response.getStatus()); - MvcResult result = HttpHelper.login(mvcBuilder, session, ObjectMother.mockAuthToken()); - session = (MockHttpSession) result.getRequest().getSession(); - MockHttpServletResponse response = result.getResponse(); - assertEquals("{\"sub\":\"JAAK-KRISTJAN JÕEORG\",\"auth\":\"[ROLE_USER]\"}", response.getContentAsString()); - - /* Example how to test file upload. - response = HttpHelper.upload(mvcBuilder, session, mockMultipartFile()); - assertEquals(HttpStatus.OK.value(), response.getStatus()); - public static MockMultipartFile mockMultipartFile() { - return new MockMultipartFile("file", "test-file.txt", "text/plain", "some xml".getBytes()); + response = HttpHelper.download(mvcBuilder, session); + assertEquals(HttpStatus.OK.value(), response.getStatus()); + assertEquals("attachment; filename=example-for-signing.asice", response.getHeader("Content-Disposition")); + final Container signedContainer = (Container) session.getAttribute("container-to-sign"); + assertEquals(1, signedContainer.getSignatures().size()); + assertEquals(SignatureProfile.T, signedContainer.getSignatures().get(0).getProfile()); } - */ - - response = HttpHelper.prepare(mvcBuilder, session, ObjectMother.mockPrepareRequest()); - assertEquals(HttpStatus.OK.value(), response.getStatus()); - - DigestDTO digestDTO = ObjectMother.jsonStringToBean(response.getContentAsString(), DigestDTO.class); + } - response = HttpHelper.sign(mvcBuilder, session, ObjectMother.mockSignRequest(digestDTO.getHash())); - assertEquals(HttpStatus.OK.value(), response.getStatus()); + private static final class TestSignatureBuilder extends AsicESignatureBuilder { + private TestSignatureBuilder(Container container) { + setContainer(container); + signatureParameters.setClaimedSigningDate(Date.from(Dates.getSigningDateTime().toInstant())); + } - response = HttpHelper.download(mvcBuilder, session); - assertEquals(HttpStatus.OK.value(), response.getStatus()); - assertEquals("attachment; filename=example-for-signing.asice", response.getHeader("Content-Disposition")); + @Override + public SignatureBuilder withSignatureProfile(SignatureProfile profile) { + // Verify that the application requests LT signatures. + assertEquals(SignatureProfile.LT, profile); + // LT requires OCSP evidence unavailable for this test certificate. + // Use T (timestamped signing) for this test so it can complete without that evidence. + return super.withSignatureProfile(SignatureProfile.T); + } } } diff --git a/example/src/test/java/eu/webeid/example/config/TestValidatorConfiguration.java b/example/src/test/java/eu/webeid/example/config/TestValidatorConfiguration.java new file mode 100644 index 00000000..f838a2da --- /dev/null +++ b/example/src/test/java/eu/webeid/example/config/TestValidatorConfiguration.java @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.example.config; + +import eu.webeid.security.exceptions.JceException; +import eu.webeid.security.validator.AuthTokenValidator; +import eu.webeid.security.validator.AuthTokenValidatorBuilder; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; + +import java.net.URI; +import java.util.List; + +/** + * Provides an {@link AuthTokenValidator} that does not call the real OCSP service. + *

+ * Certificate trust validation is performed as usual, only the revocation check is replaced with a + * no-op {@code CertificateRevocationChecker}. + */ +@TestConfiguration +public class TestValidatorConfiguration { + + @Bean + @Primary + AuthTokenValidator validatorWithoutOcspCall(ValidationConfiguration validationConfiguration, + YAMLConfig yamlConfig) throws JceException { + return new AuthTokenValidatorBuilder() + .withSiteOrigin(URI.create(yamlConfig.getLocalOrigin())) + .withTrustedCertificateAuthorities(validationConfiguration.loadTrustedCACertificatesFromCerFiles()) + .withTrustedCertificateAuthorities(validationConfiguration.loadTrustedCACertificatesFromTrustStore(yamlConfig)) + // Do not call the real OCSP service in tests. + .withCertificateRevocationChecker((subjectCertificate, issuerCertificate) -> List.of()) + .build(); + } + +} diff --git a/example/src/test/java/eu/webeid/example/security/WebEidAjaxLoginProcessingFilterTest.java b/example/src/test/java/eu/webeid/example/security/WebEidAjaxLoginProcessingFilterTest.java index 6f6a20fa..159c21d0 100644 --- a/example/src/test/java/eu/webeid/example/security/WebEidAjaxLoginProcessingFilterTest.java +++ b/example/src/test/java/eu/webeid/example/security/WebEidAjaxLoginProcessingFilterTest.java @@ -3,27 +3,38 @@ package eu.webeid.example.security; +import com.fasterxml.jackson.core.JsonProcessingException; +import eu.webeid.example.security.dto.AuthTokenDTO; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.core.Authentication; import java.io.BufferedReader; import java.io.StringReader; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; class WebEidAjaxLoginProcessingFilterTest { - private static final String AUTH_TOKEN = "{\"auth-token\":" + - "{\"algorithm\":\"ES384\"," + - "\"certificate\":\"MIIEAzCCA2WgAwIBAgIQHWbVWxCkcYxbzz9nBzGrDzAKBggqhkjOPQQDBDBgMQswCQYDVQQGEwJFRTEbMBkGA1UECgwSU0sgSUQgU29sdXRpb25zIEFTMRcwFQYDVQRhDA5OVFJFRS0xMDc0NzAxMzEbMBkGA1UEAwwSVEVTVCBvZiBFU1RFSUQyMDE4MB4XDTE4MTAyMzE1MzM1OVoXDTIzMTAyMjIxNTk1OVowfzELMAkGA1UEBhMCRUUxKjAoBgNVBAMMIUrDlUVPUkcsSkFBSy1LUklTVEpBTiwzODAwMTA4NTcxODEQMA4GA1UEBAwHSsOVRU9SRzEWMBQGA1UEKgwNSkFBSy1LUklTVEpBTjEaMBgGA1UEBRMRUE5PRUUtMzgwMDEwODU3MTgwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQ/u+9IncarVpgrACN6aRgUiT9lWC9H7llnxoEXe8xoCI982Md8YuJsVfRdeG5jwVfXe0N6KkHLFRARspst8qnACULkqFNat/Kj+XRwJ2UANeJ3Gl5XBr+tnLNuDf/UiR6jggHDMIIBvzAJBgNVHRMEAjAAMA4GA1UdDwEB/wQEAwIDiDBHBgNVHSAEQDA+MDIGCysGAQQBg5EhAQIBMCMwIQYIKwYBBQUHAgEWFWh0dHBzOi8vd3d3LnNrLmVlL0NQUzAIBgYEAI96AQIwHwYDVR0RBBgwFoEUMzgwMDEwODU3MThAZWVzdGkuZWUwHQYDVR0OBBYEFOTddHnA9rJtbLwhBNyn0xZTQGCMMGEGCCsGAQUFBwEDBFUwUzBRBgYEAI5GAQUwRzBFFj9odHRwczovL3NrLmVlL2VuL3JlcG9zaXRvcnkvY29uZGl0aW9ucy1mb3ItdXNlLW9mLWNlcnRpZmljYXRlcy8TAkVOMCAGA1UdJQEB/wQWMBQGCCsGAQUFBwMCBggrBgEFBQcDBDAfBgNVHSMEGDAWgBTAhJkpxE6fOwI09pnhClYACCk+ezBzBggrBgEFBQcBAQRnMGUwLAYIKwYBBQUHMAGGIGh0dHA6Ly9haWEuZGVtby5zay5lZS9lc3RlaWQyMDE4MDUGCCsGAQUFBzAChilodHRwOi8vYy5zay5lZS9UZXN0X29mX0VTVEVJRDIwMTguZGVyLmNydDAKBggqhkjOPQQDBAOBiwAwgYcCQgHYElkX4vn821JR41akI/lpexCnJFUf4GiOMbTfzAxpZma333R8LNrmI4zbzDp03hvMTzH49g1jcbGnaCcbboS8DAJBObenUp++L5VqldHwKAps61nM4V+TiLqD0jILnTzl+pV+LexNL3uGzUfvvDNLHnF9t6ygi8+Bsjsu3iHHyM1haKM=\"," + - "\"issuerApp\":\"https://web-eid.eu/web-eid-app/releases/2.0.0+976\"," + - "\"signature\":\"Z+r6IIx0lmXNTHrlJOTknVaOYszXba5ko1e8rjWJXd4nzIVsxeTps/Revg+tuKREkkIuIKhwvOnARdWV6vBmhjlpRUZn9aNPSDRP98T/0sPZgDp31hwsWfCAYnPzzYC9\"," + - "\"version\":\"web-eid:1\"}}"; + private static final String AUTH_TOKEN = """ + {"auth-token": { + "format": "web-eid:1.0", + "algorithm": "ES384", + "unverifiedCertificate": "test-certificate", + "signature": "test-signature" + }} + """; @Test void testAttemptAuthentication() throws Exception { @@ -38,5 +49,28 @@ void testAttemptAuthentication() throws Exception { assertDoesNotThrow(() -> new WebEidAjaxLoginProcessingFilter("/auth/login", authenticationManager) .attemptAuthentication(request, response)); + + final ArgumentCaptor authentication = ArgumentCaptor.forClass(Authentication.class); + verify(authenticationManager).authenticate(authentication.capture()); + assertThat(authentication.getValue().getCredentials()).isInstanceOfSatisfying(AuthTokenDTO.class, dto -> { + assertThat(dto.token().format()).isEqualTo("web-eid:1.0"); + assertThat(dto.token().unverifiedCertificate()).isEqualTo("test-certificate"); + assertThat(dto.token().signature()).isEqualTo("test-signature"); + }); + } + + @Test + void whenJsonIsMalformed_thenPreservesCauseInAuthenticationFailure() throws Exception { + final HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getMethod()).thenReturn(HttpMethod.POST.name()); + when(request.getHeader("Content-type")).thenReturn("application/json"); + when(request.getReader()).thenReturn(new BufferedReader(new StringReader("{"))); + final AuthenticationManager manager = mock(AuthenticationManager.class); + + assertThatThrownBy(() -> new WebEidAjaxLoginProcessingFilter("/auth/login", manager) + .attemptAuthentication(request, mock(HttpServletResponse.class))) + .isInstanceOf(AuthenticationServiceException.class) + .hasCauseInstanceOf(JsonProcessingException.class); + verifyNoInteractions(manager); } -} \ No newline at end of file +} diff --git a/example/src/test/java/eu/webeid/example/security/WebEidAuthenticationTest.java b/example/src/test/java/eu/webeid/example/security/WebEidAuthenticationTest.java index 1e9d412b..899781d8 100644 --- a/example/src/test/java/eu/webeid/example/security/WebEidAuthenticationTest.java +++ b/example/src/test/java/eu/webeid/example/security/WebEidAuthenticationTest.java @@ -6,6 +6,7 @@ import eu.webeid.security.certificate.CertificateLoader; import org.junit.jupiter.api.Test; import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken; import java.security.cert.X509Certificate; import java.util.Collections; @@ -23,4 +24,16 @@ void whenOrganizationCertificate_thenSucceeds() throws Exception { assertThat(authentication.getPrincipal()).isEqualTo("Testijad.ee isikutuvastus"); } -} \ No newline at end of file + @Test + void whenComparedWithAnotherAuthenticationType_thenReturnsFalse() throws Exception { + final X509Certificate certificate = CertificateLoader.decodeCertificateFromBase64(ORGANIZATION_CERT); + final Authentication authentication = WebEidAuthentication.fromCertificate(certificate, Collections.emptyList()); + final Authentication other = new PreAuthenticatedAuthenticationToken( + authentication.getPrincipal(), authentication.getCredentials(), Collections.emptyList()); + + assertThat(authentication.equals(other)).isFalse(); + assertThat(authentication.equals(null)).isFalse(); + assertThat(authentication).isEqualTo(WebEidAuthentication.fromCertificate(certificate, Collections.emptyList())); + } + +} diff --git a/example/src/test/java/eu/webeid/example/service/dto/FileDTOTest.java b/example/src/test/java/eu/webeid/example/service/dto/FileDTOTest.java new file mode 100644 index 00000000..a46ba229 --- /dev/null +++ b/example/src/test/java/eu/webeid/example/service/dto/FileDTOTest.java @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.example.service.dto; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; + +import static org.assertj.core.api.Assertions.assertThat; + +class FileDTOTest { + + @Test + void whenExampleFileIsInsideJar_thenLoadsItsContents(@TempDir Path directory) throws Exception { + final Path jar = directory.resolve("resources.jar"); + final byte[] contents = "Example from a JAR".getBytes(StandardCharsets.UTF_8); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(jar))) { + output.putNextEntry(new JarEntry("static/files/example-for-signing.txt")); + output.write(contents); + output.closeEntry(); + } + + final Thread thread = Thread.currentThread(); + final ClassLoader originalLoader = thread.getContextClassLoader(); + try (URLClassLoader resourceLoader = new URLClassLoader(new URL[] {jar.toUri().toURL()}, null)) { + thread.setContextClassLoader(resourceLoader); + final FileDTO file = FileDTO.getExampleForSigningFromResources(); + + assertThat(file.getName()).isEqualTo("example-for-signing.txt"); + assertThat(file.getContentType()).isEqualTo("text/plain"); + assertThat(file.getContentBytes()).containsExactly(contents); + } finally { + thread.setContextClassLoader(originalLoader); + } + } +} diff --git a/example/src/test/java/eu/webeid/example/testutil/Dates.java b/example/src/test/java/eu/webeid/example/testutil/Dates.java index a9cb4603..286e4d1f 100644 --- a/example/src/test/java/eu/webeid/example/testutil/Dates.java +++ b/example/src/test/java/eu/webeid/example/testutil/Dates.java @@ -3,14 +3,8 @@ package eu.webeid.example.testutil; -import eu.europa.esig.dss.model.BLevelParameters; -import eu.webeid.security.util.DateAndTime; -import mockit.Mock; -import mockit.MockUp; - import java.time.ZoneId; import java.time.ZonedDateTime; -import java.util.Date; public final class Dates { @@ -25,21 +19,4 @@ public static ZonedDateTime getAuthTokenValidationDateTime() { ZoneId.of("UTC")); } - public static void setMockedSignatureDate(ZonedDateTime mockedDateTime) { - new MockUp() { - @Mock - public Date getSigningDate() { - return Date.from(mockedDateTime.toInstant()); - } - }; - } - - public static void setMockedAuthTokenValidationDate(ZonedDateTime mockedDateTime) { - new MockUp() { - @Mock - public Date now() { - return Date.from(mockedDateTime.toInstant()); - } - }; - } } diff --git a/example/src/test/java/eu/webeid/example/testutil/ObjectMother.java b/example/src/test/java/eu/webeid/example/testutil/ObjectMother.java index 3753c20e..aa7a77b6 100644 --- a/example/src/test/java/eu/webeid/example/testutil/ObjectMother.java +++ b/example/src/test/java/eu/webeid/example/testutil/ObjectMother.java @@ -47,9 +47,7 @@ public class ObjectMother { public static final String VALID_CHALLENGE_NONCE = "12345678123456781234567812345678912356789123"; public static AuthTokenDTO mockAuthToken() { - AuthTokenDTO authToken = new AuthTokenDTO(); - authToken.setToken(VALID_AUTH_TOKEN); - return authToken; + return new AuthTokenDTO(VALID_AUTH_TOKEN); } public static String toJson(Object object) throws JsonProcessingException { @@ -86,9 +84,7 @@ public static CertificateDTO mockPrepareRequest() { } public static SignatureDTO mockSignRequest(String digestToSign) { - SignatureDTO signatureDTO = new SignatureDTO(); - signatureDTO.setBase64Signature(mockSignatureInBase64(digestToSign)); - return signatureDTO; + return new SignatureDTO(mockSignatureInBase64(digestToSign)); } private static X509Certificate getSigningCert() { diff --git a/pom.xml b/pom.xml index ff33f906..ac9595e0 100644 --- a/pom.xml +++ b/pom.xml @@ -7,13 +7,13 @@ 4.0.0 authtoken-validation eu.webeid.security - 3.2.1 + 4.0.0-SNAPSHOT jar authtoken-validation Web eID authentication token validation library for Java - 11 + 17 0.13.0 1.86 2.22.1 @@ -22,6 +22,7 @@ 3.27.7 5.23.0 3.5.5 + 3.15.0 3.4.0 3.12.0 3.6.2 @@ -31,9 +32,8 @@ UTF-8 UTF-8 - ${java.version} - ${java.version} - -Djava.security.egd=file:/dev/./urandom -Xmx256m + ${java.version} + -Djava.security.egd=file:/dev/urandom -Xmx256m web-eid_web-eid-authtoken-validation-java @@ -96,6 +96,11 @@ + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + org.apache.maven.plugins diff --git a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidator.java b/src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java similarity index 62% rename from src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidator.java rename to src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java index bd6d55e8..0acca9c2 100644 --- a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidator.java +++ b/src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java @@ -1,17 +1,19 @@ // SPDX-FileCopyrightText: Estonian Information System Authority // SPDX-License-Identifier: MIT -package eu.webeid.security.validator.certvalidators; +package eu.webeid.ocsp; +import eu.webeid.ocsp.client.OcspClient; +import eu.webeid.ocsp.protocol.DigestCalculatorImpl; +import eu.webeid.ocsp.protocol.OcspRequestBuilder; +import eu.webeid.ocsp.protocol.OcspResponseValidator; import eu.webeid.security.exceptions.AuthTokenException; -import eu.webeid.security.exceptions.UserCertificateOCSPCheckFailedException; +import eu.webeid.ocsp.exceptions.UserCertificateOCSPCheckFailedException; import eu.webeid.security.util.DateAndTime; -import eu.webeid.security.validator.ocsp.DigestCalculatorImpl; -import eu.webeid.security.validator.ocsp.OcspClient; -import eu.webeid.security.validator.ocsp.OcspRequestBuilder; -import eu.webeid.security.validator.ocsp.OcspResponseValidator; -import eu.webeid.security.validator.ocsp.OcspServiceProvider; -import eu.webeid.security.validator.ocsp.service.OcspService; +import eu.webeid.ocsp.service.OcspServiceProvider; +import eu.webeid.ocsp.service.OcspService; +import eu.webeid.security.validator.revocationcheck.CertificateRevocationChecker; +import eu.webeid.security.validator.revocationcheck.RevocationInfo; import org.bouncycastle.asn1.ocsp.OCSPObjectIdentifiers; import org.bouncycastle.asn1.ocsp.OCSPResponseStatus; import org.bouncycastle.asn1.x509.Extension; @@ -30,52 +32,64 @@ import java.io.IOException; import java.math.BigInteger; +import java.net.URI; import java.security.Security; import java.security.cert.CertificateEncodingException; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.time.Duration; import java.util.Date; -import java.util.Objects; +import java.util.List; +import java.util.Map; -public final class SubjectCertificateNotRevokedValidator { +import static eu.webeid.security.util.DateAndTime.requirePositiveDuration; +import static java.util.Objects.requireNonNull; - private static final Logger LOG = LoggerFactory.getLogger(SubjectCertificateNotRevokedValidator.class); +public final class OcspCertificateRevocationChecker implements CertificateRevocationChecker { + + public static final Duration DEFAULT_TIME_SKEW = Duration.ofMinutes(15); + public static final Duration DEFAULT_THIS_UPDATE_AGE = Duration.ofMinutes(2); + + private static final Logger LOG = LoggerFactory.getLogger(OcspCertificateRevocationChecker.class); - private final SubjectCertificateTrustedValidator trustValidator; private final OcspClient ocspClient; private final OcspServiceProvider ocspServiceProvider; private final Duration allowedOcspResponseTimeSkew; private final Duration maxOcspResponseThisUpdateAge; static { - Security.addProvider(new BouncyCastleProvider()); + if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { + Security.addProvider(new BouncyCastleProvider()); + } } - public SubjectCertificateNotRevokedValidator(SubjectCertificateTrustedValidator trustValidator, - OcspClient ocspClient, - OcspServiceProvider ocspServiceProvider, - Duration allowedOcspResponseTimeSkew, - Duration maxOcspResponseThisUpdateAge) { - this.trustValidator = trustValidator; - this.ocspClient = ocspClient; - this.ocspServiceProvider = ocspServiceProvider; - this.allowedOcspResponseTimeSkew = allowedOcspResponseTimeSkew; - this.maxOcspResponseThisUpdateAge = maxOcspResponseThisUpdateAge; + public OcspCertificateRevocationChecker(OcspClient ocspClient, + OcspServiceProvider ocspServiceProvider, + Duration allowedOcspResponseTimeSkew, + Duration maxOcspResponseThisUpdateAge) { + this.ocspClient = requireNonNull(ocspClient, "ocspClient"); + this.ocspServiceProvider = requireNonNull(ocspServiceProvider, "ocspServiceProvider"); + this.allowedOcspResponseTimeSkew = requirePositiveDuration(allowedOcspResponseTimeSkew, "allowedOcspResponseTimeSkew"); + this.maxOcspResponseThisUpdateAge = requirePositiveDuration(maxOcspResponseThisUpdateAge, "maxOcspResponseThisUpdateAge"); } /** - * Validates that the user certificate from the authentication token is not revoked with OCSP. + * Validates with OCSP that the user certificate from the authentication token is not revoked. * * @param subjectCertificate user certificate to be validated * @throws AuthTokenException when user certificate is revoked or revocation check fails. */ - public void validateCertificateNotRevoked(X509Certificate subjectCertificate) throws AuthTokenException { + @Override + public List validateCertificateNotRevoked(X509Certificate subjectCertificate, X509Certificate issuerCertificate) throws AuthTokenException { + requireNonNull(subjectCertificate, "subjectCertificate"); + requireNonNull(issuerCertificate, "issuerCertificate"); + + URI ocspResponderUri = null; try { OcspService ocspService = ocspServiceProvider.getService(subjectCertificate); + ocspResponderUri = requireNonNull(ocspService.getAccessLocation(), "ocspResponderUri"); - final CertificateID certificateId = getCertificateId(subjectCertificate, - Objects.requireNonNull(trustValidator.getSubjectCertificateIssuerCertificate())); + final CertificateID certificateId = getCertificateId(subjectCertificate, issuerCertificate); final OCSPReq request = new OcspRequestBuilder() .withCertificateId(certificateId) @@ -87,21 +101,26 @@ public void validateCertificateNotRevoked(X509Certificate subjectCertificate) th } LOG.debug("Sending OCSP request"); - final OCSPResp response = Objects.requireNonNull(ocspClient.request(ocspService.getAccessLocation(), request)); + final OCSPResp response = requireNonNull(ocspClient.request(ocspResponderUri, request), "OCSPResp"); if (response.getStatus() != OCSPResponseStatus.SUCCESSFUL) { - throw new UserCertificateOCSPCheckFailedException("Response status: " + ocspStatusToString(response.getStatus())); + throw new UserCertificateOCSPCheckFailedException("Response status: " + ocspStatusToString(response.getStatus()), ocspResponderUri); } - final BasicOCSPResp basicResponse = (BasicOCSPResp) response.getResponseObject(); - if (basicResponse == null) { - throw new UserCertificateOCSPCheckFailedException("Missing Basic OCSP Response"); + if (!(response.getResponseObject() instanceof BasicOCSPResp basicResponse)) { + throw new UserCertificateOCSPCheckFailedException("Missing or unsupported Basic OCSP Response", ocspResponderUri); } + LOG.debug("OCSP response received successfully"); + verifyOcspResponse(basicResponse, ocspService, certificateId); if (ocspService.doesSupportNonce()) { - checkNonce(request, basicResponse); + checkNonce(request, basicResponse, ocspResponderUri); } + LOG.debug("OCSP response verified successfully"); + + return List.of(new RevocationInfo(ocspResponderUri, Map.of(RevocationInfo.KEY_OCSP_RESPONSE, response))); + } catch (OCSPException | CertificateException | OperatorCreationException | IOException e) { - throw new UserCertificateOCSPCheckFailedException(e); + throw new UserCertificateOCSPCheckFailedException(e, ocspResponderUri); } } @@ -118,11 +137,12 @@ private void verifyOcspResponse(BasicOCSPResp basicResponse, OcspService ocspSer // As we sent the request for only a single certificate, we expect only a single response. if (basicResponse.getResponses().length != 1) { throw new UserCertificateOCSPCheckFailedException("OCSP response must contain one response, " - + "received " + basicResponse.getResponses().length + " responses instead"); + + "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"); + throw new UserCertificateOCSPCheckFailedException("OCSP responded with certificate ID that differs from the requested ID", + ocspService.getAccessLocation()); } // 2. The signature on the response is valid. @@ -132,11 +152,11 @@ private void verifyOcspResponse(BasicOCSPResp basicResponse, OcspService ocspSer // is standard practice. if (basicResponse.getCerts().length < 1) { throw new UserCertificateOCSPCheckFailedException("OCSP response must contain the responder certificate, " - + "but none was provided"); + + "but none was provided", ocspService.getAccessLocation()); } // 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); + OcspResponseValidator.validateResponseSignature(basicResponse, responderCert, ocspService.getAccessLocation()); // 3. The identity of the signer matches the intended recipient of the // request. @@ -155,23 +175,23 @@ private void verifyOcspResponse(BasicOCSPResp basicResponse, OcspService ocspSer // be available about the status of the certificate (nextUpdate) is // greater than the current time. - OcspResponseValidator.validateCertificateStatusUpdateTime(certStatusResponse, allowedOcspResponseTimeSkew, maxOcspResponseThisUpdateAge); + OcspResponseValidator.validateCertificateStatusUpdateTime(certStatusResponse, allowedOcspResponseTimeSkew, maxOcspResponseThisUpdateAge, ocspService.getAccessLocation()); // Now we can accept the signed response as valid and validate the certificate status. - OcspResponseValidator.validateSubjectCertificateStatus(certStatusResponse); + OcspResponseValidator.validateSubjectCertificateStatus(certStatusResponse, ocspService.getAccessLocation()); LOG.debug("OCSP check result is GOOD"); } - private static void checkNonce(OCSPReq request, BasicOCSPResp response) throws UserCertificateOCSPCheckFailedException { + private static void checkNonce(OCSPReq request, BasicOCSPResp response, URI ocspResponderUri) throws UserCertificateOCSPCheckFailedException { final Extension requestNonce = request.getExtension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce); final Extension responseNonce = response.getExtension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce); if (requestNonce == null || responseNonce == null) { throw new UserCertificateOCSPCheckFailedException("OCSP request or response nonce extension missing, " + - "possible replay attack"); + "possible replay attack", ocspResponderUri); } if (!requestNonce.equals(responseNonce)) { throw new UserCertificateOCSPCheckFailedException("OCSP request and response nonces differ, " + - "possible replay attack"); + "possible replay attack", ocspResponderUri); } } @@ -183,20 +203,14 @@ private static CertificateID getCertificateId(X509Certificate subjectCertificate } private static String ocspStatusToString(int status) { - switch (status) { - case OCSPResp.MALFORMED_REQUEST: - return "malformed request"; - case OCSPResp.INTERNAL_ERROR: - return "internal error"; - case OCSPResp.TRY_LATER: - return "service unavailable"; - case OCSPResp.SIG_REQUIRED: - return "request signature missing"; - case OCSPResp.UNAUTHORIZED: - return "unauthorized"; - default: - return "unknown"; - } + return switch (status) { + case OCSPResp.MALFORMED_REQUEST -> "malformed request"; + case OCSPResp.INTERNAL_ERROR -> "internal error"; + case OCSPResp.TRY_LATER -> "service unavailable"; + case OCSPResp.SIG_REQUIRED -> "request signature missing"; + case OCSPResp.UNAUTHORIZED -> "unauthorized"; + default -> "unknown"; + }; } } diff --git a/src/main/java/eu/webeid/ocsp/README.md b/src/main/java/eu/webeid/ocsp/README.md new file mode 100644 index 00000000..8336f99a --- /dev/null +++ b/src/main/java/eu/webeid/ocsp/README.md @@ -0,0 +1,204 @@ +# Advanced OCSP configuration + +[Back to the main README](../../../../../../README.md#authentication-token-validation) + +The default validator uses platform OCSP and needs no additional OCSP configuration. This guide is for applications that need control over responder selection, HTTP transport, nonce handling, response freshness, or a different revocation-checking implementation. + +Use `withCertificateRevocationChecker(...)` to supply your own implementation or configure the bundled `eu.webeid.ocsp.OcspCertificateRevocationChecker`. Use `withPKIXRevocationChecker(...)` when you need to configure a JDK PKIX checker directly. + +## Contents + +- [Choosing a revocation checker](#choosing-a-revocation-checker) +- [Implementing CertificateRevocationChecker](#implementing-certificaterevocationchecker) +- [Custom OCSP checker](#custom-ocsp-checker) +- [Custom PKIX revocation checker](#custom-pkix-revocation-checker) +- [Platform OCSP nonce configuration](#platform-ocsp-nonce-configuration) +- [Certificates' Authority Information Access (AIA) extension](#certificates-authority-information-access-aia-extension) +- [Revocation information](#revocation-information) +- [Errors and diagnostics](#errors-and-diagnostics) + +## Choosing a revocation checker + +The default mode is `PLATFORM_OCSP`: the library uses the platform PKIX revocation checker to check the subject certificate with OCSP, with no fallback to CRLs and no soft-fail option. Trust is validated before revocation checking. + +The following additional configuration options are available in `AuthTokenValidatorBuilder`: + +- `withPlatformOcspNonceEnabled(boolean enabled)` – controls the library's default nonce generation for `PLATFORM_OCSP`. Enabled by default; an explicit JVM nonce property takes precedence. See [Platform OCSP nonce configuration](#platform-ocsp-nonce-configuration). +- `withPKIXRevocationChecker(PKIXRevocationChecker checker)` – selects `CUSTOM_PKIX` and uses the supplied checker with its configured options. +- `withCertificateRevocationChecker(CertificateRevocationChecker checker)` – selects `CUSTOM_CHECKER` and delegates revocation checking to the supplied implementation after validating certificate trust. +- `withoutUserCertificateRevocationCheck()` – selects `DISABLED`. Certificate trust, validity, purpose, policies and token signatures are still checked, but revoked certificates may be accepted. Use only in exceptional circumstances. + +The two custom checker options and `withoutUserCertificateRevocationCheck()` are mutually exclusive. Combining them causes `build()` to throw `IllegalArgumentException`. Custom checker implementations must support concurrent validation calls. + +Platform OCSP networking and response freshness are controlled by the JDK provider. The old 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. + +## 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. + +The library calls `validateCertificateNotRevoked(subjectCertificate, issuerCertificate)` after validating the subject certificate's trust, validity, purpose and policies. The issuer argument is the trusted CA certificate that issued the subject certificate. Token signature validation remains the library's responsibility and follows the revocation check. + +Your checker must: + +- Validate revocation status according to your application's policy, including the authenticity and freshness of any status information it uses. +- Throw `CertificateRevokedException` when the certificate is revoked, or `CertificateRevocationCheckFailedException` when the status cannot be established. Both extend `AuthTokenException`; retain the underlying cause when available. +- Return a non-null `List` on success. Use an empty list when there is no additional information to return. +- Support concurrent calls when the validator is shared across threads. + +The platform nonce setting does not configure your implementation. The bundled checker below is available when you want OCSP-specific controls without implementing the protocol yourself. + +## Custom OCSP checker + +The examples use `trustedIntermediateCACertificates()` from the [main quickstart](../../../../../../README.md#4-add-trusted-certificate-authority-certificates). Run configuration code during application startup and handle its checked exceptions there. + +The Bouncy Castle-based implementation is available under `eu.webeid.ocsp`. Construct it explicitly and pass it to `withCertificateRevocationChecker(...)`: + +```java +import java.net.URI; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.List; +import java.util.Set; +import eu.webeid.ocsp.OcspCertificateRevocationChecker; +import eu.webeid.ocsp.client.OcspClientImpl; +import eu.webeid.ocsp.service.AiaOcspServiceConfiguration; +import eu.webeid.ocsp.service.OcspServiceProvider; +import eu.webeid.security.certificate.CertificateValidator; +import eu.webeid.security.validator.AuthTokenValidator; +import eu.webeid.security.validator.AuthTokenValidatorBuilder; + +... +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) +); +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 +); + +AuthTokenValidator validator = new AuthTokenValidatorBuilder() + .withSiteOrigin(URI.create("https://example.org")) + .withTrustedCertificateAuthorities(trustedCAs.toArray(X509Certificate[]::new)) + .withCertificateRevocationChecker(checker) + .build(); +``` + +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). + +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: + +```java +import eu.webeid.ocsp.service.DesignatedOcspServiceConfiguration; + +... +DesignatedOcspServiceConfiguration designated = new DesignatedOcspServiceConfiguration( + URI.create("https://ocsp.example.org"), + responderCertificate, + supportedIssuers, + true // This service supports nonces. +); +OcspServiceProvider services = new OcspServiceProvider(designated, aiaConfiguration); +``` + +The provider selects the designated service only for supported issuers; otherwise it uses the certificate's AIA OCSP URL. For AIA services, 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. + +## 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: + +```java +import java.net.URI; +import java.security.cert.CertPathValidator; +import java.security.cert.PKIXRevocationChecker; +import java.util.EnumSet; +import eu.webeid.security.validator.AuthTokenValidator; +import eu.webeid.security.validator.AuthTokenValidatorBuilder; + +... +PKIXRevocationChecker checker = (PKIXRevocationChecker) + CertPathValidator.getInstance("PKIX").getRevocationChecker(); +checker.setOptions(EnumSet.of( + PKIXRevocationChecker.Option.ONLY_END_ENTITY, + PKIXRevocationChecker.Option.NO_FALLBACK +)); +checker.setOcspResponder(URI.create("https://ocsp.example.org")); + +AuthTokenValidator validator = new AuthTokenValidatorBuilder() + .withSiteOrigin(URI.create("https://example.org")) + .withTrustedCertificateAuthorities(trustedIntermediateCACertificates()) + .withPKIXRevocationChecker(checker) + .build(); +``` + +The library does not add options or nonce extensions to a custom PKIX checker. `withPlatformOcspNonceEnabled(...)` does not apply to it. A responder set with `setOcspResponder()` overrides AIA discovery for certificates checked by that checker; it does not perform issuer-based responder selection. Use the [custom OCSP service provider](#custom-ocsp-checker) for that policy. + +## Platform OCSP nonce configuration + +By default, the library includes a fresh 32-byte nonce in each platform OCSP request. To disable this default for a validator, use: + +```java +AuthTokenValidator validator = new AuthTokenValidatorBuilder() + .withSiteOrigin(URI.create("https://example.org")) + .withTrustedCertificateAuthorities(trustedIntermediateCACertificates()) + .withPlatformOcspNonceEnabled(false) + .build(); +``` + +An explicitly configured `jdk.security.certpath.ocspNonce` JVM system property takes precedence over the builder setting: + +| Builder setting | JVM property unset | JVM property `true` | JVM property `false` | +| --- | --- | --- | --- | +| Enabled (default) | Library supplies a fresh 32-byte nonce | JDK supplies the nonce | No nonce | +| Disabled | No nonce | JDK supplies the nonce | No nonce | + +For example, `-Djdk.security.certpath.ocspNonce=false` disables nonces for this library's platform OCSP requests regardless of the builder setting. The JVM property also affects other users of the JDK OCSP implementation in the same process. The library reads this property without modifying it. + +The builder setting applies only to `PLATFORM_OCSP`; it does not configure custom revocation checkers. Enabling a nonce controls the request, not strict response nonce enforcement: the JDK may accept a response without a nonce. This OCSP setting is separate from the authentication challenge nonce. + +## Certificates' *Authority Information Access* (AIA) extension + +The default platform checker obtains the OCSP responder URL from the subject certificate’s AIA extension unless a JDK responder override is configured. A custom PKIX checker can override the URL with `setOcspResponder()`. The bundled custom OCSP checker requires an AIA OCSP URL unless its designated service supports the certificate’s issuer. + +Note that there may be limitations to using AIA URLs as the services behind these URLs provide different security and SLA guarantees than dedicated OCSP responder services. In case you need a SLA guarantee, use a designated OCSP responder service. + +## Revocation information + +The `validate()` method returns a `ValidationInfo` object on success and throws an exception on failure, as described in [Errors and diagnostics](#errors-and-diagnostics). Its `subjectCertificate()` accessor returns the validated certificate. Its `revocationInfoList()` accessor returns information supplied by the selected revocation checker: + +- `PLATFORM_OCSP` and `DISABLED` return an empty list. An empty list does not mean that validation failed or, in platform mode, that revocation checking was skipped. +- `CUSTOM_PKIX` returns the configured responder URI when `getOcspResponder()` is non-null; response attributes are not populated. +- `CUSTOM_CHECKER` returns the custom checker's result. The bundled `OcspCertificateRevocationChecker` includes the responder URI and the Bouncy Castle `OCSPResp` under `RevocationInfo.KEY_OCSP_RESPONSE`. + +## Errors and diagnostics + +The `validate()` method returns `ValidationInfo` on success. Certificate and token validation failures are reported through `AuthTokenException` subclasses. In particular: + +| Exception | Meaning | +| --- | --- | +| `CertificateNotTrustedException` | Certificate trust/path validation failed. | +| `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). + +Log the exception itself to retain the cause chain, including the JDK's revocation failure details: + +```java +try { + ValidationInfo validationInfo = tokenValidator.validate(token, challengeNonce); + // Continue authentication using validationInfo.subjectCertificate(). +} catch (AuthTokenException e) { + LOG.warn("Web eID authentication failed", e); + throw e; +} +``` + +Preserve the cause when wrapping the exception, for example with `new AuthenticationServiceException("Web eID token validation failed", e)`. Return a generic authentication failure to the client; keep diagnostic details in server logs. diff --git a/src/main/java/eu/webeid/security/validator/ocsp/OcspClient.java b/src/main/java/eu/webeid/ocsp/client/OcspClient.java similarity index 88% rename from src/main/java/eu/webeid/security/validator/ocsp/OcspClient.java rename to src/main/java/eu/webeid/ocsp/client/OcspClient.java index de67a0e9..61c2c5bf 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/OcspClient.java +++ b/src/main/java/eu/webeid/ocsp/client/OcspClient.java @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Estonian Information System Authority // SPDX-License-Identifier: MIT -package eu.webeid.security.validator.ocsp; +package eu.webeid.ocsp.client; import org.bouncycastle.cert.ocsp.OCSPReq; import org.bouncycastle.cert.ocsp.OCSPResp; diff --git a/src/main/java/eu/webeid/security/validator/ocsp/OcspClientImpl.java b/src/main/java/eu/webeid/ocsp/client/OcspClientImpl.java similarity index 87% rename from src/main/java/eu/webeid/security/validator/ocsp/OcspClientImpl.java rename to src/main/java/eu/webeid/ocsp/client/OcspClientImpl.java index c8f407d6..4f842446 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/OcspClientImpl.java +++ b/src/main/java/eu/webeid/ocsp/client/OcspClientImpl.java @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Estonian Information System Authority // SPDX-License-Identifier: MIT -package eu.webeid.security.validator.ocsp; +package eu.webeid.ocsp.client; import org.bouncycastle.cert.ocsp.OCSPReq; import org.bouncycastle.cert.ocsp.OCSPResp; @@ -15,6 +15,9 @@ import java.net.http.HttpResponse; import java.time.Duration; +import static eu.webeid.security.util.DateAndTime.requirePositiveDuration; +import static java.util.Objects.requireNonNull; + public class OcspClientImpl implements OcspClient { private static final Logger LOG = LoggerFactory.getLogger(OcspClientImpl.class); @@ -26,6 +29,7 @@ public class OcspClientImpl implements OcspClient { private final Duration ocspRequestTimeout; public static OcspClient build(Duration ocspRequestTimeout) { + requirePositiveDuration(ocspRequestTimeout, "ocspRequestTimeout"); return new OcspClientImpl( HttpClient.newBuilder() .connectTimeout(ocspRequestTimeout) @@ -72,8 +76,8 @@ public OCSPResp request(URI uri, OCSPReq ocspReq) throws IOException { } public OcspClientImpl(HttpClient httpClient, Duration ocspRequestTimeout) { - this.httpClient = httpClient; - this.ocspRequestTimeout = ocspRequestTimeout; + this.httpClient = requireNonNull(httpClient, "httpClient"); + this.ocspRequestTimeout = requirePositiveDuration(ocspRequestTimeout, "ocspRequestTimeout"); } } diff --git a/src/main/java/eu/webeid/security/exceptions/OCSPCertificateException.java b/src/main/java/eu/webeid/ocsp/exceptions/OCSPCertificateException.java similarity index 79% rename from src/main/java/eu/webeid/security/exceptions/OCSPCertificateException.java rename to src/main/java/eu/webeid/ocsp/exceptions/OCSPCertificateException.java index 0e2b7cb2..e07a40ce 100644 --- a/src/main/java/eu/webeid/security/exceptions/OCSPCertificateException.java +++ b/src/main/java/eu/webeid/ocsp/exceptions/OCSPCertificateException.java @@ -1,7 +1,9 @@ // SPDX-FileCopyrightText: Estonian Information System Authority // SPDX-License-Identifier: MIT -package eu.webeid.security.exceptions; +package eu.webeid.ocsp.exceptions; + +import eu.webeid.security.exceptions.AuthTokenException; public class OCSPCertificateException extends AuthTokenException { diff --git a/src/main/java/eu/webeid/ocsp/exceptions/OcspResponderUriMessageAppender.java b/src/main/java/eu/webeid/ocsp/exceptions/OcspResponderUriMessageAppender.java new file mode 100644 index 00000000..8aac31af --- /dev/null +++ b/src/main/java/eu/webeid/ocsp/exceptions/OcspResponderUriMessageAppender.java @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.ocsp.exceptions; + +import java.net.URI; + +/** + * Helper class for adding OCSP responder URL to messages. + */ +final class OcspResponderUriMessageAppender { + + static String appendResponderUri(String message, URI ocspResponderUri) { + if (ocspResponderUri == null) { + return message; + } + return message + " (OCSP responder: " + ocspResponderUri + ")"; + } + + private OcspResponderUriMessageAppender() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateOCSPCheckFailedException.java b/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateOCSPCheckFailedException.java new file mode 100644 index 00000000..f440542e --- /dev/null +++ b/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateOCSPCheckFailedException.java @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.ocsp.exceptions; + +import eu.webeid.security.exceptions.CertificateRevocationCheckFailedException; + +import java.net.URI; + +import static eu.webeid.ocsp.exceptions.OcspResponderUriMessageAppender.appendResponderUri; + +/** + * Thrown when user certificate revocation check with OCSP fails. + */ +public class UserCertificateOCSPCheckFailedException extends CertificateRevocationCheckFailedException { + + public UserCertificateOCSPCheckFailedException(Throwable cause, URI ocspResponderUri) { + super(appendResponderUri("User certificate revocation check has failed", ocspResponderUri), cause); + } + + public UserCertificateOCSPCheckFailedException(String message, URI ocspResponderUri) { + super(appendResponderUri("User certificate revocation check has failed: " + message, ocspResponderUri)); + } + + public UserCertificateOCSPCheckFailedException(String message) { + super(message); + } + +} diff --git a/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateRevokedException.java b/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateRevokedException.java new file mode 100644 index 00000000..f5ce0d6d --- /dev/null +++ b/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateRevokedException.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2020-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package eu.webeid.ocsp.exceptions; + +import eu.webeid.security.exceptions.CertificateRevokedException; + +import java.net.URI; + +import static eu.webeid.ocsp.exceptions.OcspResponderUriMessageAppender.appendResponderUri; + +/** + * Thrown when the user certificate has been revoked. + */ +public class UserCertificateRevokedException extends CertificateRevokedException { + + public UserCertificateRevokedException(URI ocspResponderUri) { + super(appendResponderUri("User certificate has been revoked", ocspResponderUri)); + } + + public UserCertificateRevokedException(String msg, URI ocspResponderUri) { + super(appendResponderUri("User certificate has been revoked: " + msg, ocspResponderUri)); + } +} diff --git a/src/main/java/eu/webeid/security/validator/ocsp/DigestCalculatorImpl.java b/src/main/java/eu/webeid/ocsp/protocol/DigestCalculatorImpl.java similarity index 97% rename from src/main/java/eu/webeid/security/validator/ocsp/DigestCalculatorImpl.java rename to src/main/java/eu/webeid/ocsp/protocol/DigestCalculatorImpl.java index 5910442c..a577d215 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/DigestCalculatorImpl.java +++ b/src/main/java/eu/webeid/ocsp/protocol/DigestCalculatorImpl.java @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Estonian Information System Authority // SPDX-License-Identifier: MIT -package eu.webeid.security.validator.ocsp; +package eu.webeid.ocsp.protocol; import org.bouncycastle.asn1.nist.NISTObjectIdentifiers; import org.bouncycastle.asn1.oiw.OIWObjectIdentifiers; diff --git a/src/main/java/eu/webeid/ocsp/protocol/OcspNonceFactory.java b/src/main/java/eu/webeid/ocsp/protocol/OcspNonceFactory.java new file mode 100644 index 00000000..8d5db408 --- /dev/null +++ b/src/main/java/eu/webeid/ocsp/protocol/OcspNonceFactory.java @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.ocsp.protocol; + +import org.bouncycastle.asn1.DEROctetString; +import org.bouncycastle.asn1.ocsp.OCSPObjectIdentifiers; +import org.bouncycastle.asn1.x509.Extension; + +import java.io.IOException; +import java.security.SecureRandom; + +/** + * Creates OCSP nonce extensions with fresh 32-byte nonces. + * The extension's critical flag is false, as recommended for OCSP requests. + */ +public final class OcspNonceFactory { + + private static final int NONCE_LENGTH_BYTES = 32; + private static final SecureRandom RANDOM = new SecureRandom(); + + private OcspNonceFactory() { + } + + public static Extension create() throws IOException { + final byte[] nonce = new byte[NONCE_LENGTH_BYTES]; + RANDOM.nextBytes(nonce); + // The nonce is an OCTET STRING inside the extension's extnValue OCTET STRING. + return Extension.create(OCSPObjectIdentifiers.id_pkix_ocsp_nonce, false, new DEROctetString(nonce)); + } +} diff --git a/src/main/java/eu/webeid/security/validator/ocsp/OcspRequestBuilder.java b/src/main/java/eu/webeid/ocsp/protocol/OcspRequestBuilder.java similarity index 57% rename from src/main/java/eu/webeid/security/validator/ocsp/OcspRequestBuilder.java rename to src/main/java/eu/webeid/ocsp/protocol/OcspRequestBuilder.java index b93009be..cfb29c2e 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/OcspRequestBuilder.java +++ b/src/main/java/eu/webeid/ocsp/protocol/OcspRequestBuilder.java @@ -1,11 +1,8 @@ // SPDX-FileCopyrightText: Estonian Information System Authority // SPDX-License-Identifier: MIT -package eu.webeid.security.validator.ocsp; +package eu.webeid.ocsp.protocol; -import org.bouncycastle.asn1.DEROctetString; -import org.bouncycastle.asn1.ocsp.OCSPObjectIdentifiers; -import org.bouncycastle.asn1.x509.Extension; import org.bouncycastle.asn1.x509.Extensions; import org.bouncycastle.cert.ocsp.CertificateID; import org.bouncycastle.cert.ocsp.OCSPException; @@ -13,7 +10,6 @@ import org.bouncycastle.cert.ocsp.OCSPReqBuilder; import java.io.IOException; -import java.security.SecureRandom; import java.util.Objects; /** @@ -22,8 +18,6 @@ */ public final class OcspRequestBuilder { - private static final SecureRandom RANDOM_GENERATOR = new SecureRandom(); - private boolean ocspNonceEnabled = true; private CertificateID certificateId; @@ -38,8 +32,8 @@ public OcspRequestBuilder enableOcspNonce(boolean ocspNonceEnabled) { } /** - * The returned {@link OCSPReq} is not re-usable/cacheable. It contains a one-time nonce - * and responders will reject subsequent requests that have the same nonce value. + * Builds a request with a fresh nonce when nonce support is enabled. + * Create a new request for each check so a matching response nonce can establish freshness. */ public OCSPReq build() throws OCSPException { final OCSPReqBuilder builder = new OCSPReqBuilder(); @@ -47,7 +41,7 @@ public OCSPReq build() throws OCSPException { if (ocspNonceEnabled) { try { - addNonce(builder); + builder.setRequestExtensions(new Extensions(OcspNonceFactory.create())); } catch (IOException e) { throw new OCSPException("Failed to generate OCSP nonce extension", e); } @@ -56,16 +50,4 @@ public OCSPReq build() throws OCSPException { return builder.build(); } - private void addNonce(OCSPReqBuilder builder) throws IOException { - final byte[] nonce = new byte[32]; - RANDOM_GENERATOR.nextBytes(nonce); - - final Extension[] extensions = new Extension[]{ - new Extension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce, false, - // Follow OpenSSL OCSP nonce encoding convention and add double octet string header. - new DEROctetString(new DEROctetString(nonce))) - }; - builder.setRequestExtensions(new Extensions(extensions)); - } - } diff --git a/src/main/java/eu/webeid/security/validator/ocsp/OcspResponseValidator.java b/src/main/java/eu/webeid/ocsp/protocol/OcspResponseValidator.java similarity index 78% rename from src/main/java/eu/webeid/security/validator/ocsp/OcspResponseValidator.java rename to src/main/java/eu/webeid/ocsp/protocol/OcspResponseValidator.java index 640c1b5b..e0679e2e 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/OcspResponseValidator.java +++ b/src/main/java/eu/webeid/ocsp/protocol/OcspResponseValidator.java @@ -1,11 +1,11 @@ // SPDX-FileCopyrightText: Estonian Information System Authority // SPDX-License-Identifier: MIT -package eu.webeid.security.validator.ocsp; +package eu.webeid.ocsp.protocol; -import eu.webeid.security.exceptions.OCSPCertificateException; -import eu.webeid.security.exceptions.UserCertificateOCSPCheckFailedException; -import eu.webeid.security.exceptions.UserCertificateRevokedException; +import eu.webeid.ocsp.exceptions.OCSPCertificateException; +import eu.webeid.ocsp.exceptions.UserCertificateOCSPCheckFailedException; +import eu.webeid.ocsp.exceptions.UserCertificateRevokedException; import eu.webeid.security.util.DateAndTime; import org.bouncycastle.cert.X509CertificateHolder; import org.bouncycastle.cert.ocsp.BasicOCSPResp; @@ -14,10 +14,12 @@ import org.bouncycastle.cert.ocsp.RevokedStatus; import org.bouncycastle.cert.ocsp.SingleResp; import org.bouncycastle.cert.ocsp.UnknownStatus; +import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.operator.ContentVerifierProvider; import org.bouncycastle.operator.OperatorCreationException; import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder; +import java.net.URI; import java.security.cert.CertificateException; import java.security.cert.CertificateParsingException; import java.security.cert.X509Certificate; @@ -47,16 +49,16 @@ public static void validateHasSigningExtension(X509Certificate certificate) thro } } - public static void validateResponseSignature(BasicOCSPResp basicResponse, X509CertificateHolder responderCert) throws CertificateException, OperatorCreationException, OCSPException, UserCertificateOCSPCheckFailedException { + public static void validateResponseSignature(BasicOCSPResp basicResponse, X509CertificateHolder responderCert, URI ocspResponderUri) throws CertificateException, OperatorCreationException, OCSPException, UserCertificateOCSPCheckFailedException { final ContentVerifierProvider verifierProvider = new JcaContentVerifierProviderBuilder() - .setProvider("BC") + .setProvider(BouncyCastleProvider.PROVIDER_NAME) .build(responderCert); if (!basicResponse.isSignatureValid(verifierProvider)) { - throw new UserCertificateOCSPCheckFailedException("OCSP response signature is invalid"); + throw new UserCertificateOCSPCheckFailedException("OCSP response signature is invalid", ocspResponderUri); } } - public static void validateCertificateStatusUpdateTime(SingleResp certStatusResponse, Duration allowedTimeSkew, Duration maxThisupdateAge) throws UserCertificateOCSPCheckFailedException { + public static void validateCertificateStatusUpdateTime(SingleResp certStatusResponse, Duration allowedTimeSkew, Duration maxThisupdateAge, 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 @@ -75,12 +77,12 @@ public static void validateCertificateStatusUpdateTime(SingleResp certStatusResp if (thisUpdate.isAfter(latestAcceptableTimeSkew)) { throw new UserCertificateOCSPCheckFailedException(ERROR_PREFIX + "thisUpdate '" + thisUpdate + "' is too far in the future, " + - "latest allowed: '" + latestAcceptableTimeSkew + "'"); + "latest allowed: '" + latestAcceptableTimeSkew + "'", ocspResponderUri); } if (thisUpdate.isBefore(minimumValidThisUpdateTime)) { throw new UserCertificateOCSPCheckFailedException(ERROR_PREFIX + "thisUpdate '" + thisUpdate + "' is too old, " + - "minimum time allowed: '" + minimumValidThisUpdateTime + "'"); + "minimum time allowed: '" + minimumValidThisUpdateTime + "'", ocspResponderUri); } if (certStatusResponse.getNextUpdate() == null) { @@ -89,28 +91,28 @@ public static void validateCertificateStatusUpdateTime(SingleResp certStatusResp final Instant nextUpdate = certStatusResponse.getNextUpdate().toInstant(); if (nextUpdate.isBefore(earliestAcceptableTimeSkew)) { throw new UserCertificateOCSPCheckFailedException(ERROR_PREFIX + - "nextUpdate '" + nextUpdate + "' is in the past"); + "nextUpdate '" + nextUpdate + "' is in the past", ocspResponderUri); } if (nextUpdate.isBefore(thisUpdate)) { throw new UserCertificateOCSPCheckFailedException(ERROR_PREFIX + - "nextUpdate '" + nextUpdate + "' is before thisUpdate '" + thisUpdate + "'"); + "nextUpdate '" + nextUpdate + "' is before thisUpdate '" + thisUpdate + "'", ocspResponderUri); } } - public static void validateSubjectCertificateStatus(SingleResp certStatusResponse) throws UserCertificateRevokedException { + public static void validateSubjectCertificateStatus(SingleResp certStatusResponse, URI ocspResponderUri) + throws UserCertificateRevokedException, UserCertificateOCSPCheckFailedException { final CertificateStatus status = certStatusResponse.getCertStatus(); if (status == null) { return; } - if (status instanceof RevokedStatus) { - RevokedStatus revokedStatus = (RevokedStatus) status; + if (status instanceof RevokedStatus revokedStatus) { throw (revokedStatus.hasRevocationReason() ? - new UserCertificateRevokedException("Revocation reason: " + revokedStatus.getRevocationReason()) : - new UserCertificateRevokedException()); + new UserCertificateRevokedException("Revocation reason: " + revokedStatus.getRevocationReason(), ocspResponderUri) : + new UserCertificateRevokedException(ocspResponderUri)); } else if (status instanceof UnknownStatus) { - throw new UserCertificateRevokedException("Unknown status"); + throw new UserCertificateOCSPCheckFailedException("Unknown status", ocspResponderUri); } else { - throw new UserCertificateRevokedException("Status is neither good, revoked nor unknown"); + throw new UserCertificateOCSPCheckFailedException("Status is neither good, revoked nor unknown", ocspResponderUri); } } diff --git a/src/main/java/eu/webeid/security/validator/ocsp/OcspUrl.java b/src/main/java/eu/webeid/ocsp/protocol/OcspUrl.java similarity index 97% rename from src/main/java/eu/webeid/security/validator/ocsp/OcspUrl.java rename to src/main/java/eu/webeid/ocsp/protocol/OcspUrl.java index 59bbfb10..ffad7398 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/OcspUrl.java +++ b/src/main/java/eu/webeid/ocsp/protocol/OcspUrl.java @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Estonian Information System Authority // SPDX-License-Identifier: MIT -package eu.webeid.security.validator.ocsp; +package eu.webeid.ocsp.protocol; import org.bouncycastle.asn1.ASN1String; import org.bouncycastle.asn1.x509.AccessDescription; diff --git a/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java b/src/main/java/eu/webeid/ocsp/service/AiaOcspService.java similarity index 76% rename from src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java rename to src/main/java/eu/webeid/ocsp/service/AiaOcspService.java index fcd8c297..8e0f3a3f 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java +++ b/src/main/java/eu/webeid/ocsp/service/AiaOcspService.java @@ -1,13 +1,14 @@ // SPDX-FileCopyrightText: Estonian Information System Authority // SPDX-License-Identifier: MIT -package eu.webeid.security.validator.ocsp.service; +package eu.webeid.ocsp.service; import eu.webeid.security.certificate.CertificateValidator; import eu.webeid.security.exceptions.AuthTokenException; -import eu.webeid.security.exceptions.OCSPCertificateException; -import eu.webeid.security.exceptions.UserCertificateOCSPCheckFailedException; -import eu.webeid.security.validator.ocsp.OcspResponseValidator; +import eu.webeid.ocsp.exceptions.OCSPCertificateException; +import eu.webeid.ocsp.exceptions.UserCertificateOCSPCheckFailedException; +import eu.webeid.ocsp.protocol.OcspResponseValidator; +import eu.webeid.security.validator.revocationcheck.RevocationMode; import org.bouncycastle.cert.X509CertificateHolder; import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; @@ -20,7 +21,7 @@ import java.util.Objects; import java.util.Set; -import static eu.webeid.security.validator.ocsp.OcspUrl.getOcspUri; +import static eu.webeid.ocsp.protocol.OcspUrl.getOcspUri; /** * An OCSP service that uses the responders from the Certificates' Authority Information Access (AIA) extension. @@ -55,10 +56,19 @@ public URI getAccessLocation() { public void validateResponderCertificate(X509CertificateHolder cert, Date now) throws AuthTokenException { try { final X509Certificate certificate = certificateConverter.getCertificate(cert); - CertificateValidator.certificateIsValidOnDate(certificate, now, "AIA OCSP responder"); + CertificateValidator.requireCertificateIsValidOnDate(certificate, now, "AIA OCSP responder"); // Trusted certificates' validity has been already verified in validateCertificateExpiry(). OcspResponseValidator.validateHasSigningExtension(certificate); - CertificateValidator.validateIsSignedByTrustedCA(certificate, trustedCACertificateAnchors, trustedCACertificateCertStore, now); + CertificateValidator.validateCertificateTrustAndRevocation( + certificate, + trustedCACertificateAnchors, + trustedCACertificateCertStore, + now, + RevocationMode.DISABLED, + null, + null, + false + ); } catch (CertificateException e) { throw new OCSPCertificateException("Invalid responder certificate", e); } diff --git a/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspServiceConfiguration.java b/src/main/java/eu/webeid/ocsp/service/AiaOcspServiceConfiguration.java similarity index 82% rename from src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspServiceConfiguration.java rename to src/main/java/eu/webeid/ocsp/service/AiaOcspServiceConfiguration.java index 436f0a42..4d56c211 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspServiceConfiguration.java +++ b/src/main/java/eu/webeid/ocsp/service/AiaOcspServiceConfiguration.java @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Estonian Information System Authority // SPDX-License-Identifier: MIT -package eu.webeid.security.validator.ocsp.service; +package eu.webeid.ocsp.service; import java.net.URI; import java.security.cert.CertStore; @@ -17,8 +17,8 @@ public class AiaOcspServiceConfiguration { private final CertStore trustedCACertificateCertStore; public AiaOcspServiceConfiguration(Collection nonceDisabledOcspUrls, Set trustedCACertificateAnchors, CertStore trustedCACertificateCertStore) { - this.nonceDisabledOcspUrls = Objects.requireNonNull(nonceDisabledOcspUrls); - this.trustedCACertificateAnchors = Objects.requireNonNull(trustedCACertificateAnchors); + this.nonceDisabledOcspUrls = Set.copyOf(nonceDisabledOcspUrls); + this.trustedCACertificateAnchors = Set.copyOf(trustedCACertificateAnchors); this.trustedCACertificateCertStore = Objects.requireNonNull(trustedCACertificateCertStore); } diff --git a/src/main/java/eu/webeid/security/validator/ocsp/service/DesignatedOcspService.java b/src/main/java/eu/webeid/ocsp/service/DesignatedOcspService.java similarity index 90% rename from src/main/java/eu/webeid/security/validator/ocsp/service/DesignatedOcspService.java rename to src/main/java/eu/webeid/ocsp/service/DesignatedOcspService.java index 586adf98..a803d6b1 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/service/DesignatedOcspService.java +++ b/src/main/java/eu/webeid/ocsp/service/DesignatedOcspService.java @@ -1,11 +1,11 @@ // SPDX-FileCopyrightText: Estonian Information System Authority // SPDX-License-Identifier: MIT -package eu.webeid.security.validator.ocsp.service; +package eu.webeid.ocsp.service; import org.bouncycastle.cert.X509CertificateHolder; import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; -import eu.webeid.security.exceptions.OCSPCertificateException; +import eu.webeid.ocsp.exceptions.OCSPCertificateException; import eu.webeid.security.exceptions.AuthTokenException; import java.net.URI; @@ -15,7 +15,7 @@ import java.util.Date; import java.util.Objects; -import static eu.webeid.security.certificate.CertificateValidator.certificateIsValidOnDate; +import static eu.webeid.security.certificate.CertificateValidator.requireCertificateIsValidOnDate; /** * An OCSP service that uses a single designated OCSP responder. @@ -49,7 +49,7 @@ public void validateResponderCertificate(X509CertificateHolder cert, Date now) t throw new OCSPCertificateException("Responder certificate from the OCSP response is not equal to " + "the configured designated OCSP responder certificate"); } - certificateIsValidOnDate(responderCertificate, now, "Designated OCSP responder"); + requireCertificateIsValidOnDate(responderCertificate, now, "Designated OCSP responder"); } catch (CertificateException e) { throw new OCSPCertificateException("X509CertificateHolder conversion to X509Certificate failed", e); } diff --git a/src/main/java/eu/webeid/security/validator/ocsp/service/DesignatedOcspServiceConfiguration.java b/src/main/java/eu/webeid/ocsp/service/DesignatedOcspServiceConfiguration.java similarity index 92% rename from src/main/java/eu/webeid/security/validator/ocsp/service/DesignatedOcspServiceConfiguration.java rename to src/main/java/eu/webeid/ocsp/service/DesignatedOcspServiceConfiguration.java index 129366ed..5db67af4 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/service/DesignatedOcspServiceConfiguration.java +++ b/src/main/java/eu/webeid/ocsp/service/DesignatedOcspServiceConfiguration.java @@ -1,19 +1,18 @@ // SPDX-FileCopyrightText: Estonian Information System Authority // SPDX-License-Identifier: MIT -package eu.webeid.security.validator.ocsp.service; +package eu.webeid.ocsp.service; -import eu.webeid.security.validator.ocsp.OcspResponseValidator; +import eu.webeid.ocsp.protocol.OcspResponseValidator; import org.bouncycastle.asn1.x500.X500Name; import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder; -import eu.webeid.security.exceptions.OCSPCertificateException; +import eu.webeid.ocsp.exceptions.OCSPCertificateException; import java.net.URI; import java.security.cert.CertificateEncodingException; import java.security.cert.X509Certificate; import java.util.Collection; import java.util.Objects; -import java.util.stream.Collectors; public class DesignatedOcspServiceConfiguration { @@ -59,7 +58,7 @@ private Collection getIssuerX500Names(Collection supp try { return supportedIssuers.stream() .map(this::getSubject) - .collect(Collectors.toList()); + .toList(); } catch (IllegalArgumentException e) { throw new OCSPCertificateException("Supported issuer list contains an invalid certificate", e.getCause()); } diff --git a/src/main/java/eu/webeid/security/validator/ocsp/service/OcspService.java b/src/main/java/eu/webeid/ocsp/service/OcspService.java similarity index 89% rename from src/main/java/eu/webeid/security/validator/ocsp/service/OcspService.java rename to src/main/java/eu/webeid/ocsp/service/OcspService.java index 2d83a25f..92a6c155 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/service/OcspService.java +++ b/src/main/java/eu/webeid/ocsp/service/OcspService.java @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Estonian Information System Authority // SPDX-License-Identifier: MIT -package eu.webeid.security.validator.ocsp.service; +package eu.webeid.ocsp.service; import org.bouncycastle.cert.X509CertificateHolder; import eu.webeid.security.exceptions.AuthTokenException; diff --git a/src/main/java/eu/webeid/security/validator/ocsp/OcspServiceProvider.java b/src/main/java/eu/webeid/ocsp/service/OcspServiceProvider.java similarity index 81% rename from src/main/java/eu/webeid/security/validator/ocsp/OcspServiceProvider.java rename to src/main/java/eu/webeid/ocsp/service/OcspServiceProvider.java index d7f0464d..925d9bfc 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/OcspServiceProvider.java +++ b/src/main/java/eu/webeid/ocsp/service/OcspServiceProvider.java @@ -1,14 +1,9 @@ // SPDX-FileCopyrightText: Estonian Information System Authority // SPDX-License-Identifier: MIT -package eu.webeid.security.validator.ocsp; +package eu.webeid.ocsp.service; import eu.webeid.security.exceptions.AuthTokenException; -import eu.webeid.security.validator.ocsp.service.AiaOcspService; -import eu.webeid.security.validator.ocsp.service.AiaOcspServiceConfiguration; -import eu.webeid.security.validator.ocsp.service.DesignatedOcspService; -import eu.webeid.security.validator.ocsp.service.DesignatedOcspServiceConfiguration; -import eu.webeid.security.validator.ocsp.service.OcspService; import java.security.cert.CertificateEncodingException; import java.security.cert.X509Certificate; diff --git a/src/main/java/eu/webeid/security/authtoken/WebEidAuthToken.java b/src/main/java/eu/webeid/security/authtoken/WebEidAuthToken.java index f3139017..7dc80a62 100644 --- a/src/main/java/eu/webeid/security/authtoken/WebEidAuthToken.java +++ b/src/main/java/eu/webeid/security/authtoken/WebEidAuthToken.java @@ -6,43 +6,10 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; @JsonIgnoreProperties(ignoreUnknown = true) -public class WebEidAuthToken { - - private String unverifiedCertificate; - private String signature; - private String algorithm; - private String format; - - public String getUnverifiedCertificate() { - return unverifiedCertificate; - } - - public void setUnverifiedCertificate(String unverifiedCertificate) { - this.unverifiedCertificate = unverifiedCertificate; - } - - public String getSignature() { - return signature; - } - - public void setSignature(String signature) { - this.signature = signature; - } - - public String getAlgorithm() { - return algorithm; - } - - public void setAlgorithm(String algorithm) { - this.algorithm = algorithm; - } - - public String getFormat() { - return format; - } - - public void setFormat(String format) { - this.format = format; - } - +public record WebEidAuthToken( + String unverifiedCertificate, + String signature, + String algorithm, + String format +) { } diff --git a/src/main/java/eu/webeid/security/certificate/CertificateLoader.java b/src/main/java/eu/webeid/security/certificate/CertificateLoader.java index b9a2576f..4546842a 100644 --- a/src/main/java/eu/webeid/security/certificate/CertificateLoader.java +++ b/src/main/java/eu/webeid/security/certificate/CertificateLoader.java @@ -30,7 +30,7 @@ public static X509Certificate[] loadCertificatesFromResources(String... resource } } - return caCertificates.toArray(new X509Certificate[0]); + return caCertificates.toArray(X509Certificate[]::new); } public static X509Certificate decodeCertificateFromBase64(String certificateInBase64) throws CertificateDecodingException { diff --git a/src/main/java/eu/webeid/security/certificate/CertificateValidator.java b/src/main/java/eu/webeid/security/certificate/CertificateValidator.java index f479128d..72d5bb86 100644 --- a/src/main/java/eu/webeid/security/certificate/CertificateValidator.java +++ b/src/main/java/eu/webeid/security/certificate/CertificateValidator.java @@ -3,31 +3,46 @@ package eu.webeid.security.certificate; +import eu.webeid.security.exceptions.AuthTokenException; import eu.webeid.security.exceptions.CertificateExpiredException; import eu.webeid.security.exceptions.CertificateNotTrustedException; import eu.webeid.security.exceptions.CertificateNotYetValidException; +import eu.webeid.security.exceptions.CertificateRevocationCheckFailedException; +import eu.webeid.security.exceptions.CertificateRevokedException; import eu.webeid.security.exceptions.JceException; +import eu.webeid.security.validator.revocationcheck.CertificateRevocationChecker; +import eu.webeid.security.validator.revocationcheck.RevocationInfo; +import eu.webeid.security.validator.revocationcheck.RevocationMode; import java.security.GeneralSecurityException; import java.security.InvalidAlgorithmParameterException; import java.security.NoSuchAlgorithmException; import java.security.cert.CertPathBuilder; import java.security.cert.CertPathBuilderException; +import java.security.cert.CertPathValidator; +import java.security.cert.CertPathValidatorException; import java.security.cert.CertStore; import java.security.cert.CollectionCertStoreParameters; import java.security.cert.PKIXBuilderParameters; import java.security.cert.PKIXCertPathBuilderResult; +import java.security.cert.PKIXRevocationChecker; import java.security.cert.TrustAnchor; import java.security.cert.X509CertSelector; import java.security.cert.X509Certificate; import java.util.Collection; import java.util.Date; +import java.util.EnumSet; +import java.util.List; import java.util.Set; import java.util.stream.Collectors; +import static java.util.Objects.requireNonNull; + public final class CertificateValidator { - public static void certificateIsValidOnDate(X509Certificate cert, Date date, String subject) throws CertificateNotYetValidException, CertificateExpiredException { + private static final String JDK_OCSP_NONCE_PROPERTY = "jdk.security.certpath.ocspNonce"; + + public static void requireCertificateIsValidOnDate(X509Certificate cert, Date date, String subject) throws CertificateNotYetValidException, CertificateExpiredException { try { cert.checkValidity(date); } catch (java.security.cert.CertificateNotYetValidException e) { @@ -37,32 +52,165 @@ public static void certificateIsValidOnDate(X509Certificate cert, Date date, Str } } - public static X509Certificate validateIsSignedByTrustedCA(X509Certificate certificate, - Set trustedCACertificateAnchors, - CertStore trustedCACertificateCertStore, - Date now) throws CertificateNotTrustedException, JceException, CertificateNotYetValidException, CertificateExpiredException { - certificateIsValidOnDate(certificate, now, "User"); + /** + * Validates that the provided {@code certificate} is trusted and performs certificate revocation checking + * depending on {@code revocationMode}. + *

+ * Trust validation is performed by building a certification path from {@code certificate} to one of the + * configured {@code trustedCACertificateAnchors} using the supplied {@code trustedCACertificateCertStore}. + * The effective validation time is {@code now}. In addition, the trust anchor certificate's validity period + * is explicitly validated. + * It is assumed that each configured trust anchor represents the intermediate certificate authority that directly + * issued the subject certificate. + *

+ * Revocation behavior is controlled by {@code revocationMode}: + *

    + *
  • {@link RevocationMode#DISABLED} - no revocation checking is performed. Both + * {@code certificateRevocationChecker} and {@code customPkixRevocationChecker} must be {@code null}.
  • + *
  • {@link RevocationMode#CUSTOM_CHECKER} - revocation is checked by the provided + * {@code certificateRevocationChecker}. Platform (provider default) revocation checking is disabled. + * {@code customPkixRevocationChecker} must be {@code null}.
  • + *
  • {@link RevocationMode#CUSTOM_PKIX} - revocation is checked by the provided + * {@code customPkixRevocationChecker} installed as the (only) PKIX cert path checker during a separate + * validation pass. Provider default revocation checking is disabled. + * {@code certificateRevocationChecker} must be {@code null}.
  • + *
  • {@link RevocationMode#PLATFORM_OCSP} - revocation is checked using the platform PKIX revocation checker + * configured to enforce OCSP checking for the subject certificate with no fallback to CRLs + * ({@link PKIXRevocationChecker.Option#ONLY_END_ENTITY} and {@link PKIXRevocationChecker.Option#NO_FALLBACK}). + * When {@code platformOcspNonceEnabled} is true, a fresh OCSP nonce is included in each request. + * An explicitly set {@code jdk.security.certpath.ocspNonce} JVM property takes precedence over this setting + * and leaves nonce handling to the JDK. The JDK provider remains responsible for + * response nonce validation and may accept a response without one. + * Provider default revocation checking is disabled. Both custom checker parameters must be {@code null}.
  • + *
+ * + * @param certificate the subject certificate to validate + * @param trustedCACertificateAnchors trust anchors used for PKIX path building (Web eID typically configures issuing intermediates) + * @param trustedCACertificateCertStore certificate store containing trusted CA/intermediate certificates used during path building + * @param now validation time used for certificate validity and PKIX path building + * @param revocationMode revocation checking mode + * @param certificateRevocationChecker custom certificate revocation checker (required only for {@code CUSTOM_CHECKER}) + * @param customPkixRevocationChecker custom PKIX revocation checker (required only for {@code CUSTOM_PKIX}) + * @param platformOcspNonceEnabled whether to supply a fresh OCSP nonce when the JVM nonce property is unset; + * ignored in other revocation modes + * @return a list of {@link RevocationInfo} objects; the list is non-null and may be empty. + * It is populated for {@link RevocationMode#CUSTOM_CHECKER}, and may be populated for + * {@link RevocationMode#CUSTOM_PKIX} when the provided {@code customPkixRevocationChecker} + * has an explicit OCSP responder URI configured; otherwise it is empty. + *

+ * @throws NullPointerException if any required parameter is {@code null} + * @throws IllegalArgumentException if the supplied checker parameters are inconsistent with {@code revocationMode} + * @throws CertificateNotYetValidException if the subject or trust anchor certificate is not yet valid at {@code now} + * @throws CertificateExpiredException if the subject or trust anchor certificate is expired at {@code now} + * @throws CertificateNotTrustedException if no valid certification path can be built to the configured trust anchors + * @throws CertificateRevokedException if a PKIX revocation checker reports that the subject certificate is revoked + * @throws CertificateRevocationCheckFailedException if a PKIX revocation checker cannot determine the revocation status + * @throws JceException if the underlying JCA/JCE implementation fails unexpectedly + * @throws AuthTokenException if a custom revocation checker fails or reports the certificate as revoked + */ + public static List validateCertificateTrustAndRevocation(X509Certificate certificate, + Set trustedCACertificateAnchors, + CertStore trustedCACertificateCertStore, + Date now, + RevocationMode revocationMode, + CertificateRevocationChecker certificateRevocationChecker, + PKIXRevocationChecker customPkixRevocationChecker, + boolean platformOcspNonceEnabled) throws AuthTokenException { + + requireNonNull(certificate, "certificate"); + requireNonNull(trustedCACertificateAnchors, "trustedCACertificateAnchors"); + requireNonNull(trustedCACertificateCertStore, "trustedCACertificateCertStore"); + requireNonNull(now, "now"); + requireNonNull(revocationMode, "revocationMode"); + + requireCertificateIsValidOnDate(certificate, now, "User"); final X509CertSelector selector = new X509CertSelector(); selector.setCertificate(certificate); try { final PKIXBuilderParameters pkixBuilderParameters = new PKIXBuilderParameters(trustedCACertificateAnchors, selector); - // Certificate revocation check is intentionally disabled as we do the OCSP check with SubjectCertificateNotRevokedValidator ourselves. - pkixBuilderParameters.setRevocationEnabled(false); pkixBuilderParameters.setDate(now); pkixBuilderParameters.addCertStore(trustedCACertificateCertStore); + List revocationInfoList = List.of(); + PKIXRevocationChecker pkixRevocationChecker = null; + + switch (revocationMode) { + case DISABLED -> { + if (customPkixRevocationChecker != null || certificateRevocationChecker != null) { + throw new IllegalArgumentException("customPkixRevocationChecker and certificateRevocationChecker must be null when revocationMode is DISABLED"); + } + } + + case CUSTOM_CHECKER -> { + if (customPkixRevocationChecker != null) { + throw new IllegalArgumentException("customPkixRevocationChecker must be null when revocationMode is CUSTOM_CHECKER"); + } + if (certificateRevocationChecker == null) { + throw new IllegalArgumentException("certificateRevocationChecker must be provided when revocationMode is CUSTOM_CHECKER"); + } + } + + case CUSTOM_PKIX -> { + if (certificateRevocationChecker != null) { + throw new IllegalArgumentException("certificateRevocationChecker must be null when revocationMode is CUSTOM_PKIX"); + } + if (customPkixRevocationChecker == null) { + throw new IllegalArgumentException("customPkixRevocationChecker must be provided when revocationMode is CUSTOM_PKIX"); + } + pkixRevocationChecker = customPkixRevocationChecker; + + if (customPkixRevocationChecker.getOcspResponder() != null) { + revocationInfoList = List.of(new RevocationInfo(customPkixRevocationChecker.getOcspResponder(), null)); + } + } + + case PLATFORM_OCSP -> { + if (customPkixRevocationChecker != null || certificateRevocationChecker != null) { + throw new IllegalArgumentException("customPkixRevocationChecker and certificateRevocationChecker must be null when revocationMode is PLATFORM_OCSP"); + } + + pkixRevocationChecker = buildOcspEnforcedPkixRevocationChecker(platformOcspNonceEnabled); + } + + default -> throw new IllegalStateException("Unhandled revocationMode: " + revocationMode); + } + + // Build the trusted path without revocation checking. Revocation is validated separately below so that + // CertPathValidatorException and its structured reason are not hidden by CertPathBuilderException. + pkixBuilderParameters.setRevocationEnabled(false); // See the comment in buildCertStoreFromCertificates() below why we use the default JCE provider. final CertPathBuilder certPathBuilder = CertPathBuilder.getInstance(CertPathBuilder.getDefaultType()); final PKIXCertPathBuilderResult result = (PKIXCertPathBuilderResult) certPathBuilder.build(pkixBuilderParameters); final X509Certificate trustedCACert = result.getTrustAnchor().getTrustedCert(); + if (trustedCACert == null) { + throw new IllegalStateException("TrustAnchor.getTrustedCert() returned null, it must contain a trusted certificate"); + } + + // PKIX path building does not validate trust anchor validity period, do it ourselves. + requireCertificateIsValidOnDate(trustedCACert, now, "Trusted CA"); - // Verify that the trusted CA cert is presently valid before returning the result. - certificateIsValidOnDate(trustedCACert, now, "Trusted CA"); + if (pkixRevocationChecker != null) { + validateRevocation( + result, + pkixBuilderParameters, + pkixRevocationChecker, + certificate + ); + } else if (revocationMode == RevocationMode.CUSTOM_CHECKER) { + if (!certificate.getIssuerX500Principal().equals(trustedCACert.getSubjectX500Principal())) { + throw new IllegalStateException( + "Trust anchor is not the issuer of the subject certificate, check your configured certificate authorities. " + + "Subject issuer=" + certificate.getIssuerX500Principal() + + ", trust anchor subject=" + trustedCACert.getSubjectX500Principal() + ); + } + revocationInfoList = certificateRevocationChecker.validateCertificateNotRevoked(certificate, trustedCACert); + } - return trustedCACert; + return revocationInfoList; } catch (InvalidAlgorithmParameterException | NoSuchAlgorithmException e) { throw new JceException(e); @@ -76,8 +224,33 @@ public static Set buildTrustAnchorsFromCertificates(Collection new TrustAnchor(cert, null)).collect(Collectors.toUnmodifiableSet()); } + private static void validateRevocation(PKIXCertPathBuilderResult pathBuilderResult, + PKIXBuilderParameters pkixBuilderParameters, + PKIXRevocationChecker revocationChecker, + X509Certificate certificate) + throws AuthTokenException, InvalidAlgorithmParameterException, NoSuchAlgorithmException { + // Reuse the parameters, restrict validation to the trust anchor selected during path building. + pkixBuilderParameters.setTrustAnchors(Set.of(pathBuilderResult.getTrustAnchor())); + // Provider-default revocation remains disabled; adding an explicit checker is sufficient for it to run. + pkixBuilderParameters.setCertPathCheckers(List.of(revocationChecker)); + + final CertPathValidator certPathValidator = CertPathValidator.getInstance(CertPathValidator.getDefaultType()); + try { + certPathValidator.validate(pathBuilderResult.getCertPath(), pkixBuilderParameters); + } catch (CertPathValidatorException e) { + if (e.getReason() == CertPathValidatorException.BasicReason.REVOKED) { + throw new CertificateRevokedException(certificate, e); + } + if (e.getReason() == CertPathValidatorException.BasicReason.UNDETERMINED_REVOCATION_STATUS || + e.getReason() == CertPathValidatorException.BasicReason.UNSPECIFIED) { + throw new CertificateRevocationCheckFailedException(certificate, e); + } + throw new CertificateNotTrustedException(certificate, e); + } + } + public static CertStore buildCertStoreFromCertificates(Collection certificates) throws JceException { - // We use the default JCE provider as there is no reason to use Bouncy Castle, moreover BC requires + // Use the default JCE provider as there is no reason to use Bouncy Castle, moreover BC requires // the validated certificate to be in the certificate store which breaks the clean immutable usage of // trustedCACertificateCertStore in SubjectCertificateTrustedValidator. try { @@ -87,6 +260,29 @@ public static CertStore buildCertStoreFromCertificates(Collection Set newHashSet(T... elements) { - final Set set = new HashSet<>(); - java.util.Collections.addAll(set, elements); - return set; - } - - public static byte[] concat(byte[] first, byte[] second) { - byte[] result = Arrays.copyOf(first, first.length + second.length); - System.arraycopy(second, 0, result, first.length, second.length); - return result; - } - - private Collections() { - throw new IllegalStateException("Utility class"); - } - -} diff --git a/src/main/java/eu/webeid/security/util/DateAndTime.java b/src/main/java/eu/webeid/security/util/DateAndTime.java index 85eac7dd..5edf6610 100644 --- a/src/main/java/eu/webeid/security/util/DateAndTime.java +++ b/src/main/java/eu/webeid/security/util/DateAndTime.java @@ -17,11 +17,12 @@ public static ZonedDateTime utcNow() { return ZonedDateTime.now(ZoneOffset.UTC); } - public static void requirePositiveDuration(Duration duration, String fieldName) { + public static Duration requirePositiveDuration(Duration duration, String fieldName) { Objects.requireNonNull(duration, fieldName + " must not be null"); if (duration.isNegative() || duration.isZero()) { throw new IllegalArgumentException(fieldName + " must be greater than zero"); } + return duration; } public static class DefaultClock implements Clock { diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenSignatureValidator.java b/src/main/java/eu/webeid/security/validator/AuthTokenSignatureValidator.java index 5e813552..18397f94 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenSignatureValidator.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenSignatureValidator.java @@ -10,6 +10,7 @@ import io.jsonwebtoken.Jwts; import io.jsonwebtoken.impl.security.DefaultVerifySecureDigestRequest; import io.jsonwebtoken.security.SignatureAlgorithm; +import io.jsonwebtoken.security.SignatureException; import io.jsonwebtoken.security.VerifySecureDigestRequest; import java.io.ByteArrayInputStream; @@ -19,23 +20,21 @@ import java.security.NoSuchAlgorithmException; import java.security.PublicKey; import java.util.Arrays; -import java.util.HashSet; import java.util.Objects; import java.util.Set; import static eu.webeid.security.util.Base64Decoder.decodeBase64; -import static eu.webeid.security.util.Collections.concat; import static eu.webeid.security.util.Strings.isNullOrEmpty; public class AuthTokenSignatureValidator { // Supported subset of JSON Web Signature algorithms as defined in RFC 7518, sections 3.3, 3.4, 3.5. // See https://github.com/web-eid/libelectronic-id/blob/main/include/electronic-id/enums.hpp#L176. - private static final Set ALLOWED_SIGNATURE_ALGORITHMS = new HashSet<>(Arrays.asList( + private static final Set ALLOWED_SIGNATURE_ALGORITHMS = Set.of( "ES256", "ES384", "ES512", // ECDSA "PS256", "PS384", "PS512", // RSASSA-PSS "RS256", "RS384", "RS512" // RSASSA-PKCS1-v1_5 - )); + ); private final byte[] originBytes; @@ -70,7 +69,12 @@ public void validate(String algorithm, String signature, PublicKey publicKey, St } Objects.requireNonNull(hashAlgorithm, "hashAlgorithm"); - final byte[] decodedSignature = decodeBase64(signature); + final byte[] decodedSignature; + try { + decodedSignature = decodeBase64(signature); + } catch (IllegalArgumentException e) { + throw new AuthTokenParseException("Token signature is not valid Base64", e); + } final byte[] originHash = hashAlgorithm.digest(originBytes); final byte[] nonceHash = hashAlgorithm.digest(currentChallengeNonce.getBytes(StandardCharsets.UTF_8)); @@ -84,8 +88,12 @@ public void validate(String algorithm, String signature, PublicKey publicKey, St new ByteArrayInputStream(concatSignedFields), null, null, publicKey, decodedSignature); - if (!signatureAlgorithm.verify(verificationRequest)) { - throw new AuthTokenSignatureValidationException(); + try { + if (!signatureAlgorithm.verify(verificationRequest)) { + throw new AuthTokenSignatureValidationException(); + } + } catch (SignatureException e) { + throw new AuthTokenSignatureValidationException(e); } } @@ -99,4 +107,10 @@ private void requireNotEmpty(String argument, String fieldName) throws AuthToken } } + private static byte[] concat(byte[] first, byte[] second) { + byte[] result = Arrays.copyOf(first, first.length + second.length); + System.arraycopy(second, 0, result, first.length, second.length); + return result; + } + } diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java index 596ff3c0..158d1860 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java @@ -4,22 +4,19 @@ package eu.webeid.security.validator; import eu.webeid.security.certificate.SubjectCertificatePolicies; -import eu.webeid.security.validator.ocsp.service.DesignatedOcspServiceConfiguration; +import eu.webeid.security.validator.revocationcheck.CertificateRevocationChecker; +import eu.webeid.security.validator.revocationcheck.RevocationMode; import org.bouncycastle.asn1.ASN1ObjectIdentifier; import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; +import java.security.cert.PKIXRevocationChecker; import java.security.cert.X509Certificate; -import java.time.Duration; import java.util.Collection; import java.util.HashSet; -import java.util.Objects; import java.util.Set; -import static eu.webeid.security.util.Collections.newHashSet; -import static eu.webeid.security.util.DateAndTime.requirePositiveDuration; - /** * Stores configuration parameters for {@link AuthTokenValidatorImpl}. */ @@ -27,19 +24,18 @@ public final class AuthTokenValidationConfiguration { private URI siteOrigin; private Collection trustedCACertificates = new HashSet<>(); - private boolean isUserCertificateRevocationCheckWithOcspEnabled = true; - private Duration ocspRequestTimeout = Duration.ofSeconds(5); - private Duration allowedOcspResponseTimeSkew = Duration.ofMinutes(15); - private Duration maxOcspResponseThisUpdateAge = Duration.ofMinutes(2); - private DesignatedOcspServiceConfiguration designatedOcspServiceConfiguration; // Don't allow Estonian Mobile-ID policy by default. - private Collection disallowedSubjectCertificatePolicies = newHashSet( + private Collection disallowedSubjectCertificatePolicies = new HashSet<>(Set.of( SubjectCertificatePolicies.ESTEID_SK_2015_MOBILE_ID_POLICY_V1, SubjectCertificatePolicies.ESTEID_SK_2015_MOBILE_ID_POLICY_V2, SubjectCertificatePolicies.ESTEID_SK_2015_MOBILE_ID_POLICY_V3, SubjectCertificatePolicies.ESTEID_SK_2015_MOBILE_ID_POLICY - ); - private Collection nonceDisabledOcspUrls = new HashSet<>(); + )); + private boolean isUserCertificateRevocationCheckEnabled = true; + private boolean platformOcspNonceEnabled = true; + private CertificateRevocationChecker certificateRevocationChecker; + private PKIXRevocationChecker pkixRevocationChecker; + private RevocationMode revocationMode = RevocationMode.PLATFORM_OCSP; AuthTokenValidationConfiguration() { } @@ -47,13 +43,12 @@ public final class AuthTokenValidationConfiguration { private AuthTokenValidationConfiguration(AuthTokenValidationConfiguration other) { this.siteOrigin = other.siteOrigin; this.trustedCACertificates = Set.copyOf(other.trustedCACertificates); - this.isUserCertificateRevocationCheckWithOcspEnabled = other.isUserCertificateRevocationCheckWithOcspEnabled; - this.ocspRequestTimeout = other.ocspRequestTimeout; - this.allowedOcspResponseTimeSkew = other.allowedOcspResponseTimeSkew; - this.maxOcspResponseThisUpdateAge = other.maxOcspResponseThisUpdateAge; - this.designatedOcspServiceConfiguration = other.designatedOcspServiceConfiguration; this.disallowedSubjectCertificatePolicies = Set.copyOf(other.disallowedSubjectCertificatePolicies); - this.nonceDisabledOcspUrls = Set.copyOf(other.nonceDisabledOcspUrls); + this.isUserCertificateRevocationCheckEnabled = other.isUserCertificateRevocationCheckEnabled; + this.platformOcspNonceEnabled = other.platformOcspNonceEnabled; + this.certificateRevocationChecker = other.certificateRevocationChecker; + this.pkixRevocationChecker = other.pkixRevocationChecker; + this.revocationMode = other.revocationMode; } void setSiteOrigin(URI siteOrigin) { @@ -68,69 +63,57 @@ Collection getTrustedCACertificates() { return trustedCACertificates; } - boolean isUserCertificateRevocationCheckWithOcspEnabled() { - return isUserCertificateRevocationCheckWithOcspEnabled; - } - - void setUserCertificateRevocationCheckWithOcspDisabled() { - isUserCertificateRevocationCheckWithOcspEnabled = false; - } - - public Duration getOcspRequestTimeout() { - return ocspRequestTimeout; + public Collection getDisallowedSubjectCertificatePolicies() { + return disallowedSubjectCertificatePolicies; } - void setOcspRequestTimeout(Duration ocspRequestTimeout) { - this.ocspRequestTimeout = ocspRequestTimeout; + boolean isUserCertificateRevocationCheckEnabled() { + return isUserCertificateRevocationCheckEnabled; } - public Duration getAllowedOcspResponseTimeSkew() { - return allowedOcspResponseTimeSkew; + void setUserCertificateRevocationCheckDisabled() { + isUserCertificateRevocationCheckEnabled = false; } - public void setAllowedOcspResponseTimeSkew(Duration allowedOcspResponseTimeSkew) { - this.allowedOcspResponseTimeSkew = allowedOcspResponseTimeSkew; + boolean isPlatformOcspNonceEnabled() { + return platformOcspNonceEnabled; } - public Duration getMaxOcspResponseThisUpdateAge() { - return maxOcspResponseThisUpdateAge; + void setPlatformOcspNonceEnabled(boolean enabled) { + platformOcspNonceEnabled = enabled; } - public void setMaxOcspResponseThisUpdateAge(Duration maxOcspResponseThisUpdateAge) { - this.maxOcspResponseThisUpdateAge = maxOcspResponseThisUpdateAge; + public void setCertificateRevocationChecker(CertificateRevocationChecker certificateRevocationChecker) { + this.certificateRevocationChecker = certificateRevocationChecker; } - public DesignatedOcspServiceConfiguration getDesignatedOcspServiceConfiguration() { - return designatedOcspServiceConfiguration; + public CertificateRevocationChecker getCertificateRevocationChecker() { + return certificateRevocationChecker; } - public void setDesignatedOcspServiceConfiguration(DesignatedOcspServiceConfiguration designatedOcspServiceConfiguration) { - this.designatedOcspServiceConfiguration = designatedOcspServiceConfiguration; + public void setPkixRevocationChecker(PKIXRevocationChecker pkixRevocationChecker) { + this.pkixRevocationChecker = pkixRevocationChecker; } - public Collection getDisallowedSubjectCertificatePolicies() { - return disallowedSubjectCertificatePolicies; + public PKIXRevocationChecker getPkixRevocationChecker() { + return pkixRevocationChecker; } - public Collection getNonceDisabledOcspUrls() { - return nonceDisabledOcspUrls; + public RevocationMode getRevocationMode() { + return revocationMode; } /** * Checks that the configuration parameters are valid. * - * @throws NullPointerException when required parameters are null * @throws IllegalArgumentException when any parameter is invalid */ void validate() { - Objects.requireNonNull(siteOrigin, "Origin URI must not be null"); validateIsOriginURL(siteOrigin); if (trustedCACertificates.isEmpty()) { throw new IllegalArgumentException("At least one trusted certificate authority must be provided"); } - requirePositiveDuration(ocspRequestTimeout, "OCSP request timeout"); - requirePositiveDuration(allowedOcspResponseTimeSkew, "Allowed OCSP response time-skew"); - requirePositiveDuration(maxOcspResponseThisUpdateAge, "Max OCSP response thisUpdate age"); + validateRevocationConfiguration(); } AuthTokenValidationConfiguration copy() { @@ -146,6 +129,9 @@ AuthTokenValidationConfiguration copy() { */ public static void validateIsOriginURL(URI uri) throws IllegalArgumentException { try { + if (uri == null) { + throw new IllegalArgumentException("Origin URI must not be null"); + } // 1. Verify that the URI can be converted to absolute URL. uri.toURL(); // 2. Verify that the URI contains only HTTPS scheme, host and optional port components. @@ -160,4 +146,42 @@ public static void validateIsOriginURL(URI uri) throws IllegalArgumentException } } + /** + * Validates that the revocation check configuration is consistent and derives the {@link RevocationMode} from it. + *

+ * Configuration is inconsistent if revocation checking is disabled but a checker is configured or if both + * checkers are configured simultaneously. + * + * @throws IllegalArgumentException if configuration is inconsistent + */ + private void validateRevocationConfiguration() { + final boolean hasCustomChecker = certificateRevocationChecker != null; + final boolean hasPkixChecker = pkixRevocationChecker != null; + + if (!isUserCertificateRevocationCheckEnabled) { + if (hasCustomChecker || hasPkixChecker) { + throw new IllegalArgumentException( + "User certificate revocation check is disabled, but a revocation checker was configured. " + + "Do not combine withoutUserCertificateRevocationCheck() with withCertificateRevocationChecker(...) " + + "or withPKIXRevocationChecker(...)." + ); + } + revocationMode = RevocationMode.DISABLED; + } else { + // Revocation check enabled, at most one checker allowed, if no checker provided, use default PKIX revocation checker in OCSP mode. + if (hasCustomChecker && hasPkixChecker) { + throw new IllegalArgumentException( + "Only one of OcspCertificateRevocationChecker or PKIXRevocationChecker may be configured. " + + "Do not combine withCertificateRevocationChecker(...) with withPKIXRevocationChecker(...)." + ); + } + if (hasCustomChecker) { + revocationMode = RevocationMode.CUSTOM_CHECKER; + } else if (hasPkixChecker) { + revocationMode = RevocationMode.CUSTOM_PKIX; + } else { + revocationMode = RevocationMode.PLATFORM_OCSP; + } + } + } } diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidator.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidator.java index 3f54175e..b3a09cf0 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenValidator.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidator.java @@ -8,8 +8,6 @@ import eu.webeid.security.exceptions.AuthTokenException; import eu.webeid.security.util.Strings; -import java.security.cert.X509Certificate; - /** * Parses and validates the provided Web eID authentication token. */ @@ -28,16 +26,16 @@ public interface AuthTokenValidator { /** * Validates the Web eID authentication token signed by the subject and returns - * the subject certificate that can be used for retrieving information about the subject. + * validation information containing the subject certificate and available revocation details. *

* See {@link CertificateData} and {@link Strings} for convenience methods for retrieving user * information from the certificate. * * @param authToken the Web eID authentication token * @param currentChallengeNonce the challenge nonce that is associated with the authentication token - * @return validated subject certificate + * @return validated subject certificate and available revocation information * @throws AuthTokenException when validation fails */ - X509Certificate validate(WebEidAuthToken authToken, String currentChallengeNonce) throws AuthTokenException; + ValidationInfo validate(WebEidAuthToken authToken, String currentChallengeNonce) throws AuthTokenException; } diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java index 355c89b5..d072979f 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java @@ -4,18 +4,15 @@ package eu.webeid.security.validator; import eu.webeid.security.exceptions.JceException; -import eu.webeid.security.validator.ocsp.OcspClient; -import eu.webeid.security.validator.ocsp.OcspClientImpl; -import eu.webeid.security.validator.ocsp.service.DesignatedOcspServiceConfiguration; +import eu.webeid.security.validator.revocationcheck.CertificateRevocationChecker; import org.bouncycastle.asn1.ASN1ObjectIdentifier; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.net.URI; +import java.security.cert.PKIXRevocationChecker; import java.security.cert.X509Certificate; -import java.time.Duration; import java.util.Collections; -import java.util.stream.Collectors; /** * Builder for constructing {@link AuthTokenValidator} instances. @@ -25,7 +22,6 @@ public class AuthTokenValidatorBuilder { private static final Logger LOG = LoggerFactory.getLogger(AuthTokenValidatorBuilder.class); private final AuthTokenValidationConfiguration configuration = new AuthTokenValidationConfiguration(); - private OcspClient ocspClient; /** * Sets the expected site origin, i.e. the domain that the application is running on. @@ -59,7 +55,7 @@ public AuthTokenValidatorBuilder withTrustedCertificateAuthorities(X509Certifica LOG.debug("Trusted intermediate certificate authorities set to {}", configuration.getTrustedCACertificates().stream() .map(X509Certificate::getSubjectX500Principal) - .collect(Collectors.toList())); + .toList()); } return this; } @@ -79,105 +75,66 @@ public AuthTokenValidatorBuilder withDisallowedCertificatePolicies(ASN1ObjectIde } /** - * Turns off user certificate revocation check with OCSP. + * Turns off user certificate revocation check (with OCSP and/or CRL). *

- * Turning off user certificate revocation check with OCSP is dangerous and should be - * used only in exceptional circumstances. + * Turning off user certificate revocation check is dangerous and should be used only in + * exceptional circumstances. * By default, the revocation check is turned on. * * @return the builder instance for method chaining. */ - public AuthTokenValidatorBuilder withoutUserCertificateRevocationCheckWithOcsp() { - configuration.setUserCertificateRevocationCheckWithOcspDisabled(); - LOG.warn("User certificate revocation check with OCSP is disabled, " + + public AuthTokenValidatorBuilder withoutUserCertificateRevocationCheck() { + configuration.setUserCertificateRevocationCheckDisabled(); + LOG.warn("User certificate revocation check is disabled, " + "you should turn off the revocation check only in exceptional circumstances"); return this; } /** - * Sets both the connection and response timeout of user certificate revocation check OCSP requests. + * Controls whether the library supplies a nonce for platform OCSP requests. Enabled by default. *

- * This is an optional configuration parameter, the default is 5 seconds. + * An explicitly configured {@code jdk.security.certpath.ocspNonce} JVM system property takes precedence: + * {@code true} delegates nonce generation to the JDK and {@code false} disables it, regardless of this setting. + * This setting has no effect on custom revocation checkers or when revocation checking is disabled. + * Enabling a request nonce does not require the JDK to reject a response without a nonce. * - * @param ocspRequestTimeout the duration of OCSP request connection and response timeout - * @return the builder instance for method chaining. - */ - public AuthTokenValidatorBuilder withOcspRequestTimeout(Duration ocspRequestTimeout) { - configuration.setOcspRequestTimeout(ocspRequestTimeout); - LOG.debug("OCSP request timeout set to {}", ocspRequestTimeout); - return this; - } - - /** - * Sets the allowed time skew for OCSP response's thisUpdate and nextUpdate times. - * This parameter is used to allow discrepancies between the system clock and the OCSP responder's clock, - * which may occur due to clock drift, network delays or revocation updates that are not published in real time. - *

- * This is an optional configuration parameter, the default is 15 minutes. - * The relatively long default is specifically chosen to account for one particular OCSP responder that used - * CRLs for authoritative revocation info, these CRLs were updated every 15 minutes. - * - * @param allowedTimeSkew the allowed time skew - * @return the builder instance for method chaining. - */ - public AuthTokenValidatorBuilder withAllowedOcspResponseTimeSkew(Duration allowedTimeSkew) { - configuration.setAllowedOcspResponseTimeSkew(allowedTimeSkew); - LOG.debug("Allowed OCSP response time skew set to {}", allowedTimeSkew); - return this; - } - - /** - * Sets the maximum age of the OCSP response's thisUpdate time before it is considered too old. - *

- * This is an optional configuration parameter, the default is 2 minutes. - * - * @param maxThisUpdateAge the maximum age of the OCSP response's thisUpdate time - * @return the builder instance for method chaining. - */ - public AuthTokenValidatorBuilder withMaxOcspResponseThisUpdateAge(Duration maxThisUpdateAge) { - configuration.setMaxOcspResponseThisUpdateAge(maxThisUpdateAge); - LOG.debug("Maximum OCSP response thisUpdate age set to {}", maxThisUpdateAge); - return this; - } - - /** - * Adds the given URLs to the list of OCSP URLs for which the nonce protocol extension will be disabled. - * The OCSP URL is extracted from the user certificate and some OCSP services don't support the nonce extension. - * - * @param urls OCSP URLs for which the nonce protocol extension will be disabled + * @param enabled whether to supply a nonce when the JVM nonce property is unset * @return the builder instance for method chaining */ - public AuthTokenValidatorBuilder withNonceDisabledOcspUrls(URI... urls) { - Collections.addAll(configuration.getNonceDisabledOcspUrls(), urls); - LOG.debug("OCSP URLs for which the nonce protocol extension is disabled set to {}", configuration.getNonceDisabledOcspUrls()); + public AuthTokenValidatorBuilder withPlatformOcspNonceEnabled(boolean enabled) { + configuration.setPlatformOcspNonceEnabled(enabled); return this; } /** - * Activates the provided designated OCSP service for user certificate revocation check with OCSP. - * The designated service is only used for checking the status of the certificates whose issuers are - * supported by the service, falling back to the default OCSP service access location from - * the certificate's AIA extension if not. + * Configures a custom certificate revocation checker for validating user certificate revocation status. + *

+ * When set, the platform (provider default) revocation mechanism is disabled and revocation checking is + * delegated to the given {@link CertificateRevocationChecker}. This option is mutually exclusive with + * {@link #withPKIXRevocationChecker(PKIXRevocationChecker)} and {@link #withoutUserCertificateRevocationCheck()}. * - * @param serviceConfiguration configuration of the designated OCSP service + * @param customChecker custom certificate revocation checker implementation * @return the builder instance for method chaining */ - public AuthTokenValidatorBuilder withDesignatedOcspServiceConfiguration(DesignatedOcspServiceConfiguration serviceConfiguration) { - configuration.setDesignatedOcspServiceConfiguration(serviceConfiguration); - LOG.debug("Using designated OCSP service configuration"); + public AuthTokenValidatorBuilder withCertificateRevocationChecker(CertificateRevocationChecker customChecker) { + configuration.setCertificateRevocationChecker(customChecker); return this; } /** - * Uses the provided OCSP client instance during user certificate revocation check with OCSP. - * The provided client instance must be thread-safe. + * Configures a custom {@link PKIXRevocationChecker} to be used as the revocation mechanism during user certificate + * validation with platform PKIX. + *

+ * When set, this checker replaces the platform (provider default) {@link PKIXRevocationChecker}. This option is + * mutually exclusive with {@link #withCertificateRevocationChecker(CertificateRevocationChecker)} + * and {@link #withoutUserCertificateRevocationCheck()}. * - * @param ocspClient OCSP client instance + * @param customChecker custom PKIX revocation checker * @return the builder instance for method chaining + * @throws NullPointerException if {@code customChecker} is null */ - public AuthTokenValidatorBuilder withOcspClient(OcspClient ocspClient) { - this.ocspClient = ocspClient; - LOG.debug("Using the OCSP client provided by API consumer"); + public AuthTokenValidatorBuilder withPKIXRevocationChecker(PKIXRevocationChecker customChecker) { + configuration.setPkixRevocationChecker(customChecker); return this; } @@ -186,16 +143,12 @@ public AuthTokenValidatorBuilder withOcspClient(OcspClient ocspClient) { * The returned {@link AuthTokenValidator} object is immutable/thread-safe. * * @return the configured authentication token validator object - * @throws NullPointerException when required parameters are null * @throws IllegalArgumentException when any parameter is invalid - * @throws RuntimeException when JCE configuration is invalid + * @throws JceException when JCE configuration is invalid */ - public AuthTokenValidator build() throws NullPointerException, IllegalArgumentException, JceException { + public AuthTokenValidator build() throws IllegalArgumentException, JceException { configuration.validate(); - if (configuration.isUserCertificateRevocationCheckWithOcspEnabled() && ocspClient == null) { - ocspClient = OcspClientImpl.build(configuration.getOcspRequestTimeout()); - } - return new AuthTokenValidatorImpl(configuration, ocspClient); + return new AuthTokenValidatorImpl(configuration); } } diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java index 0d951323..afae8093 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java @@ -11,14 +11,10 @@ import eu.webeid.security.exceptions.AuthTokenException; import eu.webeid.security.exceptions.AuthTokenParseException; import eu.webeid.security.exceptions.JceException; -import eu.webeid.security.validator.certvalidators.SubjectCertificateNotRevokedValidator; +import eu.webeid.security.util.DateAndTime; import eu.webeid.security.validator.certvalidators.SubjectCertificatePolicyValidator; import eu.webeid.security.validator.certvalidators.SubjectCertificatePurposeValidator; -import eu.webeid.security.validator.certvalidators.SubjectCertificateTrustedValidator; -import eu.webeid.security.validator.certvalidators.SubjectCertificateValidatorBatch; -import eu.webeid.security.validator.ocsp.OcspClient; -import eu.webeid.security.validator.ocsp.OcspServiceProvider; -import eu.webeid.security.validator.ocsp.service.AiaOcspServiceConfiguration; +import eu.webeid.security.validator.revocationcheck.RevocationInfo; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -26,7 +22,8 @@ import java.security.cert.CertStore; import java.security.cert.TrustAnchor; import java.security.cert.X509Certificate; -import java.util.Objects; +import java.util.Date; +import java.util.List; import java.util.Set; /** @@ -41,41 +38,23 @@ final class AuthTokenValidatorImpl implements AuthTokenValidator { private static final ObjectReader OBJECT_READER = new ObjectMapper().readerFor(WebEidAuthToken.class); private final AuthTokenValidationConfiguration configuration; - private final SubjectCertificateValidatorBatch simpleSubjectCertificateValidators; private final Set trustedCACertificateAnchors; private final CertStore trustedCACertificateCertStore; - // OcspClient uses built-in HttpClient internally by default. - // A single HttpClient instance is reused for all HTTP calls to utilize connection and thread pools. - private OcspClient ocspClient; - private OcspServiceProvider ocspServiceProvider; private final AuthTokenSignatureValidator authTokenSignatureValidator; + private final SubjectCertificatePolicyValidator subjectCertificatePolicyValidator; /** * @param configuration configuration parameters for the token validator - * @param ocspClient client for communicating with the OCSP service */ - AuthTokenValidatorImpl(AuthTokenValidationConfiguration configuration, OcspClient ocspClient) throws JceException { + AuthTokenValidatorImpl(AuthTokenValidationConfiguration configuration) throws JceException { // Copy the configuration object to make AuthTokenValidatorImpl immutable and thread-safe. this.configuration = configuration.copy(); - // Create and cache trusted CA certificate JCA objects for SubjectCertificateTrustedValidator and AiaOcspService. + // Create and cache trusted CA certificate JCA objects for certificate path validation. trustedCACertificateAnchors = CertificateValidator.buildTrustAnchorsFromCertificates(configuration.getTrustedCACertificates()); trustedCACertificateCertStore = CertificateValidator.buildCertStoreFromCertificates(configuration.getTrustedCACertificates()); - simpleSubjectCertificateValidators = SubjectCertificateValidatorBatch.createFrom( - SubjectCertificatePurposeValidator::validateCertificatePurpose, - new SubjectCertificatePolicyValidator(configuration.getDisallowedSubjectCertificatePolicies())::validateCertificatePolicies - ); - - if (configuration.isUserCertificateRevocationCheckWithOcspEnabled()) { - // The OCSP client may be provided by the API consumer. - this.ocspClient = Objects.requireNonNull(ocspClient, "OCSP client must not be null when OCSP check is enabled"); - ocspServiceProvider = new OcspServiceProvider( - configuration.getDesignatedOcspServiceConfiguration(), - new AiaOcspServiceConfiguration(configuration.getNonceDisabledOcspUrls(), - trustedCACertificateAnchors, - trustedCACertificateCertStore)); - } + subjectCertificatePolicyValidator = new SubjectCertificatePolicyValidator(configuration.getDisallowedSubjectCertificatePolicies()); authTokenSignatureValidator = new AuthTokenSignatureValidator(configuration.getSiteOrigin()); } @@ -94,7 +73,7 @@ public WebEidAuthToken parse(String authToken) throws AuthTokenException { } @Override - public X509Certificate validate(WebEidAuthToken authToken, String currentChallengeNonce) throws AuthTokenException { + public ValidationInfo validate(WebEidAuthToken authToken, String currentChallengeNonce) throws AuthTokenException { try { LOG.info("Starting token validation"); return validateToken(authToken, currentChallengeNonce); @@ -126,49 +105,43 @@ private WebEidAuthToken parseToken(String authToken) throws AuthTokenParseExcept } } - private X509Certificate validateToken(WebEidAuthToken token, String currentChallengeNonce) throws AuthTokenException { - if (token.getFormat() == null || !token.getFormat().startsWith(CURRENT_TOKEN_FORMAT_VERSION)) { + private ValidationInfo validateToken(WebEidAuthToken token, String currentChallengeNonce) throws AuthTokenException { + if (token.format() == null || !token.format().startsWith(CURRENT_TOKEN_FORMAT_VERSION)) { throw new AuthTokenParseException("Only token format version '" + CURRENT_TOKEN_FORMAT_VERSION + "' is currently supported"); } - if (token.getUnverifiedCertificate() == null || token.getUnverifiedCertificate().isEmpty()) { + if (token.unverifiedCertificate() == null || token.unverifiedCertificate().isEmpty()) { throw new AuthTokenParseException("'unverifiedCertificate' field is missing, null or empty"); } - final X509Certificate subjectCertificate = CertificateLoader.decodeCertificateFromBase64(token.getUnverifiedCertificate()); - - simpleSubjectCertificateValidators.executeFor(subjectCertificate); - getCertTrustValidators().executeFor(subjectCertificate); + final X509Certificate subjectCertificate = CertificateLoader.decodeCertificateFromBase64(token.unverifiedCertificate()); + + SubjectCertificatePurposeValidator.validateCertificatePurpose(subjectCertificate); + subjectCertificatePolicyValidator.validateCertificatePolicies(subjectCertificate); + + // Use the clock instance so that the date can be mocked in tests. + final Date now = DateAndTime.DefaultClock.getInstance().now(); + + final List revocationInfoList = CertificateValidator.validateCertificateTrustAndRevocation( + subjectCertificate, + trustedCACertificateAnchors, + trustedCACertificateCertStore, + now, + configuration.getRevocationMode(), + configuration.getCertificateRevocationChecker(), + configuration.getPkixRevocationChecker(), + configuration.isPlatformOcspNonceEnabled() + ); + LOG.debug("Subject certificate is valid and signed by a trusted CA"); // It is guaranteed that if the signature verification succeeds, then the origin and challenge // have been implicitly and correctly verified without the need to implement any additional checks. - authTokenSignatureValidator.validate(token.getAlgorithm(), - token.getSignature(), + authTokenSignatureValidator.validate(token.algorithm(), + token.signature(), subjectCertificate.getPublicKey(), - currentChallengeNonce); - - return subjectCertificate; - } - - /** - * Creates the certificate trust validators batch. - * As SubjectCertificateTrustedValidator has mutable state that SubjectCertificateNotRevokedValidator depends on, - * they cannot be reused/cached in an instance variable in a multi-threaded environment. Hence, they are - * re-created for each validation run for thread safety. - * - * @return certificate trust validator batch - */ - private SubjectCertificateValidatorBatch getCertTrustValidators() { - final SubjectCertificateTrustedValidator certTrustedValidator = - new SubjectCertificateTrustedValidator(trustedCACertificateAnchors, trustedCACertificateCertStore); - return SubjectCertificateValidatorBatch.createFrom( - certTrustedValidator::validateCertificateTrusted - ).addOptional(configuration.isUserCertificateRevocationCheckWithOcspEnabled(), - new SubjectCertificateNotRevokedValidator(certTrustedValidator, - ocspClient, ocspServiceProvider, - configuration.getAllowedOcspResponseTimeSkew(), - configuration.getMaxOcspResponseThisUpdateAge() - )::validateCertificateNotRevoked + currentChallengeNonce ); + + return new ValidationInfo(subjectCertificate, revocationInfoList); } } diff --git a/src/main/java/eu/webeid/security/validator/ValidationInfo.java b/src/main/java/eu/webeid/security/validator/ValidationInfo.java new file mode 100644 index 00000000..a4a88105 --- /dev/null +++ b/src/main/java/eu/webeid/security/validator/ValidationInfo.java @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.security.validator; + +import eu.webeid.security.validator.revocationcheck.RevocationInfo; + +import java.security.cert.X509Certificate; +import java.util.List; + +import static java.util.Objects.requireNonNull; + +public record ValidationInfo(X509Certificate subjectCertificate, List revocationInfoList) { + public ValidationInfo { + requireNonNull(subjectCertificate, "subjectCertificate"); + revocationInfoList = List.copyOf(revocationInfoList); + } +} diff --git a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePolicyValidator.java b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePolicyValidator.java index a7521c96..43f2e8b2 100644 --- a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePolicyValidator.java +++ b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePolicyValidator.java @@ -7,7 +7,6 @@ import org.bouncycastle.asn1.ASN1ObjectIdentifier; import org.bouncycastle.asn1.x509.CertificatePolicies; import org.bouncycastle.asn1.x509.Extension; -import org.bouncycastle.asn1.x509.PolicyInformation; import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils; import eu.webeid.security.exceptions.UserCertificateDisallowedPolicyException; import eu.webeid.security.exceptions.UserCertificateParseException; @@ -15,10 +14,10 @@ import org.slf4j.LoggerFactory; import java.io.IOException; +import java.security.cert.CertificateParsingException; import java.security.cert.X509Certificate; import java.util.Arrays; import java.util.Collection; -import java.util.Optional; public final class SubjectCertificatePolicyValidator { @@ -31,26 +30,30 @@ public SubjectCertificatePolicyValidator(Collection disall } /** - * Validates that the user certificate policies match the configured policies. + * Requires a certificate policies extension and rejects any configured disallowed policy. * * @param subjectCertificate user certificate to be validated - * @throws UserCertificateDisallowedPolicyException when user certificate policy does not match the configured policies. - * @throws UserCertificateParseException when user certificate policy is invalid. + * @throws UserCertificateDisallowedPolicyException when a disallowed policy is present. + * @throws UserCertificateParseException when the certificate policies extension is missing or invalid. */ public void validateCertificatePolicies(X509Certificate subjectCertificate) throws AuthTokenException { final byte[] extensionValue = subjectCertificate.getExtensionValue(Extension.certificatePolicies.getId()); + if (extensionValue == null) { + throw new UserCertificateParseException(new CertificateParsingException("Certificate policies extension is missing")); + } try { final CertificatePolicies policies = CertificatePolicies.getInstance( JcaX509ExtensionUtils.parseExtensionValue(extensionValue) ); - final Optional disallowedPolicy = Arrays.stream(policies.getPolicyInformation()) - .filter(policyInformation -> - disallowedSubjectCertificatePolicies.contains(policyInformation.getPolicyIdentifier())) - .findFirst(); - if (disallowedPolicy.isPresent()) { + if (policies == null) { + throw new IllegalArgumentException("Certificate policies extension is empty"); + } + if (Arrays.stream(policies.getPolicyInformation()) + .anyMatch(policyInformation -> + disallowedSubjectCertificatePolicies.contains(policyInformation.getPolicyIdentifier()))) { throw new UserCertificateDisallowedPolicyException(); } - } catch (IOException e) { + } catch (IOException | IllegalArgumentException e) { throw new UserCertificateParseException(e); } LOG.debug("User certificate does not contain disallowed policies."); diff --git a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateTrustedValidator.java b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateTrustedValidator.java deleted file mode 100644 index 1a619d6a..00000000 --- a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateTrustedValidator.java +++ /dev/null @@ -1,59 +0,0 @@ -// SPDX-FileCopyrightText: Estonian Information System Authority -// SPDX-License-Identifier: MIT - -package eu.webeid.security.validator.certvalidators; - -import eu.webeid.security.certificate.CertificateValidator; -import eu.webeid.security.exceptions.AuthTokenException; -import eu.webeid.security.exceptions.CertificateExpiredException; -import eu.webeid.security.exceptions.CertificateNotTrustedException; -import eu.webeid.security.exceptions.CertificateNotYetValidException; -import eu.webeid.security.util.DateAndTime; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.security.cert.CertStore; -import java.security.cert.TrustAnchor; -import java.security.cert.X509Certificate; -import java.util.Date; -import java.util.Set; - -public final class SubjectCertificateTrustedValidator { - - private static final Logger LOG = LoggerFactory.getLogger(SubjectCertificateTrustedValidator.class); - - private final Set trustedCACertificateAnchors; - private final CertStore trustedCACertificateCertStore; - private X509Certificate subjectCertificateIssuerCertificate; - - public SubjectCertificateTrustedValidator(Set trustedCACertificateAnchors, CertStore trustedCACertificateCertStore) { - this.trustedCACertificateAnchors = trustedCACertificateAnchors; - this.trustedCACertificateCertStore = trustedCACertificateCertStore; - } - - /** - * Checks that the user certificate from the authentication token is valid and signed by - * a trusted certificate authority. Also checks the validity of the user certificate's - * trusted CA certificate. - * - * @param subjectCertificate user certificate to be validated - * @throws CertificateNotTrustedException when user certificate is not signed by a trusted CA - * @throws CertificateNotYetValidException when a CA certificate in the chain or the user certificate is not yet valid - * @throws CertificateExpiredException when a CA certificate in the chain or the user certificate is expired - */ - public void validateCertificateTrusted(X509Certificate subjectCertificate) throws AuthTokenException { - // Use the clock instance so that the date can be mocked in tests. - final Date now = DateAndTime.DefaultClock.getInstance().now(); - subjectCertificateIssuerCertificate = CertificateValidator.validateIsSignedByTrustedCA( - subjectCertificate, - trustedCACertificateAnchors, - trustedCACertificateCertStore, - now - ); - LOG.debug("Subject certificate is valid and signed by a trusted CA"); - } - - public X509Certificate getSubjectCertificateIssuerCertificate() { - return subjectCertificateIssuerCertificate; - } -} diff --git a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateValidator.java b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateValidator.java deleted file mode 100644 index e6e5f2e0..00000000 --- a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateValidator.java +++ /dev/null @@ -1,18 +0,0 @@ -// SPDX-FileCopyrightText: Estonian Information System Authority -// SPDX-License-Identifier: MIT - -package eu.webeid.security.validator.certvalidators; - -import eu.webeid.security.exceptions.AuthTokenException; - -import java.security.cert.X509Certificate; - -/** - * Validators perform the actual user certificate validation actions. - *

- * They are used by AuthTokenValidatorImpl and are not part of the public API. - */ -@FunctionalInterface -public interface SubjectCertificateValidator { - void validate(X509Certificate subjectCertificate) throws AuthTokenException; -} diff --git a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateValidatorBatch.java b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateValidatorBatch.java deleted file mode 100644 index 5e50ecce..00000000 --- a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateValidatorBatch.java +++ /dev/null @@ -1,39 +0,0 @@ -// SPDX-FileCopyrightText: Estonian Information System Authority -// SPDX-License-Identifier: MIT - -package eu.webeid.security.validator.certvalidators; - -import eu.webeid.security.exceptions.AuthTokenException; - -import java.security.cert.X509Certificate; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -public final class SubjectCertificateValidatorBatch { - - private final List validatorList; - - public static SubjectCertificateValidatorBatch createFrom(SubjectCertificateValidator... validatorList) { - final List list = new ArrayList<>(); - Collections.addAll(list, validatorList); - return new SubjectCertificateValidatorBatch(list); - } - - public void executeFor(X509Certificate subjectCertificate) throws AuthTokenException { - for (final SubjectCertificateValidator validator : validatorList) { - validator.validate(subjectCertificate); - } - } - - public SubjectCertificateValidatorBatch addOptional(boolean condition, SubjectCertificateValidator optionalValidator) { - if (condition) { - validatorList.add(optionalValidator); - } - return this; - } - - private SubjectCertificateValidatorBatch(List validatorList) { - this.validatorList = validatorList; - } -} diff --git a/src/main/java/eu/webeid/security/validator/revocationcheck/CertificateRevocationChecker.java b/src/main/java/eu/webeid/security/validator/revocationcheck/CertificateRevocationChecker.java new file mode 100644 index 00000000..a3e8ac9d --- /dev/null +++ b/src/main/java/eu/webeid/security/validator/revocationcheck/CertificateRevocationChecker.java @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT +package eu.webeid.security.validator.revocationcheck; + +import eu.webeid.security.exceptions.AuthTokenException; + +import java.security.cert.X509Certificate; +import java.util.List; + +public interface CertificateRevocationChecker { + + List validateCertificateNotRevoked(X509Certificate subjectCertificate, + X509Certificate issuerCertificate) throws AuthTokenException; + +} \ No newline at end of file diff --git a/src/main/java/eu/webeid/security/validator/revocationcheck/RevocationInfo.java b/src/main/java/eu/webeid/security/validator/revocationcheck/RevocationInfo.java new file mode 100644 index 00000000..f2b7265a --- /dev/null +++ b/src/main/java/eu/webeid/security/validator/revocationcheck/RevocationInfo.java @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT +package eu.webeid.security.validator.revocationcheck; + +import java.net.URI; +import java.util.Map; + +public record RevocationInfo(URI ocspResponderUri, Map ocspResponseAttributes) { + + public static final String KEY_OCSP_RESPONSE = "OCSP_RESPONSE"; + public static final String KEY_OCSP_ERROR = "OCSP_ERROR"; + +} \ No newline at end of file diff --git a/src/main/java/eu/webeid/security/validator/revocationcheck/RevocationMode.java b/src/main/java/eu/webeid/security/validator/revocationcheck/RevocationMode.java new file mode 100644 index 00000000..dbf30992 --- /dev/null +++ b/src/main/java/eu/webeid/security/validator/revocationcheck/RevocationMode.java @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.security.validator.revocationcheck; + +public enum RevocationMode { + PLATFORM_OCSP, CUSTOM_CHECKER, CUSTOM_PKIX, DISABLED +} diff --git a/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidatorTest.java b/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java similarity index 67% rename from src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidatorTest.java rename to src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java index f2d46162..92157ed3 100644 --- a/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidatorTest.java +++ b/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java @@ -1,17 +1,20 @@ // SPDX-FileCopyrightText: Estonian Information System Authority // SPDX-License-Identifier: MIT -package eu.webeid.security.validator.certvalidators; +package eu.webeid.ocsp; import eu.webeid.security.exceptions.CertificateExpiredException; import eu.webeid.security.exceptions.CertificateNotTrustedException; import eu.webeid.security.exceptions.JceException; -import eu.webeid.security.exceptions.UserCertificateOCSPCheckFailedException; -import eu.webeid.security.exceptions.UserCertificateRevokedException; +import eu.webeid.ocsp.exceptions.UserCertificateOCSPCheckFailedException; +import eu.webeid.ocsp.exceptions.UserCertificateRevokedException; +import eu.webeid.security.testutil.AbstractTestWithValidator; +import eu.webeid.security.testutil.AuthTokenValidators; import eu.webeid.security.util.DateAndTime; -import eu.webeid.security.validator.ocsp.OcspClient; -import eu.webeid.security.validator.ocsp.OcspClientImpl; -import eu.webeid.security.validator.ocsp.OcspServiceProvider; +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; @@ -22,7 +25,6 @@ import java.io.IOException; import java.io.InputStream; -import java.lang.reflect.Field; import java.net.ConnectException; import java.net.URI; import java.net.URISyntaxException; @@ -38,33 +40,39 @@ 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 eu.webeid.security.testutil.OcspServiceMaker.getAiaOcspServiceProvider; -import static eu.webeid.security.testutil.OcspServiceMaker.getDesignatedOcspServiceProvider; -import static eu.webeid.security.validator.AuthTokenValidatorBuilderTest.CONFIGURATION; +import static eu.webeid.ocsp.service.OcspServiceMaker.getAiaOcspServiceProvider; +import static eu.webeid.ocsp.service.OcspServiceMaker.getDesignatedOcspServiceProvider; 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.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.when; -class SubjectCertificateNotRevokedValidatorTest { +class OcspCertificateRevocationCheckerTest extends AbstractTestWithValidator { private final OcspClient ocspClient = OcspClientImpl.build(Duration.ofSeconds(5)); - private SubjectCertificateTrustedValidator trustedValidator; private X509Certificate estEid2018Cert; + private X509Certificate testEsteid2018CA; @BeforeEach void setUp() throws Exception { - trustedValidator = new SubjectCertificateTrustedValidator(null, null); - setSubjectCertificateIssuerCertificate(trustedValidator); estEid2018Cert = getJaakKristjanEsteid2018Cert(); + testEsteid2018CA = getTestEsteid2018CA(); + } + + @Test + void whenCustomOcspCheckerWithDefaultConfigurationIsConfigured_thenValidationSucceeds() throws Exception { + final AuthTokenValidator validator = getAuthTokenValidatorWithOcspCertificateRevocationChecker(); + assertThatCode(() -> validator.validate(validAuthToken, VALID_CHALLENGE_NONCE)) + .doesNotThrowAnyException(); } @Test void whenValidAiaOcspResponderConfiguration_thenSucceeds() throws Exception { - final SubjectCertificateNotRevokedValidator validator = getSubjectCertificateNotRevokedValidator(ocspClient, getAiaOcspServiceProvider()); + final OcspCertificateRevocationChecker validator = getOcspCertificateRevocationChecker(ocspClient, getAiaOcspServiceProvider()); assertThatCode(() -> - validator.validateCertificateNotRevoked(estEid2018Cert)) + validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) .doesNotThrowAnyException(); } @@ -72,9 +80,9 @@ void whenValidAiaOcspResponderConfiguration_thenSucceeds() throws Exception { @Disabled("As new designated test OCSP responder certificates are issued more frequently now, it is no longer feasible to keep the certificates up to date") void whenValidDesignatedOcspResponderConfiguration_thenSucceeds() throws Exception { final OcspServiceProvider ocspServiceProvider = getDesignatedOcspServiceProvider(); - final SubjectCertificateNotRevokedValidator validator = getSubjectCertificateNotRevokedValidator(ocspServiceProvider); + final OcspCertificateRevocationChecker validator = getOcspCertificateRevocationChecker(ocspServiceProvider); assertThatCode(() -> - validator.validateCertificateNotRevoked(estEid2018Cert)) + validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) .doesNotThrowAnyException(); } @@ -82,18 +90,18 @@ void whenValidDesignatedOcspResponderConfiguration_thenSucceeds() throws Excepti @Disabled("As new designated test OCSP responder certificates are issued more frequently now, it is no longer feasible to keep the certificates up to date") void whenValidOcspNonceDisabledConfiguration_thenSucceeds() throws Exception { final OcspServiceProvider ocspServiceProvider = getDesignatedOcspServiceProvider(false); - final SubjectCertificateNotRevokedValidator validator = getSubjectCertificateNotRevokedValidator(ocspServiceProvider); + final OcspCertificateRevocationChecker validator = getOcspCertificateRevocationChecker(ocspServiceProvider); assertThatCode(() -> - validator.validateCertificateNotRevoked(estEid2018Cert)) + validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) .doesNotThrowAnyException(); } @Test void whenOcspUrlIsInvalid_thenThrows() throws Exception { final OcspServiceProvider ocspServiceProvider = getDesignatedOcspServiceProvider("http://invalid.invalid"); - final SubjectCertificateNotRevokedValidator validator = getSubjectCertificateNotRevokedValidator(ocspServiceProvider); + final OcspCertificateRevocationChecker validator = getOcspCertificateRevocationChecker(ocspServiceProvider); assertThatCode(() -> - validator.validateCertificateNotRevoked(estEid2018Cert)) + validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) .isInstanceOf(UserCertificateOCSPCheckFailedException.class) .cause() .isInstanceOf(ConnectException.class); @@ -102,9 +110,9 @@ void whenOcspUrlIsInvalid_thenThrows() throws Exception { @Test void whenOcspRequestFails_thenThrows() throws Exception { final OcspServiceProvider ocspServiceProvider = getDesignatedOcspServiceProvider("http://demo.sk.ee/ocsps"); - final SubjectCertificateNotRevokedValidator validator = getSubjectCertificateNotRevokedValidator(ocspServiceProvider); + final OcspCertificateRevocationChecker validator = getOcspCertificateRevocationChecker(ocspServiceProvider); assertThatCode(() -> - validator.validateCertificateNotRevoked(estEid2018Cert)) + validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) .isInstanceOf(UserCertificateOCSPCheckFailedException.class) .cause() .isInstanceOf(IOException.class) @@ -113,11 +121,11 @@ void whenOcspRequestFails_thenThrows() throws Exception { @Test void whenOcspRequestHasInvalidBody_thenThrows() throws Exception { - final SubjectCertificateNotRevokedValidator validator = getSubjectCertificateNotRevokedValidatorWithAiaOcsp( + final OcspCertificateRevocationChecker validator = getOcspCertificateRevocationCheckerWithAiaOcsp( getMockedResponse("invalid".getBytes()) ); assertThatCode(() -> - validator.validateCertificateNotRevoked(estEid2018Cert)) + validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) .isInstanceOf(UserCertificateOCSPCheckFailedException.class) .cause() .isExactlyInstanceOf(CertIOException.class); @@ -125,44 +133,44 @@ void whenOcspRequestHasInvalidBody_thenThrows() throws Exception { @Test void whenOcspResponseIsNotSuccessful_thenThrows() throws Exception { - final SubjectCertificateNotRevokedValidator validator = getSubjectCertificateNotRevokedValidatorWithAiaOcsp( + final OcspCertificateRevocationChecker validator = getOcspCertificateRevocationCheckerWithAiaOcsp( getMockedResponse(buildOcspResponseBodyWithInternalErrorStatus()) ); assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) .isThrownBy(() -> - validator.validateCertificateNotRevoked(estEid2018Cert)) - .withMessage("User certificate revocation check has failed: Response status: internal error"); + validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .withMessage("User certificate revocation check has failed: Response status: internal error (OCSP responder: http://aia.demo.sk.ee/esteid2018)"); } @Test void whenOcspResponseHasInvalidCertificateId_thenThrows() throws Exception { - final SubjectCertificateNotRevokedValidator validator = getSubjectCertificateNotRevokedValidatorWithAiaOcsp( + final OcspCertificateRevocationChecker validator = getOcspCertificateRevocationCheckerWithAiaOcsp( getMockedResponse(buildOcspResponseBodyWithInvalidCertificateId()) ); assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) .isThrownBy(() -> - validator.validateCertificateNotRevoked(estEid2018Cert)) - .withMessage("User certificate revocation check has failed: OCSP responded with certificate ID that differs from the requested ID"); + validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .withMessage("User certificate revocation check has failed: OCSP responded with certificate ID that differs from the requested ID (OCSP responder: http://aia.demo.sk.ee/esteid2018)"); } @Test void whenOcspResponseHasInvalidSignature_thenThrows() throws Exception { - final SubjectCertificateNotRevokedValidator validator = getSubjectCertificateNotRevokedValidatorWithAiaOcsp( + final OcspCertificateRevocationChecker validator = getOcspCertificateRevocationCheckerWithAiaOcsp( getMockedResponse(buildOcspResponseBodyWithInvalidSignature()) ); assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) .isThrownBy(() -> - validator.validateCertificateNotRevoked(estEid2018Cert)) - .withMessage("User certificate revocation check has failed: OCSP response signature is invalid"); + validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .withMessage("User certificate revocation check has failed: OCSP response signature is invalid (OCSP responder: http://aia.demo.sk.ee/esteid2018)"); } @Test void whenOcspResponseHasInvalidResponderCert_thenThrows() throws Exception { - final SubjectCertificateNotRevokedValidator validator = getSubjectCertificateNotRevokedValidatorWithAiaOcsp( + final OcspCertificateRevocationChecker validator = getOcspCertificateRevocationCheckerWithAiaOcsp( getMockedResponse(buildOcspResponseBodyWithInvalidResponderCert()) ); assertThatCode(() -> - validator.validateCertificateNotRevoked(estEid2018Cert)) + validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) .isInstanceOf(UserCertificateOCSPCheckFailedException.class) .cause() .isExactlyInstanceOf(OCSPException.class); @@ -170,11 +178,11 @@ void whenOcspResponseHasInvalidResponderCert_thenThrows() throws Exception { @Test void whenOcspResponseHasInvalidTag_thenThrows() throws Exception { - final SubjectCertificateNotRevokedValidator validator = getSubjectCertificateNotRevokedValidatorWithAiaOcsp( + final OcspCertificateRevocationChecker validator = getOcspCertificateRevocationCheckerWithAiaOcsp( getMockedResponse(buildOcspResponseBodyWithInvalidTag()) ); assertThatCode(() -> - validator.validateCertificateNotRevoked(estEid2018Cert)) + validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) .isInstanceOf(UserCertificateOCSPCheckFailedException.class) .cause() .isInstanceOf(OCSPException.class) @@ -183,37 +191,37 @@ void whenOcspResponseHasInvalidTag_thenThrows() throws Exception { @Test void whenOcspResponseHas2CertResponses_thenThrows() throws Exception { - final SubjectCertificateNotRevokedValidator validator = getSubjectCertificateNotRevokedValidatorWithAiaOcsp( + final OcspCertificateRevocationChecker validator = getOcspCertificateRevocationCheckerWithAiaOcsp( getMockedResponse(getOcspResponseBytesFromResources("ocsp_response_with_2_responses.der")) ); assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) .isThrownBy(() -> - validator.validateCertificateNotRevoked(estEid2018Cert)) - .withMessage("User certificate revocation check has failed: OCSP response must contain one response, received 2 responses instead"); + validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .withMessage("User certificate revocation check has failed: OCSP response must contain one response, received 2 responses instead (OCSP responder: http://aia.demo.sk.ee/esteid2018)"); } @Disabled("It is difficult to make Python and Java CertId equal, needs more work") void whenOcspResponseHas2ResponderCerts_thenThrows() throws Exception { - final SubjectCertificateNotRevokedValidator validator = getSubjectCertificateNotRevokedValidatorWithAiaOcsp( + final OcspCertificateRevocationChecker validator = getOcspCertificateRevocationCheckerWithAiaOcsp( getMockedResponse(getOcspResponseBytesFromResources("ocsp_response_with_2_responder_certs.der")) ); assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) .isThrownBy(() -> - validator.validateCertificateNotRevoked(estEid2018Cert)) + validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) .withMessage("User certificate revocation check has failed: OCSP response must contain one responder certificate, received 2 certificates instead"); } @Test void whenOcspResponseRevoked_thenThrows() throws Exception { - final SubjectCertificateNotRevokedValidator validator = getSubjectCertificateNotRevokedValidatorWithAiaOcsp( + final OcspCertificateRevocationChecker validator = getOcspCertificateRevocationCheckerWithAiaOcsp( getMockedResponse(getOcspResponseBytesFromResources("ocsp_response_revoked.der")) ); try (var mockedClock = mockStatic(DateAndTime.DefaultClock.class)) { mockDate("2021-09-18", mockedClock); assertThatExceptionOfType(UserCertificateRevokedException.class) .isThrownBy(() -> - validator.validateCertificateNotRevoked(estEid2018Cert)) - .withMessage("User certificate has been revoked: Revocation reason: 0"); + validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .withMessage("User certificate has been revoked: Revocation reason: 0 (OCSP responder: http://aia.demo.sk.ee/esteid2018)"); } } @@ -221,55 +229,79 @@ void whenOcspResponseRevoked_thenThrows() throws Exception { 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 SubjectCertificateNotRevokedValidator validator = getSubjectCertificateNotRevokedValidator(getMockClient(response), ocspServiceProvider); + final OcspCertificateRevocationChecker validator = getOcspCertificateRevocationChecker(getMockClient(response), ocspServiceProvider); try (var mockedClock = mockStatic(DateAndTime.DefaultClock.class)) { mockDate("2021-09-18T00:16:25", mockedClock); - assertThatExceptionOfType(UserCertificateRevokedException.class) + assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) .isThrownBy(() -> - validator.validateCertificateNotRevoked(estEid2018Cert)) - .withMessage("User certificate has been revoked: Unknown status"); + validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .withMessage("User certificate revocation check has failed: Unknown status (OCSP responder: https://web-eid-test.free.beeceptor.com)"); } } @Test void whenOcspResponseCACertNotTrusted_thenThrows() throws Exception { - final SubjectCertificateNotRevokedValidator validator = getSubjectCertificateNotRevokedValidatorWithAiaOcsp( + final OcspCertificateRevocationChecker validator = getOcspCertificateRevocationCheckerWithAiaOcsp( getMockedResponse(getOcspResponseBytesFromResources("ocsp_response_unknown.der")) ); try (var mockedClock = mockStatic(DateAndTime.DefaultClock.class)) { mockDate("2021-09-18T00:16:25", mockedClock); assertThatExceptionOfType(CertificateNotTrustedException.class) .isThrownBy(() -> - validator.validateCertificateNotRevoked(estEid2018Cert)) + validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) .withMessage("Certificate EMAILADDRESS=pki@sk.ee, CN=TEST of SK OCSP RESPONDER 2020, OU=OCSP, O=AS Sertifitseerimiskeskus, C=EE is not trusted"); } } @Test void whenOcspResponseCACertExpired_thenThrows() throws Exception { - final SubjectCertificateNotRevokedValidator validator = getSubjectCertificateNotRevokedValidatorWithAiaOcsp( + final OcspCertificateRevocationChecker validator = getOcspCertificateRevocationCheckerWithAiaOcsp( getMockedResponse(getOcspResponseBytesFromResources("ocsp_response_unknown.der")) ); assertThatExceptionOfType(CertificateExpiredException.class) .isThrownBy(() -> - validator.validateCertificateNotRevoked(estEid2018Cert)) + validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) .withMessage("AIA OCSP responder certificate has expired"); } @Test void whenNonceDiffers_thenThrows() throws Exception { - final SubjectCertificateNotRevokedValidator validator = getSubjectCertificateNotRevokedValidatorWithAiaOcsp( + final OcspCertificateRevocationChecker validator = getOcspCertificateRevocationCheckerWithAiaOcsp( getMockedResponse(getOcspResponseBytesFromResources()) ); try (var mockedClock = mockStatic(DateAndTime.DefaultClock.class)) { mockDate("2021-09-17T18:25:24", mockedClock); assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) .isThrownBy(() -> - validator.validateCertificateNotRevoked(estEid2018Cert)) - .withMessage("User certificate revocation check has failed: OCSP request and response nonces differ, possible replay attack"); + validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .withMessage("User certificate revocation check has failed: OCSP request and response nonces differ, possible replay attack (OCSP responder: http://aia.demo.sk.ee/esteid2018)"); } } + @Test + void whenInvalidOcspResponseTimeSkew_thenThrows() { + assertThatThrownBy(() -> getOcspCertificateRevocationCheckerWithTimeSkewAndUpdateAge(Duration.ofMinutes(-1), 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 + )).build(); + } + private static byte[] buildOcspResponseBodyWithInternalErrorStatus() throws IOException { final byte[] ocspResponseBytes = getOcspResponseBytesFromResources(); final int STATUS_OFFSET = 6; @@ -318,22 +350,20 @@ private static byte[] getOcspResponseBytesFromResources(String resource) throws } } - private SubjectCertificateNotRevokedValidator getSubjectCertificateNotRevokedValidatorWithAiaOcsp(HttpResponse response) throws JceException { - return getSubjectCertificateNotRevokedValidator(getMockClient(response), getAiaOcspServiceProvider()); + private OcspCertificateRevocationChecker getOcspCertificateRevocationCheckerWithAiaOcsp(HttpResponse response) throws JceException { + return getOcspCertificateRevocationChecker(getMockClient(response), getAiaOcspServiceProvider()); } - private SubjectCertificateNotRevokedValidator getSubjectCertificateNotRevokedValidator(OcspServiceProvider ocspServiceProvider) { - return getSubjectCertificateNotRevokedValidator(ocspClient, ocspServiceProvider); + private OcspCertificateRevocationChecker getOcspCertificateRevocationChecker(OcspServiceProvider ocspServiceProvider) { + return getOcspCertificateRevocationChecker(ocspClient, ocspServiceProvider); } - private SubjectCertificateNotRevokedValidator getSubjectCertificateNotRevokedValidator(OcspClient client, OcspServiceProvider ocspServiceProvider) { - return new SubjectCertificateNotRevokedValidator(trustedValidator, client, ocspServiceProvider, CONFIGURATION.getAllowedOcspResponseTimeSkew(), CONFIGURATION.getMaxOcspResponseThisUpdateAge()); + private OcspCertificateRevocationChecker getOcspCertificateRevocationChecker(OcspClient client, OcspServiceProvider ocspServiceProvider) { + return new OcspCertificateRevocationChecker(client, ocspServiceProvider, OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW, OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE); } - private static void setSubjectCertificateIssuerCertificate(SubjectCertificateTrustedValidator trustedValidator) throws NoSuchFieldException, IllegalAccessException, CertificateException, IOException { - final Field field = trustedValidator.getClass().getDeclaredField("subjectCertificateIssuerCertificate"); - field.setAccessible(true); - field.set(trustedValidator, getTestEsteid2018CA()); + private void getOcspCertificateRevocationCheckerWithTimeSkewAndUpdateAge(Duration timeSkew, Duration updateAge) throws JceException { + new OcspCertificateRevocationChecker(ocspClient, getAiaOcspServiceProvider(), timeSkew, updateAge); } private HttpResponse getMockedResponse(byte[] bodyContent) throws URISyntaxException { diff --git a/src/test/java/eu/webeid/security/validator/ocsp/OcspClientOverrideTest.java b/src/test/java/eu/webeid/ocsp/client/OcspClientOverrideTest.java similarity index 56% rename from src/test/java/eu/webeid/security/validator/ocsp/OcspClientOverrideTest.java rename to src/test/java/eu/webeid/ocsp/client/OcspClientOverrideTest.java index 40a1ccde..06e1fc18 100644 --- a/src/test/java/eu/webeid/security/validator/ocsp/OcspClientOverrideTest.java +++ b/src/test/java/eu/webeid/ocsp/client/OcspClientOverrideTest.java @@ -1,20 +1,16 @@ // SPDX-FileCopyrightText: Estonian Information System Authority // SPDX-License-Identifier: MIT -package eu.webeid.security.validator.ocsp; +package eu.webeid.ocsp.client; +import eu.webeid.ocsp.OcspCertificateRevocationChecker; import eu.webeid.security.exceptions.JceException; import eu.webeid.security.testutil.AbstractTestWithValidator; import eu.webeid.security.testutil.AuthTokenValidators; -import eu.webeid.security.util.DateAndTime; import eu.webeid.security.validator.AuthTokenValidator; import org.bouncycastle.cert.ocsp.OCSPReq; import org.bouncycastle.cert.ocsp.OCSPResp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; -import org.mockito.MockedStatic; import java.io.IOException; import java.net.URI; @@ -22,46 +18,48 @@ import java.security.cert.CertificateException; import java.time.Duration; -import static eu.webeid.security.testutil.DateMocker.mockDate; +import static eu.webeid.ocsp.service.OcspServiceMaker.getAiaOcspServiceProvider; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.mockStatic; class OcspClientOverrideTest extends AbstractTestWithValidator { - private MockedStatic mockedClock; - - @Override - @BeforeEach - protected void setup() { - super.setup(); - mockedClock = mockStatic(DateAndTime.DefaultClock.class); - // Ensure that the certificates do not expire. - mockDate("2021-07-23", mockedClock); - } - - @AfterEach - void tearDown() { - mockedClock.close(); - } - @Test void whenOcspClientIsOverridden_thenItIsUsed() throws JceException, CertificateException, IOException { - final AuthTokenValidator validator = AuthTokenValidators.getAuthTokenValidatorWithOverriddenOcspClient(new OcpClientThatThrows()); + final AuthTokenValidator validator = getAuthTokenValidatorWithOverriddenOcspClient(new OcpClientThatThrows()); assertThatThrownBy(() -> validator.validate(validAuthToken, VALID_CHALLENGE_NONCE)) .cause() .isInstanceOf(OcpClientThatThrowsException.class); } @Test - @Disabled("Demonstrates how to configure the built-in HttpClient instance for OcspClientImpl") + void whenInvalidOcspRequestTimeout_thenThrows() { + assertThatThrownBy(() -> OcspClientImpl.build(Duration.ofMinutes(-1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("ocspRequestTimeout must be greater than zero"); + } + + /** + * Demonstrates how to configure the built-in HttpClient instance for OcspClientImpl. + */ + @Test void whenOcspClientIsConfiguredWithCustomHttpClient_thenOcspCallSucceeds() throws JceException, CertificateException, IOException { - final AuthTokenValidator validator = AuthTokenValidators.getAuthTokenValidatorWithOverriddenOcspClient( + final AuthTokenValidator validator = getAuthTokenValidatorWithOverriddenOcspClient( new OcspClientImpl(HttpClient.newBuilder().build(), Duration.ofSeconds(5)) ); assertThatCode(() -> validator.validate(validAuthToken, VALID_CHALLENGE_NONCE)) .doesNotThrowAnyException(); } + private static AuthTokenValidator getAuthTokenValidatorWithOverriddenOcspClient(OcspClient ocspClient) throws CertificateException, JceException, IOException { + return AuthTokenValidators.getDefaultAuthTokenValidatorBuilder() + .withCertificateRevocationChecker(new OcspCertificateRevocationChecker( + ocspClient, + getAiaOcspServiceProvider(), + OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW, + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE + )).build(); + } + private static class OcpClientThatThrows implements OcspClient { @Override public OCSPResp request(URI url, OCSPReq request) throws IOException { diff --git a/src/test/java/eu/webeid/ocsp/protocol/OcspNonceFactoryTest.java b/src/test/java/eu/webeid/ocsp/protocol/OcspNonceFactoryTest.java new file mode 100644 index 00000000..49868058 --- /dev/null +++ b/src/test/java/eu/webeid/ocsp/protocol/OcspNonceFactoryTest.java @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.ocsp.protocol; + +import org.bouncycastle.asn1.ASN1OctetString; +import org.bouncycastle.asn1.x509.Extension; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class OcspNonceFactoryTest { + + @Test + void whenNonceExtensionIsCreated_thenItEncodesANonCritical32ByteNonce() throws Exception { + final Extension decodedExtension = Extension.getInstance(OcspNonceFactory.create().getEncoded()); + + assertThat(decodedExtension.getExtnId().getId()).isEqualTo("1.3.6.1.5.5.7.48.1.2"); + assertThat(decodedExtension.isCritical()).isFalse(); + assertThat(ASN1OctetString.getInstance(decodedExtension.getParsedValue()).getOctets()).hasSize(32); + } + + @Test + void whenExtensionsAreCreated_thenNoncesAreFreshAnd32BytesLong() throws Exception { + final byte[] firstNonce = ASN1OctetString.getInstance( + OcspNonceFactory.create().getParsedValue()).getOctets(); + final byte[] secondNonce = ASN1OctetString.getInstance( + OcspNonceFactory.create().getParsedValue()).getOctets(); + + assertThat(firstNonce).hasSize(32); + assertThat(secondNonce).hasSize(32).isNotEqualTo(firstNonce); + } +} diff --git a/src/test/java/eu/webeid/security/validator/ocsp/OcspResponseValidatorTest.java b/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java similarity index 84% rename from src/test/java/eu/webeid/security/validator/ocsp/OcspResponseValidatorTest.java rename to src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java index db66ada5..01f5daed 100644 --- a/src/test/java/eu/webeid/security/validator/ocsp/OcspResponseValidatorTest.java +++ b/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java @@ -1,19 +1,20 @@ // SPDX-FileCopyrightText: Estonian Information System Authority // SPDX-License-Identifier: MIT -package eu.webeid.security.validator.ocsp; +package eu.webeid.ocsp.protocol; -import eu.webeid.security.exceptions.UserCertificateOCSPCheckFailedException; +import eu.webeid.ocsp.OcspCertificateRevocationChecker; +import eu.webeid.ocsp.exceptions.UserCertificateOCSPCheckFailedException; import org.bouncycastle.cert.ocsp.SingleResp; import org.junit.jupiter.api.Test; +import java.net.URI; import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Date; -import static eu.webeid.security.validator.AuthTokenValidatorBuilderTest.CONFIGURATION; -import static eu.webeid.security.validator.ocsp.OcspResponseValidator.validateCertificateStatusUpdateTime; +import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateCertificateStatusUpdateTime; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.mockito.Mockito.mock; @@ -21,8 +22,9 @@ class OcspResponseValidatorTest { - private static final Duration TIME_SKEW = CONFIGURATION.getAllowedOcspResponseTimeSkew(); - private static final Duration THIS_UPDATE_AGE = CONFIGURATION.getMaxOcspResponseThisUpdateAge(); + 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 URI OCSP_URL = URI.create("https://example.org"); @Test void whenThisAndNextUpdateWithinSkew_thenValidationSucceeds() { @@ -32,7 +34,7 @@ void whenThisAndNextUpdateWithinSkew_thenValidationSucceeds() { var nextUpdateWithinAgeLimit = Date.from(now.minus(THIS_UPDATE_AGE.minusSeconds(2))); when(mockResponse.getThisUpdate()).thenReturn(thisUpdateWithinAgeLimit); when(mockResponse.getNextUpdate()).thenReturn(nextUpdateWithinAgeLimit); - assertThatCode(() -> validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE)) + assertThatCode(() -> validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, OCSP_URL)) .doesNotThrowAnyException(); } @@ -46,7 +48,7 @@ void whenNextUpdateBeforeThisUpdate_thenThrows() { when(mockResponse.getNextUpdate()).thenReturn(beforeThisUpdate); assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) .isThrownBy(() -> - validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE)) + validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_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() + "'"); @@ -60,7 +62,7 @@ void whenThisUpdateHalfHourBeforeNow_thenThrows() { when(mockResponse.getThisUpdate()).thenReturn(halfHourBeforeNow); assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) .isThrownBy(() -> - validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE)) + validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_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: "); @@ -74,7 +76,7 @@ void whenThisUpdateHalfHourAfterNow_thenThrows() { when(mockResponse.getThisUpdate()).thenReturn(halfHourAfterNow); assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) .isThrownBy(() -> - validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE)) + validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_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: "); @@ -90,10 +92,11 @@ void whenNextUpdateHalfHourBeforeNow_thenThrows() { when(mockResponse.getNextUpdate()).thenReturn(halfHourBeforeNow); assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) .isThrownBy(() -> - validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE)) + validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, OCSP_URL)) .withMessage("User certificate revocation check has failed: " + "Certificate status update time check failed: " - + "nextUpdate '" + halfHourBeforeNow.toInstant() + "' is in the past"); + + "nextUpdate '" + halfHourBeforeNow.toInstant() + "' is in the past" + + " (OCSP responder: https://example.org)"); } private static Date getThisUpdateWithinAgeLimit(Instant now) { diff --git a/src/test/java/eu/webeid/security/validator/ocsp/OcspUrlTest.java b/src/test/java/eu/webeid/ocsp/protocol/OcspUrlTest.java similarity index 93% rename from src/test/java/eu/webeid/security/validator/ocsp/OcspUrlTest.java rename to src/test/java/eu/webeid/ocsp/protocol/OcspUrlTest.java index 0ceca574..12314dc4 100644 --- a/src/test/java/eu/webeid/security/validator/ocsp/OcspUrlTest.java +++ b/src/test/java/eu/webeid/ocsp/protocol/OcspUrlTest.java @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Estonian Information System Authority // SPDX-License-Identifier: MIT -package eu.webeid.security.validator.ocsp; +package eu.webeid.ocsp.protocol; import org.junit.jupiter.api.Test; @@ -11,7 +11,7 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import static eu.webeid.security.validator.ocsp.OcspUrl.getOcspUri; +import static eu.webeid.ocsp.protocol.OcspUrl.getOcspUri; class OcspUrlTest { diff --git a/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceConfigurationTest.java b/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceConfigurationTest.java new file mode 100644 index 00000000..cd939d3a --- /dev/null +++ b/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceConfigurationTest.java @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.ocsp.service; + +import eu.webeid.security.certificate.CertificateValidator; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.security.cert.TrustAnchor; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static eu.webeid.security.testutil.Certificates.getTestEsteid2018CA; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class AiaOcspServiceConfigurationTest { + + @Test + void whenCallerMutatesCollections_thenConfigurationRemainsUnchanged() throws Exception { + final URI responder = URI.create("http://ocsp.example"); + final Set nonceDisabledUrls = new HashSet<>(Set.of(responder)); + final TrustAnchor anchor = new TrustAnchor(getTestEsteid2018CA(), null); + final Set anchors = new HashSet<>(Set.of(anchor)); + final AiaOcspServiceConfiguration configuration = new AiaOcspServiceConfiguration( + nonceDisabledUrls, anchors, CertificateValidator.buildCertStoreFromCertificates(List.of(getTestEsteid2018CA()))); + + nonceDisabledUrls.clear(); + anchors.clear(); + + assertThat(configuration.getNonceDisabledOcspUrls()).containsExactly(responder); + assertThat(configuration.getTrustedCACertificateAnchors()).containsExactly(anchor); + assertThatThrownBy(() -> configuration.getNonceDisabledOcspUrls().clear()).isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> configuration.getTrustedCACertificateAnchors().clear()).isInstanceOf(UnsupportedOperationException.class); + } +} diff --git a/src/test/java/eu/webeid/security/testutil/OcspServiceMaker.java b/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java similarity index 86% rename from src/test/java/eu/webeid/security/testutil/OcspServiceMaker.java rename to src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java index c8cf1120..d7b6af1c 100644 --- a/src/test/java/eu/webeid/security/testutil/OcspServiceMaker.java +++ b/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java @@ -1,26 +1,22 @@ // SPDX-FileCopyrightText: Estonian Information System Authority // SPDX-License-Identifier: MIT -package eu.webeid.security.testutil; +package eu.webeid.ocsp.service; import eu.webeid.security.certificate.CertificateValidator; import eu.webeid.security.exceptions.JceException; -import eu.webeid.security.exceptions.OCSPCertificateException; -import eu.webeid.security.validator.ocsp.OcspServiceProvider; -import eu.webeid.security.validator.ocsp.service.AiaOcspServiceConfiguration; -import eu.webeid.security.validator.ocsp.service.DesignatedOcspServiceConfiguration; +import eu.webeid.ocsp.exceptions.OCSPCertificateException; import java.io.IOException; import java.net.URI; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; -import java.util.Arrays; 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.util.Collections.newHashSet; public class OcspServiceMaker { @@ -30,7 +26,7 @@ public class OcspServiceMaker { static { try { - TRUSTED_CA_CERTIFICATES = Arrays.asList(getTestEsteid2018CA(), getTestEsteid2015CA()); + TRUSTED_CA_CERTIFICATES = List.of(getTestEsteid2018CA(), getTestEsteid2015CA()); } catch (CertificateException | IOException e) { throw new ExceptionInInitializerError(e); } @@ -54,7 +50,7 @@ public static OcspServiceProvider getDesignatedOcspServiceProvider(String ocspSe private static AiaOcspServiceConfiguration getAiaOcspServiceConfiguration() throws JceException { return new AiaOcspServiceConfiguration( - newHashSet(TEST_ESTEID_2015), + Set.of(TEST_ESTEID_2015), CertificateValidator.buildTrustAnchorsFromCertificates(TRUSTED_CA_CERTIFICATES), CertificateValidator.buildCertStoreFromCertificates(TRUSTED_CA_CERTIFICATES)); } diff --git a/src/test/java/eu/webeid/security/validator/ocsp/OcspServiceProviderTest.java b/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java similarity index 66% rename from src/test/java/eu/webeid/security/validator/ocsp/OcspServiceProviderTest.java rename to src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java index 46c75897..f4db501f 100644 --- a/src/test/java/eu/webeid/security/validator/ocsp/OcspServiceProviderTest.java +++ b/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java @@ -1,20 +1,25 @@ // SPDX-FileCopyrightText: Estonian Information System Authority // SPDX-License-Identifier: MIT -package eu.webeid.security.validator.ocsp; +package eu.webeid.ocsp.service; import org.bouncycastle.cert.X509CertificateHolder; import org.junit.jupiter.api.Test; -import eu.webeid.security.exceptions.OCSPCertificateException; -import eu.webeid.security.validator.ocsp.service.OcspService; +import eu.webeid.ocsp.exceptions.OCSPCertificateException; import java.net.URI; import java.util.Date; -import static org.assertj.core.api.Assertions.*; -import static eu.webeid.security.testutil.Certificates.*; -import static eu.webeid.security.testutil.OcspServiceMaker.getAiaOcspServiceProvider; -import static eu.webeid.security.testutil.OcspServiceMaker.getDesignatedOcspServiceProvider; +import static eu.webeid.ocsp.service.OcspServiceMaker.getAiaOcspServiceProvider; +import static eu.webeid.ocsp.service.OcspServiceMaker.getDesignatedOcspServiceProvider; +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.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; class OcspServiceProviderTest { @@ -64,3 +69,16 @@ void whenAiaOcspServiceConfigurationDoesNotHaveResponderCertTrustedCA_thenThrows } } + +// Old disabled example AuthTokenValidator test with designated OCSP check. +// +// @Test +// @Disabled("A new designated test OCSP responder certificate was issued whose validity period no longer overlaps with the revoked certificate") +// void whenCertificateIsRevoked_thenOcspCheckWithDesignatedOcspServiceFails() throws Exception { +// mockDate("2020-01-01", mockedClock); +// final AuthTokenValidator validatorWithOcspCheck = AuthTokenValidators.getAuthTokenValidatorWithDesignatedOcspCheck(); +// final WebEidAuthToken token = replaceTokenField(AUTH_TOKEN, "X5C", REVOKED_CERT); +// assertThatThrownBy(() -> validatorWithOcspCheck +// .validate(token, VALID_CHALLENGE_NONCE)) +// .isInstanceOf(UserCertificateRevokedException.class); +// } \ No newline at end of file diff --git a/src/test/java/eu/webeid/security/certificate/CertificateValidatorTest.java b/src/test/java/eu/webeid/security/certificate/CertificateValidatorTest.java new file mode 100644 index 00000000..c91b9a15 --- /dev/null +++ b/src/test/java/eu/webeid/security/certificate/CertificateValidatorTest.java @@ -0,0 +1,516 @@ +/* + * Copyright (c) 2020-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package eu.webeid.security.certificate; + +import eu.webeid.security.exceptions.CertificateNotTrustedException; +import eu.webeid.security.exceptions.CertificateRevocationCheckFailedException; +import eu.webeid.security.exceptions.CertificateRevokedException; +import eu.webeid.security.testutil.Certificates; +import eu.webeid.security.validator.revocationcheck.RevocationInfo; +import eu.webeid.security.validator.revocationcheck.RevocationMode; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.net.URI; +import java.security.cert.CertPathValidator; +import java.security.cert.CertPathValidatorException; +import java.security.cert.CertStore; +import java.security.cert.Certificate; +import java.security.cert.PKIXRevocationChecker; +import java.security.cert.TrustAnchor; +import java.security.cert.X509Certificate; +import java.util.Collection; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static java.util.Objects.requireNonNull; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class CertificateValidatorTest { + + private static final Date NOW = new Date(1627776000000L); + private static final Date OCSP_RESPONSE_DATE = new Date(1631903124000L); + private static final Date REVOKED_OCSP_RESPONSE_DATE = new Date(1631924023000L); + + @Test + void whenRevocationDisabled_thenValidationSucceedsWithoutRevocationInfo() throws Exception { + final X509Certificate subject = Certificates.getJaakKristjanEsteid2018Cert(); + final X509Certificate issuer = Certificates.getTestEsteid2018CA(); + + final List revocationInfo = CertificateValidator.validateCertificateTrustAndRevocation( + subject, + trustAnchors(issuer), + certStore(issuer), + NOW, + RevocationMode.DISABLED, + null, + null, + true + ); + + assertThat(revocationInfo).isEmpty(); + } + + @Test + void whenRevocationDisabledAndCheckerProvided_thenThrows() throws Exception { + final X509Certificate subject = Certificates.getJaakKristjanEsteid2018Cert(); + final X509Certificate issuer = Certificates.getTestEsteid2018CA(); + + assertThatThrownBy(() -> CertificateValidator.validateCertificateTrustAndRevocation( + subject, + trustAnchors(issuer), + certStore(issuer), + NOW, + RevocationMode.DISABLED, + (s, i) -> List.of(), + null, + true + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("customPkixRevocationChecker and certificateRevocationChecker must be null when revocationMode is DISABLED"); + } + + @Test + void whenCustomCheckerMissing_thenThrows() throws Exception { + final X509Certificate subject = Certificates.getJaakKristjanEsteid2018Cert(); + final X509Certificate issuer = Certificates.getTestEsteid2018CA(); + + assertThatThrownBy(() -> CertificateValidator.validateCertificateTrustAndRevocation( + subject, + trustAnchors(issuer), + certStore(issuer), + NOW, + RevocationMode.CUSTOM_CHECKER, + null, + null, + true + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("certificateRevocationChecker must be provided when revocationMode is CUSTOM_CHECKER"); + } + + @Test + void whenCustomCheckerAndCustomPkixProvided_thenThrows() throws Exception { + final X509Certificate subject = Certificates.getJaakKristjanEsteid2018Cert(); + final X509Certificate issuer = Certificates.getTestEsteid2018CA(); + + assertThatThrownBy(() -> CertificateValidator.validateCertificateTrustAndRevocation( + subject, + trustAnchors(issuer), + certStore(issuer), + NOW, + RevocationMode.CUSTOM_CHECKER, + (s, i) -> List.of(), + new NoopPkixRevocationChecker(), + true + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("customPkixRevocationChecker must be null when revocationMode is CUSTOM_CHECKER"); + } + + @Test + void whenCustomCheckerReturnsRevocationInfo_thenItIsReturned() throws Exception { + final X509Certificate subject = Certificates.getJaakKristjanEsteid2018Cert(); + final X509Certificate issuer = Certificates.getTestEsteid2018CA(); + final RevocationInfo expected = new RevocationInfo( + URI.create("http://ocsp.example"), + null + ); + + final List revocationInfo = CertificateValidator.validateCertificateTrustAndRevocation( + subject, + trustAnchors(issuer), + certStore(issuer), + NOW, + RevocationMode.CUSTOM_CHECKER, + (s, i) -> List.of(expected), + null, + true + ); + + assertThat(revocationInfo).containsExactly(expected); + } + + @Test + void whenCustomPkixMissing_thenThrows() throws Exception { + final X509Certificate subject = Certificates.getJaakKristjanEsteid2018Cert(); + final X509Certificate issuer = Certificates.getTestEsteid2018CA(); + + assertThatThrownBy(() -> CertificateValidator.validateCertificateTrustAndRevocation( + subject, + trustAnchors(issuer), + certStore(issuer), + NOW, + RevocationMode.CUSTOM_PKIX, + null, + null, + true + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("customPkixRevocationChecker must be provided when revocationMode is CUSTOM_PKIX"); + } + + @Test + void whenCustomPkixAndCustomCheckerProvided_thenThrows() throws Exception { + final X509Certificate subject = Certificates.getJaakKristjanEsteid2018Cert(); + final X509Certificate issuer = Certificates.getTestEsteid2018CA(); + + assertThatThrownBy(() -> CertificateValidator.validateCertificateTrustAndRevocation( + subject, + trustAnchors(issuer), + certStore(issuer), + NOW, + RevocationMode.CUSTOM_PKIX, + (s, i) -> List.of(), + new NoopPkixRevocationChecker(), + true + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("certificateRevocationChecker must be null when revocationMode is CUSTOM_PKIX"); + } + + @Test + void whenCustomPkixWithOcspResponder_thenRevocationInfoContainsResponder() throws Exception { + final X509Certificate subject = Certificates.getJaakKristjanEsteid2018Cert(); + final X509Certificate issuer = Certificates.getTestEsteid2018CA(); + final NoopPkixRevocationChecker checker = new NoopPkixRevocationChecker(); + checker.setOcspResponder(URI.create("http://ocsp.example")); + + final List revocationInfo = CertificateValidator.validateCertificateTrustAndRevocation( + subject, + trustAnchors(issuer), + certStore(issuer), + NOW, + RevocationMode.CUSTOM_PKIX, + null, + checker, + true + ); + + assertThat(revocationInfo).containsExactly(new RevocationInfo(checker.getOcspResponder(), null)); + } + + @Test + void whenCustomPkixUsesBundledOcspResponse_thenValidationSucceeds() throws Exception { + final X509Certificate subject = Certificates.getJaakKristjanEsteid2018Cert(); + final X509Certificate issuer = Certificates.getTestEsteid2018CA(); + + assertThat(CertificateValidator.validateCertificateTrustAndRevocation( + subject, + trustAnchors(issuer), + certStore(issuer), + OCSP_RESPONSE_DATE, + RevocationMode.CUSTOM_PKIX, + null, + pkixCheckerWithOcspResponse(subject, "/ocsp_response.der"), + true + )).isEmpty(); + } + + @Test + void whenCustomPkixUsesBundledRevokedOcspResponse_thenThrowsCertificateRevoked() throws Exception { + final X509Certificate subject = Certificates.getJaakKristjanEsteid2018Cert(); + final X509Certificate issuer = Certificates.getTestEsteid2018CA(); + + assertThatThrownBy(() -> CertificateValidator.validateCertificateTrustAndRevocation( + subject, + trustAnchors(issuer), + certStore(issuer), + REVOKED_OCSP_RESPONSE_DATE, + RevocationMode.CUSTOM_PKIX, + null, + pkixCheckerWithOcspResponse(subject, "/ocsp_response_revoked.der"), + true + )) + .isInstanceOf(CertificateRevokedException.class) + .hasCauseInstanceOf(CertPathValidatorException.class); + } + + @Test + void whenCustomPkixCannotDetermineRevocationStatus_thenThrowsRevocationCheckFailedWithCause() throws Exception { + final X509Certificate subject = Certificates.getJaakKristjanEsteid2018Cert(); + final X509Certificate issuer = Certificates.getTestEsteid2018CA(); + final CertPathValidatorException checkerFailure = new CertPathValidatorException( + "OCSP responder returned TRY_LATER", + null, + null, + -1, + CertPathValidatorException.BasicReason.UNDETERMINED_REVOCATION_STATUS + ); + + assertThatThrownBy(() -> CertificateValidator.validateCertificateTrustAndRevocation( + subject, + trustAnchors(issuer), + certStore(issuer), + NOW, + RevocationMode.CUSTOM_PKIX, + null, + new FailingPkixRevocationChecker(checkerFailure), + true + )) + .isInstanceOf(CertificateRevocationCheckFailedException.class) + .hasMessageContaining("OCSP responder returned TRY_LATER") + .hasCause(checkerFailure); + } + + @Test + void whenCustomPkixReportsRevoked_thenThrowsCertificateRevokedWithCause() throws Exception { + final X509Certificate subject = Certificates.getJaakKristjanEsteid2018Cert(); + final X509Certificate issuer = Certificates.getTestEsteid2018CA(); + final CertPathValidatorException checkerFailure = new CertPathValidatorException( + "Certificate has been revoked", + null, + null, + -1, + CertPathValidatorException.BasicReason.REVOKED + ); + + assertThatThrownBy(() -> CertificateValidator.validateCertificateTrustAndRevocation( + subject, + trustAnchors(issuer), + certStore(issuer), + NOW, + RevocationMode.CUSTOM_PKIX, + null, + new FailingPkixRevocationChecker(checkerFailure), + true + )) + .isInstanceOf(CertificateRevokedException.class) + .hasCause(checkerFailure); + } + + @Test + void whenCustomPkixReportsUnspecifiedFailure_thenThrowsRevocationCheckFailedWithCause() throws Exception { + final X509Certificate subject = Certificates.getJaakKristjanEsteid2018Cert(); + final X509Certificate issuer = Certificates.getTestEsteid2018CA(); + final CertPathValidatorException checkerFailure = new CertPathValidatorException( + "Certificate does not specify OCSP responder" + ); + + assertThatThrownBy(() -> CertificateValidator.validateCertificateTrustAndRevocation( + subject, + trustAnchors(issuer), + certStore(issuer), + NOW, + RevocationMode.CUSTOM_PKIX, + null, + new FailingPkixRevocationChecker(checkerFailure), + true + )) + .isInstanceOf(CertificateRevocationCheckFailedException.class) + .hasMessageContaining("Certificate does not specify OCSP responder") + .hasCause(checkerFailure); + } + + @Test + void whenCustomPkixReportsNonRevocationValidationFailure_thenThrowsCertificateNotTrusted() throws Exception { + final X509Certificate subject = Certificates.getJaakKristjanEsteid2018Cert(); + final X509Certificate issuer = Certificates.getTestEsteid2018CA(); + final CertPathValidatorException checkerFailure = new CertPathValidatorException( + "Invalid certificate signature", + null, + null, + -1, + CertPathValidatorException.BasicReason.INVALID_SIGNATURE + ); + + assertThatThrownBy(() -> CertificateValidator.validateCertificateTrustAndRevocation( + subject, + trustAnchors(issuer), + certStore(issuer), + NOW, + RevocationMode.CUSTOM_PKIX, + null, + new FailingPkixRevocationChecker(checkerFailure), + true + )) + .isInstanceOf(CertificateNotTrustedException.class) + .hasCause(checkerFailure); + } + + @Test + void whenPlatformOcspHasCustomChecker_thenThrows() throws Exception { + final X509Certificate subject = Certificates.getJaakKristjanEsteid2018Cert(); + final X509Certificate issuer = Certificates.getTestEsteid2018CA(); + + assertThatThrownBy(() -> CertificateValidator.validateCertificateTrustAndRevocation( + subject, + trustAnchors(issuer), + certStore(issuer), + NOW, + RevocationMode.PLATFORM_OCSP, + (s, i) -> List.of(), + null, + true + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("customPkixRevocationChecker and certificateRevocationChecker must be null when revocationMode is PLATFORM_OCSP"); + } + + @Test + void whenJdkOcspNoncePropertyIsNotSet_thenFreshApplicationNonceIsConfigured() { + final NoopPkixRevocationChecker firstChecker = new NoopPkixRevocationChecker(); + final NoopPkixRevocationChecker secondChecker = new NoopPkixRevocationChecker(); + + CertificateValidator.configureOcspNonce(firstChecker, true, null); + CertificateValidator.configureOcspNonce(secondChecker, true, null); + + assertThat(firstChecker.getOcspExtensions()) + .singleElement() + .isInstanceOf(OcspNonceExtension.class); + assertThat(firstChecker.getOcspExtensions().get(0).getValue()) + .isNotEqualTo(secondChecker.getOcspExtensions().get(0).getValue()); + } + + @Test + void whenPlatformOcspNonceIsDisabledAndJdkPropertyIsUnset_thenApplicationNonceIsNotConfigured() { + final NoopPkixRevocationChecker checker = new NoopPkixRevocationChecker(); + + CertificateValidator.configureOcspNonce(checker, false, null); + + assertThat(checker.getOcspExtensions()).isEmpty(); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void whenJdkOcspNoncePropertyIsTrue_thenNonceGenerationIsLeftToJdk(boolean platformOcspNonceEnabled) { + final NoopPkixRevocationChecker checker = new NoopPkixRevocationChecker(); + + CertificateValidator.configureOcspNonce(checker, platformOcspNonceEnabled, "true"); + + assertThat(checker.getOcspExtensions()).isEmpty(); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void whenJdkOcspNoncePropertyIsFalse_thenApplicationNonceIsNotConfigured(boolean platformOcspNonceEnabled) { + final NoopPkixRevocationChecker checker = new NoopPkixRevocationChecker(); + + CertificateValidator.configureOcspNonce(checker, platformOcspNonceEnabled, "false"); + + assertThat(checker.getOcspExtensions()).isEmpty(); + } + + @Test + void whenPlatformOcspWithUntrustedIssuer_thenThrowsCertificateNotTrusted() throws Exception { + final X509Certificate subject = Certificates.getJaakKristjanEsteid2018Cert(); + final X509Certificate wrongIssuer = Certificates.getTestEsteid2015CA(); + + assertThatThrownBy(() -> CertificateValidator.validateCertificateTrustAndRevocation( + subject, + trustAnchors(wrongIssuer), + certStore(wrongIssuer), + NOW, + RevocationMode.PLATFORM_OCSP, + null, + null, + true + )) + .isInstanceOf(CertificateNotTrustedException.class); + } + + private static Set trustAnchors(X509Certificate issuer) { + return CertificateValidator.buildTrustAnchorsFromCertificates(List.of(issuer)); + } + + private static CertStore certStore(X509Certificate issuer) throws Exception { + return CertificateValidator.buildCertStoreFromCertificates(List.of(issuer)); + } + + private static PKIXRevocationChecker pkixCheckerWithOcspResponse(X509Certificate subject, + String responseResource) throws Exception { + final PKIXRevocationChecker checker = (PKIXRevocationChecker) CertPathValidator + .getInstance(CertPathValidator.getDefaultType()) + .getRevocationChecker(); + checker.setOptions(Set.of( + PKIXRevocationChecker.Option.ONLY_END_ENTITY, + PKIXRevocationChecker.Option.NO_FALLBACK + )); + checker.setOcspResponses(Map.of( + subject, + requireNonNull(CertificateValidatorTest.class.getResourceAsStream(responseResource)).readAllBytes() + )); + return checker; + } + + private static final class NoopPkixRevocationChecker extends PKIXRevocationChecker { + @Override + public void init(boolean forward) { + } + + @Override + public boolean isForwardCheckingSupported() { + return false; + } + + @Override + public Set getSupportedExtensions() { + return null; + } + + @Override + public void check(Certificate cert, Collection unresolvedCritExts) { + } + + @Override + public List getSoftFailExceptions() { + return List.of(); + } + } + + private static final class FailingPkixRevocationChecker extends PKIXRevocationChecker { + private final CertPathValidatorException failure; + + private FailingPkixRevocationChecker(CertPathValidatorException failure) { + this.failure = failure; + } + + @Override + public void init(boolean forward) { + } + + @Override + public boolean isForwardCheckingSupported() { + return false; + } + + @Override + public Set getSupportedExtensions() { + return null; + } + + @Override + public void check(Certificate cert, Collection unresolvedCritExts) throws CertPathValidatorException { + throw failure; + } + + @Override + public List getSoftFailExceptions() { + return List.of(); + } + } +} diff --git a/src/test/java/eu/webeid/security/certificate/OcspNetworkTest.java b/src/test/java/eu/webeid/security/certificate/OcspNetworkTest.java new file mode 100644 index 00000000..afb2c420 --- /dev/null +++ b/src/test/java/eu/webeid/security/certificate/OcspNetworkTest.java @@ -0,0 +1,309 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.security.certificate; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import eu.webeid.ocsp.OcspCertificateRevocationChecker; +import eu.webeid.ocsp.client.OcspClientImpl; +import eu.webeid.ocsp.exceptions.UserCertificateOCSPCheckFailedException; +import eu.webeid.ocsp.service.AiaOcspServiceConfiguration; +import eu.webeid.ocsp.service.DesignatedOcspServiceConfiguration; +import eu.webeid.ocsp.service.OcspServiceProvider; +import eu.webeid.security.exceptions.CertificateRevocationCheckFailedException; +import eu.webeid.security.exceptions.CertificateRevokedException; +import eu.webeid.security.validator.revocationcheck.RevocationInfo; +import eu.webeid.security.validator.revocationcheck.RevocationMode; +import org.bouncycastle.asn1.ASN1ObjectIdentifier; +import org.bouncycastle.asn1.ASN1OctetString; +import org.bouncycastle.asn1.DEROctetString; +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.AuthorityInformationAccess; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.CRLReason; +import org.bouncycastle.asn1.x509.ExtendedKeyUsage; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.Extensions; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.cert.ocsp.CertificateID; +import org.bouncycastle.cert.ocsp.CertificateStatus; +import org.bouncycastle.cert.ocsp.OCSPReq; +import org.bouncycastle.cert.ocsp.OCSPResp; +import org.bouncycastle.cert.ocsp.OCSPRespBuilder; +import org.bouncycastle.cert.ocsp.RevokedStatus; +import org.bouncycastle.cert.ocsp.jcajce.JcaBasicOCSPRespBuilder; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.io.IOException; +import java.math.BigInteger; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.cert.X509Certificate; +import java.security.spec.ECGenParameterSpec; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Date; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** Exercises the real JDK and bundled OCSP HTTP clients against a local, signing responder. */ +class OcspNetworkTest { + + private final Instant now = Instant.now(); + private final AtomicInteger requestCount = new AtomicInteger(); + private final AtomicReference receivedRequest = new AtomicReference<>(); + private final AtomicReference receivedPath = new AtomicReference<>(); + private final AtomicReference serverFailure = new AtomicReference<>(); + private HttpServer server; + private URI aiaUri; + private URI designatedUri; + private X509Certificate issuer; + private X509Certificate subject; + private X509Certificate responder; + private KeyPair responderKeys; + private volatile Reply reply = Reply.GOOD; + private volatile boolean includeNonce = true; + + private enum Reply { GOOD, REVOKED, TRY_LATER, DISCONNECT, UNSUPPORTED_TYPE, MISSING_RESPONSE } + + @BeforeEach + void setup() throws Exception { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + aiaUri = URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/aia"); + designatedUri = aiaUri.resolve("/designated"); + + final KeyPair issuerKeys = newKeys(); + responderKeys = newKeys(); + final X500Name issuerName = new X500Name("CN=Local OCSP test CA"); + final var issuerBuilder = certificateBuilder(issuerName, issuerName, issuerKeys, 1); + issuerBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(true)); + issuerBuilder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign | KeyUsage.cRLSign)); + issuer = sign(issuerBuilder, issuerKeys); + + final var subjectBuilder = certificateBuilder(issuerName, new X500Name("CN=Local OCSP test subject"), newKeys(), 2); + subjectBuilder.addExtension(Extension.authorityInfoAccess, false, new AuthorityInformationAccess( + AccessDescription.id_ad_ocsp, new GeneralName(GeneralName.uniformResourceIdentifier, aiaUri.toString()))); + subject = sign(subjectBuilder, issuerKeys); + + final var responderBuilder = certificateBuilder(issuerName, new X500Name("CN=Local OCSP test responder"), responderKeys, 3); + responderBuilder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature)); + responderBuilder.addExtension(Extension.extendedKeyUsage, false, new ExtendedKeyUsage(KeyPurposeId.id_kp_OCSPSigning)); + responder = sign(responderBuilder, issuerKeys); + + server.createContext("/aia", this::respond); + server.createContext("/designated", this::respond); + server.start(); + } + + @AfterEach + void stopServer() { + if (server != null) { + server.stop(0); + } + assertThat(serverFailure.get()).as("Local responder failure").isNull(); + } + + @ParameterizedTest + @EnumSource(value = RevocationMode.class, names = {"PLATFORM_OCSP", "CUSTOM_CHECKER"}) + void whenResponderReturnsGood_thenValidationSendsRequestAndSucceeds(RevocationMode mode) throws Exception { + final List info = validate(mode, true); + + assertRequestReachedResponder(mode); + assertThat(receivedRequest.get().getExtension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce)).isNotNull(); + if (mode == RevocationMode.CUSTOM_CHECKER) { + assertThat(info).singleElement().extracting(RevocationInfo::ocspResponderUri).isEqualTo(designatedUri); + } else { + assertThat(info).isEmpty(); + } + } + + @Test + void whenPlatformMakesTwoChecks_thenRequestsContainDifferent32ByteNonces() throws Exception { + validate(RevocationMode.PLATFORM_OCSP, true); + final byte[] firstNonce = nonce(receivedRequest.get()); + validate(RevocationMode.PLATFORM_OCSP, true); + + assertThat(requestCount.get()).isEqualTo(2); + assertThat(firstNonce).hasSize(32).isNotEqualTo(nonce(receivedRequest.get())); + assertThat(nonce(receivedRequest.get())).hasSize(32); + } + + @ParameterizedTest + @EnumSource(value = RevocationMode.class, names = {"PLATFORM_OCSP", "CUSTOM_CHECKER"}) + void whenResponderReturnsRevoked_thenValidationRejectsCertificate(RevocationMode mode) { + reply = Reply.REVOKED; + + assertThatThrownBy(() -> validate(mode, true)).isInstanceOf(CertificateRevokedException.class); + assertRequestReachedResponder(mode); + } + + @ParameterizedTest + @EnumSource(value = RevocationMode.class, names = {"PLATFORM_OCSP", "CUSTOM_CHECKER"}) + void whenResponderReturnsTryLater_thenValidationReportsFailure(RevocationMode mode) { + reply = Reply.TRY_LATER; + + assertThatThrownBy(() -> validate(mode, true)).isInstanceOf(CertificateRevocationCheckFailedException.class); + assertRequestReachedResponder(mode); + } + + @ParameterizedTest + @EnumSource(value = RevocationMode.class, names = {"PLATFORM_OCSP", "CUSTOM_CHECKER"}) + void whenResponderDisconnects_thenValidationReportsFailureWithCause(RevocationMode mode) { + reply = Reply.DISCONNECT; + + assertThatThrownBy(() -> validate(mode, true)) + .isInstanceOf(CertificateRevocationCheckFailedException.class) + .hasRootCauseInstanceOf(IOException.class); + assertRequestReachedResponder(mode); + } + + @ParameterizedTest + @EnumSource(value = Reply.class, names = {"UNSUPPORTED_TYPE", "MISSING_RESPONSE"}) + void whenBasicResponseIsMissingOrUnsupported_thenCustomCheckerReportsFailure(Reply response) { + reply = response; + + assertThatThrownBy(() -> validate(RevocationMode.CUSTOM_CHECKER, true)) + .isInstanceOf(UserCertificateOCSPCheckFailedException.class) + .hasMessageContaining("Missing or unsupported Basic OCSP Response"); + assertRequestReachedResponder(RevocationMode.CUSTOM_CHECKER); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void whenDesignatedResponderOmitsNonce_thenConfiguredPolicyIsEnforced(boolean nonceEnabled) throws Exception { + includeNonce = false; + + if (nonceEnabled) { + assertThatThrownBy(() -> validate(RevocationMode.CUSTOM_CHECKER, true)) + .isInstanceOf(UserCertificateOCSPCheckFailedException.class) + .hasMessageContaining("nonce extension missing"); + } else { + validate(RevocationMode.CUSTOM_CHECKER, false); + assertThat(receivedRequest.get().getExtension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce)).isNull(); + } + assertRequestReachedResponder(RevocationMode.CUSTOM_CHECKER); + } + + private List validate(RevocationMode mode, boolean nonceEnabled) throws Exception { + final var anchors = CertificateValidator.buildTrustAnchorsFromCertificates(List.of(issuer)); + final var store = CertificateValidator.buildCertStoreFromCertificates(List.of(issuer)); + final OcspCertificateRevocationChecker checker = mode == RevocationMode.CUSTOM_CHECKER + ? new OcspCertificateRevocationChecker( + OcspClientImpl.build(Duration.ofSeconds(2)), + new OcspServiceProvider( + new DesignatedOcspServiceConfiguration(designatedUri, responder, List.of(issuer), nonceEnabled), + new AiaOcspServiceConfiguration(Set.of(), anchors, store)), + OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW, + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE) + : null; + return CertificateValidator.validateCertificateTrustAndRevocation( + subject, anchors, store, Date.from(now), mode, checker, null, nonceEnabled); + } + + private void assertRequestReachedResponder(RevocationMode mode) { + assertThat(requestCount.get()).isPositive(); + assertThat(receivedRequest.get().getRequestList()).hasSize(1); + assertThat(receivedRequest.get().getRequestList()[0].getCertID().getSerialNumber()).isEqualTo(subject.getSerialNumber()); + assertThat(receivedPath.get()).startsWith(mode == RevocationMode.PLATFORM_OCSP ? "/aia" : "/designated"); + } + + private void respond(HttpExchange exchange) throws IOException { + try { + // The JDK may use GET for small requests; the bundled client uses POST. + final byte[] bytes = exchange.getRequestMethod().equals("GET") + ? Base64.getDecoder().decode(URLDecoder.decode(exchange.getRequestURI().getRawPath() + .substring(exchange.getHttpContext().getPath().length() + 1), StandardCharsets.UTF_8)) + : exchange.getRequestBody().readAllBytes(); + final OCSPReq request = new OCSPReq(bytes); + receivedRequest.set(request); + receivedPath.set(exchange.getRequestURI().getPath()); + requestCount.incrementAndGet(); + if (reply == Reply.DISCONNECT) { + return; + } + final byte[] response = response(request).getEncoded(); + exchange.getResponseHeaders().set("Content-Type", "application/ocsp-response"); + exchange.sendResponseHeaders(200, response.length); + exchange.getResponseBody().write(response); + } catch (Exception e) { + serverFailure.set(e); + exchange.sendResponseHeaders(500, -1); + } finally { + exchange.close(); + } + } + + private OCSPResp response(OCSPReq request) throws Exception { + if (reply == Reply.TRY_LATER) { + return new OCSPRespBuilder().build(OCSPResp.TRY_LATER, null); + } + if (reply == Reply.MISSING_RESPONSE || reply == Reply.UNSUPPORTED_TYPE) { + return new OCSPResp(new OCSPResponse(new OCSPResponseStatus(0), reply == Reply.MISSING_RESPONSE ? null + : new ResponseBytes(new ASN1ObjectIdentifier("1.2.3.4"), new DEROctetString(new byte[0])))); + } + final var builder = new JcaBasicOCSPRespBuilder(responder.getPublicKey(), + 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(request.getRequestList()[0].getCertID(), 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) { + builder.setResponseExtensions(new Extensions(nonce)); + } + return new OCSPRespBuilder().build(OCSPResp.SUCCESSFUL, builder.build( + new JcaContentSignerBuilder("SHA256withECDSA").build(responderKeys.getPrivate()), + new X509CertificateHolder[] {new JcaX509CertificateHolder(responder)}, Date.from(now))); + } + + private static byte[] nonce(OCSPReq request) throws IOException { + return ASN1OctetString.getInstance(request.getExtension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce) + .getExtnValue().getOctets()).getOctets(); + } + + 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), + Date.from(now.minusSeconds(3600)), Date.from(now.plusSeconds(3600)), subjectName, keys.getPublic()); + } + + private static X509Certificate sign(JcaX509v3CertificateBuilder builder, KeyPair issuerKeys) throws Exception { + return new JcaX509CertificateConverter().getCertificate(builder.build( + new JcaContentSignerBuilder("SHA256withECDSA").build(issuerKeys.getPrivate()))); + } + + private static KeyPair newKeys() throws Exception { + final KeyPairGenerator generator = KeyPairGenerator.getInstance("EC"); + generator.initialize(new ECGenParameterSpec("secp256r1")); + return generator.generateKeyPair(); + } +} diff --git a/src/test/java/eu/webeid/security/certificate/OcspNonceExtensionTest.java b/src/test/java/eu/webeid/security/certificate/OcspNonceExtensionTest.java new file mode 100644 index 00000000..4b9fb3d9 --- /dev/null +++ b/src/test/java/eu/webeid/security/certificate/OcspNonceExtensionTest.java @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.security.certificate; + +import org.bouncycastle.asn1.DEROctetString; +import org.bouncycastle.asn1.ocsp.OCSPObjectIdentifiers; +import org.bouncycastle.asn1.x509.Extension; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.util.HexFormat; + +import static org.assertj.core.api.Assertions.assertThat; + +class OcspNonceExtensionTest { + + @Test + void whenNonceExtensionIsCreated_thenItIsEncodedAsNonCriticalOcspNonceExtension() throws Exception { + final byte[] nonce = HexFormat.of().parseHex( + "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + ); + final OcspNonceExtension extension = new OcspNonceExtension( + Extension.create(OCSPObjectIdentifiers.id_pkix_ocsp_nonce, false, new DEROctetString(nonce)) + ); + final ByteArrayOutputStream encodedExtension = new ByteArrayOutputStream(); + + extension.encode(encodedExtension); + + assertThat(extension.getId()).isEqualTo("1.3.6.1.5.5.7.48.1.2"); + assertThat(extension.isCritical()).isFalse(); + assertThat(extension.getValue()).containsExactly( + HexFormat.of().parseHex( + "0420000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + ) + ); + assertThat(encodedExtension.toByteArray()).containsExactly( + HexFormat.of().parseHex( + "302f06092b060105050730010204220420" + + "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + ) + ); + } + + @Test + void whenValueIsReturned_thenItCannotBeUsedToModifyExtension() { + final OcspNonceExtension extension = OcspNonceExtension.create(); + final byte[] originalValue = extension.getValue(); + + final byte[] value = extension.getValue(); + value[2] ^= 1; + + assertThat(extension.getValue()).containsExactly(originalValue); + } +} diff --git a/src/test/java/eu/webeid/security/testutil/AbstractTestWithValidator.java b/src/test/java/eu/webeid/security/testutil/AbstractTestWithValidator.java index 5724d74e..0a5588a2 100644 --- a/src/test/java/eu/webeid/security/testutil/AbstractTestWithValidator.java +++ b/src/test/java/eu/webeid/security/testutil/AbstractTestWithValidator.java @@ -15,15 +15,26 @@ public abstract class AbstractTestWithValidator { + /* + * notBefore Time UTCTime 2025-06-05 09:48:06 UTC + * notAfter Time UTCTime 2030-05-26 20:59:59 UTC + */ + public static final String VALID_AUTH_TOKEN = "{\"algorithm\":\"ES384\"," + + "\"unverifiedCertificate\":\"MIIEDTCCA2+gAwIBAgIQa4KLRyy89ijWxYGOlhn62TAKBggqhkjOPQQDBDBgMQswCQYDVQQGEwJFRTEbMBkGA1UECgwSU0sgSUQgU29sdXRpb25zIEFTMRcwFQYDVQRhDA5OVFJFRS0xMDc0NzAxMzEbMBkGA1UEAwwSVEVTVCBvZiBFU1RFSUQyMDE4MB4XDTI1MDYwNTA5NDgwNloXDTMwMDUyNjIwNTk1OVowfzELMAkGA1UEBhMCRUUxKjAoBgNVBAMMIUrDlUVPUkcsSkFBSy1LUklTVEpBTiwzODAwMTA4NTcxODEQMA4GA1UEBAwHSsOVRU9SRzEWMBQGA1UEKgwNSkFBSy1LUklTVEpBTjEaMBgGA1UEBRMRUE5PRUUtMzgwMDEwODU3MTgwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAReCgD7V8bTpgy72fL9Fzx9zdN2Qlrn+UVZnwZttB5wJyhOBaXHow6aaoD+te/ep4PpGMTxJZp/FDm8PwHU7MO6aFuAT5w/0lLDfl1KHXvPR5cf/RLP1qi4RUfMwjJI5DajggHNMIIByTAJBgNVHRMEAjAAMB8GA1UdIwQYMBaAFMCEmSnETp87AjT2meEKVgAIKT57MHMGCCsGAQUFBwEBBGcwZTA1BggrBgEFBQcwAoYpaHR0cDovL2Muc2suZWUvVGVzdF9vZl9FU1RFSUQyMDE4LmRlci5jcnQwLAYIKwYBBQUHMAGGIGh0dHA6Ly9haWEuZGVtby5zay5lZS9lc3RlaWQyMDE4MB8GA1UdEQQYMBaBFDM4MDAxMDg1NzE4QGVlc3RpLmVlMEcGA1UdIARAMD4wMgYLKwYBBAGDkSEBAQEwIzAhBggrBgEFBQcCARYVaHR0cHM6Ly93d3cuc2suZWUvQ1BTMAgGBgQAj3oBAjAgBgNVHSUBAf8EFjAUBggrBgEFBQcDAgYIKwYBBQUHAwQwawYIKwYBBQUHAQMEXzBdMAgGBgQAjkYBATBRBgYEAI5GAQUwRzBFFj9odHRwczovL3NrLmVlL2VuL3JlcG9zaXRvcnkvY29uZGl0aW9ucy1mb3ItdXNlLW9mLWNlcnRpZmljYXRlcy8TAmVuMB0GA1UdDgQWBBRPJi71QGoBPJ3gaWAg9hPaMKjHpzAOBgNVHQ8BAf8EBAMCA4gwCgYIKoZIzj0EAwQDgYsAMIGHAkIBCgLGWbJ+zHMuno36ArmPanGIyry85orjDSkI3qSpp02SVlRui3Te25s2DZoEPspEFktVHkDP+ElkCJzs+fVOiX8CQQnRI1BxeKyExNPre+mvyYS5dXnDTuGAgjqVpELaY0IeROkcHnYq62CZnYElpN/xl8KLhYe9YkG1V7zDqF9zlpAU\"," + + "\"appVersion\":\"https://web-eid.eu/web-eid-app/releases/2.5.0+0\"," + + "\"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 */ - public static final String VALID_AUTH_TOKEN = "{\"algorithm\":\"ES384\"," + + public static final String LEGACY_AUTH_TOKEN_2021 = "{\"algorithm\":\"ES384\"," + "\"unverifiedCertificate\":\"MIIEBDCCA2WgAwIBAgIQY5OGshxoPMFg+Wfc0gFEaTAKBggqhkjOPQQDBDBgMQswCQYDVQQGEwJFRTEbMBkGA1UECgwSU0sgSUQgU29sdXRpb25zIEFTMRcwFQYDVQRhDA5OVFJFRS0xMDc0NzAxMzEbMBkGA1UEAwwSVEVTVCBvZiBFU1RFSUQyMDE4MB4XDTIxMDcyMjEyNDMwOFoXDTI2MDcwOTIxNTk1OVowfzELMAkGA1UEBhMCRUUxKjAoBgNVBAMMIUrDlUVPUkcsSkFBSy1LUklTVEpBTiwzODAwMTA4NTcxODEQMA4GA1UEBAwHSsOVRU9SRzEWMBQGA1UEKgwNSkFBSy1LUklTVEpBTjEaMBgGA1UEBRMRUE5PRUUtMzgwMDEwODU3MTgwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQmwEKsJTjaMHSaZj19hb9EJaJlwbKc5VFzmlGMFSJVk4dDy+eUxa5KOA7tWXqzcmhh5SYdv+MxcaQKlKWLMa36pfgv20FpEDb03GCtLqjLTRZ7649PugAQ5EmAqIic29CjggHDMIIBvzAJBgNVHRMEAjAAMA4GA1UdDwEB/wQEAwIDiDBHBgNVHSAEQDA+MDIGCysGAQQBg5EhAQIBMCMwIQYIKwYBBQUHAgEWFWh0dHBzOi8vd3d3LnNrLmVlL0NQUzAIBgYEAI96AQIwHwYDVR0RBBgwFoEUMzgwMDEwODU3MThAZWVzdGkuZWUwHQYDVR0OBBYEFPlp/ceABC52itoqppEmbf71TJz6MGEGCCsGAQUFBwEDBFUwUzBRBgYEAI5GAQUwRzBFFj9odHRwczovL3NrLmVlL2VuL3JlcG9zaXRvcnkvY29uZGl0aW9ucy1mb3ItdXNlLW9mLWNlcnRpZmljYXRlcy8TAkVOMCAGA1UdJQEB/wQWMBQGCCsGAQUFBwMCBggrBgEFBQcDBDAfBgNVHSMEGDAWgBTAhJkpxE6fOwI09pnhClYACCk+ezBzBggrBgEFBQcBAQRnMGUwLAYIKwYBBQUHMAGGIGh0dHA6Ly9haWEuZGVtby5zay5lZS9lc3RlaWQyMDE4MDUGCCsGAQUFBzAChilodHRwOi8vYy5zay5lZS9UZXN0X29mX0VTVEVJRDIwMTguZGVyLmNydDAKBggqhkjOPQQDBAOBjAAwgYgCQgDCAgybz0u3W+tGI+AX+PiI5CrE9ptEHO5eezR1Jo4j7iGaO0i39xTGUB+NSC7P6AQbyE/ywqJjA1a62jTLcS9GHAJCARxN4NO4eVdWU3zVohCXm8WN3DWA7XUcn9TZiLGQ29P4xfQZOXJi/z4PNRRsR4plvSNB3dfyBvZn31HhC7my8woi\"," + "\"appVersion\":\"https://web-eid.eu/web-eid-app/releases/2.5.0+0\"," + "\"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_CHALLENGE_NONCE = "12345678123456781234567812345678912356789123"; protected AuthTokenValidator validator; diff --git a/src/test/java/eu/webeid/security/testutil/AuthTokenValidators.java b/src/test/java/eu/webeid/security/testutil/AuthTokenValidators.java index 51e8fabd..fd7348e2 100644 --- a/src/test/java/eu/webeid/security/testutil/AuthTokenValidators.java +++ b/src/test/java/eu/webeid/security/testutil/AuthTokenValidators.java @@ -5,19 +5,14 @@ import eu.webeid.security.certificate.CertificateLoader; import eu.webeid.security.exceptions.JceException; -import eu.webeid.security.exceptions.OCSPCertificateException; import eu.webeid.security.validator.AuthTokenValidator; import eu.webeid.security.validator.AuthTokenValidatorBuilder; -import eu.webeid.security.validator.ocsp.OcspClient; import org.bouncycastle.asn1.ASN1ObjectIdentifier; import java.io.IOException; import java.net.URI; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; -import java.time.Duration; - -import static eu.webeid.security.testutil.OcspServiceMaker.getDesignatedOcspServiceConfiguration; public final class AuthTokenValidators { @@ -34,16 +29,7 @@ public static AuthTokenValidator getAuthTokenValidator(String url) throws Certif public static AuthTokenValidator getAuthTokenValidator(String url, X509Certificate... certificates) throws JceException { return getAuthTokenValidatorBuilder(url, certificates) - // Assure that all builder methods are covered with tests. - .withOcspRequestTimeout(Duration.ofSeconds(1)) - .withNonceDisabledOcspUrls(URI.create("http://example.org")) - .withoutUserCertificateRevocationCheckWithOcsp() - .build(); - } - - public static AuthTokenValidator getAuthTokenValidatorWithOverriddenOcspClient(OcspClient ocspClient) throws CertificateException, JceException, IOException { - return getAuthTokenValidatorBuilder(TOKEN_ORIGIN_URL, getCACertificates()) - .withOcspClient(ocspClient) + .withoutUserCertificateRevocationCheck() .build(); } @@ -52,12 +38,6 @@ public static AuthTokenValidator getAuthTokenValidatorWithOcspCheck() throws Cer .build(); } - public static AuthTokenValidator getAuthTokenValidatorWithDesignatedOcspCheck() throws CertificateException, JceException, IOException, OCSPCertificateException { - return getAuthTokenValidatorBuilder(TOKEN_ORIGIN_URL, getCACertificates()) - .withDesignatedOcspServiceConfiguration(getDesignatedOcspServiceConfiguration()) - .build(); - } - public static AuthTokenValidator getAuthTokenValidatorWithWrongTrustedCA() throws CertificateException, JceException, IOException { return getAuthTokenValidator(TOKEN_ORIGIN_URL, CertificateLoader.loadCertificatesFromResources("ESTEID2018.cer")); @@ -71,7 +51,7 @@ public static AuthTokenValidator getAuthTokenValidatorWithJuly2024ExpiredUnrelat public static AuthTokenValidator getAuthTokenValidatorWithDisallowedESTEIDPolicy() throws CertificateException, JceException, IOException { return getAuthTokenValidatorBuilder(TOKEN_ORIGIN_URL, getCACertificates()) .withDisallowedCertificatePolicies(EST_IDEMIA_POLICY) - .withoutUserCertificateRevocationCheckWithOcsp() + .withoutUserCertificateRevocationCheck() .build(); } diff --git a/src/test/java/eu/webeid/security/validator/AuthTokenAlgorithmTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenAlgorithmTest.java index 280fd898..a4cc8461 100644 --- a/src/test/java/eu/webeid/security/validator/AuthTokenAlgorithmTest.java +++ b/src/test/java/eu/webeid/security/validator/AuthTokenAlgorithmTest.java @@ -26,7 +26,7 @@ protected void setup() { super.setup(); mockedClock = mockStatic(DateAndTime.DefaultClock.class); // Ensure that the certificates do not expire. - mockDate("2021-07-23", mockedClock); + mockDate(VALID_AUTH_TOKEN_TEST_DATE, mockedClock); } @AfterEach diff --git a/src/test/java/eu/webeid/security/validator/AuthTokenCertificateTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenCertificateTest.java index a13d0e3d..bc9982ad 100644 --- a/src/test/java/eu/webeid/security/validator/AuthTokenCertificateTest.java +++ b/src/test/java/eu/webeid/security/validator/AuthTokenCertificateTest.java @@ -13,7 +13,7 @@ import eu.webeid.security.exceptions.CertificateNotYetValidException; import eu.webeid.security.exceptions.UserCertificateDisallowedPolicyException; import eu.webeid.security.exceptions.UserCertificateMissingPurposeException; -import eu.webeid.security.exceptions.UserCertificateRevokedException; +import eu.webeid.ocsp.exceptions.UserCertificateRevokedException; import eu.webeid.security.exceptions.UserCertificateWrongPurposeException; import eu.webeid.security.testutil.AbstractTestWithValidator; import eu.webeid.security.testutil.AuthTokenValidators; @@ -59,6 +59,13 @@ protected void setup() { mockedClock = mockStatic(DateAndTime.DefaultClock.class); // Ensure that the certificates do not expire. mockDate("2021-08-01", mockedClock); + // This class asserts certificate validity window behaviour at fixed historical dates, + // so it keeps using the older (2021-2026) authentication token. + try { + validAuthToken = validator.parse(LEGACY_AUTH_TOKEN_2021); + } catch (AuthTokenException e) { + throw new RuntimeException(e); + } } @AfterEach @@ -262,17 +269,6 @@ void whenCertificateIsRevoked_thenOcspCheckFails() throws Exception { .isInstanceOf(UserCertificateRevokedException.class); } - @Test - @Disabled("A new designated test OCSP responder certificate was issued whose validity period no longer overlaps with the revoked certificate") - void whenCertificateIsRevoked_thenOcspCheckWithDesignatedOcspServiceFails() throws Exception { - mockDate("2020-01-01", mockedClock); - final AuthTokenValidator validatorWithOcspCheck = AuthTokenValidators.getAuthTokenValidatorWithDesignatedOcspCheck(); - final WebEidAuthToken token = replaceTokenField(AUTH_TOKEN, "X5C", REVOKED_CERT); - assertThatThrownBy(() -> validatorWithOcspCheck - .validate(token, VALID_CHALLENGE_NONCE)) - .isInstanceOf(UserCertificateRevokedException.class); - } - @Test void whenCertificateCaIsNotPartOfTrustChain_thenValidationFails() throws Exception { final AuthTokenValidator validatorWithWrongTrustedCA = AuthTokenValidators.getAuthTokenValidatorWithWrongTrustedCA(); diff --git a/src/test/java/eu/webeid/security/validator/AuthTokenSignatureTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenSignatureTest.java index 50b9010c..5e01bdd3 100644 --- a/src/test/java/eu/webeid/security/validator/AuthTokenSignatureTest.java +++ b/src/test/java/eu/webeid/security/validator/AuthTokenSignatureTest.java @@ -5,6 +5,7 @@ import eu.webeid.security.authtoken.WebEidAuthToken; import eu.webeid.security.certificate.CertificateData; +import eu.webeid.security.exceptions.AuthTokenException; import eu.webeid.security.exceptions.AuthTokenSignatureValidationException; import eu.webeid.security.testutil.AbstractTestWithValidator; import eu.webeid.security.testutil.AuthTokenValidators; @@ -39,6 +40,13 @@ protected void setup() { mockedClock = mockStatic(DateAndTime.DefaultClock.class); // Ensure that the certificates do not expire. mockDate("2021-07-23", mockedClock); + // The deliberately wrong certificate above and the refreshed token have no overlapping validity period, + // so this fixture family stays on the older authentication token. + try { + validAuthToken = validator.parse(LEGACY_AUTH_TOKEN_2021); + } catch (AuthTokenException e) { + throw new RuntimeException(e); + } } @AfterEach @@ -48,7 +56,7 @@ void tearDown() { @Test void whenValidTokenAndNonce_thenValidationSucceeds() throws Exception { - final X509Certificate result = validator.validate(validAuthToken, VALID_CHALLENGE_NONCE); + final X509Certificate result = validator.validate(validAuthToken, VALID_CHALLENGE_NONCE).subjectCertificate(); assertThat(CertificateData.getSubjectCN(result).orElseThrow()) .isEqualTo("JÕEORG\\,JAAK-KRISTJAN\\,38001085718"); diff --git a/src/test/java/eu/webeid/security/validator/AuthTokenSignatureValidatorTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenSignatureValidatorTest.java index 773b096e..5e294d53 100644 --- a/src/test/java/eu/webeid/security/validator/AuthTokenSignatureValidatorTest.java +++ b/src/test/java/eu/webeid/security/validator/AuthTokenSignatureValidatorTest.java @@ -6,6 +6,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectReader; import eu.webeid.security.certificate.CertificateLoader; +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; @@ -13,6 +16,7 @@ import java.security.cert.X509Certificate; 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; @@ -20,6 +24,28 @@ class AuthTokenSignatureValidatorTest { private static final ObjectReader OBJECT_READER = new ObjectMapper().readerFor(WebEidAuthToken.class); + @Test + void whenSignatureIsNotBase64_thenThrowsParseExceptionWithCause() throws Exception { + final WebEidAuthToken token = OBJECT_READER.readValue(VALID_RS256_AUTH_TOKEN); + final X509Certificate certificate = CertificateLoader.decodeCertificateFromBase64(token.unverifiedCertificate()); + final AuthTokenSignatureValidator validator = new AuthTokenSignatureValidator(URI.create("https://ria.ee")); + + assertThatThrownBy(() -> validator.validate("RS256", "!!", certificate.getPublicKey(), VALID_CHALLENGE_NONCE)) + .isInstanceOf(AuthTokenParseException.class) + .hasCauseInstanceOf(IllegalArgumentException.class); + } + + @Test + void whenRsaSignatureHasInvalidLength_thenThrowsSignatureValidationExceptionWithCause() throws Exception { + final WebEidAuthToken token = OBJECT_READER.readValue(VALID_RS256_AUTH_TOKEN); + final X509Certificate certificate = CertificateLoader.decodeCertificateFromBase64(token.unverifiedCertificate()); + final AuthTokenSignatureValidator validator = new AuthTokenSignatureValidator(URI.create("https://ria.ee")); + + assertThatThrownBy(() -> validator.validate("RS256", "AA==", certificate.getPublicKey(), VALID_CHALLENGE_NONCE)) + .isInstanceOf(AuthTokenSignatureValidationException.class) + .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\"," + @@ -32,10 +58,10 @@ void whenValidES384Signature_thenSucceeds() throws Exception { new AuthTokenSignatureValidator(URI.create("https://ria.ee")); final WebEidAuthToken authToken = OBJECT_READER.readValue(VALID_AUTH_TOKEN); - final X509Certificate x509Certificate = CertificateLoader.decodeCertificateFromBase64(authToken.getUnverifiedCertificate()); + final X509Certificate x509Certificate = CertificateLoader.decodeCertificateFromBase64(authToken.unverifiedCertificate()); assertThatCode(() -> signatureValidator - .validate("ES384", authToken.getSignature(), x509Certificate.getPublicKey(), VALID_CHALLENGE_NONCE)) + .validate("ES384", authToken.signature(), x509Certificate.getPublicKey(), VALID_CHALLENGE_NONCE)) .doesNotThrowAnyException(); } @@ -45,10 +71,10 @@ void whenValidRS256Signature_thenSucceeds() throws Exception { new AuthTokenSignatureValidator(URI.create("https://ria.ee")); final WebEidAuthToken authToken = OBJECT_READER.readValue(VALID_RS256_AUTH_TOKEN); - final X509Certificate x509Certificate = CertificateLoader.decodeCertificateFromBase64(authToken.getUnverifiedCertificate()); + final X509Certificate x509Certificate = CertificateLoader.decodeCertificateFromBase64(authToken.unverifiedCertificate()); assertThatCode(() -> signatureValidator - .validate("RS256", authToken.getSignature(), x509Certificate.getPublicKey(), VALID_CHALLENGE_NONCE)) + .validate("RS256", authToken.signature(), x509Certificate.getPublicKey(), VALID_CHALLENGE_NONCE)) .doesNotThrowAnyException(); } diff --git a/src/test/java/eu/webeid/security/validator/AuthTokenValidationConfigurationTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenValidationConfigurationTest.java new file mode 100644 index 00000000..f70eae22 --- /dev/null +++ b/src/test/java/eu/webeid/security/validator/AuthTokenValidationConfigurationTest.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2020-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package eu.webeid.security.validator; + +import eu.webeid.security.testutil.Certificates; +import eu.webeid.security.validator.revocationcheck.CertificateRevocationChecker; +import eu.webeid.security.validator.revocationcheck.RevocationMode; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.security.cert.PKIXRevocationChecker; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class AuthTokenValidationConfigurationTest { + + @Test + void whenNoCustomCheckerProvided_thenRevocationModeIsPlatformOcsp() throws Exception { + final AuthTokenValidationConfiguration configuration = getValidConfiguration(); + configuration.validate(); + assertThat(configuration.getRevocationMode()).isEqualTo(RevocationMode.PLATFORM_OCSP); + } + + @Test + void whenRevocationCheckDisabled_thenRevocationModeIsDisabled() throws Exception { + final AuthTokenValidationConfiguration configuration = getValidConfiguration(); + configuration.setUserCertificateRevocationCheckDisabled(); + configuration.validate(); + assertThat(configuration.getRevocationMode()).isEqualTo(RevocationMode.DISABLED); + } + + @Test + void whenCustomCheckerConfigured_thenRevocationModeIsCustomChecker() throws Exception { + final AuthTokenValidationConfiguration configuration = getValidConfiguration(); + configuration.setCertificateRevocationChecker(getNoopChecker()); + configuration.validate(); + assertThat(configuration.getRevocationMode()).isEqualTo(RevocationMode.CUSTOM_CHECKER); + } + + @Test + void whenCustomPkixCheckerConfigured_thenRevocationModeIsCustomPkix() throws Exception { + final AuthTokenValidationConfiguration configuration = getValidConfiguration(); + configuration.setPkixRevocationChecker(mock(PKIXRevocationChecker.class)); + configuration.validate(); + assertThat(configuration.getRevocationMode()).isEqualTo(RevocationMode.CUSTOM_PKIX); + } + + private static AuthTokenValidationConfiguration getValidConfiguration() throws Exception { + final AuthTokenValidationConfiguration configuration = new AuthTokenValidationConfiguration(); + configuration.setSiteOrigin(URI.create("https://ria.ee")); + configuration.getTrustedCACertificates().add(Certificates.getTestEsteid2018CA()); + return configuration; + } + + private static CertificateRevocationChecker getNoopChecker() { + return (subjectCertificate, issuerCertificate) -> List.of(); + } +} diff --git a/src/test/java/eu/webeid/security/validator/AuthTokenValidationInfoTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenValidationInfoTest.java new file mode 100644 index 00000000..9a38f131 --- /dev/null +++ b/src/test/java/eu/webeid/security/validator/AuthTokenValidationInfoTest.java @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2020-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package eu.webeid.security.validator; + +import eu.webeid.security.authtoken.WebEidAuthToken; +import eu.webeid.security.testutil.AuthTokenValidators; +import eu.webeid.security.testutil.DateMocker; +import eu.webeid.security.util.DateAndTime; +import eu.webeid.security.validator.revocationcheck.CertificateRevocationChecker; +import eu.webeid.security.validator.revocationcheck.RevocationInfo; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +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 org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mockStatic; + +class AuthTokenValidationInfoTest { + + private MockedStatic mockedClock; + + @BeforeEach + void setup() { + mockedClock = mockStatic(DateAndTime.DefaultClock.class); + DateMocker.mockDate(VALID_AUTH_TOKEN_TEST_DATE, mockedClock); + } + + @AfterEach + void tearDown() { + mockedClock.close(); + } + + @Test + void whenCustomRevocationCheckerProvidesInfo_thenValidationInfoContainsIt() throws Exception { + final RevocationInfo expectedInfo = new RevocationInfo( + URI.create("https://ocsp.example"), + Map.of(RevocationInfo.KEY_OCSP_RESPONSE, "dummy-response") + ); + final List checkerResults = new ArrayList<>(List.of(expectedInfo)); + final CertificateRevocationChecker checker = (subjectCertificate, issuerCertificate) -> checkerResults; + final AuthTokenValidator validator = AuthTokenValidators.getDefaultAuthTokenValidatorBuilder() + .withCertificateRevocationChecker(checker) + .build(); + + final WebEidAuthToken token = validator.parse(VALID_AUTH_TOKEN); + final ValidationInfo validationInfo = validator.validate(token, VALID_CHALLENGE_NONCE); + + checkerResults.clear(); + + assertThat(validationInfo.revocationInfoList()).containsExactly(expectedInfo); + assertThat(validationInfo.subjectCertificate()).isNotNull(); + assertThatThrownBy(() -> validationInfo.revocationInfoList().clear()).isInstanceOf(UnsupportedOperationException.class); + } +} diff --git a/src/test/java/eu/webeid/security/validator/AuthTokenValidatorBuilderTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenValidatorBuilderTest.java index fcf88f79..7db6dde6 100644 --- a/src/test/java/eu/webeid/security/validator/AuthTokenValidatorBuilderTest.java +++ b/src/test/java/eu/webeid/security/validator/AuthTokenValidatorBuilderTest.java @@ -3,13 +3,28 @@ package eu.webeid.security.validator; +import eu.webeid.security.certificate.CertificateValidator; import eu.webeid.security.testutil.AuthTokenValidators; +import eu.webeid.security.validator.revocationcheck.CertificateRevocationChecker; +import eu.webeid.security.validator.revocationcheck.RevocationMode; import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; import java.net.URI; -import java.time.Duration; - +import java.security.cert.PKIXRevocationChecker; +import java.util.List; + +import static eu.webeid.security.testutil.AbstractTestWithValidator.VALID_AUTH_TOKEN; +import static eu.webeid.security.testutil.AbstractTestWithValidator.VALID_CHALLENGE_NONCE; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; public class AuthTokenValidatorBuilderTest { @@ -22,7 +37,7 @@ public class AuthTokenValidatorBuilderTest { @Test void testOriginMissing() { assertThatThrownBy(builder::build) - .isInstanceOf(NullPointerException.class) + .isInstanceOf(IllegalArgumentException.class) .hasMessageStartingWith("Origin URI must not be null"); } @@ -68,29 +83,62 @@ void testValidatorOriginNotValidSyntax() { } @Test - void testInvalidOcspResponseTimeSkew() throws Exception { - final AuthTokenValidatorBuilder builderWithInvalidOcspResponseTimeSkew = AuthTokenValidators.getDefaultAuthTokenValidatorBuilder() - .withAllowedOcspResponseTimeSkew(Duration.ofMinutes(-1)); - assertThatThrownBy(builderWithInvalidOcspResponseTimeSkew::build) + void whenRevocationCheckDisabledAndCustomCheckerConfigured_thenBuildFails() throws Exception { + final AuthTokenValidatorBuilder builderWithRevocationDisabled = AuthTokenValidators.getDefaultAuthTokenValidatorBuilder() + .withoutUserCertificateRevocationCheck() + .withCertificateRevocationChecker(getNoopChecker()); + assertThatThrownBy(builderWithRevocationDisabled::build) .isInstanceOf(IllegalArgumentException.class) - .hasMessageStartingWith("Allowed OCSP response time-skew must be greater than zero"); + .hasMessageStartingWith("User certificate revocation check is disabled, but a revocation checker was configured"); } @Test - void testInvalidMaxOcspResponseThisUpdateAge() throws Exception { - final AuthTokenValidatorBuilder builderWithInvalidOcspResponseTimeSkew = AuthTokenValidators.getDefaultAuthTokenValidatorBuilder() - .withMaxOcspResponseThisUpdateAge(Duration.ZERO); - assertThatThrownBy(builderWithInvalidOcspResponseTimeSkew::build) + void whenRevocationCheckDisabledAndPkixCheckerConfigured_thenBuildFails() throws Exception { + final AuthTokenValidatorBuilder builderWithRevocationDisabled = AuthTokenValidators.getDefaultAuthTokenValidatorBuilder() + .withoutUserCertificateRevocationCheck() + .withPKIXRevocationChecker(mock(PKIXRevocationChecker.class)); + assertThatThrownBy(builderWithRevocationDisabled::build) .isInstanceOf(IllegalArgumentException.class) - .hasMessageStartingWith("Max OCSP response thisUpdate age must be greater than zero"); + .hasMessageStartingWith("User certificate revocation check is disabled, but a revocation checker was configured"); } @Test - void testInvalidOcspRequestTimeout() throws Exception { - final AuthTokenValidatorBuilder builderWithInvalidOcspResponseTimeSkew = AuthTokenValidators.getDefaultAuthTokenValidatorBuilder() - .withOcspRequestTimeout(Duration.ofMinutes(-1)); - assertThatThrownBy(builderWithInvalidOcspResponseTimeSkew::build) + void whenCustomCheckerAndPkixCheckerConfigured_thenBuildFails() throws Exception { + final AuthTokenValidatorBuilder builderWithConflictingCheckers = AuthTokenValidators.getDefaultAuthTokenValidatorBuilder() + .withCertificateRevocationChecker(getNoopChecker()) + .withPKIXRevocationChecker(mock(PKIXRevocationChecker.class)); + assertThatThrownBy(builderWithConflictingCheckers::build) .isInstanceOf(IllegalArgumentException.class) - .hasMessageStartingWith("OCSP request timeout must be greater than zero"); + .hasMessageStartingWith("Only one of OcspCertificateRevocationChecker or PKIXRevocationChecker may be configured"); + } + + @Test + void whenPlatformOcspNonceSettingChanges_thenBuiltValidatorsRetainTheirSetting() throws Exception { + final AuthTokenValidatorBuilder configurableBuilder = AuthTokenValidators.getDefaultAuthTokenValidatorBuilder(); + final AuthTokenValidator defaultValidator = configurableBuilder.build(); + final AuthTokenValidator nonceDisabledValidator = configurableBuilder.withPlatformOcspNonceEnabled(false).build(); + final AuthTokenValidator nonceEnabledValidator = configurableBuilder.withPlatformOcspNonceEnabled(true).build(); + + try (MockedStatic certificateValidator = mockStatic(CertificateValidator.class)) { + certificateValidator.when(() -> CertificateValidator.validateCertificateTrustAndRevocation( + any(), anySet(), any(), any(), eq(RevocationMode.PLATFORM_OCSP), isNull(), isNull(), anyBoolean() + )).thenReturn(List.of()); + + for (AuthTokenValidator validator : List.of(defaultValidator, nonceDisabledValidator, nonceEnabledValidator)) { + validator.validate(validator.parse(VALID_AUTH_TOKEN), VALID_CHALLENGE_NONCE); + } + + certificateValidator.verify(() -> CertificateValidator.validateCertificateTrustAndRevocation( + any(), anySet(), any(), any(), eq(RevocationMode.PLATFORM_OCSP), isNull(), isNull(), eq(true) + ), times(2)); + certificateValidator.verify(() -> CertificateValidator.validateCertificateTrustAndRevocation( + any(), anySet(), any(), any(), eq(RevocationMode.PLATFORM_OCSP), isNull(), isNull(), eq(false) + )); + } + } + + private static CertificateRevocationChecker getNoopChecker() { + return (subjectCertificate, issuerCertificate) -> List.of(); } + } diff --git a/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePolicyValidatorTest.java b/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePolicyValidatorTest.java new file mode 100644 index 00000000..22d82df8 --- /dev/null +++ b/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePolicyValidatorTest.java @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.security.validator.certvalidators; + +import eu.webeid.security.exceptions.UserCertificateParseException; +import org.bouncycastle.asn1.ASN1Exception; +import org.bouncycastle.asn1.ASN1Integer; +import org.bouncycastle.asn1.DEROctetString; +import org.bouncycastle.asn1.x509.Extension; +import org.junit.jupiter.api.Test; + +import java.security.cert.CertificateParsingException; +import java.security.cert.X509Certificate; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class SubjectCertificatePolicyValidatorTest { + + private final X509Certificate certificate = mock(X509Certificate.class); + private final SubjectCertificatePolicyValidator validator = new SubjectCertificatePolicyValidator(Set.of()); + + @Test + void whenPoliciesAreMissing_thenThrowsCertificateParseException() { + assertThatThrownBy(() -> validator.validateCertificatePolicies(certificate)) + .isInstanceOf(UserCertificateParseException.class) + .hasCauseInstanceOf(CertificateParsingException.class); + } + + @Test + void whenPoliciesHaveInvalidEncoding_thenPreservesParsingFailure() { + when(certificate.getExtensionValue(Extension.certificatePolicies.getId())).thenReturn(new byte[] {4, 2, 4, 5}); + + assertThatThrownBy(() -> validator.validateCertificatePolicies(certificate)) + .isInstanceOf(UserCertificateParseException.class) + .hasCauseExactlyInstanceOf(ASN1Exception.class); + } + + @Test + void whenPoliciesValueIsEmpty_thenThrowsCertificateParseException() throws Exception { + when(certificate.getExtensionValue(Extension.certificatePolicies.getId())) + .thenReturn(new DEROctetString(new byte[0]).getEncoded()); + + assertThatThrownBy(() -> validator.validateCertificatePolicies(certificate)) + .isInstanceOf(UserCertificateParseException.class) + .hasCauseInstanceOf(IllegalArgumentException.class); + } + + @Test + void whenPoliciesHaveWrongAsn1Type_thenPreservesParsingFailure() throws Exception { + when(certificate.getExtensionValue(Extension.certificatePolicies.getId())) + .thenReturn(new DEROctetString(new ASN1Integer(1)).getEncoded()); + + assertThatThrownBy(() -> validator.validateCertificatePolicies(certificate)) + .isInstanceOf(UserCertificateParseException.class) + .hasCauseInstanceOf(IllegalArgumentException.class); + } +} From b74273d25097dca0122c23098562e80c3663bc52 Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Fri, 30 Jan 2026 10:48:33 +0200 Subject: [PATCH 02/19] Add resilient OCSP certificate revocation checker --- pom.xml | 29 ++ .../OcspCertificateRevocationChecker.java | 26 +- .../eu/webeid/ocsp/client/OcspClient.java | 4 +- .../eu/webeid/ocsp/client/OcspClientImpl.java | 30 +- .../ocsp/exceptions/OCSPClientException.java | 64 +++ ...erCertificateOCSPCheckFailedException.java | 4 + .../UserCertificateRevokedException.java | 4 + .../protocol/IssuerDistinguishedName.java | 18 + .../webeid/ocsp/service/AiaOcspService.java | 15 +- .../service/AiaOcspServiceConfiguration.java | 13 +- .../ocsp/service/FallbackOcspService.java | 116 +++++ .../FallbackOcspServiceConfiguration.java | 90 ++++ .../eu/webeid/ocsp/service/OcspService.java | 5 + .../ocsp/service/OcspServiceProvider.java | 26 +- ...lientOcspCertificateRevocationChecker.java | 407 ++++++++++++++++++ ...erCertificateOCSPCheckFailedException.java | 53 +++ ...ilientUserCertificateRevokedException.java | 43 ++ .../revocationcheck/RevocationInfo.java | 25 +- .../OcspCertificateRevocationCheckerTest.java | 34 +- .../ocsp/client/OcspClientOverrideTest.java | 5 +- .../protocol/OcspResponseValidatorTest.java | 20 + .../AiaOcspServiceConfigurationTest.java | 14 +- .../webeid/ocsp/service/OcspServiceMaker.java | 5 +- .../ocsp/service/OcspServiceProviderTest.java | 2 +- ...tOcspCertificateRevocationCheckerTest.java | 385 +++++++++++++++++ .../resources/ocsp_response_unauthorized.der | 2 + 26 files changed, 1388 insertions(+), 51 deletions(-) create mode 100644 src/main/java/eu/webeid/ocsp/exceptions/OCSPClientException.java create mode 100644 src/main/java/eu/webeid/ocsp/protocol/IssuerDistinguishedName.java create mode 100644 src/main/java/eu/webeid/ocsp/service/FallbackOcspService.java create mode 100644 src/main/java/eu/webeid/ocsp/service/FallbackOcspServiceConfiguration.java create mode 100644 src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java create mode 100644 src/main/java/eu/webeid/resilientocsp/exceptions/ResilientUserCertificateOCSPCheckFailedException.java create mode 100644 src/main/java/eu/webeid/resilientocsp/exceptions/ResilientUserCertificateRevokedException.java create mode 100644 src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java create mode 100644 src/test/resources/ocsp_response_unauthorized.der diff --git a/pom.xml b/pom.xml index ac9595e0..a8b0bdf5 100644 --- a/pom.xml +++ b/pom.xml @@ -18,6 +18,7 @@ 1.86 2.22.1 2.0.17 + 2.3.0 5.14.4 3.27.7 5.23.0 @@ -67,6 +68,34 @@ bcpkix-jdk18on ${bouncycastle.version} + + io.github.resilience4j + resilience4j-all + ${resilience4j.version} + + + io.github.resilience4j + resilience4j-bulkhead + + + io.github.resilience4j + resilience4j-cache + + + io.github.resilience4j + resilience4j-ratelimiter + + + io.github.resilience4j + resilience4j-timelimiter + + + + + io.github.resilience4j + resilience4j-vavr + ${resilience4j.version} + org.junit.jupiter diff --git a/src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java b/src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java index 0acca9c2..15465c10 100644 --- a/src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java +++ b/src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java @@ -4,6 +4,7 @@ package eu.webeid.ocsp; import eu.webeid.ocsp.client.OcspClient; +import eu.webeid.ocsp.exceptions.OCSPClientException; import eu.webeid.ocsp.protocol.DigestCalculatorImpl; import eu.webeid.ocsp.protocol.OcspRequestBuilder; import eu.webeid.ocsp.protocol.OcspResponseValidator; @@ -45,7 +46,7 @@ import static eu.webeid.security.util.DateAndTime.requirePositiveDuration; import static java.util.Objects.requireNonNull; -public final class OcspCertificateRevocationChecker implements CertificateRevocationChecker { +public class OcspCertificateRevocationChecker implements CertificateRevocationChecker { public static final Duration DEFAULT_TIME_SKEW = Duration.ofMinutes(15); public static final Duration DEFAULT_THIS_UPDATE_AGE = Duration.ofMinutes(2); @@ -111,7 +112,7 @@ public List validateCertificateNotRevoked(X509Certificate subjec } LOG.debug("OCSP response received successfully"); - verifyOcspResponse(basicResponse, ocspService, certificateId); + verifyOcspResponse(basicResponse, ocspService, certificateId, maxOcspResponseThisUpdateAge); if (ocspService.doesSupportNonce()) { checkNonce(request, basicResponse, ocspResponderUri); } @@ -119,12 +120,12 @@ public List validateCertificateNotRevoked(X509Certificate subjec return List.of(new RevocationInfo(ocspResponderUri, Map.of(RevocationInfo.KEY_OCSP_RESPONSE, response))); - } catch (OCSPException | CertificateException | OperatorCreationException | IOException e) { + } catch (OCSPException | CertificateException | OperatorCreationException | IOException | OCSPClientException e) { throw new UserCertificateOCSPCheckFailedException(e, ocspResponderUri); } } - private void verifyOcspResponse(BasicOCSPResp basicResponse, OcspService ocspService, CertificateID requestCertificateId) throws AuthTokenException, OCSPException, CertificateException, OperatorCreationException { + protected void verifyOcspResponse(BasicOCSPResp basicResponse, OcspService ocspService, CertificateID requestCertificateId, Duration maxOcspResponseThisUpdateAge) throws AuthTokenException, OCSPException, CertificateException, OperatorCreationException { // The verification algorithm follows RFC 2560, https://www.ietf.org/rfc/rfc2560.txt. // // 3.2. Signed Response Acceptance Requirements @@ -182,7 +183,7 @@ private void verifyOcspResponse(BasicOCSPResp basicResponse, OcspService ocspSer LOG.debug("OCSP check result is GOOD"); } - private static void checkNonce(OCSPReq request, BasicOCSPResp response, URI ocspResponderUri) throws UserCertificateOCSPCheckFailedException { + protected static void checkNonce(OCSPReq request, BasicOCSPResp response, URI ocspResponderUri) throws UserCertificateOCSPCheckFailedException { final Extension requestNonce = request.getExtension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce); final Extension responseNonce = response.getExtension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce); if (requestNonce == null || responseNonce == null) { @@ -195,14 +196,14 @@ private static void checkNonce(OCSPReq request, BasicOCSPResp response, URI ocsp } } - private static CertificateID getCertificateId(X509Certificate subjectCertificate, X509Certificate issuerCertificate) throws CertificateEncodingException, IOException, OCSPException { + protected static CertificateID getCertificateId(X509Certificate subjectCertificate, X509Certificate issuerCertificate) throws CertificateEncodingException, IOException, OCSPException { final BigInteger serial = subjectCertificate.getSerialNumber(); final DigestCalculator digestCalculator = DigestCalculatorImpl.sha1(); return new CertificateID(digestCalculator, new X509CertificateHolder(issuerCertificate.getEncoded()), serial); } - private static String ocspStatusToString(int status) { + protected static String ocspStatusToString(int status) { return switch (status) { case OCSPResp.MALFORMED_REQUEST -> "malformed request"; case OCSPResp.INTERNAL_ERROR -> "internal error"; @@ -213,4 +214,15 @@ private static String ocspStatusToString(int status) { }; } + protected OcspClient getOcspClient() { + return ocspClient; + } + + protected OcspServiceProvider getOcspServiceProvider() { + return ocspServiceProvider; + } + + protected Duration getMaxOcspResponseThisUpdateAge() { + return maxOcspResponseThisUpdateAge; + } } diff --git a/src/main/java/eu/webeid/ocsp/client/OcspClient.java b/src/main/java/eu/webeid/ocsp/client/OcspClient.java index 61c2c5bf..2ebb62ae 100644 --- a/src/main/java/eu/webeid/ocsp/client/OcspClient.java +++ b/src/main/java/eu/webeid/ocsp/client/OcspClient.java @@ -3,14 +3,14 @@ package eu.webeid.ocsp.client; +import eu.webeid.ocsp.exceptions.OCSPClientException; import org.bouncycastle.cert.ocsp.OCSPReq; import org.bouncycastle.cert.ocsp.OCSPResp; -import java.io.IOException; import java.net.URI; public interface OcspClient { - OCSPResp request(URI url, OCSPReq request) throws IOException; + OCSPResp request(URI url, OCSPReq request) throws OCSPClientException; } diff --git a/src/main/java/eu/webeid/ocsp/client/OcspClientImpl.java b/src/main/java/eu/webeid/ocsp/client/OcspClientImpl.java index 4f842446..74dd466c 100644 --- a/src/main/java/eu/webeid/ocsp/client/OcspClientImpl.java +++ b/src/main/java/eu/webeid/ocsp/client/OcspClientImpl.java @@ -3,6 +3,7 @@ package eu.webeid.ocsp.client; +import eu.webeid.ocsp.exceptions.OCSPClientException; import org.bouncycastle.cert.ocsp.OCSPReq; import org.bouncycastle.cert.ocsp.OCSPResp; import org.slf4j.Logger; @@ -43,15 +44,21 @@ public static OcspClient build(Duration ocspRequestTimeout) { * @param uri OCSP server URL * @param ocspReq OCSP request * @return OCSP response from the server - * @throws IOException if the request could not be executed due to cancellation, a connectivity problem or timeout, + * @throws OCSPClientException if the request could not be executed due to cancellation, a connectivity problem or timeout, * or if the response status is not successful, or if response has wrong content type. */ @Override - public OCSPResp request(URI uri, OCSPReq ocspReq) throws IOException { + public OCSPResp request(URI uri, OCSPReq ocspReq) throws OCSPClientException { + byte[] encodedOcspReq; + try { + encodedOcspReq = ocspReq.getEncoded(); + } catch (IOException e) { + throw new OCSPClientException(e); + } final HttpRequest request = HttpRequest.newBuilder() .uri(uri) .header(CONTENT_TYPE, OCSP_REQUEST_TYPE) - .POST(HttpRequest.BodyPublishers.ofByteArray(ocspReq.getEncoded())) + .POST(HttpRequest.BodyPublishers.ofByteArray(encodedOcspReq)) .timeout(ocspRequestTimeout) .build(); @@ -60,19 +67,28 @@ public OCSPResp request(URI uri, OCSPReq ocspReq) throws IOException { response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - throw new IOException("Interrupted while sending OCSP request", e); + throw new OCSPClientException("Interrupted while sending OCSP request", e); + } catch (IOException e) { + throw new OCSPClientException(e); } if (response.statusCode() != 200) { - throw new IOException("OCSP request was not successful, response: " + response); + throw new OCSPClientException("OCSP request was not successful", response.body(), response.statusCode()); } else { LOG.debug("OCSP response: {}", response); } final String contentType = response.headers().firstValue(CONTENT_TYPE).orElse(""); if (!contentType.startsWith(OCSP_RESPONSE_TYPE)) { - throw new IOException("OCSP response content type is not " + OCSP_RESPONSE_TYPE); + throw new OCSPClientException("OCSP response content type is not " + OCSP_RESPONSE_TYPE); + } + + OCSPResp ocspResp; + try { + ocspResp = new OCSPResp(response.body()); + } catch (IOException e) { + throw new OCSPClientException(e); } - return new OCSPResp(response.body()); + return ocspResp; } public OcspClientImpl(HttpClient httpClient, Duration ocspRequestTimeout) { diff --git a/src/main/java/eu/webeid/ocsp/exceptions/OCSPClientException.java b/src/main/java/eu/webeid/ocsp/exceptions/OCSPClientException.java new file mode 100644 index 00000000..6adadabe --- /dev/null +++ b/src/main/java/eu/webeid/ocsp/exceptions/OCSPClientException.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2020-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package eu.webeid.ocsp.exceptions; + +public class OCSPClientException extends Exception { + + private final byte[] responseBody; + + private final Integer statusCode; + + public OCSPClientException() { + this(null, null); + } + + public OCSPClientException(String message) { + this(message, null, null); + } + + public OCSPClientException(Throwable cause) { + this(null, cause, null, null); + } + + public OCSPClientException(String message, Throwable cause) { + this(message, cause, null, null); + } + + public OCSPClientException(String message, byte[] responseBody, Integer statusCode) { + this(message, null, responseBody, statusCode); + } + + public OCSPClientException(String message, Throwable cause, byte[] responseBody, Integer statusCode) { + super(message, cause); + this.responseBody = responseBody; + this.statusCode = statusCode; + } + + public byte[] getResponseBody() { + return responseBody; + } + + public Integer getStatusCode() { + return statusCode; + } +} diff --git a/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateOCSPCheckFailedException.java b/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateOCSPCheckFailedException.java index f440542e..adae8579 100644 --- a/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateOCSPCheckFailedException.java +++ b/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateOCSPCheckFailedException.java @@ -14,6 +14,10 @@ */ public class UserCertificateOCSPCheckFailedException extends CertificateRevocationCheckFailedException { + public UserCertificateOCSPCheckFailedException() { + super("User certificate revocation check has failed"); + } + public UserCertificateOCSPCheckFailedException(Throwable cause, URI ocspResponderUri) { super(appendResponderUri("User certificate revocation check has failed", ocspResponderUri), cause); } diff --git a/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateRevokedException.java b/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateRevokedException.java index f5ce0d6d..c0eb08e3 100644 --- a/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateRevokedException.java +++ b/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateRevokedException.java @@ -33,6 +33,10 @@ */ public class UserCertificateRevokedException extends CertificateRevokedException { + public UserCertificateRevokedException() { + super("User certificate has been revoked"); + } + public UserCertificateRevokedException(URI ocspResponderUri) { super(appendResponderUri("User certificate has been revoked", ocspResponderUri)); } diff --git a/src/main/java/eu/webeid/ocsp/protocol/IssuerDistinguishedName.java b/src/main/java/eu/webeid/ocsp/protocol/IssuerDistinguishedName.java new file mode 100644 index 00000000..df096a38 --- /dev/null +++ b/src/main/java/eu/webeid/ocsp/protocol/IssuerDistinguishedName.java @@ -0,0 +1,18 @@ +package eu.webeid.ocsp.protocol; + +import org.bouncycastle.asn1.x500.X500Name; + +import java.security.cert.X509Certificate; +import java.util.Objects; + +public class IssuerDistinguishedName { + + public static X500Name getIssuerDistinguishedName(X509Certificate certificate) { + Objects.requireNonNull(certificate, "certificate"); + return X500Name.getInstance(certificate.getIssuerX500Principal().getEncoded()); + } + + private IssuerDistinguishedName() { + 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 8e0f3a3f..d66ad444 100644 --- a/src/main/java/eu/webeid/ocsp/service/AiaOcspService.java +++ b/src/main/java/eu/webeid/ocsp/service/AiaOcspService.java @@ -9,6 +9,7 @@ import eu.webeid.ocsp.exceptions.UserCertificateOCSPCheckFailedException; import eu.webeid.ocsp.protocol.OcspResponseValidator; import eu.webeid.security.validator.revocationcheck.RevocationMode; +import org.bouncycastle.asn1.x500.X500Name; import org.bouncycastle.cert.X509CertificateHolder; import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; @@ -19,8 +20,10 @@ import java.security.cert.X509Certificate; import java.util.Date; import java.util.Objects; +import java.util.Optional; import java.util.Set; +import static eu.webeid.ocsp.protocol.IssuerDistinguishedName.getIssuerDistinguishedName; import static eu.webeid.ocsp.protocol.OcspUrl.getOcspUri; /** @@ -33,13 +36,16 @@ public class AiaOcspService implements OcspService { private final CertStore trustedCACertificateCertStore; private final URI url; private final boolean supportsNonce; + private final FallbackOcspService fallbackOcspService; - public AiaOcspService(AiaOcspServiceConfiguration configuration, X509Certificate certificate) throws AuthTokenException { + public AiaOcspService(AiaOcspServiceConfiguration configuration, X509Certificate certificate, FallbackOcspService fallbackOcspService) throws AuthTokenException { Objects.requireNonNull(configuration); this.trustedCACertificateAnchors = configuration.getTrustedCACertificateAnchors(); this.trustedCACertificateCertStore = configuration.getTrustedCACertificateCertStore(); this.url = getOcspAiaUrlFromCertificate(Objects.requireNonNull(certificate)); - this.supportsNonce = !configuration.getNonceDisabledOcspUrls().contains(this.url); + this.fallbackOcspService = fallbackOcspService; + X500Name issuerDN = getIssuerDistinguishedName(certificate); + this.supportsNonce = !configuration.getNonceDisabledIssuerDNs().contains(issuerDN); } @Override @@ -52,6 +58,11 @@ public URI getAccessLocation() { return url; } + @Override + public Optional getFallbackService() { + return Optional.ofNullable(fallbackOcspService); + } + @Override public void validateResponderCertificate(X509CertificateHolder cert, Date now) throws AuthTokenException { try { diff --git a/src/main/java/eu/webeid/ocsp/service/AiaOcspServiceConfiguration.java b/src/main/java/eu/webeid/ocsp/service/AiaOcspServiceConfiguration.java index 4d56c211..44b601fd 100644 --- a/src/main/java/eu/webeid/ocsp/service/AiaOcspServiceConfiguration.java +++ b/src/main/java/eu/webeid/ocsp/service/AiaOcspServiceConfiguration.java @@ -3,7 +3,8 @@ package eu.webeid.ocsp.service; -import java.net.URI; +import org.bouncycastle.asn1.x500.X500Name; + import java.security.cert.CertStore; import java.security.cert.TrustAnchor; import java.util.Collection; @@ -12,18 +13,18 @@ public class AiaOcspServiceConfiguration { - private final Collection nonceDisabledOcspUrls; + private final Collection nonceDisabledIssuerDNs; private final Set trustedCACertificateAnchors; private final CertStore trustedCACertificateCertStore; - public AiaOcspServiceConfiguration(Collection nonceDisabledOcspUrls, Set trustedCACertificateAnchors, CertStore trustedCACertificateCertStore) { - this.nonceDisabledOcspUrls = Set.copyOf(nonceDisabledOcspUrls); + public AiaOcspServiceConfiguration(Collection nonceDisabledIssuerDNs, Set trustedCACertificateAnchors, CertStore trustedCACertificateCertStore) { + this.nonceDisabledIssuerDNs = Set.copyOf(nonceDisabledIssuerDNs); this.trustedCACertificateAnchors = Set.copyOf(trustedCACertificateAnchors); this.trustedCACertificateCertStore = Objects.requireNonNull(trustedCACertificateCertStore); } - public Collection getNonceDisabledOcspUrls() { - return nonceDisabledOcspUrls; + public Collection getNonceDisabledIssuerDNs() { + return nonceDisabledIssuerDNs; } public Set getTrustedCACertificateAnchors() { diff --git a/src/main/java/eu/webeid/ocsp/service/FallbackOcspService.java b/src/main/java/eu/webeid/ocsp/service/FallbackOcspService.java new file mode 100644 index 00000000..52a8774d --- /dev/null +++ b/src/main/java/eu/webeid/ocsp/service/FallbackOcspService.java @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2020-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package eu.webeid.ocsp.service; + +import eu.webeid.ocsp.exceptions.OCSPCertificateException; +import eu.webeid.ocsp.protocol.OcspResponseValidator; +import eu.webeid.security.certificate.CertificateValidator; +import eu.webeid.security.exceptions.AuthTokenException; +import eu.webeid.security.validator.revocationcheck.RevocationMode; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; + +import java.net.URI; +import java.security.cert.CertStore; +import java.security.cert.CertificateException; +import java.security.cert.TrustAnchor; +import java.security.cert.X509Certificate; +import java.util.Date; +import java.util.Set; + + +import static eu.webeid.security.certificate.CertificateValidator.requireCertificateIsValidOnDate; + +public class FallbackOcspService implements OcspService { + + private final JcaX509CertificateConverter certificateConverter = new JcaX509CertificateConverter(); + private final URI url; + private final boolean supportsNonce; + private final X509Certificate trustedResponderCertificate; + private final FallbackOcspService nextFallback; + private final Set trustedCACertificateAnchors; + private final CertStore trustedCACertificateCertStore; + + public FallbackOcspService(FallbackOcspServiceConfiguration configuration) { + this.url = configuration.getAccessLocation(); + this.supportsNonce = configuration.doesSupportNonce(); + this.trustedResponderCertificate = configuration.getResponderCertificate(); + this.nextFallback = configuration.getNextFallbackConfiguration() != null + ? new FallbackOcspService(configuration.getNextFallbackConfiguration()) + : null; + this.trustedCACertificateAnchors = configuration.getTrustedCACertificateAnchors(); + this.trustedCACertificateCertStore = configuration.getTrustedCACertificateCertStore(); + } + + @Override + public boolean doesSupportNonce() { + return supportsNonce; + } + + @Override + public URI getAccessLocation() { + return url; + } + + @Override + public void validateResponderCertificate(X509CertificateHolder cert, Date now) throws AuthTokenException { + try { + final X509Certificate responderCertificate = certificateConverter.getCertificate(cert); + requireCertificateIsValidOnDate(responderCertificate, now, "Fallback OCSP responder"); + if (trustedResponderCertificate != null) { + validatePinnedResponderCertificate(responderCertificate); + } else { + validateResponderCertificateAgainstTrustedCa(responderCertificate, now); + } + } catch (CertificateException e) { + throw new OCSPCertificateException("X509CertificateHolder conversion to X509Certificate failed", e); + } + } + + private void validatePinnedResponderCertificate(X509Certificate responderCertificate) throws OCSPCertificateException { + // Certificate pinning is implemented simply by comparing the certificates or their public keys, + // see https://owasp.org/www-community/controls/Certificate_and_Public_Key_Pinning. + if (!trustedResponderCertificate.equals(responderCertificate)) { + throw new OCSPCertificateException("Responder certificate from the OCSP response is not equal to " + + "the configured fallback OCSP responder certificate"); + } + } + + private void validateResponderCertificateAgainstTrustedCa(X509Certificate responderCertificate, Date now) throws AuthTokenException { + OcspResponseValidator.validateHasSigningExtension(responderCertificate); + CertificateValidator.validateCertificateTrustAndRevocation( + responderCertificate, + trustedCACertificateAnchors, + trustedCACertificateCertStore, + now, + RevocationMode.DISABLED, + null, + null, + false + ); + } + + public FallbackOcspService getNextFallback() { + return nextFallback; + } +} diff --git a/src/main/java/eu/webeid/ocsp/service/FallbackOcspServiceConfiguration.java b/src/main/java/eu/webeid/ocsp/service/FallbackOcspServiceConfiguration.java new file mode 100644 index 00000000..49bedd3a --- /dev/null +++ b/src/main/java/eu/webeid/ocsp/service/FallbackOcspServiceConfiguration.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2020-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package eu.webeid.ocsp.service; + +import eu.webeid.ocsp.exceptions.OCSPCertificateException; +import eu.webeid.ocsp.protocol.OcspResponseValidator; +import org.bouncycastle.asn1.x500.X500Name; + +import java.net.URI; +import java.security.cert.CertStore; +import java.security.cert.TrustAnchor; +import java.security.cert.X509Certificate; +import java.util.Objects; +import java.util.Set; + +public class FallbackOcspServiceConfiguration { + + private final URI accessLocation; + private final X509Certificate responderCertificate; + private final boolean doesSupportNonce; + private final FallbackOcspServiceConfiguration nextFallbackConfiguration; + private final X500Name issuerDN; + private final Set trustedCACertificateAnchors; + private final CertStore trustedCACertificateCertStore; + + public FallbackOcspServiceConfiguration(URI accessLocation, X509Certificate responderCertificate, + boolean doesSupportNonce, + FallbackOcspServiceConfiguration nextFallbackConfiguration, + X500Name issuerDN, Set trustedCACertificateAnchors, + CertStore trustedCACertificateCertStore) throws OCSPCertificateException { + this.accessLocation = Objects.requireNonNull(accessLocation, "Fallback OCSP service access location"); + this.responderCertificate = responderCertificate; + if (responderCertificate != null) { + OcspResponseValidator.validateHasSigningExtension(responderCertificate); + } + this.doesSupportNonce = doesSupportNonce; + this.nextFallbackConfiguration = nextFallbackConfiguration; + this.issuerDN = Objects.requireNonNull(issuerDN, "issuerDN"); + this.trustedCACertificateAnchors = Objects.requireNonNull(trustedCACertificateAnchors, "trustedCACertificateAnchors"); + this.trustedCACertificateCertStore = Objects.requireNonNull(trustedCACertificateCertStore, "trustedCACertificateCertStore"); + } + + public URI getAccessLocation() { + return accessLocation; + } + + public X509Certificate getResponderCertificate() { + return responderCertificate; + } + + public boolean doesSupportNonce() { + return doesSupportNonce; + } + + public FallbackOcspServiceConfiguration getNextFallbackConfiguration() { + return nextFallbackConfiguration; + } + + public X500Name getIssuerDN() { + return issuerDN; + } + + public Set getTrustedCACertificateAnchors() { + return trustedCACertificateAnchors; + } + + public CertStore getTrustedCACertificateCertStore() { + return trustedCACertificateCertStore; + } +} diff --git a/src/main/java/eu/webeid/ocsp/service/OcspService.java b/src/main/java/eu/webeid/ocsp/service/OcspService.java index 92a6c155..e72bf3b8 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.util.Date; +import java.util.Optional; public interface OcspService { @@ -17,4 +18,8 @@ public interface OcspService { void validateResponderCertificate(X509CertificateHolder cert, Date now) throws AuthTokenException; + default Optional getFallbackService() { + return Optional.empty(); + } + } diff --git a/src/main/java/eu/webeid/ocsp/service/OcspServiceProvider.java b/src/main/java/eu/webeid/ocsp/service/OcspServiceProvider.java index 925d9bfc..8ee38cdb 100644 --- a/src/main/java/eu/webeid/ocsp/service/OcspServiceProvider.java +++ b/src/main/java/eu/webeid/ocsp/service/OcspServiceProvider.java @@ -3,22 +3,39 @@ package eu.webeid.ocsp.service; +import eu.webeid.ocsp.exceptions.UserCertificateOCSPCheckFailedException; import eu.webeid.security.exceptions.AuthTokenException; +import org.bouncycastle.asn1.x500.X500Name; import java.security.cert.CertificateEncodingException; import java.security.cert.X509Certificate; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; import java.util.Objects; +import static eu.webeid.ocsp.protocol.IssuerDistinguishedName.getIssuerDistinguishedName; + public class OcspServiceProvider { private final DesignatedOcspService designatedOcspService; private final AiaOcspServiceConfiguration aiaOcspServiceConfiguration; + private final Map fallbackOcspServiceMap = new HashMap<>(); public OcspServiceProvider(DesignatedOcspServiceConfiguration designatedOcspServiceConfiguration, AiaOcspServiceConfiguration aiaOcspServiceConfiguration) { + this(designatedOcspServiceConfiguration, aiaOcspServiceConfiguration, null); + } + + public OcspServiceProvider(DesignatedOcspServiceConfiguration designatedOcspServiceConfiguration, AiaOcspServiceConfiguration aiaOcspServiceConfiguration, Collection fallbackOcspServiceConfigurations) { designatedOcspService = designatedOcspServiceConfiguration != null ? new DesignatedOcspService(designatedOcspServiceConfiguration) : null; this.aiaOcspServiceConfiguration = Objects.requireNonNull(aiaOcspServiceConfiguration, "aiaOcspServiceConfiguration"); + if (fallbackOcspServiceConfigurations != null) { + for (FallbackOcspServiceConfiguration configuration : fallbackOcspServiceConfigurations) { + fallbackOcspServiceMap.put(configuration.getIssuerDN(), new FallbackOcspService(configuration)); + } + } } /** @@ -27,14 +44,15 @@ public OcspServiceProvider(DesignatedOcspServiceConfiguration designatedOcspServ * * @param certificate subject certificate that is to be checked with OCSP * @return either the designated or AIA OCSP service instance - * @throws AuthTokenException when AIA URL is not found in certificate - * @throws CertificateEncodingException when certificate is invalid + * @throws UserCertificateOCSPCheckFailedException when issuer common name is not found in certificate + * @throws IllegalArgumentException when certificate is invalid */ public OcspService getService(X509Certificate certificate) throws AuthTokenException, CertificateEncodingException { if (designatedOcspService != null && designatedOcspService.supportsIssuerOf(certificate)) { return designatedOcspService; } - return new AiaOcspService(aiaOcspServiceConfiguration, certificate); + X500Name issuerDistinguishedName = getIssuerDistinguishedName(certificate); + FallbackOcspService fallbackOcspService = fallbackOcspServiceMap.get(issuerDistinguishedName); + return new AiaOcspService(aiaOcspServiceConfiguration, certificate, fallbackOcspService); } - } diff --git a/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java b/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java new file mode 100644 index 00000000..4ebc1cb5 --- /dev/null +++ b/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java @@ -0,0 +1,407 @@ +/* + * Copyright (c) 2020-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package eu.webeid.resilientocsp; + +import eu.webeid.ocsp.OcspCertificateRevocationChecker; +import eu.webeid.ocsp.client.OcspClient; +import eu.webeid.ocsp.exceptions.OCSPClientException; +import eu.webeid.ocsp.exceptions.UserCertificateRevokedException; +import eu.webeid.ocsp.protocol.OcspRequestBuilder; +import eu.webeid.ocsp.service.OcspService; +import eu.webeid.ocsp.service.OcspServiceProvider; +import eu.webeid.resilientocsp.exceptions.ResilientUserCertificateOCSPCheckFailedException; +import eu.webeid.resilientocsp.exceptions.ResilientUserCertificateRevokedException; +import eu.webeid.ocsp.service.FallbackOcspService; +import eu.webeid.security.exceptions.AuthTokenException; +import eu.webeid.security.validator.ValidationInfo; +import eu.webeid.security.validator.revocationcheck.RevocationInfo; +import io.github.resilience4j.circuitbreaker.CallNotPermittedException; +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +import io.github.resilience4j.core.functions.CheckedSupplier; +import io.github.resilience4j.decorators.Decorators; +import io.github.resilience4j.retry.Retry; +import io.github.resilience4j.retry.RetryConfig; +import io.github.resilience4j.retry.RetryRegistry; +import io.vavr.control.Try; +import org.bouncycastle.asn1.ocsp.OCSPResponseStatus; +import org.bouncycastle.cert.ocsp.BasicOCSPResp; +import org.bouncycastle.cert.ocsp.CertificateID; +import org.bouncycastle.cert.ocsp.OCSPReq; +import org.bouncycastle.cert.ocsp.OCSPResp; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URI; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static eu.webeid.security.util.DateAndTime.requirePositiveDuration; +import static java.util.Objects.requireNonNull; + +/** + * OCSP revocation checker that falls back to configured fallback OCSP responders when the primary OCSP service fails. + * + *

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. + */ +public class ResilientOcspCertificateRevocationChecker extends OcspCertificateRevocationChecker { + + private static final Logger LOG = LoggerFactory.getLogger(ResilientOcspCertificateRevocationChecker.class); + + private final CircuitBreakerRegistry circuitBreakerRegistry; + private final RetryRegistry retryRegistry; + private final Duration fallbackMaxOcspResponseThisUpdateAge; + + public ResilientOcspCertificateRevocationChecker(OcspClient ocspClient, + OcspServiceProvider ocspServiceProvider, + CircuitBreakerConfig circuitBreakerConfig, + RetryConfig retryConfig, + Duration allowedOcspResponseTimeSkew, + Duration primaryMaxOcspResponseThisUpdateAge, + Duration fallbackMaxOcspResponseThisUpdateAge) { + super(ocspClient, ocspServiceProvider, allowedOcspResponseTimeSkew, primaryMaxOcspResponseThisUpdateAge); + this.fallbackMaxOcspResponseThisUpdateAge = requirePositiveDuration(fallbackMaxOcspResponseThisUpdateAge, "fallbackMaxOcspResponseThisUpdateAge"); + this.circuitBreakerRegistry = CircuitBreakerRegistry.custom() + .withCircuitBreakerConfig(getCircuitBreakerConfig(circuitBreakerConfig)) + .build(); + this.retryRegistry = retryConfig != null ? RetryRegistry.custom() + .withRetryConfig(getRetryConfig(retryConfig)) + .build() : null; + if (LOG.isDebugEnabled()) { + this.circuitBreakerRegistry.getEventPublisher() + .onEntryAdded(entryAddedEvent -> { + CircuitBreaker circuitBreaker = entryAddedEvent.getAddedEntry(); + LOG.debug("CircuitBreaker {} added", circuitBreaker.getName()); + circuitBreaker.getEventPublisher() + .onEvent(event -> LOG.debug(event.toString())); + }); + } + } + + @Override + public List validateCertificateNotRevoked(X509Certificate subjectCertificate, + X509Certificate issuerCertificate) throws AuthTokenException { + OcspService primaryService = resolvePrimaryOcspService(subjectCertificate); + 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())); + } + + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(primaryService.getAccessLocation().toASCIIString()); + List revocationInfoList = new ArrayList<>(); + CheckedSupplier fallbackSupplier = buildFallbackSupplier(firstFallbackServiceOpt.get(), subjectCertificate, + issuerCertificate, revocationInfoList); + CheckedSupplier decoratedSupplier = decorateWithResilience(primaryService, subjectCertificate, + issuerCertificate, revocationInfoList, fallbackSupplier, circuitBreaker); + + // 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); + revocationInfoList.add(revocationInfo); + return revocationInfoList; + } + + private OcspService resolvePrimaryOcspService(X509Certificate subjectCertificate) throws AuthTokenException { + try { + return getOcspServiceProvider().getService(subjectCertificate); + } catch (CertificateException e) { + throw new ResilientUserCertificateOCSPCheckFailedException(new ValidationInfo(subjectCertificate, List.of())); + } + } + + private CircuitBreakerStatistics createCircuitBreakerStatistics(CircuitBreaker circuitBreaker) { + CircuitBreaker.Metrics metrics = circuitBreaker.getMetrics(); + return new CircuitBreakerStatistics( + circuitBreaker.getState(), + metrics.getFailureRate(), + metrics.getSlowCallRate(), + metrics.getNumberOfSlowCalls(), + metrics.getNumberOfSlowSuccessfulCalls(), + metrics.getNumberOfSlowFailedCalls(), + metrics.getNumberOfBufferedCalls(), + metrics.getNumberOfFailedCalls(), + metrics.getNumberOfNotPermittedCalls(), + metrics.getNumberOfSuccessfulCalls() + ); + } + + private CheckedSupplier buildFallbackSupplier(FallbackOcspService firstFallbackService, + X509Certificate subjectCertificate, + X509Certificate issuerCertificate, + List revocationInfoList) { + CheckedSupplier firstFallbackSupplier = () -> { + try { + return request(firstFallbackService, subjectCertificate, issuerCertificate, fallbackMaxOcspResponseThisUpdateAge); + } catch (Exception e) { + createAndAddRevocationInfoToList(e, revocationInfoList); + throw e; + } + }; + // NOTE: Up to two fallbacks are currently supported. To enable the full potential of recursive fallbacks + // with FallbackOcspService#getNextFallback, the fallback supplier creation needs to be changed. + OcspService secondFallbackService = firstFallbackService.getNextFallback(); + if (secondFallbackService == null) { + return firstFallbackSupplier; + } + CheckedSupplier secondFallbackSupplier = () -> { + try { + return request(secondFallbackService, subjectCertificate, issuerCertificate, fallbackMaxOcspResponseThisUpdateAge); + } catch (Exception e) { + createAndAddRevocationInfoToList(e, revocationInfoList); + throw e; + } + }; + return () -> { + try { + return firstFallbackSupplier.get(); + } catch (ResilientUserCertificateRevokedException e) { + // NOTE: ResilientUserCertificateRevokedException must be re-thrown before the generic + // catch (Exception) block. Without this, a "revoked" verdict from the first fallback would + // be swallowed, and the second fallback could silently override it with a "good" response. + throw e; + } catch (Exception e) { + return secondFallbackSupplier.get(); + } + }; + } + + private CheckedSupplier decorateWithResilience(OcspService primaryService, + X509Certificate subjectCertificate, + X509Certificate issuerCertificate, + List revocationInfoList, + CheckedSupplier fallbackSupplier, + CircuitBreaker circuitBreaker) { + CheckedSupplier primarySupplier = () -> { + try { + return request(primaryService, subjectCertificate, issuerCertificate, getMaxOcspResponseThisUpdateAge()); + } catch (Exception e) { + createAndAddRevocationInfoToList(e, revocationInfoList); + throw e; + } + }; + Decorators.DecorateCheckedSupplier decorateCheckedSupplier = Decorators.ofCheckedSupplier(primarySupplier); + if (retryRegistry != null) { + Retry retry = retryRegistry.retry(primaryService.getAccessLocation().toASCIIString()); + decorateCheckedSupplier.withRetry(retry); + } + decorateCheckedSupplier.withCircuitBreaker(circuitBreaker) + .withFallback(List.of(ResilientUserCertificateOCSPCheckFailedException.class, CallNotPermittedException.class), e -> fallbackSupplier.get()); + + return decorateCheckedSupplier.decorate(); + } + + private RevocationInfo processResult(Try result, X509Certificate subjectCertificate, + List revocationInfoList, + CircuitBreakerStatistics circuitBreakerStatistics) throws AuthTokenException { + if (result.isSuccess()) { + RevocationInfo revocationInfo = result.get(); + if (revocationInfoList.isEmpty()) { + revocationInfo = withCircuitBreakerStatistics(revocationInfo, circuitBreakerStatistics); + } else { + addCircuitBreakerStatistics(revocationInfoList, circuitBreakerStatistics); + } + return revocationInfo; + } + addCircuitBreakerStatistics(revocationInfoList, circuitBreakerStatistics); + Throwable throwable = result.getCause(); + if (throwable instanceof ResilientUserCertificateOCSPCheckFailedException exception) { + exception.setValidationInfo(new ValidationInfo(subjectCertificate, revocationInfoList)); + throw exception; + } + if (throwable instanceof ResilientUserCertificateRevokedException exception) { + exception.setValidationInfo(new ValidationInfo(subjectCertificate, revocationInfoList)); + throw exception; + } + throw new ResilientUserCertificateOCSPCheckFailedException(new ValidationInfo(subjectCertificate, revocationInfoList)); + } + + private void addCircuitBreakerStatistics(List revocationInfoList, + CircuitBreakerStatistics circuitBreakerStatistics) { + revocationInfoList.set(0, withCircuitBreakerStatistics(revocationInfoList.get(0), circuitBreakerStatistics)); + } + + + private void createAndAddRevocationInfoToList(Throwable throwable, List revocationInfoList) { + if (throwable instanceof ResilientUserCertificateOCSPCheckFailedException exception) { + revocationInfoList.addAll((exception.getValidationInfo().revocationInfoList())); + return; + } + if (throwable instanceof ResilientUserCertificateRevokedException exception) { + revocationInfoList.addAll((exception.getValidationInfo().revocationInfoList())); + return; + } + revocationInfoList.add(new RevocationInfo(null, new HashMap<>(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; + 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 { + response = requireNonNull(getOcspClient().request(ocspResponderUri, request)); + responseTime = Instant.now(); + requestDuration = Duration.between(requestTime, responseTime); + } catch (OCSPClientException e) { + responseTime = Instant.now(); + requestDuration = Duration.between(requestTime, responseTime); + RevocationInfo revocationInfo = getRevocationInfo(ocspResponderUri, e, request, null, requestDuration, responseTime); + revocationInfo = withOCSPClientException(revocationInfo, e); + throw new ResilientUserCertificateOCSPCheckFailedException(new ValidationInfo(subjectCertificate, List.of(revocationInfo))); + } + if (response.getStatus() != OCSPResponseStatus.SUCCESSFUL) { + throw createException("Response status: " + ocspStatusToString(response.getStatus()), + subjectCertificate, ocspResponderUri, request, response, requestDuration, responseTime + ); + } + + final BasicOCSPResp basicResponse = (BasicOCSPResp) response.getResponseObject(); + if (basicResponse == null) { + throw createException("Missing Basic OCSP Response", subjectCertificate, + ocspResponderUri, request, response, requestDuration, responseTime + ); + } + LOG.debug("OCSP response received successfully"); + + verifyOcspResponse(basicResponse, ocspService, certificateId, maxOcspResponseThisUpdateAge); + if (ocspService.doesSupportNonce()) { + checkNonce(request, basicResponse, ocspResponderUri); + } + LOG.debug("OCSP response verified successfully"); + + return getRevocationInfo(ocspResponderUri, null, request, response, requestDuration, responseTime); + } 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 + // (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))); + } + } + + + private ResilientUserCertificateOCSPCheckFailedException createException(String message, X509Certificate subjectCertificate, + URI ocspResponderUri, OCSPReq request, OCSPResp response, + Duration requestDuration, Instant responseTime) throws ResilientUserCertificateOCSPCheckFailedException { + ResilientUserCertificateOCSPCheckFailedException exception = new ResilientUserCertificateOCSPCheckFailedException(message); + RevocationInfo revocationInfo = getRevocationInfo(ocspResponderUri, exception, request, response, requestDuration, responseTime); + exception.setValidationInfo(new ValidationInfo(subjectCertificate, List.of(revocationInfo))); + return exception; + } + + private RevocationInfo getRevocationInfo(URI ocspResponderUri, Exception e, OCSPReq request, OCSPResp response, + Duration requestDuration, Instant end) { + Map ocspResponseAttributes = new HashMap<>(); + if (e != null) { + ocspResponseAttributes.put(RevocationInfo.KEY_OCSP_ERROR, e); + } + if (request != null) { + ocspResponseAttributes.put(RevocationInfo.KEY_OCSP_REQUEST, request); + } + if (response != null) { + ocspResponseAttributes.put(RevocationInfo.KEY_OCSP_RESPONSE, response); + } + if (requestDuration != null) { + ocspResponseAttributes.put(RevocationInfo.KEY_REQUEST_DURATION, requestDuration); + } + if (end != null) { + ocspResponseAttributes.put(RevocationInfo.KEY_OCSP_RESPONSE_TIME, end); + } + return new RevocationInfo(ocspResponderUri, ocspResponseAttributes); + } + + private static CircuitBreakerConfig getCircuitBreakerConfig(CircuitBreakerConfig circuitBreakerConfig) { + return CircuitBreakerConfig.from(circuitBreakerConfig) + // Users must not be able to modify these three values. + .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED) + .ignoreExceptions(ResilientUserCertificateRevokedException.class) + .automaticTransitionFromOpenToHalfOpenEnabled(true) + .build(); + } + + private static RetryConfig getRetryConfig(RetryConfig retryConfig) { + return RetryConfig.from(retryConfig) + // Users must not be able to modify this value. + .ignoreExceptions(ResilientUserCertificateRevokedException.class) + .build(); + } + + private static RevocationInfo withCircuitBreakerStatistics(RevocationInfo revocationInfo, CircuitBreakerStatistics circuitBreakerStatistics) { + return revocationInfo.withAdditionalOcspResponseAttribute(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS, circuitBreakerStatistics); + } + + private static RevocationInfo withOCSPClientException(RevocationInfo revocationInfo, OCSPClientException e) { + return revocationInfo + .withAdditionalOcspResponseAttribute(RevocationInfo.KEY_OCSP_RESPONSE, e.getResponseBody()) + .withAdditionalOcspResponseAttribute(RevocationInfo.KEY_HTTP_STATUS_CODE, e.getStatusCode()); + } + + public record CircuitBreakerStatistics( + CircuitBreaker.State state, + float failureRate, + float slowCallRate, + int numberOfSlowCalls, + int numberOfSlowSuccessfulCalls, + int numberOfSlowFailedCalls, + int numberOfBufferedCalls, + int numberOfFailedCalls, + long numberOfNotPermittedCalls, + int numberOfSuccessfulCalls + ) {} +} diff --git a/src/main/java/eu/webeid/resilientocsp/exceptions/ResilientUserCertificateOCSPCheckFailedException.java b/src/main/java/eu/webeid/resilientocsp/exceptions/ResilientUserCertificateOCSPCheckFailedException.java new file mode 100644 index 00000000..159de9c8 --- /dev/null +++ b/src/main/java/eu/webeid/resilientocsp/exceptions/ResilientUserCertificateOCSPCheckFailedException.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2020-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package eu.webeid.resilientocsp.exceptions; + +import eu.webeid.ocsp.exceptions.UserCertificateOCSPCheckFailedException; +import eu.webeid.security.validator.ValidationInfo; + +public class ResilientUserCertificateOCSPCheckFailedException extends UserCertificateOCSPCheckFailedException { + + private ValidationInfo validationInfo; + + public ResilientUserCertificateOCSPCheckFailedException(String message) { + this(message, null); + } + + public ResilientUserCertificateOCSPCheckFailedException(ValidationInfo validationInfo) { + super(); + this.validationInfo = validationInfo; + } + + public ResilientUserCertificateOCSPCheckFailedException(String message, ValidationInfo validationInfo) { + super(message); + this.validationInfo = validationInfo; + } + + public ValidationInfo getValidationInfo() { + return validationInfo; + } + + public void setValidationInfo(ValidationInfo validationInfo) { + this.validationInfo = validationInfo; + } +} diff --git a/src/main/java/eu/webeid/resilientocsp/exceptions/ResilientUserCertificateRevokedException.java b/src/main/java/eu/webeid/resilientocsp/exceptions/ResilientUserCertificateRevokedException.java new file mode 100644 index 00000000..27ec8f4e --- /dev/null +++ b/src/main/java/eu/webeid/resilientocsp/exceptions/ResilientUserCertificateRevokedException.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2020-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package eu.webeid.resilientocsp.exceptions; + +import eu.webeid.ocsp.exceptions.UserCertificateRevokedException; +import eu.webeid.security.validator.ValidationInfo; + +public class ResilientUserCertificateRevokedException extends UserCertificateRevokedException { + + private ValidationInfo validationInfo; + + public ResilientUserCertificateRevokedException(ValidationInfo validationInfo) { + this.validationInfo = validationInfo; + } + + public ValidationInfo getValidationInfo() { + return validationInfo; + } + + public void setValidationInfo(ValidationInfo validationInfo) { + this.validationInfo = validationInfo; + } +} diff --git a/src/main/java/eu/webeid/security/validator/revocationcheck/RevocationInfo.java b/src/main/java/eu/webeid/security/validator/revocationcheck/RevocationInfo.java index f2b7265a..8a85691a 100644 --- a/src/main/java/eu/webeid/security/validator/revocationcheck/RevocationInfo.java +++ b/src/main/java/eu/webeid/security/validator/revocationcheck/RevocationInfo.java @@ -3,11 +3,34 @@ package eu.webeid.security.validator.revocationcheck; import java.net.URI; +import java.util.HashMap; import java.util.Map; public record RevocationInfo(URI ocspResponderUri, Map ocspResponseAttributes) { + public static final String KEY_OCSP_REQUEST = "OCSP_REQUEST"; public static final String KEY_OCSP_RESPONSE = "OCSP_RESPONSE"; public static final String KEY_OCSP_ERROR = "OCSP_ERROR"; + public static final String KEY_HTTP_STATUS_CODE = "HTTP_STATUS_CODE"; + public static final String KEY_REQUEST_DURATION = "REQUEST_DURATION"; + public static final String KEY_CIRCUIT_BREAKER_STATISTICS = "CIRCUIT_BREAKER_STATISTICS"; + public static final String KEY_OCSP_RESPONSE_TIME = "OCSP_RESPONSE_TIME"; -} \ No newline at end of file + public RevocationInfo(URI ocspResponderUri, Map ocspResponseAttributes) { + this.ocspResponderUri = ocspResponderUri; + this.ocspResponseAttributes = ocspResponseAttributes != null + ? Map.copyOf(ocspResponseAttributes) + : null; + } + + public RevocationInfo withAdditionalOcspResponseAttribute(String key, Object value) { + if (value == null) { + return this; + } + Map newOcspResponseAttributes = ocspResponseAttributes != null + ? new HashMap<>(ocspResponseAttributes) + : new HashMap<>(); + newOcspResponseAttributes.put(key, value); + return new RevocationInfo(ocspResponderUri, newOcspResponseAttributes); + } +} diff --git a/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java b/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java index 92157ed3..4bd31619 100644 --- a/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java +++ b/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java @@ -3,6 +3,7 @@ package eu.webeid.ocsp; +import eu.webeid.ocsp.exceptions.OCSPClientException; import eu.webeid.security.exceptions.CertificateExpiredException; import eu.webeid.security.exceptions.CertificateNotTrustedException; import eu.webeid.security.exceptions.JceException; @@ -42,14 +43,17 @@ import static eu.webeid.security.testutil.DateMocker.mockDate; import static eu.webeid.ocsp.service.OcspServiceMaker.getAiaOcspServiceProvider; import static eu.webeid.ocsp.service.OcspServiceMaker.getDesignatedOcspServiceProvider; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.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; -class OcspCertificateRevocationCheckerTest extends AbstractTestWithValidator { +public class OcspCertificateRevocationCheckerTest extends AbstractTestWithValidator { private final OcspClient ocspClient = OcspClientImpl.build(Duration.ofSeconds(5)); private X509Certificate estEid2018Cert; @@ -104,6 +108,8 @@ void whenOcspUrlIsInvalid_thenThrows() throws Exception { validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) .isInstanceOf(UserCertificateOCSPCheckFailedException.class) .cause() + .isInstanceOf(OCSPClientException.class) + .cause() .isInstanceOf(ConnectException.class); } @@ -111,12 +117,11 @@ void whenOcspUrlIsInvalid_thenThrows() throws Exception { void whenOcspRequestFails_thenThrows() throws Exception { final OcspServiceProvider ocspServiceProvider = getDesignatedOcspServiceProvider("http://demo.sk.ee/ocsps"); final OcspCertificateRevocationChecker validator = getOcspCertificateRevocationChecker(ocspServiceProvider); - assertThatCode(() -> - validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) - .isInstanceOf(UserCertificateOCSPCheckFailedException.class) - .cause() - .isInstanceOf(IOException.class) - .hasMessageStartingWith("OCSP request was not successful, response: (POST http://demo.sk.ee/ocsps) 404"); + 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); } @Test @@ -128,7 +133,10 @@ void whenOcspRequestHasInvalidBody_thenThrows() throws Exception { validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) .isInstanceOf(UserCertificateOCSPCheckFailedException.class) .cause() - .isExactlyInstanceOf(CertIOException.class); + .isInstanceOf(OCSPClientException.class) + .cause() + .isInstanceOf(IOException.class) + .hasMessage("malformed response: corrupted stream - out of bounds length found: 110 > 7"); } @Test @@ -344,7 +352,7 @@ private static byte[] getOcspResponseBytesFromResources() throws IOException { return getOcspResponseBytesFromResources("ocsp_response.der"); } - private static byte[] getOcspResponseBytesFromResources(String resource) throws IOException { + public static byte[] getOcspResponseBytesFromResources(String resource) throws IOException { try (final InputStream resourceAsStream = ClassLoader.getSystemResourceAsStream(resource)) { return toByteArray(resourceAsStream); } @@ -384,7 +392,13 @@ private HttpResponse getMockedResponse(byte[] bodyContent) throws URISyn } private OcspClient getMockClient(HttpResponse response) { - return (url, request) -> new OCSPResp(Objects.requireNonNull(response.body())); + return (url, request) -> { + try { + return new OCSPResp(Objects.requireNonNull(response.body())); + } catch (IOException e) { + throw new OCSPClientException(e); + } + }; } private static byte[] toByteArray(InputStream resourceAsStream) throws IOException { diff --git a/src/test/java/eu/webeid/ocsp/client/OcspClientOverrideTest.java b/src/test/java/eu/webeid/ocsp/client/OcspClientOverrideTest.java index 06e1fc18..828d2915 100644 --- a/src/test/java/eu/webeid/ocsp/client/OcspClientOverrideTest.java +++ b/src/test/java/eu/webeid/ocsp/client/OcspClientOverrideTest.java @@ -4,6 +4,7 @@ package eu.webeid.ocsp.client; import eu.webeid.ocsp.OcspCertificateRevocationChecker; +import eu.webeid.ocsp.exceptions.OCSPClientException; import eu.webeid.security.exceptions.JceException; import eu.webeid.security.testutil.AbstractTestWithValidator; import eu.webeid.security.testutil.AuthTokenValidators; @@ -62,12 +63,12 @@ private static AuthTokenValidator getAuthTokenValidatorWithOverriddenOcspClient( private static class OcpClientThatThrows implements OcspClient { @Override - public OCSPResp request(URI url, OCSPReq request) throws IOException { + public OCSPResp request(URI url, OCSPReq request) throws OCSPClientException { throw new OcpClientThatThrowsException(); } } - private static class OcpClientThatThrowsException extends IOException { + private static class OcpClientThatThrowsException extends OCSPClientException { } } diff --git a/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java b/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java index 01f5daed..677bc8dd 100644 --- a/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java +++ b/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java @@ -5,6 +5,9 @@ import eu.webeid.ocsp.OcspCertificateRevocationChecker; import eu.webeid.ocsp.exceptions.UserCertificateOCSPCheckFailedException; +import eu.webeid.ocsp.exceptions.UserCertificateRevokedException; +import org.bouncycastle.cert.ocsp.BasicOCSPResp; +import org.bouncycastle.cert.ocsp.OCSPResp; import org.bouncycastle.cert.ocsp.SingleResp; import org.junit.jupiter.api.Test; @@ -14,7 +17,9 @@ import java.time.temporal.ChronoUnit; import java.util.Date; +import static eu.webeid.ocsp.OcspCertificateRevocationCheckerTest.getOcspResponseBytesFromResources; import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateCertificateStatusUpdateTime; +import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateSubjectCertificateStatus; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.mockito.Mockito.mock; @@ -99,8 +104,23 @@ void whenNextUpdateHalfHourBeforeNow_thenThrows() { + " (OCSP responder: https://example.org)"); } + @Test + void whenOcspResponseStatusIsUnknown_ThenThrowsUserCertificateOCSPCheckFailedException() throws Exception { + SingleResp unknownCertStatus = getUnknownCertStatusResponse(); + assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> + validateSubjectCertificateStatus(unknownCertStatus, OCSP_URL)) + .withMessage("User certificate revocation check has failed: Unknown status (OCSP responder: https://example.org)"); + } + private static Date getThisUpdateWithinAgeLimit(Instant now) { return Date.from(now.minus(THIS_UPDATE_AGE.minusSeconds(1))); } + private static SingleResp getUnknownCertStatusResponse() throws Exception { + final OCSPResp ocspRespUnknown = new OCSPResp(getOcspResponseBytesFromResources("ocsp_response_unknown.der")); + final BasicOCSPResp basicResponse = (BasicOCSPResp) ocspRespUnknown.getResponseObject(); + return basicResponse.getResponses()[0]; + } + } diff --git a/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceConfigurationTest.java b/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceConfigurationTest.java index cd939d3a..06d79020 100644 --- a/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceConfigurationTest.java +++ b/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceConfigurationTest.java @@ -4,9 +4,9 @@ package eu.webeid.ocsp.service; import eu.webeid.security.certificate.CertificateValidator; +import org.bouncycastle.asn1.x500.X500Name; import org.junit.jupiter.api.Test; -import java.net.URI; import java.security.cert.TrustAnchor; import java.util.HashSet; import java.util.List; @@ -20,19 +20,19 @@ class AiaOcspServiceConfigurationTest { @Test void whenCallerMutatesCollections_thenConfigurationRemainsUnchanged() throws Exception { - final URI responder = URI.create("http://ocsp.example"); - final Set nonceDisabledUrls = new HashSet<>(Set.of(responder)); + final X500Name issuerDN = new X500Name("CN=TEST of ESTEID2018, O=SK ID Solutions AS, C=EE"); + final Set nonceDisabledIssuerDNs = new HashSet<>(Set.of(issuerDN)); final TrustAnchor anchor = new TrustAnchor(getTestEsteid2018CA(), null); final Set anchors = new HashSet<>(Set.of(anchor)); final AiaOcspServiceConfiguration configuration = new AiaOcspServiceConfiguration( - nonceDisabledUrls, anchors, CertificateValidator.buildCertStoreFromCertificates(List.of(getTestEsteid2018CA()))); + nonceDisabledIssuerDNs, anchors, CertificateValidator.buildCertStoreFromCertificates(List.of(getTestEsteid2018CA()))); - nonceDisabledUrls.clear(); + nonceDisabledIssuerDNs.clear(); anchors.clear(); - assertThat(configuration.getNonceDisabledOcspUrls()).containsExactly(responder); + assertThat(configuration.getNonceDisabledIssuerDNs()).containsExactly(issuerDN); assertThat(configuration.getTrustedCACertificateAnchors()).containsExactly(anchor); - assertThatThrownBy(() -> configuration.getNonceDisabledOcspUrls().clear()).isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> configuration.getNonceDisabledIssuerDNs().clear()).isInstanceOf(UnsupportedOperationException.class); assertThatThrownBy(() -> configuration.getTrustedCACertificateAnchors().clear()).isInstanceOf(UnsupportedOperationException.class); } } diff --git a/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java b/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java index d7b6af1c..57417604 100644 --- a/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java +++ b/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java @@ -6,6 +6,7 @@ import eu.webeid.security.certificate.CertificateValidator; import eu.webeid.security.exceptions.JceException; import eu.webeid.ocsp.exceptions.OCSPCertificateException; +import org.bouncycastle.asn1.x500.X500Name; import java.io.IOException; import java.net.URI; @@ -22,7 +23,7 @@ public class OcspServiceMaker { private static final String TEST_OCSP_ACCESS_LOCATION = "http://demo.sk.ee/ocsp"; private static final List TRUSTED_CA_CERTIFICATES; - private static final URI TEST_ESTEID_2015 = URI.create("http://aia.demo.sk.ee/esteid2015"); + private static final X500Name ISSUER_DN = new X500Name("CN=TEST of ESTEID-SK 2015, OID.2.5.4.97=NTREE-10747013, O=AS Sertifitseerimiskeskus, C=EE"); static { try { @@ -50,7 +51,7 @@ public static OcspServiceProvider getDesignatedOcspServiceProvider(String ocspSe private static AiaOcspServiceConfiguration getAiaOcspServiceConfiguration() throws JceException { return new AiaOcspServiceConfiguration( - Set.of(TEST_ESTEID_2015), + Set.of(ISSUER_DN), CertificateValidator.buildTrustAnchorsFromCertificates(TRUSTED_CA_CERTIFICATES), CertificateValidator.buildCertStoreFromCertificates(TRUSTED_CA_CERTIFICATES)); } diff --git a/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java b/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java index f4db501f..362cec81 100644 --- a/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java +++ b/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java @@ -81,4 +81,4 @@ void whenAiaOcspServiceConfigurationDoesNotHaveResponderCertTrustedCA_thenThrows // assertThatThrownBy(() -> validatorWithOcspCheck // .validate(token, VALID_CHALLENGE_NONCE)) // .isInstanceOf(UserCertificateRevokedException.class); -// } \ No newline at end of file +// } diff --git a/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java b/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java new file mode 100644 index 00000000..9fa7d16b --- /dev/null +++ b/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java @@ -0,0 +1,385 @@ +/* + * Copyright (c) 2020-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package eu.webeid.resilientocsp; + +import eu.webeid.ocsp.OcspCertificateRevocationChecker; +import eu.webeid.ocsp.client.OcspClient; +import eu.webeid.ocsp.exceptions.OCSPClientException; +import eu.webeid.ocsp.service.OcspService; +import eu.webeid.ocsp.service.OcspServiceProvider; +import eu.webeid.resilientocsp.exceptions.ResilientUserCertificateOCSPCheckFailedException; +import eu.webeid.resilientocsp.exceptions.ResilientUserCertificateRevokedException; +import eu.webeid.ocsp.service.FallbackOcspService; +import eu.webeid.security.authtoken.WebEidAuthToken; +import eu.webeid.security.validator.AuthTokenValidator; +import eu.webeid.security.validator.revocationcheck.RevocationInfo; +import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig; +import io.github.resilience4j.retry.RetryConfig; +import org.bouncycastle.cert.ocsp.BasicOCSPResp; +import org.bouncycastle.cert.ocsp.CertificateStatus; +import org.bouncycastle.cert.ocsp.OCSPResp; +import org.bouncycastle.cert.ocsp.RevokedStatus; +import org.bouncycastle.cert.ocsp.SingleResp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.security.cert.X509Certificate; +import java.time.Duration; +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 org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class ResilientOcspCertificateRevocationCheckerTest { + + private static final URI PRIMARY_URI = URI.create("http://primary.ocsp.test"); + private static final URI FALLBACK_URI = URI.create("http://fallback.ocsp.test"); + private static final URI SECOND_FALLBACK_URI = URI.create("http://second-fallback.ocsp.test"); + + private static final Duration LONG_THIS_UPDATE_AGE = Duration.ofDays(365 * 10); + + private X509Certificate estEid2018Cert; + private X509Certificate testEsteid2018CA; + + private OCSPResp ocspRespGood; + private OCSPResp ocspRespRevoked; + + @BeforeEach + void setUp() throws Exception { + estEid2018Cert = getJaakKristjanEsteid2018Cert(); + testEsteid2018CA = getTestEsteid2018CA(); + ocspRespGood = new OCSPResp(getOcspResponseBytesFromResources("ocsp_response.der")); + ocspRespRevoked = new OCSPResp(getOcspResponseBytesFromResources("ocsp_response_revoked.der")); + } + + @Test + void whenMultipleValidationCalls_thenPreviousResultsAreNotModified() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())) + .thenThrow(new OCSPClientException("Primary OCSP service unavailable (call1)")) + .thenThrow(new OCSPClientException("Primary OCSP service unavailable (call2)")); + when(ocspClient.request(eq(FALLBACK_URI), any())) + .thenThrow(new OCSPClientException("Fallback OCSP service unavailable (call1)")) + .thenThrow(new OCSPClientException("Fallback OCSP service unavailable (call2)")); + when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())) + .thenThrow(new OCSPClientException("Secondary fallback OCSP service unavailable (call1)")) + .thenThrow(new OCSPClientException("Secondary fallback OCSP service unavailable (call2)")); + ResilientOcspCertificateRevocationChecker resilientChecker = buildChecker(ocspClient, null); + AuthTokenValidator validator = getDefaultAuthTokenValidatorBuilder() + .withCertificateRevocationChecker(resilientChecker) + .build(); + WebEidAuthToken authToken = validator.parse(VALID_AUTH_TOKEN); + + ResilientUserCertificateOCSPCheckFailedException ex1 = assertThrows(ResilientUserCertificateOCSPCheckFailedException.class, + () -> validator.validate(authToken, VALID_CHALLENGE_NONCE)); + List revocationInfo1 = ex1.getValidationInfo().revocationInfoList(); + assertThat(revocationInfo1).hasSize(3); + assertThat(revocationInfo1) + .extracting(ri -> ((OCSPClientException) ri.ocspResponseAttributes().get("OCSP_ERROR")).getMessage()) + .containsExactly( + "Primary OCSP service unavailable (call1)", + "Fallback OCSP service unavailable (call1)", + "Secondary fallback OCSP service unavailable (call1)" + ); + ResilientUserCertificateOCSPCheckFailedException ex2 = assertThrows(ResilientUserCertificateOCSPCheckFailedException.class, + () -> validator.validate(authToken, VALID_CHALLENGE_NONCE)); + List revocationInfo2 = ex2.getValidationInfo().revocationInfoList(); + assertThat(revocationInfo2).hasSize(3); + assertThat(revocationInfo2) + .extracting(ri -> ((OCSPClientException) ri.ocspResponseAttributes().get("OCSP_ERROR")).getMessage()) + .containsExactly( + "Primary OCSP service unavailable (call2)", + "Fallback OCSP service unavailable (call2)", + "Secondary fallback OCSP service unavailable (call2)" + ); + assertThat(revocationInfo1).hasSize(3); + assertThat(revocationInfo1) + .extracting(ri -> ((OCSPClientException) ri.ocspResponseAttributes().get("OCSP_ERROR")).getMessage()) + .containsExactly( + "Primary OCSP service unavailable (call1)", + "Fallback OCSP service unavailable (call1)", + "Secondary fallback OCSP service unavailable (call1)" + ); + } + + @Test + void whenFirstFallbackReturnsRevoked_thenRevocationPropagatesWithoutSecondFallback() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())) + .thenThrow(new OCSPClientException("Primary OCSP service unavailable")); + when(ocspClient.request(eq(FALLBACK_URI), any())) + .thenReturn(ocspRespRevoked); + when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())) + .thenReturn(ocspRespGood); + + ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, null); + + assertThatExceptionOfType(ResilientUserCertificateRevokedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .withMessage("User certificate has been revoked"); + + verify(ocspClient, never()).request(eq(SECOND_FALLBACK_URI), any()); + } + + @Test + void whenMaxAttemptsIsOneAndAllCallsFail_thenRevocationInfoListShouldHaveThreeElements() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())) + .thenThrow(new OCSPClientException()); + when(ocspClient.request(eq(FALLBACK_URI), any())) + .thenThrow(new OCSPClientException()); + when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())) + .thenThrow(new OCSPClientException()); + + RetryConfig retryConfig = RetryConfig.custom() + .maxAttempts(1) + .build(); + + ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, retryConfig); + ResilientUserCertificateOCSPCheckFailedException ex = assertThrows(ResilientUserCertificateOCSPCheckFailedException.class, () -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); + assertThat(ex.getValidationInfo().revocationInfoList().size()).isEqualTo(3); + } + + @Test + void whenMaxAttemptsIsTwoAndAllCallsFail_thenRevocationInfoListShouldHaveFourElements() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())) + .thenThrow(new OCSPClientException()); + when(ocspClient.request(eq(FALLBACK_URI), any())) + .thenThrow(new OCSPClientException()); + when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())) + .thenThrow(new OCSPClientException()); + + RetryConfig retryConfig = RetryConfig.custom() + .maxAttempts(2) + .build(); + + ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, retryConfig); + ResilientUserCertificateOCSPCheckFailedException ex = assertThrows(ResilientUserCertificateOCSPCheckFailedException.class, () -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); + assertThat(ex.getValidationInfo().revocationInfoList().size()).isEqualTo(4); + } + + @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); + when(ocspClient.request(eq(FALLBACK_URI), any())) + .thenReturn(ocspRespRevoked); + when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())) + .thenReturn(ocspRespRevoked); + + RetryConfig retryConfig = RetryConfig.custom() + .maxAttempts(2) + .build(); + + ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, retryConfig); + List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + assertThat(revocationInfoList.size()).isEqualTo(2); + + Map firstResponseAttributes = revocationInfoList.get(0).ocspResponseAttributes(); + OCSPClientException ex1 = (OCSPClientException) firstResponseAttributes.get("OCSP_ERROR"); + assertThat(ex1.getMessage()).isEqualTo("Primary OCSP service unavailable (call1)"); + + Map secondResponseAttributes = revocationInfoList.get(1).ocspResponseAttributes(); + OCSPResp ocspResp = (OCSPResp) secondResponseAttributes.get("OCSP_RESPONSE"); + final BasicOCSPResp basicResponse = (BasicOCSPResp) ocspResp.getResponseObject(); + final SingleResp certStatusResponse = basicResponse.getResponses()[0]; + assertThat(certStatusResponse.getCertStatus()).isEqualTo(org.bouncycastle.cert.ocsp.CertificateStatus.GOOD); + } + + @Test + void whenFirstCallSucceeds_thenRevocationInfoListShouldHaveOneElementAndItShouldHaveGoodStatus() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())) + .thenReturn(ocspRespGood); + when(ocspClient.request(eq(FALLBACK_URI), any())) + .thenReturn(ocspRespRevoked); + when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())) + .thenReturn(ocspRespRevoked); + + ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, null); + + List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + assertThat(revocationInfoList.size()).isEqualTo(1); + Map responseAttributes = revocationInfoList.get(0).ocspResponseAttributes(); + OCSPResp ocspResp = (OCSPResp) responseAttributes.get("OCSP_RESPONSE"); + CertificateStatus status = getCertificateStatus(ocspResp); + assertThat(status).isEqualTo(org.bouncycastle.cert.ocsp.CertificateStatus.GOOD); + } + + @Test + void whenFirstCallResultsInRevoked_thenRevocationInfoListShouldHaveOneElementAndItShouldHaveRevokedStatus() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())) + .thenReturn(ocspRespRevoked); + when(ocspClient.request(eq(FALLBACK_URI), any())) + .thenReturn(ocspRespGood); + when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())) + .thenReturn(ocspRespGood); + + ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, null); + 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); + } + + @Test + void whenOneFallbackIsConfiguredAndPrimaryFails_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()); + + FallbackOcspService fallbackService = mock(FallbackOcspService.class); + when(fallbackService.getAccessLocation()).thenReturn(FALLBACK_URI); + when(fallbackService.doesSupportNonce()).thenReturn(false); + when(fallbackService.getNextFallback()).thenReturn(null); + + OcspService primaryService = mock(OcspService.class); + when(primaryService.getAccessLocation()).thenReturn(PRIMARY_URI); + when(primaryService.doesSupportNonce()).thenReturn(false); + when(primaryService.getFallbackService()).thenReturn(Optional.of(fallbackService)); + + OcspServiceProvider ocspServiceProvider = mock(OcspServiceProvider.class); + when(ocspServiceProvider.getService(any())).thenReturn(primaryService); + + 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); + } + + @Test + void whenNoFallbacksAreConfigured_thenRevocationInfoListShouldHaveOneElement() 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()); + + OcspService primaryService = mock(OcspService.class); + when(primaryService.getAccessLocation()).thenReturn(PRIMARY_URI); + when(primaryService.doesSupportNonce()).thenReturn(false); + when(primaryService.getFallbackService()).thenReturn(Optional.empty()); + + OcspServiceProvider ocspServiceProvider = mock(OcspServiceProvider.class); + when(ocspServiceProvider.getService(any())).thenReturn(primaryService); + + 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(1); + } + + @Test + void whenOcspResponseStatusIsUnauthorized_thenThrows() throws Exception { + OCSPResp ocspRespStatusUnauthorized = new OCSPResp(getOcspResponseBytesFromResources("ocsp_response_unauthorized.der")); + + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())) + .thenReturn(ocspRespStatusUnauthorized); + + ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, null); + ResilientUserCertificateOCSPCheckFailedException ex = assertThrows(ResilientUserCertificateOCSPCheckFailedException.class, () -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); + + Map responseAttributes = ex.getValidationInfo().revocationInfoList().get(0).ocspResponseAttributes(); + ResilientUserCertificateOCSPCheckFailedException firstException = (ResilientUserCertificateOCSPCheckFailedException) responseAttributes.get(RevocationInfo.KEY_OCSP_ERROR); + assertThat(firstException.getMessage()).isEqualTo("Response status: unauthorized"); + } + + 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); + + FallbackOcspService fallbackService = mock(FallbackOcspService.class); + when(fallbackService.getAccessLocation()).thenReturn(FALLBACK_URI); + when(fallbackService.doesSupportNonce()).thenReturn(false); + when(fallbackService.getNextFallback()).thenReturn(secondFallbackService); + + OcspService primaryService = mock(OcspService.class); + when(primaryService.getAccessLocation()).thenReturn(PRIMARY_URI); + when(primaryService.doesSupportNonce()).thenReturn(false); + when(primaryService.getFallbackService()).thenReturn(Optional.of(fallbackService)); + + OcspServiceProvider ocspServiceProvider = mock(OcspServiceProvider.class); + when(ocspServiceProvider.getService(any())).thenReturn(primaryService); + + return new ResilientOcspCertificateRevocationChecker( + ocspClient, + ocspServiceProvider, + CircuitBreakerConfig.ofDefaults(), + retryConfig, + OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW, + LONG_THIS_UPDATE_AGE, + LONG_THIS_UPDATE_AGE + ); + } + + private CertificateStatus getCertificateStatus(OCSPResp ocspResp) throws Exception { + final BasicOCSPResp basicResponse = (BasicOCSPResp) ocspResp.getResponseObject(); + final SingleResp certStatusResponse = basicResponse.getResponses()[0]; + return certStatusResponse.getCertStatus(); + } +} diff --git a/src/test/resources/ocsp_response_unauthorized.der b/src/test/resources/ocsp_response_unauthorized.der new file mode 100644 index 00000000..d6ea0659 --- /dev/null +++ b/src/test/resources/ocsp_response_unauthorized.der @@ -0,0 +1,2 @@ +0 + \ No newline at end of file From f588f5814ea9cacd6c90a1c06c66381c9199bf84 Mon Sep 17 00:00:00 2001 From: Madis Jaagup Laurson Date: Tue, 21 Apr 2026 18:07:12 +0300 Subject: [PATCH 03/19] AUT-2737 Change circuit breaker configuration parameter overrides --- ...lientOcspCertificateRevocationChecker.java | 12 ++++-- ...tOcspCertificateRevocationCheckerTest.java | 38 ++++++++++++++++++- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java b/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java index 4ebc1cb5..6c4f43e9 100644 --- a/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java +++ b/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java @@ -368,10 +368,14 @@ 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 is counted as a SUCCESS because it represents + // 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(); } diff --git a/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java b/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java index 9fa7d16b..9f8dd552 100644 --- a/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java +++ b/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java @@ -63,6 +63,7 @@ import static org.mockito.ArgumentMatchers.eq; 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; @@ -265,6 +266,37 @@ void whenFirstCallResultsInRevoked_thenRevocationInfoListShouldHaveOneElementAnd assertThat(status).isInstanceOf(RevokedStatus.class); } + @Test + 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())) + .thenReturn(ocspRespRevoked); + when(ocspClient.request(eq(FALLBACK_URI), any())) + .thenReturn(ocspRespGood); + CircuitBreakerConfig callerConfig = CircuitBreakerConfig.custom() + .recordExceptions(Throwable.class) + .slidingWindowSize(2) + .minimumNumberOfCalls(2) + .failureRateThreshold(50) + .permittedNumberOfCallsInHalfOpenState(1) + .build(); + + ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, null, callerConfig); + + // 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 whenOneFallbackIsConfiguredAndPrimaryFails_thenRevocationInfoListShouldHaveTwoElements() throws Exception { OcspClient ocspClient = mock(OcspClient.class); @@ -349,6 +381,10 @@ void whenOcspResponseStatusIsUnauthorized_thenThrows() throws Exception { } private ResilientOcspCertificateRevocationChecker buildChecker(OcspClient ocspClient, RetryConfig retryConfig) throws Exception { + return buildChecker(ocspClient, retryConfig, CircuitBreakerConfig.ofDefaults()); + } + + private ResilientOcspCertificateRevocationChecker buildChecker(OcspClient ocspClient, RetryConfig retryConfig, CircuitBreakerConfig circuitBreakerConfig) throws Exception { FallbackOcspService secondFallbackService = mock(FallbackOcspService.class); when(secondFallbackService.getAccessLocation()).thenReturn(SECOND_FALLBACK_URI); when(secondFallbackService.doesSupportNonce()).thenReturn(false); @@ -369,7 +405,7 @@ private ResilientOcspCertificateRevocationChecker buildChecker(OcspClient ocspCl return new ResilientOcspCertificateRevocationChecker( ocspClient, ocspServiceProvider, - CircuitBreakerConfig.ofDefaults(), + circuitBreakerConfig, retryConfig, OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW, LONG_THIS_UPDATE_AGE, From 5efaf4ad739730e13ae56a97ee5d87cd57c90504 Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Tue, 14 Apr 2026 19:45:06 +0300 Subject: [PATCH 04/19] AUT-2679 Validate Basic Constraints and Key Usage extensions of OCSP responder certificate --- .../ocsp/protocol/OcspResponseValidator.java | 50 +++++- .../webeid/ocsp/service/AiaOcspService.java | 8 +- .../ocsp/service/DesignatedOcspService.java | 3 + .../DesignatedOcspServiceConfiguration.java | 5 +- .../ocsp/service/FallbackOcspService.java | 8 +- .../FallbackOcspServiceConfiguration.java | 5 +- .../OcspCertificateRevocationCheckerTest.java | 8 +- .../protocol/OcspResponseValidatorTest.java | 166 ++++++++++++++++++ .../service/OcspServiceConfigurationTest.java | 151 ++++++++++++++++ .../webeid/ocsp/service/OcspServiceMaker.java | 4 +- .../ocsp/service/OcspServiceProviderTest.java | 140 +++++++++++++-- .../security/testutil/Certificates.java | 21 ++- .../testutil/TestCertificateBuilder.java | 70 ++++++++ ...ESTEID-SK_2018_AIA_OCSP_RESPONDER_2018.cer | Bin 0 -> 961 bytes .../TEST_of_self-signed_OCSP_RESPONDER.cer | Bin 0 -> 1095 bytes .../ocsp_response_unknown_self_signed.der | Bin 0 -> 1580 bytes 16 files changed, 601 insertions(+), 38 deletions(-) create mode 100644 src/test/java/eu/webeid/ocsp/service/OcspServiceConfigurationTest.java create mode 100644 src/test/java/eu/webeid/security/testutil/TestCertificateBuilder.java create mode 100644 src/test/resources/DEMO_of_ESTEID-SK_2018_AIA_OCSP_RESPONDER_2018.cer create mode 100644 src/test/resources/TEST_of_self-signed_OCSP_RESPONDER.cer create mode 100644 src/test/resources/ocsp_response_unknown_self_signed.der diff --git a/src/main/java/eu/webeid/ocsp/protocol/OcspResponseValidator.java b/src/main/java/eu/webeid/ocsp/protocol/OcspResponseValidator.java index e0679e2e..58b62e50 100644 --- a/src/main/java/eu/webeid/ocsp/protocol/OcspResponseValidator.java +++ b/src/main/java/eu/webeid/ocsp/protocol/OcspResponseValidator.java @@ -25,6 +25,7 @@ import java.security.cert.X509Certificate; import java.time.Duration; import java.time.Instant; +import java.util.List; import java.util.Objects; public final class OcspResponseValidator { @@ -35,14 +36,57 @@ public final class OcspResponseValidator { * https://oidref.com/1.3.6.1.5.5.7.3.9 */ private static final String OID_OCSP_SIGNING = "1.3.6.1.5.5.7.3.9"; + private static final int KEY_USAGE_DIGITAL_SIGNATURE_BIT_INDEX = 0; + private static final int KEY_USAGE_KEY_CERT_SIGN_BIT_INDEX = 5; private static final String ERROR_PREFIX = "Certificate status update time check failed: "; - public static void validateHasSigningExtension(X509Certificate certificate) throws OCSPCertificateException { + public static void validateBasicConstraintsNotCA(X509Certificate certificate) throws OCSPCertificateException { + Objects.requireNonNull(certificate, "certificate"); + // X509Certificate.getBasicConstraints() returns -1 when the Basic Constraints extension is absent + // or when cA=FALSE, and a non-negative value only when cA=TRUE (the pathLenConstraint, or + // Integer.MAX_VALUE when cA=TRUE without pathLenConstraint). + if (certificate.getBasicConstraints() >= 0) { + throw new OCSPCertificateException("Certificate " + certificate.getSubjectX500Principal() + + " must not be a CA certificate (Basic Constraints CA:TRUE is not allowed for OCSP responder)"); + } + } + + public static void validateKeyUsageDigitalSignature(X509Certificate certificate) throws OCSPCertificateException { + Objects.requireNonNull(certificate, "certificate"); + final boolean[] keyUsage = certificate.getKeyUsage(); + if (keyUsage == null) { + throw new OCSPCertificateException("Certificate " + certificate.getSubjectX500Principal() + + " does not contain the Key Usage extension required for OCSP response signing"); + } + if (keyUsage.length <= KEY_USAGE_DIGITAL_SIGNATURE_BIT_INDEX || !keyUsage[KEY_USAGE_DIGITAL_SIGNATURE_BIT_INDEX]) { + throw new OCSPCertificateException("Certificate " + certificate.getSubjectX500Principal() + + " Key Usage extension does not contain Digital Signature, which is required for OCSP response signing"); + } + } + + public static void validateKeyUsageNotCertificateSigning(X509Certificate certificate) throws OCSPCertificateException { + Objects.requireNonNull(certificate, "certificate"); + final boolean[] keyUsage = certificate.getKeyUsage(); + if (keyUsage == null) { + return; + } + if (keyUsage.length > KEY_USAGE_KEY_CERT_SIGN_BIT_INDEX && keyUsage[KEY_USAGE_KEY_CERT_SIGN_BIT_INDEX]) { + throw new OCSPCertificateException("Certificate " + certificate.getSubjectX500Principal() + + " Key Usage extension contains Certificate Signing, which is not allowed for OCSP responder"); + } + } + + public static void validateExtendedKeyUsageOcspSigning(X509Certificate certificate) throws OCSPCertificateException { Objects.requireNonNull(certificate, "certificate"); try { - if (certificate.getExtendedKeyUsage() == null || !certificate.getExtendedKeyUsage().contains(OID_OCSP_SIGNING)) { + final List extendedKeyUsage = certificate.getExtendedKeyUsage(); + if (extendedKeyUsage == null) { + throw new OCSPCertificateException("Certificate " + certificate.getSubjectX500Principal() + + " does not contain the Extended Key Usage extension required for OCSP response signing"); + } + if (!extendedKeyUsage.contains(OID_OCSP_SIGNING)) { throw new OCSPCertificateException("Certificate " + certificate.getSubjectX500Principal() + - " does not contain the key usage extension for OCSP response signing"); + " Extended Key Usage extension does not contain OCSP Signing, which is required for OCSP response signing"); } } catch (CertificateParsingException e) { throw new OCSPCertificateException("Certificate parsing failed:", e); diff --git a/src/main/java/eu/webeid/ocsp/service/AiaOcspService.java b/src/main/java/eu/webeid/ocsp/service/AiaOcspService.java index d66ad444..f727e2e1 100644 --- a/src/main/java/eu/webeid/ocsp/service/AiaOcspService.java +++ b/src/main/java/eu/webeid/ocsp/service/AiaOcspService.java @@ -67,9 +67,11 @@ public Optional getFallbackService() { public void validateResponderCertificate(X509CertificateHolder cert, Date now) throws AuthTokenException { try { final X509Certificate certificate = certificateConverter.getCertificate(cert); - CertificateValidator.requireCertificateIsValidOnDate(certificate, now, "AIA OCSP responder"); - // Trusted certificates' validity has been already verified in validateCertificateExpiry(). - OcspResponseValidator.validateHasSigningExtension(certificate); + CertificateValidator.requireCertificateIsValidOnDate(certificate, now, "AIA OCSP responder"); // Trusted certificates' validity has been already verified in validateCertificateExpiry(). + OcspResponseValidator.validateBasicConstraintsNotCA(certificate); + OcspResponseValidator.validateKeyUsageDigitalSignature(certificate); + OcspResponseValidator.validateKeyUsageNotCertificateSigning(certificate); + OcspResponseValidator.validateExtendedKeyUsageOcspSigning(certificate); CertificateValidator.validateCertificateTrustAndRevocation( certificate, trustedCACertificateAnchors, diff --git a/src/main/java/eu/webeid/ocsp/service/DesignatedOcspService.java b/src/main/java/eu/webeid/ocsp/service/DesignatedOcspService.java index a803d6b1..d4b48fea 100644 --- a/src/main/java/eu/webeid/ocsp/service/DesignatedOcspService.java +++ b/src/main/java/eu/webeid/ocsp/service/DesignatedOcspService.java @@ -43,6 +43,9 @@ public URI getAccessLocation() { public void validateResponderCertificate(X509CertificateHolder cert, 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 5db67af4..3fb5daf1 100644 --- a/src/main/java/eu/webeid/ocsp/service/DesignatedOcspServiceConfiguration.java +++ b/src/main/java/eu/webeid/ocsp/service/DesignatedOcspServiceConfiguration.java @@ -34,7 +34,10 @@ public DesignatedOcspServiceConfiguration(URI ocspServiceAccessLocation, X509Cer this.ocspServiceAccessLocation = Objects.requireNonNull(ocspServiceAccessLocation, "OCSP service access location"); this.responderCertificate = Objects.requireNonNull(responderCertificate, "OCSP responder certificate"); this.supportedIssuers = getIssuerX500Names(Objects.requireNonNull(supportedCertificateIssuers, "supported issuers")); - OcspResponseValidator.validateHasSigningExtension(responderCertificate); + OcspResponseValidator.validateBasicConstraintsNotCA(responderCertificate); + OcspResponseValidator.validateKeyUsageDigitalSignature(responderCertificate); + OcspResponseValidator.validateKeyUsageNotCertificateSigning(responderCertificate); + OcspResponseValidator.validateExtendedKeyUsageOcspSigning(responderCertificate); this.doesSupportNonce = doesSupportNonce; } diff --git a/src/main/java/eu/webeid/ocsp/service/FallbackOcspService.java b/src/main/java/eu/webeid/ocsp/service/FallbackOcspService.java index 52a8774d..fd47e49d 100644 --- a/src/main/java/eu/webeid/ocsp/service/FallbackOcspService.java +++ b/src/main/java/eu/webeid/ocsp/service/FallbackOcspService.java @@ -88,6 +88,9 @@ public void validateResponderCertificate(X509CertificateHolder cert, Date now) t } 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)) { @@ -97,7 +100,10 @@ private void validatePinnedResponderCertificate(X509Certificate responderCertifi } private void validateResponderCertificateAgainstTrustedCa(X509Certificate responderCertificate, Date now) throws AuthTokenException { - OcspResponseValidator.validateHasSigningExtension(responderCertificate); + OcspResponseValidator.validateBasicConstraintsNotCA(responderCertificate); + OcspResponseValidator.validateKeyUsageDigitalSignature(responderCertificate); + OcspResponseValidator.validateKeyUsageNotCertificateSigning(responderCertificate); + OcspResponseValidator.validateExtendedKeyUsageOcspSigning(responderCertificate); CertificateValidator.validateCertificateTrustAndRevocation( responderCertificate, trustedCACertificateAnchors, diff --git a/src/main/java/eu/webeid/ocsp/service/FallbackOcspServiceConfiguration.java b/src/main/java/eu/webeid/ocsp/service/FallbackOcspServiceConfiguration.java index 49bedd3a..d883825d 100644 --- a/src/main/java/eu/webeid/ocsp/service/FallbackOcspServiceConfiguration.java +++ b/src/main/java/eu/webeid/ocsp/service/FallbackOcspServiceConfiguration.java @@ -51,7 +51,10 @@ public FallbackOcspServiceConfiguration(URI accessLocation, X509Certificate resp this.accessLocation = Objects.requireNonNull(accessLocation, "Fallback OCSP service access location"); this.responderCertificate = responderCertificate; if (responderCertificate != null) { - OcspResponseValidator.validateHasSigningExtension(responderCertificate); + OcspResponseValidator.validateBasicConstraintsNotCA(responderCertificate); + OcspResponseValidator.validateKeyUsageDigitalSignature(responderCertificate); + OcspResponseValidator.validateKeyUsageNotCertificateSigning(responderCertificate); + OcspResponseValidator.validateExtendedKeyUsageOcspSigning(responderCertificate); } this.doesSupportNonce = doesSupportNonce; this.nextFallbackConfiguration = nextFallbackConfiguration; diff --git a/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java b/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java index 4bd31619..65b38c2a 100644 --- a/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java +++ b/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java @@ -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,21 +250,21 @@ void whenOcspResponseUnknown_thenThrows() throws Exception { @Test void whenOcspResponseCACertNotTrusted_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); assertThatExceptionOfType(CertificateNotTrustedException.class) .isThrownBy(() -> validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) - .withMessage("Certificate EMAILADDRESS=pki@sk.ee, CN=TEST of SK OCSP RESPONDER 2020, OU=OCSP, O=AS Sertifitseerimiskeskus, C=EE is not trusted"); + .withMessage("Certificate CN=TEST of self-signed OCSP RESPONDER 2024, OU=OCSP, O=TEST Self-signed OCSP, C=EE is not trusted"); } } @Test void whenOcspResponseCACertExpired_thenThrows() throws Exception { final OcspCertificateRevocationChecker validator = getOcspCertificateRevocationCheckerWithAiaOcsp( - getMockedResponse(getOcspResponseBytesFromResources("ocsp_response_unknown.der")) + getMockedResponse(getOcspResponseBytesFromResources("ocsp_response_unknown_self_signed.der")) ); assertThatExceptionOfType(CertificateExpiredException.class) .isThrownBy(() -> diff --git a/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java b/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java index 677bc8dd..e346a277 100644 --- a/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java +++ b/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java @@ -4,24 +4,38 @@ package eu.webeid.ocsp.protocol; import eu.webeid.ocsp.OcspCertificateRevocationChecker; +import eu.webeid.ocsp.exceptions.OCSPCertificateException; import eu.webeid.ocsp.exceptions.UserCertificateOCSPCheckFailedException; import eu.webeid.ocsp.exceptions.UserCertificateRevokedException; +import org.bouncycastle.asn1.DLBitString; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.ExtendedKeyUsage; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.bouncycastle.asn1.x509.KeyUsage; import org.bouncycastle.cert.ocsp.BasicOCSPResp; import org.bouncycastle.cert.ocsp.OCSPResp; import org.bouncycastle.cert.ocsp.SingleResp; import org.junit.jupiter.api.Test; import java.net.URI; +import java.security.cert.X509Certificate; import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Date; import static eu.webeid.ocsp.OcspCertificateRevocationCheckerTest.getOcspResponseBytesFromResources; +import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateBasicConstraintsNotCA; import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateCertificateStatusUpdateTime; +import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateExtendedKeyUsageOcspSigning; +import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateKeyUsageDigitalSignature; +import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateKeyUsageNotCertificateSigning; import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateSubjectCertificateStatus; +import static eu.webeid.security.testutil.TestCertificateBuilder.buildCertificate; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -113,6 +127,158 @@ void whenOcspResponseStatusIsUnknown_ThenThrowsUserCertificateOCSPCheckFailedExc .withMessage("User certificate revocation check has failed: Unknown status (OCSP responder: https://example.org)"); } + @Test + void whenCertIsNotCA_thenBasicConstraintsValidationSucceeds() throws Exception { + final X509Certificate cert = buildCertificate( + new Extension(Extension.basicConstraints, true, new BasicConstraints(false).getEncoded())); + assertThatCode(() -> validateBasicConstraintsNotCA(cert)).doesNotThrowAnyException(); + } + + @Test + void whenBasicConstraintsExtensionAbsent_thenBasicConstraintsValidationSucceeds() throws Exception { + final X509Certificate cert = buildCertificate(); + assertThatCode(() -> validateBasicConstraintsNotCA(cert)).doesNotThrowAnyException(); + } + + @Test + void whenCertIsCA_thenBasicConstraintsValidationThrows() throws Exception { + final X509Certificate cert = buildCertificate( + new Extension(Extension.basicConstraints, true, new BasicConstraints(0).getEncoded())); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> validateBasicConstraintsNotCA(cert)) + .withMessage("Certificate " + cert.getSubjectX500Principal() + + " must not be a CA certificate (Basic Constraints CA:TRUE is not allowed for OCSP responder)"); + } + + @Test + void whenCertIsNull_thenBasicConstraintsValidationThrowsNullPointerException() { + assertThatNullPointerException().isThrownBy(() -> validateBasicConstraintsNotCA(null)) + .withMessage("certificate"); + } + + @Test + void whenCertHasKeyUsageDigitalSignature_thenKeyUsageValidationSucceeds() throws Exception { + final X509Certificate cert = buildCertificate( + new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature).getEncoded())); + assertThatCode(() -> validateKeyUsageDigitalSignature(cert)).doesNotThrowAnyException(); + } + + @Test + void whenKeyUsageExtensionAbsent_thenKeyUsageValidationThrows() throws Exception { + final X509Certificate cert = buildCertificate(); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> validateKeyUsageDigitalSignature(cert)) + .withMessage("Certificate " + cert.getSubjectX500Principal() + + " does not contain the Key Usage extension required for OCSP response signing"); + } + + @Test + void whenCertMissingKeyUsageDigitalSignature_thenKeyUsageValidationThrows() throws Exception { + final X509Certificate cert = buildCertificate( + new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.nonRepudiation).getEncoded())); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> validateKeyUsageDigitalSignature(cert)) + .withMessage("Certificate " + cert.getSubjectX500Principal() + + " Key Usage extension does not contain Digital Signature, which is required for OCSP response signing"); + } + + @Test + void whenCertKeyUsageBitStringEmpty_thenKeyUsageValidationThrows() throws Exception { + final X509Certificate cert = buildCertificate( + new Extension(Extension.keyUsage, true, new DLBitString(new byte[0]).getEncoded())); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> validateKeyUsageDigitalSignature(cert)) + .withMessage("Certificate " + cert.getSubjectX500Principal() + + " Key Usage extension does not contain Digital Signature, which is required for OCSP response signing"); + } + + @Test + void whenCertIsNull_thenKeyUsageValidationThrowsNullPointerException() { + assertThatNullPointerException().isThrownBy(() -> validateKeyUsageDigitalSignature(null)) + .withMessage("certificate"); + } + + @Test + void whenCertHasNoKeyUsageKeyCertSign_thenKeyCertSignValidationSucceeds() throws Exception { + final X509Certificate cert = buildCertificate( + new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature).getEncoded())); + assertThatCode(() -> validateKeyUsageNotCertificateSigning(cert)).doesNotThrowAnyException(); + } + + @Test + void whenKeyUsageExtensionAbsent_thenKeyCertSignValidationSucceeds() throws Exception { + final X509Certificate cert = buildCertificate(); + assertThatCode(() -> validateKeyUsageNotCertificateSigning(cert)).doesNotThrowAnyException(); + } + + @Test + void whenCertKeyUsageBitStringEmpty_thenKeyCertSignValidationSucceeds() throws Exception { + final X509Certificate cert = buildCertificate( + new Extension(Extension.keyUsage, true, new DLBitString(new byte[0]).getEncoded())); + assertThatCode(() -> validateKeyUsageNotCertificateSigning(cert)).doesNotThrowAnyException(); + } + + @Test + void whenCertHasKeyUsageKeyCertSign_thenKeyCertSignValidationThrows() throws Exception { + final X509Certificate cert = buildCertificate( + new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign).getEncoded())); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> validateKeyUsageNotCertificateSigning(cert)) + .withMessage("Certificate " + cert.getSubjectX500Principal() + + " Key Usage extension contains Certificate Signing, which is not allowed for OCSP responder"); + } + + @Test + void whenCertHasKeyUsageKeyCertSignCombinedWithDigitalSignature_thenKeyCertSignValidationThrows() throws Exception { + final X509Certificate cert = buildCertificate( + new Extension(Extension.keyUsage, true, + new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyCertSign).getEncoded())); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> validateKeyUsageNotCertificateSigning(cert)) + .withMessage("Certificate " + cert.getSubjectX500Principal() + + " Key Usage extension contains Certificate Signing, which is not allowed for OCSP responder"); + } + + @Test + void whenCertIsNull_thenKeyCertSignValidationThrowsNullPointerException() { + assertThatNullPointerException().isThrownBy(() -> validateKeyUsageNotCertificateSigning(null)) + .withMessage("certificate"); + } + + @Test + void whenCertHasOcspSigningEku_thenExtendedKeyUsageValidationSucceeds() throws Exception { + final X509Certificate cert = buildCertificate( + new Extension(Extension.extendedKeyUsage, true, + new ExtendedKeyUsage(KeyPurposeId.id_kp_OCSPSigning).getEncoded())); + assertThatCode(() -> validateExtendedKeyUsageOcspSigning(cert)).doesNotThrowAnyException(); + } + + @Test + void whenExtendedKeyUsageExtensionAbsent_thenExtendedKeyUsageValidationThrows() throws Exception { + final X509Certificate cert = buildCertificate(); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> validateExtendedKeyUsageOcspSigning(cert)) + .withMessage("Certificate " + cert.getSubjectX500Principal() + + " does not contain the Extended Key Usage extension required for OCSP response signing"); + } + + @Test + void whenCertMissingOcspSigningEku_thenExtendedKeyUsageValidationThrows() throws Exception { + final X509Certificate cert = buildCertificate( + new Extension(Extension.extendedKeyUsage, true, + new ExtendedKeyUsage(KeyPurposeId.id_kp_clientAuth).getEncoded())); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> validateExtendedKeyUsageOcspSigning(cert)) + .withMessage("Certificate " + cert.getSubjectX500Principal() + + " Extended Key Usage extension does not contain OCSP Signing, which is required for OCSP response signing"); + } + + @Test + void whenCertIsNull_thenExtendedKeyUsageValidationThrowsNullPointerException() { + assertThatNullPointerException().isThrownBy(() -> validateExtendedKeyUsageOcspSigning(null)) + .withMessage("certificate"); + } + private static Date getThisUpdateWithinAgeLimit(Instant now) { return Date.from(now.minus(THIS_UPDATE_AGE.minusSeconds(1))); } 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..2e4933ab --- /dev/null +++ b/src/test/java/eu/webeid/ocsp/service/OcspServiceConfigurationTest.java @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2020-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package eu.webeid.ocsp.service; + +import eu.webeid.ocsp.exceptions.OCSPCertificateException; +import eu.webeid.security.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.security.testutil.Certificates.getTestEsteid2018CA; +import static eu.webeid.security.testutil.TestCertificateBuilder.buildCertificate; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +class OcspServiceConfigurationTest { + + private static final URI OCSP_URL = URI.create("http://demo.sk.ee/ocsp"); + + @Test + void whenDesignatedOcspServiceConfigurationResponderCertIsCA_thenConstructorThrows() throws Exception { + final X509Certificate caResponder = buildCertificate( + new Extension(Extension.basicConstraints, true, new BasicConstraints(0).getEncoded())); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> newDesignatedOcspServiceConfiguration(caResponder)) + .withMessage("Certificate " + caResponder.getSubjectX500Principal() + + " must not be a CA certificate (Basic Constraints CA:TRUE is not allowed for OCSP responder)"); + } + + @Test + void whenDesignatedOcspServiceConfigurationResponderCertMissingKeyUsageDigitalSignature_thenConstructorThrows() throws Exception { + final X509Certificate responder = buildCertificate( + new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.nonRepudiation).getEncoded())); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> newDesignatedOcspServiceConfiguration(responder)) + .withMessage("Certificate " + responder.getSubjectX500Principal() + + " Key Usage extension does not contain Digital Signature, which is required for OCSP response signing"); + } + + @Test + void whenDesignatedOcspServiceConfigurationResponderCertHasKeyCertSign_thenConstructorThrows() throws Exception { + final X509Certificate responder = buildCertificate( + new Extension(Extension.keyUsage, true, + new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyCertSign).getEncoded()), + new Extension(Extension.extendedKeyUsage, true, + new ExtendedKeyUsage(KeyPurposeId.id_kp_OCSPSigning).getEncoded())); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> newDesignatedOcspServiceConfiguration(responder)) + .withMessage("Certificate " + responder.getSubjectX500Principal() + + " Key Usage extension contains Certificate Signing, which is not allowed for OCSP responder"); + } + + @Test + void whenDesignatedOcspServiceConfigurationResponderCertMissingOcspSigningEku_thenConstructorThrows() throws Exception { + final X509Certificate responder = buildCertificate( + new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature).getEncoded()), + new Extension(Extension.extendedKeyUsage, true, + new ExtendedKeyUsage(KeyPurposeId.id_kp_clientAuth).getEncoded())); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> newDesignatedOcspServiceConfiguration(responder)) + .withMessage("Certificate " + responder.getSubjectX500Principal() + + " Extended Key Usage extension does not contain OCSP Signing, which is required for OCSP response signing"); + } + + @Test + void whenFallbackOcspServiceConfigurationResponderCertIsCA_thenConstructorThrows() throws Exception { + final X509Certificate caResponder = buildCertificate( + new Extension(Extension.basicConstraints, true, new BasicConstraints(0).getEncoded())); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> newFallbackOcspServiceConfiguration(caResponder)) + .withMessage("Certificate " + caResponder.getSubjectX500Principal() + + " must not be a CA certificate (Basic Constraints CA:TRUE is not allowed for OCSP responder)"); + } + + @Test + void whenFallbackOcspServiceConfigurationResponderCertMissingKeyUsageDigitalSignature_thenConstructorThrows() throws Exception { + final X509Certificate responder = buildCertificate( + new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.nonRepudiation).getEncoded())); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> newFallbackOcspServiceConfiguration(responder)) + .withMessage("Certificate " + responder.getSubjectX500Principal() + + " Key Usage extension does not contain Digital Signature, which is required for OCSP response signing"); + } + + @Test + void whenFallbackOcspServiceConfigurationResponderCertHasKeyCertSign_thenConstructorThrows() throws Exception { + final X509Certificate responder = buildCertificate( + new Extension(Extension.keyUsage, true, + new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyCertSign).getEncoded()), + new Extension(Extension.extendedKeyUsage, true, + new ExtendedKeyUsage(KeyPurposeId.id_kp_OCSPSigning).getEncoded())); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> newFallbackOcspServiceConfiguration(responder)) + .withMessage("Certificate " + responder.getSubjectX500Principal() + + " Key Usage extension contains Certificate Signing, which is not allowed for OCSP responder"); + } + + @Test + void whenFallbackOcspServiceConfigurationResponderCertMissingOcspSigningEku_thenConstructorThrows() throws Exception { + final X509Certificate responder = buildCertificate( + new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature).getEncoded()), + new Extension(Extension.extendedKeyUsage, true, + new ExtendedKeyUsage(KeyPurposeId.id_kp_clientAuth).getEncoded())); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> newFallbackOcspServiceConfiguration(responder)) + .withMessage("Certificate " + responder.getSubjectX500Principal() + + " Extended Key Usage extension does not contain OCSP Signing, which is required for OCSP response signing"); + } + + private static void newDesignatedOcspServiceConfiguration(X509Certificate responder) throws Exception { + new DesignatedOcspServiceConfiguration( + OCSP_URL, responder, List.of(getTestEsteid2018CA()), true); + } + + 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); + } + +} diff --git a/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java b/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java index 57417604..ac31769c 100644 --- a/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java +++ b/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java @@ -17,7 +17,7 @@ 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 { @@ -67,7 +67,7 @@ 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); } diff --git a/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java b/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java index 362cec81..168fc426 100644 --- a/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java +++ b/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java @@ -3,20 +3,33 @@ package eu.webeid.ocsp.service; +import eu.webeid.ocsp.exceptions.OCSPCertificateException; +import eu.webeid.security.certificate.CertificateValidator; +import eu.webeid.security.exceptions.CertificateNotTrustedException; +import org.bouncycastle.asn1.x500.X500Name; +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 eu.webeid.ocsp.exceptions.OCSPCertificateException; 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.service.OcspServiceMaker.getAiaOcspServiceProvider; 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.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; @@ -30,7 +43,7 @@ void whenDesignatedOcspServiceConfigurationProvided_thenCreatesDesignatedOcspSer assertThat(service.getAccessLocation()).isEqualTo(new URI("http://demo.sk.ee/ocsp")); assertThat(service.doesSupportNonce()).isTrue(); assertThatCode(() -> - service.validateResponderCertificate(new X509CertificateHolder(getTestSkOcspResponder2020().getEncoded()), new Date(1630000000000L))) + service.validateResponderCertificate(new X509CertificateHolder(getTestSelfSignedOcspResponder().getEncoded()), new Date(1630000000000L))) .doesNotThrowAnyException(); assertThatCode(() -> service.validateResponderCertificate(new X509CertificateHolder(getTestEsteid2018CA().getEncoded()), new Date(1630000000000L))) @@ -45,16 +58,7 @@ void whenAiaOcspServiceConfigurationProvided_thenCreatesAiaOcspService() throws assertThat(service2018.getAccessLocation()).isEqualTo(new URI("http://aia.demo.sk.ee/esteid2018")); assertThat(service2018.doesSupportNonce()).isTrue(); assertThatCode(() -> - // Use the CA certificate instead of responder certificate for convenience. - service2018.validateResponderCertificate(new X509CertificateHolder(getTestEsteid2018CA().getEncoded()), new Date(1630000000000L))) - .doesNotThrowAnyException(); - - final OcspService service2015 = ocspServiceProvider.getService(getMariliisEsteid2015Cert()); - assertThat(service2015.getAccessLocation()).isEqualTo(new URI("http://aia.demo.sk.ee/esteid2015")); - assertThat(service2015.doesSupportNonce()).isFalse(); - assertThatCode(() -> - // Use the CA certificate instead of responder certificate for convenience. - service2015.validateResponderCertificate(new X509CertificateHolder(getTestEsteid2015CA().getEncoded()), new Date(1630000000000L))) + service2018.validateResponderCertificate(new X509CertificateHolder(getDemoEsteidSk2018AiaOcspResponder().getEncoded()), new Date(1630000000000L))) .doesNotThrowAnyException(); } @@ -62,10 +66,112 @@ void whenAiaOcspServiceConfigurationProvided_thenCreatesAiaOcspService() throws void whenAiaOcspServiceConfigurationDoesNotHaveResponderCertTrustedCA_thenThrows() throws Exception { final OcspServiceProvider ocspServiceProvider = getAiaOcspServiceProvider(); final OcspService service2018 = ocspServiceProvider.getService(getJaakKristjanEsteid2018Cert()); - final X509CertificateHolder wrongResponderCert = new X509CertificateHolder(getMariliisEsteid2015Cert().getEncoded()); + final X509Certificate untrustedResponder = getTestSelfSignedOcspResponder(); + final X509CertificateHolder untrustedResponderCert = new X509CertificateHolder(untrustedResponder.getEncoded()); + assertThatExceptionOfType(CertificateNotTrustedException.class) + .isThrownBy(() -> + service2018.validateResponderCertificate(untrustedResponderCert, new Date(1630000000000L))) + .withMessage("Certificate " + untrustedResponder.getSubjectX500Principal() + " is not trusted"); + } + + @Test + void whenAiaOcspResponderCertIsCA_thenThrows() throws Exception { + final OcspServiceProvider ocspServiceProvider = getAiaOcspServiceProvider(); + final OcspService service2018 = ocspServiceProvider.getService(getJaakKristjanEsteid2018Cert()); + 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, new Date())) + .withMessage("Certificate " + caResponder.getSubjectX500Principal() + + " must not be a CA certificate (Basic Constraints CA:TRUE is not allowed for OCSP responder)"); + } + + @Test + void whenAiaOcspResponderCertMissingKeyUsageDigitalSignature_thenThrows() throws Exception { + final OcspServiceProvider ocspServiceProvider = getAiaOcspServiceProvider(); + final OcspService service2018 = ocspServiceProvider.getService(getJaakKristjanEsteid2018Cert()); + final X509Certificate responderWithoutDigitalSignature = buildCertificate( + new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.nonRepudiation).getEncoded())); + final X509CertificateHolder responderCert = new X509CertificateHolder(responderWithoutDigitalSignature.getEncoded()); + + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> + service2018.validateResponderCertificate(responderCert, new Date())) + .withMessage("Certificate " + responderWithoutDigitalSignature.getSubjectX500Principal() + + " Key Usage extension does not contain Digital Signature, which is required for OCSP response signing"); + } + + @Test + void whenAiaOcspResponderCertMissingOcspSigningEku_thenThrows() throws Exception { + final OcspServiceProvider ocspServiceProvider = getAiaOcspServiceProvider(); + final OcspService service2018 = ocspServiceProvider.getService(getJaakKristjanEsteid2018Cert()); + 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, 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, new Date())) + .withMessage("Certificate " + caResponder.getSubjectX500Principal() + + " must not be a CA certificate (Basic Constraints CA:TRUE is not allowed for OCSP responder)"); + } + + @Test + void whenFallbackOcspResponderCertMissingKeyUsageDigitalSignature_thenThrows() throws Exception { + final FallbackOcspService service = newFallbackOcspServiceWithoutPinnedResponder(); + final X509Certificate responderWithoutDigitalSignature = buildCertificate( + new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.nonRepudiation).getEncoded())); + final X509CertificateHolder responderCert = new X509CertificateHolder(responderWithoutDigitalSignature.getEncoded()); + + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> + service.validateResponderCertificate(responderCert, new Date())) + .withMessage("Certificate " + responderWithoutDigitalSignature.getSubjectX500Principal() + + " Key Usage extension does not contain Digital Signature, which is required for OCSP response signing"); + } + + @Test + void whenFallbackOcspResponderCertMissingOcspSigningEku_thenThrows() throws Exception { + final FallbackOcspService service = newFallbackOcspServiceWithoutPinnedResponder(); + final X509Certificate responderWithoutOcspSigning = buildCertificate( + new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature).getEncoded()), + new Extension(Extension.extendedKeyUsage, true, + new ExtendedKeyUsage(KeyPurposeId.id_kp_clientAuth).getEncoded())); + final X509CertificateHolder responderCert = new X509CertificateHolder(responderWithoutOcspSigning.getEncoded()); + assertThatExceptionOfType(OCSPCertificateException.class) .isThrownBy(() -> - service2018.validateResponderCertificate(wrongResponderCert, new Date(1630000000000L))); + service.validateResponderCertificate(responderCert, 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, new X500Name("CN=TEST ISSUER"), trustAnchors, certStore); + return new FallbackOcspService(configuration); } } diff --git a/src/test/java/eu/webeid/security/testutil/Certificates.java b/src/test/java/eu/webeid/security/testutil/Certificates.java index ec3dd235..8d4273c5 100644 --- a/src/test/java/eu/webeid/security/testutil/Certificates.java +++ b/src/test/java/eu/webeid/security/testutil/Certificates.java @@ -22,13 +22,15 @@ public class Certificates { private static X509Certificate jaakKristjanEsteid2018Cert; private static X509Certificate mariliisEsteid2015Cert; private static X509Certificate organizationCert; - 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_self-signed_OCSP_RESPONDER.cer", "DEMO_of_ESTEID-SK_2018_AIA_OCSP_RESPONDER_2018.cer"); testEsteid2015CA = certificates[0]; testEsteid2018CA = certificates[1]; - testSkOcspResponder2020 = certificates[2]; + testSelfSignedOcspResponder = certificates[2]; + demoEsteidSk2018AiaOcspResponder = certificates[3]; } public static X509Certificate getTestEsteid2018CA() throws CertificateException, IOException { @@ -45,11 +47,18 @@ public static X509Certificate getTestEsteid2015CA() throws CertificateException, return testEsteid2015CA; } - public static X509Certificate getTestSkOcspResponder2020() throws CertificateException, IOException { - if (testSkOcspResponder2020 == null) { + public static X509Certificate getTestSelfSignedOcspResponder() throws CertificateException, IOException { + if (testSelfSignedOcspResponder == null) { loadCertificates(); } - return testSkOcspResponder2020; + return testSelfSignedOcspResponder; + } + + public static X509Certificate getDemoEsteidSk2018AiaOcspResponder() throws CertificateException, IOException { + if (demoEsteidSk2018AiaOcspResponder == null) { + loadCertificates(); + } + return demoEsteidSk2018AiaOcspResponder; } public static X509Certificate getJaakKristjanEsteid2018Cert() throws CertificateDecodingException { 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..149a23e0 --- /dev/null +++ b/src/test/java/eu/webeid/security/testutil/TestCertificateBuilder.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2020-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package eu.webeid.security.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/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 0000000000000000000000000000000000000000..30ce32b07510e9f7318500588639903b83159727 GIT binary patch literal 961 zcmXqLV%}@e#4NvnnTe5!NucU&tHg}i59UNa+&xD?(x||Ii;Y98&EuRc3p0yBf+4p7 zCmVAp3!5;LtE-{3fh35-#Um8#t>EdR5S*V=T9TQcSFGR|Y$$FZ3X)9Z;qwa#a&^@; zG%z0;&)3ULh%QOHkI08*}=E=C5176x+SyoMHr2F8X)h6X?oCC+PXY+zwz z3FR6T8JZiIg3M*+(Q|S2^+#ye1-b!bgo2}|qk_M4aDYOPYjA+SpNne{Slp1;fE%Qd zn}-D?3v&i`$0K{wpovk*fR~LE7z%CQc^MfQSs9p{82K51;#^EkjEoFBMVnMFO{y|E z`Tq1V|Cc^BA-O#bH{#^Z<@O| zFYJpryUopmo8CV&?{FXC~>!qgZmlovYC#LA9W#*(7>w}V9 ze12LyBFX8cq!#HV7nKkfllsS`|W9Sp>*rjsa^X_|n&s(s0Rm~EXO7CQade_UsOQ#*H zSju^fyST-N>kyN3^{r+mN0~N*doBy!@_i4=U{Fx`{<2U)(8ctBWmemsLiNTTGuLIz lkFs~j{yd~?lW;!2^L@=}>(mZoH-Y3%*4pVB%ruRNBv`u-o>bwQ;gn;OxH8uW#iOp^Jx3d%gD&h%3zRZ z$Zf#M#vIDRCd}mOYA9zQ1LAP;h=#ZZhbRQ6=A`KsXQt<+rYQJ32L~AP8gPT;xp`Q? zTmu6=5SN)p9jrV*O`#Y~heD8RaDcy`i))aAk%5tkft)z6k%6J1p|PQnv9Ymf6p(9T zU*0AF)}iA6?0GddUf}{P&SX5t<@3ElV^QB zvBGM7`C={BS2yeL?TEFU66IS^8lrLFsa^=jEUqPUCbS2y`(~y8MC#$^CY$>K^EcPs zVc3zACi>K<)%}@>8CUlIqYP_)2JFAXt}pO!F;o4=Qx?pd5~MGjE08}Be%r(1SqRT= z`=Vov0Zgh7FXTOrZ%MhK`~3U4g)^Vc7ia%G&17<`o~{bpJDs9gPFugd{IesXw42Co(ZJGB7S~ykpRK z(|`{c*RuSKjQ?3!n3>oc48%ZuRS=)YfQyYon~jl`m7ST>Ko%s<$0EieGW*hueU9R< z-B+bq=bh75uZia4^EHqMNh`BR7>G4sR}cU*i;y$= Y-zqb;#Y>j&*A82{zRPgMZ|(V}0Ck*sUH||9 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9dee6fc43afedf76ab7faba76f73eb1b06f6fc83 GIT binary patch literal 1580 zcmXqLV$$NS&Bx~}!Ea<>WN2V%XklPrXl7&@We{f&W8iMU&c>?E$IK+fB4RY%Y0{ju zui@P1FRS#0bX>c*ZuM#wkpnF=HIMksw`MZ=HuE7@7z2lA)@X+*`7rB8FsT$Zu6ncNF7Kl=K%blMHO)U_`Y zq?a*GvURW7nRjK2+LvWUPubn$E%z;yIawz1>fOFYdrQg!ciYd&jS79^;C^wNKw7S9 z;B%3p-D(S(SiB9ISlofw*`SG8djT^OBNLN=;vyaOk3D)9qh3xidM7en4=v~n@(j5R zIN6v(S=fY`TwM+23}iqYE*{Yk*WeI^;MANn-Qvvjywns0f9K!;LtX=JkUTdJ3z%zQ zpaE+$a2>MG`*^7ZQOeW7d~Gh3@8oF~uvdSZpu`trqE ztgmj?-`f#uIVH-spfp6|z*D^tj#*qw=1gc0UiZyP|B2MY&rLS>1Lkk8yTh;}Cr$LJ zQLFni5i_pr|3?|t{0!KChh1Ob-(sfvkEbk{Hzi14I9DKlApEw6#j_Be-S$Pt7z3D8 zA703N9N&_1L-+aja|>ran=j7(d78=ORy|!6ws$&3vz)emd--QaL~lpZC&xECIpdt- zmpv~FeH|3W`akc|{kxpjLABAxI2V1knVom!h(K3Yz``A+Dn}01wOZP&jVP|ZaQT0{ zZRpAc`9U#TZ38;jmbs~Lui2$}cJ@umSEFs^0! z85#exurM>RHyDV4_^Kd2j{z4O2c$G%<}{E6iSx1GC`b(CLDI@B5(Z)oSW6m%0GL_8 zg2~0e$-n`|H(+Wr$tWo)D7MnqPpwGIEyzjLD@iRb(ML*ky7|e)1-hBV#igl5dMQAh zTvTGf1F}*Wy$=-zqb;#Y>j&*A82{zRPgMZ|(V}09k4@Hvj+t literal 0 HcmV?d00001 From 2234f38d30aa3a7a623120a0bfc0e328175ac272 Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Mon, 20 Apr 2026 11:56:26 +0300 Subject: [PATCH 05/19] AUT-2730 Fix validation order --- .../validator/AuthTokenValidatorImpl.java | 18 +++-- .../testutil/AbstractTestWithValidator.java | 11 ++- .../validator/AuthTokenCertificateTest.java | 19 ++--- .../AuthTokenSignatureValidatorTest.java | 13 +--- ...AuthTokenValidatorValidationOrderTest.java | 78 +++++++++++++++++++ 5 files changed, 107 insertions(+), 32 deletions(-) create mode 100644 src/test/java/eu/webeid/security/validator/AuthTokenValidatorValidationOrderTest.java diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java index afae8093..14331f7d 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java @@ -121,6 +121,16 @@ private ValidationInfo validateToken(WebEidAuthToken token, String currentChalle // Use the clock instance so that the date can be mocked in tests. final Date now = DateAndTime.DefaultClock.getInstance().now(); + // It is guaranteed that if the signature verification succeeds, then the origin and challenge + // have been implicitly and correctly verified without the need to implement any additional checks. + authTokenSignatureValidator.validate(token.algorithm(), + token.signature(), + subjectCertificate.getPublicKey(), + currentChallengeNonce + ); + + // Trust and revocation validation is the last step to ensure that all non-OCSP checks + // are completed before any OCSP requests are made. final List revocationInfoList = CertificateValidator.validateCertificateTrustAndRevocation( subjectCertificate, trustedCACertificateAnchors, @@ -133,14 +143,6 @@ private ValidationInfo validateToken(WebEidAuthToken token, String currentChalle ); LOG.debug("Subject certificate is valid and signed by a trusted CA"); - // It is guaranteed that if the signature verification succeeds, then the origin and challenge - // have been implicitly and correctly verified without the need to implement any additional checks. - authTokenSignatureValidator.validate(token.algorithm(), - token.signature(), - subjectCertificate.getPublicKey(), - currentChallengeNonce - ); - return new ValidationInfo(subjectCertificate, revocationInfoList); } diff --git a/src/test/java/eu/webeid/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/validator/AuthTokenCertificateTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenCertificateTest.java index bc9982ad..0ff15224 100644 --- a/src/test/java/eu/webeid/security/validator/AuthTokenCertificateTest.java +++ b/src/test/java/eu/webeid/security/validator/AuthTokenCertificateTest.java @@ -4,6 +4,7 @@ package eu.webeid.security.validator; import com.fasterxml.jackson.databind.exc.MismatchedInputException; +import eu.webeid.ocsp.exceptions.UserCertificateRevokedException; import eu.webeid.security.authtoken.WebEidAuthToken; import eu.webeid.security.exceptions.AuthTokenException; import eu.webeid.security.exceptions.AuthTokenParseException; @@ -13,7 +14,6 @@ import eu.webeid.security.exceptions.CertificateNotYetValidException; import eu.webeid.security.exceptions.UserCertificateDisallowedPolicyException; import eu.webeid.security.exceptions.UserCertificateMissingPurposeException; -import eu.webeid.ocsp.exceptions.UserCertificateRevokedException; import eu.webeid.security.exceptions.UserCertificateWrongPurposeException; import eu.webeid.security.testutil.AbstractTestWithValidator; import eu.webeid.security.testutil.AuthTokenValidators; @@ -45,8 +45,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; @@ -184,19 +182,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..a4754a94 100644 --- a/src/test/java/eu/webeid/security/validator/AuthTokenSignatureValidatorTest.java +++ b/src/test/java/eu/webeid/security/validator/AuthTokenSignatureValidatorTest.java @@ -5,20 +5,21 @@ 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.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.cert.X509Certificate; +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 +47,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 = 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..bb0edd2d --- /dev/null +++ b/src/test/java/eu/webeid/security/validator/AuthTokenValidatorValidationOrderTest.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2020-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package eu.webeid.security.validator; + +import eu.webeid.security.authtoken.WebEidAuthToken; +import eu.webeid.security.certificate.CertificateData; +import eu.webeid.security.exceptions.AuthTokenSignatureValidationException; +import eu.webeid.security.testutil.AuthTokenValidators; +import eu.webeid.security.validator.revocationcheck.CertificateRevocationChecker; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +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_CHALLENGE_NONCE; +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.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class AuthTokenValidatorValidationOrderTest { + + @Test + void whenSignatureIsInvalid_thenRevocationCheckerIsNotInvoked() throws Exception { + CertificateRevocationChecker checker = mock(CertificateRevocationChecker.class); + AuthTokenValidator validator = AuthTokenValidators.getDefaultAuthTokenValidatorBuilder() + .withCertificateRevocationChecker(checker) + .build(); + WebEidAuthToken token = validator.parse(VALID_AUTH_TOKEN); + + assertThatThrownBy(() -> validator.validate(token, "invalidToken")) + .isInstanceOf(AuthTokenSignatureValidationException.class); + verify(checker, never()).validateCertificateNotRevoked(any(), any()); + } + + @Test + void whenSignatureIsValid_thenRevocationCheckerIsInvoked() throws Exception { + CertificateRevocationChecker checker = mock(CertificateRevocationChecker.class); + when(checker.validateCertificateNotRevoked(any(), any())).thenReturn(List.of()); + AuthTokenValidator validator = AuthTokenValidators.getDefaultAuthTokenValidatorBuilder() + .withCertificateRevocationChecker(checker) + .build(); + WebEidAuthToken token = validator.parse(VALID_AUTH_TOKEN); + + assertThatCode(() -> validator.validate(token, VALID_CHALLENGE_NONCE)) + .doesNotThrowAnyException(); + ArgumentCaptor subjectCaptor = ArgumentCaptor.forClass(X509Certificate.class); + verify(checker).validateCertificateNotRevoked(subjectCaptor.capture(), any()); + assertThat(CertificateData.getSubjectCN(subjectCaptor.getValue()).orElseThrow()) + .isEqualTo("JÕEORG\\,JAAK-KRISTJAN\\,38001085718"); + } +} From 79f6708a9de641c7df1f6d97892a41a84c2c8134 Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Mon, 20 Apr 2026 12:40:29 +0300 Subject: [PATCH 06/19] AUT-2730 Throw UserCertificateOCSPCheckFailedException on service resolution failure --- ...lientOcspCertificateRevocationChecker.java | 3 ++- ...tOcspCertificateRevocationCheckerTest.java | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java b/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java index 6c4f43e9..573bac89 100644 --- a/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java +++ b/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java @@ -25,6 +25,7 @@ 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.UserCertificateRevokedException; import eu.webeid.ocsp.protocol.OcspRequestBuilder; import eu.webeid.ocsp.service.OcspService; @@ -135,7 +136,7 @@ private OcspService resolvePrimaryOcspService(X509Certificate subjectCertificate try { return getOcspServiceProvider().getService(subjectCertificate); } catch (CertificateException e) { - throw new ResilientUserCertificateOCSPCheckFailedException(new ValidationInfo(subjectCertificate, List.of())); + throw new UserCertificateOCSPCheckFailedException("Resolving primary OCSP service from subject certificate failed"); } } diff --git a/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java b/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java index 9f8dd552..9b80311c 100644 --- a/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java +++ b/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java @@ -25,6 +25,7 @@ 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.service.OcspService; import eu.webeid.ocsp.service.OcspServiceProvider; import eu.webeid.resilientocsp.exceptions.ResilientUserCertificateOCSPCheckFailedException; @@ -44,6 +45,7 @@ import org.junit.jupiter.api.Test; import java.net.URI; +import java.security.cert.CertificateEncodingException; import java.security.cert.X509Certificate; import java.time.Duration; import java.util.List; @@ -380,6 +382,26 @@ void whenOcspResponseStatusIsUnauthorized_thenThrows() throws Exception { assertThat(firstException.getMessage()).isEqualTo("Response status: unauthorized"); } + @Test + void whenOcspServiceProviderThrowsCertificateException_thenThrows() throws Exception { + OcspServiceProvider ocspServiceProvider = mock(OcspServiceProvider.class); + when(ocspServiceProvider.getService(any())).thenThrow(new CertificateEncodingException("bad cert")); + ResilientOcspCertificateRevocationChecker checker = new ResilientOcspCertificateRevocationChecker( + mock(OcspClient.class), + ocspServiceProvider, + CircuitBreakerConfig.ofDefaults(), + null, + OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW, + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, + LONG_THIS_UPDATE_AGE + ); + + assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .isExactlyInstanceOf(UserCertificateOCSPCheckFailedException.class) + .withMessage("Resolving primary OCSP service from subject certificate failed"); + } + private ResilientOcspCertificateRevocationChecker buildChecker(OcspClient ocspClient, RetryConfig retryConfig) throws Exception { return buildChecker(ocspClient, retryConfig, CircuitBreakerConfig.ofDefaults()); } From bea4b657854a4f912c5ebf4f0e437423265ee8f3 Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Mon, 20 Apr 2026 13:07:45 +0300 Subject: [PATCH 07/19] AUT-2730 Compute the CertificateID once outside the request method --- ...erCertificateOCSPCheckFailedException.java | 4 +++ ...lientOcspCertificateRevocationChecker.java | 32 ++++++++++++------- ...tOcspCertificateRevocationCheckerTest.java | 14 ++++++++ 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateOCSPCheckFailedException.java b/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateOCSPCheckFailedException.java index adae8579..2650dea0 100644 --- a/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateOCSPCheckFailedException.java +++ b/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateOCSPCheckFailedException.java @@ -30,4 +30,8 @@ public UserCertificateOCSPCheckFailedException(String message) { super(message); } + public UserCertificateOCSPCheckFailedException(String message, Throwable cause) { + super(message, cause); + } + } diff --git a/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java b/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java index 573bac89..3d85e2e7 100644 --- a/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java +++ b/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java @@ -28,11 +28,11 @@ import eu.webeid.ocsp.exceptions.UserCertificateOCSPCheckFailedException; import eu.webeid.ocsp.exceptions.UserCertificateRevokedException; import eu.webeid.ocsp.protocol.OcspRequestBuilder; +import eu.webeid.ocsp.service.FallbackOcspService; import eu.webeid.ocsp.service.OcspService; import eu.webeid.ocsp.service.OcspServiceProvider; import eu.webeid.resilientocsp.exceptions.ResilientUserCertificateOCSPCheckFailedException; import eu.webeid.resilientocsp.exceptions.ResilientUserCertificateRevokedException; -import eu.webeid.ocsp.service.FallbackOcspService; import eu.webeid.security.exceptions.AuthTokenException; import eu.webeid.security.validator.ValidationInfo; import eu.webeid.security.validator.revocationcheck.RevocationInfo; @@ -49,12 +49,15 @@ import org.bouncycastle.asn1.ocsp.OCSPResponseStatus; import org.bouncycastle.cert.ocsp.BasicOCSPResp; import org.bouncycastle.cert.ocsp.CertificateID; +import org.bouncycastle.cert.ocsp.OCSPException; import org.bouncycastle.cert.ocsp.OCSPReq; import org.bouncycastle.cert.ocsp.OCSPResp; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.IOException; import java.net.URI; +import java.security.cert.CertificateEncodingException; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.time.Duration; @@ -112,18 +115,26 @@ public ResilientOcspCertificateRevocationChecker(OcspClient ocspClient, public List validateCertificateNotRevoked(X509Certificate subjectCertificate, X509Certificate issuerCertificate) throws AuthTokenException { OcspService primaryService = resolvePrimaryOcspService(subjectCertificate); + + final CertificateID certificateId; + try { + certificateId = getCertificateId(subjectCertificate, issuerCertificate); + } catch (CertificateEncodingException | IOException | OCSPException e) { + throw new UserCertificateOCSPCheckFailedException("Unable to compute certificateId for subject certificate", e); + } + 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, certificateId, getMaxOcspResponseThisUpdateAge())); } CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(primaryService.getAccessLocation().toASCIIString()); List revocationInfoList = new ArrayList<>(); CheckedSupplier fallbackSupplier = buildFallbackSupplier(firstFallbackServiceOpt.get(), subjectCertificate, - issuerCertificate, revocationInfoList); + certificateId, revocationInfoList); CheckedSupplier decoratedSupplier = decorateWithResilience(primaryService, subjectCertificate, - issuerCertificate, revocationInfoList, fallbackSupplier, circuitBreaker); + certificateId, revocationInfoList, fallbackSupplier, circuitBreaker); // Take a snapshot of circuit breaker statistics right before the first request. CircuitBreakerStatistics circuitBreakerStatistics = createCircuitBreakerStatistics(circuitBreaker); @@ -158,11 +169,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, certificateId, fallbackMaxOcspResponseThisUpdateAge); } catch (Exception e) { createAndAddRevocationInfoToList(e, revocationInfoList); throw e; @@ -176,7 +187,7 @@ private CheckedSupplier buildFallbackSupplier(FallbackOcspServic } CheckedSupplier secondFallbackSupplier = () -> { try { - return request(secondFallbackService, subjectCertificate, issuerCertificate, fallbackMaxOcspResponseThisUpdateAge); + return request(secondFallbackService, subjectCertificate, certificateId, fallbackMaxOcspResponseThisUpdateAge); } catch (Exception e) { createAndAddRevocationInfoToList(e, revocationInfoList); throw e; @@ -198,13 +209,13 @@ private CheckedSupplier buildFallbackSupplier(FallbackOcspServic private CheckedSupplier decorateWithResilience(OcspService primaryService, X509Certificate subjectCertificate, - X509Certificate issuerCertificate, + CertificateID certificateId, List revocationInfoList, CheckedSupplier fallbackSupplier, CircuitBreaker circuitBreaker) { CheckedSupplier primarySupplier = () -> { try { - return request(primaryService, subjectCertificate, issuerCertificate, getMaxOcspResponseThisUpdateAge()); + return request(primaryService, subjectCertificate, certificateId, getMaxOcspResponseThisUpdateAge()); } catch (Exception e) { createAndAddRevocationInfoToList(e, revocationInfoList); throw e; @@ -266,7 +277,7 @@ private void createAndAddRevocationInfoToList(Throwable throwable, List checker.validateCertificateNotRevoked(estEid2018Cert, badIssuer)) + .isExactlyInstanceOf(UserCertificateOCSPCheckFailedException.class) + .withMessage("Unable to compute certificateId for subject certificate") + .withCause(encodingException); + } + private ResilientOcspCertificateRevocationChecker buildChecker(OcspClient ocspClient, RetryConfig retryConfig) throws Exception { return buildChecker(ocspClient, retryConfig, CircuitBreakerConfig.ofDefaults()); } From e0cb758040b4b696c58de1927c19cca70a65fa99 Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Mon, 20 Apr 2026 14:06:11 +0300 Subject: [PATCH 08/19] AUT-2730 Throw UserCertificateOCSPCheckFailedException on OCSP request building failure --- ...lientOcspCertificateRevocationChecker.java | 25 +++++++++++-------- ...tOcspCertificateRevocationCheckerTest.java | 8 +++--- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java b/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java index 3d85e2e7..b839ff20 100644 --- a/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java +++ b/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java @@ -147,7 +147,7 @@ private OcspService resolvePrimaryOcspService(X509Certificate subjectCertificate try { return getOcspServiceProvider().getService(subjectCertificate); } catch (CertificateException e) { - throw new UserCertificateOCSPCheckFailedException("Resolving primary OCSP service from subject certificate failed"); + throw new UserCertificateOCSPCheckFailedException("Resolving primary OCSP service from subject certificate failed", e); } } @@ -277,24 +277,27 @@ private void createAndAddRevocationInfoToList(Throwable throwable, List checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) .isExactlyInstanceOf(UserCertificateOCSPCheckFailedException.class) - .withMessage("Resolving primary OCSP service from subject certificate failed"); + .withMessage("Resolving primary OCSP service from subject certificate failed") + .withCause(encodingException); } @Test From a6131bac3dbe5f55ac2a6d793dd471d5863a818c Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Tue, 21 Apr 2026 13:18:42 +0300 Subject: [PATCH 09/19] AUT-2730 Add UserCertificateOCSPException for pre OCSP request failures --- ...erCertificateOCSPCheckFailedException.java | 4 -- .../UserCertificateOCSPException.java | 37 +++++++++++++++++++ ...lientOcspCertificateRevocationChecker.java | 9 +++-- 3 files changed, 42 insertions(+), 8 deletions(-) create mode 100644 src/main/java/eu/webeid/ocsp/exceptions/UserCertificateOCSPException.java diff --git a/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateOCSPCheckFailedException.java b/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateOCSPCheckFailedException.java index 2650dea0..adae8579 100644 --- a/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateOCSPCheckFailedException.java +++ b/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateOCSPCheckFailedException.java @@ -30,8 +30,4 @@ public UserCertificateOCSPCheckFailedException(String message) { super(message); } - public UserCertificateOCSPCheckFailedException(String message, Throwable cause) { - super(message, cause); - } - } 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..8db06c07 --- /dev/null +++ b/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateOCSPException.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2020-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package eu.webeid.ocsp.exceptions; + +import eu.webeid.security.exceptions.AuthTokenException; + +public class UserCertificateOCSPException extends AuthTokenException { + + public UserCertificateOCSPException(String message) { + super(message); + } + + public UserCertificateOCSPException(String message, Throwable exception) { + super(message, exception); + } + +} diff --git a/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java b/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java index b839ff20..de3705c2 100644 --- a/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java +++ b/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java @@ -26,6 +26,7 @@ import eu.webeid.ocsp.client.OcspClient; import eu.webeid.ocsp.exceptions.OCSPClientException; import eu.webeid.ocsp.exceptions.UserCertificateOCSPCheckFailedException; +import eu.webeid.ocsp.exceptions.UserCertificateOCSPException; import eu.webeid.ocsp.exceptions.UserCertificateRevokedException; import eu.webeid.ocsp.protocol.OcspRequestBuilder; import eu.webeid.ocsp.service.FallbackOcspService; @@ -120,7 +121,7 @@ public List validateCertificateNotRevoked(X509Certificate subjec try { certificateId = getCertificateId(subjectCertificate, issuerCertificate); } catch (CertificateEncodingException | IOException | OCSPException e) { - throw new UserCertificateOCSPCheckFailedException("Unable to compute certificateId for subject certificate", e); + throw new UserCertificateOCSPException("Unable to compute certificateId for subject certificate", e); } Optional firstFallbackServiceOpt = primaryService.getFallbackService(); @@ -147,7 +148,7 @@ private OcspService resolvePrimaryOcspService(X509Certificate subjectCertificate try { return getOcspServiceProvider().getService(subjectCertificate); } catch (CertificateException e) { - throw new UserCertificateOCSPCheckFailedException("Resolving primary OCSP service from subject certificate failed", e); + throw new UserCertificateOCSPException("Resolving primary OCSP service from subject certificate failed", e); } } @@ -277,7 +278,7 @@ private void createAndAddRevocationInfoToList(Throwable throwable, List Date: Tue, 21 Apr 2026 14:21:55 +0300 Subject: [PATCH 10/19] AUT-2730 Refactor OcspCertificateRevocationChecker to use UserCertificateOCSPException --- .../OcspCertificateRevocationChecker.java | 38 +++++++++++-------- .../webeid/ocsp/service/AiaOcspService.java | 8 ++-- .../ocsp/service/OcspServiceProvider.java | 24 +++++++----- ...lientOcspCertificateRevocationChecker.java | 23 ++--------- ...tOcspCertificateRevocationCheckerTest.java | 15 ++++---- 5 files changed, 51 insertions(+), 57 deletions(-) diff --git a/src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java b/src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java index 15465c10..2e70cfeb 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; @@ -85,22 +86,25 @@ public List validateCertificateNotRevoked(X509Certificate subjec requireNonNull(subjectCertificate, "subjectCertificate"); requireNonNull(issuerCertificate, "issuerCertificate"); - URI ocspResponderUri = null; + final OcspService ocspService = ocspServiceProvider.getService(subjectCertificate); + final CertificateID certificateId = getCertificateId(subjectCertificate, issuerCertificate); + final URI ocspResponderUri; + final OCSPReq request; try { - OcspService ocspService = ocspServiceProvider.getService(subjectCertificate); ocspResponderUri = requireNonNull(ocspService.getAccessLocation(), "ocspResponderUri"); - - final CertificateID certificateId = getCertificateId(subjectCertificate, issuerCertificate); - - final OCSPReq request = new OcspRequestBuilder() + request = new OcspRequestBuilder() .withCertificateId(certificateId) .enableOcspNonce(ocspService.doesSupportNonce()) .build(); + } catch (OCSPException | NullPointerException e) { + throw new UserCertificateOCSPException("Unable to create OCSP request", e); + } - 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) { @@ -120,7 +124,7 @@ 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); } } @@ -196,11 +200,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) { diff --git a/src/main/java/eu/webeid/ocsp/service/AiaOcspService.java b/src/main/java/eu/webeid/ocsp/service/AiaOcspService.java index f727e2e1..20694255 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; @@ -38,7 +38,7 @@ public class AiaOcspService implements OcspService { private final boolean supportsNonce; private final FallbackOcspService fallbackOcspService; - 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(); @@ -87,9 +87,9 @@ public void validateResponderCertificate(X509CertificateHolder cert, Date now) t } } - 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/OcspServiceProvider.java b/src/main/java/eu/webeid/ocsp/service/OcspServiceProvider.java index 8ee38cdb..2de93f8c 100644 --- a/src/main/java/eu/webeid/ocsp/service/OcspServiceProvider.java +++ b/src/main/java/eu/webeid/ocsp/service/OcspServiceProvider.java @@ -3,11 +3,10 @@ package eu.webeid.ocsp.service; -import eu.webeid.ocsp.exceptions.UserCertificateOCSPCheckFailedException; -import eu.webeid.security.exceptions.AuthTokenException; +import eu.webeid.ocsp.exceptions.UserCertificateOCSPException; import org.bouncycastle.asn1.x500.X500Name; -import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.util.Collection; import java.util.HashMap; @@ -44,15 +43,20 @@ public OcspServiceProvider(DesignatedOcspServiceConfiguration designatedOcspServ * * @param certificate subject certificate that is to be checked with OCSP * @return either the designated or AIA OCSP service instance - * @throws UserCertificateOCSPCheckFailedException when issuer common name is not found in certificate + * @throws UserCertificateOCSPException when the subject certificate's issuer cannot be extracted, + * or when the AIA OCSP responder URL cannot be resolved from the certificate * @throws IllegalArgumentException when certificate is invalid */ - public OcspService getService(X509Certificate certificate) throws AuthTokenException, CertificateEncodingException { - if (designatedOcspService != null && designatedOcspService.supportsIssuerOf(certificate)) { - return designatedOcspService; + public OcspService getService(X509Certificate certificate) throws UserCertificateOCSPException { + try { + if (designatedOcspService != null && designatedOcspService.supportsIssuerOf(certificate)) { + return designatedOcspService; + } + X500Name issuerDistinguishedName = getIssuerDistinguishedName(certificate); + FallbackOcspService fallbackOcspService = fallbackOcspServiceMap.get(issuerDistinguishedName); + return new AiaOcspService(aiaOcspServiceConfiguration, certificate, fallbackOcspService); + } catch (CertificateException e) { + throw new UserCertificateOCSPException("Resolving OCSP service from subject certificate failed", e); } - X500Name issuerDistinguishedName = getIssuerDistinguishedName(certificate); - FallbackOcspService fallbackOcspService = fallbackOcspServiceMap.get(issuerDistinguishedName); - return new AiaOcspService(aiaOcspServiceConfiguration, certificate, fallbackOcspService); } } diff --git a/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java b/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java index de3705c2..033aa37d 100644 --- a/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java +++ b/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java @@ -56,10 +56,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.IOException; import java.net.URI; -import java.security.cert.CertificateEncodingException; -import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.time.Duration; import java.time.Instant; @@ -115,14 +112,8 @@ public ResilientOcspCertificateRevocationChecker(OcspClient ocspClient, @Override public List validateCertificateNotRevoked(X509Certificate subjectCertificate, X509Certificate issuerCertificate) throws AuthTokenException { - OcspService primaryService = resolvePrimaryOcspService(subjectCertificate); - - final CertificateID certificateId; - try { - certificateId = getCertificateId(subjectCertificate, issuerCertificate); - } catch (CertificateEncodingException | IOException | OCSPException e) { - throw new UserCertificateOCSPException("Unable to compute certificateId for subject certificate", e); - } + OcspService primaryService = getOcspServiceProvider().getService(subjectCertificate); + CertificateID certificateId = getCertificateId(subjectCertificate, issuerCertificate); Optional firstFallbackServiceOpt = primaryService.getFallbackService(); if (firstFallbackServiceOpt.isEmpty()) { @@ -144,14 +135,6 @@ public List validateCertificateNotRevoked(X509Certificate subjec return revocationInfoList; } - private OcspService resolvePrimaryOcspService(X509Certificate subjectCertificate) throws AuthTokenException { - try { - return getOcspServiceProvider().getService(subjectCertificate); - } catch (CertificateException e) { - throw new UserCertificateOCSPException("Resolving primary OCSP service from subject certificate failed", e); - } - } - private CircuitBreakerStatistics createCircuitBreakerStatistics(CircuitBreaker circuitBreaker) { CircuitBreaker.Metrics metrics = circuitBreaker.getMetrics(); return new CircuitBreakerStatistics( @@ -287,7 +270,7 @@ private RevocationInfo request(OcspService ocspService, X509Certificate subjectC .withCertificateId(certificateId) .enableOcspNonce(ocspService.doesSupportNonce()) .build(); - } catch (Exception e) { + } catch (OCSPException | NullPointerException e) { throw new UserCertificateOCSPException("Unable to create OCSP request", e); } diff --git a/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java b/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java index f58e2cd6..d7014af1 100644 --- a/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java +++ b/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java @@ -25,7 +25,7 @@ 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.service.FallbackOcspService; import eu.webeid.ocsp.service.OcspService; import eu.webeid.ocsp.service.OcspServiceProvider; @@ -384,7 +384,7 @@ void whenOcspResponseStatusIsUnauthorized_thenThrows() throws Exception { @Test void whenOcspServiceProviderThrowsCertificateException_thenThrows() throws Exception { - CertificateEncodingException encodingException = new CertificateEncodingException(); + UserCertificateOCSPException encodingException = new UserCertificateOCSPException("Resolving primary OCSP service from subject certificate failed"); OcspServiceProvider ocspServiceProvider = mock(OcspServiceProvider.class); when(ocspServiceProvider.getService(any())).thenThrow(encodingException); ResilientOcspCertificateRevocationChecker checker = new ResilientOcspCertificateRevocationChecker( @@ -397,11 +397,10 @@ void whenOcspServiceProviderThrowsCertificateException_thenThrows() throws Excep LONG_THIS_UPDATE_AGE ); - assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) + assertThatExceptionOfType(UserCertificateOCSPException.class) .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) - .isExactlyInstanceOf(UserCertificateOCSPCheckFailedException.class) - .withMessage("Resolving primary OCSP service from subject certificate failed") - .withCause(encodingException); + .isExactlyInstanceOf(UserCertificateOCSPException.class) + .withMessage("Resolving primary OCSP service from subject certificate failed"); } @Test @@ -411,9 +410,9 @@ void whenCertificateIdComputationFails_thenThrows() throws Exception { CertificateEncodingException encodingException = new CertificateEncodingException("bad issuer"); when(badIssuer.getEncoded()).thenThrow(encodingException); - assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) + assertThatExceptionOfType(UserCertificateOCSPException.class) .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, badIssuer)) - .isExactlyInstanceOf(UserCertificateOCSPCheckFailedException.class) + .isExactlyInstanceOf(UserCertificateOCSPException.class) .withMessage("Unable to compute certificateId for subject certificate") .withCause(encodingException); } From 9d31c0d004aab8380c1199ea732304484e5cd6da Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Tue, 21 Apr 2026 15:40:10 +0300 Subject: [PATCH 11/19] AUT-2730 Update note --- .../eu/webeid/security/validator/AuthTokenValidatorImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java index 14331f7d..9a51ffed 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java @@ -129,7 +129,7 @@ private ValidationInfo validateToken(WebEidAuthToken token, String currentChalle currentChallengeNonce ); - // Trust and revocation validation is the last step to ensure that all non-OCSP checks + // Revocation validation is the last step to ensure that all non-network checks // are completed before any OCSP requests are made. final List revocationInfoList = CertificateValidator.validateCertificateTrustAndRevocation( subjectCertificate, From 195f144a2715c4b7ce572dcf0fb0c5a87ebece2d Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Tue, 28 Apr 2026 11:11:24 +0300 Subject: [PATCH 12/19] AUT-2730 Remove redundant access location null check --- .../java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java | 4 ++-- .../ResilientOcspCertificateRevocationChecker.java | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java b/src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java index 2e70cfeb..f0229b88 100644 --- a/src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java +++ b/src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java @@ -91,12 +91,12 @@ public List validateCertificateNotRevoked(X509Certificate subjec final URI ocspResponderUri; final OCSPReq request; try { - ocspResponderUri = requireNonNull(ocspService.getAccessLocation(), "ocspResponderUri"); + ocspResponderUri = ocspService.getAccessLocation(); request = new OcspRequestBuilder() .withCertificateId(certificateId) .enableOcspNonce(ocspService.doesSupportNonce()) .build(); - } catch (OCSPException | NullPointerException e) { + } catch (OCSPException e) { throw new UserCertificateOCSPException("Unable to create OCSP request", e); } diff --git a/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java b/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java index 033aa37d..5e84a90f 100644 --- a/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java +++ b/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java @@ -265,12 +265,12 @@ private RevocationInfo request(OcspService ocspService, X509Certificate subjectC final URI ocspResponderUri; final OCSPReq request; try { - ocspResponderUri = requireNonNull(ocspService.getAccessLocation(), "ocspResponderUri"); + ocspResponderUri = ocspService.getAccessLocation(); request = new OcspRequestBuilder() .withCertificateId(certificateId) .enableOcspNonce(ocspService.doesSupportNonce()) .build(); - } catch (OCSPException | NullPointerException e) { + } catch (OCSPException e) { throw new UserCertificateOCSPException("Unable to create OCSP request", e); } From b520b6a4f972ec8bdd6290bf80014f74a54646a5 Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Mon, 27 Apr 2026 11:43:28 +0300 Subject: [PATCH 13/19] AUT-2730 Extract getOcspRequest --- .../OcspCertificateRevocationChecker.java | 26 +++++++++++-------- ...lientOcspCertificateRevocationChecker.java | 15 ++--------- 2 files changed, 17 insertions(+), 24 deletions(-) diff --git a/src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java b/src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java index f0229b88..da3477d2 100644 --- a/src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java +++ b/src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java @@ -88,17 +88,8 @@ public List validateCertificateNotRevoked(X509Certificate subjec final OcspService ocspService = ocspServiceProvider.getService(subjectCertificate); final CertificateID certificateId = getCertificateId(subjectCertificate, issuerCertificate); - final URI ocspResponderUri; - final OCSPReq request; - try { - ocspResponderUri = ocspService.getAccessLocation(); - request = new OcspRequestBuilder() - .withCertificateId(certificateId) - .enableOcspNonce(ocspService.doesSupportNonce()) - .build(); - } catch (OCSPException e) { - throw new UserCertificateOCSPException("Unable to create OCSP request", e); - } + final URI ocspResponderUri = ocspService.getAccessLocation(); + final OCSPReq request = getOcspRequest(certificateId, ocspService); if (!ocspService.doesSupportNonce()) { LOG.debug("Disabling OCSP nonce extension"); @@ -129,6 +120,19 @@ public List validateCertificateNotRevoked(X509Certificate subjec } } + 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, Duration maxOcspResponseThisUpdateAge) throws AuthTokenException, OCSPException, CertificateException, OperatorCreationException { // The verification algorithm follows RFC 2560, https://www.ietf.org/rfc/rfc2560.txt. // diff --git a/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java b/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java index 5e84a90f..16afe9f5 100644 --- a/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java +++ b/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java @@ -28,7 +28,6 @@ import eu.webeid.ocsp.exceptions.UserCertificateOCSPCheckFailedException; import eu.webeid.ocsp.exceptions.UserCertificateOCSPException; import eu.webeid.ocsp.exceptions.UserCertificateRevokedException; -import eu.webeid.ocsp.protocol.OcspRequestBuilder; import eu.webeid.ocsp.service.FallbackOcspService; import eu.webeid.ocsp.service.OcspService; import eu.webeid.ocsp.service.OcspServiceProvider; @@ -50,7 +49,6 @@ import org.bouncycastle.asn1.ocsp.OCSPResponseStatus; import org.bouncycastle.cert.ocsp.BasicOCSPResp; import org.bouncycastle.cert.ocsp.CertificateID; -import org.bouncycastle.cert.ocsp.OCSPException; import org.bouncycastle.cert.ocsp.OCSPReq; import org.bouncycastle.cert.ocsp.OCSPResp; import org.slf4j.Logger; @@ -262,17 +260,8 @@ private void createAndAddRevocationInfoToList(Throwable throwable, List Date: Tue, 11 Aug 2026 16:41:27 +0300 Subject: [PATCH 14/19] AUT-2730 Mock date in validation order test to keep certificates valid --- ...AuthTokenValidatorValidationOrderTest.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/test/java/eu/webeid/security/validator/AuthTokenValidatorValidationOrderTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenValidatorValidationOrderTest.java index bb0edd2d..dbdc548a 100644 --- a/src/test/java/eu/webeid/security/validator/AuthTokenValidatorValidationOrderTest.java +++ b/src/test/java/eu/webeid/security/validator/AuthTokenValidatorValidationOrderTest.java @@ -26,26 +26,46 @@ import eu.webeid.security.certificate.CertificateData; import eu.webeid.security.exceptions.AuthTokenSignatureValidationException; import eu.webeid.security.testutil.AuthTokenValidators; +import eu.webeid.security.util.DateAndTime; import eu.webeid.security.validator.revocationcheck.CertificateRevocationChecker; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; import java.security.cert.X509Certificate; import java.util.List; import static eu.webeid.security.testutil.AbstractTestWithValidator.VALID_AUTH_TOKEN; import static eu.webeid.security.testutil.AbstractTestWithValidator.VALID_CHALLENGE_NONCE; +import static eu.webeid.security.testutil.DateMocker.mockDate; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class AuthTokenValidatorValidationOrderTest { + private MockedStatic mockedClock; + + @BeforeEach + void setUp() { + mockedClock = mockStatic(DateAndTime.DefaultClock.class); + // Ensure that the certificates do not expire. + mockDate("2021-07-23", mockedClock); + } + + @AfterEach + void tearDown() { + mockedClock.close(); + } + @Test void whenSignatureIsInvalid_thenRevocationCheckerIsNotInvoked() throws Exception { CertificateRevocationChecker checker = mock(CertificateRevocationChecker.class); From 531fb48e204eb98bc65bad42bbc358e1af133315 Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Fri, 22 May 2026 18:12:21 +0300 Subject: [PATCH 15/19] AUT-2759 Improve test coverage --- pom.xml | 7 + .../OcspCertificateRevocationCheckerTest.java | 35 +- .../ocsp/client/OcspClientImplTest.java | 226 ++++ .../protocol/IssuerDistinguishedNameTest.java | 46 + .../protocol/OcspResponseValidatorTest.java | 29 +- .../ocsp/service/AiaOcspServiceTest.java | 130 +++ .../FallbackOcspServiceConfigurationTest.java | 145 +++ .../ocsp/service/FallbackOcspServiceTest.java | 239 +++++ .../ocsp/service/OcspServiceProviderTest.java | 137 +++ ...tOcspCertificateRevocationCheckerTest.java | 977 ++++++++++++++---- .../security/testutil/Certificates.java | 30 +- .../security/testutil/ResourceUtil.java | 40 + ...EST_of_EE_Certification_Centre_Root_CA.cer | Bin 0 -> 1047 bytes 13 files changed, 1822 insertions(+), 219 deletions(-) create mode 100644 src/test/java/eu/webeid/ocsp/client/OcspClientImplTest.java create mode 100644 src/test/java/eu/webeid/ocsp/protocol/IssuerDistinguishedNameTest.java create mode 100644 src/test/java/eu/webeid/ocsp/service/AiaOcspServiceTest.java create mode 100644 src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceConfigurationTest.java create mode 100644 src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceTest.java create mode 100644 src/test/java/eu/webeid/security/testutil/ResourceUtil.java create mode 100644 src/test/resources/TEST_of_EE_Certification_Centre_Root_CA.cer diff --git a/pom.xml b/pom.xml index a8b0bdf5..6b00a637 100644 --- a/pom.xml +++ b/pom.xml @@ -22,6 +22,7 @@ 5.14.4 3.27.7 5.23.0 + 4.3.0 3.5.5 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/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java b/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java index 65b38c2a..d717b8ed 100644 --- a/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java +++ b/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java @@ -11,6 +11,7 @@ import eu.webeid.ocsp.exceptions.UserCertificateRevokedException; import eu.webeid.security.testutil.AbstractTestWithValidator; import eu.webeid.security.testutil.AuthTokenValidators; +import eu.webeid.security.testutil.ResourceUtil; import eu.webeid.security.util.DateAndTime; import eu.webeid.ocsp.client.OcspClient; import eu.webeid.ocsp.client.OcspClientImpl; @@ -25,7 +26,6 @@ import org.junit.jupiter.api.Test; import java.io.IOException; -import java.io.InputStream; import java.net.ConnectException; import java.net.URI; import java.net.URISyntaxException; @@ -47,13 +47,11 @@ import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.when; -public class OcspCertificateRevocationCheckerTest extends AbstractTestWithValidator { +class OcspCertificateRevocationCheckerTest extends AbstractTestWithValidator { private final OcspClient ocspClient = OcspClientImpl.build(Duration.ofSeconds(5)); private X509Certificate estEid2018Cert; @@ -117,11 +115,13 @@ void whenOcspUrlIsInvalid_thenThrows() throws Exception { void whenOcspRequestFails_thenThrows() throws Exception { final OcspServiceProvider ocspServiceProvider = getDesignatedOcspServiceProvider("http://demo.sk.ee/ocsps"); final OcspCertificateRevocationChecker validator = getOcspCertificateRevocationChecker(ocspServiceProvider); - UserCertificateOCSPCheckFailedException ex = assertThrows(UserCertificateOCSPCheckFailedException.class, () -> - validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); - OCSPClientException ocspClientException = assertInstanceOf(OCSPClientException.class, ex.getCause()); - assertThat(ocspClientException).hasMessageStartingWith("OCSP request was not successful"); - assertThat(ocspClientException.getStatusCode()).isEqualTo(404); + assertThatThrownBy(() -> + validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .isInstanceOf(UserCertificateOCSPCheckFailedException.class) + .cause() + .isInstanceOf(OCSPClientException.class) + .hasMessageStartingWith("OCSP request was not successful") + .satisfies(cause -> assertThat(((OCSPClientException) cause).getStatusCode()).isEqualTo(404)); } @Test @@ -352,10 +352,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 { @@ -401,15 +399,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..315c098e --- /dev/null +++ b/src/test/java/eu/webeid/ocsp/client/OcspClientImplTest.java @@ -0,0 +1,226 @@ +/* + * Copyright (c) 2020-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package eu.webeid.ocsp.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/protocol/IssuerDistinguishedNameTest.java b/src/test/java/eu/webeid/ocsp/protocol/IssuerDistinguishedNameTest.java new file mode 100644 index 00000000..dd9d9744 --- /dev/null +++ b/src/test/java/eu/webeid/ocsp/protocol/IssuerDistinguishedNameTest.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2020-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package eu.webeid.ocsp.protocol; + +import org.bouncycastle.asn1.x500.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 e346a277..490ae60c 100644 --- a/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java +++ b/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java @@ -14,7 +14,9 @@ import org.bouncycastle.asn1.x509.KeyPurposeId; import org.bouncycastle.asn1.x509.KeyUsage; import org.bouncycastle.cert.ocsp.BasicOCSPResp; +import org.bouncycastle.cert.ocsp.CertificateStatus; import org.bouncycastle.cert.ocsp.OCSPResp; +import org.bouncycastle.cert.ocsp.RevokedStatus; import org.bouncycastle.cert.ocsp.SingleResp; import org.junit.jupiter.api.Test; @@ -25,8 +27,8 @@ import java.time.temporal.ChronoUnit; import java.util.Date; -import static eu.webeid.ocsp.OcspCertificateRevocationCheckerTest.getOcspResponseBytesFromResources; import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateBasicConstraintsNotCA; +import static eu.webeid.security.testutil.ResourceUtil.bytesFromResource; import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateCertificateStatusUpdateTime; import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateExtendedKeyUsageOcspSigning; import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateKeyUsageDigitalSignature; @@ -279,14 +281,37 @@ void whenCertIsNull_thenExtendedKeyUsageValidationThrowsNullPointerException() { .withMessage("certificate"); } + @Test + void whenRevokedStatusHasNoReason_thenThrows() { + final SingleResp mockResponse = mock(SingleResp.class); + when(mockResponse.getCertStatus()).thenReturn(new RevokedStatus(new Date())); + assertThatExceptionOfType(UserCertificateRevokedException.class) + .isThrownBy(() -> + validateSubjectCertificateStatus(mockResponse, OCSP_URL)) + .withMessage("User certificate has been revoked (OCSP responder: https://example.org)"); + } + + @Test + void whenStatusIsNeitherGoodRevokedNorUnknown_thenThrowsUserCertificateOCSPCheckFailedException() { + final SingleResp mockResponse = mock(SingleResp.class); + when(mockResponse.getCertStatus()).thenReturn(new UnexpectedCertificateStatus()); + assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> + validateSubjectCertificateStatus(mockResponse, OCSP_URL)) + .withMessage("User certificate revocation check has failed: Status is neither good, revoked nor unknown (OCSP responder: https://example.org)"); + } + private static Date getThisUpdateWithinAgeLimit(Instant now) { return Date.from(now.minus(THIS_UPDATE_AGE.minusSeconds(1))); } private static SingleResp getUnknownCertStatusResponse() throws Exception { - final OCSPResp ocspRespUnknown = new OCSPResp(getOcspResponseBytesFromResources("ocsp_response_unknown.der")); + final OCSPResp ocspRespUnknown = new OCSPResp(bytesFromResource("ocsp_response_unknown.der")); final BasicOCSPResp basicResponse = (BasicOCSPResp) ocspRespUnknown.getResponseObject(); return basicResponse.getResponses()[0]; } + private static class UnexpectedCertificateStatus implements CertificateStatus { + } + } diff --git a/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceTest.java b/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceTest.java new file mode 100644 index 00000000..7547cc54 --- /dev/null +++ b/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceTest.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2020-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package eu.webeid.ocsp.service; + +import eu.webeid.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.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); + } + + @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, esteid2018IssuerDN, trustedCaAnchors, trustedCaCertStore); + 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 new file mode 100644 index 00000000..6c501a6b --- /dev/null +++ b/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceConfigurationTest.java @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2020-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package eu.webeid.ocsp.service; + +import eu.webeid.ocsp.exceptions.OCSPCertificateException; +import eu.webeid.security.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.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.assertThatNullPointerException; + +class FallbackOcspServiceConfigurationTest { + + private static final URI FALLBACK_URI = URI.create("http://fallback.ocsp.test"); + 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"); + + 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, ISSUER_DN, trustedCaAnchors, trustedCaCertStore)) + .withMessage("Fallback OCSP service access location"); + } + + @Test + void whenIssuerDnIsNull_thenThrows() { + assertThatNullPointerException() + .isThrownBy(() -> new FallbackOcspServiceConfiguration( + FALLBACK_URI, null, true, null, null, trustedCaAnchors, trustedCaCertStore)) + .withMessage("issuerDN"); + } + + @Test + void whenTrustedCaAnchorsIsNull_thenThrows() { + assertThatNullPointerException() + .isThrownBy(() -> new FallbackOcspServiceConfiguration( + FALLBACK_URI, null, true, null, ISSUER_DN, null, trustedCaCertStore)) + .withMessage("trustedCACertificateAnchors"); + } + + @Test + void whenTrustedCaCertStoreIsNull_thenThrows() { + assertThatNullPointerException() + .isThrownBy(() -> new FallbackOcspServiceConfiguration( + FALLBACK_URI, null, true, null, ISSUER_DN, trustedCaAnchors, null)) + .withMessage("trustedCACertificateCertStore"); + } + + @Test + void whenResponderCertificateLacksSigningExtension_thenThrows() { + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> new FallbackOcspServiceConfiguration( + FALLBACK_URI, nonSigningUserCert, true, null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore)) + .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, ISSUER_DN, trustedCaAnchors, trustedCaCertStore)) + .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, ISSUER_DN, trustedCaAnchors, trustedCaCertStore)) + .doesNotThrowAnyException(); + } + + @Test + void whenResponderCertificateHasSigningExtension_thenConstructionSucceedsAndAccessorsReturnConfiguredValues() throws Exception { + FallbackOcspServiceConfiguration nextFallback = new FallbackOcspServiceConfiguration( + URI.create("http://next.fallback.ocsp.test"), null, false, null, + ISSUER_DN, trustedCaAnchors, trustedCaCertStore); + + FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( + FALLBACK_URI, aiaOcspResponderCert, true, nextFallback, + ISSUER_DN, trustedCaAnchors, trustedCaCertStore); + + assertThat(configuration.getAccessLocation()).isEqualTo(FALLBACK_URI); + assertThat(configuration.getResponderCertificate()).isEqualTo(aiaOcspResponderCert); + assertThat(configuration.doesSupportNonce()).isTrue(); + assertThat(configuration.getNextFallbackConfiguration()).isSameAs(nextFallback); + assertThat(configuration.getIssuerDN()).isEqualTo(ISSUER_DN); + assertThat(configuration.getTrustedCACertificateAnchors()).isSameAs(trustedCaAnchors); + assertThat(configuration.getTrustedCACertificateCertStore()).isSameAs(trustedCaCertStore); + } +} 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..0b8aaa83 --- /dev/null +++ b/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceTest.java @@ -0,0 +1,239 @@ +/* + * Copyright (c) 2020-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package eu.webeid.ocsp.service; + +import eu.webeid.ocsp.exceptions.OCSPCertificateException; +import eu.webeid.security.certificate.CertificateValidator; +import eu.webeid.security.exceptions.CertificateExpiredException; +import eu.webeid.security.exceptions.CertificateNotTrustedException; +import org.bouncycastle.asn1.x500.X500Name; +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.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 X500Name ISSUER_DN = new X500Name("CN=TEST of ESTEID-SK 2015, OID.2.5.4.97=NTREE-10747013, O=AS Sertifitseerimiskeskus, C=EE"); + 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, ISSUER_DN, trustedCaAnchors, trustedCaCertStore); + + 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, ISSUER_DN, trustedCaAnchors, trustedCaCertStore); + FallbackOcspServiceConfiguration primaryConfiguration = new FallbackOcspServiceConfiguration( + PRIMARY_FALLBACK_URI, null, true, + secondaryConfiguration, ISSUER_DN, trustedCaAnchors, trustedCaCertStore); + + 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, ISSUER_DN, trustedCaAnchors, trustedCaCertStore); + FallbackOcspService service = new FallbackOcspService(configuration); + X509CertificateHolder matchingHolder = new X509CertificateHolder(aiaOcspResponderCert.getEncoded()); + + assertThatCode(() -> + service.validateResponderCertificate(matchingHolder, VALIDATION_DATE_WITHIN_RESPONDER_VALIDITY)) + .doesNotThrowAnyException(); + } + + @Test + void whenResponderCertificateIsPinnedAndDiffers_thenThrows() throws Exception { + FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( + PRIMARY_FALLBACK_URI, aiaOcspResponderCert, true, + null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore); + FallbackOcspService service = new FallbackOcspService(configuration); + X509CertificateHolder differentHolder = new X509CertificateHolder(esteid2018CaCert.getEncoded()); + + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> + service.validateResponderCertificate(differentHolder, 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, ISSUER_DN, trustedCaAnchors, trustedCaCertStore); + 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, VALIDATION_DATE_WITHIN_RESPONDER_VALIDITY)) + .doesNotThrowAnyException(); + } + + @Test + void whenResponderCertificateIsNotPinnedAndLacksSigningExtension_thenThrows() throws Exception { + FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( + PRIMARY_FALLBACK_URI, null, true, + null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore); + FallbackOcspService service = new FallbackOcspService(configuration); + X509CertificateHolder nonSigningHolder = new X509CertificateHolder(nonSigningUserCert.getEncoded()); + + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> + service.validateResponderCertificate(nonSigningHolder, 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, ISSUER_DN, trustedCaAnchors, trustedCaCertStore); + FallbackOcspService service = new FallbackOcspService(configuration); + X509CertificateHolder noKeyUsageHolder = new X509CertificateHolder(ocspResponder2020Cert.getEncoded()); + + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> + service.validateResponderCertificate(noKeyUsageHolder, VALIDATION_DATE_WITHIN_RESPONDER_VALIDITY)) + .withMessageContaining("does not contain the Key Usage extension required for OCSP response signing"); + } + + @Test + void whenResponderCertificateIsNotPinnedAndNotTrustedByCa_thenThrows() throws Exception { + FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( + PRIMARY_FALLBACK_URI, null, true, + null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore); + FallbackOcspService service = new FallbackOcspService(configuration); + // The self-signed responder certificate satisfies every OCSP responder extension requirement, but no + // trusted CA issued it (only the ESTEID2018 and ESTEID-SK 2015 CAs are anchors), so PKIX path building fails. + X509CertificateHolder untrustedButEkuValidHolder = new X509CertificateHolder(selfSignedOcspResponderCert.getEncoded()); + + assertThatExceptionOfType(CertificateNotTrustedException.class) + .isThrownBy(() -> + service.validateResponderCertificate(untrustedButEkuValidHolder, VALIDATION_DATE_WITHIN_RESPONDER_VALIDITY)); + } + + @Test + void whenResponderCertificateHolderConversionFails_thenThrows() throws Exception { + FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( + PRIMARY_FALLBACK_URI, aiaOcspResponderCert, true, + null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore); + 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, VALIDATION_DATE_WITHIN_RESPONDER_VALIDITY)) + .withMessageContaining("X509CertificateHolder conversion to X509Certificate failed") + .withCauseInstanceOf(java.security.cert.CertificateException.class); + } + + @Test + void whenResponderCertificateIsExpiredAtValidationDate_thenThrows() throws Exception { + FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( + PRIMARY_FALLBACK_URI, aiaOcspResponderCert, true, + null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore); + 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, farFuture)); + } +} diff --git a/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java b/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java index 168fc426..428f84ea 100644 --- a/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java +++ b/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java @@ -21,12 +21,16 @@ import java.security.cert.X509Certificate; import java.util.Date; import java.util.List; +import java.util.Optional; import java.util.Set; +import static eu.webeid.ocsp.protocol.IssuerDistinguishedName.getIssuerDistinguishedName; 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.getTestEsteid2015CA; import static eu.webeid.security.testutil.Certificates.getTestEsteid2018CA; import static eu.webeid.security.testutil.Certificates.getTestSelfSignedOcspResponder; import static eu.webeid.security.testutil.TestCertificateBuilder.buildCertificate; @@ -174,6 +178,139 @@ private static FallbackOcspService newFallbackOcspServiceWithoutPinnedResponder( return new FallbackOcspService(configuration); } + @Test + void whenFallbackOcspServiceConfigurationProvided_thenAiaServiceCarriesMatchingFallback() throws Exception { + X509Certificate userCert = getJaakKristjanEsteid2018Cert(); + X500Name issuerDN = getIssuerDistinguishedName(userCert); + 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, issuerDN, trustedAnchors, trustedStore); + + OcspServiceProvider provider = new OcspServiceProvider(null, getAiaOcspServiceProvider2018Configuration(), + List.of(fallbackConfiguration)); + OcspService service = provider.getService(userCert); + + 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()), validationDate)) + .doesNotThrowAnyException(); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> + fallback.validateResponderCertificate(new X509CertificateHolder(getTestEsteid2018CA().getEncoded()), 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); + X500Name unrelatedIssuerDN = new X500Name("CN=Unrelated CA"); + FallbackOcspServiceConfiguration fallbackConfiguration = new FallbackOcspServiceConfiguration( + URI.create("http://fallback.test/ocsp"), null, true, + null, unrelatedIssuerDN, trustedAnchors, trustedStore); + + OcspServiceProvider provider = new OcspServiceProvider(null, getAiaOcspServiceProvider2018Configuration(), + List.of(fallbackConfiguration)); + OcspService service = provider.getService(userCert); + + 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); + + 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); + + assertThat(service).isInstanceOf(AiaOcspService.class); + assertThat(service.getFallbackService()).isEmpty(); + } + + @Test + void whenDesignatedServiceSupportsIssuer_thenDesignatedTakesPrecedenceOverFallback() throws Exception { + X509Certificate userCert = getJaakKristjanEsteid2018Cert(); + X500Name issuerDN = getIssuerDistinguishedName(userCert); + 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, issuerDN, trustedAnchors, trustedStore); + + OcspServiceProvider provider = new OcspServiceProvider(getDesignatedOcspServiceConfiguration(), + getAiaOcspServiceProvider2018Configuration(), List.of(fallbackConfiguration)); + OcspService service = provider.getService(userCert); + + assertThat(service).isInstanceOf(DesignatedOcspService.class); + assertThat(service.getAccessLocation()).isEqualTo(new URI("http://demo.sk.ee/ocsp")); + assertThat(service.getFallbackService()).isEmpty(); + } + + @Test + void whenDuplicateIssuerFallbackConfigurations_thenLastOneWins() throws Exception { + X509Certificate userCert = getJaakKristjanEsteid2018Cert(); + X500Name issuerDN = getIssuerDistinguishedName(userCert); + 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, issuerDN, trustedAnchors, trustedStore); + FallbackOcspServiceConfiguration lastConfiguration = new FallbackOcspServiceConfiguration( + lastFallbackUri, getDemoEsteidSk2018AiaOcspResponder(), true, + null, issuerDN, trustedAnchors, trustedStore); + + OcspServiceProvider provider = new OcspServiceProvider(null, getAiaOcspServiceProvider2018Configuration(), + List.of(firstConfiguration, lastConfiguration)); + OcspService service = provider.getService(userCert); + + 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)); + } } // 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 d7014af1..f3ecb12b 100644 --- a/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java +++ b/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java @@ -34,8 +34,10 @@ import eu.webeid.security.authtoken.WebEidAuthToken; 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; @@ -43,33 +45,41 @@ import org.bouncycastle.cert.ocsp.SingleResp; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +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 org.assertj.core.api.Assertions.assertThatThrownBy; +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"); @@ -77,18 +87,35 @@ public class ResilientOcspCertificateRevocationCheckerTest { private static final Duration LONG_THIS_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 @@ -103,29 +130,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)", @@ -133,7 +162,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)", @@ -142,48 +171,15 @@ void whenMultipleValidationCalls_thenPreviousResultsAreNotModified() throws Exce } @Test - void whenFirstFallbackReturnsRevoked_thenRevocationPropagatesWithoutSecondFallback() throws Exception { - OcspClient ocspClient = mock(OcspClient.class); - when(ocspClient.request(eq(PRIMARY_URI), any())) - .thenThrow(new OCSPClientException("Primary OCSP service unavailable")); - when(ocspClient.request(eq(FALLBACK_URI), any())) - .thenReturn(ocspRespRevoked); - when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())) - .thenReturn(ocspRespGood); - - ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, null); - - assertThatExceptionOfType(ResilientUserCertificateRevokedException.class) - .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) - .withMessage("User certificate has been revoked"); - - verify(ocspClient, never()).request(eq(SECOND_FALLBACK_URI), any()); - } - - @Test - void whenMaxAttemptsIsOneAndAllCallsFail_thenRevocationInfoListShouldHaveThreeElements() throws Exception { + 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()); - when(ocspClient.request(eq(FALLBACK_URI), any())) - .thenThrow(new OCSPClientException()); - when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())) - .thenThrow(new OCSPClientException()); - - RetryConfig retryConfig = RetryConfig.custom() - .maxAttempts(1) - .build(); - - ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, retryConfig); - ResilientUserCertificateOCSPCheckFailedException ex = assertThrows(ResilientUserCertificateOCSPCheckFailedException.class, () -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); - assertThat(ex.getValidationInfo().revocationInfoList().size()).isEqualTo(3); - } - - @Test - void whenMaxAttemptsIsTwoAndAllCallsFail_thenRevocationInfoListShouldHaveFourElements() throws Exception { - OcspClient ocspClient = mock(OcspClient.class); - when(ocspClient.request(eq(PRIMARY_URI), any())) - .thenThrow(new OCSPClientException()); + .thenThrow(new OCSPClientException("primary attempt 1")) + .thenThrow(new OCSPClientException("primary attempt 2")); when(ocspClient.request(eq(FALLBACK_URI), any())) .thenThrow(new OCSPClientException()); when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())) @@ -193,9 +189,19 @@ void whenMaxAttemptsIsTwoAndAllCallsFail_thenRevocationInfoListShouldHaveFourEle .maxAttempts(2) .build(); - ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, retryConfig); - ResilientUserCertificateOCSPCheckFailedException ex = assertThrows(ResilientUserCertificateOCSPCheckFailedException.class, () -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); - assertThat(ex.getValidationInfo().revocationInfoList().size()).isEqualTo(4); + 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 @@ -204,68 +210,36 @@ void whenMaxAttemptsIsTwoAndFirstCallFails_thenTwoCallsToPrimaryShouldBeRecorded when(ocspClient.request(eq(PRIMARY_URI), any())) .thenThrow(new OCSPClientException("Primary OCSP service unavailable (call1)")) .thenReturn(ocspRespGood); - when(ocspClient.request(eq(FALLBACK_URI), any())) - .thenReturn(ocspRespRevoked); - when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())) - .thenReturn(ocspRespRevoked); RetryConfig retryConfig = RetryConfig.custom() .maxAttempts(2) .build(); - ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, retryConfig); + 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("OCSP_ERROR"); + OCSPClientException ex1 = (OCSPClientException) firstResponseAttributes.get(RevocationInfo.KEY_OCSP_ERROR); assertThat(ex1.getMessage()).isEqualTo("Primary OCSP service unavailable (call1)"); - Map secondResponseAttributes = revocationInfoList.get(1).ocspResponseAttributes(); - OCSPResp ocspResp = (OCSPResp) secondResponseAttributes.get("OCSP_RESPONSE"); - final BasicOCSPResp basicResponse = (BasicOCSPResp) ocspResp.getResponseObject(); - final SingleResp certStatusResponse = basicResponse.getResponses()[0]; - assertThat(certStatusResponse.getCertStatus()).isEqualTo(org.bouncycastle.cert.ocsp.CertificateStatus.GOOD); - } - - @Test - void whenFirstCallSucceeds_thenRevocationInfoListShouldHaveOneElementAndItShouldHaveGoodStatus() throws Exception { - OcspClient ocspClient = mock(OcspClient.class); - when(ocspClient.request(eq(PRIMARY_URI), any())) - .thenReturn(ocspRespGood); - when(ocspClient.request(eq(FALLBACK_URI), any())) - .thenReturn(ocspRespRevoked); - when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())) - .thenReturn(ocspRespRevoked); - - ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, null); - - 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); + assertThat(getCertificateStatus(revocationInfoList.get(1))).isEqualTo(CertificateStatus.GOOD); } @Test - void whenFirstCallResultsInRevoked_thenRevocationInfoListShouldHaveOneElementAndItShouldHaveRevokedStatus() throws Exception { + void whenPrimaryReturnsRevoked_thenRevocationInfoListShouldHaveOneElementAndItShouldHaveRevokedStatus() throws Exception { OcspClient ocspClient = mock(OcspClient.class); when(ocspClient.request(eq(PRIMARY_URI), any())) .thenReturn(ocspRespRevoked); - when(ocspClient.request(eq(FALLBACK_URI), any())) - .thenReturn(ocspRespGood); - when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())) - .thenReturn(ocspRespGood); - ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, null); - 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); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build(); + assertThatExceptionOfType(ResilientUserCertificateRevokedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> { + List revocationInfoList = ex.getValidationInfo().revocationInfoList(); + assertThat(revocationInfoList.size()).isEqualTo(1); + assertThat(getCertificateStatus(revocationInfoList.get(0))).isInstanceOf(RevokedStatus.class); + }); } @Test @@ -287,7 +261,10 @@ void whenCallerConfigRecordsAllExceptions_thenRevokedVerdictDoesNotOpenCircuitBr .permittedNumberOfCallsInHalfOpenState(1) .build(); - ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, null, callerConfig); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient) + .withFallbacks(FALLBACK_URI) + .withCircuitBreakerConfig(callerConfig) + .build(); // 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. @@ -300,39 +277,21 @@ void whenCallerConfigRecordsAllExceptions_thenRevokedVerdictDoesNotOpenCircuitBr } @Test - void whenOneFallbackIsConfiguredAndPrimaryFails_thenRevocationInfoListShouldHaveTwoElements() 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()); - FallbackOcspService fallbackService = mock(FallbackOcspService.class); - when(fallbackService.getAccessLocation()).thenReturn(FALLBACK_URI); - when(fallbackService.doesSupportNonce()).thenReturn(false); - when(fallbackService.getNextFallback()).thenReturn(null); - - OcspService primaryService = mock(OcspService.class); - when(primaryService.getAccessLocation()).thenReturn(PRIMARY_URI); - when(primaryService.doesSupportNonce()).thenReturn(false); - when(primaryService.getFallbackService()).thenReturn(Optional.of(fallbackService)); - - OcspServiceProvider ocspServiceProvider = mock(OcspServiceProvider.class); - when(ocspServiceProvider.getService(any())).thenReturn(primaryService); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withFallbacks(FALLBACK_URI).build(); - 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); + assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> { + List revocationInfoList = ex.getValidationInfo().revocationInfoList(); + assertThat(revocationInfoList.size()).isEqualTo(2); + }); } @Test @@ -340,46 +299,33 @@ void whenNoFallbacksAreConfigured_thenRevocationInfoListShouldHaveOneElement() t 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()); - OcspService primaryService = mock(OcspService.class); - when(primaryService.getAccessLocation()).thenReturn(PRIMARY_URI); - when(primaryService.doesSupportNonce()).thenReturn(false); - when(primaryService.getFallbackService()).thenReturn(Optional.empty()); - - OcspServiceProvider ocspServiceProvider = mock(OcspServiceProvider.class); - when(ocspServiceProvider.getService(any())).thenReturn(primaryService); - - ResilientOcspCertificateRevocationChecker checker = new ResilientOcspCertificateRevocationChecker( - ocspClient, - ocspServiceProvider, - CircuitBreakerConfig.ofDefaults(), - null, - OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW, - OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, - LONG_THIS_UPDATE_AGE - ); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withoutFallbacks().build(); - ResilientUserCertificateOCSPCheckFailedException ex = assertThrows(ResilientUserCertificateOCSPCheckFailedException.class, () -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); - List revocationInfoList = ex.getValidationInfo().revocationInfoList(); - assertThat(revocationInfoList.size()).isEqualTo(1); + assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> { + List revocationInfoList = ex.getValidationInfo().revocationInfoList(); + assertThat(revocationInfoList.size()).isEqualTo(1); + }); } @Test - void whenOcspResponseStatusIsUnauthorized_thenThrows() throws Exception { - OCSPResp ocspRespStatusUnauthorized = new OCSPResp(getOcspResponseBytesFromResources("ocsp_response_unauthorized.der")); + 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 = buildChecker(ocspClient, null); - ResilientUserCertificateOCSPCheckFailedException ex = assertThrows(ResilientUserCertificateOCSPCheckFailedException.class, () -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); - - Map responseAttributes = ex.getValidationInfo().revocationInfoList().get(0).ocspResponseAttributes(); - ResilientUserCertificateOCSPCheckFailedException firstException = (ResilientUserCertificateOCSPCheckFailedException) responseAttributes.get(RevocationInfo.KEY_OCSP_ERROR); - assertThat(firstException.getMessage()).isEqualTo("Response status: unauthorized"); + 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 @@ -387,15 +333,9 @@ void whenOcspServiceProviderThrowsCertificateException_thenThrows() throws Excep UserCertificateOCSPException encodingException = new UserCertificateOCSPException("Resolving primary OCSP service from subject certificate failed"); OcspServiceProvider ocspServiceProvider = mock(OcspServiceProvider.class); when(ocspServiceProvider.getService(any())).thenThrow(encodingException); - ResilientOcspCertificateRevocationChecker checker = new ResilientOcspCertificateRevocationChecker( - mock(OcspClient.class), - ocspServiceProvider, - CircuitBreakerConfig.ofDefaults(), - null, - OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW, - OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, - LONG_THIS_UPDATE_AGE - ); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(mock(OcspClient.class)) + .withOcspServiceProvider(ocspServiceProvider) + .build(); assertThatExceptionOfType(UserCertificateOCSPException.class) .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) @@ -405,7 +345,7 @@ void whenOcspServiceProviderThrowsCertificateException_thenThrows() throws Excep @Test void whenCertificateIdComputationFails_thenThrows() throws Exception { - ResilientOcspCertificateRevocationChecker checker = buildChecker(mock(OcspClient.class), null); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(mock(OcspClient.class)).build(); X509Certificate badIssuer = mock(X509Certificate.class); CertificateEncodingException encodingException = new CertificateEncodingException("bad issuer"); when(badIssuer.getEncoded()).thenThrow(encodingException); @@ -417,40 +357,697 @@ void whenCertificateIdComputationFails_thenThrows() throws Exception { .withCause(encodingException); } - private ResilientOcspCertificateRevocationChecker buildChecker(OcspClient ocspClient, RetryConfig retryConfig) throws Exception { - return buildChecker(ocspClient, retryConfig, CircuitBreakerConfig.ofDefaults()); + @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(); + + assertThatExceptionOfType(ResilientUserCertificateRevokedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); + assertThatExceptionOfType(ResilientUserCertificateRevokedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); + assertThatExceptionOfType(ResilientUserCertificateRevokedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); + + verify(ocspClient, times(3)).request(eq(PRIMARY_URI), any()); + verify(ocspClient, never()).request(eq(FALLBACK_URI), any()); + } + + @Test + void whenPrimaryFailsAndFirstFallbackReturnsRevoked_thenListContainsBothEntries() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())) + .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 Basic OCSP Response"); + }); + } + + @Test + void whenRejectUnknownStatusIsTrueAndPrimaryReturnsUnknown_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).withRejectUnknownOcspResponseStatus().build(); + + try (var mockedClock = mockStaticClockAt(WITHIN_RESPONDER_CERT_VALIDITY)) { + List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + + assertThat(revocationInfoList).hasSize(2); + verify(ocspClient).request(eq(FALLBACK_URI), any()); + } } - private ResilientOcspCertificateRevocationChecker buildChecker(OcspClient ocspClient, RetryConfig retryConfig, CircuitBreakerConfig circuitBreakerConfig) throws Exception { - FallbackOcspService secondFallbackService = mock(FallbackOcspService.class); - when(secondFallbackService.getAccessLocation()).thenReturn(SECOND_FALLBACK_URI); - when(secondFallbackService.doesSupportNonce()).thenReturn(false); + @Test + void whenRejectUnknownStatusIsFalseAndPrimaryReturnsUnknown_thenRevokedShortCircuits() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespUnknown); + when(ocspClient.request(eq(FALLBACK_URI), any())).thenReturn(ocspRespGood); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build(); + + try (var mockedClock = mockStaticClockAt(WITHIN_RESPONDER_CERT_VALIDITY)) { + assertThatExceptionOfType(ResilientUserCertificateRevokedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); + verify(ocspClient, never()).request(eq(FALLBACK_URI), any()); + } + } + + @Test + void whenNonceEnabledAndResponseNonceDiffers_thenThrows() throws Exception { + // primaryService advertises nonce support; ocspRespGood was signed with a different nonce. + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespGood); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient) + .withoutFallbacks() + .withPrimaryNonceSupport() + .build(); + + try (var ignored = mockStaticClockAt(DER_THIS_UPDATE)) { + assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> { + Throwable originalError = (Throwable) ex.getValidationInfo().revocationInfoList().get(0) + .ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR); + assertThat(originalError).hasMessageContaining("OCSP request and response nonces differ"); + }); + } + } + + @Test + void 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(ocspRespGood); + CircuitBreakerConfig recoverableCircuitBreakerConfig = CircuitBreakerConfig.custom() + .slidingWindowSize(2) + .minimumNumberOfCalls(2) + .failureRateThreshold(50) + .waitDurationInOpenState(Duration.ofSeconds(1)) + .permittedNumberOfCallsInHalfOpenState(1) + .build(); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient) + .withFallbacks(FALLBACK_URI) + .withCircuitBreakerConfig(recoverableCircuitBreakerConfig) + .build(); + + checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); - FallbackOcspService fallbackService = mock(FallbackOcspService.class); - when(fallbackService.getAccessLocation()).thenReturn(FALLBACK_URI); - when(fallbackService.doesSupportNonce()).thenReturn(false); - when(fallbackService.getNextFallback()).thenReturn(secondFallbackService); + 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 whenOcspRequestFailsWithStatusCode_thenRevocationInfoContainsHttpStatusCodeAndResponseBody() throws Exception { + byte[] responseBody = "error".getBytes(); + OCSPClientException ocspClientException = new OCSPClientException("OCSP request was not successful", responseBody, 503); + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())).thenThrow(ocspClientException); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withoutFallbacks().build(); + + assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> { + Map attributes = ex.getValidationInfo().revocationInfoList().get(0).ocspResponseAttributes(); + assertThat(attributes.get(RevocationInfo.KEY_HTTP_STATUS_CODE)).isEqualTo(503); + assertThat(attributes.get(RevocationInfo.KEY_OCSP_RESPONSE)).isEqualTo(responseBody); + }); + } + + @ParameterizedTest + @ValueSource(ints = {0, -1}) + void whenFallbackMaxOcspResponseThisUpdateAgeIsNotPositive_thenThrows(int minutes) { + assertThatThrownBy(() -> new ResilientOcspCertificateRevocationChecker( + mock(OcspClient.class), + mock(OcspServiceProvider.class), + CircuitBreakerConfig.ofDefaults(), + null, + OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW, + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, + Duration.ofMinutes(minutes), + false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("fallbackMaxOcspResponseThisUpdateAge must be greater than zero"); + } + + @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.of(fallbackService)); - + when(primaryService.getFallbackService()).thenReturn(Optional.empty()); OcspServiceProvider ocspServiceProvider = mock(OcspServiceProvider.class); when(ocspServiceProvider.getService(any())).thenReturn(primaryService); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient) + .withOcspServiceProvider(ocspServiceProvider) + .build(); - return new ResilientOcspCertificateRevocationChecker( - ocspClient, - ocspServiceProvider, - circuitBreakerConfig, - retryConfig, - OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW, - LONG_THIS_UPDATE_AGE, - LONG_THIS_UPDATE_AGE - ); + assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> { + RevocationInfo revocationInfo = ex.getValidationInfo().revocationInfoList().get(0); + assertThat(revocationInfo.ocspResponderUri()).isNull(); + Map attributes = revocationInfo.ocspResponseAttributes(); + assertThat(attributes.get(RevocationInfo.KEY_OCSP_ERROR)).isSameAs(nullUriRejectedByClient); + // getOcspRequest() builds the request before the responder is contacted, so the request + // is recorded even when the call itself fails. + assertThat(attributes).containsKey(RevocationInfo.KEY_OCSP_REQUEST); + assertThat(attributes).doesNotContainKey(RevocationInfo.KEY_OCSP_RESPONSE); + }); + verify(ocspClient).request(isNull(), any()); + } + + @Test + void whenPrimarySucceeds_thenRevocationInfoListContainsExpectedResponderUrisAndAttributes() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespGood); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build(); + + List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + + assertThat(revocationInfoList).hasSize(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 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())).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); + + RevocationInfo firstFallback = revocationInfoList.get(1); + assertThat(firstFallback.ocspResponderUri()).isEqualTo(FALLBACK_URI); + assertThat(firstFallback.ocspResponseAttributes()) + .containsKey(RevocationInfo.KEY_OCSP_ERROR) + .doesNotContainKey(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS); + + RevocationInfo secondFallback = revocationInfoList.get(2); + assertThat(secondFallback.ocspResponderUri()).isEqualTo(SECOND_FALLBACK_URI); + assertThat(secondFallback.ocspResponseAttributes()) + .doesNotContainKey(RevocationInfo.KEY_OCSP_ERROR) + .doesNotContainKey(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS); + } + + @Test + void whenAllFail_thenRevocationInfoListContainsExpectedResponderUrisAndAttributes() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())).thenThrow(new OCSPClientException("primary")); + when(ocspClient.request(eq(FALLBACK_URI), any())).thenThrow(new OCSPClientException("fallback")); + when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())).thenThrow(new OCSPClientException("second")); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build(); + + assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> { + assertThat(ex.getValidationInfo()).isNotNull(); + List revocationInfoList = ex.getValidationInfo().revocationInfoList(); + + assertThat(revocationInfoList).hasSize(3); + + RevocationInfo primary = revocationInfoList.get(0); + assertThat(primary.ocspResponderUri()).isEqualTo(PRIMARY_URI); + assertThat(primary.ocspResponseAttributes()) + .containsKey(RevocationInfo.KEY_OCSP_ERROR) + .containsKey(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS); + + RevocationInfo firstFallback = revocationInfoList.get(1); + assertThat(firstFallback.ocspResponderUri()).isEqualTo(FALLBACK_URI); + assertThat(firstFallback.ocspResponseAttributes()) + .containsKey(RevocationInfo.KEY_OCSP_ERROR) + .doesNotContainKey(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS); + + RevocationInfo secondFallback = revocationInfoList.get(2); + assertThat(secondFallback.ocspResponderUri()).isEqualTo(SECOND_FALLBACK_URI); + assertThat(secondFallback.ocspResponseAttributes()) + .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 the primary uses + // primaryMaxOcspResponseThisUpdateAge and the fallback uses fallbackMaxOcspResponseThisUpdateAge; swapping + // the two parameters 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) + .withPrimaryMaxOcspResponseThisUpdateAge(Duration.ofMinutes(2)) + .withFallbackMaxOcspResponseThisUpdateAge(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); + ResilientOcspCertificateRevocationChecker.CircuitBreakerStatistics statistics = + (ResilientOcspCertificateRevocationChecker.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); + } + + @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 whenNoFallbackConfiguredAndRejectUnknownAndPrimaryReturnsUnknown_thenOcspCheckFails() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespUnknown); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient) + .withoutFallbacks() + .withRejectUnknownOcspResponseStatus() + .build(); + + try (var ignored = mockStaticClockAt(WITHIN_RESPONDER_CERT_VALIDITY)) { + assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> assertThat(ex.getValidationInfo().revocationInfoList()).hasSize(1)); + } + } + + @Test + void whenNoFallbackConfiguredAndPrimaryReturnsUnknown_thenCheckFailedPropagates() throws Exception { + // The unknown status fails the OCSP check. + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespUnknown); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withoutFallbacks().build(); + + try (var ignored = mockStaticClockAt(WITHIN_RESPONDER_CERT_VALIDITY)) { + assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> assertThat(ex.getValidationInfo().revocationInfoList()).hasSize(1)); + } + } + + @Test + void whenNoFallbackConfigured_thenRetryAndCircuitBreakerAreNotApplied() throws Exception { + // The class contract states retry and circuit breaker apply only when a fallback is configured. With no + // fallback the primary must be queried exactly once (no retry) and no circuit breaker statistics attached. + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())).thenThrow(new OCSPClientException("Primary OCSP service unavailable")); + RetryConfig retryConfig = RetryConfig.custom().maxAttempts(2).waitDuration(Duration.ZERO).build(); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient) + .withoutFallbacks() + .withRetryConfig(retryConfig) + .build(); + + assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> { + List revocationInfoList = ex.getValidationInfo().revocationInfoList(); + assertThat(revocationInfoList).hasSize(1); + assertThat(revocationInfoList.get(0).ocspResponseAttributes()) + .doesNotContainKey(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS); + }); + verify(ocspClient, times(1)).request(eq(PRIMARY_URI), any()); + } + + @Test + void whenPrimaryAndFirstFallbackFailAndSecondFallbackReturnsRevoked_thenRevokedPropagates() throws Exception { + OcspClient ocspClient = mock(OcspClient.class); + when(ocspClient.request(eq(PRIMARY_URI), any())).thenThrow(new OCSPClientException("Primary OCSP service unavailable")); + when(ocspClient.request(eq(FALLBACK_URI), any())).thenThrow(new OCSPClientException("Fallback OCSP service unavailable")); + when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())).thenReturn(ocspRespRevoked); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build(); + + assertThatExceptionOfType(ResilientUserCertificateRevokedException.class) + .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .satisfies(ex -> { + List revocationInfoList = ex.getValidationInfo().revocationInfoList(); + assertThat(revocationInfoList).hasSize(3); + assertThat(revocationInfoList).extracting(RevocationInfo::ocspResponderUri) + .containsExactly(PRIMARY_URI, FALLBACK_URI, SECOND_FALLBACK_URI); + assertThat(getCertificateStatus(revocationInfoList.get(2))).isInstanceOf(RevokedStatus.class); + }); + } + + @Test + void whenFallbackResponseIsTooOldForFallbackAgeLimit_thenOcspCheckFails() throws Exception { + // The response thisUpdate is 2021-09-17T18:25:24 and the clock is mocked 5 minutes later, while the fallback + // age limit is only 2 minutes, so the fallback rejects the response as too old. This exercises the failure + // direction of fallbackMaxOcspResponseThisUpdateAge (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) + .withFallbackMaxOcspResponseThisUpdateAge(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 relaxed thisUpdate age limits and, + * by default, a primary OCSP service with two chained fallbacks: + * PRIMARY_URI -> FALLBACK_URI -> SECOND_FALLBACK_URI. + */ + 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 primaryMaxOcspResponseThisUpdateAge = LONG_THIS_UPDATE_AGE; + private Duration fallbackMaxOcspResponseThisUpdateAge = LONG_THIS_UPDATE_AGE; + + private CheckerBuilder(OcspClient ocspClient) { + this.ocspClient = ocspClient; + } + + private CheckerBuilder withFallbacks(URI... fallbackUris) { + this.fallbackUris = fallbackUris; + return this; + } + + private CheckerBuilder withoutFallbacks() { + return withFallbacks(); + } + + private CheckerBuilder withPrimaryNonceSupport() { + this.primarySupportsNonce = true; + return this; + } + + private CheckerBuilder withCircuitBreakerConfig(CircuitBreakerConfig circuitBreakerConfig) { + this.circuitBreakerConfig = circuitBreakerConfig; + return this; + } + + private CheckerBuilder withRetryConfig(RetryConfig retryConfig) { + this.retryConfig = retryConfig; + return this; + } + + private CheckerBuilder withOcspServiceProvider(OcspServiceProvider ocspServiceProvider) { + this.ocspServiceProvider = ocspServiceProvider; + return this; + } + + private CheckerBuilder withPrimaryMaxOcspResponseThisUpdateAge(Duration primaryMaxOcspResponseThisUpdateAge) { + this.primaryMaxOcspResponseThisUpdateAge = primaryMaxOcspResponseThisUpdateAge; + return this; + } + + private CheckerBuilder withFallbackMaxOcspResponseThisUpdateAge(Duration fallbackMaxOcspResponseThisUpdateAge) { + this.fallbackMaxOcspResponseThisUpdateAge = fallbackMaxOcspResponseThisUpdateAge; + return this; + } + + private ResilientOcspCertificateRevocationChecker build() throws Exception { + OcspServiceProvider serviceProvider = ocspServiceProvider != null ? ocspServiceProvider : buildMockServiceProvider(); + return new ResilientOcspCertificateRevocationChecker( + ocspClient, + serviceProvider, + circuitBreakerConfig, + retryConfig, + OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW, + primaryMaxOcspResponseThisUpdateAge, + fallbackMaxOcspResponseThisUpdateAge + ); + } + + 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); + 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)); + + OcspServiceProvider serviceProvider = mock(OcspServiceProvider.class); + when(serviceProvider.getService(any())).thenReturn(primaryService); + return serviceProvider; + } + } + + private static MockedStatic mockStaticClockAt(String isoDateTime) { + MockedStatic mockedClock = Mockito.mockStatic(DateAndTime.DefaultClock.class); + mockDate(isoDateTime, mockedClock); + return mockedClock; } - 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/Certificates.java b/src/test/java/eu/webeid/security/testutil/Certificates.java index 8d4273c5..cc5479c9 100644 --- a/src/test/java/eu/webeid/security/testutil/Certificates.java +++ b/src/test/java/eu/webeid/security/testutil/Certificates.java @@ -18,19 +18,29 @@ 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 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_self-signed_OCSP_RESPONDER.cer", "DEMO_of_ESTEID-SK_2018_AIA_OCSP_RESPONDER_2018.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]; - testSelfSignedOcspResponder = certificates[2]; - demoEsteidSk2018AiaOcspResponder = certificates[3]; + testSkOcspResponder2020 = certificates[2]; + testSelfSignedOcspResponder = certificates[3]; + demoEsteidSk2018AiaOcspResponder = certificates[4]; + testEeCertCentreRootCA = certificates[5]; } public static X509Certificate getTestEsteid2018CA() throws CertificateException, IOException { @@ -47,6 +57,13 @@ public static X509Certificate getTestEsteid2015CA() throws CertificateException, return testEsteid2015CA; } + public static X509Certificate getTestSkOcspResponder2020() throws CertificateException, IOException { + if (testSkOcspResponder2020 == null) { + loadCertificates(); + } + return testSkOcspResponder2020; + } + public static X509Certificate getTestSelfSignedOcspResponder() throws CertificateException, IOException { if (testSelfSignedOcspResponder == null) { loadCertificates(); @@ -61,6 +78,13 @@ public static X509Certificate getDemoEsteidSk2018AiaOcspResponder() throws Certi return demoEsteidSk2018AiaOcspResponder; } + public static X509Certificate getTestEeCertCentreRootCA() throws CertificateException, IOException { + if (testEeCertCentreRootCA == null) { + loadCertificates(); + } + return testEeCertCentreRootCA; + } + public static X509Certificate getJaakKristjanEsteid2018Cert() throws CertificateDecodingException { if (jaakKristjanEsteid2018Cert == null) { jaakKristjanEsteid2018Cert = CertificateLoader.decodeCertificateFromBase64(JAAK_KRISTJAN_ESTEID2018_CERT); diff --git a/src/test/java/eu/webeid/security/testutil/ResourceUtil.java b/src/test/java/eu/webeid/security/testutil/ResourceUtil.java new file mode 100644 index 00000000..74da75bc --- /dev/null +++ b/src/test/java/eu/webeid/security/testutil/ResourceUtil.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2020-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package eu.webeid.security.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/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 0000000000000000000000000000000000000000..f51a85803ec6218ab84bf3a262d097158ed89449 GIT binary patch literal 1047 zcmXqLVi7iIV*0&+nTe5!Nuc=0+f~XeA}&5#qu+QP{JqJ5myJ`a&7D}zC; zA-4f18*?ZNn=q5BtD%yC0*J%KBk35d5S&_6l9`rSQkZ;%jQJb7tl9`_eq z&els!HBge^H!?5;LIZO{BV!X&vnY^&v4NqHp}CQ`4%*SbLX%ld&=J0X;k6bo8+hp4A zi~ex=b?xiCB~K>@o9b^ow=Kc>=FZ^NJ*U|;j~ABnMo5=3-Th$AJ?p98dEX^}Z8Y5O z=Gm{w4ZqcPp_*;aebd#2?``EjYhAE5N{-yk-7LzZ)AVQouWV977QmqTvsm5k2^18O< z`rmES#SVD(zi<7}FZlX}&R+;@E)HKhPaf*HJ@4SZ zGm~fCj5@2il6QaN^PLSHi)!92XJTe#U|ihTWzg7ezz<9qvcimv|5;cKn1Pf5A4q^7 zB)|epk8B3AAU+?97>meO6Rx>S^#zVTSLEAwWMitkG;`TM16Po=DvO$Zd_bSOm z`Du#NUY1&p_?diOqRR^AbZv~<$e%5eoXlEsWZ70GozNx0v#;4t-x;}}ey8Hml22^u zHL5}%rtgm9a`@_DdF8+2vx9wS!Y@XPh}S(W+OhTF)jh6(8|G$+bI2TxC|dMmqF31o z+r{fAzg0i7VClM$U8Z@#k@Gi6&ny1$pv9xC`^9vnSx!fLB`$4bxTfIkyyu_LmfH6U zNBQlP=62j<>kYb8qERq0_w%n@mOUJw_-{Rz&|F;MxwA~^=zbqy{sK@yP0FG&SApigX literal 0 HcmV?d00001 From 8a4abecfb284f1a5b9440c10ed7c5419f163d78e Mon Sep 17 00:00:00 2001 From: Madis Jaagup Laurson Date: Thu, 14 May 2026 10:46:53 +0300 Subject: [PATCH 16/19] AUT-2802 Make maxThisUpdateAge and maxNextUpdateAge separately configurable for OCSP servers --- .../OcspCertificateRevocationChecker.java | 16 +--- src/main/java/eu/webeid/ocsp/README.md | 20 +++- .../ocsp/protocol/OcspResponseValidator.java | 11 ++- .../webeid/ocsp/service/AiaOcspService.java | 15 +++ .../service/AiaOcspServiceConfiguration.java | 16 +++- .../ocsp/service/DesignatedOcspService.java | 11 +++ .../DesignatedOcspServiceConfiguration.java | 19 +++- .../ocsp/service/FallbackOcspService.java | 15 +++ .../FallbackOcspServiceConfiguration.java | 18 +++- .../eu/webeid/ocsp/service/OcspService.java | 5 + ...lientOcspCertificateRevocationChecker.java | 22 ++--- .../OcspCertificateRevocationCheckerTest.java | 18 +--- .../ocsp/client/OcspClientOverrideTest.java | 3 +- .../protocol/OcspResponseValidatorTest.java | 61 ++++++++++-- .../ocsp/service/AiaOcspServiceTest.java | 8 +- .../FallbackOcspServiceConfigurationTest.java | 88 +++++++++++++++-- .../ocsp/service/FallbackOcspServiceTest.java | 35 ++++--- .../service/OcspServiceConfigurationTest.java | 8 +- .../webeid/ocsp/service/OcspServiceMaker.java | 12 ++- .../ocsp/service/OcspServiceProviderTest.java | 34 +++++-- ...tOcspCertificateRevocationCheckerTest.java | 94 +++++-------------- 21 files changed, 363 insertions(+), 166 deletions(-) diff --git a/src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java b/src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java index da3477d2..e83b539e 100644 --- a/src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java +++ b/src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java @@ -51,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) { @@ -67,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"); } /** @@ -107,7 +105,7 @@ public List validateCertificateNotRevoked(X509Certificate subjec } LOG.debug("OCSP response received successfully"); - verifyOcspResponse(basicResponse, ocspService, certificateId, maxOcspResponseThisUpdateAge); + verifyOcspResponse(basicResponse, ocspService, certificateId); if (ocspService.doesSupportNonce()) { checkNonce(request, basicResponse, ocspResponderUri); } @@ -133,7 +131,7 @@ protected static OCSPReq getOcspRequest(CertificateID certificateId, OcspService return request; } - protected void verifyOcspResponse(BasicOCSPResp basicResponse, OcspService ocspService, CertificateID requestCertificateId, Duration maxOcspResponseThisUpdateAge) throws AuthTokenException, OCSPException, CertificateException, OperatorCreationException { + protected void verifyOcspResponse(BasicOCSPResp basicResponse, OcspService ocspService, CertificateID requestCertificateId) throws AuthTokenException, OCSPException, CertificateException, OperatorCreationException { // The verification algorithm follows RFC 2560, https://www.ietf.org/rfc/rfc2560.txt. // // 3.2. Signed Response Acceptance Requirements @@ -184,7 +182,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()); @@ -233,8 +231,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 8336f99a..1ea24a45 100644 --- a/src/main/java/eu/webeid/ocsp/README.md +++ b/src/main/java/eu/webeid/ocsp/README.md @@ -72,14 +72,15 @@ List trustedCAs = List.of(trustedIntermediateCACertificates()); AiaOcspServiceConfiguration aiaConfiguration = new AiaOcspServiceConfiguration( Set.of(), // AIA responder URLs for which request and response nonce checks are disabled. CertificateValidator.buildTrustAnchorsFromCertificates(trustedCAs), - CertificateValidator.buildCertStoreFromCertificates(trustedCAs) + CertificateValidator.buildCertStoreFromCertificates(trustedCAs), + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, + OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE ); OcspServiceProvider services = new OcspServiceProvider(null, aiaConfiguration); OcspCertificateRevocationChecker checker = new OcspCertificateRevocationChecker( OcspClientImpl.build(Duration.ofSeconds(5)), services, - OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW, - OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE + OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW ); AuthTokenValidator validator = new AuthTokenValidatorBuilder() @@ -91,7 +92,14 @@ AuthTokenValidator validator = new AuthTokenValidatorBuilder() The five-second connection and response timeout above is an explicit example setting. For a custom Java `HttpClient`, use `new OcspClientImpl(httpClient, responseTimeout)` and configure the connection timeout on that client. Alternatively, supply your own `OcspClient` implementation. See [OcspClientOverrideTest](../../../../../test/java/eu/webeid/ocsp/client/OcspClientOverrideTest.java). -The custom checker's suggested constants are 15 minutes for clock/update skew and 2 minutes for maximum `thisUpdate` age; pass different positive durations to its constructor to change them. These checks are implemented by [OcspResponseValidator](protocol/OcspResponseValidator.java). +The custom checker's suggested constant for clock/update skew is 15 minutes; pass a different positive duration to its constructor to change it. + +The maximum ages of the OCSP response's `thisUpdate` and `nextUpdate` times are configured per OCSP service, via the constructor of `AiaOcspServiceConfiguration`, `DesignatedOcspServiceConfiguration` or `FallbackOcspServiceConfiguration`: + +- `maxThisUpdateAge` – the maximum age of the OCSP response's `thisUpdate` time before the response is too old to rely on. `OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE` is 2 minutes. +- `maxNextUpdateAge` – the maximum age of the OCSP response's `nextUpdate` time before the response is too old to rely on. `OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE` is 15 minutes, which is equal to the default allowed time skew. + +These checks are implemented by [OcspResponseValidator](protocol/OcspResponseValidator.java). For a designated responder, replace the `services` definition above with the following configuration. `responderCertificate` must be the service's trusted signing certificate and `supportedIssuers` the collection of issuer certificates served by it: @@ -103,7 +111,9 @@ DesignatedOcspServiceConfiguration designated = new DesignatedOcspServiceConfigu URI.create("https://ocsp.example.org"), responderCertificate, supportedIssuers, - true // This service supports nonces. + true, // This service supports nonces. + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, + OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE ); OcspServiceProvider services = new OcspServiceProvider(designated, aiaConfiguration); ``` diff --git a/src/main/java/eu/webeid/ocsp/protocol/OcspResponseValidator.java b/src/main/java/eu/webeid/ocsp/protocol/OcspResponseValidator.java index 58b62e50..0f0f23ff 100644 --- a/src/main/java/eu/webeid/ocsp/protocol/OcspResponseValidator.java +++ b/src/main/java/eu/webeid/ocsp/protocol/OcspResponseValidator.java @@ -102,7 +102,7 @@ public static void validateResponseSignature(BasicOCSPResp basicResponse, X509Ce } } - public static void validateCertificateStatusUpdateTime(SingleResp certStatusResponse, Duration allowedTimeSkew, Duration maxThisupdateAge, URI ocspResponderUri) throws UserCertificateOCSPCheckFailedException { + public static void validateCertificateStatusUpdateTime(SingleResp certStatusResponse, Duration allowedTimeSkew, Duration maxThisUpdateAge, Duration maxNextUpdateAge, URI ocspResponderUri) throws UserCertificateOCSPCheckFailedException { // From RFC 2560, https://www.ietf.org/rfc/rfc2560.txt: // 4.2.2. Notes on OCSP Responses // 4.2.2.1. Time @@ -113,9 +113,9 @@ public static void validateCertificateStatusUpdateTime(SingleResp certStatusResp // If nextUpdate is not set, the responder is indicating that newer // revocation information is available all the time. final Instant now = DateAndTime.DefaultClock.getInstance().now().toInstant(); - final Instant earliestAcceptableTimeSkew = now.minus(allowedTimeSkew); final Instant latestAcceptableTimeSkew = now.plus(allowedTimeSkew); - final Instant minimumValidThisUpdateTime = now.minus(maxThisupdateAge); + final Instant minimumValidThisUpdateTime = now.minus(maxThisUpdateAge); + final Instant minimumValidNextUpdateTime = now.minus(maxNextUpdateAge); final Instant thisUpdate = certStatusResponse.getThisUpdate().toInstant(); if (thisUpdate.isAfter(latestAcceptableTimeSkew)) { @@ -133,9 +133,10 @@ public static void validateCertificateStatusUpdateTime(SingleResp certStatusResp return; } final Instant nextUpdate = certStatusResponse.getNextUpdate().toInstant(); - if (nextUpdate.isBefore(earliestAcceptableTimeSkew)) { + if (nextUpdate.isBefore(minimumValidNextUpdateTime)) { throw new UserCertificateOCSPCheckFailedException(ERROR_PREFIX + - "nextUpdate '" + nextUpdate + "' is in the past", ocspResponderUri); + "nextUpdate '" + nextUpdate + "' is too old, " + + "minimum time allowed: '" + minimumValidNextUpdateTime + "'", ocspResponderUri); } if (nextUpdate.isBefore(thisUpdate)) { throw new UserCertificateOCSPCheckFailedException(ERROR_PREFIX + diff --git a/src/main/java/eu/webeid/ocsp/service/AiaOcspService.java b/src/main/java/eu/webeid/ocsp/service/AiaOcspService.java index 20694255..7ba02ec3 100644 --- a/src/main/java/eu/webeid/ocsp/service/AiaOcspService.java +++ b/src/main/java/eu/webeid/ocsp/service/AiaOcspService.java @@ -18,6 +18,7 @@ import java.security.cert.CertificateException; 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,6 +38,8 @@ public class AiaOcspService implements OcspService { private final URI url; private final boolean supportsNonce; private final FallbackOcspService fallbackOcspService; + private final Duration maxThisUpdateAge; + private final Duration maxNextUpdateAge; public AiaOcspService(AiaOcspServiceConfiguration configuration, X509Certificate certificate, FallbackOcspService fallbackOcspService) throws UserCertificateOCSPException { Objects.requireNonNull(configuration); @@ -46,6 +49,8 @@ public AiaOcspService(AiaOcspServiceConfiguration configuration, X509Certificate this.fallbackOcspService = fallbackOcspService; X500Name issuerDN = getIssuerDistinguishedName(certificate); this.supportsNonce = !configuration.getNonceDisabledIssuerDNs().contains(issuerDN); + this.maxThisUpdateAge = configuration.getMaxThisUpdateAge(); + this.maxNextUpdateAge = configuration.getMaxNextUpdateAge(); } @Override @@ -58,6 +63,16 @@ public URI getAccessLocation() { return url; } + @Override + public Duration getMaxThisUpdateAge() { + return maxThisUpdateAge; + } + + @Override + public Duration getMaxNextUpdateAge() { + return maxNextUpdateAge; + } + @Override public Optional getFallbackService() { return Optional.ofNullable(fallbackOcspService); diff --git a/src/main/java/eu/webeid/ocsp/service/AiaOcspServiceConfiguration.java b/src/main/java/eu/webeid/ocsp/service/AiaOcspServiceConfiguration.java index 44b601fd..90687639 100644 --- a/src/main/java/eu/webeid/ocsp/service/AiaOcspServiceConfiguration.java +++ b/src/main/java/eu/webeid/ocsp/service/AiaOcspServiceConfiguration.java @@ -7,20 +7,27 @@ import java.security.cert.CertStore; import java.security.cert.TrustAnchor; +import java.time.Duration; import java.util.Collection; import java.util.Objects; import java.util.Set; +import static eu.webeid.security.util.DateAndTime.requirePositiveDuration; + public class AiaOcspServiceConfiguration { private final Collection nonceDisabledIssuerDNs; private final Set trustedCACertificateAnchors; private final CertStore trustedCACertificateCertStore; + private final Duration maxThisUpdateAge; + private final Duration maxNextUpdateAge; - public AiaOcspServiceConfiguration(Collection nonceDisabledIssuerDNs, Set trustedCACertificateAnchors, CertStore trustedCACertificateCertStore) { + public AiaOcspServiceConfiguration(Collection nonceDisabledIssuerDNs, Set trustedCACertificateAnchors, CertStore trustedCACertificateCertStore, Duration maxThisUpdateAge, Duration maxNextUpdateAge) { this.nonceDisabledIssuerDNs = Set.copyOf(nonceDisabledIssuerDNs); this.trustedCACertificateAnchors = Set.copyOf(trustedCACertificateAnchors); this.trustedCACertificateCertStore = Objects.requireNonNull(trustedCACertificateCertStore); + this.maxThisUpdateAge = requirePositiveDuration(maxThisUpdateAge, "maxThisUpdateAge"); + this.maxNextUpdateAge = requirePositiveDuration(maxNextUpdateAge, "maxNextUpdateAge"); } public Collection getNonceDisabledIssuerDNs() { @@ -35,4 +42,11 @@ public CertStore getTrustedCACertificateCertStore() { return trustedCACertificateCertStore; } + public Duration getMaxThisUpdateAge() { + return maxThisUpdateAge; + } + + public Duration getMaxNextUpdateAge() { + return maxNextUpdateAge; + } } diff --git a/src/main/java/eu/webeid/ocsp/service/DesignatedOcspService.java b/src/main/java/eu/webeid/ocsp/service/DesignatedOcspService.java index d4b48fea..34e294ed 100644 --- a/src/main/java/eu/webeid/ocsp/service/DesignatedOcspService.java +++ b/src/main/java/eu/webeid/ocsp/service/DesignatedOcspService.java @@ -12,6 +12,7 @@ import java.security.cert.CertificateEncodingException; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; +import java.time.Duration; import java.util.Date; import java.util.Objects; @@ -39,6 +40,16 @@ 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, Date now) throws AuthTokenException { try { diff --git a/src/main/java/eu/webeid/ocsp/service/DesignatedOcspServiceConfiguration.java b/src/main/java/eu/webeid/ocsp/service/DesignatedOcspServiceConfiguration.java index 3fb5daf1..650a1b99 100644 --- a/src/main/java/eu/webeid/ocsp/service/DesignatedOcspServiceConfiguration.java +++ b/src/main/java/eu/webeid/ocsp/service/DesignatedOcspServiceConfiguration.java @@ -11,15 +11,20 @@ import java.net.URI; import java.security.cert.CertificateEncodingException; import java.security.cert.X509Certificate; +import java.time.Duration; import java.util.Collection; import java.util.Objects; +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 Collection supportedIssuers; + private final Duration maxThisUpdateAge; + private final Duration maxNextUpdateAge; /** * Configuration of a designated OCSP service. @@ -28,9 +33,11 @@ public class DesignatedOcspServiceConfiguration { * @param responderCertificate the service's OCSP responder certificate * @param supportedCertificateIssuers the certificate issuers supported by the service * @param doesSupportNonce true if the service supports the OCSP protocol nonce extension + * @param maxThisUpdateAge the maximum age of the OCSP response's {@code thisUpdate} time, must be greater than zero + * @param maxNextUpdateAge the maximum age of the OCSP response's {@code nextUpdate} time, must be greater than zero * @throws OCSPCertificateException when an error occurs while extracting issuer names from certificates */ - public DesignatedOcspServiceConfiguration(URI ocspServiceAccessLocation, X509Certificate responderCertificate, Collection supportedCertificateIssuers, boolean doesSupportNonce) throws OCSPCertificateException { + public DesignatedOcspServiceConfiguration(URI ocspServiceAccessLocation, X509Certificate responderCertificate, Collection supportedCertificateIssuers, boolean doesSupportNonce, Duration maxThisUpdateAge, Duration maxNextUpdateAge) throws OCSPCertificateException { this.ocspServiceAccessLocation = Objects.requireNonNull(ocspServiceAccessLocation, "OCSP service access location"); this.responderCertificate = Objects.requireNonNull(responderCertificate, "OCSP responder certificate"); this.supportedIssuers = getIssuerX500Names(Objects.requireNonNull(supportedCertificateIssuers, "supported issuers")); @@ -39,6 +46,8 @@ public DesignatedOcspServiceConfiguration(URI ocspServiceAccessLocation, X509Cer OcspResponseValidator.validateKeyUsageNotCertificateSigning(responderCertificate); OcspResponseValidator.validateExtendedKeyUsageOcspSigning(responderCertificate); this.doesSupportNonce = doesSupportNonce; + this.maxThisUpdateAge = requirePositiveDuration(maxThisUpdateAge, "maxThisUpdateAge"); + this.maxNextUpdateAge = requirePositiveDuration(maxNextUpdateAge, "maxNextUpdateAge"); } public URI getOcspServiceAccessLocation() { @@ -53,6 +62,14 @@ public boolean doesSupportNonce() { return doesSupportNonce; } + public Duration getMaxThisUpdateAge() { + return maxThisUpdateAge; + } + + public Duration getMaxNextUpdateAge() { + return maxNextUpdateAge; + } + public boolean supportsIssuerOf(X509Certificate certificate) throws CertificateEncodingException { return supportedIssuers.contains(new JcaX509CertificateHolder(Objects.requireNonNull(certificate)).getIssuer()); } diff --git a/src/main/java/eu/webeid/ocsp/service/FallbackOcspService.java b/src/main/java/eu/webeid/ocsp/service/FallbackOcspService.java index fd47e49d..94f11a0a 100644 --- a/src/main/java/eu/webeid/ocsp/service/FallbackOcspService.java +++ b/src/main/java/eu/webeid/ocsp/service/FallbackOcspService.java @@ -35,6 +35,7 @@ import java.security.cert.CertificateException; import java.security.cert.TrustAnchor; import java.security.cert.X509Certificate; +import java.time.Duration; import java.util.Date; import java.util.Set; @@ -50,6 +51,8 @@ public class FallbackOcspService implements OcspService { private final FallbackOcspService nextFallback; private final Set trustedCACertificateAnchors; private final CertStore trustedCACertificateCertStore; + private final Duration maxThisUpdateAge; + private final Duration maxNextUpdateAge; public FallbackOcspService(FallbackOcspServiceConfiguration configuration) { this.url = configuration.getAccessLocation(); @@ -60,6 +63,8 @@ public FallbackOcspService(FallbackOcspServiceConfiguration configuration) { : null; this.trustedCACertificateAnchors = configuration.getTrustedCACertificateAnchors(); this.trustedCACertificateCertStore = configuration.getTrustedCACertificateCertStore(); + this.maxThisUpdateAge = configuration.getMaxThisUpdateAge(); + this.maxNextUpdateAge = configuration.getMaxNextUpdateAge(); } @Override @@ -72,6 +77,16 @@ public URI getAccessLocation() { return url; } + @Override + public Duration getMaxThisUpdateAge() { + return maxThisUpdateAge; + } + + @Override + public Duration getMaxNextUpdateAge() { + return maxNextUpdateAge; + } + @Override public void validateResponderCertificate(X509CertificateHolder cert, Date now) throws AuthTokenException { try { diff --git a/src/main/java/eu/webeid/ocsp/service/FallbackOcspServiceConfiguration.java b/src/main/java/eu/webeid/ocsp/service/FallbackOcspServiceConfiguration.java index d883825d..943c0923 100644 --- a/src/main/java/eu/webeid/ocsp/service/FallbackOcspServiceConfiguration.java +++ b/src/main/java/eu/webeid/ocsp/service/FallbackOcspServiceConfiguration.java @@ -30,9 +30,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; @@ -42,12 +45,15 @@ public class FallbackOcspServiceConfiguration { private final X500Name issuerDN; private final Set trustedCACertificateAnchors; private final CertStore trustedCACertificateCertStore; + private final Duration maxThisUpdateAge; + private final Duration maxNextUpdateAge; public FallbackOcspServiceConfiguration(URI accessLocation, X509Certificate responderCertificate, boolean doesSupportNonce, FallbackOcspServiceConfiguration nextFallbackConfiguration, X500Name issuerDN, Set trustedCACertificateAnchors, - CertStore trustedCACertificateCertStore) throws OCSPCertificateException { + CertStore trustedCACertificateCertStore, + Duration maxThisUpdateAge, Duration maxNextUpdateAge) throws OCSPCertificateException { this.accessLocation = Objects.requireNonNull(accessLocation, "Fallback OCSP service access location"); this.responderCertificate = responderCertificate; if (responderCertificate != null) { @@ -61,6 +67,8 @@ public FallbackOcspServiceConfiguration(URI accessLocation, X509Certificate resp this.issuerDN = Objects.requireNonNull(issuerDN, "issuerDN"); this.trustedCACertificateAnchors = Objects.requireNonNull(trustedCACertificateAnchors, "trustedCACertificateAnchors"); this.trustedCACertificateCertStore = Objects.requireNonNull(trustedCACertificateCertStore, "trustedCACertificateCertStore"); + this.maxThisUpdateAge = requirePositiveDuration(maxThisUpdateAge, "maxThisUpdateAge"); + this.maxNextUpdateAge = requirePositiveDuration(maxNextUpdateAge, "maxNextUpdateAge"); } public URI getAccessLocation() { @@ -90,4 +98,12 @@ public Set getTrustedCACertificateAnchors() { public CertStore getTrustedCACertificateCertStore() { return trustedCACertificateCertStore; } + + public Duration getMaxThisUpdateAge() { + return maxThisUpdateAge; + } + + public Duration getMaxNextUpdateAge() { + return maxNextUpdateAge; + } } diff --git a/src/main/java/eu/webeid/ocsp/service/OcspService.java b/src/main/java/eu/webeid/ocsp/service/OcspService.java index e72bf3b8..2f388e2f 100644 --- a/src/main/java/eu/webeid/ocsp/service/OcspService.java +++ b/src/main/java/eu/webeid/ocsp/service/OcspService.java @@ -7,6 +7,7 @@ import eu.webeid.security.exceptions.AuthTokenException; import java.net.URI; +import java.time.Duration; import java.util.Date; import java.util.Optional; @@ -16,6 +17,10 @@ public interface OcspService { URI getAccessLocation(); + Duration getMaxThisUpdateAge(); + + Duration getMaxNextUpdateAge(); + void validateResponderCertificate(X509CertificateHolder cert, Date now) throws AuthTokenException; default Optional getFallbackService() { diff --git a/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java b/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java index 16afe9f5..a7a38191 100644 --- a/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java +++ b/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java @@ -64,7 +64,6 @@ import java.util.Map; import java.util.Optional; -import static eu.webeid.security.util.DateAndTime.requirePositiveDuration; import static java.util.Objects.requireNonNull; /** @@ -79,17 +78,13 @@ public class ResilientOcspCertificateRevocationChecker extends OcspCertificateRe private final CircuitBreakerRegistry circuitBreakerRegistry; private final RetryRegistry retryRegistry; - private final Duration fallbackMaxOcspResponseThisUpdateAge; public ResilientOcspCertificateRevocationChecker(OcspClient ocspClient, OcspServiceProvider ocspServiceProvider, CircuitBreakerConfig circuitBreakerConfig, RetryConfig retryConfig, - Duration allowedOcspResponseTimeSkew, - Duration primaryMaxOcspResponseThisUpdateAge, - Duration fallbackMaxOcspResponseThisUpdateAge) { - super(ocspClient, ocspServiceProvider, allowedOcspResponseTimeSkew, primaryMaxOcspResponseThisUpdateAge); - this.fallbackMaxOcspResponseThisUpdateAge = requirePositiveDuration(fallbackMaxOcspResponseThisUpdateAge, "fallbackMaxOcspResponseThisUpdateAge"); + Duration allowedOcspResponseTimeSkew) { + super(ocspClient, ocspServiceProvider, allowedOcspResponseTimeSkew); this.circuitBreakerRegistry = CircuitBreakerRegistry.custom() .withCircuitBreakerConfig(getCircuitBreakerConfig(circuitBreakerConfig)) .build(); @@ -116,7 +111,7 @@ public List validateCertificateNotRevoked(X509Certificate subjec 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, certificateId, getMaxOcspResponseThisUpdateAge())); + return List.of(request(primaryService, subjectCertificate, certificateId)); } CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(primaryService.getAccessLocation().toASCIIString()); @@ -155,7 +150,7 @@ private CheckedSupplier buildFallbackSupplier(FallbackOcspServic List revocationInfoList) { CheckedSupplier firstFallbackSupplier = () -> { try { - return request(firstFallbackService, subjectCertificate, certificateId, fallbackMaxOcspResponseThisUpdateAge); + return request(firstFallbackService, subjectCertificate, certificateId); } catch (Exception e) { createAndAddRevocationInfoToList(e, revocationInfoList); throw e; @@ -169,7 +164,7 @@ private CheckedSupplier buildFallbackSupplier(FallbackOcspServic } CheckedSupplier secondFallbackSupplier = () -> { try { - return request(secondFallbackService, subjectCertificate, certificateId, fallbackMaxOcspResponseThisUpdateAge); + return request(secondFallbackService, subjectCertificate, certificateId); } catch (Exception e) { createAndAddRevocationInfoToList(e, revocationInfoList); throw e; @@ -197,7 +192,7 @@ private CheckedSupplier decorateWithResilience(OcspService prima CircuitBreaker circuitBreaker) { CheckedSupplier primarySupplier = () -> { try { - return request(primaryService, subjectCertificate, certificateId, getMaxOcspResponseThisUpdateAge()); + return request(primaryService, subjectCertificate, certificateId); } catch (Exception e) { createAndAddRevocationInfoToList(e, revocationInfoList); throw e; @@ -259,7 +254,7 @@ private void createAndAddRevocationInfoToList(Throwable throwable, List 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(); } @@ -365,11 +357,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 { 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/OcspResponseValidatorTest.java b/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java index 490ae60c..8a53653b 100644 --- a/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java +++ b/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java @@ -45,17 +45,22 @@ class OcspResponseValidatorTest { private static final Duration TIME_SKEW = OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW; private static final Duration THIS_UPDATE_AGE = OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE; + private static final Duration NEXT_UPDATE_AGE = OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE; + private static final Duration LONG_THIS_UPDATE_AGE = Duration.ofDays(365); + private static final Duration LONG_NEXT_UPDATE_AGE = Duration.ofDays(365); + /** Shorter than {@link #TIME_SKEW}, to show that the nextUpdate age check is independent of the time skew. */ + private static final Duration SHORT_NEXT_UPDATE_AGE = Duration.ofMinutes(2); private static final URI OCSP_URL = URI.create("https://example.org"); @Test - void whenThisAndNextUpdateWithinSkew_thenValidationSucceeds() { + void whenThisAndNextUpdateWithinAgeLimits_thenValidationSucceeds() { final SingleResp mockResponse = mock(SingleResp.class); var now = Instant.now(); var thisUpdateWithinAgeLimit = getThisUpdateWithinAgeLimit(now); var nextUpdateWithinAgeLimit = Date.from(now.minus(THIS_UPDATE_AGE.minusSeconds(2))); when(mockResponse.getThisUpdate()).thenReturn(thisUpdateWithinAgeLimit); when(mockResponse.getNextUpdate()).thenReturn(nextUpdateWithinAgeLimit); - assertThatCode(() -> validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, OCSP_URL)) + assertThatCode(() -> validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, NEXT_UPDATE_AGE, OCSP_URL)) .doesNotThrowAnyException(); } @@ -69,7 +74,7 @@ void whenNextUpdateBeforeThisUpdate_thenThrows() { when(mockResponse.getNextUpdate()).thenReturn(beforeThisUpdate); assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) .isThrownBy(() -> - validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, OCSP_URL)) + validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, LONG_NEXT_UPDATE_AGE, OCSP_URL)) .withMessageStartingWith("User certificate revocation check has failed: " + "Certificate status update time check failed: " + "nextUpdate '" + beforeThisUpdate.toInstant() + "' is before thisUpdate '" + thisUpdateWithinAgeLimit.toInstant() + "'"); @@ -83,7 +88,7 @@ void whenThisUpdateHalfHourBeforeNow_thenThrows() { when(mockResponse.getThisUpdate()).thenReturn(halfHourBeforeNow); assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) .isThrownBy(() -> - validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, OCSP_URL)) + validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, NEXT_UPDATE_AGE, OCSP_URL)) .withMessageStartingWith("User certificate revocation check has failed: " + "Certificate status update time check failed: " + "thisUpdate '" + halfHourBeforeNow.toInstant() + "' is too old, minimum time allowed: "); @@ -97,12 +102,22 @@ void whenThisUpdateHalfHourAfterNow_thenThrows() { when(mockResponse.getThisUpdate()).thenReturn(halfHourAfterNow); assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) .isThrownBy(() -> - validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, OCSP_URL)) + validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, NEXT_UPDATE_AGE, OCSP_URL)) .withMessageStartingWith("User certificate revocation check has failed: " + "Certificate status update time check failed: " + "thisUpdate '" + halfHourAfterNow.toInstant() + "' is too far in the future, latest allowed: "); } + @Test + void whenNextUpdateIsNull_thenValidationSucceeds() { + final SingleResp mockResponse = mock(SingleResp.class); + var now = Instant.now(); + when(mockResponse.getThisUpdate()).thenReturn(getThisUpdateWithinAgeLimit(now)); + when(mockResponse.getNextUpdate()).thenReturn(null); + assertThatCode(() -> validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, NEXT_UPDATE_AGE, OCSP_URL)) + .doesNotThrowAnyException(); + } + @Test void whenNextUpdateHalfHourBeforeNow_thenThrows() { final SingleResp mockResponse = mock(SingleResp.class); @@ -113,11 +128,39 @@ void whenNextUpdateHalfHourBeforeNow_thenThrows() { when(mockResponse.getNextUpdate()).thenReturn(halfHourBeforeNow); assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) .isThrownBy(() -> - validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, OCSP_URL)) - .withMessage("User certificate revocation check has failed: " + validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, NEXT_UPDATE_AGE, OCSP_URL)) + .withMessageStartingWith("User certificate revocation check has failed: " + + "Certificate status update time check failed: " + + "nextUpdate '" + halfHourBeforeNow.toInstant() + "' is too old, minimum time allowed: '"); + } + + @Test + void whenNextUpdateOlderThanMaxNextUpdateAgeButWithinTimeSkew_thenThrows() { + final SingleResp mockResponse = mock(SingleResp.class); + var now = Instant.now(); + var thisUpdateBeforeNextUpdate = Date.from(now.minus(14, ChronoUnit.MINUTES)); + var nextUpdateWithinTimeSkewButTooOld = Date.from(now.minus(10, ChronoUnit.MINUTES)); + when(mockResponse.getThisUpdate()).thenReturn(thisUpdateBeforeNextUpdate); + when(mockResponse.getNextUpdate()).thenReturn(nextUpdateWithinTimeSkewButTooOld); + assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> + validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, TIME_SKEW, SHORT_NEXT_UPDATE_AGE, OCSP_URL)) + .withMessageStartingWith("User certificate revocation check has failed: " + "Certificate status update time check failed: " - + "nextUpdate '" + halfHourBeforeNow.toInstant() + "' is in the past" - + " (OCSP responder: https://example.org)"); + + "nextUpdate '" + nextUpdateWithinTimeSkewButTooOld.toInstant() + "' is too old, minimum time allowed: '"); + } + + @Test + void whenNextUpdateOlderThanTimeSkewButWithinMaxNextUpdateAge_thenValidationSucceeds() { + final SingleResp mockResponse = mock(SingleResp.class); + var now = Instant.now(); + var thisUpdateOlderThanTimeSkew = Date.from(now.minus(25, ChronoUnit.MINUTES)); + var nextUpdateOlderThanTimeSkew = Date.from(now.minus(20, ChronoUnit.MINUTES)); + when(mockResponse.getThisUpdate()).thenReturn(thisUpdateOlderThanTimeSkew); + when(mockResponse.getNextUpdate()).thenReturn(nextUpdateOlderThanTimeSkew); + assertThatCode(() -> + validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, LONG_THIS_UPDATE_AGE, LONG_NEXT_UPDATE_AGE, OCSP_URL)) + .doesNotThrowAnyException(); } @Test diff --git a/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceTest.java b/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceTest.java index 7547cc54..1acb5238 100644 --- a/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceTest.java +++ b/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceTest.java @@ -34,6 +34,8 @@ 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; @@ -73,7 +75,8 @@ static void setUpFixtures() throws Exception { } private static AiaOcspServiceConfiguration configurationWithNonceDisabledFor(X500Name... nonceDisabledIssuerDNs) { - return new AiaOcspServiceConfiguration(Set.of(nonceDisabledIssuerDNs), trustedCaAnchors, trustedCaCertStore); + return new AiaOcspServiceConfiguration(Set.of(nonceDisabledIssuerDNs), trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE); } @Test @@ -102,7 +105,8 @@ void whenFallbackServiceProvided_thenGetFallbackServiceReturnsIt() throws Except AiaOcspServiceConfiguration configuration = configurationWithNonceDisabledFor(); FallbackOcspServiceConfiguration fallbackConfiguration = new FallbackOcspServiceConfiguration( FALLBACK_URI, getDemoEsteidSk2018AiaOcspResponder(), true, - null, esteid2018IssuerDN, trustedCaAnchors, trustedCaCertStore); + null, esteid2018IssuerDN, trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE); FallbackOcspService fallback = new FallbackOcspService(fallbackConfiguration); AiaOcspService service = new AiaOcspService(configuration, esteid2018UserCert, fallback); diff --git a/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceConfigurationTest.java b/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceConfigurationTest.java index 6c501a6b..d638aed8 100644 --- a/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceConfigurationTest.java +++ b/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceConfigurationTest.java @@ -32,9 +32,13 @@ import java.security.cert.CertStore; import java.security.cert.TrustAnchor; import java.security.cert.X509Certificate; +import java.time.Duration; 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; @@ -43,6 +47,7 @@ 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; class FallbackOcspServiceConfigurationTest { @@ -72,7 +77,8 @@ static void setUpFixtures() throws Exception { void whenAccessLocationIsNull_thenThrows() { assertThatNullPointerException() .isThrownBy(() -> new FallbackOcspServiceConfiguration( - null, null, true, null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore)) + null, null, true, null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE)) .withMessage("Fallback OCSP service access location"); } @@ -80,7 +86,8 @@ void whenAccessLocationIsNull_thenThrows() { void whenIssuerDnIsNull_thenThrows() { assertThatNullPointerException() .isThrownBy(() -> new FallbackOcspServiceConfiguration( - FALLBACK_URI, null, true, null, null, trustedCaAnchors, trustedCaCertStore)) + FALLBACK_URI, null, true, null, null, trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE)) .withMessage("issuerDN"); } @@ -88,7 +95,8 @@ void whenIssuerDnIsNull_thenThrows() { void whenTrustedCaAnchorsIsNull_thenThrows() { assertThatNullPointerException() .isThrownBy(() -> new FallbackOcspServiceConfiguration( - FALLBACK_URI, null, true, null, ISSUER_DN, null, trustedCaCertStore)) + FALLBACK_URI, null, true, null, ISSUER_DN, null, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE)) .withMessage("trustedCACertificateAnchors"); } @@ -96,15 +104,71 @@ void whenTrustedCaAnchorsIsNull_thenThrows() { void whenTrustedCaCertStoreIsNull_thenThrows() { assertThatNullPointerException() .isThrownBy(() -> new FallbackOcspServiceConfiguration( - FALLBACK_URI, null, true, null, ISSUER_DN, trustedCaAnchors, null)) + FALLBACK_URI, null, true, null, ISSUER_DN, trustedCaAnchors, null, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE)) .withMessage("trustedCACertificateCertStore"); } + @Test + void whenMaxThisUpdateAgeIsNull_thenThrows() { + assertThatNullPointerException() + .isThrownBy(() -> new FallbackOcspServiceConfiguration( + FALLBACK_URI, null, true, null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore, + null, DEFAULT_NEXT_UPDATE_AGE)) + .withMessage("maxThisUpdateAge must not be null"); + } + + @Test + void whenMaxNextUpdateAgeIsNull_thenThrows() { + assertThatNullPointerException() + .isThrownBy(() -> new FallbackOcspServiceConfiguration( + FALLBACK_URI, null, true, null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, null)) + .withMessage("maxNextUpdateAge must not be null"); + } + + @Test + void whenMaxThisUpdateAgeIsZero_thenThrows() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new FallbackOcspServiceConfiguration( + FALLBACK_URI, null, true, null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore, + Duration.ZERO, DEFAULT_NEXT_UPDATE_AGE)) + .withMessage("maxThisUpdateAge must be greater than zero"); + } + + @Test + void whenMaxThisUpdateAgeIsNegative_thenThrows() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new FallbackOcspServiceConfiguration( + FALLBACK_URI, null, true, null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore, + Duration.ofMinutes(-1), DEFAULT_NEXT_UPDATE_AGE)) + .withMessage("maxThisUpdateAge must be greater than zero"); + } + + @Test + void whenMaxNextUpdateAgeIsZero_thenThrows() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new FallbackOcspServiceConfiguration( + FALLBACK_URI, null, true, null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, Duration.ZERO)) + .withMessage("maxNextUpdateAge must be greater than zero"); + } + + @Test + void whenMaxNextUpdateAgeIsNegative_thenThrows() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new FallbackOcspServiceConfiguration( + FALLBACK_URI, null, true, null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, Duration.ofMinutes(-1))) + .withMessage("maxNextUpdateAge must be greater than zero"); + } + @Test void whenResponderCertificateLacksSigningExtension_thenThrows() { assertThatExceptionOfType(OCSPCertificateException.class) .isThrownBy(() -> new FallbackOcspServiceConfiguration( - FALLBACK_URI, nonSigningUserCert, true, null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore)) + FALLBACK_URI, nonSigningUserCert, true, null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE)) .withMessageContaining("Extended Key Usage extension does not contain OCSP Signing, " + "which is required for OCSP response signing"); } @@ -113,14 +177,16 @@ void whenResponderCertificateLacksSigningExtension_thenThrows() { void whenResponderCertificateLacksKeyUsageExtension_thenThrows() { assertThatExceptionOfType(OCSPCertificateException.class) .isThrownBy(() -> new FallbackOcspServiceConfiguration( - FALLBACK_URI, ocspResponder2020Cert, true, null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore)) + FALLBACK_URI, ocspResponder2020Cert, true, null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE)) .withMessageContaining("does not contain the Key Usage extension required for OCSP response signing"); } @Test void whenResponderCertificateIsNull_thenConstructionSucceeds() { assertThatCode(() -> new FallbackOcspServiceConfiguration( - FALLBACK_URI, null, true, null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore)) + FALLBACK_URI, null, true, null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE)) .doesNotThrowAnyException(); } @@ -128,11 +194,13 @@ void whenResponderCertificateIsNull_thenConstructionSucceeds() { void whenResponderCertificateHasSigningExtension_thenConstructionSucceedsAndAccessorsReturnConfiguredValues() throws Exception { FallbackOcspServiceConfiguration nextFallback = new FallbackOcspServiceConfiguration( URI.create("http://next.fallback.ocsp.test"), null, false, null, - ISSUER_DN, trustedCaAnchors, trustedCaCertStore); + ISSUER_DN, trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE); FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( FALLBACK_URI, aiaOcspResponderCert, true, nextFallback, - ISSUER_DN, trustedCaAnchors, trustedCaCertStore); + ISSUER_DN, trustedCaAnchors, trustedCaCertStore, + Duration.ofMinutes(3), Duration.ofMinutes(4)); assertThat(configuration.getAccessLocation()).isEqualTo(FALLBACK_URI); assertThat(configuration.getResponderCertificate()).isEqualTo(aiaOcspResponderCert); @@ -141,5 +209,7 @@ void whenResponderCertificateHasSigningExtension_thenConstructionSucceedsAndAcce assertThat(configuration.getIssuerDN()).isEqualTo(ISSUER_DN); assertThat(configuration.getTrustedCACertificateAnchors()).isSameAs(trustedCaAnchors); assertThat(configuration.getTrustedCACertificateCertStore()).isSameAs(trustedCaCertStore); + assertThat(configuration.getMaxThisUpdateAge()).isEqualTo(Duration.ofMinutes(3)); + assertThat(configuration.getMaxNextUpdateAge()).isEqualTo(Duration.ofMinutes(4)); } } diff --git a/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceTest.java b/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceTest.java index 0b8aaa83..57267ab8 100644 --- a/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceTest.java +++ b/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceTest.java @@ -40,6 +40,8 @@ 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; @@ -90,7 +92,8 @@ static void setUpFixtures() throws Exception { void whenConfigurationIsProvided_thenAccessorsReturnConfiguredValues() throws Exception { FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( PRIMARY_FALLBACK_URI, aiaOcspResponderCert, true, - null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore); + null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE); FallbackOcspService service = new FallbackOcspService(configuration); @@ -103,10 +106,12 @@ void whenConfigurationIsProvided_thenAccessorsReturnConfiguredValues() throws Ex void whenNextFallbackConfigurationProvided_thenChainIsBuiltRecursively() throws Exception { FallbackOcspServiceConfiguration secondaryConfiguration = new FallbackOcspServiceConfiguration( SECONDARY_FALLBACK_URI, null, false, - null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore); + null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE); FallbackOcspServiceConfiguration primaryConfiguration = new FallbackOcspServiceConfiguration( PRIMARY_FALLBACK_URI, null, true, - secondaryConfiguration, ISSUER_DN, trustedCaAnchors, trustedCaCertStore); + secondaryConfiguration, ISSUER_DN, trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE); FallbackOcspService primary = new FallbackOcspService(primaryConfiguration); @@ -121,7 +126,8 @@ void whenNextFallbackConfigurationProvided_thenChainIsBuiltRecursively() throws void whenResponderCertificateIsPinnedAndMatches_thenValidationSucceeds() throws Exception { FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( PRIMARY_FALLBACK_URI, aiaOcspResponderCert, true, - null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore); + null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE); FallbackOcspService service = new FallbackOcspService(configuration); X509CertificateHolder matchingHolder = new X509CertificateHolder(aiaOcspResponderCert.getEncoded()); @@ -134,7 +140,8 @@ void whenResponderCertificateIsPinnedAndMatches_thenValidationSucceeds() throws void whenResponderCertificateIsPinnedAndDiffers_thenThrows() throws Exception { FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( PRIMARY_FALLBACK_URI, aiaOcspResponderCert, true, - null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore); + null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE); FallbackOcspService service = new FallbackOcspService(configuration); X509CertificateHolder differentHolder = new X509CertificateHolder(esteid2018CaCert.getEncoded()); @@ -148,7 +155,8 @@ void whenResponderCertificateIsPinnedAndDiffers_thenThrows() throws Exception { void whenResponderCertificateIsNotPinnedButTrustedByCa_thenValidationSucceeds() throws Exception { FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( PRIMARY_FALLBACK_URI, null, true, - null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore); + null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE); FallbackOcspService service = new FallbackOcspService(configuration); // The DEMO AIA responder certificate chains to the TEST of ESTEID2018 CA, which is a trusted anchor. X509CertificateHolder responderHolder = new X509CertificateHolder(aiaOcspResponderCert.getEncoded()); @@ -162,7 +170,8 @@ void whenResponderCertificateIsNotPinnedButTrustedByCa_thenValidationSucceeds() void whenResponderCertificateIsNotPinnedAndLacksSigningExtension_thenThrows() throws Exception { FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( PRIMARY_FALLBACK_URI, null, true, - null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore); + null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE); FallbackOcspService service = new FallbackOcspService(configuration); X509CertificateHolder nonSigningHolder = new X509CertificateHolder(nonSigningUserCert.getEncoded()); @@ -180,7 +189,8 @@ void whenResponderCertificateLacksKeyUsageExtension_thenThrows() throws Exceptio // Key Usage check rejects this certificate whatever the configured trust anchors are. FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( PRIMARY_FALLBACK_URI, null, true, - null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore); + null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE); FallbackOcspService service = new FallbackOcspService(configuration); X509CertificateHolder noKeyUsageHolder = new X509CertificateHolder(ocspResponder2020Cert.getEncoded()); @@ -194,7 +204,8 @@ void whenResponderCertificateLacksKeyUsageExtension_thenThrows() throws Exceptio void whenResponderCertificateIsNotPinnedAndNotTrustedByCa_thenThrows() throws Exception { FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( PRIMARY_FALLBACK_URI, null, true, - null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore); + null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE); FallbackOcspService service = new FallbackOcspService(configuration); // The self-signed responder certificate satisfies every OCSP responder extension requirement, but no // trusted CA issued it (only the ESTEID2018 and ESTEID-SK 2015 CAs are anchors), so PKIX path building fails. @@ -209,7 +220,8 @@ void whenResponderCertificateIsNotPinnedAndNotTrustedByCa_thenThrows() throws Ex void whenResponderCertificateHolderConversionFails_thenThrows() throws Exception { FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( PRIMARY_FALLBACK_URI, aiaOcspResponderCert, true, - null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore); + null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE); FallbackOcspService service = new FallbackOcspService(configuration); // Make JcaX509CertificateConverter.getCertificate(holder) fail: the converter calls holder.getEncoded() // and wraps the resulting IOException into a CertificateException, which the service catches and rewraps. @@ -227,7 +239,8 @@ void whenResponderCertificateHolderConversionFails_thenThrows() throws Exception void whenResponderCertificateIsExpiredAtValidationDate_thenThrows() throws Exception { FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration( PRIMARY_FALLBACK_URI, aiaOcspResponderCert, true, - null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore); + null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE); FallbackOcspService service = new FallbackOcspService(configuration); X509CertificateHolder responderHolder = new X509CertificateHolder(aiaOcspResponderCert.getEncoded()); Date farFuture = new Date(4102444800000L); // 2100-01-01 diff --git a/src/test/java/eu/webeid/ocsp/service/OcspServiceConfigurationTest.java b/src/test/java/eu/webeid/ocsp/service/OcspServiceConfigurationTest.java index 2e4933ab..09c428fd 100644 --- a/src/test/java/eu/webeid/ocsp/service/OcspServiceConfigurationTest.java +++ b/src/test/java/eu/webeid/ocsp/service/OcspServiceConfigurationTest.java @@ -38,6 +38,8 @@ import java.util.List; import java.util.Set; +import static eu.webeid.ocsp.OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE; +import static eu.webeid.ocsp.OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE; import static eu.webeid.security.testutil.Certificates.getTestEsteid2018CA; import static eu.webeid.security.testutil.TestCertificateBuilder.buildCertificate; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; @@ -138,14 +140,16 @@ void whenFallbackOcspServiceConfigurationResponderCertMissingOcspSigningEku_then private static void newDesignatedOcspServiceConfiguration(X509Certificate responder) throws Exception { new DesignatedOcspServiceConfiguration( - OCSP_URL, responder, List.of(getTestEsteid2018CA()), true); + OCSP_URL, responder, List.of(getTestEsteid2018CA()), true, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE); } private static void newFallbackOcspServiceConfiguration(X509Certificate responder) throws Exception { final List trustedCAs = List.of(getTestEsteid2018CA()); final Set trustAnchors = CertificateValidator.buildTrustAnchorsFromCertificates(trustedCAs); final CertStore certStore = CertificateValidator.buildCertStoreFromCertificates(trustedCAs); - new FallbackOcspServiceConfiguration(OCSP_URL, responder, true, null, null, trustAnchors, certStore); + new FallbackOcspServiceConfiguration(OCSP_URL, responder, true, null, null, trustAnchors, certStore, + DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE); } } diff --git a/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java b/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java index ac31769c..46a4b2b5 100644 --- a/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java +++ b/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java @@ -12,6 +12,7 @@ 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; @@ -21,6 +22,9 @@ public class OcspServiceMaker { + public static final Duration MAX_THIS_UPDATE_AGE = Duration.ofMinutes(3); + public static final Duration MAX_NEXT_UPDATE_AGE = Duration.ofMinutes(20); + private static final String TEST_OCSP_ACCESS_LOCATION = "http://demo.sk.ee/ocsp"; private static final List TRUSTED_CA_CERTIFICATES; private static final X500Name ISSUER_DN = new X500Name("CN=TEST of ESTEID-SK 2015, OID.2.5.4.97=NTREE-10747013, O=AS Sertifitseerimiskeskus, C=EE"); @@ -53,7 +57,9 @@ private static AiaOcspServiceConfiguration getAiaOcspServiceConfiguration() thro return new AiaOcspServiceConfiguration( Set.of(ISSUER_DN), CertificateValidator.buildTrustAnchorsFromCertificates(TRUSTED_CA_CERTIFICATES), - CertificateValidator.buildCertStoreFromCertificates(TRUSTED_CA_CERTIFICATES)); + CertificateValidator.buildCertStoreFromCertificates(TRUSTED_CA_CERTIFICATES), + MAX_THIS_UPDATE_AGE, + MAX_NEXT_UPDATE_AGE); } public static DesignatedOcspServiceConfiguration getDesignatedOcspServiceConfiguration() throws CertificateException, IOException, OCSPCertificateException { @@ -69,7 +75,9 @@ private static DesignatedOcspServiceConfiguration getDesignatedOcspServiceConfig URI.create(ocspServiceAccessLocation), getTestSelfSignedOcspResponder(), TRUSTED_CA_CERTIFICATES, - doesSupportNonce); + doesSupportNonce, + MAX_THIS_UPDATE_AGE, + MAX_NEXT_UPDATE_AGE); } } diff --git a/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java b/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java index 428f84ea..2036e730 100644 --- a/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java +++ b/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java @@ -3,6 +3,7 @@ package eu.webeid.ocsp.service; +import eu.webeid.ocsp.OcspCertificateRevocationChecker; import eu.webeid.ocsp.exceptions.OCSPCertificateException; import eu.webeid.security.certificate.CertificateValidator; import eu.webeid.security.exceptions.CertificateNotTrustedException; @@ -19,6 +20,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.List; import java.util.Optional; @@ -46,6 +48,8 @@ void whenDesignatedOcspServiceConfigurationProvided_thenCreatesDesignatedOcspSer final OcspService service = ocspServiceProvider.getService(getJaakKristjanEsteid2018Cert()); 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(getTestSelfSignedOcspResponder().getEncoded()), new Date(1630000000000L))) .doesNotThrowAnyException(); @@ -61,6 +65,8 @@ void whenAiaOcspServiceConfigurationProvided_thenCreatesAiaOcspService() throws final OcspService service2018 = ocspServiceProvider.getService(getJaakKristjanEsteid2018Cert()); assertThat(service2018.getAccessLocation()).isEqualTo(new URI("http://aia.demo.sk.ee/esteid2018")); assertThat(service2018.doesSupportNonce()).isTrue(); + assertThat(service2018.getMaxThisUpdateAge()).isEqualTo(Duration.ofMinutes(3)); + assertThat(service2018.getMaxNextUpdateAge()).isEqualTo(Duration.ofMinutes(20)); assertThatCode(() -> service2018.validateResponderCertificate(new X509CertificateHolder(getDemoEsteidSk2018AiaOcspResponder().getEncoded()), new Date(1630000000000L))) .doesNotThrowAnyException(); @@ -174,7 +180,9 @@ private static FallbackOcspService newFallbackOcspServiceWithoutPinnedResponder( 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, new X500Name("CN=TEST ISSUER"), trustAnchors, certStore); + URI.create("http://fallback.demo.sk.ee/ocsp"), null, true, null, new X500Name("CN=TEST ISSUER"), trustAnchors, certStore, + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, + OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE); return new FallbackOcspService(configuration); } @@ -190,7 +198,9 @@ void whenFallbackOcspServiceConfigurationProvided_thenAiaServiceCarriesMatchingF URI fallbackUri = URI.create("http://fallback.test/ocsp"); FallbackOcspServiceConfiguration fallbackConfiguration = new FallbackOcspServiceConfiguration( fallbackUri, getDemoEsteidSk2018AiaOcspResponder(), true, - null, issuerDN, trustedAnchors, trustedStore); + null, issuerDN, trustedAnchors, trustedStore, + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, + OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE); OcspServiceProvider provider = new OcspServiceProvider(null, getAiaOcspServiceProvider2018Configuration(), List.of(fallbackConfiguration)); @@ -223,7 +233,9 @@ void whenFallbackOcspServiceConfigurationDoesNotMatchIssuer_thenAiaServiceCarrie X500Name unrelatedIssuerDN = new X500Name("CN=Unrelated CA"); FallbackOcspServiceConfiguration fallbackConfiguration = new FallbackOcspServiceConfiguration( URI.create("http://fallback.test/ocsp"), null, true, - null, unrelatedIssuerDN, trustedAnchors, trustedStore); + null, unrelatedIssuerDN, trustedAnchors, trustedStore, + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, + OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE); OcspServiceProvider provider = new OcspServiceProvider(null, getAiaOcspServiceProvider2018Configuration(), List.of(fallbackConfiguration)); @@ -266,7 +278,9 @@ void whenDesignatedServiceSupportsIssuer_thenDesignatedTakesPrecedenceOverFallba CertificateValidator.buildCertStoreFromCertificates(trustedCertificates); FallbackOcspServiceConfiguration fallbackConfiguration = new FallbackOcspServiceConfiguration( URI.create("http://fallback.test/ocsp"), getDemoEsteidSk2018AiaOcspResponder(), true, - null, issuerDN, trustedAnchors, trustedStore); + null, issuerDN, trustedAnchors, trustedStore, + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, + OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE); OcspServiceProvider provider = new OcspServiceProvider(getDesignatedOcspServiceConfiguration(), getAiaOcspServiceProvider2018Configuration(), List.of(fallbackConfiguration)); @@ -290,10 +304,14 @@ void whenDuplicateIssuerFallbackConfigurations_thenLastOneWins() throws Exceptio URI lastFallbackUri = URI.create("http://fallback-last.test/ocsp"); FallbackOcspServiceConfiguration firstConfiguration = new FallbackOcspServiceConfiguration( firstFallbackUri, getDemoEsteidSk2018AiaOcspResponder(), true, - null, issuerDN, trustedAnchors, trustedStore); + null, issuerDN, trustedAnchors, trustedStore, + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, + OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE); FallbackOcspServiceConfiguration lastConfiguration = new FallbackOcspServiceConfiguration( lastFallbackUri, getDemoEsteidSk2018AiaOcspResponder(), true, - null, issuerDN, trustedAnchors, trustedStore); + null, issuerDN, trustedAnchors, trustedStore, + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, + OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE); OcspServiceProvider provider = new OcspServiceProvider(null, getAiaOcspServiceProvider2018Configuration(), List.of(firstConfiguration, lastConfiguration)); @@ -309,7 +327,9 @@ private static AiaOcspServiceConfiguration getAiaOcspServiceProvider2018Configur return new AiaOcspServiceConfiguration( Set.of(), CertificateValidator.buildTrustAnchorsFromCertificates(trustedCertificates), - CertificateValidator.buildCertStoreFromCertificates(trustedCertificates)); + CertificateValidator.buildCertStoreFromCertificates(trustedCertificates), + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, + OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE); } } diff --git a/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java b/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java index f3ecb12b..77b18838 100644 --- a/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java +++ b/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java @@ -45,8 +45,6 @@ import org.bouncycastle.cert.ocsp.SingleResp; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; import org.mockito.MockedStatic; import org.mockito.Mockito; @@ -67,7 +65,6 @@ 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.assertj.core.api.Assertions.assertThatThrownBy; import static eu.webeid.security.testutil.ResourceUtil.bytesFromResource; import static org.awaitility.Awaitility.await; import static org.mockito.ArgumentMatchers.any; @@ -86,6 +83,7 @@ class ResilientOcspCertificateRevocationCheckerTest { 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 @@ -457,11 +455,11 @@ void whenPrimaryReturnsMissingBasicOcspResponse_thenThrows() throws Exception { } @Test - void whenRejectUnknownStatusIsTrueAndPrimaryReturnsUnknown_thenFallbackHandlesTheCall() throws Exception { + void whenPrimaryReturnsUnknown_thenFallbackHandlesTheCall() throws Exception { OcspClient ocspClient = mock(OcspClient.class); when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespUnknown); when(ocspClient.request(eq(FALLBACK_URI), any())).thenReturn(ocspRespGood); - ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withRejectUnknownOcspResponseStatus().build(); + ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build(); try (var mockedClock = mockStaticClockAt(WITHIN_RESPONDER_CERT_VALIDITY)) { List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); @@ -471,20 +469,6 @@ void whenRejectUnknownStatusIsTrueAndPrimaryReturnsUnknown_thenFallbackHandlesTh } } - @Test - void whenRejectUnknownStatusIsFalseAndPrimaryReturnsUnknown_thenRevokedShortCircuits() throws Exception { - OcspClient ocspClient = mock(OcspClient.class); - when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespUnknown); - when(ocspClient.request(eq(FALLBACK_URI), any())).thenReturn(ocspRespGood); - ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build(); - - try (var mockedClock = mockStaticClockAt(WITHIN_RESPONDER_CERT_VALIDITY)) { - assertThatExceptionOfType(ResilientUserCertificateRevokedException.class) - .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)); - verify(ocspClient, never()).request(eq(FALLBACK_URI), any()); - } - } - @Test void whenNonceEnabledAndResponseNonceDiffers_thenThrows() throws Exception { // primaryService advertises nonce support; ocspRespGood was signed with a different nonce. @@ -564,22 +548,6 @@ void whenOcspRequestFailsWithStatusCode_thenRevocationInfoContainsHttpStatusCode }); } - @ParameterizedTest - @ValueSource(ints = {0, -1}) - void whenFallbackMaxOcspResponseThisUpdateAgeIsNotPositive_thenThrows(int minutes) { - assertThatThrownBy(() -> new ResilientOcspCertificateRevocationChecker( - mock(OcspClient.class), - mock(OcspServiceProvider.class), - CircuitBreakerConfig.ofDefaults(), - null, - OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW, - OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, - Duration.ofMinutes(minutes), - false)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageStartingWith("fallbackMaxOcspResponseThisUpdateAge must be greater than zero"); - } - @Test void whenPrimaryThrowsRuntimeExceptionThatIsNotOCSPClientException_thenWrapsAsResilientUserCertificateOCSPCheckFailedException() throws Exception { OcspClient ocspClient = mock(OcspClient.class); @@ -768,16 +736,16 @@ void whenAllFail_thenRevocationInfoListContainsExpectedResponderUrisAndAttribute 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 the primary uses - // primaryMaxOcspResponseThisUpdateAge and the fallback uses fallbackMaxOcspResponseThisUpdateAge; swapping - // the two parameters would flip the outcome and fail this test. + // 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) - .withPrimaryMaxOcspResponseThisUpdateAge(Duration.ofMinutes(2)) - .withFallbackMaxOcspResponseThisUpdateAge(Duration.ofMinutes(10)) + .withPrimaryMaxThisUpdateAge(Duration.ofMinutes(2)) + .withFallbackMaxThisUpdateAge(Duration.ofMinutes(10)) .build(); try (var ignored = mockStaticClockAt(FIVE_MIN_AFTER_THIS_UPDATE)) { @@ -845,22 +813,6 @@ void whenNoFallbackConfiguredAndPrimaryReturnsRevoked_thenRevokedPropagatesWithS }); } - @Test - void whenNoFallbackConfiguredAndRejectUnknownAndPrimaryReturnsUnknown_thenOcspCheckFails() throws Exception { - OcspClient ocspClient = mock(OcspClient.class); - when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespUnknown); - ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient) - .withoutFallbacks() - .withRejectUnknownOcspResponseStatus() - .build(); - - try (var ignored = mockStaticClockAt(WITHIN_RESPONDER_CERT_VALIDITY)) { - assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class) - .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) - .satisfies(ex -> assertThat(ex.getValidationInfo().revocationInfoList()).hasSize(1)); - } - } - @Test void whenNoFallbackConfiguredAndPrimaryReturnsUnknown_thenCheckFailedPropagates() throws Exception { // The unknown status fails the OCSP check. @@ -921,13 +873,13 @@ void whenPrimaryAndFirstFallbackFailAndSecondFallbackReturnsRevoked_thenRevokedP 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 fallbackMaxOcspResponseThisUpdateAge (the accepting direction is covered elsewhere). + // 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) - .withFallbackMaxOcspResponseThisUpdateAge(Duration.ofMinutes(2)) + .withFallbackMaxThisUpdateAge(Duration.ofMinutes(2)) .build(); try (var ignored = mockStaticClockAt(FIVE_MIN_AFTER_THIS_UPDATE)) { @@ -948,9 +900,9 @@ private static CheckerBuilder checkerBuilder(OcspClient ocspClient) { } /** - * Builds a {@link ResilientOcspCertificateRevocationChecker} with relaxed thisUpdate age limits and, - * by default, a primary OCSP service with two chained fallbacks: - * PRIMARY_URI -> FALLBACK_URI -> SECOND_FALLBACK_URI. + * 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 { @@ -960,8 +912,8 @@ private static final class CheckerBuilder { private boolean primarySupportsNonce; private CircuitBreakerConfig circuitBreakerConfig = CircuitBreakerConfig.ofDefaults(); private RetryConfig retryConfig; - private Duration primaryMaxOcspResponseThisUpdateAge = LONG_THIS_UPDATE_AGE; - private Duration fallbackMaxOcspResponseThisUpdateAge = LONG_THIS_UPDATE_AGE; + private Duration primaryMaxThisUpdateAge = LONG_THIS_UPDATE_AGE; + private Duration fallbackMaxThisUpdateAge = LONG_THIS_UPDATE_AGE; private CheckerBuilder(OcspClient ocspClient) { this.ocspClient = ocspClient; @@ -996,13 +948,13 @@ private CheckerBuilder withOcspServiceProvider(OcspServiceProvider ocspServicePr return this; } - private CheckerBuilder withPrimaryMaxOcspResponseThisUpdateAge(Duration primaryMaxOcspResponseThisUpdateAge) { - this.primaryMaxOcspResponseThisUpdateAge = primaryMaxOcspResponseThisUpdateAge; + private CheckerBuilder withPrimaryMaxThisUpdateAge(Duration primaryMaxThisUpdateAge) { + this.primaryMaxThisUpdateAge = primaryMaxThisUpdateAge; return this; } - private CheckerBuilder withFallbackMaxOcspResponseThisUpdateAge(Duration fallbackMaxOcspResponseThisUpdateAge) { - this.fallbackMaxOcspResponseThisUpdateAge = fallbackMaxOcspResponseThisUpdateAge; + private CheckerBuilder withFallbackMaxThisUpdateAge(Duration fallbackMaxThisUpdateAge) { + this.fallbackMaxThisUpdateAge = fallbackMaxThisUpdateAge; return this; } @@ -1013,9 +965,7 @@ private ResilientOcspCertificateRevocationChecker build() throws Exception { serviceProvider, circuitBreakerConfig, retryConfig, - OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW, - primaryMaxOcspResponseThisUpdateAge, - fallbackMaxOcspResponseThisUpdateAge + OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW ); } @@ -1026,6 +976,8 @@ private OcspServiceProvider buildMockServiceProvider() throws Exception { 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; } @@ -1033,6 +985,8 @@ private OcspServiceProvider buildMockServiceProvider() throws Exception { 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())).thenReturn(primaryService); From a979fa75e3d621188df25ac7f3c3775f03880a0f Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Thu, 13 Aug 2026 12:03:14 +0300 Subject: [PATCH 17/19] AUT-2981 Wrap unchecked JJWT exceptions in signature validation --- ...AuthTokenSignatureValidationException.java | 8 +++- .../AuthTokenSignatureValidator.java | 11 ++++-- .../AuthTokenSignatureValidatorTest.java | 39 +++++++++++++++++++ 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/main/java/eu/webeid/security/exceptions/AuthTokenSignatureValidationException.java b/src/main/java/eu/webeid/security/exceptions/AuthTokenSignatureValidationException.java index 0c7ab83c..14a58213 100644 --- a/src/main/java/eu/webeid/security/exceptions/AuthTokenSignatureValidationException.java +++ b/src/main/java/eu/webeid/security/exceptions/AuthTokenSignatureValidationException.java @@ -8,8 +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(MESSAGE, cause); } public AuthTokenSignatureValidationException(Throwable 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/test/java/eu/webeid/security/validator/AuthTokenSignatureValidatorTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenSignatureValidatorTest.java index a4754a94..dcbd0baa 100644 --- a/src/test/java/eu/webeid/security/validator/AuthTokenSignatureValidatorTest.java +++ b/src/test/java/eu/webeid/security/validator/AuthTokenSignatureValidatorTest.java @@ -7,13 +7,17 @@ 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 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; @@ -73,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); + } + } From 34878be59c86893a84b35f5183686db855296e66 Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Thu, 13 Aug 2026 12:25:06 +0300 Subject: [PATCH 18/19] AUT-2981 Capture circuit breaker statistics when the request starts --- ...lientOcspCertificateRevocationChecker.java | 48 ++++++- ...tOcspCertificateRevocationCheckerTest.java | 134 +++++++++++++++++- 2 files changed, 174 insertions(+), 8 deletions(-) diff --git a/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java b/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java index a7a38191..72443688 100644 --- a/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java +++ b/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java @@ -116,14 +116,19 @@ public List validateCertificateNotRevoked(X509Certificate subjec 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, certificateId, revocationInfoList); CheckedSupplier decoratedSupplier = decorateWithResilience(primaryService, subjectCertificate, - certificateId, revocationInfoList, fallbackSupplier, circuitBreaker); + 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; } @@ -189,8 +194,12 @@ private CheckedSupplier decorateWithResilience(OcspService prima 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, certificateId); } catch (Exception e) { @@ -204,7 +213,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(); } @@ -379,6 +393,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/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java b/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java index 77b18838..0198bffe 100644 --- a/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java +++ b/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java @@ -29,6 +29,7 @@ import eu.webeid.ocsp.service.FallbackOcspService; import eu.webeid.ocsp.service.OcspService; 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.security.authtoken.WebEidAuthToken; @@ -531,6 +532,121 @@ void whenCircuitBreakerIsOpenAndRecoveryTimeElapses_thenPrimaryIsTriedAgainInHal verify(ocspClient, times(3)).request(eq(PRIMARY_URI), any()); } + @Test + void whenPrimaryAnswersInHalfOpenState_thenStatisticsReportHalfOpenState() 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(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); + + 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 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(); + + List firstCallRevocationInfoList = + checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + List secondCallRevocationInfoList = + checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA); + + // The statistics are captured when the primary request starts, so the outcome of the very call they + // are attached to is not yet included: the first call reports no recorded calls at all and the + // second call reports only the first call's failure. + CircuitBreakerStatistics firstCallStatistics = + getCircuitBreakerStatistics(firstCallRevocationInfoList.get(0)); + assertThat(firstCallStatistics.state()).isEqualTo(CircuitBreaker.State.CLOSED); + assertThat(firstCallStatistics.numberOfBufferedCalls()).isZero(); + assertThat(firstCallStatistics.numberOfFailedCalls()).isZero(); + CircuitBreakerStatistics secondCallStatistics = + getCircuitBreakerStatistics(secondCallRevocationInfoList.get(0)); + assertThat(secondCallStatistics.state()).isEqualTo(CircuitBreaker.State.CLOSED); + assertThat(secondCallStatistics.numberOfBufferedCalls()).isEqualTo(1); + assertThat(secondCallStatistics.numberOfFailedCalls()).isEqualTo(1); + } + @Test void whenOcspRequestFailsWithStatusCode_thenRevocationInfoContainsHttpStatusCodeAndResponseBody() throws Exception { byte[] responseBody = "error".getBytes(); @@ -787,12 +903,15 @@ void whenCircuitBreakerOpens_thenFallbackHandlesCallAndStatisticsReflectOpenStat assertThat(revocationInfoList).hasSize(1); assertThat(revocationInfoList.get(0).ocspResponderUri()).isEqualTo(FALLBACK_URI); - ResilientOcspCertificateRevocationChecker.CircuitBreakerStatistics statistics = - (ResilientOcspCertificateRevocationChecker.CircuitBreakerStatistics) + 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 @@ -1000,6 +1119,17 @@ private static MockedStatic mockStaticClockAt(String i 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); + } + + private static CircuitBreakerStatistics getCircuitBreakerStatistics(RevocationInfo revocationInfo) { + return (CircuitBreakerStatistics) + revocationInfo.ocspResponseAttributes().get(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS); + } + private static CertificateStatus getCertificateStatus(RevocationInfo revocationInfo) throws Exception { OCSPResp ocspResp = (OCSPResp) revocationInfo.ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_RESPONSE); final BasicOCSPResp basicResponse = (BasicOCSPResp) ocspResp.getResponseObject(); From 1bf54e00b1db06ac04a65d56d1abae1b8622307a Mon Sep 17 00:00:00 2001 From: Madis Jaagup Laurson Date: Mon, 28 Sep 2026 12:11:33 +0300 Subject: [PATCH 19/19] AUT-2981 Fix compilation and tests after cherry-picking AUT-2981 commits --- .../AuthTokenSignatureValidationException.java | 4 ---- .../ocsp/service/AiaOcspServiceConfigurationTest.java | 4 +++- .../ResilientOcspCertificateRevocationCheckerTest.java | 1 + .../eu/webeid/security/certificate/OcspNetworkTest.java | 9 +++++---- .../validator/AuthTokenValidatorValidationOrderTest.java | 3 ++- 5 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/main/java/eu/webeid/security/exceptions/AuthTokenSignatureValidationException.java b/src/main/java/eu/webeid/security/exceptions/AuthTokenSignatureValidationException.java index 14a58213..fbf1d371 100644 --- a/src/main/java/eu/webeid/security/exceptions/AuthTokenSignatureValidationException.java +++ b/src/main/java/eu/webeid/security/exceptions/AuthTokenSignatureValidationException.java @@ -18,8 +18,4 @@ public AuthTokenSignatureValidationException(Throwable cause) { super(MESSAGE, cause); } - public AuthTokenSignatureValidationException(Throwable cause) { - super("Token signature validation has failed", cause); - } - } diff --git a/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceConfigurationTest.java b/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceConfigurationTest.java index 06d79020..3b87db8c 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,8 @@ void whenCallerMutatesCollections_thenConfigurationRemainsUnchanged() throws Exc final TrustAnchor anchor = new TrustAnchor(getTestEsteid2018CA(), null); final Set anchors = new HashSet<>(Set.of(anchor)); final AiaOcspServiceConfiguration configuration = new AiaOcspServiceConfiguration( - nonceDisabledIssuerDNs, anchors, CertificateValidator.buildCertStoreFromCertificates(List.of(getTestEsteid2018CA()))); + nonceDisabledIssuerDNs, anchors, CertificateValidator.buildCertStoreFromCertificates(List.of(getTestEsteid2018CA())), + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE); nonceDisabledIssuerDNs.clear(); anchors.clear(); diff --git a/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java b/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java index 0198bffe..84c1fdb9 100644 --- a/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java +++ b/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java @@ -33,6 +33,7 @@ import eu.webeid.resilientocsp.exceptions.ResilientUserCertificateOCSPCheckFailedException; import eu.webeid.resilientocsp.exceptions.ResilientUserCertificateRevokedException; 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; diff --git a/src/test/java/eu/webeid/security/certificate/OcspNetworkTest.java b/src/test/java/eu/webeid/security/certificate/OcspNetworkTest.java index afb2c420..5f58e929 100644 --- a/src/test/java/eu/webeid/security/certificate/OcspNetworkTest.java +++ b/src/test/java/eu/webeid/security/certificate/OcspNetworkTest.java @@ -220,10 +220,11 @@ private List validate(RevocationMode mode, boolean nonceEnabled) ? new OcspCertificateRevocationChecker( OcspClientImpl.build(Duration.ofSeconds(2)), new OcspServiceProvider( - new DesignatedOcspServiceConfiguration(designatedUri, responder, List.of(issuer), nonceEnabled), - new AiaOcspServiceConfiguration(Set.of(), anchors, store)), - OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW, - OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE) + new DesignatedOcspServiceConfiguration(designatedUri, responder, List.of(issuer), nonceEnabled, + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE), + new AiaOcspServiceConfiguration(Set.of(), anchors, store, + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE)), + OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW) : null; return CertificateValidator.validateCertificateTrustAndRevocation( subject, anchors, store, Date.from(now), mode, checker, null, nonceEnabled); diff --git a/src/test/java/eu/webeid/security/validator/AuthTokenValidatorValidationOrderTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenValidatorValidationOrderTest.java index dbdc548a..68874aa1 100644 --- a/src/test/java/eu/webeid/security/validator/AuthTokenValidatorValidationOrderTest.java +++ b/src/test/java/eu/webeid/security/validator/AuthTokenValidatorValidationOrderTest.java @@ -38,6 +38,7 @@ 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; @@ -58,7 +59,7 @@ class AuthTokenValidatorValidationOrderTest { void setUp() { mockedClock = mockStatic(DateAndTime.DefaultClock.class); // Ensure that the certificates do not expire. - mockDate("2021-07-23", mockedClock); + mockDate(VALID_AUTH_TOKEN_TEST_DATE, mockedClock); } @AfterEach