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 59daad1b..6ba58bad 100644 --- a/example/pom.xml +++ b/example/pom.xml @@ -12,7 +12,7 @@ eu.webeid.example web-eid-springboot-example - 3.2.3 + 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,10 +21,9 @@ 17 3.6.0 - 3.2.2 + 4.0.0-SNAPSHOT 6.2.0 1.86 - 1.44 3.5.2 3.6.3 @@ -83,12 +82,6 @@ spring-security-test test - - org.jmockit - jmockit - ${jmockit.version} - test - @@ -102,9 +95,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/main/resources/application.yaml b/example/src/main/resources/application.yaml index 89329c6c..44c0217c 100644 --- a/example/src/main/resources/application.yaml +++ b/example/src/main/resources/application.yaml @@ -18,5 +18,6 @@ server: logging: level: eu.webeid.security: DEBUG + eu.webeid.ocsp: DEBUG eu.webeid.example: DEBUG org.springframework.security.web.csrf.CsrfFilter: DEBUG 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 cc93a16b..5d5a0e8a 100644 --- a/pom.xml +++ b/pom.xml @@ -7,13 +7,13 @@ 4.0.0 authtoken-validation eu.webeid.security - 3.2.2 + 4.0.0-SNAPSHOT jar authtoken-validation Web eID authentication token validation library for Java - 11 + 17 0.13.0 1.86 2.22.2 @@ -22,6 +22,7 @@ 3.27.7 5.23.0 3.6.0 + 3.15.0 3.4.0 3.12.0 3.6.3 @@ -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 57% rename from src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidator.java rename to src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java index bd6d55e8..d8db3d32 100644 --- a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidator.java +++ b/src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.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.ocsp.client.OcspClient; +import eu.webeid.ocsp.exceptions.UserCertificateRevokedException; +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 +33,65 @@ 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 + * @param issuerCertificate direct issuer of the user certificate from the validated certification path * @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); + OcspService ocspService = ocspServiceProvider.getService(subjectCertificate, issuerCertificate); + 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,25 +103,30 @@ 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); } - verifyOcspResponse(basicResponse, ocspService, certificateId); + LOG.debug("OCSP response received successfully"); + + verifyOcspResponse(basicResponse, ocspService, certificateId, issuerCertificate); 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); } } - private void verifyOcspResponse(BasicOCSPResp basicResponse, OcspService ocspService, CertificateID requestCertificateId) throws AuthTokenException, OCSPException, CertificateException, OperatorCreationException { + private void verifyOcspResponse(BasicOCSPResp basicResponse, OcspService ocspService, CertificateID requestCertificateId, X509Certificate issuerCertificate) throws UserCertificateOCSPCheckFailedException, UserCertificateRevokedException, OCSPException, CertificateException, OperatorCreationException { // The verification algorithm follows RFC 2560, https://www.ietf.org/rfc/rfc2560.txt. // // 3.2. Signed Response Acceptance Requirements @@ -118,11 +139,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 +154,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. @@ -146,7 +168,12 @@ private void verifyOcspResponse(BasicOCSPResp basicResponse, OcspService ocspSer // Use the clock instance so that the date can be mocked in tests. final Date now = DateAndTime.DefaultClock.getInstance().now(); - ocspService.validateResponderCertificate(responderCert, now); + try { + ocspService.validateResponderCertificate(responderCert, issuerCertificate, now); + } catch (AuthTokenException e) { + // An invalid responder certificate prevents establishing the user certificate's revocation status. + throw new UserCertificateOCSPCheckFailedException(e, ocspService.getAccessLocation()); + } // 5. The time at which the status being indicated is known to be // correct (thisUpdate) is sufficiently recent. @@ -155,23 +182,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 +210,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..7d0ffacc --- /dev/null +++ b/src/main/java/eu/webeid/ocsp/README.md @@ -0,0 +1,224 @@ +# 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 timeouts](#platform-ocsp-timeouts) +- [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. + +`PLATFORM_OCSP` checks only the subject certificate's revocation status. In `CUSTOM_CHECKER` mode, the library invokes the supplied checker once for the subject certificate. Neither mode automatically checks intermediate CA certificates for revocation, although both validate the certification path. A custom PKIX checker follows its own configured options. + +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 revocation status is not, so 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; see [Platform OCSP timeouts](#platform-ocsp-timeouts). The v3 API builder-level OCSP client, timeout, designated-service and response-age options are no longer available; use the custom OCSP checker below when those controls are needed. Its timing constants do not configure the platform checker. + +## 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 subject certificate's direct issuer from the validated certification path. With `AuthTokenValidatorBuilder`, every configured trusted CA is also a trust anchor, so a trusted direct issuer terminates the path. Applications calling `CertificateValidator` directly can supply separate anchors and intermediate certificates, giving a direct issuer below the trust anchor. The library validates token signature after 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 when the validated direct issuer certificate equals one of its configured `supportedIssuers`. Supply the same encoded issuer certificates that validation may select as direct issuers; a reissued CA certificate with the same name and key does not match. On a mismatch, the provider uses the certificate's AIA OCSP URL. The designated responder certificate is pinned locally and need not be issued by the CAs it serves. + +For AIA services, the response may be signed by the certificate's direct issuer CA itself, or by a delegated responder certificate signed directly by that CA with the OCSP-signing extended key usage. Nonce support is enabled unless the URL appears in the first argument of `AiaOcspServiceConfiguration`. When nonce support is enabled, this custom checker requires a matching response nonce and rejects its absence. Its nonce policy is independent of the platform builder setting and JVM nonce property. + +## 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 timeouts + +On current OpenJDK/Oracle JDK 17, set connection and read timeouts through JVM system properties at application startup, for example: + +```text +-Dcom.sun.security.ocsp.timeout=5s +-Dcom.sun.security.ocsp.readtimeout=5s +``` + +`com.sun.security.ocsp.timeout` controls connection establishment; `com.sun.security.ocsp.readtimeout` controls reads after connecting. A bare number or a value ending in `s` means seconds; `ms` means milliseconds. For example, `3`, `3s`, and `3000ms` all mean three seconds, while `3000` means 50 minutes. The default connection timeout is 15 seconds, and the read timeout defaults to the connection timeout. Zero means an infinite timeout. + +These settings apply JVM-wide to the JDK OCSP implementation, including custom PKIX checkers using that provider. They are read when the JDK OCSP classes initialize and are not an overall deadline for authentication. They do not configure the bundled custom OCSP client's timeouts. + +## 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). + +During revocation checking, invalid responder certificates (including an unexpected designated responder, missing OCSP signing usage, expiry, or untrusted certificates) produce `UserCertificateOCSPCheckFailedException`, with the detailed certificate error retained as its cause. Invalid certificates supplied during designated-service configuration can instead throw `OCSPCertificateException` at application startup, before any revocation check runs. + +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 59% rename from src/main/java/eu/webeid/security/exceptions/OCSPCertificateException.java rename to src/main/java/eu/webeid/ocsp/exceptions/OCSPCertificateException.java index 0e2b7cb2..c5d8aa07 100644 --- a/src/main/java/eu/webeid/security/exceptions/OCSPCertificateException.java +++ b/src/main/java/eu/webeid/ocsp/exceptions/OCSPCertificateException.java @@ -1,9 +1,11 @@ // SPDX-FileCopyrightText: Estonian Information System Authority // SPDX-License-Identifier: MIT -package eu.webeid.security.exceptions; +package eu.webeid.ocsp.exceptions; -public class OCSPCertificateException extends AuthTokenException { +import eu.webeid.security.exceptions.CertificateRevocationCheckFailedException; + +public class OCSPCertificateException extends CertificateRevocationCheckFailedException { public OCSPCertificateException(String message) { super(message); 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/security/validator/ocsp/OcspRequestBuilder.java b/src/main/java/eu/webeid/ocsp/protocol/OcspRequestBuilder.java similarity index 53% 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..126f4acb 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,9 @@ // 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 eu.webeid.security.certificate.OcspNonceExtension; import org.bouncycastle.asn1.x509.Extensions; import org.bouncycastle.cert.ocsp.CertificateID; import org.bouncycastle.cert.ocsp.OCSPException; @@ -13,7 +11,6 @@ import org.bouncycastle.cert.ocsp.OCSPReqBuilder; import java.io.IOException; -import java.security.SecureRandom; import java.util.Objects; /** @@ -22,8 +19,6 @@ */ public final class OcspRequestBuilder { - private static final SecureRandom RANDOM_GENERATOR = new SecureRandom(); - private boolean ocspNonceEnabled = true; private CertificateID certificateId; @@ -38,8 +33,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,25 +42,13 @@ public OCSPReq build() throws OCSPException { if (ocspNonceEnabled) { try { - addNonce(builder); + builder.setRequestExtensions(new Extensions(OcspNonceExtension.create())); } catch (IOException e) { - throw new OCSPException("Failed to generate OCSP nonce extension", e); + throw new OCSPException("Failed to create OCSP nonce extension", e); } } 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 59% 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..48ee9b7b 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java +++ b/src/main/java/eu/webeid/ocsp/service/AiaOcspService.java @@ -1,26 +1,27 @@ // 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; import java.net.URI; +import java.security.GeneralSecurityException; 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.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. @@ -52,14 +53,29 @@ public URI getAccessLocation() { } @Override - public void validateResponderCertificate(X509CertificateHolder cert, Date now) throws AuthTokenException { + public void validateResponderCertificate(X509CertificateHolder cert, X509Certificate issuerCertificate, Date now) throws AuthTokenException { try { final X509Certificate certificate = certificateConverter.getCertificate(cert); - CertificateValidator.certificateIsValidOnDate(certificate, now, "AIA OCSP responder"); - // Trusted certificates' validity has been already verified in validateCertificateExpiry(). - OcspResponseValidator.validateHasSigningExtension(certificate); - CertificateValidator.validateIsSignedByTrustedCA(certificate, trustedCACertificateAnchors, trustedCACertificateCertStore, now); - } catch (CertificateException e) { + CertificateValidator.requireCertificateIsValidOnDate(certificate, now, "AIA OCSP responder"); + if (!certificate.equals(issuerCertificate)) { + OcspResponseValidator.validateHasSigningExtension(certificate); + // A delegated OCSP signer must be issued directly by the CA whose certificate status was requested. + if (!certificate.getIssuerX500Principal().equals(issuerCertificate.getSubjectX500Principal())) { + throw new OCSPCertificateException("AIA OCSP responder is not issued by the subject certificate's issuer"); + } + certificate.verify(issuerCertificate.getPublicKey()); + } + CertificateValidator.validateCertificateTrustAndRevocation( + certificate, + trustedCACertificateAnchors, + trustedCACertificateCertStore, + now, + RevocationMode.DISABLED, + null, + null, + false + ); + } catch (GeneralSecurityException 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 80% 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..853a9cbe 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/service/DesignatedOcspService.java +++ b/src/main/java/eu/webeid/ocsp/service/DesignatedOcspService.java @@ -1,21 +1,20 @@ // 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; -import java.security.cert.CertificateEncodingException; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; 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. @@ -40,7 +39,7 @@ public URI getAccessLocation() { } @Override - public void validateResponderCertificate(X509CertificateHolder cert, Date now) throws AuthTokenException { + public void validateResponderCertificate(X509CertificateHolder cert, X509Certificate issuerCertificate, Date now) throws AuthTokenException { try { final X509Certificate responderCertificate = certificateConverter.getCertificate(cert); // Certificate pinning is implemented simply by comparing the certificates or their public keys, @@ -49,14 +48,14 @@ 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); } } - public boolean supportsIssuerOf(X509Certificate certificate) throws CertificateEncodingException { - return configuration.supportsIssuerOf(certificate); + public boolean supportsIssuer(X509Certificate issuerCertificate) { + return configuration.supportsIssuer(issuerCertificate); } } 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 51% 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..85397700 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/service/DesignatedOcspServiceConfiguration.java +++ b/src/main/java/eu/webeid/ocsp/service/DesignatedOcspServiceConfiguration.java @@ -1,26 +1,23 @@ // 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 org.bouncycastle.asn1.x500.X500Name; -import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder; -import eu.webeid.security.exceptions.OCSPCertificateException; +import eu.webeid.ocsp.protocol.OcspResponseValidator; +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; +import java.util.Set; public class DesignatedOcspServiceConfiguration { private final URI ocspServiceAccessLocation; private final X509Certificate responderCertificate; private final boolean doesSupportNonce; - private final Collection supportedIssuers; + private final Set supportedIssuers; /** * Configuration of a designated OCSP service. @@ -29,12 +26,12 @@ 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 - * @throws OCSPCertificateException when an error occurs while extracting issuer names from certificates + * @throws OCSPCertificateException when the responder certificate lacks OCSP signing usage */ public DesignatedOcspServiceConfiguration(URI ocspServiceAccessLocation, X509Certificate responderCertificate, Collection supportedCertificateIssuers, boolean doesSupportNonce) 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")); + this.supportedIssuers = Set.copyOf(Objects.requireNonNull(supportedCertificateIssuers, "supported issuers")); OcspResponseValidator.validateHasSigningExtension(responderCertificate); this.doesSupportNonce = doesSupportNonce; } @@ -51,25 +48,7 @@ public boolean doesSupportNonce() { return doesSupportNonce; } - public boolean supportsIssuerOf(X509Certificate certificate) throws CertificateEncodingException { - return supportedIssuers.contains(new JcaX509CertificateHolder(Objects.requireNonNull(certificate)).getIssuer()); - } - - private Collection getIssuerX500Names(Collection supportedIssuers) throws OCSPCertificateException { - try { - return supportedIssuers.stream() - .map(this::getSubject) - .collect(Collectors.toList()); - } catch (IllegalArgumentException e) { - throw new OCSPCertificateException("Supported issuer list contains an invalid certificate", e.getCause()); - } - } - - private X500Name getSubject(X509Certificate certificate) throws IllegalArgumentException { - try { - return new JcaX509CertificateHolder(certificate).getSubject(); - } catch (CertificateEncodingException e) { - throw new IllegalArgumentException(e); - } + public boolean supportsIssuer(X509Certificate issuerCertificate) { + return supportedIssuers.contains(Objects.requireNonNull(issuerCertificate, "issuerCertificate")); } } 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 73% 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..96d055ed 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/service/OcspService.java +++ b/src/main/java/eu/webeid/ocsp/service/OcspService.java @@ -1,12 +1,13 @@ // 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; import java.net.URI; +import java.security.cert.X509Certificate; import java.util.Date; public interface OcspService { @@ -15,6 +16,6 @@ public interface OcspService { URI getAccessLocation(); - void validateResponderCertificate(X509CertificateHolder cert, Date now) throws AuthTokenException; + void validateResponderCertificate(X509CertificateHolder cert, X509Certificate issuerCertificate, Date now) throws 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 64% 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..d7987144 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/OcspServiceProvider.java +++ b/src/main/java/eu/webeid/ocsp/service/OcspServiceProvider.java @@ -1,16 +1,10 @@ // 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; import java.util.Objects; @@ -28,15 +22,15 @@ public OcspServiceProvider(DesignatedOcspServiceConfiguration designatedOcspServ /** * A static factory method that returns either the designated or AIA OCSP service instance depending on whether - * the designated OCSP service is configured and supports the issuer of the certificate. + * the designated OCSP service is configured for the certificate's validated direct issuer. * * @param certificate subject certificate that is to be checked with OCSP + * @param issuerCertificate direct issuer from the validated certification path * @return either the designated or AIA OCSP service instance * @throws AuthTokenException when AIA URL is not found in certificate - * @throws CertificateEncodingException when certificate is invalid */ - public OcspService getService(X509Certificate certificate) throws AuthTokenException, CertificateEncodingException { - if (designatedOcspService != null && designatedOcspService.supportsIssuerOf(certificate)) { + public OcspService getService(X509Certificate certificate, X509Certificate issuerCertificate) throws AuthTokenException { + if (designatedOcspService != null && designatedOcspService.supportsIssuer(issuerCertificate)) { return designatedOcspService; } return new AiaOcspService(aiaOcspServiceConfiguration, certificate); 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..2533957a 100644 --- a/src/main/java/eu/webeid/security/certificate/CertificateValidator.java +++ b/src/main/java/eu/webeid/security/certificate/CertificateValidator.java @@ -3,31 +3,47 @@ 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.Certificate; 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 +53,167 @@ 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. + *

+ * 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}, using the subject's direct issuer from the validated path. + * 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) { + // CertPath certificates are ordered from the subject towards the trust anchor, which is not + // included in the path. The next certificate, when present, is the subject's direct issuer. + final List pathCertificates = result.getCertPath().getCertificates(); + if (pathCertificates.isEmpty()) { + throw new IllegalStateException( + "Cannot determine the subject certificate's issuer: " + + "the subject certificate is itself a configured trust anchor"); + } + final X509Certificate issuerCertificate = pathCertificates.size() > 1 + ? (X509Certificate) pathCertificates.get(1) : trustedCACert; + revocationInfoList = certificateRevocationChecker.validateCertificateNotRevoked(certificate, issuerCertificate); + } - return trustedCACert; + return revocationInfoList; } catch (InvalidAlgorithmParameterException | NoSuchAlgorithmException e) { throw new JceException(e); @@ -76,8 +227,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 +263,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..4dbfcb54 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,41 @@ 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( + "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 90430c54..f05109b2 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; @@ -18,7 +17,6 @@ import java.security.cert.X509Certificate; import java.util.Arrays; import java.util.Collection; -import java.util.Optional; public final class SubjectCertificatePolicyValidator { @@ -31,12 +29,12 @@ public SubjectCertificatePolicyValidator(Collection disall } /** - * Validates that the user certificate policies match the configured policies. + * Rejects any configured disallowed policy present in the certificate policies extension. * A certificate without the certificate policies extension does not contain disallowed policies and passes validation. * * @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 invalid. */ public void validateCertificatePolicies(X509Certificate subjectCertificate) throws AuthTokenException { final byte[] extensionValue = subjectCertificate.getExtensionValue(Extension.certificatePolicies.getId()); @@ -48,14 +46,15 @@ public void validateCertificatePolicies(X509Certificate subjectCertificate) thro 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/ocsp/OcspCertificateRevocationCheckerNetworkTest.java b/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerNetworkTest.java new file mode 100644 index 00000000..0916071f --- /dev/null +++ b/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerNetworkTest.java @@ -0,0 +1,268 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.ocsp; + +import eu.webeid.ocsp.client.OcspClientImpl; +import eu.webeid.ocsp.exceptions.OCSPCertificateException; +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.certificate.CertificateValidator; +import eu.webeid.security.exceptions.CertificateRevocationCheckFailedException; +import eu.webeid.security.exceptions.CertificateRevokedException; +import eu.webeid.security.testutil.LocalOcspResponder; +import eu.webeid.security.testutil.LocalOcspResponder.Reply; +import eu.webeid.security.validator.revocationcheck.RevocationInfo; +import eu.webeid.security.validator.revocationcheck.RevocationMode; +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.security.cert.X509Certificate; +import java.time.Duration; +import java.util.Date; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** Exercises the bundled OCSP checker against a local, signing responder. */ +class OcspCertificateRevocationCheckerNetworkTest { + + private LocalOcspResponder responder; + + @BeforeEach + void startResponder() throws Exception { + responder = new LocalOcspResponder(); + responder.start(); + } + + @AfterEach + void stopResponder() { + if (responder != null) { + responder.close(); + } + } + + @Test + void whenResponderReturnsGood_thenValidationSendsRequestAndSucceeds() throws Exception { + final List info = validate(); + + assertRequestReachedResponder(); + assertThat(responder.requestNonce()).hasSize(32); + assertThat(info).singleElement().extracting(RevocationInfo::ocspResponderUri).isEqualTo(responder.designatedUri()); + } + + @Test + void whenResponderReturnsRevoked_thenValidationRejectsCertificate() { + responder.setReply(Reply.REVOKED); + + assertThatThrownBy(this::validate).isInstanceOf(CertificateRevokedException.class); + assertRequestReachedResponder(); + } + + @Test + void whenResponderReturnsTryLater_thenValidationReportsFailure() { + responder.setReply(Reply.TRY_LATER); + + assertThatThrownBy(this::validate).isInstanceOf(CertificateRevocationCheckFailedException.class); + assertRequestReachedResponder(); + } + + @Test + void whenResponderDisconnects_thenValidationReportsFailureWithCause() { + responder.setReply(Reply.DISCONNECT); + + assertThatThrownBy(this::validate) + .isInstanceOf(CertificateRevocationCheckFailedException.class) + .hasRootCauseInstanceOf(IOException.class); + assertRequestReachedResponder(); + } + + @ParameterizedTest + @EnumSource(value = Reply.class, names = {"UNSUPPORTED_TYPE", "MISSING_RESPONSE"}) + void whenBasicResponseIsMissingOrUnsupported_thenCustomCheckerReportsFailure(Reply response) { + responder.setReply(response); + + assertThatThrownBy(this::validate) + .isInstanceOf(UserCertificateOCSPCheckFailedException.class) + .hasMessageContaining("Missing or unsupported Basic OCSP Response"); + assertRequestReachedResponder(); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void whenDesignatedResponderOmitsNonce_thenConfiguredPolicyIsEnforced(boolean nonceEnabled) throws Exception { + responder.setIncludeNonce(false); + + if (nonceEnabled) { + assertThatThrownBy(this::validate) + .isInstanceOf(UserCertificateOCSPCheckFailedException.class) + .hasMessageContaining("nonce extension missing"); + } else { + validate(false); + assertThat(responder.requestNonce()).isNull(); + } + assertRequestReachedResponder(); + } + + @Test + void whenDesignatedResponderCertificateDiffers_thenFailurePreservesCertificateCause() throws Exception { + final var checker = customChecker(new DesignatedOcspServiceConfiguration( + responder.designatedUri(), responder.responderCertificate(), List.of(responder.issuer()), true)); + responder.replaceResponderCertificate(true); + + assertThatThrownBy(() -> checker.validateCertificateNotRevoked(responder.subject(), responder.issuer())) + .isInstanceOf(CertificateRevocationCheckFailedException.class) + .hasMessageContaining(responder.designatedUri().toString()) + .cause() + .isExactlyInstanceOf(OCSPCertificateException.class) + .hasMessageContaining("not equal to the configured designated OCSP responder certificate"); + assertRequestReachedResponder(); + } + + @Test + void whenAiaResponderLacksSigningUsage_thenFailurePreservesCertificateCause() throws Exception { + responder.replaceResponderCertificate(false); + final var checker = customChecker(null); + + assertThatThrownBy(() -> checker.validateCertificateNotRevoked(responder.subject(), responder.issuer())) + .isInstanceOf(CertificateRevocationCheckFailedException.class) + .hasMessageContaining(responder.aiaUri().toString()) + .cause() + .isExactlyInstanceOf(OCSPCertificateException.class) + .hasMessageContaining("does not contain the key usage extension for OCSP response signing"); + assertThat(responder.requestCount()).isEqualTo(1); + assertThat(responder.receivedPath()).isEqualTo("/aia"); + } + + @Test + void whenAiaResponderIsDelegatedBySubjectIssuer_thenValidationSucceeds() throws Exception { + final var checker = customChecker(null); + + assertThat(checker.validateCertificateNotRevoked(responder.subject(), responder.issuer())) + .singleElement().extracting(RevocationInfo::ocspResponderUri).isEqualTo(responder.aiaUri()); + assertThat(responder.receivedPath()).isEqualTo("/aia"); + } + + @Test + void whenAiaResponseIsSignedBySubjectIssuer_thenValidationSucceeds() throws Exception { + responder.close(); + responder = new LocalOcspResponder(); + responder.startWithIntermediate(); + assertThat(responder.issuer().getExtendedKeyUsage()).isNull(); + responder.useIssuerAsResponder(); + final var checker = customChecker(null, List.of(responder.root()), List.of(responder.issuer())); + + final List info = CertificateValidator.validateCertificateTrustAndRevocation( + responder.subject(), + CertificateValidator.buildTrustAnchorsFromCertificates(List.of(responder.root())), + CertificateValidator.buildCertStoreFromCertificates(List.of(responder.issuer())), + Date.from(responder.now()), RevocationMode.CUSTOM_CHECKER, checker, null, true); + + assertThat(info) + .singleElement().extracting(RevocationInfo::ocspResponderUri).isEqualTo(responder.aiaUri()); + assertThat(responder.requestCount()).isEqualTo(1); + assertThat(responder.receivedPath()).isEqualTo("/aia"); + } + + @Test + void whenTrustAnchorIsAboveDirectIssuer_thenAiaOcspValidationUsesIntermediate() throws Exception { + responder.close(); + responder = new LocalOcspResponder(); + responder.startWithIntermediate(); + final var checker = customChecker(null, List.of(responder.root()), List.of(responder.issuer())); + + final List info = CertificateValidator.validateCertificateTrustAndRevocation( + responder.subject(), + CertificateValidator.buildTrustAnchorsFromCertificates(List.of(responder.root())), + CertificateValidator.buildCertStoreFromCertificates(List.of(responder.issuer())), + Date.from(responder.now()), RevocationMode.CUSTOM_CHECKER, checker, null, true); + + assertThat(info).singleElement().extracting(RevocationInfo::ocspResponderUri).isEqualTo(responder.aiaUri()); + assertThat(responder.requestCount()).isEqualTo(1); + assertThat(responder.receivedPath()).isEqualTo("/aia"); + } + + @Test + void whenAiaRequestUsesRootInsteadOfIntermediate_thenResponderRejectsCertificateId() throws Exception { + responder.close(); + responder = new LocalOcspResponder(); + responder.startWithIntermediate(); + final var checker = customChecker(null, List.of(responder.root()), List.of(responder.issuer())); + + assertThatThrownBy(() -> checker.validateCertificateNotRevoked(responder.subject(), responder.root())) + .isInstanceOf(UserCertificateOCSPCheckFailedException.class) + .hasMessageContaining("Response status: unauthorized"); + assertThat(responder.requestCount()).isEqualTo(1); + assertThat(responder.receivedPath()).isEqualTo("/aia"); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void whenAiaResponderIsDelegatedByAnotherTrustedCA_thenValidationFails(boolean sameIssuerName) throws Exception { + responder.replaceResponderCertificateFromDifferentIssuer(sameIssuerName); + final var checker = customChecker(null, List.of(responder.issuer(), responder.otherIssuer())); + + assertThatThrownBy(() -> CertificateValidator.validateCertificateTrustAndRevocation( + responder.subject(), + CertificateValidator.buildTrustAnchorsFromCertificates(List.of(responder.issuer())), + CertificateValidator.buildCertStoreFromCertificates(List.of(responder.issuer())), + Date.from(responder.now()), RevocationMode.CUSTOM_CHECKER, checker, null, true)) + .isInstanceOf(CertificateRevocationCheckFailedException.class) + .cause() + .isInstanceOf(OCSPCertificateException.class); + assertThat(responder.requestCount()).isEqualTo(1); + assertThat(responder.receivedPath()).isEqualTo("/aia"); + } + + private OcspCertificateRevocationChecker customChecker(DesignatedOcspServiceConfiguration designated) throws Exception { + return customChecker(designated, List.of(responder.issuer())); + } + + private OcspCertificateRevocationChecker customChecker(DesignatedOcspServiceConfiguration designated, + List authorities) throws Exception { + return customChecker(designated, authorities, authorities); + } + + private OcspCertificateRevocationChecker customChecker(DesignatedOcspServiceConfiguration designated, + List anchors, + List intermediates) throws Exception { + return new OcspCertificateRevocationChecker( + OcspClientImpl.build(Duration.ofSeconds(2)), + new OcspServiceProvider(designated, new AiaOcspServiceConfiguration(Set.of(), + CertificateValidator.buildTrustAnchorsFromCertificates(anchors), + CertificateValidator.buildCertStoreFromCertificates(intermediates))), + OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW, + OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE); + } + + private List validate() throws Exception { + return validate(true); + } + + private List validate(boolean nonceEnabled) throws Exception { + final var checker = customChecker(new DesignatedOcspServiceConfiguration( + responder.designatedUri(), responder.responderCertificate(), List.of(responder.issuer()), nonceEnabled)); + return CertificateValidator.validateCertificateTrustAndRevocation( + responder.subject(), + CertificateValidator.buildTrustAnchorsFromCertificates(List.of(responder.issuer())), + CertificateValidator.buildCertStoreFromCertificates(List.of(responder.issuer())), + Date.from(responder.now()), RevocationMode.CUSTOM_CHECKER, checker, null, nonceEnabled); + } + + private void assertRequestReachedResponder() { + assertThat(responder.requestCount()).isPositive(); + assertThat(responder.receivedRequest().getRequestList()).hasSize(1); + assertThat(responder.receivedRequest().getRequestList()[0].getCertID().getSerialNumber()) + .isEqualTo(responder.subject().getSerialNumber()); + assertThat(responder.receivedPath()).startsWith("/designated"); + } +} diff --git a/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidatorTest.java b/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java similarity index 63% rename from src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidatorTest.java rename to src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java index f2d46162..97a14245 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.ocsp.exceptions.OCSPCertificateException; 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,83 @@ 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( + void whenOcspResponseSignerIsNotIssuedBySubjectIssuer_thenThrows() throws Exception { + 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)) - .withMessage("Certificate EMAILADDRESS=pki@sk.ee, CN=TEST of SK OCSP RESPONDER 2020, OU=OCSP, O=AS Sertifitseerimiskeskus, C=EE is not trusted"); + assertThatThrownBy(() -> validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .isInstanceOf(UserCertificateOCSPCheckFailedException.class) + .hasMessageContaining("OCSP responder: http://aia.demo.sk.ee/esteid2018") + .cause() + .isExactlyInstanceOf(OCSPCertificateException.class) + .hasMessage("AIA OCSP responder is not issued by the subject certificate's issuer"); } } @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)) - .withMessage("AIA OCSP responder certificate has expired"); + assertThatThrownBy(() -> validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA)) + .isInstanceOf(UserCertificateOCSPCheckFailedException.class) + .hasMessageContaining("OCSP responder: http://aia.demo.sk.ee/esteid2018") + .cause() + .isExactlyInstanceOf(CertificateExpiredException.class) + .hasMessage("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 +354,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/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/ocsp/service/OcspServiceProviderTest.java b/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java new file mode 100644 index 00000000..597ccdc3 --- /dev/null +++ b/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.ocsp.service; + +import org.bouncycastle.cert.X509CertificateHolder; +import org.junit.jupiter.api.Test; +import eu.webeid.ocsp.exceptions.OCSPCertificateException; +import eu.webeid.security.certificate.CertificateValidator; +import eu.webeid.security.testutil.LocalOcspResponder; + +import java.net.URI; +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.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 { + + @Test + void whenDesignatedOcspServiceConfigurationProvided_thenCreatesDesignatedOcspService() throws Exception { + final OcspServiceProvider ocspServiceProvider = getDesignatedOcspServiceProvider(); + final OcspService service = ocspServiceProvider.getService(getJaakKristjanEsteid2018Cert(), getTestEsteid2018CA()); + assertThat(service.getAccessLocation()).isEqualTo(new URI("http://demo.sk.ee/ocsp")); + assertThat(service.doesSupportNonce()).isTrue(); + assertThatCode(() -> + service.validateResponderCertificate(new X509CertificateHolder(getTestSkOcspResponder2020().getEncoded()), getTestEsteid2018CA(), new Date(1630000000000L))) + .doesNotThrowAnyException(); + assertThatCode(() -> + service.validateResponderCertificate(new X509CertificateHolder(getTestEsteid2018CA().getEncoded()), getTestEsteid2018CA(), new Date(1630000000000L))) + .isInstanceOf(OCSPCertificateException.class) + .hasMessage("Responder certificate from the OCSP response is not equal to the configured designated OCSP responder certificate"); + } + + @Test + void whenAiaOcspServiceConfigurationProvided_thenCreatesAiaOcspService() throws Exception { + final OcspServiceProvider ocspServiceProvider = getAiaOcspServiceProvider(); + final OcspService service2018 = ocspServiceProvider.getService(getJaakKristjanEsteid2018Cert(), getTestEsteid2018CA()); + assertThat(service2018.getAccessLocation()).isEqualTo(new URI("http://aia.demo.sk.ee/esteid2018")); + assertThat(service2018.doesSupportNonce()).isTrue(); + + final OcspService service2015 = ocspServiceProvider.getService(getMariliisEsteid2015Cert(), getTestEsteid2015CA()); + assertThat(service2015.getAccessLocation()).isEqualTo(new URI("http://aia.demo.sk.ee/esteid2015")); + assertThat(service2015.doesSupportNonce()).isFalse(); + } + + @Test + void whenAiaResponderCertificateLacksOcspSigningUsage_thenThrows() throws Exception { + final OcspServiceProvider ocspServiceProvider = getAiaOcspServiceProvider(); + final OcspService service2018 = ocspServiceProvider.getService(getJaakKristjanEsteid2018Cert(), getTestEsteid2018CA()); + final X509CertificateHolder wrongResponderCert = new X509CertificateHolder(getMariliisEsteid2015Cert().getEncoded()); + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> + service2018.validateResponderCertificate(wrongResponderCert, getTestEsteid2018CA(), new Date(1630000000000L))) + .withMessageContaining("does not contain the key usage extension for OCSP response signing"); + } + + @Test + void whenDifferentIssuersHaveSameName_thenDesignatedServiceAppliesOnlyToConfiguredCertificate() throws Exception { + try (LocalOcspResponder first = new LocalOcspResponder(); + LocalOcspResponder second = new LocalOcspResponder()) { + first.start(); + second.start(); + final var authorities = List.of(first.issuer(), second.issuer()); + final var designated = new DesignatedOcspServiceConfiguration( + first.designatedUri(), first.responderCertificate(), List.of(first.issuer()), true); + final var aia = new AiaOcspServiceConfiguration(Set.of(), + CertificateValidator.buildTrustAnchorsFromCertificates(authorities), + CertificateValidator.buildCertStoreFromCertificates(authorities)); + final var provider = new OcspServiceProvider(designated, aia); + + assertThat(first.issuer().getSubjectX500Principal()).isEqualTo(second.issuer().getSubjectX500Principal()); + assertThat(first.issuer()).isNotEqualTo(second.issuer()); + assertThat(provider.getService(first.subject(), first.issuer())).isInstanceOf(DesignatedOcspService.class); + assertThat(provider.getService(second.subject(), second.issuer())).isInstanceOf(AiaOcspService.class); + } + } + +} + +// 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..0f87c8e0 --- /dev/null +++ b/src/test/java/eu/webeid/security/certificate/CertificateValidatorTest.java @@ -0,0 +1,563 @@ +/* + * 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.testutil.LocalOcspResponder; +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 whenSubjectIsTrustAnchor_thenThrowsWithoutInvokingCustomChecker() throws Exception { + final X509Certificate subject = Certificates.getTestEsteid2018CA(); + assertThat(subject.getIssuerX500Principal()).isNotEqualTo(subject.getSubjectX500Principal()); + + assertThatThrownBy(() -> CertificateValidator.validateCertificateTrustAndRevocation( + subject, + trustAnchors(subject), + certStore(subject), + NOW, + RevocationMode.CUSTOM_CHECKER, + (s, i) -> { + throw new AssertionError("Custom checker must not be invoked without a validated issuer"); + }, + null, + false + )) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Cannot determine the subject certificate's issuer: " + + "the subject certificate is itself a configured trust anchor"); + } + + @Test + void whenTrustAnchorIsAboveDirectIssuer_thenCustomCheckerReceivesDirectIssuer() throws Exception { + try (LocalOcspResponder responder = new LocalOcspResponder()) { + responder.startWithIntermediate(); + final RevocationInfo expected = new RevocationInfo(URI.create("http://ocsp.example"), null); + + final List revocationInfo = CertificateValidator.validateCertificateTrustAndRevocation( + responder.subject(), + trustAnchors(responder.root()), + certStore(responder.issuer()), + Date.from(responder.now()), + RevocationMode.CUSTOM_CHECKER, + (subject, issuer) -> { + assertThat(subject).isEqualTo(responder.subject()); + assertThat(issuer).isEqualTo(responder.issuer()); + return List.of(expected); + }, + null, + false + ); + 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/OcspNonceExtensionTest.java b/src/test/java/eu/webeid/security/certificate/OcspNonceExtensionTest.java new file mode 100644 index 00000000..d0504000 --- /dev/null +++ b/src/test/java/eu/webeid/security/certificate/OcspNonceExtensionTest.java @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.security.certificate; + +import org.bouncycastle.asn1.ASN1OctetString; +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 whenExtensionsAreCreated_thenNoncesAreFreshAnd32BytesLong() throws Exception { + final byte[] firstNonce = ASN1OctetString.getInstance(OcspNonceExtension.create().getParsedValue()).getOctets(); + final byte[] secondNonce = ASN1OctetString.getInstance(OcspNonceExtension.create().getParsedValue()).getOctets(); + + assertThat(firstNonce).hasSize(32); + assertThat(secondNonce).hasSize(32).isNotEqualTo(firstNonce); + } + + @Test + void whenBcExtensionIsCreated_thenItEncodesANonCritical32ByteNonce() throws Exception { + final Extension extension = Extension.getInstance(OcspNonceExtension.create().getEncoded()); + + assertThat(extension.getExtnId().getId()).isEqualTo("1.3.6.1.5.5.7.48.1.2"); + assertThat(extension.isCritical()).isFalse(); + assertThat(ASN1OctetString.getInstance(extension.getParsedValue()).getOctets()).hasSize(32); + } + + @Test + void whenNonceExtensionIsCreated_thenItIsEncodedAsNonCriticalOcspNonceExtension() throws Exception { + final OcspNonceExtension extension = new OcspNonceExtension(); + 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()).hasSize(34).startsWith((byte) 0x04, (byte) 0x20); + assertThat(encodedExtension.toByteArray()).containsExactly( + HexFormat.of().parseHex( + "302f06092b06010505073001020422" + HexFormat.of().formatHex(extension.getValue()) + ) + ); + } + + @Test + void whenValueIsReturned_thenItCannotBeUsedToModifyExtension() { + final OcspNonceExtension extension = new OcspNonceExtension(); + 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/certificate/PlatformOcspNetworkTest.java b/src/test/java/eu/webeid/security/certificate/PlatformOcspNetworkTest.java new file mode 100644 index 00000000..12456c5d --- /dev/null +++ b/src/test/java/eu/webeid/security/certificate/PlatformOcspNetworkTest.java @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.security.certificate; + +import eu.webeid.security.exceptions.CertificateRevocationCheckFailedException; +import eu.webeid.security.exceptions.CertificateRevokedException; +import eu.webeid.security.testutil.LocalOcspResponder; +import eu.webeid.security.testutil.LocalOcspResponder.Reply; +import eu.webeid.security.validator.revocationcheck.RevocationInfo; +import eu.webeid.security.validator.revocationcheck.RevocationMode; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.Date; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** Exercises the platform PKIX checker against a local, signing OCSP responder. */ +class PlatformOcspNetworkTest { + + private LocalOcspResponder responder; + + @BeforeEach + void startResponder() throws Exception { + responder = new LocalOcspResponder(); + responder.start(); + } + + @AfterEach + void stopResponder() { + if (responder != null) { + responder.close(); + } + } + + @Test + void whenResponderReturnsGood_thenValidationSendsRequestAndSucceeds() throws Exception { + final List info = validate(); + + assertRequestReachedResponder(); + assertThat(responder.requestNonce()).hasSize(32); + assertThat(info).isEmpty(); + } + + @Test + void whenPlatformMakesTwoChecks_thenRequestsContainDifferent32ByteNonces() throws Exception { + validate(); + final byte[] firstNonce = responder.requestNonce(); + validate(); + + assertThat(responder.requestCount()).isEqualTo(2); + assertThat(firstNonce).hasSize(32).isNotEqualTo(responder.requestNonce()); + assertThat(responder.requestNonce()).hasSize(32); + } + + @Test + void whenResponderReturnsRevoked_thenValidationRejectsCertificate() { + responder.setReply(Reply.REVOKED); + + assertThatThrownBy(this::validate).isInstanceOf(CertificateRevokedException.class); + assertRequestReachedResponder(); + } + + @Test + void whenResponderReturnsTryLater_thenValidationReportsFailure() { + responder.setReply(Reply.TRY_LATER); + + assertThatThrownBy(this::validate).isInstanceOf(CertificateRevocationCheckFailedException.class); + assertRequestReachedResponder(); + } + + @Test + void whenResponderDisconnects_thenValidationReportsFailureWithCause() { + responder.setReply(Reply.DISCONNECT); + + assertThatThrownBy(this::validate) + .isInstanceOf(CertificateRevocationCheckFailedException.class) + .hasRootCauseInstanceOf(IOException.class); + assertRequestReachedResponder(); + } + + private List validate() throws Exception { + return CertificateValidator.validateCertificateTrustAndRevocation( + responder.subject(), + CertificateValidator.buildTrustAnchorsFromCertificates(List.of(responder.issuer())), + CertificateValidator.buildCertStoreFromCertificates(List.of(responder.issuer())), + Date.from(responder.now()), RevocationMode.PLATFORM_OCSP, null, null, true); + } + + private void assertRequestReachedResponder() { + assertThat(responder.requestCount()).isPositive(); + assertThat(responder.receivedRequest().getRequestList()).hasSize(1); + assertThat(responder.receivedRequest().getRequestList()[0].getCertID().getSerialNumber()) + .isEqualTo(responder.subject().getSerialNumber()); + assertThat(responder.receivedPath()).startsWith("/aia"); + } +} 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/testutil/LocalOcspResponder.java b/src/test/java/eu/webeid/security/testutil/LocalOcspResponder.java new file mode 100644 index 00000000..499b3dc0 --- /dev/null +++ b/src/test/java/eu/webeid/security/testutil/LocalOcspResponder.java @@ -0,0 +1,296 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.security.testutil; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +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 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.Instant; +import java.util.Base64; +import java.util.Date; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +/** Local HTTP responder with generated certificates, signed OCSP replies, and request recording. */ +public final class LocalOcspResponder implements AutoCloseable { + + 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 root; + private X509Certificate issuer; + private X509Certificate otherIssuer; + private X509Certificate subject; + private volatile X509Certificate responder; + private KeyPair issuerKeys; + private KeyPair responderKeys; + private volatile Reply reply = Reply.GOOD; + private volatile boolean includeNonce = true; + + public enum Reply { GOOD, REVOKED, TRY_LATER, DISCONNECT, UNSUPPORTED_TYPE, MISSING_RESPONSE } + + public void start() throws Exception { + start(false); + } + + public void startWithIntermediate() throws Exception { + start(true); + } + + private void start(boolean withIntermediate) 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"); + + issuerKeys = newKeys(); + responderKeys = newKeys(); + final X500Name issuerName = new X500Name(withIntermediate + ? "CN=Local OCSP test intermediate CA" : "CN=Local OCSP test CA"); + final X500Name rootName = new X500Name("CN=Local OCSP test root CA"); + KeyPair rootKeys = null; + if (withIntermediate) { + rootKeys = newKeys(); + final var rootBuilder = certificateBuilder(rootName, rootName, rootKeys, 10); + rootBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(true)); + rootBuilder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign | KeyUsage.cRLSign)); + root = sign(rootBuilder, rootKeys); + } + final var issuerBuilder = certificateBuilder(withIntermediate ? rootName : 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, withIntermediate ? rootKeys : issuerKeys); + if (!withIntermediate) { + root = issuer; + } + + 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(); + } + + @Override + public void close() { + if (server != null) { + server.stop(0); + } + if (serverFailure.get() != null) { + throw new AssertionError("Local responder failure", serverFailure.get()); + } + } + + public Instant now() { + return now; + } + + public URI aiaUri() { + return aiaUri; + } + + public URI designatedUri() { + return designatedUri; + } + + public X509Certificate issuer() { + return issuer; + } + + public X509Certificate root() { + return root; + } + + public X509Certificate otherIssuer() { + return otherIssuer; + } + + public X509Certificate subject() { + return subject; + } + + public X509Certificate responderCertificate() { + return responder; + } + + public int requestCount() { + return requestCount.get(); + } + + public OCSPReq receivedRequest() { + return receivedRequest.get(); + } + + public String receivedPath() { + return receivedPath.get(); + } + + public byte[] requestNonce() { + final Extension nonce = receivedRequest.get().getExtension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce); + return nonce == null ? null : ASN1OctetString.getInstance(nonce.getExtnValue().getOctets()).getOctets(); + } + + public void setReply(Reply reply) { + this.reply = reply; + } + + public void setIncludeNonce(boolean includeNonce) { + this.includeNonce = includeNonce; + } + + public void useIssuerAsResponder() { + responder = issuer; + responderKeys = issuerKeys; + } + + public void replaceResponderCertificate(boolean includeSigningUsage) throws Exception { + final var builder = certificateBuilder(new JcaX509CertificateHolder(issuer).getSubject(), + new X500Name("CN=Replacement OCSP test responder"), responderKeys, 4); + if (includeSigningUsage) { + builder.addExtension(Extension.extendedKeyUsage, false, new ExtendedKeyUsage(KeyPurposeId.id_kp_OCSPSigning)); + } + responder = sign(builder, issuerKeys); + } + + public void replaceResponderCertificateFromDifferentIssuer(boolean sameIssuerName) throws Exception { + final KeyPair otherIssuerKeys = newKeys(); + final X500Name otherIssuerName = sameIssuerName + ? new JcaX509CertificateHolder(issuer).getSubject() + : new X500Name("CN=Other local OCSP test CA"); + final var otherIssuerBuilder = certificateBuilder(otherIssuerName, otherIssuerName, otherIssuerKeys, 5); + otherIssuerBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(true)); + otherIssuerBuilder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign | KeyUsage.cRLSign)); + otherIssuer = sign(otherIssuerBuilder, otherIssuerKeys); + + final var responderBuilder = certificateBuilder(otherIssuerName, + new X500Name("CN=Other local OCSP test responder"), responderKeys, 6); + responderBuilder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature)); + responderBuilder.addExtension(Extension.extendedKeyUsage, false, new ExtendedKeyUsage(KeyPurposeId.id_kp_OCSPSigning)); + responder = sign(responderBuilder, otherIssuerKeys); + } + + 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 CertificateID requestedId = request.getRequestList()[0].getCertID(); + if (!requestedId.matchesIssuer(new JcaX509CertificateHolder(issuer), + new JcaDigestCalculatorProviderBuilder().build())) { + return new OCSPRespBuilder().build(OCSPResp.UNAUTHORIZED, null); + } + 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(requestedId, status, + Date.from(now.minusSeconds(1)), Date.from(now.plusSeconds(60)), null); + final Extension nonce = request.getExtension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce); + if (includeNonce && nonce != null) { + 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 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/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 2bffe91b..c5043a2d 100644 --- a/src/test/java/eu/webeid/security/validator/AuthTokenCertificateTest.java +++ b/src/test/java/eu/webeid/security/validator/AuthTokenCertificateTest.java @@ -11,9 +11,9 @@ import eu.webeid.security.exceptions.CertificateExpiredException; import eu.webeid.security.exceptions.CertificateNotTrustedException; import eu.webeid.security.exceptions.CertificateNotYetValidException; +import eu.webeid.security.exceptions.CertificateRevokedException; import eu.webeid.security.exceptions.UserCertificateDisallowedPolicyException; import eu.webeid.security.exceptions.UserCertificateMissingPurposeException; -import eu.webeid.security.exceptions.UserCertificateRevokedException; import eu.webeid.security.exceptions.UserCertificateWrongPurposeException; import eu.webeid.security.testutil.AbstractTestWithValidator; import eu.webeid.security.testutil.AuthTokenValidators; @@ -61,6 +61,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 @@ -271,18 +278,7 @@ void whenCertificateIsRevoked_thenOcspCheckFails() throws Exception { final WebEidAuthToken token = replaceTokenField(AUTH_TOKEN, "X5C", REVOKED_CERT); assertThatThrownBy(() -> validatorWithOcspCheck .validate(token, VALID_CHALLENGE_NONCE)) - .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); + .isInstanceOf(CertificateRevokedException.class); } @Test 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..5aa9e0dc 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"); + .hasMessage("Do not combine withCertificateRevocationChecker(...) with withPKIXRevocationChecker(...)."); + } + + @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 index 8dd5dfdc..4dd7fe40 100644 --- a/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePolicyValidatorTest.java +++ b/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePolicyValidatorTest.java @@ -4,21 +4,34 @@ package eu.webeid.security.validator.certvalidators; import eu.webeid.security.exceptions.UserCertificateDisallowedPolicyException; +import eu.webeid.security.exceptions.UserCertificateParseException; import org.bouncycastle.asn1.ASN1ObjectIdentifier; +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.X509Certificate; import java.util.List; +import java.util.Set; import static eu.webeid.security.testutil.Certificates.getCertificateWithoutCertificatePolicies; import static eu.webeid.security.testutil.Certificates.getJaakKristjanEsteid2018Cert; 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.when; class SubjectCertificatePolicyValidatorTest { private static final ASN1ObjectIdentifier ESTEID2018_POLICY = new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.1.2.1"); private static final ASN1ObjectIdentifier UNRELATED_POLICY = new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.1.2.2"); + private final X509Certificate certificate = mock(X509Certificate.class); + private final SubjectCertificatePolicyValidator validator = new SubjectCertificatePolicyValidator(Set.of()); + @Test void whenCertificateContainsDisallowedPolicy_thenValidationFails() throws Exception { final SubjectCertificatePolicyValidator validator = new SubjectCertificatePolicyValidator(List.of(ESTEID2018_POLICY)); @@ -40,4 +53,32 @@ void whenCertificateDoesNotContainCertificatePoliciesExtension_thenValidationSuc .doesNotThrowAnyException(); } + @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); + } } diff --git a/src/test/java/eu/webeid/security/validator/ocsp/OcspServiceProviderTest.java b/src/test/java/eu/webeid/security/validator/ocsp/OcspServiceProviderTest.java deleted file mode 100644 index 46c75897..00000000 --- a/src/test/java/eu/webeid/security/validator/ocsp/OcspServiceProviderTest.java +++ /dev/null @@ -1,66 +0,0 @@ -// SPDX-FileCopyrightText: Estonian Information System Authority -// SPDX-License-Identifier: MIT - -package eu.webeid.security.validator.ocsp; - -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 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; - -class OcspServiceProviderTest { - - @Test - void whenDesignatedOcspServiceConfigurationProvided_thenCreatesDesignatedOcspService() throws Exception { - final OcspServiceProvider ocspServiceProvider = getDesignatedOcspServiceProvider(); - final OcspService service = ocspServiceProvider.getService(getJaakKristjanEsteid2018Cert()); - 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))) - .doesNotThrowAnyException(); - assertThatCode(() -> - service.validateResponderCertificate(new X509CertificateHolder(getTestEsteid2018CA().getEncoded()), new Date(1630000000000L))) - .isInstanceOf(OCSPCertificateException.class) - .hasMessage("Responder certificate from the OCSP response is not equal to the configured designated OCSP responder certificate"); - } - - @Test - void whenAiaOcspServiceConfigurationProvided_thenCreatesAiaOcspService() throws Exception { - final OcspServiceProvider ocspServiceProvider = getAiaOcspServiceProvider(); - final OcspService service2018 = ocspServiceProvider.getService(getJaakKristjanEsteid2018Cert()); - 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))) - .doesNotThrowAnyException(); - } - - @Test - void whenAiaOcspServiceConfigurationDoesNotHaveResponderCertTrustedCA_thenThrows() throws Exception { - final OcspServiceProvider ocspServiceProvider = getAiaOcspServiceProvider(); - final OcspService service2018 = ocspServiceProvider.getService(getJaakKristjanEsteid2018Cert()); - final X509CertificateHolder wrongResponderCert = new X509CertificateHolder(getMariliisEsteid2015Cert().getEncoded()); - assertThatExceptionOfType(OCSPCertificateException.class) - .isThrownBy(() -> - service2018.validateResponderCertificate(wrongResponderCert, new Date(1630000000000L))); - } - -}