when(DateAndTime.DefaultClock::getInstance)
+ .thenReturn(() -> Date.from(Dates.getAuthTokenValidationDateTime().toInstant()));
+ mockedSignatureBuilder.when(() -> SignatureBuilder.aSignature(any(Container.class)))
+ .thenAnswer(invocation -> new TestSignatureBuilder(invocation.getArgument(0)));
+
+ MockHttpSession session = new MockHttpSession();
+ session.setAttribute("challenge-nonce", new ChallengeNonce(ObjectMother.VALID_CHALLENGE_NONCE, DateAndTime.utcNow().plusMinutes(1)));
+
+ // Act and assert
+ mvcBuilder.build().perform(get("/auth/challenge"));
+
+ MvcResult result = HttpHelper.login(mvcBuilder, session, ObjectMother.mockAuthToken());
+ session = (MockHttpSession) result.getRequest().getSession();
+ MockHttpServletResponse response = result.getResponse();
+ assertEquals("{\"sub\":\"JAAK-KRISTJAN JÕEORG\",\"auth\":\"[ROLE_USER]\"}", response.getContentAsString());
+
+ /* Example how to test file upload.
+ response = HttpHelper.upload(mvcBuilder, session, mockMultipartFile());
+ assertEquals(HttpStatus.OK.value(), response.getStatus());
+ public static MockMultipartFile mockMultipartFile() {
+ return new MockMultipartFile("file", "test-file.txt", "text/plain", "some xml".getBytes());
}
- };
+ */
- MockHttpSession session = new MockHttpSession();
- session.setAttribute("challenge-nonce", new ChallengeNonce(ObjectMother.VALID_CHALLENGE_NONCE, DateAndTime.utcNow().plusMinutes(1)));
+ response = HttpHelper.prepare(mvcBuilder, session, ObjectMother.mockPrepareRequest());
+ assertEquals(HttpStatus.OK.value(), response.getStatus());
- Dates.setMockedSignatureDate(Dates.getSigningDateTime());
- Dates.setMockedAuthTokenValidationDate(Dates.getAuthTokenValidationDateTime());
+ DigestDTO digestDTO = ObjectMother.jsonStringToBean(response.getContentAsString(), DigestDTO.class);
- // Act and assert
- mvcBuilder.build().perform(get("/auth/challenge"));
+ response = HttpHelper.sign(mvcBuilder, session, ObjectMother.mockSignRequest(digestDTO.hash()));
+ assertEquals(HttpStatus.OK.value(), response.getStatus());
- MvcResult result = HttpHelper.login(mvcBuilder, session, ObjectMother.mockAuthToken());
- session = (MockHttpSession) result.getRequest().getSession();
- MockHttpServletResponse response = result.getResponse();
- assertEquals("{\"sub\":\"JAAK-KRISTJAN JÕEORG\",\"auth\":\"[ROLE_USER]\"}", response.getContentAsString());
-
- /* Example how to test file upload.
- response = HttpHelper.upload(mvcBuilder, session, mockMultipartFile());
- assertEquals(HttpStatus.OK.value(), response.getStatus());
- public static MockMultipartFile mockMultipartFile() {
- return new MockMultipartFile("file", "test-file.txt", "text/plain", "some xml".getBytes());
+ response = HttpHelper.download(mvcBuilder, session);
+ assertEquals(HttpStatus.OK.value(), response.getStatus());
+ assertEquals("attachment; filename=example-for-signing.asice", response.getHeader("Content-Disposition"));
+ final Container signedContainer = (Container) session.getAttribute("container-to-sign");
+ assertEquals(1, signedContainer.getSignatures().size());
+ assertEquals(SignatureProfile.T, signedContainer.getSignatures().get(0).getProfile());
}
- */
-
- response = HttpHelper.prepare(mvcBuilder, session, ObjectMother.mockPrepareRequest());
- assertEquals(HttpStatus.OK.value(), response.getStatus());
-
- DigestDTO digestDTO = ObjectMother.jsonStringToBean(response.getContentAsString(), DigestDTO.class);
+ }
- response = HttpHelper.sign(mvcBuilder, session, ObjectMother.mockSignRequest(digestDTO.getHash()));
- assertEquals(HttpStatus.OK.value(), response.getStatus());
+ private static final class TestSignatureBuilder extends AsicESignatureBuilder {
+ private TestSignatureBuilder(Container container) {
+ setContainer(container);
+ signatureParameters.setClaimedSigningDate(Date.from(Dates.getSigningDateTime().toInstant()));
+ }
- response = HttpHelper.download(mvcBuilder, session);
- assertEquals(HttpStatus.OK.value(), response.getStatus());
- assertEquals("attachment; filename=example-for-signing.asice", response.getHeader("Content-Disposition"));
+ @Override
+ public SignatureBuilder withSignatureProfile(SignatureProfile profile) {
+ // Verify that the application requests LT signatures.
+ assertEquals(SignatureProfile.LT, profile);
+ // LT requires OCSP evidence unavailable for this test certificate.
+ // Use T (timestamped signing) for this test so it can complete without that evidence.
+ return super.withSignatureProfile(SignatureProfile.T);
+ }
}
}
diff --git a/example/src/test/java/eu/webeid/example/config/TestValidatorConfiguration.java b/example/src/test/java/eu/webeid/example/config/TestValidatorConfiguration.java
new file mode 100644
index 00000000..f838a2da
--- /dev/null
+++ b/example/src/test/java/eu/webeid/example/config/TestValidatorConfiguration.java
@@ -0,0 +1,38 @@
+// SPDX-FileCopyrightText: Estonian Information System Authority
+// SPDX-License-Identifier: MIT
+
+package eu.webeid.example.config;
+
+import eu.webeid.security.exceptions.JceException;
+import eu.webeid.security.validator.AuthTokenValidator;
+import eu.webeid.security.validator.AuthTokenValidatorBuilder;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Primary;
+
+import java.net.URI;
+import java.util.List;
+
+/**
+ * Provides an {@link AuthTokenValidator} that does not call the real OCSP service.
+ *
+ * Certificate trust validation is performed as usual, only the revocation check is replaced with a
+ * no-op {@code CertificateRevocationChecker}.
+ */
+@TestConfiguration
+public class TestValidatorConfiguration {
+
+ @Bean
+ @Primary
+ AuthTokenValidator validatorWithoutOcspCall(ValidationConfiguration validationConfiguration,
+ YAMLConfig yamlConfig) throws JceException {
+ return new AuthTokenValidatorBuilder()
+ .withSiteOrigin(URI.create(yamlConfig.getLocalOrigin()))
+ .withTrustedCertificateAuthorities(validationConfiguration.loadTrustedCACertificatesFromCerFiles())
+ .withTrustedCertificateAuthorities(validationConfiguration.loadTrustedCACertificatesFromTrustStore(yamlConfig))
+ // Do not call the real OCSP service in tests.
+ .withCertificateRevocationChecker((subjectCertificate, issuerCertificate) -> List.of())
+ .build();
+ }
+
+}
diff --git a/example/src/test/java/eu/webeid/example/security/WebEidAjaxLoginProcessingFilterTest.java b/example/src/test/java/eu/webeid/example/security/WebEidAjaxLoginProcessingFilterTest.java
index 6f6a20fa..159c21d0 100644
--- a/example/src/test/java/eu/webeid/example/security/WebEidAjaxLoginProcessingFilterTest.java
+++ b/example/src/test/java/eu/webeid/example/security/WebEidAjaxLoginProcessingFilterTest.java
@@ -3,27 +3,38 @@
package eu.webeid.example.security;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import eu.webeid.example.security.dto.AuthTokenDTO;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.AuthenticationServiceException;
+import org.springframework.security.core.Authentication;
import java.io.BufferedReader;
import java.io.StringReader;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
class WebEidAjaxLoginProcessingFilterTest {
- private static final String AUTH_TOKEN = "{\"auth-token\":" +
- "{\"algorithm\":\"ES384\"," +
- "\"certificate\":\"MIIEAzCCA2WgAwIBAgIQHWbVWxCkcYxbzz9nBzGrDzAKBggqhkjOPQQDBDBgMQswCQYDVQQGEwJFRTEbMBkGA1UECgwSU0sgSUQgU29sdXRpb25zIEFTMRcwFQYDVQRhDA5OVFJFRS0xMDc0NzAxMzEbMBkGA1UEAwwSVEVTVCBvZiBFU1RFSUQyMDE4MB4XDTE4MTAyMzE1MzM1OVoXDTIzMTAyMjIxNTk1OVowfzELMAkGA1UEBhMCRUUxKjAoBgNVBAMMIUrDlUVPUkcsSkFBSy1LUklTVEpBTiwzODAwMTA4NTcxODEQMA4GA1UEBAwHSsOVRU9SRzEWMBQGA1UEKgwNSkFBSy1LUklTVEpBTjEaMBgGA1UEBRMRUE5PRUUtMzgwMDEwODU3MTgwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQ/u+9IncarVpgrACN6aRgUiT9lWC9H7llnxoEXe8xoCI982Md8YuJsVfRdeG5jwVfXe0N6KkHLFRARspst8qnACULkqFNat/Kj+XRwJ2UANeJ3Gl5XBr+tnLNuDf/UiR6jggHDMIIBvzAJBgNVHRMEAjAAMA4GA1UdDwEB/wQEAwIDiDBHBgNVHSAEQDA+MDIGCysGAQQBg5EhAQIBMCMwIQYIKwYBBQUHAgEWFWh0dHBzOi8vd3d3LnNrLmVlL0NQUzAIBgYEAI96AQIwHwYDVR0RBBgwFoEUMzgwMDEwODU3MThAZWVzdGkuZWUwHQYDVR0OBBYEFOTddHnA9rJtbLwhBNyn0xZTQGCMMGEGCCsGAQUFBwEDBFUwUzBRBgYEAI5GAQUwRzBFFj9odHRwczovL3NrLmVlL2VuL3JlcG9zaXRvcnkvY29uZGl0aW9ucy1mb3ItdXNlLW9mLWNlcnRpZmljYXRlcy8TAkVOMCAGA1UdJQEB/wQWMBQGCCsGAQUFBwMCBggrBgEFBQcDBDAfBgNVHSMEGDAWgBTAhJkpxE6fOwI09pnhClYACCk+ezBzBggrBgEFBQcBAQRnMGUwLAYIKwYBBQUHMAGGIGh0dHA6Ly9haWEuZGVtby5zay5lZS9lc3RlaWQyMDE4MDUGCCsGAQUFBzAChilodHRwOi8vYy5zay5lZS9UZXN0X29mX0VTVEVJRDIwMTguZGVyLmNydDAKBggqhkjOPQQDBAOBiwAwgYcCQgHYElkX4vn821JR41akI/lpexCnJFUf4GiOMbTfzAxpZma333R8LNrmI4zbzDp03hvMTzH49g1jcbGnaCcbboS8DAJBObenUp++L5VqldHwKAps61nM4V+TiLqD0jILnTzl+pV+LexNL3uGzUfvvDNLHnF9t6ygi8+Bsjsu3iHHyM1haKM=\"," +
- "\"issuerApp\":\"https://web-eid.eu/web-eid-app/releases/2.0.0+976\"," +
- "\"signature\":\"Z+r6IIx0lmXNTHrlJOTknVaOYszXba5ko1e8rjWJXd4nzIVsxeTps/Revg+tuKREkkIuIKhwvOnARdWV6vBmhjlpRUZn9aNPSDRP98T/0sPZgDp31hwsWfCAYnPzzYC9\"," +
- "\"version\":\"web-eid:1\"}}";
+ private static final String AUTH_TOKEN = """
+ {"auth-token": {
+ "format": "web-eid:1.0",
+ "algorithm": "ES384",
+ "unverifiedCertificate": "test-certificate",
+ "signature": "test-signature"
+ }}
+ """;
@Test
void testAttemptAuthentication() throws Exception {
@@ -38,5 +49,28 @@ void testAttemptAuthentication() throws Exception {
assertDoesNotThrow(() ->
new WebEidAjaxLoginProcessingFilter("/auth/login", authenticationManager)
.attemptAuthentication(request, response));
+
+ final ArgumentCaptor authentication = ArgumentCaptor.forClass(Authentication.class);
+ verify(authenticationManager).authenticate(authentication.capture());
+ assertThat(authentication.getValue().getCredentials()).isInstanceOfSatisfying(AuthTokenDTO.class, dto -> {
+ assertThat(dto.token().format()).isEqualTo("web-eid:1.0");
+ assertThat(dto.token().unverifiedCertificate()).isEqualTo("test-certificate");
+ assertThat(dto.token().signature()).isEqualTo("test-signature");
+ });
+ }
+
+ @Test
+ void whenJsonIsMalformed_thenPreservesCauseInAuthenticationFailure() throws Exception {
+ final HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getMethod()).thenReturn(HttpMethod.POST.name());
+ when(request.getHeader("Content-type")).thenReturn("application/json");
+ when(request.getReader()).thenReturn(new BufferedReader(new StringReader("{")));
+ final AuthenticationManager manager = mock(AuthenticationManager.class);
+
+ assertThatThrownBy(() -> new WebEidAjaxLoginProcessingFilter("/auth/login", manager)
+ .attemptAuthentication(request, mock(HttpServletResponse.class)))
+ .isInstanceOf(AuthenticationServiceException.class)
+ .hasCauseInstanceOf(JsonProcessingException.class);
+ verifyNoInteractions(manager);
}
-}
\ No newline at end of file
+}
diff --git a/example/src/test/java/eu/webeid/example/security/WebEidAuthenticationTest.java b/example/src/test/java/eu/webeid/example/security/WebEidAuthenticationTest.java
index 1e9d412b..899781d8 100644
--- a/example/src/test/java/eu/webeid/example/security/WebEidAuthenticationTest.java
+++ b/example/src/test/java/eu/webeid/example/security/WebEidAuthenticationTest.java
@@ -6,6 +6,7 @@
import eu.webeid.security.certificate.CertificateLoader;
import org.junit.jupiter.api.Test;
import org.springframework.security.core.Authentication;
+import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken;
import java.security.cert.X509Certificate;
import java.util.Collections;
@@ -23,4 +24,16 @@ void whenOrganizationCertificate_thenSucceeds() throws Exception {
assertThat(authentication.getPrincipal()).isEqualTo("Testijad.ee isikutuvastus");
}
-}
\ No newline at end of file
+ @Test
+ void whenComparedWithAnotherAuthenticationType_thenReturnsFalse() throws Exception {
+ final X509Certificate certificate = CertificateLoader.decodeCertificateFromBase64(ORGANIZATION_CERT);
+ final Authentication authentication = WebEidAuthentication.fromCertificate(certificate, Collections.emptyList());
+ final Authentication other = new PreAuthenticatedAuthenticationToken(
+ authentication.getPrincipal(), authentication.getCredentials(), Collections.emptyList());
+
+ assertThat(authentication.equals(other)).isFalse();
+ assertThat(authentication.equals(null)).isFalse();
+ assertThat(authentication).isEqualTo(WebEidAuthentication.fromCertificate(certificate, Collections.emptyList()));
+ }
+
+}
diff --git a/example/src/test/java/eu/webeid/example/service/dto/FileDTOTest.java b/example/src/test/java/eu/webeid/example/service/dto/FileDTOTest.java
new file mode 100644
index 00000000..a46ba229
--- /dev/null
+++ b/example/src/test/java/eu/webeid/example/service/dto/FileDTOTest.java
@@ -0,0 +1,44 @@
+// SPDX-FileCopyrightText: Estonian Information System Authority
+// SPDX-License-Identifier: MIT
+
+package eu.webeid.example.service.dto;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.jar.JarEntry;
+import java.util.jar.JarOutputStream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class FileDTOTest {
+
+ @Test
+ void whenExampleFileIsInsideJar_thenLoadsItsContents(@TempDir Path directory) throws Exception {
+ final Path jar = directory.resolve("resources.jar");
+ final byte[] contents = "Example from a JAR".getBytes(StandardCharsets.UTF_8);
+ try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(jar))) {
+ output.putNextEntry(new JarEntry("static/files/example-for-signing.txt"));
+ output.write(contents);
+ output.closeEntry();
+ }
+
+ final Thread thread = Thread.currentThread();
+ final ClassLoader originalLoader = thread.getContextClassLoader();
+ try (URLClassLoader resourceLoader = new URLClassLoader(new URL[] {jar.toUri().toURL()}, null)) {
+ thread.setContextClassLoader(resourceLoader);
+ final FileDTO file = FileDTO.getExampleForSigningFromResources();
+
+ assertThat(file.getName()).isEqualTo("example-for-signing.txt");
+ assertThat(file.getContentType()).isEqualTo("text/plain");
+ assertThat(file.getContentBytes()).containsExactly(contents);
+ } finally {
+ thread.setContextClassLoader(originalLoader);
+ }
+ }
+}
diff --git a/example/src/test/java/eu/webeid/example/testutil/Dates.java b/example/src/test/java/eu/webeid/example/testutil/Dates.java
index a9cb4603..286e4d1f 100644
--- a/example/src/test/java/eu/webeid/example/testutil/Dates.java
+++ b/example/src/test/java/eu/webeid/example/testutil/Dates.java
@@ -3,14 +3,8 @@
package eu.webeid.example.testutil;
-import eu.europa.esig.dss.model.BLevelParameters;
-import eu.webeid.security.util.DateAndTime;
-import mockit.Mock;
-import mockit.MockUp;
-
import java.time.ZoneId;
import java.time.ZonedDateTime;
-import java.util.Date;
public final class Dates {
@@ -25,21 +19,4 @@ public static ZonedDateTime getAuthTokenValidationDateTime() {
ZoneId.of("UTC"));
}
- public static void setMockedSignatureDate(ZonedDateTime mockedDateTime) {
- new MockUp() {
- @Mock
- public Date getSigningDate() {
- return Date.from(mockedDateTime.toInstant());
- }
- };
- }
-
- public static void setMockedAuthTokenValidationDate(ZonedDateTime mockedDateTime) {
- new MockUp() {
- @Mock
- public Date now() {
- return Date.from(mockedDateTime.toInstant());
- }
- };
- }
}
diff --git a/example/src/test/java/eu/webeid/example/testutil/ObjectMother.java b/example/src/test/java/eu/webeid/example/testutil/ObjectMother.java
index 3753c20e..aa7a77b6 100644
--- a/example/src/test/java/eu/webeid/example/testutil/ObjectMother.java
+++ b/example/src/test/java/eu/webeid/example/testutil/ObjectMother.java
@@ -47,9 +47,7 @@ public class ObjectMother {
public static final String VALID_CHALLENGE_NONCE = "12345678123456781234567812345678912356789123";
public static AuthTokenDTO mockAuthToken() {
- AuthTokenDTO authToken = new AuthTokenDTO();
- authToken.setToken(VALID_AUTH_TOKEN);
- return authToken;
+ return new AuthTokenDTO(VALID_AUTH_TOKEN);
}
public static String toJson(Object object) throws JsonProcessingException {
@@ -86,9 +84,7 @@ public static CertificateDTO mockPrepareRequest() {
}
public static SignatureDTO mockSignRequest(String digestToSign) {
- SignatureDTO signatureDTO = new SignatureDTO();
- signatureDTO.setBase64Signature(mockSignatureInBase64(digestToSign));
- return signatureDTO;
+ return new SignatureDTO(mockSignatureInBase64(digestToSign));
}
private static X509Certificate getSigningCert() {
diff --git a/pom.xml b/pom.xml
index ff33f906..6b00a637 100644
--- a/pom.xml
+++ b/pom.xml
@@ -7,21 +7,24 @@
4.0.0
authtoken-validation
eu.webeid.security
- 3.2.1
+ 4.0.0-SNAPSHOT
jar
authtoken-validation
Web eID authentication token validation library for Java
- 11
+ 17
0.13.0
1.86
2.22.1
2.0.17
+ 2.3.0
5.14.4
3.27.7
5.23.0
+ 4.3.0
3.5.5
+ 3.15.0
3.4.0
3.12.0
3.6.2
@@ -31,9 +34,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
@@ -67,6 +69,34 @@
bcpkix-jdk18on
${bouncycastle.version}
+
+ io.github.resilience4j
+ resilience4j-all
+ ${resilience4j.version}
+
+
+ io.github.resilience4j
+ resilience4j-bulkhead
+
+
+ io.github.resilience4j
+ resilience4j-cache
+
+
+ io.github.resilience4j
+ resilience4j-ratelimiter
+
+
+ io.github.resilience4j
+ resilience4j-timelimiter
+
+
+
+
+ io.github.resilience4j
+ resilience4j-vavr
+ ${resilience4j.version}
+
org.junit.jupiter
@@ -86,6 +116,12 @@
${mockito.version}
test
+
+ org.awaitility
+ awaitility
+ ${awaitility.version}
+ test
+
org.slf4j
slf4j-simple
@@ -96,6 +132,11 @@
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ ${maven-compiler-plugin.version}
+
org.apache.maven.plugins
diff --git a/src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java b/src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java
new file mode 100644
index 00000000..e83b539e
--- /dev/null
+++ b/src/main/java/eu/webeid/ocsp/OcspCertificateRevocationChecker.java
@@ -0,0 +1,234 @@
+// SPDX-FileCopyrightText: Estonian Information System Authority
+// SPDX-License-Identifier: MIT
+
+package eu.webeid.ocsp;
+
+import eu.webeid.ocsp.client.OcspClient;
+import eu.webeid.ocsp.exceptions.OCSPClientException;
+import eu.webeid.ocsp.protocol.DigestCalculatorImpl;
+import eu.webeid.ocsp.protocol.OcspRequestBuilder;
+import eu.webeid.ocsp.protocol.OcspResponseValidator;
+import eu.webeid.security.exceptions.AuthTokenException;
+import eu.webeid.ocsp.exceptions.UserCertificateOCSPCheckFailedException;
+import eu.webeid.ocsp.exceptions.UserCertificateOCSPException;
+import eu.webeid.security.util.DateAndTime;
+import eu.webeid.ocsp.service.OcspServiceProvider;
+import eu.webeid.ocsp.service.OcspService;
+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;
+import org.bouncycastle.cert.X509CertificateHolder;
+import org.bouncycastle.cert.ocsp.BasicOCSPResp;
+import org.bouncycastle.cert.ocsp.CertificateID;
+import org.bouncycastle.cert.ocsp.OCSPException;
+import org.bouncycastle.cert.ocsp.OCSPReq;
+import org.bouncycastle.cert.ocsp.OCSPResp;
+import org.bouncycastle.cert.ocsp.SingleResp;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.bouncycastle.operator.DigestCalculator;
+import org.bouncycastle.operator.OperatorCreationException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+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.List;
+import java.util.Map;
+
+import static eu.webeid.security.util.DateAndTime.requirePositiveDuration;
+import static java.util.Objects.requireNonNull;
+
+public class OcspCertificateRevocationChecker implements CertificateRevocationChecker {
+
+ public static final Duration DEFAULT_TIME_SKEW = Duration.ofMinutes(15);
+ public static final Duration DEFAULT_THIS_UPDATE_AGE = Duration.ofMinutes(2);
+ public static final Duration DEFAULT_NEXT_UPDATE_AGE = Duration.ofMinutes(15);
+
+ private static final Logger LOG = LoggerFactory.getLogger(OcspCertificateRevocationChecker.class);
+
+ private final OcspClient ocspClient;
+ private final OcspServiceProvider ocspServiceProvider;
+ private final Duration allowedOcspResponseTimeSkew;
+
+ static {
+ if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
+ Security.addProvider(new BouncyCastleProvider());
+ }
+ }
+
+ public OcspCertificateRevocationChecker(OcspClient ocspClient,
+ OcspServiceProvider ocspServiceProvider,
+ Duration allowedOcspResponseTimeSkew) {
+ this.ocspClient = requireNonNull(ocspClient, "ocspClient");
+ this.ocspServiceProvider = requireNonNull(ocspServiceProvider, "ocspServiceProvider");
+ this.allowedOcspResponseTimeSkew = requirePositiveDuration(allowedOcspResponseTimeSkew, "allowedOcspResponseTimeSkew");
+ }
+
+ /**
+ * Validates with OCSP that the user certificate from the authentication token is not revoked.
+ *
+ * @param subjectCertificate user certificate to be validated
+ * @throws AuthTokenException when user certificate is revoked or revocation check fails.
+ */
+ @Override
+ public List validateCertificateNotRevoked(X509Certificate subjectCertificate, X509Certificate issuerCertificate) throws AuthTokenException {
+ requireNonNull(subjectCertificate, "subjectCertificate");
+ requireNonNull(issuerCertificate, "issuerCertificate");
+
+ final OcspService ocspService = ocspServiceProvider.getService(subjectCertificate);
+ final CertificateID certificateId = getCertificateId(subjectCertificate, issuerCertificate);
+ final URI ocspResponderUri = ocspService.getAccessLocation();
+ final OCSPReq request = getOcspRequest(certificateId, ocspService);
+
+ if (!ocspService.doesSupportNonce()) {
+ LOG.debug("Disabling OCSP nonce extension");
+ }
+
+ try {
+ LOG.debug("Sending OCSP request");
+ final OCSPResp response = requireNonNull(ocspClient.request(ocspResponderUri, request), "OCSPResp");
+ if (response.getStatus() != OCSPResponseStatus.SUCCESSFUL) {
+ throw new UserCertificateOCSPCheckFailedException("Response status: " + ocspStatusToString(response.getStatus()), ocspResponderUri);
+ }
+
+ if (!(response.getResponseObject() instanceof BasicOCSPResp basicResponse)) {
+ throw new UserCertificateOCSPCheckFailedException("Missing or unsupported Basic OCSP Response", ocspResponderUri);
+ }
+ LOG.debug("OCSP response received successfully");
+
+ verifyOcspResponse(basicResponse, ocspService, certificateId);
+ if (ocspService.doesSupportNonce()) {
+ checkNonce(request, basicResponse, ocspResponderUri);
+ }
+ LOG.debug("OCSP response verified successfully");
+
+ return List.of(new RevocationInfo(ocspResponderUri, Map.of(RevocationInfo.KEY_OCSP_RESPONSE, response)));
+
+ } catch (OCSPException | CertificateException | OperatorCreationException | OCSPClientException e) {
+ throw new UserCertificateOCSPCheckFailedException(e, ocspResponderUri);
+ }
+ }
+
+ protected static OCSPReq getOcspRequest(CertificateID certificateId, OcspService ocspService) throws UserCertificateOCSPException {
+ final OCSPReq request;
+ try {
+ request = new OcspRequestBuilder()
+ .withCertificateId(certificateId)
+ .enableOcspNonce(ocspService.doesSupportNonce())
+ .build();
+ } catch (OCSPException e) {
+ throw new UserCertificateOCSPException("Unable to create OCSP request", e);
+ }
+ return request;
+ }
+
+ protected void verifyOcspResponse(BasicOCSPResp basicResponse, OcspService ocspService, CertificateID requestCertificateId) throws AuthTokenException, OCSPException, CertificateException, OperatorCreationException {
+ // The verification algorithm follows RFC 2560, https://www.ietf.org/rfc/rfc2560.txt.
+ //
+ // 3.2. Signed Response Acceptance Requirements
+ // Prior to accepting a signed response for a particular certificate as
+ // valid, OCSP clients SHALL confirm that:
+ //
+ // 1. The certificate identified in a received response corresponds to
+ // the certificate that was identified in the corresponding request.
+
+ // 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", ocspService.getAccessLocation());
+ }
+ final SingleResp certStatusResponse = basicResponse.getResponses()[0];
+ if (!requestCertificateId.equals(certStatusResponse.getCertID())) {
+ throw new UserCertificateOCSPCheckFailedException("OCSP responded with certificate ID that differs from the requested ID",
+ ocspService.getAccessLocation());
+ }
+
+ // 2. The signature on the response is valid.
+
+ // We assume that the responder includes its certificate in the certs field of the response
+ // that helps us to verify it. According to RFC 2560 this field is optional, but including it
+ // is standard practice.
+ if (basicResponse.getCerts().length < 1) {
+ throw new UserCertificateOCSPCheckFailedException("OCSP response must contain the responder certificate, "
+ + "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, ocspService.getAccessLocation());
+
+ // 3. The identity of the signer matches the intended recipient of the
+ // request.
+ //
+ // 4. The signer is currently authorized to provide a response for the
+ // certificate in question.
+
+ // Use the clock instance so that the date can be mocked in tests.
+ final Date now = DateAndTime.DefaultClock.getInstance().now();
+ ocspService.validateResponderCertificate(responderCert, now);
+
+ // 5. The time at which the status being indicated is known to be
+ // correct (thisUpdate) is sufficiently recent.
+ //
+ // 6. When available, the time at or before which newer information will
+ // be available about the status of the certificate (nextUpdate) is
+ // greater than the current time.
+
+ OcspResponseValidator.validateCertificateStatusUpdateTime(certStatusResponse, allowedOcspResponseTimeSkew, ocspService.getMaxThisUpdateAge(), ocspService.getMaxNextUpdateAge(), ocspService.getAccessLocation());
+
+ // Now we can accept the signed response as valid and validate the certificate status.
+ OcspResponseValidator.validateSubjectCertificateStatus(certStatusResponse, ocspService.getAccessLocation());
+ LOG.debug("OCSP check result is GOOD");
+ }
+
+ protected static void checkNonce(OCSPReq request, BasicOCSPResp response, URI ocspResponderUri) throws UserCertificateOCSPCheckFailedException {
+ final Extension requestNonce = request.getExtension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce);
+ final Extension responseNonce = response.getExtension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce);
+ if (requestNonce == null || responseNonce == null) {
+ throw new UserCertificateOCSPCheckFailedException("OCSP request or response nonce extension missing, " +
+ "possible replay attack", ocspResponderUri);
+ }
+ if (!requestNonce.equals(responseNonce)) {
+ throw new UserCertificateOCSPCheckFailedException("OCSP request and response nonces differ, " +
+ "possible replay attack", ocspResponderUri);
+ }
+ }
+
+ protected static CertificateID getCertificateId(X509Certificate subjectCertificate, X509Certificate issuerCertificate) throws UserCertificateOCSPException {
+ try {
+ final BigInteger serial = subjectCertificate.getSerialNumber();
+ final DigestCalculator digestCalculator = DigestCalculatorImpl.sha1();
+ return new CertificateID(digestCalculator,
+ new X509CertificateHolder(issuerCertificate.getEncoded()), serial);
+ } catch (CertificateEncodingException | IOException | OCSPException e) {
+ throw new UserCertificateOCSPException("Unable to compute certificateId for subject certificate", e);
+ }
+ }
+
+ protected static String ocspStatusToString(int status) {
+ 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";
+ };
+ }
+
+ protected OcspClient getOcspClient() {
+ return ocspClient;
+ }
+
+ protected OcspServiceProvider getOcspServiceProvider() {
+ return ocspServiceProvider;
+ }
+}
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..1ea24a45
--- /dev/null
+++ b/src/main/java/eu/webeid/ocsp/README.md
@@ -0,0 +1,214 @@
+# Advanced OCSP configuration
+
+[Back to the main README](../../../../../../README.md#authentication-token-validation)
+
+The default validator uses platform OCSP and needs no additional OCSP configuration. This guide is for applications that need control over responder selection, HTTP transport, nonce handling, response freshness, or a different revocation-checking implementation.
+
+Use `withCertificateRevocationChecker(...)` to supply your own implementation or configure the bundled `eu.webeid.ocsp.OcspCertificateRevocationChecker`. Use `withPKIXRevocationChecker(...)` when you need to configure a JDK PKIX checker directly.
+
+## Contents
+
+- [Choosing a revocation checker](#choosing-a-revocation-checker)
+- [Implementing CertificateRevocationChecker](#implementing-certificaterevocationchecker)
+- [Custom OCSP checker](#custom-ocsp-checker)
+- [Custom PKIX revocation checker](#custom-pkix-revocation-checker)
+- [Platform OCSP nonce configuration](#platform-ocsp-nonce-configuration)
+- [Certificates' Authority Information Access (AIA) extension](#certificates-authority-information-access-aia-extension)
+- [Revocation information](#revocation-information)
+- [Errors and diagnostics](#errors-and-diagnostics)
+
+## Choosing a revocation checker
+
+The default mode is `PLATFORM_OCSP`: the library uses the platform PKIX revocation checker to check the subject certificate with OCSP, with no fallback to CRLs and no soft-fail option. Trust is validated before revocation checking.
+
+The following additional configuration options are available in `AuthTokenValidatorBuilder`:
+
+- `withPlatformOcspNonceEnabled(boolean enabled)` – controls the library's default nonce generation for `PLATFORM_OCSP`. Enabled by default; an explicit JVM nonce property takes precedence. See [Platform OCSP nonce configuration](#platform-ocsp-nonce-configuration).
+- `withPKIXRevocationChecker(PKIXRevocationChecker checker)` – selects `CUSTOM_PKIX` and uses the supplied checker with its configured options.
+- `withCertificateRevocationChecker(CertificateRevocationChecker checker)` – selects `CUSTOM_CHECKER` and delegates revocation checking to the supplied implementation after validating certificate trust.
+- `withoutUserCertificateRevocationCheck()` – selects `DISABLED`. Certificate trust, validity, purpose, policies and token signatures are still checked, but revoked certificates may be accepted. Use only in exceptional circumstances.
+
+The two custom checker options and `withoutUserCertificateRevocationCheck()` are mutually exclusive. Combining them causes `build()` to throw `IllegalArgumentException`. Custom checker implementations must support concurrent validation calls.
+
+Platform OCSP networking and response freshness are controlled by the JDK provider. The old builder-level OCSP client, timeout, designated-service and response-age options are no longer available; use the custom OCSP checker below when those controls are needed. Its timing constants do not configure the platform checker.
+
+## Implementing CertificateRevocationChecker
+
+Implement [CertificateRevocationChecker](../security/validator/revocationcheck/CertificateRevocationChecker.java) and supply it through `AuthTokenValidatorBuilder.withCertificateRevocationChecker(...)`. This extension point can use your own revocation service, transport, or validation policy.
+
+The library calls `validateCertificateNotRevoked(subjectCertificate, issuerCertificate)` after validating the subject certificate's trust, validity, purpose and policies. The issuer argument is the trusted CA certificate that issued the subject certificate. Token signature validation remains the library's responsibility and follows the revocation check.
+
+Your checker must:
+
+- Validate revocation status according to your application's policy, including the authenticity and freshness of any status information it uses.
+- Throw `CertificateRevokedException` when the certificate is revoked, or `CertificateRevocationCheckFailedException` when the status cannot be established. Both extend `AuthTokenException`; retain the underlying cause when available.
+- Return a non-null `List` on success. Use an empty list when there is no additional information to return.
+- Support concurrent calls when the validator is shared across threads.
+
+The platform nonce setting does not configure your implementation. The bundled checker below is available when you want OCSP-specific controls without implementing the protocol yourself.
+
+## Custom OCSP checker
+
+The examples use `trustedIntermediateCACertificates()` from the [main quickstart](../../../../../../README.md#4-add-trusted-certificate-authority-certificates). Run configuration code during application startup and handle its checked exceptions there.
+
+The Bouncy Castle-based implementation is available under `eu.webeid.ocsp`. Construct it explicitly and pass it to `withCertificateRevocationChecker(...)`:
+
+```java
+import java.net.URI;
+import java.security.cert.X509Certificate;
+import java.time.Duration;
+import java.util.List;
+import java.util.Set;
+import eu.webeid.ocsp.OcspCertificateRevocationChecker;
+import eu.webeid.ocsp.client.OcspClientImpl;
+import eu.webeid.ocsp.service.AiaOcspServiceConfiguration;
+import eu.webeid.ocsp.service.OcspServiceProvider;
+import eu.webeid.security.certificate.CertificateValidator;
+import eu.webeid.security.validator.AuthTokenValidator;
+import eu.webeid.security.validator.AuthTokenValidatorBuilder;
+
+...
+List trustedCAs = List.of(trustedIntermediateCACertificates());
+AiaOcspServiceConfiguration aiaConfiguration = new AiaOcspServiceConfiguration(
+ Set.of(), // AIA responder URLs for which request and response nonce checks are disabled.
+ CertificateValidator.buildTrustAnchorsFromCertificates(trustedCAs),
+ CertificateValidator.buildCertStoreFromCertificates(trustedCAs),
+ OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE,
+ OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE
+);
+OcspServiceProvider services = new OcspServiceProvider(null, aiaConfiguration);
+OcspCertificateRevocationChecker checker = new OcspCertificateRevocationChecker(
+ OcspClientImpl.build(Duration.ofSeconds(5)),
+ services,
+ OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW
+);
+
+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 constant for clock/update skew is 15 minutes; pass a different positive duration to its constructor to change it.
+
+The maximum ages of the OCSP response's `thisUpdate` and `nextUpdate` times are configured per OCSP service, via the constructor of `AiaOcspServiceConfiguration`, `DesignatedOcspServiceConfiguration` or `FallbackOcspServiceConfiguration`:
+
+- `maxThisUpdateAge` – the maximum age of the OCSP response's `thisUpdate` time before the response is too old to rely on. `OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE` is 2 minutes.
+- `maxNextUpdateAge` – the maximum age of the OCSP response's `nextUpdate` time before the response is too old to rely on. `OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE` is 15 minutes, which is equal to the default allowed time skew.
+
+These checks are implemented by [OcspResponseValidator](protocol/OcspResponseValidator.java).
+
+For a designated responder, replace the `services` definition above with the following configuration. `responderCertificate` must be the service's trusted signing certificate and `supportedIssuers` the collection of issuer certificates served by it:
+
+```java
+import eu.webeid.ocsp.service.DesignatedOcspServiceConfiguration;
+
+...
+DesignatedOcspServiceConfiguration designated = new DesignatedOcspServiceConfiguration(
+ URI.create("https://ocsp.example.org"),
+ responderCertificate,
+ supportedIssuers,
+ true, // This service supports nonces.
+ OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE,
+ OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE
+);
+OcspServiceProvider services = new OcspServiceProvider(designated, aiaConfiguration);
+```
+
+The provider selects the designated service only for supported issuers; otherwise it uses the certificate's AIA OCSP URL. For AIA services, nonce support is enabled unless the URL appears in the first argument of `AiaOcspServiceConfiguration`. When nonce support is enabled, this custom checker requires a matching response nonce and rejects its absence. Its nonce policy is independent of the platform builder setting and JVM nonce property.
+
+## Custom PKIX revocation checker
+
+Configure the checker explicitly, including its fallback policy. For example, to use a fixed responder while retaining OCSP-only checking of the subject certificate:
+
+```java
+import java.net.URI;
+import java.security.cert.CertPathValidator;
+import java.security.cert.PKIXRevocationChecker;
+import java.util.EnumSet;
+import eu.webeid.security.validator.AuthTokenValidator;
+import eu.webeid.security.validator.AuthTokenValidatorBuilder;
+
+...
+PKIXRevocationChecker checker = (PKIXRevocationChecker)
+ CertPathValidator.getInstance("PKIX").getRevocationChecker();
+checker.setOptions(EnumSet.of(
+ PKIXRevocationChecker.Option.ONLY_END_ENTITY,
+ PKIXRevocationChecker.Option.NO_FALLBACK
+));
+checker.setOcspResponder(URI.create("https://ocsp.example.org"));
+
+AuthTokenValidator validator = new AuthTokenValidatorBuilder()
+ .withSiteOrigin(URI.create("https://example.org"))
+ .withTrustedCertificateAuthorities(trustedIntermediateCACertificates())
+ .withPKIXRevocationChecker(checker)
+ .build();
+```
+
+The library does not add options or nonce extensions to a custom PKIX checker. `withPlatformOcspNonceEnabled(...)` does not apply to it. A responder set with `setOcspResponder()` overrides AIA discovery for certificates checked by that checker; it does not perform issuer-based responder selection. Use the [custom OCSP service provider](#custom-ocsp-checker) for that policy.
+
+## Platform OCSP nonce configuration
+
+By default, the library includes a fresh 32-byte nonce in each platform OCSP request. To disable this default for a validator, use:
+
+```java
+AuthTokenValidator validator = new AuthTokenValidatorBuilder()
+ .withSiteOrigin(URI.create("https://example.org"))
+ .withTrustedCertificateAuthorities(trustedIntermediateCACertificates())
+ .withPlatformOcspNonceEnabled(false)
+ .build();
+```
+
+An explicitly configured `jdk.security.certpath.ocspNonce` JVM system property takes precedence over the builder setting:
+
+| Builder setting | JVM property unset | JVM property `true` | JVM property `false` |
+| --- | --- | --- | --- |
+| Enabled (default) | Library supplies a fresh 32-byte nonce | JDK supplies the nonce | No nonce |
+| Disabled | No nonce | JDK supplies the nonce | No nonce |
+
+For example, `-Djdk.security.certpath.ocspNonce=false` disables nonces for this library's platform OCSP requests regardless of the builder setting. The JVM property also affects other users of the JDK OCSP implementation in the same process. The library reads this property without modifying it.
+
+The builder setting applies only to `PLATFORM_OCSP`; it does not configure custom revocation checkers. Enabling a nonce controls the request, not strict response nonce enforcement: the JDK may accept a response without a nonce. This OCSP setting is separate from the authentication challenge nonce.
+
+## Certificates' *Authority Information Access* (AIA) extension
+
+The default platform checker obtains the OCSP responder URL from the subject certificate’s AIA extension unless a JDK responder override is configured. A custom PKIX checker can override the URL with `setOcspResponder()`. The bundled custom OCSP checker requires an AIA OCSP URL unless its designated service supports the certificate’s issuer.
+
+Note that there may be limitations to using AIA URLs as the services behind these URLs provide different security and SLA guarantees than dedicated OCSP responder services. In case you need a SLA guarantee, use a designated OCSP responder service.
+
+## Revocation information
+
+The `validate()` method returns a `ValidationInfo` object on success and throws an exception on failure, as described in [Errors and diagnostics](#errors-and-diagnostics). Its `subjectCertificate()` accessor returns the validated certificate. Its `revocationInfoList()` accessor returns information supplied by the selected revocation checker:
+
+- `PLATFORM_OCSP` and `DISABLED` return an empty list. An empty list does not mean that validation failed or, in platform mode, that revocation checking was skipped.
+- `CUSTOM_PKIX` returns the configured responder URI when `getOcspResponder()` is non-null; response attributes are not populated.
+- `CUSTOM_CHECKER` returns the custom checker's result. The bundled `OcspCertificateRevocationChecker` includes the responder URI and the Bouncy Castle `OCSPResp` under `RevocationInfo.KEY_OCSP_RESPONSE`.
+
+## Errors and diagnostics
+
+The `validate()` method returns `ValidationInfo` on success. Certificate and token validation failures are reported through `AuthTokenException` subclasses. In particular:
+
+| Exception | Meaning |
+| --- | --- |
+| `CertificateNotTrustedException` | Certificate trust/path validation failed. |
+| `CertificateRevokedException` | The checker reported that the certificate is revoked. |
+| `CertificateRevocationCheckFailedException` | The revocation status could not be established, for example because of an OCSP service or network failure. |
+
+These classes are in `eu.webeid.security.exceptions`. The bundled custom checker's `UserCertificateRevokedException` and `UserCertificateOCSPCheckFailedException` extend the corresponding common classes. Other validation errors, including expired certificates and invalid token signatures, are documented in the [exception classes](../security/exceptions).
+
+Log the exception itself to retain the cause chain, including the JDK's revocation failure details:
+
+```java
+try {
+ ValidationInfo validationInfo = tokenValidator.validate(token, challengeNonce);
+ // Continue authentication using validationInfo.subjectCertificate().
+} catch (AuthTokenException e) {
+ LOG.warn("Web eID authentication failed", e);
+ throw e;
+}
+```
+
+Preserve the cause when wrapping the exception, for example with `new AuthenticationServiceException("Web eID token validation failed", e)`. Return a generic authentication failure to the client; keep diagnostic details in server logs.
diff --git a/src/main/java/eu/webeid/security/validator/ocsp/OcspClient.java b/src/main/java/eu/webeid/ocsp/client/OcspClient.java
similarity index 60%
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..2ebb62ae 100644
--- a/src/main/java/eu/webeid/security/validator/ocsp/OcspClient.java
+++ b/src/main/java/eu/webeid/ocsp/client/OcspClient.java
@@ -1,16 +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.exceptions.OCSPClientException;
import org.bouncycastle.cert.ocsp.OCSPReq;
import org.bouncycastle.cert.ocsp.OCSPResp;
-import java.io.IOException;
import java.net.URI;
public interface OcspClient {
- OCSPResp request(URI url, OCSPReq request) throws IOException;
+ OCSPResp request(URI url, OCSPReq request) throws OCSPClientException;
}
diff --git a/src/main/java/eu/webeid/security/validator/ocsp/OcspClientImpl.java b/src/main/java/eu/webeid/ocsp/client/OcspClientImpl.java
similarity index 60%
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..74dd466c 100644
--- a/src/main/java/eu/webeid/security/validator/ocsp/OcspClientImpl.java
+++ b/src/main/java/eu/webeid/ocsp/client/OcspClientImpl.java
@@ -1,8 +1,9 @@
// 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.exceptions.OCSPClientException;
import org.bouncycastle.cert.ocsp.OCSPReq;
import org.bouncycastle.cert.ocsp.OCSPResp;
import org.slf4j.Logger;
@@ -15,6 +16,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 +30,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)
@@ -39,15 +44,21 @@ public static OcspClient build(Duration ocspRequestTimeout) {
* @param uri OCSP server URL
* @param ocspReq OCSP request
* @return OCSP response from the server
- * @throws IOException if the request could not be executed due to cancellation, a connectivity problem or timeout,
+ * @throws OCSPClientException if the request could not be executed due to cancellation, a connectivity problem or timeout,
* or if the response status is not successful, or if response has wrong content type.
*/
@Override
- public OCSPResp request(URI uri, OCSPReq ocspReq) throws IOException {
+ public OCSPResp request(URI uri, OCSPReq ocspReq) throws OCSPClientException {
+ byte[] encodedOcspReq;
+ try {
+ encodedOcspReq = ocspReq.getEncoded();
+ } catch (IOException e) {
+ throw new OCSPClientException(e);
+ }
final HttpRequest request = HttpRequest.newBuilder()
.uri(uri)
.header(CONTENT_TYPE, OCSP_REQUEST_TYPE)
- .POST(HttpRequest.BodyPublishers.ofByteArray(ocspReq.getEncoded()))
+ .POST(HttpRequest.BodyPublishers.ofByteArray(encodedOcspReq))
.timeout(ocspRequestTimeout)
.build();
@@ -56,24 +67,33 @@ public OCSPResp request(URI uri, OCSPReq ocspReq) throws IOException {
response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
- throw new IOException("Interrupted while sending OCSP request", e);
+ throw new OCSPClientException("Interrupted while sending OCSP request", e);
+ } catch (IOException e) {
+ throw new OCSPClientException(e);
}
if (response.statusCode() != 200) {
- throw new IOException("OCSP request was not successful, response: " + response);
+ throw new OCSPClientException("OCSP request was not successful", response.body(), response.statusCode());
} else {
LOG.debug("OCSP response: {}", response);
}
final String contentType = response.headers().firstValue(CONTENT_TYPE).orElse("");
if (!contentType.startsWith(OCSP_RESPONSE_TYPE)) {
- throw new IOException("OCSP response content type is not " + OCSP_RESPONSE_TYPE);
+ throw new OCSPClientException("OCSP response content type is not " + OCSP_RESPONSE_TYPE);
+ }
+
+ OCSPResp ocspResp;
+ try {
+ ocspResp = new OCSPResp(response.body());
+ } catch (IOException e) {
+ throw new OCSPClientException(e);
}
- return new OCSPResp(response.body());
+ return ocspResp;
}
public OcspClientImpl(HttpClient httpClient, Duration ocspRequestTimeout) {
- this.httpClient = httpClient;
- this.ocspRequestTimeout = ocspRequestTimeout;
+ this.httpClient = requireNonNull(httpClient, "httpClient");
+ this.ocspRequestTimeout = requirePositiveDuration(ocspRequestTimeout, "ocspRequestTimeout");
}
}
diff --git a/src/main/java/eu/webeid/security/exceptions/OCSPCertificateException.java b/src/main/java/eu/webeid/ocsp/exceptions/OCSPCertificateException.java
similarity index 79%
rename from src/main/java/eu/webeid/security/exceptions/OCSPCertificateException.java
rename to src/main/java/eu/webeid/ocsp/exceptions/OCSPCertificateException.java
index 0e2b7cb2..e07a40ce 100644
--- a/src/main/java/eu/webeid/security/exceptions/OCSPCertificateException.java
+++ b/src/main/java/eu/webeid/ocsp/exceptions/OCSPCertificateException.java
@@ -1,7 +1,9 @@
// SPDX-FileCopyrightText: Estonian Information System Authority
// SPDX-License-Identifier: MIT
-package eu.webeid.security.exceptions;
+package eu.webeid.ocsp.exceptions;
+
+import eu.webeid.security.exceptions.AuthTokenException;
public class OCSPCertificateException extends AuthTokenException {
diff --git a/src/main/java/eu/webeid/ocsp/exceptions/OCSPClientException.java b/src/main/java/eu/webeid/ocsp/exceptions/OCSPClientException.java
new file mode 100644
index 00000000..6adadabe
--- /dev/null
+++ b/src/main/java/eu/webeid/ocsp/exceptions/OCSPClientException.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright (c) 2020-2025 Estonian Information System Authority
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package eu.webeid.ocsp.exceptions;
+
+public class OCSPClientException extends Exception {
+
+ private final byte[] responseBody;
+
+ private final Integer statusCode;
+
+ public OCSPClientException() {
+ this(null, null);
+ }
+
+ public OCSPClientException(String message) {
+ this(message, null, null);
+ }
+
+ public OCSPClientException(Throwable cause) {
+ this(null, cause, null, null);
+ }
+
+ public OCSPClientException(String message, Throwable cause) {
+ this(message, cause, null, null);
+ }
+
+ public OCSPClientException(String message, byte[] responseBody, Integer statusCode) {
+ this(message, null, responseBody, statusCode);
+ }
+
+ public OCSPClientException(String message, Throwable cause, byte[] responseBody, Integer statusCode) {
+ super(message, cause);
+ this.responseBody = responseBody;
+ this.statusCode = statusCode;
+ }
+
+ public byte[] getResponseBody() {
+ return responseBody;
+ }
+
+ public Integer getStatusCode() {
+ return statusCode;
+ }
+}
diff --git a/src/main/java/eu/webeid/ocsp/exceptions/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..adae8579
--- /dev/null
+++ b/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateOCSPCheckFailedException.java
@@ -0,0 +1,33 @@
+// 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() {
+ super("User certificate revocation check has failed");
+ }
+
+ 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/UserCertificateOCSPException.java b/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateOCSPException.java
new file mode 100644
index 00000000..8db06c07
--- /dev/null
+++ b/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateOCSPException.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2020-2025 Estonian Information System Authority
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package eu.webeid.ocsp.exceptions;
+
+import eu.webeid.security.exceptions.AuthTokenException;
+
+public class UserCertificateOCSPException extends AuthTokenException {
+
+ public UserCertificateOCSPException(String message) {
+ super(message);
+ }
+
+ public UserCertificateOCSPException(String message, Throwable exception) {
+ super(message, exception);
+ }
+
+}
diff --git a/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateRevokedException.java b/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateRevokedException.java
new file mode 100644
index 00000000..c0eb08e3
--- /dev/null
+++ b/src/main/java/eu/webeid/ocsp/exceptions/UserCertificateRevokedException.java
@@ -0,0 +1,47 @@
+/*
+ * 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() {
+ super("User certificate has been revoked");
+ }
+
+ public UserCertificateRevokedException(URI ocspResponderUri) {
+ super(appendResponderUri("User certificate has been revoked", ocspResponderUri));
+ }
+
+ public UserCertificateRevokedException(String msg, URI ocspResponderUri) {
+ super(appendResponderUri("User certificate has been revoked: " + msg, ocspResponderUri));
+ }
+}
diff --git a/src/main/java/eu/webeid/security/validator/ocsp/DigestCalculatorImpl.java b/src/main/java/eu/webeid/ocsp/protocol/DigestCalculatorImpl.java
similarity index 97%
rename from src/main/java/eu/webeid/security/validator/ocsp/DigestCalculatorImpl.java
rename to src/main/java/eu/webeid/ocsp/protocol/DigestCalculatorImpl.java
index 5910442c..a577d215 100644
--- a/src/main/java/eu/webeid/security/validator/ocsp/DigestCalculatorImpl.java
+++ b/src/main/java/eu/webeid/ocsp/protocol/DigestCalculatorImpl.java
@@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Estonian Information System Authority
// SPDX-License-Identifier: MIT
-package eu.webeid.security.validator.ocsp;
+package eu.webeid.ocsp.protocol;
import org.bouncycastle.asn1.nist.NISTObjectIdentifiers;
import org.bouncycastle.asn1.oiw.OIWObjectIdentifiers;
diff --git a/src/main/java/eu/webeid/ocsp/protocol/IssuerDistinguishedName.java b/src/main/java/eu/webeid/ocsp/protocol/IssuerDistinguishedName.java
new file mode 100644
index 00000000..df096a38
--- /dev/null
+++ b/src/main/java/eu/webeid/ocsp/protocol/IssuerDistinguishedName.java
@@ -0,0 +1,18 @@
+package eu.webeid.ocsp.protocol;
+
+import org.bouncycastle.asn1.x500.X500Name;
+
+import java.security.cert.X509Certificate;
+import java.util.Objects;
+
+public class IssuerDistinguishedName {
+
+ public static X500Name getIssuerDistinguishedName(X509Certificate certificate) {
+ Objects.requireNonNull(certificate, "certificate");
+ return X500Name.getInstance(certificate.getIssuerX500Principal().getEncoded());
+ }
+
+ private IssuerDistinguishedName() {
+ throw new IllegalStateException("Utility class");
+ }
+}
diff --git a/src/main/java/eu/webeid/ocsp/protocol/OcspNonceFactory.java b/src/main/java/eu/webeid/ocsp/protocol/OcspNonceFactory.java
new file mode 100644
index 00000000..8d5db408
--- /dev/null
+++ b/src/main/java/eu/webeid/ocsp/protocol/OcspNonceFactory.java
@@ -0,0 +1,31 @@
+// SPDX-FileCopyrightText: Estonian Information System Authority
+// SPDX-License-Identifier: MIT
+
+package eu.webeid.ocsp.protocol;
+
+import org.bouncycastle.asn1.DEROctetString;
+import org.bouncycastle.asn1.ocsp.OCSPObjectIdentifiers;
+import org.bouncycastle.asn1.x509.Extension;
+
+import java.io.IOException;
+import java.security.SecureRandom;
+
+/**
+ * Creates OCSP nonce extensions with fresh 32-byte nonces.
+ * The extension's critical flag is false, as recommended for OCSP requests.
+ */
+public final class OcspNonceFactory {
+
+ private static final int NONCE_LENGTH_BYTES = 32;
+ private static final SecureRandom RANDOM = new SecureRandom();
+
+ private OcspNonceFactory() {
+ }
+
+ public static Extension create() throws IOException {
+ final byte[] nonce = new byte[NONCE_LENGTH_BYTES];
+ RANDOM.nextBytes(nonce);
+ // The nonce is an OCTET STRING inside the extension's extnValue OCTET STRING.
+ return Extension.create(OCSPObjectIdentifiers.id_pkix_ocsp_nonce, false, new DEROctetString(nonce));
+ }
+}
diff --git a/src/main/java/eu/webeid/security/validator/ocsp/OcspRequestBuilder.java b/src/main/java/eu/webeid/ocsp/protocol/OcspRequestBuilder.java
similarity index 57%
rename from src/main/java/eu/webeid/security/validator/ocsp/OcspRequestBuilder.java
rename to src/main/java/eu/webeid/ocsp/protocol/OcspRequestBuilder.java
index b93009be..cfb29c2e 100644
--- a/src/main/java/eu/webeid/security/validator/ocsp/OcspRequestBuilder.java
+++ b/src/main/java/eu/webeid/ocsp/protocol/OcspRequestBuilder.java
@@ -1,11 +1,8 @@
// SPDX-FileCopyrightText: Estonian Information System Authority
// SPDX-License-Identifier: MIT
-package eu.webeid.security.validator.ocsp;
+package eu.webeid.ocsp.protocol;
-import org.bouncycastle.asn1.DEROctetString;
-import org.bouncycastle.asn1.ocsp.OCSPObjectIdentifiers;
-import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.asn1.x509.Extensions;
import org.bouncycastle.cert.ocsp.CertificateID;
import org.bouncycastle.cert.ocsp.OCSPException;
@@ -13,7 +10,6 @@
import org.bouncycastle.cert.ocsp.OCSPReqBuilder;
import java.io.IOException;
-import java.security.SecureRandom;
import java.util.Objects;
/**
@@ -22,8 +18,6 @@
*/
public final class OcspRequestBuilder {
- private static final SecureRandom RANDOM_GENERATOR = new SecureRandom();
-
private boolean ocspNonceEnabled = true;
private CertificateID certificateId;
@@ -38,8 +32,8 @@ public OcspRequestBuilder enableOcspNonce(boolean ocspNonceEnabled) {
}
/**
- * The returned {@link OCSPReq} is not re-usable/cacheable. It contains a one-time nonce
- * and responders will reject subsequent requests that have the same nonce value.
+ * Builds a request with a fresh nonce when nonce support is enabled.
+ * Create a new request for each check so a matching response nonce can establish freshness.
*/
public OCSPReq build() throws OCSPException {
final OCSPReqBuilder builder = new OCSPReqBuilder();
@@ -47,7 +41,7 @@ public OCSPReq build() throws OCSPException {
if (ocspNonceEnabled) {
try {
- addNonce(builder);
+ builder.setRequestExtensions(new Extensions(OcspNonceFactory.create()));
} catch (IOException e) {
throw new OCSPException("Failed to generate OCSP nonce extension", e);
}
@@ -56,16 +50,4 @@ public OCSPReq build() throws OCSPException {
return builder.build();
}
- private void addNonce(OCSPReqBuilder builder) throws IOException {
- final byte[] nonce = new byte[32];
- RANDOM_GENERATOR.nextBytes(nonce);
-
- final Extension[] extensions = new Extension[]{
- new Extension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce, false,
- // Follow OpenSSL OCSP nonce encoding convention and add double octet string header.
- new DEROctetString(new DEROctetString(nonce)))
- };
- builder.setRequestExtensions(new Extensions(extensions));
- }
-
}
diff --git a/src/main/java/eu/webeid/ocsp/protocol/OcspResponseValidator.java b/src/main/java/eu/webeid/ocsp/protocol/OcspResponseValidator.java
new file mode 100644
index 00000000..0f0f23ff
--- /dev/null
+++ b/src/main/java/eu/webeid/ocsp/protocol/OcspResponseValidator.java
@@ -0,0 +1,167 @@
+// SPDX-FileCopyrightText: Estonian Information System Authority
+// SPDX-License-Identifier: MIT
+
+package eu.webeid.ocsp.protocol;
+
+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;
+import org.bouncycastle.cert.ocsp.CertificateStatus;
+import org.bouncycastle.cert.ocsp.OCSPException;
+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;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+import java.util.Objects;
+
+public final class OcspResponseValidator {
+
+ /**
+ * Indicates that a X.509 Certificates corresponding private key may be used by an authority to sign OCSP responses.
+ *
+ * https://oidref.com/1.3.6.1.5.5.7.3.9
+ */
+ private static final String OID_OCSP_SIGNING = "1.3.6.1.5.5.7.3.9";
+ private static final int KEY_USAGE_DIGITAL_SIGNATURE_BIT_INDEX = 0;
+ private static final int KEY_USAGE_KEY_CERT_SIGN_BIT_INDEX = 5;
+ private static final String ERROR_PREFIX = "Certificate status update time check failed: ";
+
+ public static void validateBasicConstraintsNotCA(X509Certificate certificate) throws OCSPCertificateException {
+ Objects.requireNonNull(certificate, "certificate");
+ // X509Certificate.getBasicConstraints() returns -1 when the Basic Constraints extension is absent
+ // or when cA=FALSE, and a non-negative value only when cA=TRUE (the pathLenConstraint, or
+ // Integer.MAX_VALUE when cA=TRUE without pathLenConstraint).
+ if (certificate.getBasicConstraints() >= 0) {
+ throw new OCSPCertificateException("Certificate " + certificate.getSubjectX500Principal() +
+ " must not be a CA certificate (Basic Constraints CA:TRUE is not allowed for OCSP responder)");
+ }
+ }
+
+ public static void validateKeyUsageDigitalSignature(X509Certificate certificate) throws OCSPCertificateException {
+ Objects.requireNonNull(certificate, "certificate");
+ final boolean[] keyUsage = certificate.getKeyUsage();
+ if (keyUsage == null) {
+ throw new OCSPCertificateException("Certificate " + certificate.getSubjectX500Principal() +
+ " does not contain the Key Usage extension required for OCSP response signing");
+ }
+ if (keyUsage.length <= KEY_USAGE_DIGITAL_SIGNATURE_BIT_INDEX || !keyUsage[KEY_USAGE_DIGITAL_SIGNATURE_BIT_INDEX]) {
+ throw new OCSPCertificateException("Certificate " + certificate.getSubjectX500Principal() +
+ " Key Usage extension does not contain Digital Signature, which is required for OCSP response signing");
+ }
+ }
+
+ public static void validateKeyUsageNotCertificateSigning(X509Certificate certificate) throws OCSPCertificateException {
+ Objects.requireNonNull(certificate, "certificate");
+ final boolean[] keyUsage = certificate.getKeyUsage();
+ if (keyUsage == null) {
+ return;
+ }
+ if (keyUsage.length > KEY_USAGE_KEY_CERT_SIGN_BIT_INDEX && keyUsage[KEY_USAGE_KEY_CERT_SIGN_BIT_INDEX]) {
+ throw new OCSPCertificateException("Certificate " + certificate.getSubjectX500Principal() +
+ " Key Usage extension contains Certificate Signing, which is not allowed for OCSP responder");
+ }
+ }
+
+ public static void validateExtendedKeyUsageOcspSigning(X509Certificate certificate) throws OCSPCertificateException {
+ Objects.requireNonNull(certificate, "certificate");
+ try {
+ final List extendedKeyUsage = certificate.getExtendedKeyUsage();
+ if (extendedKeyUsage == null) {
+ throw new OCSPCertificateException("Certificate " + certificate.getSubjectX500Principal() +
+ " does not contain the Extended Key Usage extension required for OCSP response signing");
+ }
+ if (!extendedKeyUsage.contains(OID_OCSP_SIGNING)) {
+ throw new OCSPCertificateException("Certificate " + certificate.getSubjectX500Principal() +
+ " Extended Key Usage extension does not contain OCSP Signing, which is required for OCSP response signing");
+ }
+ } catch (CertificateParsingException e) {
+ throw new OCSPCertificateException("Certificate parsing failed:", e);
+ }
+ }
+
+ public static void validateResponseSignature(BasicOCSPResp basicResponse, X509CertificateHolder responderCert, URI ocspResponderUri) throws CertificateException, OperatorCreationException, OCSPException, UserCertificateOCSPCheckFailedException {
+ final ContentVerifierProvider verifierProvider = new JcaContentVerifierProviderBuilder()
+ .setProvider(BouncyCastleProvider.PROVIDER_NAME)
+ .build(responderCert);
+ if (!basicResponse.isSignatureValid(verifierProvider)) {
+ throw new UserCertificateOCSPCheckFailedException("OCSP response signature is invalid", ocspResponderUri);
+ }
+ }
+
+ public static void validateCertificateStatusUpdateTime(SingleResp certStatusResponse, Duration allowedTimeSkew, Duration maxThisUpdateAge, Duration maxNextUpdateAge, URI ocspResponderUri) throws UserCertificateOCSPCheckFailedException {
+ // From RFC 2560, https://www.ietf.org/rfc/rfc2560.txt:
+ // 4.2.2. Notes on OCSP Responses
+ // 4.2.2.1. Time
+ // Responses whose nextUpdate value is earlier than
+ // the local system time value SHOULD be considered unreliable.
+ // Responses whose thisUpdate time is later than the local system time
+ // SHOULD be considered unreliable.
+ // If nextUpdate is not set, the responder is indicating that newer
+ // revocation information is available all the time.
+ final Instant now = DateAndTime.DefaultClock.getInstance().now().toInstant();
+ final Instant latestAcceptableTimeSkew = now.plus(allowedTimeSkew);
+ final Instant minimumValidThisUpdateTime = now.minus(maxThisUpdateAge);
+ final Instant minimumValidNextUpdateTime = now.minus(maxNextUpdateAge);
+
+ final Instant thisUpdate = certStatusResponse.getThisUpdate().toInstant();
+ if (thisUpdate.isAfter(latestAcceptableTimeSkew)) {
+ throw new UserCertificateOCSPCheckFailedException(ERROR_PREFIX +
+ "thisUpdate '" + thisUpdate + "' is too far in the future, " +
+ "latest allowed: '" + latestAcceptableTimeSkew + "'", ocspResponderUri);
+ }
+ if (thisUpdate.isBefore(minimumValidThisUpdateTime)) {
+ throw new UserCertificateOCSPCheckFailedException(ERROR_PREFIX +
+ "thisUpdate '" + thisUpdate + "' is too old, " +
+ "minimum time allowed: '" + minimumValidThisUpdateTime + "'", ocspResponderUri);
+ }
+
+ if (certStatusResponse.getNextUpdate() == null) {
+ return;
+ }
+ final Instant nextUpdate = certStatusResponse.getNextUpdate().toInstant();
+ if (nextUpdate.isBefore(minimumValidNextUpdateTime)) {
+ throw new UserCertificateOCSPCheckFailedException(ERROR_PREFIX +
+ "nextUpdate '" + nextUpdate + "' is too old, " +
+ "minimum time allowed: '" + minimumValidNextUpdateTime + "'", ocspResponderUri);
+ }
+ if (nextUpdate.isBefore(thisUpdate)) {
+ throw new UserCertificateOCSPCheckFailedException(ERROR_PREFIX +
+ "nextUpdate '" + nextUpdate + "' is before thisUpdate '" + thisUpdate + "'", ocspResponderUri);
+ }
+ }
+
+ 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) {
+ throw (revokedStatus.hasRevocationReason() ?
+ new UserCertificateRevokedException("Revocation reason: " + revokedStatus.getRevocationReason(), ocspResponderUri) :
+ new UserCertificateRevokedException(ocspResponderUri));
+ } else if (status instanceof UnknownStatus) {
+ throw new UserCertificateOCSPCheckFailedException("Unknown status", ocspResponderUri);
+ } else {
+ throw new UserCertificateOCSPCheckFailedException("Status is neither good, revoked nor unknown", ocspResponderUri);
+ }
+ }
+
+ private OcspResponseValidator() {
+ throw new IllegalStateException("Utility class");
+ }
+}
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/ocsp/service/AiaOcspService.java b/src/main/java/eu/webeid/ocsp/service/AiaOcspService.java
new file mode 100644
index 00000000..7ba02ec3
--- /dev/null
+++ b/src/main/java/eu/webeid/ocsp/service/AiaOcspService.java
@@ -0,0 +1,111 @@
+// SPDX-FileCopyrightText: Estonian Information System Authority
+// SPDX-License-Identifier: MIT
+
+package eu.webeid.ocsp.service;
+
+import eu.webeid.security.certificate.CertificateValidator;
+import eu.webeid.security.exceptions.AuthTokenException;
+import eu.webeid.ocsp.exceptions.OCSPCertificateException;
+import eu.webeid.ocsp.exceptions.UserCertificateOCSPException;
+import eu.webeid.ocsp.protocol.OcspResponseValidator;
+import eu.webeid.security.validator.revocationcheck.RevocationMode;
+import org.bouncycastle.asn1.x500.X500Name;
+import org.bouncycastle.cert.X509CertificateHolder;
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
+
+import java.net.URI;
+import java.security.cert.CertStore;
+import java.security.cert.CertificateException;
+import java.security.cert.TrustAnchor;
+import java.security.cert.X509Certificate;
+import java.time.Duration;
+import java.util.Date;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+
+import static eu.webeid.ocsp.protocol.IssuerDistinguishedName.getIssuerDistinguishedName;
+import static eu.webeid.ocsp.protocol.OcspUrl.getOcspUri;
+
+/**
+ * An OCSP service that uses the responders from the Certificates' Authority Information Access (AIA) extension.
+ */
+public class AiaOcspService implements OcspService {
+
+ private final JcaX509CertificateConverter certificateConverter = new JcaX509CertificateConverter();
+ private final Set trustedCACertificateAnchors;
+ private final CertStore trustedCACertificateCertStore;
+ private final URI url;
+ private final boolean supportsNonce;
+ private final FallbackOcspService fallbackOcspService;
+ private final Duration maxThisUpdateAge;
+ private final Duration maxNextUpdateAge;
+
+ public AiaOcspService(AiaOcspServiceConfiguration configuration, X509Certificate certificate, FallbackOcspService fallbackOcspService) throws UserCertificateOCSPException {
+ Objects.requireNonNull(configuration);
+ this.trustedCACertificateAnchors = configuration.getTrustedCACertificateAnchors();
+ this.trustedCACertificateCertStore = configuration.getTrustedCACertificateCertStore();
+ this.url = getOcspAiaUrlFromCertificate(Objects.requireNonNull(certificate));
+ this.fallbackOcspService = fallbackOcspService;
+ X500Name issuerDN = getIssuerDistinguishedName(certificate);
+ this.supportsNonce = !configuration.getNonceDisabledIssuerDNs().contains(issuerDN);
+ this.maxThisUpdateAge = configuration.getMaxThisUpdateAge();
+ this.maxNextUpdateAge = configuration.getMaxNextUpdateAge();
+ }
+
+ @Override
+ public boolean doesSupportNonce() {
+ return supportsNonce;
+ }
+
+ @Override
+ public URI getAccessLocation() {
+ return url;
+ }
+
+ @Override
+ public Duration getMaxThisUpdateAge() {
+ return maxThisUpdateAge;
+ }
+
+ @Override
+ public Duration getMaxNextUpdateAge() {
+ return maxNextUpdateAge;
+ }
+
+ @Override
+ public Optional getFallbackService() {
+ return Optional.ofNullable(fallbackOcspService);
+ }
+
+ @Override
+ public void validateResponderCertificate(X509CertificateHolder cert, Date now) throws AuthTokenException {
+ try {
+ final X509Certificate certificate = certificateConverter.getCertificate(cert);
+ CertificateValidator.requireCertificateIsValidOnDate(certificate, now, "AIA OCSP responder"); // Trusted certificates' validity has been already verified in validateCertificateExpiry().
+ OcspResponseValidator.validateBasicConstraintsNotCA(certificate);
+ OcspResponseValidator.validateKeyUsageDigitalSignature(certificate);
+ OcspResponseValidator.validateKeyUsageNotCertificateSigning(certificate);
+ OcspResponseValidator.validateExtendedKeyUsageOcspSigning(certificate);
+ CertificateValidator.validateCertificateTrustAndRevocation(
+ certificate,
+ trustedCACertificateAnchors,
+ trustedCACertificateCertStore,
+ now,
+ RevocationMode.DISABLED,
+ null,
+ null,
+ false
+ );
+ } catch (CertificateException e) {
+ throw new OCSPCertificateException("Invalid responder certificate", e);
+ }
+ }
+
+ private static URI getOcspAiaUrlFromCertificate(X509Certificate certificate) throws UserCertificateOCSPException {
+ return getOcspUri(certificate).orElseThrow(() ->
+ new UserCertificateOCSPException("Getting the AIA OCSP responder field from the certificate failed")
+ );
+ }
+
+}
diff --git a/src/main/java/eu/webeid/ocsp/service/AiaOcspServiceConfiguration.java b/src/main/java/eu/webeid/ocsp/service/AiaOcspServiceConfiguration.java
new file mode 100644
index 00000000..90687639
--- /dev/null
+++ b/src/main/java/eu/webeid/ocsp/service/AiaOcspServiceConfiguration.java
@@ -0,0 +1,52 @@
+// SPDX-FileCopyrightText: Estonian Information System Authority
+// SPDX-License-Identifier: MIT
+
+package eu.webeid.ocsp.service;
+
+import org.bouncycastle.asn1.x500.X500Name;
+
+import java.security.cert.CertStore;
+import java.security.cert.TrustAnchor;
+import java.time.Duration;
+import java.util.Collection;
+import java.util.Objects;
+import java.util.Set;
+
+import static eu.webeid.security.util.DateAndTime.requirePositiveDuration;
+
+public class AiaOcspServiceConfiguration {
+
+ private final Collection nonceDisabledIssuerDNs;
+ private final Set trustedCACertificateAnchors;
+ private final CertStore trustedCACertificateCertStore;
+ private final Duration maxThisUpdateAge;
+ private final Duration maxNextUpdateAge;
+
+ public AiaOcspServiceConfiguration(Collection nonceDisabledIssuerDNs, Set trustedCACertificateAnchors, CertStore trustedCACertificateCertStore, Duration maxThisUpdateAge, Duration maxNextUpdateAge) {
+ this.nonceDisabledIssuerDNs = Set.copyOf(nonceDisabledIssuerDNs);
+ this.trustedCACertificateAnchors = Set.copyOf(trustedCACertificateAnchors);
+ this.trustedCACertificateCertStore = Objects.requireNonNull(trustedCACertificateCertStore);
+ this.maxThisUpdateAge = requirePositiveDuration(maxThisUpdateAge, "maxThisUpdateAge");
+ this.maxNextUpdateAge = requirePositiveDuration(maxNextUpdateAge, "maxNextUpdateAge");
+ }
+
+ public Collection getNonceDisabledIssuerDNs() {
+ return nonceDisabledIssuerDNs;
+ }
+
+ public Set getTrustedCACertificateAnchors() {
+ return trustedCACertificateAnchors;
+ }
+
+ public CertStore getTrustedCACertificateCertStore() {
+ return trustedCACertificateCertStore;
+ }
+
+ public Duration getMaxThisUpdateAge() {
+ return maxThisUpdateAge;
+ }
+
+ public Duration getMaxNextUpdateAge() {
+ return maxNextUpdateAge;
+ }
+}
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 74%
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..34e294ed 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,22 @@
// 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.time.Duration;
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.
@@ -39,17 +40,30 @@ public URI getAccessLocation() {
return configuration.getOcspServiceAccessLocation();
}
+ @Override
+ public Duration getMaxThisUpdateAge() {
+ return configuration.getMaxThisUpdateAge();
+ }
+
+ @Override
+ public Duration getMaxNextUpdateAge() {
+ return configuration.getMaxNextUpdateAge();
+ }
+
@Override
public void validateResponderCertificate(X509CertificateHolder cert, Date now) throws AuthTokenException {
try {
final X509Certificate responderCertificate = certificateConverter.getCertificate(cert);
+ // Certificate extensions (Basic Constraints, Key Usage, Extended Key Usage) are validated at
+ // configuration time in DesignatedOcspServiceConfiguration. Since equals() compares the full DER
+ // encoding, a matching certificate is guaranteed to have the same validated extensions.
// Certificate pinning is implemented simply by comparing the certificates or their public keys,
// see https://owasp.org/www-community/controls/Certificate_and_Public_Key_Pinning.
if (!configuration.getResponderCertificate().equals(responderCertificate)) {
throw new OCSPCertificateException("Responder certificate from the OCSP response is not equal to " +
"the configured designated OCSP responder certificate");
}
- certificateIsValidOnDate(responderCertificate, now, "Designated OCSP responder");
+ requireCertificateIsValidOnDate(responderCertificate, now, "Designated OCSP responder");
} catch (CertificateException e) {
throw new OCSPCertificateException("X509CertificateHolder conversion to X509Certificate failed", e);
}
diff --git a/src/main/java/eu/webeid/security/validator/ocsp/service/DesignatedOcspServiceConfiguration.java b/src/main/java/eu/webeid/ocsp/service/DesignatedOcspServiceConfiguration.java
similarity index 68%
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..650a1b99 100644
--- a/src/main/java/eu/webeid/security/validator/ocsp/service/DesignatedOcspServiceConfiguration.java
+++ b/src/main/java/eu/webeid/ocsp/service/DesignatedOcspServiceConfiguration.java
@@ -1,19 +1,21 @@
// SPDX-FileCopyrightText: Estonian Information System Authority
// SPDX-License-Identifier: MIT
-package eu.webeid.security.validator.ocsp.service;
+package eu.webeid.ocsp.service;
-import eu.webeid.security.validator.ocsp.OcspResponseValidator;
+import eu.webeid.ocsp.protocol.OcspResponseValidator;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder;
-import eu.webeid.security.exceptions.OCSPCertificateException;
+import eu.webeid.ocsp.exceptions.OCSPCertificateException;
import java.net.URI;
import java.security.cert.CertificateEncodingException;
import java.security.cert.X509Certificate;
+import java.time.Duration;
import java.util.Collection;
import java.util.Objects;
-import java.util.stream.Collectors;
+
+import static eu.webeid.security.util.DateAndTime.requirePositiveDuration;
public class DesignatedOcspServiceConfiguration {
@@ -21,6 +23,8 @@ public class DesignatedOcspServiceConfiguration {
private final X509Certificate responderCertificate;
private final boolean doesSupportNonce;
private final Collection supportedIssuers;
+ private final Duration maxThisUpdateAge;
+ private final Duration maxNextUpdateAge;
/**
* Configuration of a designated OCSP service.
@@ -29,14 +33,21 @@ public class DesignatedOcspServiceConfiguration {
* @param responderCertificate the service's OCSP responder certificate
* @param supportedCertificateIssuers the certificate issuers supported by the service
* @param doesSupportNonce true if the service supports the OCSP protocol nonce extension
+ * @param maxThisUpdateAge the maximum age of the OCSP response's {@code thisUpdate} time, must be greater than zero
+ * @param maxNextUpdateAge the maximum age of the OCSP response's {@code nextUpdate} time, must be greater than zero
* @throws OCSPCertificateException when an error occurs while extracting issuer names from certificates
*/
- public DesignatedOcspServiceConfiguration(URI ocspServiceAccessLocation, X509Certificate responderCertificate, Collection supportedCertificateIssuers, boolean doesSupportNonce) throws OCSPCertificateException {
+ public DesignatedOcspServiceConfiguration(URI ocspServiceAccessLocation, X509Certificate responderCertificate, Collection supportedCertificateIssuers, boolean doesSupportNonce, Duration maxThisUpdateAge, Duration maxNextUpdateAge) throws OCSPCertificateException {
this.ocspServiceAccessLocation = Objects.requireNonNull(ocspServiceAccessLocation, "OCSP service access location");
this.responderCertificate = Objects.requireNonNull(responderCertificate, "OCSP responder certificate");
this.supportedIssuers = getIssuerX500Names(Objects.requireNonNull(supportedCertificateIssuers, "supported issuers"));
- OcspResponseValidator.validateHasSigningExtension(responderCertificate);
+ OcspResponseValidator.validateBasicConstraintsNotCA(responderCertificate);
+ OcspResponseValidator.validateKeyUsageDigitalSignature(responderCertificate);
+ OcspResponseValidator.validateKeyUsageNotCertificateSigning(responderCertificate);
+ OcspResponseValidator.validateExtendedKeyUsageOcspSigning(responderCertificate);
this.doesSupportNonce = doesSupportNonce;
+ this.maxThisUpdateAge = requirePositiveDuration(maxThisUpdateAge, "maxThisUpdateAge");
+ this.maxNextUpdateAge = requirePositiveDuration(maxNextUpdateAge, "maxNextUpdateAge");
}
public URI getOcspServiceAccessLocation() {
@@ -51,6 +62,14 @@ public boolean doesSupportNonce() {
return doesSupportNonce;
}
+ public Duration getMaxThisUpdateAge() {
+ return maxThisUpdateAge;
+ }
+
+ public Duration getMaxNextUpdateAge() {
+ return maxNextUpdateAge;
+ }
+
public boolean supportsIssuerOf(X509Certificate certificate) throws CertificateEncodingException {
return supportedIssuers.contains(new JcaX509CertificateHolder(Objects.requireNonNull(certificate)).getIssuer());
}
@@ -59,7 +78,7 @@ private Collection getIssuerX500Names(Collection supp
try {
return supportedIssuers.stream()
.map(this::getSubject)
- .collect(Collectors.toList());
+ .toList();
} catch (IllegalArgumentException e) {
throw new OCSPCertificateException("Supported issuer list contains an invalid certificate", e.getCause());
}
diff --git a/src/main/java/eu/webeid/ocsp/service/FallbackOcspService.java b/src/main/java/eu/webeid/ocsp/service/FallbackOcspService.java
new file mode 100644
index 00000000..94f11a0a
--- /dev/null
+++ b/src/main/java/eu/webeid/ocsp/service/FallbackOcspService.java
@@ -0,0 +1,137 @@
+/*
+ * Copyright (c) 2020-2025 Estonian Information System Authority
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package eu.webeid.ocsp.service;
+
+import eu.webeid.ocsp.exceptions.OCSPCertificateException;
+import eu.webeid.ocsp.protocol.OcspResponseValidator;
+import eu.webeid.security.certificate.CertificateValidator;
+import eu.webeid.security.exceptions.AuthTokenException;
+import eu.webeid.security.validator.revocationcheck.RevocationMode;
+import org.bouncycastle.cert.X509CertificateHolder;
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
+
+import java.net.URI;
+import java.security.cert.CertStore;
+import java.security.cert.CertificateException;
+import java.security.cert.TrustAnchor;
+import java.security.cert.X509Certificate;
+import java.time.Duration;
+import java.util.Date;
+import java.util.Set;
+
+
+import static eu.webeid.security.certificate.CertificateValidator.requireCertificateIsValidOnDate;
+
+public class FallbackOcspService implements OcspService {
+
+ private final JcaX509CertificateConverter certificateConverter = new JcaX509CertificateConverter();
+ private final URI url;
+ private final boolean supportsNonce;
+ private final X509Certificate trustedResponderCertificate;
+ private final FallbackOcspService nextFallback;
+ private final Set trustedCACertificateAnchors;
+ private final CertStore trustedCACertificateCertStore;
+ private final Duration maxThisUpdateAge;
+ private final Duration maxNextUpdateAge;
+
+ public FallbackOcspService(FallbackOcspServiceConfiguration configuration) {
+ this.url = configuration.getAccessLocation();
+ this.supportsNonce = configuration.doesSupportNonce();
+ this.trustedResponderCertificate = configuration.getResponderCertificate();
+ this.nextFallback = configuration.getNextFallbackConfiguration() != null
+ ? new FallbackOcspService(configuration.getNextFallbackConfiguration())
+ : null;
+ this.trustedCACertificateAnchors = configuration.getTrustedCACertificateAnchors();
+ this.trustedCACertificateCertStore = configuration.getTrustedCACertificateCertStore();
+ this.maxThisUpdateAge = configuration.getMaxThisUpdateAge();
+ this.maxNextUpdateAge = configuration.getMaxNextUpdateAge();
+ }
+
+ @Override
+ public boolean doesSupportNonce() {
+ return supportsNonce;
+ }
+
+ @Override
+ public URI getAccessLocation() {
+ return url;
+ }
+
+ @Override
+ public Duration getMaxThisUpdateAge() {
+ return maxThisUpdateAge;
+ }
+
+ @Override
+ public Duration getMaxNextUpdateAge() {
+ return maxNextUpdateAge;
+ }
+
+ @Override
+ public void validateResponderCertificate(X509CertificateHolder cert, Date now) throws AuthTokenException {
+ try {
+ final X509Certificate responderCertificate = certificateConverter.getCertificate(cert);
+ requireCertificateIsValidOnDate(responderCertificate, now, "Fallback OCSP responder");
+ if (trustedResponderCertificate != null) {
+ validatePinnedResponderCertificate(responderCertificate);
+ } else {
+ validateResponderCertificateAgainstTrustedCa(responderCertificate, now);
+ }
+ } catch (CertificateException e) {
+ throw new OCSPCertificateException("X509CertificateHolder conversion to X509Certificate failed", e);
+ }
+ }
+
+ private void validatePinnedResponderCertificate(X509Certificate responderCertificate) throws OCSPCertificateException {
+ // Certificate extensions (Basic Constraints, Key Usage, Extended Key Usage) are validated at
+ // configuration time in FallbackOcspServiceConfiguration. Since equals() compares the full DER
+ // encoding, a matching certificate is guaranteed to have the same validated extensions.
+ // Certificate pinning is implemented simply by comparing the certificates or their public keys,
+ // see https://owasp.org/www-community/controls/Certificate_and_Public_Key_Pinning.
+ if (!trustedResponderCertificate.equals(responderCertificate)) {
+ throw new OCSPCertificateException("Responder certificate from the OCSP response is not equal to " +
+ "the configured fallback OCSP responder certificate");
+ }
+ }
+
+ private void validateResponderCertificateAgainstTrustedCa(X509Certificate responderCertificate, Date now) throws AuthTokenException {
+ OcspResponseValidator.validateBasicConstraintsNotCA(responderCertificate);
+ OcspResponseValidator.validateKeyUsageDigitalSignature(responderCertificate);
+ OcspResponseValidator.validateKeyUsageNotCertificateSigning(responderCertificate);
+ OcspResponseValidator.validateExtendedKeyUsageOcspSigning(responderCertificate);
+ CertificateValidator.validateCertificateTrustAndRevocation(
+ responderCertificate,
+ trustedCACertificateAnchors,
+ trustedCACertificateCertStore,
+ now,
+ RevocationMode.DISABLED,
+ null,
+ null,
+ false
+ );
+ }
+
+ public FallbackOcspService getNextFallback() {
+ return nextFallback;
+ }
+}
diff --git a/src/main/java/eu/webeid/ocsp/service/FallbackOcspServiceConfiguration.java b/src/main/java/eu/webeid/ocsp/service/FallbackOcspServiceConfiguration.java
new file mode 100644
index 00000000..943c0923
--- /dev/null
+++ b/src/main/java/eu/webeid/ocsp/service/FallbackOcspServiceConfiguration.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright (c) 2020-2025 Estonian Information System Authority
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package eu.webeid.ocsp.service;
+
+import eu.webeid.ocsp.exceptions.OCSPCertificateException;
+import eu.webeid.ocsp.protocol.OcspResponseValidator;
+import org.bouncycastle.asn1.x500.X500Name;
+
+import java.net.URI;
+import java.security.cert.CertStore;
+import java.security.cert.TrustAnchor;
+import java.security.cert.X509Certificate;
+import java.time.Duration;
+import java.util.Objects;
+import java.util.Set;
+
+import static eu.webeid.security.util.DateAndTime.requirePositiveDuration;
+
+public class FallbackOcspServiceConfiguration {
+
+ private final URI accessLocation;
+ private final X509Certificate responderCertificate;
+ private final boolean doesSupportNonce;
+ private final FallbackOcspServiceConfiguration nextFallbackConfiguration;
+ private final X500Name issuerDN;
+ private final Set trustedCACertificateAnchors;
+ private final CertStore trustedCACertificateCertStore;
+ private final Duration maxThisUpdateAge;
+ private final Duration maxNextUpdateAge;
+
+ public FallbackOcspServiceConfiguration(URI accessLocation, X509Certificate responderCertificate,
+ boolean doesSupportNonce,
+ FallbackOcspServiceConfiguration nextFallbackConfiguration,
+ X500Name issuerDN, Set trustedCACertificateAnchors,
+ CertStore trustedCACertificateCertStore,
+ Duration maxThisUpdateAge, Duration maxNextUpdateAge) throws OCSPCertificateException {
+ this.accessLocation = Objects.requireNonNull(accessLocation, "Fallback OCSP service access location");
+ this.responderCertificate = responderCertificate;
+ if (responderCertificate != null) {
+ OcspResponseValidator.validateBasicConstraintsNotCA(responderCertificate);
+ OcspResponseValidator.validateKeyUsageDigitalSignature(responderCertificate);
+ OcspResponseValidator.validateKeyUsageNotCertificateSigning(responderCertificate);
+ OcspResponseValidator.validateExtendedKeyUsageOcspSigning(responderCertificate);
+ }
+ this.doesSupportNonce = doesSupportNonce;
+ this.nextFallbackConfiguration = nextFallbackConfiguration;
+ this.issuerDN = Objects.requireNonNull(issuerDN, "issuerDN");
+ this.trustedCACertificateAnchors = Objects.requireNonNull(trustedCACertificateAnchors, "trustedCACertificateAnchors");
+ this.trustedCACertificateCertStore = Objects.requireNonNull(trustedCACertificateCertStore, "trustedCACertificateCertStore");
+ this.maxThisUpdateAge = requirePositiveDuration(maxThisUpdateAge, "maxThisUpdateAge");
+ this.maxNextUpdateAge = requirePositiveDuration(maxNextUpdateAge, "maxNextUpdateAge");
+ }
+
+ public URI getAccessLocation() {
+ return accessLocation;
+ }
+
+ public X509Certificate getResponderCertificate() {
+ return responderCertificate;
+ }
+
+ public boolean doesSupportNonce() {
+ return doesSupportNonce;
+ }
+
+ public FallbackOcspServiceConfiguration getNextFallbackConfiguration() {
+ return nextFallbackConfiguration;
+ }
+
+ public X500Name getIssuerDN() {
+ return issuerDN;
+ }
+
+ public Set getTrustedCACertificateAnchors() {
+ return trustedCACertificateAnchors;
+ }
+
+ public CertStore getTrustedCACertificateCertStore() {
+ return trustedCACertificateCertStore;
+ }
+
+ public Duration getMaxThisUpdateAge() {
+ return maxThisUpdateAge;
+ }
+
+ public Duration getMaxNextUpdateAge() {
+ return maxNextUpdateAge;
+ }
+}
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 63%
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..2f388e2f 100644
--- a/src/main/java/eu/webeid/security/validator/ocsp/service/OcspService.java
+++ b/src/main/java/eu/webeid/ocsp/service/OcspService.java
@@ -1,13 +1,15 @@
// 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.time.Duration;
import java.util.Date;
+import java.util.Optional;
public interface OcspService {
@@ -15,6 +17,14 @@ public interface OcspService {
URI getAccessLocation();
+ Duration getMaxThisUpdateAge();
+
+ Duration getMaxNextUpdateAge();
+
void validateResponderCertificate(X509CertificateHolder cert, Date now) throws AuthTokenException;
+ default Optional getFallbackService() {
+ return Optional.empty();
+ }
+
}
diff --git a/src/main/java/eu/webeid/ocsp/service/OcspServiceProvider.java b/src/main/java/eu/webeid/ocsp/service/OcspServiceProvider.java
new file mode 100644
index 00000000..2de93f8c
--- /dev/null
+++ b/src/main/java/eu/webeid/ocsp/service/OcspServiceProvider.java
@@ -0,0 +1,62 @@
+// SPDX-FileCopyrightText: Estonian Information System Authority
+// SPDX-License-Identifier: MIT
+
+package eu.webeid.ocsp.service;
+
+import eu.webeid.ocsp.exceptions.UserCertificateOCSPException;
+import org.bouncycastle.asn1.x500.X500Name;
+
+import java.security.cert.CertificateException;
+import java.security.cert.X509Certificate;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+
+import static eu.webeid.ocsp.protocol.IssuerDistinguishedName.getIssuerDistinguishedName;
+
+public class OcspServiceProvider {
+
+ private final DesignatedOcspService designatedOcspService;
+ private final AiaOcspServiceConfiguration aiaOcspServiceConfiguration;
+ private final Map fallbackOcspServiceMap = new HashMap<>();
+
+ public OcspServiceProvider(DesignatedOcspServiceConfiguration designatedOcspServiceConfiguration, AiaOcspServiceConfiguration aiaOcspServiceConfiguration) {
+ this(designatedOcspServiceConfiguration, aiaOcspServiceConfiguration, null);
+ }
+
+ public OcspServiceProvider(DesignatedOcspServiceConfiguration designatedOcspServiceConfiguration, AiaOcspServiceConfiguration aiaOcspServiceConfiguration, Collection fallbackOcspServiceConfigurations) {
+ designatedOcspService = designatedOcspServiceConfiguration != null ?
+ new DesignatedOcspService(designatedOcspServiceConfiguration)
+ : null;
+ this.aiaOcspServiceConfiguration = Objects.requireNonNull(aiaOcspServiceConfiguration, "aiaOcspServiceConfiguration");
+ if (fallbackOcspServiceConfigurations != null) {
+ for (FallbackOcspServiceConfiguration configuration : fallbackOcspServiceConfigurations) {
+ fallbackOcspServiceMap.put(configuration.getIssuerDN(), new FallbackOcspService(configuration));
+ }
+ }
+ }
+
+ /**
+ * 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.
+ *
+ * @param certificate subject certificate that is to be checked with OCSP
+ * @return either the designated or AIA OCSP service instance
+ * @throws UserCertificateOCSPException when the subject certificate's issuer cannot be extracted,
+ * or when the AIA OCSP responder URL cannot be resolved from the certificate
+ * @throws IllegalArgumentException when certificate is invalid
+ */
+ public OcspService getService(X509Certificate certificate) throws UserCertificateOCSPException {
+ try {
+ if (designatedOcspService != null && designatedOcspService.supportsIssuerOf(certificate)) {
+ return designatedOcspService;
+ }
+ X500Name issuerDistinguishedName = getIssuerDistinguishedName(certificate);
+ FallbackOcspService fallbackOcspService = fallbackOcspServiceMap.get(issuerDistinguishedName);
+ return new AiaOcspService(aiaOcspServiceConfiguration, certificate, fallbackOcspService);
+ } catch (CertificateException e) {
+ throw new UserCertificateOCSPException("Resolving OCSP service from subject certificate failed", e);
+ }
+ }
+}
diff --git a/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java b/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java
new file mode 100644
index 00000000..72443688
--- /dev/null
+++ b/src/main/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationChecker.java
@@ -0,0 +1,430 @@
+/*
+ * Copyright (c) 2020-2025 Estonian Information System Authority
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package eu.webeid.resilientocsp;
+
+import eu.webeid.ocsp.OcspCertificateRevocationChecker;
+import eu.webeid.ocsp.client.OcspClient;
+import eu.webeid.ocsp.exceptions.OCSPClientException;
+import eu.webeid.ocsp.exceptions.UserCertificateOCSPCheckFailedException;
+import eu.webeid.ocsp.exceptions.UserCertificateOCSPException;
+import eu.webeid.ocsp.exceptions.UserCertificateRevokedException;
+import eu.webeid.ocsp.service.FallbackOcspService;
+import eu.webeid.ocsp.service.OcspService;
+import eu.webeid.ocsp.service.OcspServiceProvider;
+import eu.webeid.resilientocsp.exceptions.ResilientUserCertificateOCSPCheckFailedException;
+import eu.webeid.resilientocsp.exceptions.ResilientUserCertificateRevokedException;
+import eu.webeid.security.exceptions.AuthTokenException;
+import eu.webeid.security.validator.ValidationInfo;
+import eu.webeid.security.validator.revocationcheck.RevocationInfo;
+import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
+import io.github.resilience4j.circuitbreaker.CircuitBreaker;
+import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
+import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
+import io.github.resilience4j.core.functions.CheckedSupplier;
+import io.github.resilience4j.decorators.Decorators;
+import io.github.resilience4j.retry.Retry;
+import io.github.resilience4j.retry.RetryConfig;
+import io.github.resilience4j.retry.RetryRegistry;
+import io.vavr.control.Try;
+import org.bouncycastle.asn1.ocsp.OCSPResponseStatus;
+import org.bouncycastle.cert.ocsp.BasicOCSPResp;
+import org.bouncycastle.cert.ocsp.CertificateID;
+import org.bouncycastle.cert.ocsp.OCSPReq;
+import org.bouncycastle.cert.ocsp.OCSPResp;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.net.URI;
+import java.security.cert.X509Certificate;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static java.util.Objects.requireNonNull;
+
+/**
+ * OCSP revocation checker that falls back to configured fallback OCSP responders when the primary OCSP service fails.
+ *
+ * Retry and circuit breaker handling are applied only when a fallback OCSP service is configured for the
+ * certificate issuer. If no fallback is configured, validation is handled by the primary OCSP service directly.
+ */
+public class ResilientOcspCertificateRevocationChecker extends OcspCertificateRevocationChecker {
+
+ private static final Logger LOG = LoggerFactory.getLogger(ResilientOcspCertificateRevocationChecker.class);
+
+ private final CircuitBreakerRegistry circuitBreakerRegistry;
+ private final RetryRegistry retryRegistry;
+
+ public ResilientOcspCertificateRevocationChecker(OcspClient ocspClient,
+ OcspServiceProvider ocspServiceProvider,
+ CircuitBreakerConfig circuitBreakerConfig,
+ RetryConfig retryConfig,
+ Duration allowedOcspResponseTimeSkew) {
+ super(ocspClient, ocspServiceProvider, allowedOcspResponseTimeSkew);
+ this.circuitBreakerRegistry = CircuitBreakerRegistry.custom()
+ .withCircuitBreakerConfig(getCircuitBreakerConfig(circuitBreakerConfig))
+ .build();
+ this.retryRegistry = retryConfig != null ? RetryRegistry.custom()
+ .withRetryConfig(getRetryConfig(retryConfig))
+ .build() : null;
+ if (LOG.isDebugEnabled()) {
+ this.circuitBreakerRegistry.getEventPublisher()
+ .onEntryAdded(entryAddedEvent -> {
+ CircuitBreaker circuitBreaker = entryAddedEvent.getAddedEntry();
+ LOG.debug("CircuitBreaker {} added", circuitBreaker.getName());
+ circuitBreaker.getEventPublisher()
+ .onEvent(event -> LOG.debug(event.toString()));
+ });
+ }
+ }
+
+ @Override
+ public List validateCertificateNotRevoked(X509Certificate subjectCertificate,
+ X509Certificate issuerCertificate) throws AuthTokenException {
+ OcspService primaryService = getOcspServiceProvider().getService(subjectCertificate);
+ CertificateID certificateId = getCertificateId(subjectCertificate, issuerCertificate);
+
+ Optional firstFallbackServiceOpt = primaryService.getFallbackService();
+ if (firstFallbackServiceOpt.isEmpty()) {
+ // Without a configured fallback, use the primary service directly without retry or circuit breaker.
+ return List.of(request(primaryService, subjectCertificate, certificateId));
+ }
+
+ CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(primaryService.getAccessLocation().toASCIIString());
+ List revocationInfoList = new ArrayList<>();
+ // Requesting circuit breaker permission may change its state, for example from an expired OPEN
+ // state to HALF_OPEN when automatic transition is disabled (the default). To report the state
+ // that actually governs the request, the snapshot is captured inside the decorated call chain
+ // rather than here.
+ CircuitBreakerStatisticsSnapshot statisticsSnapshot = new CircuitBreakerStatisticsSnapshot(circuitBreaker);
+ CheckedSupplier fallbackSupplier = buildFallbackSupplier(firstFallbackServiceOpt.get(), subjectCertificate,
+ certificateId, revocationInfoList);
+ CheckedSupplier decoratedSupplier = decorateWithResilience(primaryService, subjectCertificate,
+ certificateId, revocationInfoList, fallbackSupplier, circuitBreaker, statisticsSnapshot);
+
+ Try result = Try.of(decoratedSupplier::get);
+ RevocationInfo revocationInfo = processResult(result, subjectCertificate, revocationInfoList,
+ statisticsSnapshot.get());
+ revocationInfoList.add(revocationInfo);
+ return revocationInfoList;
+ }
+
+ private CircuitBreakerStatistics createCircuitBreakerStatistics(CircuitBreaker circuitBreaker) {
+ CircuitBreaker.Metrics metrics = circuitBreaker.getMetrics();
+ return new CircuitBreakerStatistics(
+ circuitBreaker.getState(),
+ metrics.getFailureRate(),
+ metrics.getSlowCallRate(),
+ metrics.getNumberOfSlowCalls(),
+ metrics.getNumberOfSlowSuccessfulCalls(),
+ metrics.getNumberOfSlowFailedCalls(),
+ metrics.getNumberOfBufferedCalls(),
+ metrics.getNumberOfFailedCalls(),
+ metrics.getNumberOfNotPermittedCalls(),
+ metrics.getNumberOfSuccessfulCalls()
+ );
+ }
+
+ private CheckedSupplier buildFallbackSupplier(FallbackOcspService firstFallbackService,
+ X509Certificate subjectCertificate,
+ CertificateID certificateId,
+ List revocationInfoList) {
+ CheckedSupplier firstFallbackSupplier = () -> {
+ try {
+ return request(firstFallbackService, subjectCertificate, certificateId);
+ } catch (Exception e) {
+ createAndAddRevocationInfoToList(e, revocationInfoList);
+ throw e;
+ }
+ };
+ // NOTE: Up to two fallbacks are currently supported. To enable the full potential of recursive fallbacks
+ // with FallbackOcspService#getNextFallback, the fallback supplier creation needs to be changed.
+ OcspService secondFallbackService = firstFallbackService.getNextFallback();
+ if (secondFallbackService == null) {
+ return firstFallbackSupplier;
+ }
+ CheckedSupplier secondFallbackSupplier = () -> {
+ try {
+ return request(secondFallbackService, subjectCertificate, certificateId);
+ } catch (Exception e) {
+ createAndAddRevocationInfoToList(e, revocationInfoList);
+ throw e;
+ }
+ };
+ return () -> {
+ try {
+ return firstFallbackSupplier.get();
+ } catch (ResilientUserCertificateRevokedException e) {
+ // NOTE: ResilientUserCertificateRevokedException must be re-thrown before the generic
+ // catch (Exception) block. Without this, a "revoked" verdict from the first fallback would
+ // be swallowed, and the second fallback could silently override it with a "good" response.
+ throw e;
+ } catch (Exception e) {
+ return secondFallbackSupplier.get();
+ }
+ };
+ }
+
+ private CheckedSupplier decorateWithResilience(OcspService primaryService,
+ X509Certificate subjectCertificate,
+ CertificateID certificateId,
+ List revocationInfoList,
+ CheckedSupplier fallbackSupplier,
+ CircuitBreaker circuitBreaker,
+ CircuitBreakerStatisticsSnapshot statisticsSnapshot) {
+ CheckedSupplier primarySupplier = () -> {
+ // The circuit breaker has just permitted this call, so its current state is the one that
+ // governs the request.
+ statisticsSnapshot.capture();
+ try {
+ return request(primaryService, subjectCertificate, certificateId);
+ } catch (Exception e) {
+ createAndAddRevocationInfoToList(e, revocationInfoList);
+ throw e;
+ }
+ };
+ Decorators.DecorateCheckedSupplier decorateCheckedSupplier = Decorators.ofCheckedSupplier(primarySupplier);
+ if (retryRegistry != null) {
+ Retry retry = retryRegistry.retry(primaryService.getAccessLocation().toASCIIString());
+ decorateCheckedSupplier.withRetry(retry);
+ }
+ decorateCheckedSupplier.withCircuitBreaker(circuitBreaker)
+ .withFallback(List.of(ResilientUserCertificateOCSPCheckFailedException.class, CallNotPermittedException.class), e -> {
+ // No-op if the primary request ran; otherwise the circuit breaker rejected the call and
+ // this captures the rejecting state before the fallback request starts.
+ statisticsSnapshot.capture();
+ return fallbackSupplier.get();
+ });
+
+ return decorateCheckedSupplier.decorate();
+ }
+
+ private RevocationInfo processResult(Try result, X509Certificate subjectCertificate,
+ List revocationInfoList,
+ CircuitBreakerStatistics circuitBreakerStatistics) throws AuthTokenException {
+ if (result.isSuccess()) {
+ RevocationInfo revocationInfo = result.get();
+ if (revocationInfoList.isEmpty()) {
+ revocationInfo = withCircuitBreakerStatistics(revocationInfo, circuitBreakerStatistics);
+ } else {
+ addCircuitBreakerStatistics(revocationInfoList, circuitBreakerStatistics);
+ }
+ return revocationInfo;
+ }
+ addCircuitBreakerStatistics(revocationInfoList, circuitBreakerStatistics);
+ Throwable throwable = result.getCause();
+ if (throwable instanceof ResilientUserCertificateOCSPCheckFailedException exception) {
+ exception.setValidationInfo(new ValidationInfo(subjectCertificate, revocationInfoList));
+ throw exception;
+ }
+ if (throwable instanceof ResilientUserCertificateRevokedException exception) {
+ exception.setValidationInfo(new ValidationInfo(subjectCertificate, revocationInfoList));
+ throw exception;
+ }
+ throw new ResilientUserCertificateOCSPCheckFailedException(new ValidationInfo(subjectCertificate, revocationInfoList));
+ }
+
+ private void addCircuitBreakerStatistics(List revocationInfoList,
+ CircuitBreakerStatistics circuitBreakerStatistics) {
+ revocationInfoList.set(0, withCircuitBreakerStatistics(revocationInfoList.get(0), circuitBreakerStatistics));
+ }
+
+
+ private void createAndAddRevocationInfoToList(Throwable throwable, List revocationInfoList) {
+ if (throwable instanceof ResilientUserCertificateOCSPCheckFailedException exception) {
+ revocationInfoList.addAll((exception.getValidationInfo().revocationInfoList()));
+ return;
+ }
+ if (throwable instanceof ResilientUserCertificateRevokedException exception) {
+ revocationInfoList.addAll((exception.getValidationInfo().revocationInfoList()));
+ return;
+ }
+ revocationInfoList.add(new RevocationInfo(null, new HashMap<>(Map.ofEntries(
+ Map.entry(RevocationInfo.KEY_OCSP_ERROR, throwable)
+ ))));
+ }
+
+ private RevocationInfo request(OcspService ocspService, X509Certificate subjectCertificate, CertificateID certificateId) throws UserCertificateOCSPCheckFailedException, ResilientUserCertificateRevokedException, UserCertificateOCSPException {
+ final URI ocspResponderUri = ocspService.getAccessLocation();
+ final OCSPReq request = getOcspRequest(certificateId, ocspService);
+
+ if (!ocspService.doesSupportNonce()) {
+ LOG.debug("Disabling OCSP nonce extension");
+ }
+
+
+ OCSPResp response = null;
+ Duration requestDuration = null;
+ Instant responseTime = null;
+ try {
+ LOG.debug("Sending OCSP request");
+ Instant requestTime = Instant.now();
+ try {
+ response = requireNonNull(getOcspClient().request(ocspResponderUri, request));
+ responseTime = Instant.now();
+ requestDuration = Duration.between(requestTime, responseTime);
+ } catch (OCSPClientException e) {
+ responseTime = Instant.now();
+ requestDuration = Duration.between(requestTime, responseTime);
+ RevocationInfo revocationInfo = getRevocationInfo(ocspResponderUri, e, request, null, requestDuration, responseTime);
+ revocationInfo = withOCSPClientException(revocationInfo, e);
+ throw new ResilientUserCertificateOCSPCheckFailedException(new ValidationInfo(subjectCertificate, List.of(revocationInfo)));
+ }
+ if (response.getStatus() != OCSPResponseStatus.SUCCESSFUL) {
+ throw createException("Response status: " + ocspStatusToString(response.getStatus()),
+ subjectCertificate, ocspResponderUri, request, response, requestDuration, responseTime
+ );
+ }
+
+ final BasicOCSPResp basicResponse = (BasicOCSPResp) response.getResponseObject();
+ if (basicResponse == null) {
+ throw createException("Missing Basic OCSP Response", subjectCertificate,
+ ocspResponderUri, request, response, requestDuration, responseTime
+ );
+ }
+ LOG.debug("OCSP response received successfully");
+
+ verifyOcspResponse(basicResponse, ocspService, certificateId);
+ if (ocspService.doesSupportNonce()) {
+ checkNonce(request, basicResponse, ocspResponderUri);
+ }
+ LOG.debug("OCSP response verified successfully");
+
+ return getRevocationInfo(ocspResponderUri, null, request, response, requestDuration, responseTime);
+ } catch (ResilientUserCertificateOCSPCheckFailedException e) {
+ throw e;
+ } catch (UserCertificateRevokedException e) {
+ // NOTE: unknown status does not throw UserCertificateRevokedException, it throws
+ // UserCertificateOCSPCheckFailedException instead (see OcspResponseValidator.validateSubjectCertificateStatus),
+ // so it falls through to the generic catch (Exception) block below, gets wrapped as
+ // ResilientUserCertificateOCSPCheckFailedException, and triggers the circuit breaker fallback.
+ // Here, wrapping as ResilientUserCertificateRevokedException ensures the circuit breaker ignores it
+ // (a definitive OCSP answer, not a transient failure) and no fallback is attempted.
+ RevocationInfo revocationInfo = getRevocationInfo(ocspResponderUri, e, request, response, requestDuration, responseTime);
+ throw new ResilientUserCertificateRevokedException(new ValidationInfo(subjectCertificate, List.of(revocationInfo)));
+ } catch (Exception e) {
+ RevocationInfo revocationInfo = getRevocationInfo(ocspResponderUri, e, request, response, requestDuration, responseTime);
+ throw new ResilientUserCertificateOCSPCheckFailedException(new ValidationInfo(subjectCertificate, List.of(revocationInfo)));
+ }
+ }
+
+
+ private ResilientUserCertificateOCSPCheckFailedException createException(String message, X509Certificate subjectCertificate,
+ URI ocspResponderUri, OCSPReq request, OCSPResp response,
+ Duration requestDuration, Instant responseTime) throws ResilientUserCertificateOCSPCheckFailedException {
+ ResilientUserCertificateOCSPCheckFailedException exception = new ResilientUserCertificateOCSPCheckFailedException(message);
+ RevocationInfo revocationInfo = getRevocationInfo(ocspResponderUri, exception, request, response, requestDuration, responseTime);
+ exception.setValidationInfo(new ValidationInfo(subjectCertificate, List.of(revocationInfo)));
+ return exception;
+ }
+
+ private RevocationInfo getRevocationInfo(URI ocspResponderUri, Exception e, OCSPReq request, OCSPResp response,
+ Duration requestDuration, Instant end) {
+ Map ocspResponseAttributes = new HashMap<>();
+ if (e != null) {
+ ocspResponseAttributes.put(RevocationInfo.KEY_OCSP_ERROR, e);
+ }
+ if (request != null) {
+ ocspResponseAttributes.put(RevocationInfo.KEY_OCSP_REQUEST, request);
+ }
+ if (response != null) {
+ ocspResponseAttributes.put(RevocationInfo.KEY_OCSP_RESPONSE, response);
+ }
+ if (requestDuration != null) {
+ ocspResponseAttributes.put(RevocationInfo.KEY_REQUEST_DURATION, requestDuration);
+ }
+ if (end != null) {
+ ocspResponseAttributes.put(RevocationInfo.KEY_OCSP_RESPONSE_TIME, end);
+ }
+ return new RevocationInfo(ocspResponderUri, ocspResponseAttributes);
+ }
+
+ private static CircuitBreakerConfig getCircuitBreakerConfig(CircuitBreakerConfig circuitBreakerConfig) {
+ return CircuitBreakerConfig.from(circuitBreakerConfig)
+ // Users must not be able to modify this value.
+ // Only ResilientUserCertificateOCSPCheckFailedException counts as a failure.
+ // ResilientUserCertificateRevokedException is counted as a SUCCESS because it represents
+ // a definitive OCSP answer (the service is healthy), not a transient failure.
+ // Clear any recordExceptions list of the given configuration first, because it is combined
+ // with the predicate below by OR and would otherwise widen what counts as a failure.
+ .recordExceptions()
+ .recordException(throwable -> throwable instanceof ResilientUserCertificateOCSPCheckFailedException)
+ .build();
+ }
+
+ private static RetryConfig getRetryConfig(RetryConfig retryConfig) {
+ return RetryConfig.from(retryConfig)
+ // Users must not be able to modify this value.
+ .ignoreExceptions(ResilientUserCertificateRevokedException.class)
+ .build();
+ }
+
+ private static RevocationInfo withCircuitBreakerStatistics(RevocationInfo revocationInfo, CircuitBreakerStatistics circuitBreakerStatistics) {
+ return revocationInfo.withAdditionalOcspResponseAttribute(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS, circuitBreakerStatistics);
+ }
+
+ private static RevocationInfo withOCSPClientException(RevocationInfo revocationInfo, OCSPClientException e) {
+ return revocationInfo
+ .withAdditionalOcspResponseAttribute(RevocationInfo.KEY_OCSP_RESPONSE, e.getResponseBody())
+ .withAdditionalOcspResponseAttribute(RevocationInfo.KEY_HTTP_STATUS_CODE, e.getStatusCode());
+ }
+
+ private final class CircuitBreakerStatisticsSnapshot {
+
+ private final CircuitBreaker circuitBreaker;
+ private CircuitBreakerStatistics statistics;
+
+ private CircuitBreakerStatisticsSnapshot(CircuitBreaker circuitBreaker) {
+ this.circuitBreaker = circuitBreaker;
+ }
+
+ private void capture() {
+ if (statistics == null) {
+ statistics = createCircuitBreakerStatistics(circuitBreaker);
+ }
+ }
+
+ private CircuitBreakerStatistics get() {
+ // Safety net: if neither the primary supplier nor the fallback ran, capture the statistics now.
+ capture();
+ return statistics;
+ }
+ }
+
+ public record CircuitBreakerStatistics(
+ CircuitBreaker.State state,
+ float failureRate,
+ float slowCallRate,
+ int numberOfSlowCalls,
+ int numberOfSlowSuccessfulCalls,
+ int numberOfSlowFailedCalls,
+ int numberOfBufferedCalls,
+ int numberOfFailedCalls,
+ long numberOfNotPermittedCalls,
+ int numberOfSuccessfulCalls
+ ) {}
+}
diff --git a/src/main/java/eu/webeid/resilientocsp/exceptions/ResilientUserCertificateOCSPCheckFailedException.java b/src/main/java/eu/webeid/resilientocsp/exceptions/ResilientUserCertificateOCSPCheckFailedException.java
new file mode 100644
index 00000000..159de9c8
--- /dev/null
+++ b/src/main/java/eu/webeid/resilientocsp/exceptions/ResilientUserCertificateOCSPCheckFailedException.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) 2020-2025 Estonian Information System Authority
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package eu.webeid.resilientocsp.exceptions;
+
+import eu.webeid.ocsp.exceptions.UserCertificateOCSPCheckFailedException;
+import eu.webeid.security.validator.ValidationInfo;
+
+public class ResilientUserCertificateOCSPCheckFailedException extends UserCertificateOCSPCheckFailedException {
+
+ private ValidationInfo validationInfo;
+
+ public ResilientUserCertificateOCSPCheckFailedException(String message) {
+ this(message, null);
+ }
+
+ public ResilientUserCertificateOCSPCheckFailedException(ValidationInfo validationInfo) {
+ super();
+ this.validationInfo = validationInfo;
+ }
+
+ public ResilientUserCertificateOCSPCheckFailedException(String message, ValidationInfo validationInfo) {
+ super(message);
+ this.validationInfo = validationInfo;
+ }
+
+ public ValidationInfo getValidationInfo() {
+ return validationInfo;
+ }
+
+ public void setValidationInfo(ValidationInfo validationInfo) {
+ this.validationInfo = validationInfo;
+ }
+}
diff --git a/src/main/java/eu/webeid/resilientocsp/exceptions/ResilientUserCertificateRevokedException.java b/src/main/java/eu/webeid/resilientocsp/exceptions/ResilientUserCertificateRevokedException.java
new file mode 100644
index 00000000..27ec8f4e
--- /dev/null
+++ b/src/main/java/eu/webeid/resilientocsp/exceptions/ResilientUserCertificateRevokedException.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2020-2025 Estonian Information System Authority
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package eu.webeid.resilientocsp.exceptions;
+
+import eu.webeid.ocsp.exceptions.UserCertificateRevokedException;
+import eu.webeid.security.validator.ValidationInfo;
+
+public class ResilientUserCertificateRevokedException extends UserCertificateRevokedException {
+
+ private ValidationInfo validationInfo;
+
+ public ResilientUserCertificateRevokedException(ValidationInfo validationInfo) {
+ this.validationInfo = validationInfo;
+ }
+
+ public ValidationInfo getValidationInfo() {
+ return validationInfo;
+ }
+
+ public void setValidationInfo(ValidationInfo validationInfo) {
+ this.validationInfo = validationInfo;
+ }
+}
diff --git a/src/main/java/eu/webeid/security/authtoken/WebEidAuthToken.java b/src/main/java/eu/webeid/security/authtoken/WebEidAuthToken.java
index f3139017..7dc80a62 100644
--- a/src/main/java/eu/webeid/security/authtoken/WebEidAuthToken.java
+++ b/src/main/java/eu/webeid/security/authtoken/WebEidAuthToken.java
@@ -6,43 +6,10 @@
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
@JsonIgnoreProperties(ignoreUnknown = true)
-public class WebEidAuthToken {
-
- private String unverifiedCertificate;
- private String signature;
- private String algorithm;
- private String format;
-
- public String getUnverifiedCertificate() {
- return unverifiedCertificate;
- }
-
- public void setUnverifiedCertificate(String unverifiedCertificate) {
- this.unverifiedCertificate = unverifiedCertificate;
- }
-
- public String getSignature() {
- return signature;
- }
-
- public void setSignature(String signature) {
- this.signature = signature;
- }
-
- public String getAlgorithm() {
- return algorithm;
- }
-
- public void setAlgorithm(String algorithm) {
- this.algorithm = algorithm;
- }
-
- public String getFormat() {
- return format;
- }
-
- public void setFormat(String format) {
- this.format = format;
- }
-
+public record WebEidAuthToken(
+ String unverifiedCertificate,
+ String signature,
+ String algorithm,
+ String format
+) {
}
diff --git a/src/main/java/eu/webeid/security/certificate/CertificateLoader.java b/src/main/java/eu/webeid/security/certificate/CertificateLoader.java
index b9a2576f..4546842a 100644
--- a/src/main/java/eu/webeid/security/certificate/CertificateLoader.java
+++ b/src/main/java/eu/webeid/security/certificate/CertificateLoader.java
@@ -30,7 +30,7 @@ public static X509Certificate[] loadCertificatesFromResources(String... resource
}
}
- return caCertificates.toArray(new X509Certificate[0]);
+ return caCertificates.toArray(X509Certificate[]::new);
}
public static X509Certificate decodeCertificateFromBase64(String certificateInBase64) throws CertificateDecodingException {
diff --git a/src/main/java/eu/webeid/security/certificate/CertificateValidator.java b/src/main/java/eu/webeid/security/certificate/CertificateValidator.java
index f479128d..72d5bb86 100644
--- a/src/main/java/eu/webeid/security/certificate/CertificateValidator.java
+++ b/src/main/java/eu/webeid/security/certificate/CertificateValidator.java
@@ -3,31 +3,46 @@
package eu.webeid.security.certificate;
+import eu.webeid.security.exceptions.AuthTokenException;
import eu.webeid.security.exceptions.CertificateExpiredException;
import eu.webeid.security.exceptions.CertificateNotTrustedException;
import eu.webeid.security.exceptions.CertificateNotYetValidException;
+import eu.webeid.security.exceptions.CertificateRevocationCheckFailedException;
+import eu.webeid.security.exceptions.CertificateRevokedException;
import eu.webeid.security.exceptions.JceException;
+import eu.webeid.security.validator.revocationcheck.CertificateRevocationChecker;
+import eu.webeid.security.validator.revocationcheck.RevocationInfo;
+import eu.webeid.security.validator.revocationcheck.RevocationMode;
import java.security.GeneralSecurityException;
import java.security.InvalidAlgorithmParameterException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.CertPathBuilder;
import java.security.cert.CertPathBuilderException;
+import java.security.cert.CertPathValidator;
+import java.security.cert.CertPathValidatorException;
import java.security.cert.CertStore;
import java.security.cert.CollectionCertStoreParameters;
import java.security.cert.PKIXBuilderParameters;
import java.security.cert.PKIXCertPathBuilderResult;
+import java.security.cert.PKIXRevocationChecker;
import java.security.cert.TrustAnchor;
import java.security.cert.X509CertSelector;
import java.security.cert.X509Certificate;
import java.util.Collection;
import java.util.Date;
+import java.util.EnumSet;
+import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
+import static java.util.Objects.requireNonNull;
+
public final class CertificateValidator {
- public static void certificateIsValidOnDate(X509Certificate cert, Date date, String subject) throws CertificateNotYetValidException, CertificateExpiredException {
+ private static final String JDK_OCSP_NONCE_PROPERTY = "jdk.security.certpath.ocspNonce";
+
+ public static void requireCertificateIsValidOnDate(X509Certificate cert, Date date, String subject) throws CertificateNotYetValidException, CertificateExpiredException {
try {
cert.checkValidity(date);
} catch (java.security.cert.CertificateNotYetValidException e) {
@@ -37,32 +52,165 @@ public static void certificateIsValidOnDate(X509Certificate cert, Date date, Str
}
}
- public static X509Certificate validateIsSignedByTrustedCA(X509Certificate certificate,
- Set trustedCACertificateAnchors,
- CertStore trustedCACertificateCertStore,
- Date now) throws CertificateNotTrustedException, JceException, CertificateNotYetValidException, CertificateExpiredException {
- certificateIsValidOnDate(certificate, now, "User");
+ /**
+ * Validates that the provided {@code certificate} is trusted and performs certificate revocation checking
+ * depending on {@code revocationMode}.
+ *
+ * Trust validation is performed by building a certification path from {@code certificate} to one of the
+ * configured {@code trustedCACertificateAnchors} using the supplied {@code trustedCACertificateCertStore}.
+ * The effective validation time is {@code now}. In addition, the trust anchor certificate's validity period
+ * is explicitly validated.
+ * It is assumed that each configured trust anchor represents the intermediate certificate authority that directly
+ * issued the subject certificate.
+ *
+ * Revocation behavior is controlled by {@code revocationMode}:
+ *
+ * - {@link RevocationMode#DISABLED} - no revocation checking is performed. Both
+ * {@code certificateRevocationChecker} and {@code customPkixRevocationChecker} must be {@code null}.
+ * - {@link RevocationMode#CUSTOM_CHECKER} - revocation is checked by the provided
+ * {@code certificateRevocationChecker}. Platform (provider default) revocation checking is disabled.
+ * {@code customPkixRevocationChecker} must be {@code null}.
+ * - {@link RevocationMode#CUSTOM_PKIX} - revocation is checked by the provided
+ * {@code customPkixRevocationChecker} installed as the (only) PKIX cert path checker during a separate
+ * validation pass. Provider default revocation checking is disabled.
+ * {@code certificateRevocationChecker} must be {@code null}.
+ * - {@link RevocationMode#PLATFORM_OCSP} - revocation is checked using the platform PKIX revocation checker
+ * configured to enforce OCSP checking for the subject certificate with no fallback to CRLs
+ * ({@link PKIXRevocationChecker.Option#ONLY_END_ENTITY} and {@link PKIXRevocationChecker.Option#NO_FALLBACK}).
+ * When {@code platformOcspNonceEnabled} is true, a fresh OCSP nonce is included in each request.
+ * An explicitly set {@code jdk.security.certpath.ocspNonce} JVM property takes precedence over this setting
+ * and leaves nonce handling to the JDK. The JDK provider remains responsible for
+ * response nonce validation and may accept a response without one.
+ * Provider default revocation checking is disabled. Both custom checker parameters must be {@code null}.
+ *
+ *
+ * @param certificate the subject certificate to validate
+ * @param trustedCACertificateAnchors trust anchors used for PKIX path building (Web eID typically configures issuing intermediates)
+ * @param trustedCACertificateCertStore certificate store containing trusted CA/intermediate certificates used during path building
+ * @param now validation time used for certificate validity and PKIX path building
+ * @param revocationMode revocation checking mode
+ * @param certificateRevocationChecker custom certificate revocation checker (required only for {@code CUSTOM_CHECKER})
+ * @param customPkixRevocationChecker custom PKIX revocation checker (required only for {@code CUSTOM_PKIX})
+ * @param platformOcspNonceEnabled whether to supply a fresh OCSP nonce when the JVM nonce property is unset;
+ * ignored in other revocation modes
+ * @return a list of {@link RevocationInfo} objects; the list is non-null and may be empty.
+ * It is populated for {@link RevocationMode#CUSTOM_CHECKER}, and may be populated for
+ * {@link RevocationMode#CUSTOM_PKIX} when the provided {@code customPkixRevocationChecker}
+ * has an explicit OCSP responder URI configured; otherwise it is empty.
+ *
+ * @throws NullPointerException if any required parameter is {@code null}
+ * @throws IllegalArgumentException if the supplied checker parameters are inconsistent with {@code revocationMode}
+ * @throws CertificateNotYetValidException if the subject or trust anchor certificate is not yet valid at {@code now}
+ * @throws CertificateExpiredException if the subject or trust anchor certificate is expired at {@code now}
+ * @throws CertificateNotTrustedException if no valid certification path can be built to the configured trust anchors
+ * @throws CertificateRevokedException if a PKIX revocation checker reports that the subject certificate is revoked
+ * @throws CertificateRevocationCheckFailedException if a PKIX revocation checker cannot determine the revocation status
+ * @throws JceException if the underlying JCA/JCE implementation fails unexpectedly
+ * @throws AuthTokenException if a custom revocation checker fails or reports the certificate as revoked
+ */
+ public static List validateCertificateTrustAndRevocation(X509Certificate certificate,
+ Set trustedCACertificateAnchors,
+ CertStore trustedCACertificateCertStore,
+ Date now,
+ RevocationMode revocationMode,
+ CertificateRevocationChecker certificateRevocationChecker,
+ PKIXRevocationChecker customPkixRevocationChecker,
+ boolean platformOcspNonceEnabled) throws AuthTokenException {
+
+ requireNonNull(certificate, "certificate");
+ requireNonNull(trustedCACertificateAnchors, "trustedCACertificateAnchors");
+ requireNonNull(trustedCACertificateCertStore, "trustedCACertificateCertStore");
+ requireNonNull(now, "now");
+ requireNonNull(revocationMode, "revocationMode");
+
+ requireCertificateIsValidOnDate(certificate, now, "User");
final X509CertSelector selector = new X509CertSelector();
selector.setCertificate(certificate);
try {
final PKIXBuilderParameters pkixBuilderParameters = new PKIXBuilderParameters(trustedCACertificateAnchors, selector);
- // Certificate revocation check is intentionally disabled as we do the OCSP check with SubjectCertificateNotRevokedValidator ourselves.
- pkixBuilderParameters.setRevocationEnabled(false);
pkixBuilderParameters.setDate(now);
pkixBuilderParameters.addCertStore(trustedCACertificateCertStore);
+ List revocationInfoList = List.of();
+ PKIXRevocationChecker pkixRevocationChecker = null;
+
+ switch (revocationMode) {
+ case DISABLED -> {
+ if (customPkixRevocationChecker != null || certificateRevocationChecker != null) {
+ throw new IllegalArgumentException("customPkixRevocationChecker and certificateRevocationChecker must be null when revocationMode is DISABLED");
+ }
+ }
+
+ case CUSTOM_CHECKER -> {
+ if (customPkixRevocationChecker != null) {
+ throw new IllegalArgumentException("customPkixRevocationChecker must be null when revocationMode is CUSTOM_CHECKER");
+ }
+ if (certificateRevocationChecker == null) {
+ throw new IllegalArgumentException("certificateRevocationChecker must be provided when revocationMode is CUSTOM_CHECKER");
+ }
+ }
+
+ case CUSTOM_PKIX -> {
+ if (certificateRevocationChecker != null) {
+ throw new IllegalArgumentException("certificateRevocationChecker must be null when revocationMode is CUSTOM_PKIX");
+ }
+ if (customPkixRevocationChecker == null) {
+ throw new IllegalArgumentException("customPkixRevocationChecker must be provided when revocationMode is CUSTOM_PKIX");
+ }
+ pkixRevocationChecker = customPkixRevocationChecker;
+
+ if (customPkixRevocationChecker.getOcspResponder() != null) {
+ revocationInfoList = List.of(new RevocationInfo(customPkixRevocationChecker.getOcspResponder(), null));
+ }
+ }
+
+ case PLATFORM_OCSP -> {
+ if (customPkixRevocationChecker != null || certificateRevocationChecker != null) {
+ throw new IllegalArgumentException("customPkixRevocationChecker and certificateRevocationChecker must be null when revocationMode is PLATFORM_OCSP");
+ }
+
+ pkixRevocationChecker = buildOcspEnforcedPkixRevocationChecker(platformOcspNonceEnabled);
+ }
+
+ default -> throw new IllegalStateException("Unhandled revocationMode: " + revocationMode);
+ }
+
+ // Build the trusted path without revocation checking. Revocation is validated separately below so that
+ // CertPathValidatorException and its structured reason are not hidden by CertPathBuilderException.
+ pkixBuilderParameters.setRevocationEnabled(false);
// See the comment in buildCertStoreFromCertificates() below why we use the default JCE provider.
final CertPathBuilder certPathBuilder = CertPathBuilder.getInstance(CertPathBuilder.getDefaultType());
final PKIXCertPathBuilderResult result = (PKIXCertPathBuilderResult) certPathBuilder.build(pkixBuilderParameters);
final X509Certificate trustedCACert = result.getTrustAnchor().getTrustedCert();
+ if (trustedCACert == null) {
+ throw new IllegalStateException("TrustAnchor.getTrustedCert() returned null, it must contain a trusted certificate");
+ }
+
+ // PKIX path building does not validate trust anchor validity period, do it ourselves.
+ requireCertificateIsValidOnDate(trustedCACert, now, "Trusted CA");
- // Verify that the trusted CA cert is presently valid before returning the result.
- certificateIsValidOnDate(trustedCACert, now, "Trusted CA");
+ if (pkixRevocationChecker != null) {
+ validateRevocation(
+ result,
+ pkixBuilderParameters,
+ pkixRevocationChecker,
+ certificate
+ );
+ } else if (revocationMode == RevocationMode.CUSTOM_CHECKER) {
+ if (!certificate.getIssuerX500Principal().equals(trustedCACert.getSubjectX500Principal())) {
+ throw new IllegalStateException(
+ "Trust anchor is not the issuer of the subject certificate, check your configured certificate authorities. " +
+ "Subject issuer=" + certificate.getIssuerX500Principal() +
+ ", trust anchor subject=" + trustedCACert.getSubjectX500Principal()
+ );
+ }
+ revocationInfoList = certificateRevocationChecker.validateCertificateNotRevoked(certificate, trustedCACert);
+ }
- return trustedCACert;
+ return revocationInfoList;
} catch (InvalidAlgorithmParameterException | NoSuchAlgorithmException e) {
throw new JceException(e);
@@ -76,8 +224,33 @@ public static Set buildTrustAnchorsFromCertificates(Collection new TrustAnchor(cert, null)).collect(Collectors.toUnmodifiableSet());
}
+ private static void validateRevocation(PKIXCertPathBuilderResult pathBuilderResult,
+ PKIXBuilderParameters pkixBuilderParameters,
+ PKIXRevocationChecker revocationChecker,
+ X509Certificate certificate)
+ throws AuthTokenException, InvalidAlgorithmParameterException, NoSuchAlgorithmException {
+ // Reuse the parameters, restrict validation to the trust anchor selected during path building.
+ pkixBuilderParameters.setTrustAnchors(Set.of(pathBuilderResult.getTrustAnchor()));
+ // Provider-default revocation remains disabled; adding an explicit checker is sufficient for it to run.
+ pkixBuilderParameters.setCertPathCheckers(List.of(revocationChecker));
+
+ final CertPathValidator certPathValidator = CertPathValidator.getInstance(CertPathValidator.getDefaultType());
+ try {
+ certPathValidator.validate(pathBuilderResult.getCertPath(), pkixBuilderParameters);
+ } catch (CertPathValidatorException e) {
+ if (e.getReason() == CertPathValidatorException.BasicReason.REVOKED) {
+ throw new CertificateRevokedException(certificate, e);
+ }
+ if (e.getReason() == CertPathValidatorException.BasicReason.UNDETERMINED_REVOCATION_STATUS ||
+ e.getReason() == CertPathValidatorException.BasicReason.UNSPECIFIED) {
+ throw new CertificateRevocationCheckFailedException(certificate, e);
+ }
+ throw new CertificateNotTrustedException(certificate, e);
+ }
+ }
+
public static CertStore buildCertStoreFromCertificates(Collection certificates) throws JceException {
- // We use the default JCE provider as there is no reason to use Bouncy Castle, moreover BC requires
+ // Use the default JCE provider as there is no reason to use Bouncy Castle, moreover BC requires
// the validated certificate to be in the certificate store which breaks the clean immutable usage of
// trustedCACertificateCertStore in SubjectCertificateTrustedValidator.
try {
@@ -87,6 +260,29 @@ public static CertStore buildCertStoreFromCertificates(Collection Set newHashSet(T... elements) {
- final Set set = new HashSet<>();
- java.util.Collections.addAll(set, elements);
- return set;
- }
-
- public static byte[] concat(byte[] first, byte[] second) {
- byte[] result = Arrays.copyOf(first, first.length + second.length);
- System.arraycopy(second, 0, result, first.length, second.length);
- return result;
- }
-
- private Collections() {
- throw new IllegalStateException("Utility class");
- }
-
-}
diff --git a/src/main/java/eu/webeid/security/util/DateAndTime.java b/src/main/java/eu/webeid/security/util/DateAndTime.java
index 85eac7dd..5edf6610 100644
--- a/src/main/java/eu/webeid/security/util/DateAndTime.java
+++ b/src/main/java/eu/webeid/security/util/DateAndTime.java
@@ -17,11 +17,12 @@ public static ZonedDateTime utcNow() {
return ZonedDateTime.now(ZoneOffset.UTC);
}
- public static void requirePositiveDuration(Duration duration, String fieldName) {
+ public static Duration requirePositiveDuration(Duration duration, String fieldName) {
Objects.requireNonNull(duration, fieldName + " must not be null");
if (duration.isNegative() || duration.isZero()) {
throw new IllegalArgumentException(fieldName + " must be greater than zero");
}
+ return duration;
}
public static class DefaultClock implements Clock {
diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenSignatureValidator.java b/src/main/java/eu/webeid/security/validator/AuthTokenSignatureValidator.java
index 5e813552..362c3555 100644
--- a/src/main/java/eu/webeid/security/validator/AuthTokenSignatureValidator.java
+++ b/src/main/java/eu/webeid/security/validator/AuthTokenSignatureValidator.java
@@ -7,9 +7,11 @@
import eu.webeid.security.exceptions.AuthTokenParseException;
import eu.webeid.security.exceptions.AuthTokenSignatureValidationException;
import eu.webeid.security.exceptions.ChallengeNullOrEmptyException;
+import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.impl.security.DefaultVerifySecureDigestRequest;
import io.jsonwebtoken.security.SignatureAlgorithm;
+import io.jsonwebtoken.security.SignatureException;
import io.jsonwebtoken.security.VerifySecureDigestRequest;
import java.io.ByteArrayInputStream;
@@ -19,23 +21,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 +70,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,7 +89,13 @@ public void validate(String algorithm, String signature, PublicKey publicKey, St
new ByteArrayInputStream(concatSignedFields),
null, null,
publicKey, decodedSignature);
- if (!signatureAlgorithm.verify(verificationRequest)) {
+ final boolean signatureIsValid;
+ try {
+ signatureIsValid = signatureAlgorithm.verify(verificationRequest);
+ } catch (JwtException e) {
+ throw new AuthTokenSignatureValidationException(e);
+ }
+ if (!signatureIsValid) {
throw new AuthTokenSignatureValidationException();
}
}
@@ -99,4 +110,10 @@ private void requireNotEmpty(String argument, String fieldName) throws AuthToken
}
}
+ private static byte[] concat(byte[] first, byte[] second) {
+ byte[] result = Arrays.copyOf(first, first.length + second.length);
+ System.arraycopy(second, 0, result, first.length, second.length);
+ return result;
+ }
+
}
diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java
index 596ff3c0..158d1860 100644
--- a/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java
+++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java
@@ -4,22 +4,19 @@
package eu.webeid.security.validator;
import eu.webeid.security.certificate.SubjectCertificatePolicies;
-import eu.webeid.security.validator.ocsp.service.DesignatedOcspServiceConfiguration;
+import eu.webeid.security.validator.revocationcheck.CertificateRevocationChecker;
+import eu.webeid.security.validator.revocationcheck.RevocationMode;
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
+import java.security.cert.PKIXRevocationChecker;
import java.security.cert.X509Certificate;
-import java.time.Duration;
import java.util.Collection;
import java.util.HashSet;
-import java.util.Objects;
import java.util.Set;
-import static eu.webeid.security.util.Collections.newHashSet;
-import static eu.webeid.security.util.DateAndTime.requirePositiveDuration;
-
/**
* Stores configuration parameters for {@link AuthTokenValidatorImpl}.
*/
@@ -27,19 +24,18 @@ public final class AuthTokenValidationConfiguration {
private URI siteOrigin;
private Collection trustedCACertificates = new HashSet<>();
- private boolean isUserCertificateRevocationCheckWithOcspEnabled = true;
- private Duration ocspRequestTimeout = Duration.ofSeconds(5);
- private Duration allowedOcspResponseTimeSkew = Duration.ofMinutes(15);
- private Duration maxOcspResponseThisUpdateAge = Duration.ofMinutes(2);
- private DesignatedOcspServiceConfiguration designatedOcspServiceConfiguration;
// Don't allow Estonian Mobile-ID policy by default.
- private Collection disallowedSubjectCertificatePolicies = newHashSet(
+ private Collection disallowedSubjectCertificatePolicies = new HashSet<>(Set.of(
SubjectCertificatePolicies.ESTEID_SK_2015_MOBILE_ID_POLICY_V1,
SubjectCertificatePolicies.ESTEID_SK_2015_MOBILE_ID_POLICY_V2,
SubjectCertificatePolicies.ESTEID_SK_2015_MOBILE_ID_POLICY_V3,
SubjectCertificatePolicies.ESTEID_SK_2015_MOBILE_ID_POLICY
- );
- private Collection nonceDisabledOcspUrls = new HashSet<>();
+ ));
+ private boolean isUserCertificateRevocationCheckEnabled = true;
+ private boolean platformOcspNonceEnabled = true;
+ private CertificateRevocationChecker certificateRevocationChecker;
+ private PKIXRevocationChecker pkixRevocationChecker;
+ private RevocationMode revocationMode = RevocationMode.PLATFORM_OCSP;
AuthTokenValidationConfiguration() {
}
@@ -47,13 +43,12 @@ public final class AuthTokenValidationConfiguration {
private AuthTokenValidationConfiguration(AuthTokenValidationConfiguration other) {
this.siteOrigin = other.siteOrigin;
this.trustedCACertificates = Set.copyOf(other.trustedCACertificates);
- this.isUserCertificateRevocationCheckWithOcspEnabled = other.isUserCertificateRevocationCheckWithOcspEnabled;
- this.ocspRequestTimeout = other.ocspRequestTimeout;
- this.allowedOcspResponseTimeSkew = other.allowedOcspResponseTimeSkew;
- this.maxOcspResponseThisUpdateAge = other.maxOcspResponseThisUpdateAge;
- this.designatedOcspServiceConfiguration = other.designatedOcspServiceConfiguration;
this.disallowedSubjectCertificatePolicies = Set.copyOf(other.disallowedSubjectCertificatePolicies);
- this.nonceDisabledOcspUrls = Set.copyOf(other.nonceDisabledOcspUrls);
+ this.isUserCertificateRevocationCheckEnabled = other.isUserCertificateRevocationCheckEnabled;
+ this.platformOcspNonceEnabled = other.platformOcspNonceEnabled;
+ this.certificateRevocationChecker = other.certificateRevocationChecker;
+ this.pkixRevocationChecker = other.pkixRevocationChecker;
+ this.revocationMode = other.revocationMode;
}
void setSiteOrigin(URI siteOrigin) {
@@ -68,69 +63,57 @@ Collection getTrustedCACertificates() {
return trustedCACertificates;
}
- boolean isUserCertificateRevocationCheckWithOcspEnabled() {
- return isUserCertificateRevocationCheckWithOcspEnabled;
- }
-
- void setUserCertificateRevocationCheckWithOcspDisabled() {
- isUserCertificateRevocationCheckWithOcspEnabled = false;
- }
-
- public Duration getOcspRequestTimeout() {
- return ocspRequestTimeout;
+ public Collection getDisallowedSubjectCertificatePolicies() {
+ return disallowedSubjectCertificatePolicies;
}
- void setOcspRequestTimeout(Duration ocspRequestTimeout) {
- this.ocspRequestTimeout = ocspRequestTimeout;
+ boolean isUserCertificateRevocationCheckEnabled() {
+ return isUserCertificateRevocationCheckEnabled;
}
- public Duration getAllowedOcspResponseTimeSkew() {
- return allowedOcspResponseTimeSkew;
+ void setUserCertificateRevocationCheckDisabled() {
+ isUserCertificateRevocationCheckEnabled = false;
}
- public void setAllowedOcspResponseTimeSkew(Duration allowedOcspResponseTimeSkew) {
- this.allowedOcspResponseTimeSkew = allowedOcspResponseTimeSkew;
+ boolean isPlatformOcspNonceEnabled() {
+ return platformOcspNonceEnabled;
}
- public Duration getMaxOcspResponseThisUpdateAge() {
- return maxOcspResponseThisUpdateAge;
+ void setPlatformOcspNonceEnabled(boolean enabled) {
+ platformOcspNonceEnabled = enabled;
}
- public void setMaxOcspResponseThisUpdateAge(Duration maxOcspResponseThisUpdateAge) {
- this.maxOcspResponseThisUpdateAge = maxOcspResponseThisUpdateAge;
+ public void setCertificateRevocationChecker(CertificateRevocationChecker certificateRevocationChecker) {
+ this.certificateRevocationChecker = certificateRevocationChecker;
}
- public DesignatedOcspServiceConfiguration getDesignatedOcspServiceConfiguration() {
- return designatedOcspServiceConfiguration;
+ public CertificateRevocationChecker getCertificateRevocationChecker() {
+ return certificateRevocationChecker;
}
- public void setDesignatedOcspServiceConfiguration(DesignatedOcspServiceConfiguration designatedOcspServiceConfiguration) {
- this.designatedOcspServiceConfiguration = designatedOcspServiceConfiguration;
+ public void setPkixRevocationChecker(PKIXRevocationChecker pkixRevocationChecker) {
+ this.pkixRevocationChecker = pkixRevocationChecker;
}
- public Collection getDisallowedSubjectCertificatePolicies() {
- return disallowedSubjectCertificatePolicies;
+ public PKIXRevocationChecker getPkixRevocationChecker() {
+ return pkixRevocationChecker;
}
- public Collection getNonceDisabledOcspUrls() {
- return nonceDisabledOcspUrls;
+ public RevocationMode getRevocationMode() {
+ return revocationMode;
}
/**
* Checks that the configuration parameters are valid.
*
- * @throws NullPointerException when required parameters are null
* @throws IllegalArgumentException when any parameter is invalid
*/
void validate() {
- Objects.requireNonNull(siteOrigin, "Origin URI must not be null");
validateIsOriginURL(siteOrigin);
if (trustedCACertificates.isEmpty()) {
throw new IllegalArgumentException("At least one trusted certificate authority must be provided");
}
- requirePositiveDuration(ocspRequestTimeout, "OCSP request timeout");
- requirePositiveDuration(allowedOcspResponseTimeSkew, "Allowed OCSP response time-skew");
- requirePositiveDuration(maxOcspResponseThisUpdateAge, "Max OCSP response thisUpdate age");
+ validateRevocationConfiguration();
}
AuthTokenValidationConfiguration copy() {
@@ -146,6 +129,9 @@ AuthTokenValidationConfiguration copy() {
*/
public static void validateIsOriginURL(URI uri) throws IllegalArgumentException {
try {
+ if (uri == null) {
+ throw new IllegalArgumentException("Origin URI must not be null");
+ }
// 1. Verify that the URI can be converted to absolute URL.
uri.toURL();
// 2. Verify that the URI contains only HTTPS scheme, host and optional port components.
@@ -160,4 +146,42 @@ public static void validateIsOriginURL(URI uri) throws IllegalArgumentException
}
}
+ /**
+ * Validates that the revocation check configuration is consistent and derives the {@link RevocationMode} from it.
+ *
+ * Configuration is inconsistent if revocation checking is disabled but a checker is configured or if both
+ * checkers are configured simultaneously.
+ *
+ * @throws IllegalArgumentException if configuration is inconsistent
+ */
+ private void validateRevocationConfiguration() {
+ final boolean hasCustomChecker = certificateRevocationChecker != null;
+ final boolean hasPkixChecker = pkixRevocationChecker != null;
+
+ if (!isUserCertificateRevocationCheckEnabled) {
+ if (hasCustomChecker || hasPkixChecker) {
+ throw new IllegalArgumentException(
+ "User certificate revocation check is disabled, but a revocation checker was configured. " +
+ "Do not combine withoutUserCertificateRevocationCheck() with withCertificateRevocationChecker(...) " +
+ "or withPKIXRevocationChecker(...)."
+ );
+ }
+ revocationMode = RevocationMode.DISABLED;
+ } else {
+ // Revocation check enabled, at most one checker allowed, if no checker provided, use default PKIX revocation checker in OCSP mode.
+ if (hasCustomChecker && hasPkixChecker) {
+ throw new IllegalArgumentException(
+ "Only one of OcspCertificateRevocationChecker or PKIXRevocationChecker may be configured. " +
+ "Do not combine withCertificateRevocationChecker(...) with withPKIXRevocationChecker(...)."
+ );
+ }
+ if (hasCustomChecker) {
+ revocationMode = RevocationMode.CUSTOM_CHECKER;
+ } else if (hasPkixChecker) {
+ revocationMode = RevocationMode.CUSTOM_PKIX;
+ } else {
+ revocationMode = RevocationMode.PLATFORM_OCSP;
+ }
+ }
+ }
}
diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidator.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidator.java
index 3f54175e..b3a09cf0 100644
--- a/src/main/java/eu/webeid/security/validator/AuthTokenValidator.java
+++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidator.java
@@ -8,8 +8,6 @@
import eu.webeid.security.exceptions.AuthTokenException;
import eu.webeid.security.util.Strings;
-import java.security.cert.X509Certificate;
-
/**
* Parses and validates the provided Web eID authentication token.
*/
@@ -28,16 +26,16 @@ public interface AuthTokenValidator {
/**
* Validates the Web eID authentication token signed by the subject and returns
- * the subject certificate that can be used for retrieving information about the subject.
+ * validation information containing the subject certificate and available revocation details.
*
* See {@link CertificateData} and {@link Strings} for convenience methods for retrieving user
* information from the certificate.
*
* @param authToken the Web eID authentication token
* @param currentChallengeNonce the challenge nonce that is associated with the authentication token
- * @return validated subject certificate
+ * @return validated subject certificate and available revocation information
* @throws AuthTokenException when validation fails
*/
- X509Certificate validate(WebEidAuthToken authToken, String currentChallengeNonce) throws AuthTokenException;
+ ValidationInfo validate(WebEidAuthToken authToken, String currentChallengeNonce) throws AuthTokenException;
}
diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java
index 355c89b5..d072979f 100644
--- a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java
+++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java
@@ -4,18 +4,15 @@
package eu.webeid.security.validator;
import eu.webeid.security.exceptions.JceException;
-import eu.webeid.security.validator.ocsp.OcspClient;
-import eu.webeid.security.validator.ocsp.OcspClientImpl;
-import eu.webeid.security.validator.ocsp.service.DesignatedOcspServiceConfiguration;
+import eu.webeid.security.validator.revocationcheck.CertificateRevocationChecker;
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URI;
+import java.security.cert.PKIXRevocationChecker;
import java.security.cert.X509Certificate;
-import java.time.Duration;
import java.util.Collections;
-import java.util.stream.Collectors;
/**
* Builder for constructing {@link AuthTokenValidator} instances.
@@ -25,7 +22,6 @@ public class AuthTokenValidatorBuilder {
private static final Logger LOG = LoggerFactory.getLogger(AuthTokenValidatorBuilder.class);
private final AuthTokenValidationConfiguration configuration = new AuthTokenValidationConfiguration();
- private OcspClient ocspClient;
/**
* Sets the expected site origin, i.e. the domain that the application is running on.
@@ -59,7 +55,7 @@ public AuthTokenValidatorBuilder withTrustedCertificateAuthorities(X509Certifica
LOG.debug("Trusted intermediate certificate authorities set to {}",
configuration.getTrustedCACertificates().stream()
.map(X509Certificate::getSubjectX500Principal)
- .collect(Collectors.toList()));
+ .toList());
}
return this;
}
@@ -79,105 +75,66 @@ public AuthTokenValidatorBuilder withDisallowedCertificatePolicies(ASN1ObjectIde
}
/**
- * Turns off user certificate revocation check with OCSP.
+ * Turns off user certificate revocation check (with OCSP and/or CRL).
*
- * Turning off user certificate revocation check with OCSP is dangerous and should be
- * used only in exceptional circumstances.
+ * Turning off user certificate revocation check is dangerous and should be used only in
+ * exceptional circumstances.
* By default, the revocation check is turned on.
*
* @return the builder instance for method chaining.
*/
- public AuthTokenValidatorBuilder withoutUserCertificateRevocationCheckWithOcsp() {
- configuration.setUserCertificateRevocationCheckWithOcspDisabled();
- LOG.warn("User certificate revocation check with OCSP is disabled, " +
+ public AuthTokenValidatorBuilder withoutUserCertificateRevocationCheck() {
+ configuration.setUserCertificateRevocationCheckDisabled();
+ LOG.warn("User certificate revocation check is disabled, " +
"you should turn off the revocation check only in exceptional circumstances");
return this;
}
/**
- * Sets both the connection and response timeout of user certificate revocation check OCSP requests.
+ * Controls whether the library supplies a nonce for platform OCSP requests. Enabled by default.
*
- * This is an optional configuration parameter, the default is 5 seconds.
+ * An explicitly configured {@code jdk.security.certpath.ocspNonce} JVM system property takes precedence:
+ * {@code true} delegates nonce generation to the JDK and {@code false} disables it, regardless of this setting.
+ * This setting has no effect on custom revocation checkers or when revocation checking is disabled.
+ * Enabling a request nonce does not require the JDK to reject a response without a nonce.
*
- * @param ocspRequestTimeout the duration of OCSP request connection and response timeout
- * @return the builder instance for method chaining.
- */
- public AuthTokenValidatorBuilder withOcspRequestTimeout(Duration ocspRequestTimeout) {
- configuration.setOcspRequestTimeout(ocspRequestTimeout);
- LOG.debug("OCSP request timeout set to {}", ocspRequestTimeout);
- return this;
- }
-
- /**
- * Sets the allowed time skew for OCSP response's thisUpdate and nextUpdate times.
- * This parameter is used to allow discrepancies between the system clock and the OCSP responder's clock,
- * which may occur due to clock drift, network delays or revocation updates that are not published in real time.
- *
- * This is an optional configuration parameter, the default is 15 minutes.
- * The relatively long default is specifically chosen to account for one particular OCSP responder that used
- * CRLs for authoritative revocation info, these CRLs were updated every 15 minutes.
- *
- * @param allowedTimeSkew the allowed time skew
- * @return the builder instance for method chaining.
- */
- public AuthTokenValidatorBuilder withAllowedOcspResponseTimeSkew(Duration allowedTimeSkew) {
- configuration.setAllowedOcspResponseTimeSkew(allowedTimeSkew);
- LOG.debug("Allowed OCSP response time skew set to {}", allowedTimeSkew);
- return this;
- }
-
- /**
- * Sets the maximum age of the OCSP response's thisUpdate time before it is considered too old.
- *
- * This is an optional configuration parameter, the default is 2 minutes.
- *
- * @param maxThisUpdateAge the maximum age of the OCSP response's thisUpdate time
- * @return the builder instance for method chaining.
- */
- public AuthTokenValidatorBuilder withMaxOcspResponseThisUpdateAge(Duration maxThisUpdateAge) {
- configuration.setMaxOcspResponseThisUpdateAge(maxThisUpdateAge);
- LOG.debug("Maximum OCSP response thisUpdate age set to {}", maxThisUpdateAge);
- return this;
- }
-
- /**
- * Adds the given URLs to the list of OCSP URLs for which the nonce protocol extension will be disabled.
- * The OCSP URL is extracted from the user certificate and some OCSP services don't support the nonce extension.
- *
- * @param urls OCSP URLs for which the nonce protocol extension will be disabled
+ * @param enabled whether to supply a nonce when the JVM nonce property is unset
* @return the builder instance for method chaining
*/
- public AuthTokenValidatorBuilder withNonceDisabledOcspUrls(URI... urls) {
- Collections.addAll(configuration.getNonceDisabledOcspUrls(), urls);
- LOG.debug("OCSP URLs for which the nonce protocol extension is disabled set to {}", configuration.getNonceDisabledOcspUrls());
+ public AuthTokenValidatorBuilder withPlatformOcspNonceEnabled(boolean enabled) {
+ configuration.setPlatformOcspNonceEnabled(enabled);
return this;
}
/**
- * Activates the provided designated OCSP service for user certificate revocation check with OCSP.
- * The designated service is only used for checking the status of the certificates whose issuers are
- * supported by the service, falling back to the default OCSP service access location from
- * the certificate's AIA extension if not.
+ * Configures a custom certificate revocation checker for validating user certificate revocation status.
+ *
+ * When set, the platform (provider default) revocation mechanism is disabled and revocation checking is
+ * delegated to the given {@link CertificateRevocationChecker}. This option is mutually exclusive with
+ * {@link #withPKIXRevocationChecker(PKIXRevocationChecker)} and {@link #withoutUserCertificateRevocationCheck()}.
*
- * @param serviceConfiguration configuration of the designated OCSP service
+ * @param customChecker custom certificate revocation checker implementation
* @return the builder instance for method chaining
*/
- public AuthTokenValidatorBuilder withDesignatedOcspServiceConfiguration(DesignatedOcspServiceConfiguration serviceConfiguration) {
- configuration.setDesignatedOcspServiceConfiguration(serviceConfiguration);
- LOG.debug("Using designated OCSP service configuration");
+ public AuthTokenValidatorBuilder withCertificateRevocationChecker(CertificateRevocationChecker customChecker) {
+ configuration.setCertificateRevocationChecker(customChecker);
return this;
}
/**
- * Uses the provided OCSP client instance during user certificate revocation check with OCSP.
- * The provided client instance must be thread-safe.
+ * Configures a custom {@link PKIXRevocationChecker} to be used as the revocation mechanism during user certificate
+ * validation with platform PKIX.
+ *
+ * When set, this checker replaces the platform (provider default) {@link PKIXRevocationChecker}. This option is
+ * mutually exclusive with {@link #withCertificateRevocationChecker(CertificateRevocationChecker)}
+ * and {@link #withoutUserCertificateRevocationCheck()}.
*
- * @param ocspClient OCSP client instance
+ * @param customChecker custom PKIX revocation checker
* @return the builder instance for method chaining
+ * @throws NullPointerException if {@code customChecker} is null
*/
- public AuthTokenValidatorBuilder withOcspClient(OcspClient ocspClient) {
- this.ocspClient = ocspClient;
- LOG.debug("Using the OCSP client provided by API consumer");
+ public AuthTokenValidatorBuilder withPKIXRevocationChecker(PKIXRevocationChecker customChecker) {
+ configuration.setPkixRevocationChecker(customChecker);
return this;
}
@@ -186,16 +143,12 @@ public AuthTokenValidatorBuilder withOcspClient(OcspClient ocspClient) {
* The returned {@link AuthTokenValidator} object is immutable/thread-safe.
*
* @return the configured authentication token validator object
- * @throws NullPointerException when required parameters are null
* @throws IllegalArgumentException when any parameter is invalid
- * @throws RuntimeException when JCE configuration is invalid
+ * @throws JceException when JCE configuration is invalid
*/
- public AuthTokenValidator build() throws NullPointerException, IllegalArgumentException, JceException {
+ public AuthTokenValidator build() throws IllegalArgumentException, JceException {
configuration.validate();
- if (configuration.isUserCertificateRevocationCheckWithOcspEnabled() && ocspClient == null) {
- ocspClient = OcspClientImpl.build(configuration.getOcspRequestTimeout());
- }
- return new AuthTokenValidatorImpl(configuration, ocspClient);
+ return new AuthTokenValidatorImpl(configuration);
}
}
diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java
index 0d951323..9a51ffed 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,45 @@ 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());
+ final X509Certificate subjectCertificate = CertificateLoader.decodeCertificateFromBase64(token.unverifiedCertificate());
- simpleSubjectCertificateValidators.executeFor(subjectCertificate);
- getCertTrustValidators().executeFor(subjectCertificate);
+ SubjectCertificatePurposeValidator.validateCertificatePurpose(subjectCertificate);
+ subjectCertificatePolicyValidator.validateCertificatePolicies(subjectCertificate);
+
+ // Use the clock instance so that the date can be mocked in tests.
+ final Date now = DateAndTime.DefaultClock.getInstance().now();
// It is guaranteed that if the signature verification succeeds, then the origin and challenge
// have been implicitly and correctly verified without the need to implement any additional checks.
- authTokenSignatureValidator.validate(token.getAlgorithm(),
- token.getSignature(),
+ authTokenSignatureValidator.validate(token.algorithm(),
+ token.signature(),
subjectCertificate.getPublicKey(),
- currentChallengeNonce);
-
- return subjectCertificate;
- }
+ currentChallengeNonce
+ );
- /**
- * 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
+ // Revocation validation is the last step to ensure that all non-network checks
+ // are completed before any OCSP requests are made.
+ final List revocationInfoList = CertificateValidator.validateCertificateTrustAndRevocation(
+ subjectCertificate,
+ trustedCACertificateAnchors,
+ trustedCACertificateCertStore,
+ now,
+ configuration.getRevocationMode(),
+ configuration.getCertificateRevocationChecker(),
+ configuration.getPkixRevocationChecker(),
+ configuration.isPlatformOcspNonceEnabled()
);
+ LOG.debug("Subject certificate is valid and signed by a trusted CA");
+
+ 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/SubjectCertificateNotRevokedValidator.java b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidator.java
deleted file mode 100644
index bd6d55e8..00000000
--- a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidator.java
+++ /dev/null
@@ -1,202 +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 eu.webeid.security.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 org.bouncycastle.asn1.ocsp.OCSPObjectIdentifiers;
-import org.bouncycastle.asn1.ocsp.OCSPResponseStatus;
-import org.bouncycastle.asn1.x509.Extension;
-import org.bouncycastle.cert.X509CertificateHolder;
-import org.bouncycastle.cert.ocsp.BasicOCSPResp;
-import org.bouncycastle.cert.ocsp.CertificateID;
-import org.bouncycastle.cert.ocsp.OCSPException;
-import org.bouncycastle.cert.ocsp.OCSPReq;
-import org.bouncycastle.cert.ocsp.OCSPResp;
-import org.bouncycastle.cert.ocsp.SingleResp;
-import org.bouncycastle.jce.provider.BouncyCastleProvider;
-import org.bouncycastle.operator.DigestCalculator;
-import org.bouncycastle.operator.OperatorCreationException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.io.IOException;
-import java.math.BigInteger;
-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;
-
-public final class SubjectCertificateNotRevokedValidator {
-
- private static final Logger LOG = LoggerFactory.getLogger(SubjectCertificateNotRevokedValidator.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());
- }
-
- 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;
- }
-
- /**
- * Validates that the user certificate from the authentication token is not revoked with OCSP.
- *
- * @param subjectCertificate user certificate to be validated
- * @throws AuthTokenException when user certificate is revoked or revocation check fails.
- */
- public void validateCertificateNotRevoked(X509Certificate subjectCertificate) throws AuthTokenException {
- try {
- OcspService ocspService = ocspServiceProvider.getService(subjectCertificate);
-
- final CertificateID certificateId = getCertificateId(subjectCertificate,
- Objects.requireNonNull(trustValidator.getSubjectCertificateIssuerCertificate()));
-
- final OCSPReq request = new OcspRequestBuilder()
- .withCertificateId(certificateId)
- .enableOcspNonce(ocspService.doesSupportNonce())
- .build();
-
- if (!ocspService.doesSupportNonce()) {
- LOG.debug("Disabling OCSP nonce extension");
- }
-
- LOG.debug("Sending OCSP request");
- final OCSPResp response = Objects.requireNonNull(ocspClient.request(ocspService.getAccessLocation(), request));
- if (response.getStatus() != OCSPResponseStatus.SUCCESSFUL) {
- throw new UserCertificateOCSPCheckFailedException("Response status: " + ocspStatusToString(response.getStatus()));
- }
-
- final BasicOCSPResp basicResponse = (BasicOCSPResp) response.getResponseObject();
- if (basicResponse == null) {
- throw new UserCertificateOCSPCheckFailedException("Missing Basic OCSP Response");
- }
- verifyOcspResponse(basicResponse, ocspService, certificateId);
- if (ocspService.doesSupportNonce()) {
- checkNonce(request, basicResponse);
- }
- } catch (OCSPException | CertificateException | OperatorCreationException | IOException e) {
- throw new UserCertificateOCSPCheckFailedException(e);
- }
- }
-
- private void verifyOcspResponse(BasicOCSPResp basicResponse, OcspService ocspService, CertificateID requestCertificateId) throws AuthTokenException, OCSPException, CertificateException, OperatorCreationException {
- // The verification algorithm follows RFC 2560, https://www.ietf.org/rfc/rfc2560.txt.
- //
- // 3.2. Signed Response Acceptance Requirements
- // Prior to accepting a signed response for a particular certificate as
- // valid, OCSP clients SHALL confirm that:
- //
- // 1. The certificate identified in a received response corresponds to
- // the certificate that was identified in the corresponding request.
-
- // 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");
- }
- 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");
- }
-
- // 2. The signature on the response is valid.
-
- // We assume that the responder includes its certificate in the certs field of the response
- // that helps us to verify it. According to RFC 2560 this field is optional, but including it
- // is standard practice.
- if (basicResponse.getCerts().length < 1) {
- throw new UserCertificateOCSPCheckFailedException("OCSP response must contain the responder certificate, "
- + "but none was provided");
- }
- // 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);
-
- // 3. The identity of the signer matches the intended recipient of the
- // request.
- //
- // 4. The signer is currently authorized to provide a response for the
- // certificate in question.
-
- // Use the clock instance so that the date can be mocked in tests.
- final Date now = DateAndTime.DefaultClock.getInstance().now();
- ocspService.validateResponderCertificate(responderCert, now);
-
- // 5. The time at which the status being indicated is known to be
- // correct (thisUpdate) is sufficiently recent.
- //
- // 6. When available, the time at or before which newer information will
- // be available about the status of the certificate (nextUpdate) is
- // greater than the current time.
-
- OcspResponseValidator.validateCertificateStatusUpdateTime(certStatusResponse, allowedOcspResponseTimeSkew, maxOcspResponseThisUpdateAge);
-
- // Now we can accept the signed response as valid and validate the certificate status.
- OcspResponseValidator.validateSubjectCertificateStatus(certStatusResponse);
- LOG.debug("OCSP check result is GOOD");
- }
-
- private static void checkNonce(OCSPReq request, BasicOCSPResp response) 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");
- }
- if (!requestNonce.equals(responseNonce)) {
- throw new UserCertificateOCSPCheckFailedException("OCSP request and response nonces differ, " +
- "possible replay attack");
- }
- }
-
- private static CertificateID getCertificateId(X509Certificate subjectCertificate, X509Certificate issuerCertificate) throws CertificateEncodingException, IOException, OCSPException {
- final BigInteger serial = subjectCertificate.getSerialNumber();
- final DigestCalculator digestCalculator = DigestCalculatorImpl.sha1();
- return new CertificateID(digestCalculator,
- new X509CertificateHolder(issuerCertificate.getEncoded()), serial);
- }
-
- private static String ocspStatusToString(int status) {
- 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";
- }
- }
-
-}
diff --git a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePolicyValidator.java b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePolicyValidator.java
index a7521c96..43f2e8b2 100644
--- a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePolicyValidator.java
+++ b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePolicyValidator.java
@@ -7,7 +7,6 @@
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
import org.bouncycastle.asn1.x509.CertificatePolicies;
import org.bouncycastle.asn1.x509.Extension;
-import org.bouncycastle.asn1.x509.PolicyInformation;
import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils;
import eu.webeid.security.exceptions.UserCertificateDisallowedPolicyException;
import eu.webeid.security.exceptions.UserCertificateParseException;
@@ -15,10 +14,10 @@
import org.slf4j.LoggerFactory;
import java.io.IOException;
+import java.security.cert.CertificateParsingException;
import java.security.cert.X509Certificate;
import java.util.Arrays;
import java.util.Collection;
-import java.util.Optional;
public final class SubjectCertificatePolicyValidator {
@@ -31,26 +30,30 @@ public SubjectCertificatePolicyValidator(Collection disall
}
/**
- * Validates that the user certificate policies match the configured policies.
+ * Requires a certificate policies extension and rejects any configured disallowed policy.
*
* @param subjectCertificate user certificate to be validated
- * @throws UserCertificateDisallowedPolicyException when user certificate policy does not match the configured policies.
- * @throws UserCertificateParseException when user certificate policy is invalid.
+ * @throws UserCertificateDisallowedPolicyException when a disallowed policy is present.
+ * @throws UserCertificateParseException when the certificate policies extension is missing or invalid.
*/
public void validateCertificatePolicies(X509Certificate subjectCertificate) throws AuthTokenException {
final byte[] extensionValue = subjectCertificate.getExtensionValue(Extension.certificatePolicies.getId());
+ if (extensionValue == null) {
+ throw new UserCertificateParseException(new CertificateParsingException("Certificate policies extension is missing"));
+ }
try {
final CertificatePolicies policies = CertificatePolicies.getInstance(
JcaX509ExtensionUtils.parseExtensionValue(extensionValue)
);
- final Optional disallowedPolicy = Arrays.stream(policies.getPolicyInformation())
- .filter(policyInformation ->
- disallowedSubjectCertificatePolicies.contains(policyInformation.getPolicyIdentifier()))
- .findFirst();
- if (disallowedPolicy.isPresent()) {
+ if (policies == null) {
+ throw new IllegalArgumentException("Certificate policies extension is empty");
+ }
+ if (Arrays.stream(policies.getPolicyInformation())
+ .anyMatch(policyInformation ->
+ disallowedSubjectCertificatePolicies.contains(policyInformation.getPolicyIdentifier()))) {
throw new UserCertificateDisallowedPolicyException();
}
- } catch (IOException e) {
+ } catch (IOException | IllegalArgumentException e) {
throw new UserCertificateParseException(e);
}
LOG.debug("User certificate does not contain disallowed policies.");
diff --git a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateTrustedValidator.java b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateTrustedValidator.java
deleted file mode 100644
index 1a619d6a..00000000
--- a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateTrustedValidator.java
+++ /dev/null
@@ -1,59 +0,0 @@
-// SPDX-FileCopyrightText: Estonian Information System Authority
-// SPDX-License-Identifier: MIT
-
-package eu.webeid.security.validator.certvalidators;
-
-import eu.webeid.security.certificate.CertificateValidator;
-import eu.webeid.security.exceptions.AuthTokenException;
-import eu.webeid.security.exceptions.CertificateExpiredException;
-import eu.webeid.security.exceptions.CertificateNotTrustedException;
-import eu.webeid.security.exceptions.CertificateNotYetValidException;
-import eu.webeid.security.util.DateAndTime;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.security.cert.CertStore;
-import java.security.cert.TrustAnchor;
-import java.security.cert.X509Certificate;
-import java.util.Date;
-import java.util.Set;
-
-public final class SubjectCertificateTrustedValidator {
-
- private static final Logger LOG = LoggerFactory.getLogger(SubjectCertificateTrustedValidator.class);
-
- private final Set trustedCACertificateAnchors;
- private final CertStore trustedCACertificateCertStore;
- private X509Certificate subjectCertificateIssuerCertificate;
-
- public SubjectCertificateTrustedValidator(Set trustedCACertificateAnchors, CertStore trustedCACertificateCertStore) {
- this.trustedCACertificateAnchors = trustedCACertificateAnchors;
- this.trustedCACertificateCertStore = trustedCACertificateCertStore;
- }
-
- /**
- * Checks that the user certificate from the authentication token is valid and signed by
- * a trusted certificate authority. Also checks the validity of the user certificate's
- * trusted CA certificate.
- *
- * @param subjectCertificate user certificate to be validated
- * @throws CertificateNotTrustedException when user certificate is not signed by a trusted CA
- * @throws CertificateNotYetValidException when a CA certificate in the chain or the user certificate is not yet valid
- * @throws CertificateExpiredException when a CA certificate in the chain or the user certificate is expired
- */
- public void validateCertificateTrusted(X509Certificate subjectCertificate) throws AuthTokenException {
- // Use the clock instance so that the date can be mocked in tests.
- final Date now = DateAndTime.DefaultClock.getInstance().now();
- subjectCertificateIssuerCertificate = CertificateValidator.validateIsSignedByTrustedCA(
- subjectCertificate,
- trustedCACertificateAnchors,
- trustedCACertificateCertStore,
- now
- );
- LOG.debug("Subject certificate is valid and signed by a trusted CA");
- }
-
- public X509Certificate getSubjectCertificateIssuerCertificate() {
- return subjectCertificateIssuerCertificate;
- }
-}
diff --git a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateValidator.java b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateValidator.java
deleted file mode 100644
index e6e5f2e0..00000000
--- a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateValidator.java
+++ /dev/null
@@ -1,18 +0,0 @@
-// SPDX-FileCopyrightText: Estonian Information System Authority
-// SPDX-License-Identifier: MIT
-
-package eu.webeid.security.validator.certvalidators;
-
-import eu.webeid.security.exceptions.AuthTokenException;
-
-import java.security.cert.X509Certificate;
-
-/**
- * Validators perform the actual user certificate validation actions.
- *
- * They are used by AuthTokenValidatorImpl and are not part of the public API.
- */
-@FunctionalInterface
-public interface SubjectCertificateValidator {
- void validate(X509Certificate subjectCertificate) throws AuthTokenException;
-}
diff --git a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateValidatorBatch.java b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateValidatorBatch.java
deleted file mode 100644
index 5e50ecce..00000000
--- a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateValidatorBatch.java
+++ /dev/null
@@ -1,39 +0,0 @@
-// SPDX-FileCopyrightText: Estonian Information System Authority
-// SPDX-License-Identifier: MIT
-
-package eu.webeid.security.validator.certvalidators;
-
-import eu.webeid.security.exceptions.AuthTokenException;
-
-import java.security.cert.X509Certificate;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-
-public final class SubjectCertificateValidatorBatch {
-
- private final List validatorList;
-
- public static SubjectCertificateValidatorBatch createFrom(SubjectCertificateValidator... validatorList) {
- final List list = new ArrayList<>();
- Collections.addAll(list, validatorList);
- return new SubjectCertificateValidatorBatch(list);
- }
-
- public void executeFor(X509Certificate subjectCertificate) throws AuthTokenException {
- for (final SubjectCertificateValidator validator : validatorList) {
- validator.validate(subjectCertificate);
- }
- }
-
- public SubjectCertificateValidatorBatch addOptional(boolean condition, SubjectCertificateValidator optionalValidator) {
- if (condition) {
- validatorList.add(optionalValidator);
- }
- return this;
- }
-
- private SubjectCertificateValidatorBatch(List validatorList) {
- this.validatorList = validatorList;
- }
-}
diff --git a/src/main/java/eu/webeid/security/validator/ocsp/OcspResponseValidator.java b/src/main/java/eu/webeid/security/validator/ocsp/OcspResponseValidator.java
deleted file mode 100644
index 640c1b5b..00000000
--- a/src/main/java/eu/webeid/security/validator/ocsp/OcspResponseValidator.java
+++ /dev/null
@@ -1,120 +0,0 @@
-// SPDX-FileCopyrightText: Estonian Information System Authority
-// SPDX-License-Identifier: MIT
-
-package eu.webeid.security.validator.ocsp;
-
-import eu.webeid.security.exceptions.OCSPCertificateException;
-import eu.webeid.security.exceptions.UserCertificateOCSPCheckFailedException;
-import eu.webeid.security.exceptions.UserCertificateRevokedException;
-import eu.webeid.security.util.DateAndTime;
-import org.bouncycastle.cert.X509CertificateHolder;
-import org.bouncycastle.cert.ocsp.BasicOCSPResp;
-import org.bouncycastle.cert.ocsp.CertificateStatus;
-import org.bouncycastle.cert.ocsp.OCSPException;
-import org.bouncycastle.cert.ocsp.RevokedStatus;
-import org.bouncycastle.cert.ocsp.SingleResp;
-import org.bouncycastle.cert.ocsp.UnknownStatus;
-import org.bouncycastle.operator.ContentVerifierProvider;
-import org.bouncycastle.operator.OperatorCreationException;
-import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder;
-
-import java.security.cert.CertificateException;
-import java.security.cert.CertificateParsingException;
-import java.security.cert.X509Certificate;
-import java.time.Duration;
-import java.time.Instant;
-import java.util.Objects;
-
-public final class OcspResponseValidator {
-
- /**
- * Indicates that a X.509 Certificates corresponding private key may be used by an authority to sign OCSP responses.
- *
- * https://oidref.com/1.3.6.1.5.5.7.3.9
- */
- private static final String OID_OCSP_SIGNING = "1.3.6.1.5.5.7.3.9";
- private static final String ERROR_PREFIX = "Certificate status update time check failed: ";
-
- public static void validateHasSigningExtension(X509Certificate certificate) throws OCSPCertificateException {
- Objects.requireNonNull(certificate, "certificate");
- try {
- if (certificate.getExtendedKeyUsage() == null || !certificate.getExtendedKeyUsage().contains(OID_OCSP_SIGNING)) {
- throw new OCSPCertificateException("Certificate " + certificate.getSubjectX500Principal() +
- " does not contain the key usage extension for OCSP response signing");
- }
- } catch (CertificateParsingException e) {
- throw new OCSPCertificateException("Certificate parsing failed:", e);
- }
- }
-
- public static void validateResponseSignature(BasicOCSPResp basicResponse, X509CertificateHolder responderCert) throws CertificateException, OperatorCreationException, OCSPException, UserCertificateOCSPCheckFailedException {
- final ContentVerifierProvider verifierProvider = new JcaContentVerifierProviderBuilder()
- .setProvider("BC")
- .build(responderCert);
- if (!basicResponse.isSignatureValid(verifierProvider)) {
- throw new UserCertificateOCSPCheckFailedException("OCSP response signature is invalid");
- }
- }
-
- public static void validateCertificateStatusUpdateTime(SingleResp certStatusResponse, Duration allowedTimeSkew, Duration maxThisupdateAge) throws UserCertificateOCSPCheckFailedException {
- // From RFC 2560, https://www.ietf.org/rfc/rfc2560.txt:
- // 4.2.2. Notes on OCSP Responses
- // 4.2.2.1. Time
- // Responses whose nextUpdate value is earlier than
- // the local system time value SHOULD be considered unreliable.
- // Responses whose thisUpdate time is later than the local system time
- // SHOULD be considered unreliable.
- // If nextUpdate is not set, the responder is indicating that newer
- // revocation information is available all the time.
- final Instant now = DateAndTime.DefaultClock.getInstance().now().toInstant();
- final Instant earliestAcceptableTimeSkew = now.minus(allowedTimeSkew);
- final Instant latestAcceptableTimeSkew = now.plus(allowedTimeSkew);
- final Instant minimumValidThisUpdateTime = now.minus(maxThisupdateAge);
-
- final Instant thisUpdate = certStatusResponse.getThisUpdate().toInstant();
- if (thisUpdate.isAfter(latestAcceptableTimeSkew)) {
- throw new UserCertificateOCSPCheckFailedException(ERROR_PREFIX +
- "thisUpdate '" + thisUpdate + "' is too far in the future, " +
- "latest allowed: '" + latestAcceptableTimeSkew + "'");
- }
- if (thisUpdate.isBefore(minimumValidThisUpdateTime)) {
- throw new UserCertificateOCSPCheckFailedException(ERROR_PREFIX +
- "thisUpdate '" + thisUpdate + "' is too old, " +
- "minimum time allowed: '" + minimumValidThisUpdateTime + "'");
- }
-
- if (certStatusResponse.getNextUpdate() == null) {
- return;
- }
- final Instant nextUpdate = certStatusResponse.getNextUpdate().toInstant();
- if (nextUpdate.isBefore(earliestAcceptableTimeSkew)) {
- throw new UserCertificateOCSPCheckFailedException(ERROR_PREFIX +
- "nextUpdate '" + nextUpdate + "' is in the past");
- }
- if (nextUpdate.isBefore(thisUpdate)) {
- throw new UserCertificateOCSPCheckFailedException(ERROR_PREFIX +
- "nextUpdate '" + nextUpdate + "' is before thisUpdate '" + thisUpdate + "'");
- }
- }
-
- public static void validateSubjectCertificateStatus(SingleResp certStatusResponse) throws UserCertificateRevokedException {
- final CertificateStatus status = certStatusResponse.getCertStatus();
- if (status == null) {
- return;
- }
- if (status instanceof RevokedStatus) {
- RevokedStatus revokedStatus = (RevokedStatus) status;
- throw (revokedStatus.hasRevocationReason() ?
- new UserCertificateRevokedException("Revocation reason: " + revokedStatus.getRevocationReason()) :
- new UserCertificateRevokedException());
- } else if (status instanceof UnknownStatus) {
- throw new UserCertificateRevokedException("Unknown status");
- } else {
- throw new UserCertificateRevokedException("Status is neither good, revoked nor unknown");
- }
- }
-
- private OcspResponseValidator() {
- throw new IllegalStateException("Utility class");
- }
-}
diff --git a/src/main/java/eu/webeid/security/validator/ocsp/OcspServiceProvider.java b/src/main/java/eu/webeid/security/validator/ocsp/OcspServiceProvider.java
deleted file mode 100644
index d7f0464d..00000000
--- a/src/main/java/eu/webeid/security/validator/ocsp/OcspServiceProvider.java
+++ /dev/null
@@ -1,45 +0,0 @@
-// SPDX-FileCopyrightText: Estonian Information System Authority
-// SPDX-License-Identifier: MIT
-
-package eu.webeid.security.validator.ocsp;
-
-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;
-
-public class OcspServiceProvider {
-
- private final DesignatedOcspService designatedOcspService;
- private final AiaOcspServiceConfiguration aiaOcspServiceConfiguration;
-
- public OcspServiceProvider(DesignatedOcspServiceConfiguration designatedOcspServiceConfiguration, AiaOcspServiceConfiguration aiaOcspServiceConfiguration) {
- designatedOcspService = designatedOcspServiceConfiguration != null ?
- new DesignatedOcspService(designatedOcspServiceConfiguration)
- : null;
- this.aiaOcspServiceConfiguration = Objects.requireNonNull(aiaOcspServiceConfiguration, "aiaOcspServiceConfiguration");
- }
-
- /**
- * 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.
- *
- * @param certificate subject certificate that is to be checked with OCSP
- * @return either the designated or AIA OCSP service instance
- * @throws AuthTokenException when AIA URL is not found in certificate
- * @throws CertificateEncodingException when certificate is invalid
- */
- public OcspService getService(X509Certificate certificate) throws AuthTokenException, CertificateEncodingException {
- if (designatedOcspService != null && designatedOcspService.supportsIssuerOf(certificate)) {
- return designatedOcspService;
- }
- return new AiaOcspService(aiaOcspServiceConfiguration, certificate);
- }
-
-}
diff --git a/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java b/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java
deleted file mode 100644
index fcd8c297..00000000
--- a/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java
+++ /dev/null
@@ -1,73 +0,0 @@
-// SPDX-FileCopyrightText: Estonian Information System Authority
-// SPDX-License-Identifier: MIT
-
-package eu.webeid.security.validator.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 org.bouncycastle.cert.X509CertificateHolder;
-import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
-
-import java.net.URI;
-import java.security.cert.CertStore;
-import java.security.cert.CertificateException;
-import java.security.cert.TrustAnchor;
-import java.security.cert.X509Certificate;
-import java.util.Date;
-import java.util.Objects;
-import java.util.Set;
-
-import static eu.webeid.security.validator.ocsp.OcspUrl.getOcspUri;
-
-/**
- * An OCSP service that uses the responders from the Certificates' Authority Information Access (AIA) extension.
- */
-public class AiaOcspService implements OcspService {
-
- private final JcaX509CertificateConverter certificateConverter = new JcaX509CertificateConverter();
- private final Set trustedCACertificateAnchors;
- private final CertStore trustedCACertificateCertStore;
- private final URI url;
- private final boolean supportsNonce;
-
- public AiaOcspService(AiaOcspServiceConfiguration configuration, X509Certificate certificate) throws AuthTokenException {
- Objects.requireNonNull(configuration);
- this.trustedCACertificateAnchors = configuration.getTrustedCACertificateAnchors();
- this.trustedCACertificateCertStore = configuration.getTrustedCACertificateCertStore();
- this.url = getOcspAiaUrlFromCertificate(Objects.requireNonNull(certificate));
- this.supportsNonce = !configuration.getNonceDisabledOcspUrls().contains(this.url);
- }
-
- @Override
- public boolean doesSupportNonce() {
- return supportsNonce;
- }
-
- @Override
- public URI getAccessLocation() {
- return url;
- }
-
- @Override
- public void validateResponderCertificate(X509CertificateHolder cert, Date now) throws AuthTokenException {
- try {
- final X509Certificate 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) {
- throw new OCSPCertificateException("Invalid responder certificate", e);
- }
- }
-
- private static URI getOcspAiaUrlFromCertificate(X509Certificate certificate) throws AuthTokenException {
- return getOcspUri(certificate).orElseThrow(() ->
- new UserCertificateOCSPCheckFailedException("Getting the AIA OCSP responder field from the certificate failed")
- );
- }
-
-}
diff --git a/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspServiceConfiguration.java b/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspServiceConfiguration.java
deleted file mode 100644
index 436f0a42..00000000
--- a/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspServiceConfiguration.java
+++ /dev/null
@@ -1,37 +0,0 @@
-// SPDX-FileCopyrightText: Estonian Information System Authority
-// SPDX-License-Identifier: MIT
-
-package eu.webeid.security.validator.ocsp.service;
-
-import java.net.URI;
-import java.security.cert.CertStore;
-import java.security.cert.TrustAnchor;
-import java.util.Collection;
-import java.util.Objects;
-import java.util.Set;
-
-public class AiaOcspServiceConfiguration {
-
- private final Collection nonceDisabledOcspUrls;
- private final Set trustedCACertificateAnchors;
- private final CertStore trustedCACertificateCertStore;
-
- public AiaOcspServiceConfiguration(Collection nonceDisabledOcspUrls, Set trustedCACertificateAnchors, CertStore trustedCACertificateCertStore) {
- this.nonceDisabledOcspUrls = Objects.requireNonNull(nonceDisabledOcspUrls);
- this.trustedCACertificateAnchors = Objects.requireNonNull(trustedCACertificateAnchors);
- this.trustedCACertificateCertStore = Objects.requireNonNull(trustedCACertificateCertStore);
- }
-
- public Collection getNonceDisabledOcspUrls() {
- return nonceDisabledOcspUrls;
- }
-
- public Set getTrustedCACertificateAnchors() {
- return trustedCACertificateAnchors;
- }
-
- public CertStore getTrustedCACertificateCertStore() {
- return trustedCACertificateCertStore;
- }
-
-}
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..8a85691a
--- /dev/null
+++ b/src/main/java/eu/webeid/security/validator/revocationcheck/RevocationInfo.java
@@ -0,0 +1,36 @@
+// SPDX-FileCopyrightText: Estonian Information System Authority
+// SPDX-License-Identifier: MIT
+package eu.webeid.security.validator.revocationcheck;
+
+import java.net.URI;
+import java.util.HashMap;
+import java.util.Map;
+
+public record RevocationInfo(URI ocspResponderUri, Map ocspResponseAttributes) {
+
+ public static final String KEY_OCSP_REQUEST = "OCSP_REQUEST";
+ public static final String KEY_OCSP_RESPONSE = "OCSP_RESPONSE";
+ public static final String KEY_OCSP_ERROR = "OCSP_ERROR";
+ public static final String KEY_HTTP_STATUS_CODE = "HTTP_STATUS_CODE";
+ public static final String KEY_REQUEST_DURATION = "REQUEST_DURATION";
+ public static final String KEY_CIRCUIT_BREAKER_STATISTICS = "CIRCUIT_BREAKER_STATISTICS";
+ public static final String KEY_OCSP_RESPONSE_TIME = "OCSP_RESPONSE_TIME";
+
+ public RevocationInfo(URI ocspResponderUri, Map ocspResponseAttributes) {
+ this.ocspResponderUri = ocspResponderUri;
+ this.ocspResponseAttributes = ocspResponseAttributes != null
+ ? Map.copyOf(ocspResponseAttributes)
+ : null;
+ }
+
+ public RevocationInfo withAdditionalOcspResponseAttribute(String key, Object value) {
+ if (value == null) {
+ return this;
+ }
+ Map newOcspResponseAttributes = ocspResponseAttributes != null
+ ? new HashMap<>(ocspResponseAttributes)
+ : new HashMap<>();
+ newOcspResponseAttributes.put(key, value);
+ return new RevocationInfo(ocspResponderUri, newOcspResponseAttributes);
+ }
+}
diff --git a/src/main/java/eu/webeid/security/validator/revocationcheck/RevocationMode.java b/src/main/java/eu/webeid/security/validator/revocationcheck/RevocationMode.java
new file mode 100644
index 00000000..dbf30992
--- /dev/null
+++ b/src/main/java/eu/webeid/security/validator/revocationcheck/RevocationMode.java
@@ -0,0 +1,8 @@
+// SPDX-FileCopyrightText: Estonian Information System Authority
+// SPDX-License-Identifier: MIT
+
+package eu.webeid.security.validator.revocationcheck;
+
+public enum RevocationMode {
+ PLATFORM_OCSP, CUSTOM_CHECKER, CUSTOM_PKIX, DISABLED
+}
diff --git a/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidatorTest.java b/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java
similarity index 64%
rename from src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidatorTest.java
rename to src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java
index f2d46162..baa2641c 100644
--- a/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidatorTest.java
+++ b/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java
@@ -1,17 +1,22 @@
// SPDX-FileCopyrightText: Estonian Information System Authority
// SPDX-License-Identifier: MIT
-package eu.webeid.security.validator.certvalidators;
+package eu.webeid.ocsp;
+import eu.webeid.ocsp.exceptions.OCSPClientException;
import eu.webeid.security.exceptions.CertificateExpiredException;
import eu.webeid.security.exceptions.CertificateNotTrustedException;
import eu.webeid.security.exceptions.JceException;
-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.testutil.ResourceUtil;
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;
@@ -21,8 +26,6 @@
import org.junit.jupiter.api.Test;
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 +41,40 @@
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.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.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 +82,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,87 +92,93 @@ 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(OCSPClientException.class)
+ .cause()
.isInstanceOf(ConnectException.class);
}
@Test
void whenOcspRequestFails_thenThrows() throws Exception {
final OcspServiceProvider ocspServiceProvider = getDesignatedOcspServiceProvider("http://demo.sk.ee/ocsps");
- final SubjectCertificateNotRevokedValidator validator = getSubjectCertificateNotRevokedValidator(ocspServiceProvider);
- assertThatCode(() ->
- validator.validateCertificateNotRevoked(estEid2018Cert))
+ final OcspCertificateRevocationChecker validator = getOcspCertificateRevocationChecker(ocspServiceProvider);
+ assertThatThrownBy(() ->
+ validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
.isInstanceOf(UserCertificateOCSPCheckFailedException.class)
.cause()
- .isInstanceOf(IOException.class)
- .hasMessageStartingWith("OCSP request was not successful, response: (POST http://demo.sk.ee/ocsps) 404");
+ .isInstanceOf(OCSPClientException.class)
+ .hasMessageStartingWith("OCSP request was not successful")
+ .satisfies(cause -> assertThat(((OCSPClientException) cause).getStatusCode()).isEqualTo(404));
}
@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);
+ .isInstanceOf(OCSPClientException.class)
+ .cause()
+ .isInstanceOf(IOException.class)
+ .hasMessage("malformed response: corrupted stream - out of bounds length found: 110 > 7");
}
@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 +186,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,93 +199,109 @@ 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)");
}
}
@Test
void whenOcspResponseUnknown_thenThrows() throws Exception {
final OcspServiceProvider ocspServiceProvider = getDesignatedOcspServiceProvider("https://web-eid-test.free.beeceptor.com");
- final HttpResponse response = getMockedResponse(getOcspResponseBytesFromResources("ocsp_response_unknown.der"));
- final SubjectCertificateNotRevokedValidator validator = getSubjectCertificateNotRevokedValidator(getMockClient(response), ocspServiceProvider);
+ final HttpResponse response = getMockedResponse(getOcspResponseBytesFromResources("ocsp_response_unknown_self_signed.der"));
+ final OcspCertificateRevocationChecker validator = getOcspCertificateRevocationChecker(getMockClient(response), ocspServiceProvider);
try (var mockedClock = mockStatic(DateAndTime.DefaultClock.class)) {
mockDate("2021-09-18T00:16:25", mockedClock);
- 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(
- getMockedResponse(getOcspResponseBytesFromResources("ocsp_response_unknown.der"))
+ final OcspCertificateRevocationChecker validator = getOcspCertificateRevocationCheckerWithAiaOcsp(
+ getMockedResponse(getOcspResponseBytesFromResources("ocsp_response_unknown_self_signed.der"))
);
try (var mockedClock = mockStatic(DateAndTime.DefaultClock.class)) {
mockDate("2021-09-18T00:16:25", mockedClock);
assertThatExceptionOfType(CertificateNotTrustedException.class)
.isThrownBy(() ->
- validator.validateCertificateNotRevoked(estEid2018Cert))
- .withMessage("Certificate EMAILADDRESS=pki@sk.ee, CN=TEST of SK OCSP RESPONDER 2020, OU=OCSP, O=AS Sertifitseerimiskeskus, C=EE is not trusted");
+ validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .withMessage("Certificate CN=TEST of self-signed OCSP RESPONDER 2024, OU=OCSP, O=TEST Self-signed OCSP, C=EE is not trusted");
}
}
@Test
void whenOcspResponseCACertExpired_thenThrows() throws Exception {
- final SubjectCertificateNotRevokedValidator validator = getSubjectCertificateNotRevokedValidatorWithAiaOcsp(
- getMockedResponse(getOcspResponseBytesFromResources("ocsp_response_unknown.der"))
+ final OcspCertificateRevocationChecker validator = getOcspCertificateRevocationCheckerWithAiaOcsp(
+ getMockedResponse(getOcspResponseBytesFromResources("ocsp_response_unknown_self_signed.der"))
);
assertThatExceptionOfType(CertificateExpiredException.class)
.isThrownBy(() ->
- validator.validateCertificateNotRevoked(estEid2018Cert))
+ validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
.withMessage("AIA OCSP responder certificate has expired");
}
@Test
void whenNonceDiffers_thenThrows() throws Exception {
- final SubjectCertificateNotRevokedValidator validator = getSubjectCertificateNotRevokedValidatorWithAiaOcsp(
+ final OcspCertificateRevocationChecker validator = getOcspCertificateRevocationCheckerWithAiaOcsp(
getMockedResponse(getOcspResponseBytesFromResources())
);
try (var mockedClock = mockStatic(DateAndTime.DefaultClock.class)) {
mockDate("2021-09-17T18:25:24", mockedClock);
assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class)
.isThrownBy(() ->
- validator.validateCertificateNotRevoked(estEid2018Cert))
- .withMessage("User certificate revocation check has failed: OCSP request and response nonces differ, possible replay attack");
+ validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .withMessage("User certificate revocation check has failed: OCSP request and response nonces differ, possible replay attack (OCSP responder: http://aia.demo.sk.ee/esteid2018)");
}
}
+ @Test
+ void whenInvalidOcspResponseTimeSkew_thenThrows() {
+ assertThatThrownBy(() -> new OcspCertificateRevocationChecker(ocspClient, getAiaOcspServiceProvider(), Duration.ofMinutes(-1)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageStartingWith("allowedOcspResponseTimeSkew 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
+ )).build();
+ }
+
private static byte[] buildOcspResponseBodyWithInternalErrorStatus() throws IOException {
final byte[] ocspResponseBytes = getOcspResponseBytesFromResources();
final int STATUS_OFFSET = 6;
@@ -313,27 +345,19 @@ private static byte[] getOcspResponseBytesFromResources() throws IOException {
}
private static byte[] getOcspResponseBytesFromResources(String resource) throws IOException {
- try (final InputStream resourceAsStream = ClassLoader.getSystemResourceAsStream(resource)) {
- return toByteArray(resourceAsStream);
- }
+ return ResourceUtil.bytesFromResource(resource);
}
- 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 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 OcspCertificateRevocationChecker getOcspCertificateRevocationChecker(OcspClient client, OcspServiceProvider ocspServiceProvider) {
+ return new OcspCertificateRevocationChecker(client, ocspServiceProvider, OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW);
}
private HttpResponse getMockedResponse(byte[] bodyContent) throws URISyntaxException {
@@ -354,18 +378,13 @@ private HttpResponse getMockedResponse(byte[] bodyContent) throws URISyn
}
private OcspClient getMockClient(HttpResponse response) {
- return (url, request) -> new OCSPResp(Objects.requireNonNull(response.body()));
- }
-
- private static byte[] toByteArray(InputStream resourceAsStream) throws IOException {
- Objects.requireNonNull(resourceAsStream);
- int bytesAvailable = resourceAsStream.available();
- byte[] result = new byte[bytesAvailable];
- int bytesRead = resourceAsStream.read(result);
- if (bytesRead != bytesAvailable) {
- throw new RuntimeException("Short read while loading resources");
- }
- return result;
+ return (url, request) -> {
+ try {
+ return new OCSPResp(Objects.requireNonNull(response.body()));
+ } catch (IOException e) {
+ throw new OCSPClientException(e);
+ }
+ };
}
}
diff --git a/src/test/java/eu/webeid/ocsp/client/OcspClientImplTest.java b/src/test/java/eu/webeid/ocsp/client/OcspClientImplTest.java
new file mode 100644
index 00000000..315c098e
--- /dev/null
+++ b/src/test/java/eu/webeid/ocsp/client/OcspClientImplTest.java
@@ -0,0 +1,226 @@
+/*
+ * Copyright (c) 2020-2025 Estonian Information System Authority
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package eu.webeid.ocsp.client;
+
+import eu.webeid.ocsp.exceptions.OCSPClientException;
+import eu.webeid.security.testutil.ResourceUtil;
+import org.bouncycastle.cert.ocsp.OCSPReq;
+import org.bouncycastle.cert.ocsp.OCSPResp;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpHeaders;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.ByteBuffer;
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Flow;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class OcspClientImplTest {
+
+ private static final URI OCSP_URI = URI.create("http://ocsp.test/");
+ private static final Duration TIMEOUT = Duration.ofSeconds(5);
+
+ @Test
+ void whenHttpResponseStatusIsNot200_thenThrowsWithStatusCodeAndBody() throws Exception {
+ byte[] body = "not-found".getBytes();
+ HttpClient httpClient = mockHttpClient(mockResponse(404, body, "text/plain"));
+ OcspClientImpl client = new OcspClientImpl(httpClient, TIMEOUT);
+
+ assertThatExceptionOfType(OCSPClientException.class)
+ .isThrownBy(() -> client.request(OCSP_URI, encodableOcspReq()))
+ .withMessageStartingWith("OCSP request was not successful")
+ .satisfies(ex -> {
+ assertThat(ex.getStatusCode()).isEqualTo(404);
+ assertThat(ex.getResponseBody()).isEqualTo(body);
+ });
+ }
+
+ @Test
+ void whenContentTypeIsNotOcspResponse_thenThrows() throws Exception {
+ HttpClient httpClient = mockHttpClient(mockResponse(200, new byte[]{0x01}, "text/html"));
+ OcspClientImpl client = new OcspClientImpl(httpClient, TIMEOUT);
+
+ assertThatExceptionOfType(OCSPClientException.class)
+ .isThrownBy(() -> client.request(OCSP_URI, encodableOcspReq()))
+ .withMessage("OCSP response content type is not application/ocsp-response");
+ }
+
+ @Test
+ void whenHttpClientThrowsInterruptedException_thenRestoresInterruptFlagAndThrows() throws Exception {
+ HttpClient httpClient = mock(HttpClient.class);
+ when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)))
+ .thenThrow(new InterruptedException("interrupted"));
+ OcspClientImpl client = new OcspClientImpl(httpClient, TIMEOUT);
+
+ try {
+ assertThatExceptionOfType(OCSPClientException.class)
+ .isThrownBy(() -> client.request(OCSP_URI, encodableOcspReq()))
+ .withMessage("Interrupted while sending OCSP request")
+ .withCauseInstanceOf(InterruptedException.class);
+ } finally {
+ // Always clear so a failing assertion above doesn't leak the interrupt flag to other tests.
+ assertThat(Thread.interrupted()).as("interrupt flag must be set by InterruptedException handling").isTrue();
+ }
+ }
+
+ @Test
+ void whenHttpClientThrowsIOException_thenThrows() throws Exception {
+ HttpClient httpClient = mock(HttpClient.class);
+ when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)))
+ .thenThrow(new IOException("network down"));
+ OcspClientImpl client = new OcspClientImpl(httpClient, TIMEOUT);
+
+ assertThatExceptionOfType(OCSPClientException.class)
+ .isThrownBy(() -> client.request(OCSP_URI, encodableOcspReq()))
+ .withCauseInstanceOf(IOException.class);
+ }
+
+ @Test
+ void whenOcspReqGetEncodedThrowsIOException_thenThrows() throws Exception {
+ OCSPReq ocspReq = mock(OCSPReq.class);
+ when(ocspReq.getEncoded()).thenThrow(new IOException("encoding failed"));
+ OcspClientImpl client = new OcspClientImpl(mock(HttpClient.class), TIMEOUT);
+
+ assertThatExceptionOfType(OCSPClientException.class)
+ .isThrownBy(() -> client.request(OCSP_URI, ocspReq))
+ .withCauseInstanceOf(IOException.class);
+ }
+
+ @Test
+ void whenResponseBodyIsInvalidOcsp_thenThrowsWithIoExceptionCause() throws Exception {
+ HttpClient httpClient = mockHttpClient(mockResponse(200, "not-an-ocsp-response".getBytes(), "application/ocsp-response"));
+ OcspClientImpl client = new OcspClientImpl(httpClient, TIMEOUT);
+
+ assertThatExceptionOfType(OCSPClientException.class)
+ .isThrownBy(() -> client.request(OCSP_URI, encodableOcspReq()))
+ .withCauseInstanceOf(IOException.class);
+ }
+
+ @Test
+ void whenRequestIsSent_thenOcspRequestIsPostedWithCorrectUriHeaderAndBody() throws Exception {
+ byte[] responseBody = ResourceUtil.bytesFromResource("ocsp_response.der");
+ HttpClient httpClient = mockHttpClient(mockResponse(200, responseBody, "application/ocsp-response"));
+ OcspClientImpl client = new OcspClientImpl(httpClient, TIMEOUT);
+ OCSPReq ocspReq = encodableOcspReq();
+
+ client.request(OCSP_URI, ocspReq);
+
+ ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
+ verify(httpClient).send(requestCaptor.capture(), any(HttpResponse.BodyHandler.class));
+ HttpRequest request = requestCaptor.getValue();
+
+ assertThat(request.uri()).isEqualTo(OCSP_URI);
+ assertThat(request.method()).isEqualTo("POST");
+ assertThat(request.headers().firstValue("Content-Type")).contains("application/ocsp-request");
+ assertThat(request.bodyPublisher()).isPresent();
+ assertThat(request.bodyPublisher().get().contentLength()).isEqualTo(ocspReq.getEncoded().length);
+ assertThat(readBodyPublisher(request.bodyPublisher().get())).isEqualTo(ocspReq.getEncoded());
+ }
+
+ @Test
+ void whenResponseIsValidOcsp_thenReturnsParsedOcspResp() throws Exception {
+ byte[] responseBody = ResourceUtil.bytesFromResource("ocsp_response.der");
+ HttpClient httpClient = mockHttpClient(mockResponse(200, responseBody, "application/ocsp-response"));
+ OcspClientImpl client = new OcspClientImpl(httpClient, TIMEOUT);
+
+ OCSPResp ocspResp = client.request(OCSP_URI, encodableOcspReq());
+
+ assertThat(ocspResp).isNotNull();
+ assertThat(ocspResp.getStatus()).isEqualTo(OCSPResp.SUCCESSFUL);
+ }
+
+ @Test
+ void whenHttpClientIsNull_thenThrows() {
+ assertThatExceptionOfType(NullPointerException.class)
+ .isThrownBy(() -> new OcspClientImpl(null, TIMEOUT));
+ }
+
+ @SuppressWarnings("unchecked")
+ private static HttpClient mockHttpClient(HttpResponse response) throws Exception {
+ HttpClient httpClient = mock(HttpClient.class);
+ when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))).thenReturn(response);
+ return httpClient;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static HttpResponse mockResponse(int statusCode, byte[] body, String contentType) {
+ HttpResponse response = mock(HttpResponse.class);
+ when(response.statusCode()).thenReturn(statusCode);
+ when(response.body()).thenReturn(body);
+ HttpHeaders headers = HttpHeaders.of(Map.of("Content-Type", List.of(contentType)), (k, v) -> true);
+ when(response.headers()).thenReturn(headers);
+ return response;
+ }
+
+ private static OCSPReq encodableOcspReq() throws IOException {
+ OCSPReq ocspReq = mock(OCSPReq.class);
+ when(ocspReq.getEncoded()).thenReturn(new byte[]{0x30, 0x00});
+ return ocspReq;
+ }
+
+ private static byte[] readBodyPublisher(HttpRequest.BodyPublisher bodyPublisher) {
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ CompletableFuture result = new CompletableFuture<>();
+
+ bodyPublisher.subscribe(new Flow.Subscriber<>() {
+ @Override
+ public void onSubscribe(Flow.Subscription subscription) {
+ subscription.request(Long.MAX_VALUE);
+ }
+
+ @Override
+ public void onNext(ByteBuffer item) {
+ byte[] chunk = new byte[item.remaining()];
+ item.get(chunk);
+ output.write(chunk, 0, chunk.length);
+ }
+
+ @Override
+ public void onError(Throwable throwable) {
+ result.completeExceptionally(throwable);
+ }
+
+ @Override
+ public void onComplete() {
+ result.complete(output.toByteArray());
+ }
+ });
+
+ return result.join();
+ }
+}
diff --git a/src/test/java/eu/webeid/security/validator/ocsp/OcspClientOverrideTest.java b/src/test/java/eu/webeid/ocsp/client/OcspClientOverrideTest.java
similarity index 54%
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..f09aca39 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,17 @@
// 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.ocsp.exceptions.OCSPClientException;
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,54 +19,55 @@
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
+ )).build();
+ }
+
private static class OcpClientThatThrows implements OcspClient {
@Override
- public OCSPResp request(URI url, OCSPReq request) throws IOException {
+ public OCSPResp request(URI url, OCSPReq request) throws OCSPClientException {
throw new OcpClientThatThrowsException();
}
}
- private static class OcpClientThatThrowsException extends IOException {
+ private static class OcpClientThatThrowsException extends OCSPClientException {
}
}
diff --git a/src/test/java/eu/webeid/ocsp/protocol/IssuerDistinguishedNameTest.java b/src/test/java/eu/webeid/ocsp/protocol/IssuerDistinguishedNameTest.java
new file mode 100644
index 00000000..dd9d9744
--- /dev/null
+++ b/src/test/java/eu/webeid/ocsp/protocol/IssuerDistinguishedNameTest.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2020-2025 Estonian Information System Authority
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package eu.webeid.ocsp.protocol;
+
+import org.bouncycastle.asn1.x500.X500Name;
+import org.junit.jupiter.api.Test;
+
+import static eu.webeid.ocsp.protocol.IssuerDistinguishedName.getIssuerDistinguishedName;
+import static eu.webeid.security.testutil.Certificates.getMariliisEsteid2015Cert;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatNullPointerException;
+
+class IssuerDistinguishedNameTest {
+
+ private static final X500Name ISSUER_DN = new X500Name("CN=TEST of ESTEID-SK 2015, OID.2.5.4.97=NTREE-10747013, O=AS Sertifitseerimiskeskus, C=EE");
+
+ @Test
+ void whenCertificateGiven_thenReturnsIssuerDistinguishedName() throws Exception {
+ assertThat(getIssuerDistinguishedName(getMariliisEsteid2015Cert())).isEqualTo(ISSUER_DN);
+ }
+
+ @Test
+ void whenCertificateIsNull_thenThrows() {
+ assertThatNullPointerException().isThrownBy(() -> getIssuerDistinguishedName(null));
+ }
+}
diff --git a/src/test/java/eu/webeid/ocsp/protocol/OcspNonceFactoryTest.java b/src/test/java/eu/webeid/ocsp/protocol/OcspNonceFactoryTest.java
new file mode 100644
index 00000000..49868058
--- /dev/null
+++ b/src/test/java/eu/webeid/ocsp/protocol/OcspNonceFactoryTest.java
@@ -0,0 +1,33 @@
+// SPDX-FileCopyrightText: Estonian Information System Authority
+// SPDX-License-Identifier: MIT
+
+package eu.webeid.ocsp.protocol;
+
+import org.bouncycastle.asn1.ASN1OctetString;
+import org.bouncycastle.asn1.x509.Extension;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class OcspNonceFactoryTest {
+
+ @Test
+ void whenNonceExtensionIsCreated_thenItEncodesANonCritical32ByteNonce() throws Exception {
+ final Extension decodedExtension = Extension.getInstance(OcspNonceFactory.create().getEncoded());
+
+ assertThat(decodedExtension.getExtnId().getId()).isEqualTo("1.3.6.1.5.5.7.48.1.2");
+ assertThat(decodedExtension.isCritical()).isFalse();
+ assertThat(ASN1OctetString.getInstance(decodedExtension.getParsedValue()).getOctets()).hasSize(32);
+ }
+
+ @Test
+ void whenExtensionsAreCreated_thenNoncesAreFreshAnd32BytesLong() throws Exception {
+ final byte[] firstNonce = ASN1OctetString.getInstance(
+ OcspNonceFactory.create().getParsedValue()).getOctets();
+ final byte[] secondNonce = ASN1OctetString.getInstance(
+ OcspNonceFactory.create().getParsedValue()).getOctets();
+
+ assertThat(firstNonce).hasSize(32);
+ assertThat(secondNonce).hasSize(32).isNotEqualTo(firstNonce);
+ }
+}
diff --git a/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java b/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java
new file mode 100644
index 00000000..8a53653b
--- /dev/null
+++ b/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java
@@ -0,0 +1,360 @@
+// SPDX-FileCopyrightText: Estonian Information System Authority
+// SPDX-License-Identifier: MIT
+
+package eu.webeid.ocsp.protocol;
+
+import eu.webeid.ocsp.OcspCertificateRevocationChecker;
+import eu.webeid.ocsp.exceptions.OCSPCertificateException;
+import eu.webeid.ocsp.exceptions.UserCertificateOCSPCheckFailedException;
+import eu.webeid.ocsp.exceptions.UserCertificateRevokedException;
+import org.bouncycastle.asn1.DLBitString;
+import org.bouncycastle.asn1.x509.BasicConstraints;
+import org.bouncycastle.asn1.x509.ExtendedKeyUsage;
+import org.bouncycastle.asn1.x509.Extension;
+import org.bouncycastle.asn1.x509.KeyPurposeId;
+import org.bouncycastle.asn1.x509.KeyUsage;
+import org.bouncycastle.cert.ocsp.BasicOCSPResp;
+import org.bouncycastle.cert.ocsp.CertificateStatus;
+import org.bouncycastle.cert.ocsp.OCSPResp;
+import org.bouncycastle.cert.ocsp.RevokedStatus;
+import org.bouncycastle.cert.ocsp.SingleResp;
+import org.junit.jupiter.api.Test;
+
+import java.net.URI;
+import java.security.cert.X509Certificate;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Date;
+
+import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateBasicConstraintsNotCA;
+import static eu.webeid.security.testutil.ResourceUtil.bytesFromResource;
+import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateCertificateStatusUpdateTime;
+import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateExtendedKeyUsageOcspSigning;
+import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateKeyUsageDigitalSignature;
+import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateKeyUsageNotCertificateSigning;
+import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateSubjectCertificateStatus;
+import static eu.webeid.security.testutil.TestCertificateBuilder.buildCertificate;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatNullPointerException;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class OcspResponseValidatorTest {
+
+ private static final Duration TIME_SKEW = OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW;
+ private static final Duration THIS_UPDATE_AGE = OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE;
+ private static final Duration NEXT_UPDATE_AGE = OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE;
+ private static final Duration LONG_THIS_UPDATE_AGE = Duration.ofDays(365);
+ private static final Duration LONG_NEXT_UPDATE_AGE = Duration.ofDays(365);
+ /** Shorter than {@link #TIME_SKEW}, to show that the nextUpdate age check is independent of the time skew. */
+ private static final Duration SHORT_NEXT_UPDATE_AGE = Duration.ofMinutes(2);
+ private static final URI OCSP_URL = URI.create("https://example.org");
+
+ @Test
+ void whenThisAndNextUpdateWithinAgeLimits_thenValidationSucceeds() {
+ final SingleResp mockResponse = mock(SingleResp.class);
+ var now = Instant.now();
+ var thisUpdateWithinAgeLimit = getThisUpdateWithinAgeLimit(now);
+ var nextUpdateWithinAgeLimit = Date.from(now.minus(THIS_UPDATE_AGE.minusSeconds(2)));
+ when(mockResponse.getThisUpdate()).thenReturn(thisUpdateWithinAgeLimit);
+ when(mockResponse.getNextUpdate()).thenReturn(nextUpdateWithinAgeLimit);
+ assertThatCode(() -> validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, NEXT_UPDATE_AGE, OCSP_URL))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void whenNextUpdateBeforeThisUpdate_thenThrows() {
+ final SingleResp mockResponse = mock(SingleResp.class);
+ var now = Instant.now();
+ var thisUpdateWithinAgeLimit = getThisUpdateWithinAgeLimit(now);
+ var beforeThisUpdate = new Date(thisUpdateWithinAgeLimit.getTime() - 1000);
+ when(mockResponse.getThisUpdate()).thenReturn(thisUpdateWithinAgeLimit);
+ when(mockResponse.getNextUpdate()).thenReturn(beforeThisUpdate);
+ assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() ->
+ validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, LONG_NEXT_UPDATE_AGE, OCSP_URL))
+ .withMessageStartingWith("User certificate revocation check has failed: "
+ + "Certificate status update time check failed: "
+ + "nextUpdate '" + beforeThisUpdate.toInstant() + "' is before thisUpdate '" + thisUpdateWithinAgeLimit.toInstant() + "'");
+ }
+
+ @Test
+ void whenThisUpdateHalfHourBeforeNow_thenThrows() {
+ final SingleResp mockResponse = mock(SingleResp.class);
+ var now = Instant.now();
+ var halfHourBeforeNow = Date.from(now.minus(30, ChronoUnit.MINUTES));
+ when(mockResponse.getThisUpdate()).thenReturn(halfHourBeforeNow);
+ assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() ->
+ validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, NEXT_UPDATE_AGE, OCSP_URL))
+ .withMessageStartingWith("User certificate revocation check has failed: "
+ + "Certificate status update time check failed: "
+ + "thisUpdate '" + halfHourBeforeNow.toInstant() + "' is too old, minimum time allowed: ");
+ }
+
+ @Test
+ void whenThisUpdateHalfHourAfterNow_thenThrows() {
+ final SingleResp mockResponse = mock(SingleResp.class);
+ var now = Instant.now();
+ var halfHourAfterNow = Date.from(now.plus(30, ChronoUnit.MINUTES));
+ when(mockResponse.getThisUpdate()).thenReturn(halfHourAfterNow);
+ assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() ->
+ validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, NEXT_UPDATE_AGE, OCSP_URL))
+ .withMessageStartingWith("User certificate revocation check has failed: "
+ + "Certificate status update time check failed: "
+ + "thisUpdate '" + halfHourAfterNow.toInstant() + "' is too far in the future, latest allowed: ");
+ }
+
+ @Test
+ void whenNextUpdateIsNull_thenValidationSucceeds() {
+ final SingleResp mockResponse = mock(SingleResp.class);
+ var now = Instant.now();
+ when(mockResponse.getThisUpdate()).thenReturn(getThisUpdateWithinAgeLimit(now));
+ when(mockResponse.getNextUpdate()).thenReturn(null);
+ assertThatCode(() -> validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, NEXT_UPDATE_AGE, OCSP_URL))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void whenNextUpdateHalfHourBeforeNow_thenThrows() {
+ final SingleResp mockResponse = mock(SingleResp.class);
+ var now = Instant.now();
+ var thisUpdateWithinAgeLimit = getThisUpdateWithinAgeLimit(now);
+ var halfHourBeforeNow = Date.from(now.minus(30, ChronoUnit.MINUTES));
+ when(mockResponse.getThisUpdate()).thenReturn(thisUpdateWithinAgeLimit);
+ when(mockResponse.getNextUpdate()).thenReturn(halfHourBeforeNow);
+ assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() ->
+ validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, NEXT_UPDATE_AGE, OCSP_URL))
+ .withMessageStartingWith("User certificate revocation check has failed: "
+ + "Certificate status update time check failed: "
+ + "nextUpdate '" + halfHourBeforeNow.toInstant() + "' is too old, minimum time allowed: '");
+ }
+
+ @Test
+ void whenNextUpdateOlderThanMaxNextUpdateAgeButWithinTimeSkew_thenThrows() {
+ final SingleResp mockResponse = mock(SingleResp.class);
+ var now = Instant.now();
+ var thisUpdateBeforeNextUpdate = Date.from(now.minus(14, ChronoUnit.MINUTES));
+ var nextUpdateWithinTimeSkewButTooOld = Date.from(now.minus(10, ChronoUnit.MINUTES));
+ when(mockResponse.getThisUpdate()).thenReturn(thisUpdateBeforeNextUpdate);
+ when(mockResponse.getNextUpdate()).thenReturn(nextUpdateWithinTimeSkewButTooOld);
+ assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() ->
+ validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, TIME_SKEW, SHORT_NEXT_UPDATE_AGE, OCSP_URL))
+ .withMessageStartingWith("User certificate revocation check has failed: "
+ + "Certificate status update time check failed: "
+ + "nextUpdate '" + nextUpdateWithinTimeSkewButTooOld.toInstant() + "' is too old, minimum time allowed: '");
+ }
+
+ @Test
+ void whenNextUpdateOlderThanTimeSkewButWithinMaxNextUpdateAge_thenValidationSucceeds() {
+ final SingleResp mockResponse = mock(SingleResp.class);
+ var now = Instant.now();
+ var thisUpdateOlderThanTimeSkew = Date.from(now.minus(25, ChronoUnit.MINUTES));
+ var nextUpdateOlderThanTimeSkew = Date.from(now.minus(20, ChronoUnit.MINUTES));
+ when(mockResponse.getThisUpdate()).thenReturn(thisUpdateOlderThanTimeSkew);
+ when(mockResponse.getNextUpdate()).thenReturn(nextUpdateOlderThanTimeSkew);
+ assertThatCode(() ->
+ validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, LONG_THIS_UPDATE_AGE, LONG_NEXT_UPDATE_AGE, OCSP_URL))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void whenOcspResponseStatusIsUnknown_ThenThrowsUserCertificateOCSPCheckFailedException() throws Exception {
+ SingleResp unknownCertStatus = getUnknownCertStatusResponse();
+ assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() ->
+ validateSubjectCertificateStatus(unknownCertStatus, OCSP_URL))
+ .withMessage("User certificate revocation check has failed: Unknown status (OCSP responder: https://example.org)");
+ }
+
+ @Test
+ void whenCertIsNotCA_thenBasicConstraintsValidationSucceeds() throws Exception {
+ final X509Certificate cert = buildCertificate(
+ new Extension(Extension.basicConstraints, true, new BasicConstraints(false).getEncoded()));
+ assertThatCode(() -> validateBasicConstraintsNotCA(cert)).doesNotThrowAnyException();
+ }
+
+ @Test
+ void whenBasicConstraintsExtensionAbsent_thenBasicConstraintsValidationSucceeds() throws Exception {
+ final X509Certificate cert = buildCertificate();
+ assertThatCode(() -> validateBasicConstraintsNotCA(cert)).doesNotThrowAnyException();
+ }
+
+ @Test
+ void whenCertIsCA_thenBasicConstraintsValidationThrows() throws Exception {
+ final X509Certificate cert = buildCertificate(
+ new Extension(Extension.basicConstraints, true, new BasicConstraints(0).getEncoded()));
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() -> validateBasicConstraintsNotCA(cert))
+ .withMessage("Certificate " + cert.getSubjectX500Principal() +
+ " must not be a CA certificate (Basic Constraints CA:TRUE is not allowed for OCSP responder)");
+ }
+
+ @Test
+ void whenCertIsNull_thenBasicConstraintsValidationThrowsNullPointerException() {
+ assertThatNullPointerException().isThrownBy(() -> validateBasicConstraintsNotCA(null))
+ .withMessage("certificate");
+ }
+
+ @Test
+ void whenCertHasKeyUsageDigitalSignature_thenKeyUsageValidationSucceeds() throws Exception {
+ final X509Certificate cert = buildCertificate(
+ new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature).getEncoded()));
+ assertThatCode(() -> validateKeyUsageDigitalSignature(cert)).doesNotThrowAnyException();
+ }
+
+ @Test
+ void whenKeyUsageExtensionAbsent_thenKeyUsageValidationThrows() throws Exception {
+ final X509Certificate cert = buildCertificate();
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() -> validateKeyUsageDigitalSignature(cert))
+ .withMessage("Certificate " + cert.getSubjectX500Principal() +
+ " does not contain the Key Usage extension required for OCSP response signing");
+ }
+
+ @Test
+ void whenCertMissingKeyUsageDigitalSignature_thenKeyUsageValidationThrows() throws Exception {
+ final X509Certificate cert = buildCertificate(
+ new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.nonRepudiation).getEncoded()));
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() -> validateKeyUsageDigitalSignature(cert))
+ .withMessage("Certificate " + cert.getSubjectX500Principal() +
+ " Key Usage extension does not contain Digital Signature, which is required for OCSP response signing");
+ }
+
+ @Test
+ void whenCertKeyUsageBitStringEmpty_thenKeyUsageValidationThrows() throws Exception {
+ final X509Certificate cert = buildCertificate(
+ new Extension(Extension.keyUsage, true, new DLBitString(new byte[0]).getEncoded()));
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() -> validateKeyUsageDigitalSignature(cert))
+ .withMessage("Certificate " + cert.getSubjectX500Principal() +
+ " Key Usage extension does not contain Digital Signature, which is required for OCSP response signing");
+ }
+
+ @Test
+ void whenCertIsNull_thenKeyUsageValidationThrowsNullPointerException() {
+ assertThatNullPointerException().isThrownBy(() -> validateKeyUsageDigitalSignature(null))
+ .withMessage("certificate");
+ }
+
+ @Test
+ void whenCertHasNoKeyUsageKeyCertSign_thenKeyCertSignValidationSucceeds() throws Exception {
+ final X509Certificate cert = buildCertificate(
+ new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature).getEncoded()));
+ assertThatCode(() -> validateKeyUsageNotCertificateSigning(cert)).doesNotThrowAnyException();
+ }
+
+ @Test
+ void whenKeyUsageExtensionAbsent_thenKeyCertSignValidationSucceeds() throws Exception {
+ final X509Certificate cert = buildCertificate();
+ assertThatCode(() -> validateKeyUsageNotCertificateSigning(cert)).doesNotThrowAnyException();
+ }
+
+ @Test
+ void whenCertKeyUsageBitStringEmpty_thenKeyCertSignValidationSucceeds() throws Exception {
+ final X509Certificate cert = buildCertificate(
+ new Extension(Extension.keyUsage, true, new DLBitString(new byte[0]).getEncoded()));
+ assertThatCode(() -> validateKeyUsageNotCertificateSigning(cert)).doesNotThrowAnyException();
+ }
+
+ @Test
+ void whenCertHasKeyUsageKeyCertSign_thenKeyCertSignValidationThrows() throws Exception {
+ final X509Certificate cert = buildCertificate(
+ new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign).getEncoded()));
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() -> validateKeyUsageNotCertificateSigning(cert))
+ .withMessage("Certificate " + cert.getSubjectX500Principal() +
+ " Key Usage extension contains Certificate Signing, which is not allowed for OCSP responder");
+ }
+
+ @Test
+ void whenCertHasKeyUsageKeyCertSignCombinedWithDigitalSignature_thenKeyCertSignValidationThrows() throws Exception {
+ final X509Certificate cert = buildCertificate(
+ new Extension(Extension.keyUsage, true,
+ new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyCertSign).getEncoded()));
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() -> validateKeyUsageNotCertificateSigning(cert))
+ .withMessage("Certificate " + cert.getSubjectX500Principal() +
+ " Key Usage extension contains Certificate Signing, which is not allowed for OCSP responder");
+ }
+
+ @Test
+ void whenCertIsNull_thenKeyCertSignValidationThrowsNullPointerException() {
+ assertThatNullPointerException().isThrownBy(() -> validateKeyUsageNotCertificateSigning(null))
+ .withMessage("certificate");
+ }
+
+ @Test
+ void whenCertHasOcspSigningEku_thenExtendedKeyUsageValidationSucceeds() throws Exception {
+ final X509Certificate cert = buildCertificate(
+ new Extension(Extension.extendedKeyUsage, true,
+ new ExtendedKeyUsage(KeyPurposeId.id_kp_OCSPSigning).getEncoded()));
+ assertThatCode(() -> validateExtendedKeyUsageOcspSigning(cert)).doesNotThrowAnyException();
+ }
+
+ @Test
+ void whenExtendedKeyUsageExtensionAbsent_thenExtendedKeyUsageValidationThrows() throws Exception {
+ final X509Certificate cert = buildCertificate();
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() -> validateExtendedKeyUsageOcspSigning(cert))
+ .withMessage("Certificate " + cert.getSubjectX500Principal() +
+ " does not contain the Extended Key Usage extension required for OCSP response signing");
+ }
+
+ @Test
+ void whenCertMissingOcspSigningEku_thenExtendedKeyUsageValidationThrows() throws Exception {
+ final X509Certificate cert = buildCertificate(
+ new Extension(Extension.extendedKeyUsage, true,
+ new ExtendedKeyUsage(KeyPurposeId.id_kp_clientAuth).getEncoded()));
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() -> validateExtendedKeyUsageOcspSigning(cert))
+ .withMessage("Certificate " + cert.getSubjectX500Principal() +
+ " Extended Key Usage extension does not contain OCSP Signing, which is required for OCSP response signing");
+ }
+
+ @Test
+ void whenCertIsNull_thenExtendedKeyUsageValidationThrowsNullPointerException() {
+ assertThatNullPointerException().isThrownBy(() -> validateExtendedKeyUsageOcspSigning(null))
+ .withMessage("certificate");
+ }
+
+ @Test
+ void whenRevokedStatusHasNoReason_thenThrows() {
+ final SingleResp mockResponse = mock(SingleResp.class);
+ when(mockResponse.getCertStatus()).thenReturn(new RevokedStatus(new Date()));
+ assertThatExceptionOfType(UserCertificateRevokedException.class)
+ .isThrownBy(() ->
+ validateSubjectCertificateStatus(mockResponse, OCSP_URL))
+ .withMessage("User certificate has been revoked (OCSP responder: https://example.org)");
+ }
+
+ @Test
+ void whenStatusIsNeitherGoodRevokedNorUnknown_thenThrowsUserCertificateOCSPCheckFailedException() {
+ final SingleResp mockResponse = mock(SingleResp.class);
+ when(mockResponse.getCertStatus()).thenReturn(new UnexpectedCertificateStatus());
+ assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() ->
+ validateSubjectCertificateStatus(mockResponse, OCSP_URL))
+ .withMessage("User certificate revocation check has failed: Status is neither good, revoked nor unknown (OCSP responder: https://example.org)");
+ }
+
+ private static Date getThisUpdateWithinAgeLimit(Instant now) {
+ return Date.from(now.minus(THIS_UPDATE_AGE.minusSeconds(1)));
+ }
+
+ private static SingleResp getUnknownCertStatusResponse() throws Exception {
+ final OCSPResp ocspRespUnknown = new OCSPResp(bytesFromResource("ocsp_response_unknown.der"));
+ final BasicOCSPResp basicResponse = (BasicOCSPResp) ocspRespUnknown.getResponseObject();
+ return basicResponse.getResponses()[0];
+ }
+
+ private static class UnexpectedCertificateStatus implements CertificateStatus {
+ }
+
+}
diff --git a/src/test/java/eu/webeid/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..3b87db8c
--- /dev/null
+++ b/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceConfigurationTest.java
@@ -0,0 +1,40 @@
+// SPDX-FileCopyrightText: Estonian Information System Authority
+// SPDX-License-Identifier: MIT
+
+package eu.webeid.ocsp.service;
+
+import eu.webeid.ocsp.OcspCertificateRevocationChecker;
+import eu.webeid.security.certificate.CertificateValidator;
+import org.bouncycastle.asn1.x500.X500Name;
+import org.junit.jupiter.api.Test;
+
+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 X500Name issuerDN = new X500Name("CN=TEST of ESTEID2018, O=SK ID Solutions AS, C=EE");
+ final Set nonceDisabledIssuerDNs = new HashSet<>(Set.of(issuerDN));
+ final TrustAnchor anchor = new TrustAnchor(getTestEsteid2018CA(), null);
+ final Set anchors = new HashSet<>(Set.of(anchor));
+ final AiaOcspServiceConfiguration configuration = new AiaOcspServiceConfiguration(
+ nonceDisabledIssuerDNs, anchors, CertificateValidator.buildCertStoreFromCertificates(List.of(getTestEsteid2018CA())),
+ OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE);
+
+ nonceDisabledIssuerDNs.clear();
+ anchors.clear();
+
+ assertThat(configuration.getNonceDisabledIssuerDNs()).containsExactly(issuerDN);
+ assertThat(configuration.getTrustedCACertificateAnchors()).containsExactly(anchor);
+ assertThatThrownBy(() -> configuration.getNonceDisabledIssuerDNs().clear()).isInstanceOf(UnsupportedOperationException.class);
+ assertThatThrownBy(() -> configuration.getTrustedCACertificateAnchors().clear()).isInstanceOf(UnsupportedOperationException.class);
+ }
+}
diff --git a/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceTest.java b/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceTest.java
new file mode 100644
index 00000000..1acb5238
--- /dev/null
+++ b/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceTest.java
@@ -0,0 +1,134 @@
+/*
+ * Copyright (c) 2020-2025 Estonian Information System Authority
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package eu.webeid.ocsp.service;
+
+import eu.webeid.security.certificate.CertificateValidator;
+import org.bouncycastle.asn1.x500.X500Name;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.net.URI;
+import java.security.cert.CertStore;
+import java.security.cert.TrustAnchor;
+import java.security.cert.X509Certificate;
+import java.util.List;
+import java.util.Set;
+
+import static eu.webeid.ocsp.OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE;
+import static eu.webeid.ocsp.OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE;
+import static eu.webeid.ocsp.protocol.IssuerDistinguishedName.getIssuerDistinguishedName;
+import static eu.webeid.security.testutil.Certificates.getJaakKristjanEsteid2018Cert;
+import static eu.webeid.security.testutil.Certificates.getMariliisEsteid2015Cert;
+import static eu.webeid.security.testutil.Certificates.getTestEsteid2015CA;
+import static eu.webeid.security.testutil.Certificates.getTestEsteid2018CA;
+import static eu.webeid.security.testutil.Certificates.getDemoEsteidSk2018AiaOcspResponder;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+
+/**
+ * Unit tests for the {@link AiaOcspService} behaviour introduced in commit e2bd57e3:
+ * nonce support is now keyed on the certificate's issuer distinguished name (previously the OCSP URL),
+ * and the service carries an optional {@link FallbackOcspService}.
+ */
+class AiaOcspServiceTest {
+
+ private static final URI ESTEID2018_AIA_OCSP_URI = URI.create("http://aia.demo.sk.ee/esteid2018");
+ private static final URI ESTEID2015_AIA_OCSP_URI = URI.create("http://aia.demo.sk.ee/esteid2015");
+ private static final URI FALLBACK_URI = URI.create("http://fallback.ocsp.test");
+
+ private static Set trustedCaAnchors;
+ private static CertStore trustedCaCertStore;
+ private static X509Certificate esteid2018UserCert;
+ private static X509Certificate esteid2015UserCert;
+ private static X500Name esteid2018IssuerDN;
+ private static X500Name esteid2015IssuerDN;
+
+ @BeforeAll
+ static void setUpFixtures() throws Exception {
+ List trustedCaCertificates = List.of(getTestEsteid2018CA(), getTestEsteid2015CA());
+ trustedCaAnchors = CertificateValidator.buildTrustAnchorsFromCertificates(trustedCaCertificates);
+ trustedCaCertStore = CertificateValidator.buildCertStoreFromCertificates(trustedCaCertificates);
+ esteid2018UserCert = getJaakKristjanEsteid2018Cert();
+ esteid2015UserCert = getMariliisEsteid2015Cert();
+ esteid2018IssuerDN = getIssuerDistinguishedName(esteid2018UserCert);
+ esteid2015IssuerDN = getIssuerDistinguishedName(esteid2015UserCert);
+ }
+
+ private static AiaOcspServiceConfiguration configurationWithNonceDisabledFor(X500Name... nonceDisabledIssuerDNs) {
+ return new AiaOcspServiceConfiguration(Set.of(nonceDisabledIssuerDNs), trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE);
+ }
+
+ @Test
+ void whenIssuerDnIsInNonceDisabledSet_thenDoesNotSupportNonce() throws Exception {
+ AiaOcspServiceConfiguration configuration = configurationWithNonceDisabledFor(esteid2015IssuerDN);
+
+ AiaOcspService service = new AiaOcspService(configuration, esteid2015UserCert, null);
+
+ assertThat(service.getAccessLocation()).isEqualTo(ESTEID2015_AIA_OCSP_URI);
+ assertThat(service.doesSupportNonce()).isFalse();
+ }
+
+ @Test
+ void whenIssuerDnIsNotInNonceDisabledSet_thenSupportsNonce() throws Exception {
+ // Only the ESTEID-SK 2015 issuer is nonce-disabled, so a certificate from the 2018 issuer must still support nonce.
+ AiaOcspServiceConfiguration configuration = configurationWithNonceDisabledFor(esteid2015IssuerDN);
+
+ AiaOcspService service = new AiaOcspService(configuration, esteid2018UserCert, null);
+
+ assertThat(service.getAccessLocation()).isEqualTo(ESTEID2018_AIA_OCSP_URI);
+ assertThat(service.doesSupportNonce()).isTrue();
+ }
+
+ @Test
+ void whenFallbackServiceProvided_thenGetFallbackServiceReturnsIt() throws Exception {
+ AiaOcspServiceConfiguration configuration = configurationWithNonceDisabledFor();
+ FallbackOcspServiceConfiguration fallbackConfiguration = new FallbackOcspServiceConfiguration(
+ FALLBACK_URI, getDemoEsteidSk2018AiaOcspResponder(), true,
+ null, esteid2018IssuerDN, trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE);
+ FallbackOcspService fallback = new FallbackOcspService(fallbackConfiguration);
+
+ AiaOcspService service = new AiaOcspService(configuration, esteid2018UserCert, fallback);
+
+ assertThat(service.getFallbackService()).containsSame(fallback);
+ assertThat(service.getFallbackService().get().getAccessLocation()).isEqualTo(FALLBACK_URI);
+ }
+
+ @Test
+ void whenFallbackServiceIsNull_thenGetFallbackServiceIsEmpty() throws Exception {
+ AiaOcspServiceConfiguration configuration = configurationWithNonceDisabledFor();
+
+ AiaOcspService service = new AiaOcspService(configuration, esteid2018UserCert, null);
+
+ assertThat(service.getFallbackService()).isEmpty();
+ }
+
+ @Test
+ void whenCertificateIsNull_thenThrows() {
+ AiaOcspServiceConfiguration configuration = configurationWithNonceDisabledFor();
+
+ assertThatExceptionOfType(NullPointerException.class)
+ .isThrownBy(() -> new AiaOcspService(configuration, null, null));
+ }
+}
diff --git a/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceConfigurationTest.java b/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceConfigurationTest.java
new file mode 100644
index 00000000..d638aed8
--- /dev/null
+++ b/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceConfigurationTest.java
@@ -0,0 +1,215 @@
+/*
+ * Copyright (c) 2020-2025 Estonian Information System Authority
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package eu.webeid.ocsp.service;
+
+import eu.webeid.ocsp.exceptions.OCSPCertificateException;
+import eu.webeid.security.certificate.CertificateValidator;
+import org.bouncycastle.asn1.x500.X500Name;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.net.URI;
+import java.security.cert.CertStore;
+import java.security.cert.TrustAnchor;
+import java.security.cert.X509Certificate;
+import java.time.Duration;
+import java.util.List;
+import java.util.Set;
+
+import static eu.webeid.ocsp.OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE;
+import static eu.webeid.ocsp.OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE;
+
+import static eu.webeid.security.testutil.Certificates.getDemoEsteidSk2018AiaOcspResponder;
+import static eu.webeid.security.testutil.Certificates.getJaakKristjanEsteid2018Cert;
+import static eu.webeid.security.testutil.Certificates.getTestEsteid2015CA;
+import static eu.webeid.security.testutil.Certificates.getTestEsteid2018CA;
+import static eu.webeid.security.testutil.Certificates.getTestSkOcspResponder2020;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.assertj.core.api.Assertions.assertThatNullPointerException;
+
+class FallbackOcspServiceConfigurationTest {
+
+ private static final URI FALLBACK_URI = URI.create("http://fallback.ocsp.test");
+ private static final X500Name ISSUER_DN = new X500Name("CN=TEST of ESTEID-SK 2015, OID.2.5.4.97=NTREE-10747013, O=AS Sertifitseerimiskeskus, C=EE");
+
+ private static Set trustedCaAnchors;
+ private static CertStore trustedCaCertStore;
+ private static X509Certificate aiaOcspResponderCert;
+ private static X509Certificate ocspResponder2020Cert;
+ private static X509Certificate nonSigningUserCert;
+
+ @BeforeAll
+ static void setUpFixtures() throws Exception {
+ List trustedCaCertificates = List.of(getTestEsteid2018CA(), getTestEsteid2015CA());
+ trustedCaAnchors = CertificateValidator.buildTrustAnchorsFromCertificates(trustedCaCertificates);
+ trustedCaCertStore = CertificateValidator.buildCertStoreFromCertificates(trustedCaCertificates);
+ // The DEMO AIA responder certificate satisfies every OCSP responder extension requirement.
+ aiaOcspResponderCert = getDemoEsteidSk2018AiaOcspResponder();
+ // TEST of SK OCSP RESPONDER 2020 carries no Key Usage extension at all.
+ ocspResponder2020Cert = getTestSkOcspResponder2020();
+ nonSigningUserCert = getJaakKristjanEsteid2018Cert();
+ }
+
+ @Test
+ void whenAccessLocationIsNull_thenThrows() {
+ assertThatNullPointerException()
+ .isThrownBy(() -> new FallbackOcspServiceConfiguration(
+ null, null, true, null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE))
+ .withMessage("Fallback OCSP service access location");
+ }
+
+ @Test
+ void whenIssuerDnIsNull_thenThrows() {
+ assertThatNullPointerException()
+ .isThrownBy(() -> new FallbackOcspServiceConfiguration(
+ FALLBACK_URI, null, true, null, null, trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE))
+ .withMessage("issuerDN");
+ }
+
+ @Test
+ void whenTrustedCaAnchorsIsNull_thenThrows() {
+ assertThatNullPointerException()
+ .isThrownBy(() -> new FallbackOcspServiceConfiguration(
+ FALLBACK_URI, null, true, null, ISSUER_DN, null, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE))
+ .withMessage("trustedCACertificateAnchors");
+ }
+
+ @Test
+ void whenTrustedCaCertStoreIsNull_thenThrows() {
+ assertThatNullPointerException()
+ .isThrownBy(() -> new FallbackOcspServiceConfiguration(
+ FALLBACK_URI, null, true, null, ISSUER_DN, trustedCaAnchors, null,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE))
+ .withMessage("trustedCACertificateCertStore");
+ }
+
+ @Test
+ void whenMaxThisUpdateAgeIsNull_thenThrows() {
+ assertThatNullPointerException()
+ .isThrownBy(() -> new FallbackOcspServiceConfiguration(
+ FALLBACK_URI, null, true, null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore,
+ null, DEFAULT_NEXT_UPDATE_AGE))
+ .withMessage("maxThisUpdateAge must not be null");
+ }
+
+ @Test
+ void whenMaxNextUpdateAgeIsNull_thenThrows() {
+ assertThatNullPointerException()
+ .isThrownBy(() -> new FallbackOcspServiceConfiguration(
+ FALLBACK_URI, null, true, null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, null))
+ .withMessage("maxNextUpdateAge must not be null");
+ }
+
+ @Test
+ void whenMaxThisUpdateAgeIsZero_thenThrows() {
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> new FallbackOcspServiceConfiguration(
+ FALLBACK_URI, null, true, null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore,
+ Duration.ZERO, DEFAULT_NEXT_UPDATE_AGE))
+ .withMessage("maxThisUpdateAge must be greater than zero");
+ }
+
+ @Test
+ void whenMaxThisUpdateAgeIsNegative_thenThrows() {
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> new FallbackOcspServiceConfiguration(
+ FALLBACK_URI, null, true, null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore,
+ Duration.ofMinutes(-1), DEFAULT_NEXT_UPDATE_AGE))
+ .withMessage("maxThisUpdateAge must be greater than zero");
+ }
+
+ @Test
+ void whenMaxNextUpdateAgeIsZero_thenThrows() {
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> new FallbackOcspServiceConfiguration(
+ FALLBACK_URI, null, true, null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, Duration.ZERO))
+ .withMessage("maxNextUpdateAge must be greater than zero");
+ }
+
+ @Test
+ void whenMaxNextUpdateAgeIsNegative_thenThrows() {
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> new FallbackOcspServiceConfiguration(
+ FALLBACK_URI, null, true, null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, Duration.ofMinutes(-1)))
+ .withMessage("maxNextUpdateAge must be greater than zero");
+ }
+
+ @Test
+ void whenResponderCertificateLacksSigningExtension_thenThrows() {
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() -> new FallbackOcspServiceConfiguration(
+ FALLBACK_URI, nonSigningUserCert, true, null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE))
+ .withMessageContaining("Extended Key Usage extension does not contain OCSP Signing, "
+ + "which is required for OCSP response signing");
+ }
+
+ @Test
+ void whenResponderCertificateLacksKeyUsageExtension_thenThrows() {
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() -> new FallbackOcspServiceConfiguration(
+ FALLBACK_URI, ocspResponder2020Cert, true, null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE))
+ .withMessageContaining("does not contain the Key Usage extension required for OCSP response signing");
+ }
+
+ @Test
+ void whenResponderCertificateIsNull_thenConstructionSucceeds() {
+ assertThatCode(() -> new FallbackOcspServiceConfiguration(
+ FALLBACK_URI, null, true, null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void whenResponderCertificateHasSigningExtension_thenConstructionSucceedsAndAccessorsReturnConfiguredValues() throws Exception {
+ FallbackOcspServiceConfiguration nextFallback = new FallbackOcspServiceConfiguration(
+ URI.create("http://next.fallback.ocsp.test"), null, false, null,
+ ISSUER_DN, trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE);
+
+ FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration(
+ FALLBACK_URI, aiaOcspResponderCert, true, nextFallback,
+ ISSUER_DN, trustedCaAnchors, trustedCaCertStore,
+ Duration.ofMinutes(3), Duration.ofMinutes(4));
+
+ assertThat(configuration.getAccessLocation()).isEqualTo(FALLBACK_URI);
+ assertThat(configuration.getResponderCertificate()).isEqualTo(aiaOcspResponderCert);
+ assertThat(configuration.doesSupportNonce()).isTrue();
+ assertThat(configuration.getNextFallbackConfiguration()).isSameAs(nextFallback);
+ assertThat(configuration.getIssuerDN()).isEqualTo(ISSUER_DN);
+ assertThat(configuration.getTrustedCACertificateAnchors()).isSameAs(trustedCaAnchors);
+ assertThat(configuration.getTrustedCACertificateCertStore()).isSameAs(trustedCaCertStore);
+ assertThat(configuration.getMaxThisUpdateAge()).isEqualTo(Duration.ofMinutes(3));
+ assertThat(configuration.getMaxNextUpdateAge()).isEqualTo(Duration.ofMinutes(4));
+ }
+}
diff --git a/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceTest.java b/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceTest.java
new file mode 100644
index 00000000..57267ab8
--- /dev/null
+++ b/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceTest.java
@@ -0,0 +1,252 @@
+/*
+ * Copyright (c) 2020-2025 Estonian Information System Authority
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package eu.webeid.ocsp.service;
+
+import eu.webeid.ocsp.exceptions.OCSPCertificateException;
+import eu.webeid.security.certificate.CertificateValidator;
+import eu.webeid.security.exceptions.CertificateExpiredException;
+import eu.webeid.security.exceptions.CertificateNotTrustedException;
+import org.bouncycastle.asn1.x500.X500Name;
+import org.bouncycastle.cert.X509CertificateHolder;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.net.URI;
+import java.security.cert.CertStore;
+import java.security.cert.TrustAnchor;
+import java.security.cert.X509Certificate;
+import java.util.Date;
+import java.util.List;
+import java.util.Set;
+
+import static eu.webeid.ocsp.OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE;
+import static eu.webeid.ocsp.OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE;
+import static eu.webeid.security.testutil.Certificates.getJaakKristjanEsteid2018Cert;
+import static eu.webeid.security.testutil.Certificates.getDemoEsteidSk2018AiaOcspResponder;
+import static eu.webeid.security.testutil.Certificates.getTestEsteid2015CA;
+import static eu.webeid.security.testutil.Certificates.getTestEsteid2018CA;
+import static eu.webeid.security.testutil.Certificates.getTestSelfSignedOcspResponder;
+import static eu.webeid.security.testutil.Certificates.getTestSkOcspResponder2020;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class FallbackOcspServiceTest {
+
+ private static final URI PRIMARY_FALLBACK_URI = URI.create("http://primary-fallback.ocsp.test");
+ private static final URI SECONDARY_FALLBACK_URI = URI.create("http://secondary-fallback.ocsp.test");
+ private static final X500Name ISSUER_DN = new X500Name("CN=TEST of ESTEID-SK 2015, OID.2.5.4.97=NTREE-10747013, O=AS Sertifitseerimiskeskus, C=EE");
+ private static final Date VALIDATION_DATE_WITHIN_RESPONDER_VALIDITY = new Date(1630000000000L);
+
+ private static Set trustedCaAnchors;
+ private static CertStore trustedCaCertStore;
+ private static X509Certificate aiaOcspResponderCert;
+ private static X509Certificate selfSignedOcspResponderCert;
+ private static X509Certificate ocspResponder2020Cert;
+ private static X509Certificate esteid2018CaCert;
+ private static X509Certificate nonSigningUserCert;
+
+ @BeforeAll
+ static void setUpFixtures() throws Exception {
+ List trustedCaCertificates = List.of(getTestEsteid2018CA(), getTestEsteid2015CA());
+ trustedCaAnchors = CertificateValidator.buildTrustAnchorsFromCertificates(trustedCaCertificates);
+ trustedCaCertStore = CertificateValidator.buildCertStoreFromCertificates(trustedCaCertificates);
+ // The DEMO AIA responder certificate satisfies the OCSP responder extension requirements
+ // (not a CA, Key Usage Digital Signature, no Certificate Signing, Extended Key Usage OCSP Signing)
+ // and it chains to the TEST of ESTEID2018 CA, which is one of the trusted CA anchors above.
+ aiaOcspResponderCert = getDemoEsteidSk2018AiaOcspResponder();
+ // The self-signed responder certificate satisfies the same extension requirements, but no
+ // trusted CA issued it. Use it when the PKIX trust check must be the failure.
+ selfSignedOcspResponderCert = getTestSelfSignedOcspResponder();
+ // TEST of SK OCSP RESPONDER 2020 carries no Key Usage extension at all. Use it only where the
+ // Key Usage check must reject the certificate.
+ ocspResponder2020Cert = getTestSkOcspResponder2020();
+ esteid2018CaCert = getTestEsteid2018CA();
+ nonSigningUserCert = getJaakKristjanEsteid2018Cert();
+ }
+
+ @Test
+ void whenConfigurationIsProvided_thenAccessorsReturnConfiguredValues() throws Exception {
+ FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration(
+ PRIMARY_FALLBACK_URI, aiaOcspResponderCert, true,
+ null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE);
+
+ FallbackOcspService service = new FallbackOcspService(configuration);
+
+ assertThat(service.getAccessLocation()).isEqualTo(PRIMARY_FALLBACK_URI);
+ assertThat(service.doesSupportNonce()).isTrue();
+ assertThat(service.getNextFallback()).isNull();
+ }
+
+ @Test
+ void whenNextFallbackConfigurationProvided_thenChainIsBuiltRecursively() throws Exception {
+ FallbackOcspServiceConfiguration secondaryConfiguration = new FallbackOcspServiceConfiguration(
+ SECONDARY_FALLBACK_URI, null, false,
+ null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE);
+ FallbackOcspServiceConfiguration primaryConfiguration = new FallbackOcspServiceConfiguration(
+ PRIMARY_FALLBACK_URI, null, true,
+ secondaryConfiguration, ISSUER_DN, trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE);
+
+ FallbackOcspService primary = new FallbackOcspService(primaryConfiguration);
+
+ FallbackOcspService secondary = primary.getNextFallback();
+ assertThat(secondary).isNotNull();
+ assertThat(secondary.getAccessLocation()).isEqualTo(SECONDARY_FALLBACK_URI);
+ assertThat(secondary.doesSupportNonce()).isFalse();
+ assertThat(secondary.getNextFallback()).isNull();
+ }
+
+ @Test
+ void whenResponderCertificateIsPinnedAndMatches_thenValidationSucceeds() throws Exception {
+ FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration(
+ PRIMARY_FALLBACK_URI, aiaOcspResponderCert, true,
+ null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE);
+ FallbackOcspService service = new FallbackOcspService(configuration);
+ X509CertificateHolder matchingHolder = new X509CertificateHolder(aiaOcspResponderCert.getEncoded());
+
+ assertThatCode(() ->
+ service.validateResponderCertificate(matchingHolder, VALIDATION_DATE_WITHIN_RESPONDER_VALIDITY))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void whenResponderCertificateIsPinnedAndDiffers_thenThrows() throws Exception {
+ FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration(
+ PRIMARY_FALLBACK_URI, aiaOcspResponderCert, true,
+ null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE);
+ FallbackOcspService service = new FallbackOcspService(configuration);
+ X509CertificateHolder differentHolder = new X509CertificateHolder(esteid2018CaCert.getEncoded());
+
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() ->
+ service.validateResponderCertificate(differentHolder, VALIDATION_DATE_WITHIN_RESPONDER_VALIDITY))
+ .withMessage("Responder certificate from the OCSP response is not equal to the configured fallback OCSP responder certificate");
+ }
+
+ @Test
+ void whenResponderCertificateIsNotPinnedButTrustedByCa_thenValidationSucceeds() throws Exception {
+ FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration(
+ PRIMARY_FALLBACK_URI, null, true,
+ null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE);
+ FallbackOcspService service = new FallbackOcspService(configuration);
+ // The DEMO AIA responder certificate chains to the TEST of ESTEID2018 CA, which is a trusted anchor.
+ X509CertificateHolder responderHolder = new X509CertificateHolder(aiaOcspResponderCert.getEncoded());
+
+ assertThatCode(() ->
+ service.validateResponderCertificate(responderHolder, VALIDATION_DATE_WITHIN_RESPONDER_VALIDITY))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void whenResponderCertificateIsNotPinnedAndLacksSigningExtension_thenThrows() throws Exception {
+ FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration(
+ PRIMARY_FALLBACK_URI, null, true,
+ null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE);
+ FallbackOcspService service = new FallbackOcspService(configuration);
+ X509CertificateHolder nonSigningHolder = new X509CertificateHolder(nonSigningUserCert.getEncoded());
+
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() ->
+ service.validateResponderCertificate(nonSigningHolder, VALIDATION_DATE_WITHIN_RESPONDER_VALIDITY))
+ .withMessageContaining("Extended Key Usage extension does not contain OCSP Signing, "
+ + "which is required for OCSP response signing");
+ }
+
+ @Test
+ void whenResponderCertificateLacksKeyUsageExtension_thenThrows() throws Exception {
+ // TEST of SK OCSP RESPONDER 2020 carries the OCSP-signing EKU, but it has no Key Usage extension.
+ // FallbackOcspService runs the certificate extension checks before the PKIX trust check, so the
+ // Key Usage check rejects this certificate whatever the configured trust anchors are.
+ FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration(
+ PRIMARY_FALLBACK_URI, null, true,
+ null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE);
+ FallbackOcspService service = new FallbackOcspService(configuration);
+ X509CertificateHolder noKeyUsageHolder = new X509CertificateHolder(ocspResponder2020Cert.getEncoded());
+
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() ->
+ service.validateResponderCertificate(noKeyUsageHolder, VALIDATION_DATE_WITHIN_RESPONDER_VALIDITY))
+ .withMessageContaining("does not contain the Key Usage extension required for OCSP response signing");
+ }
+
+ @Test
+ void whenResponderCertificateIsNotPinnedAndNotTrustedByCa_thenThrows() throws Exception {
+ FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration(
+ PRIMARY_FALLBACK_URI, null, true,
+ null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE);
+ FallbackOcspService service = new FallbackOcspService(configuration);
+ // The self-signed responder certificate satisfies every OCSP responder extension requirement, but no
+ // trusted CA issued it (only the ESTEID2018 and ESTEID-SK 2015 CAs are anchors), so PKIX path building fails.
+ X509CertificateHolder untrustedButEkuValidHolder = new X509CertificateHolder(selfSignedOcspResponderCert.getEncoded());
+
+ assertThatExceptionOfType(CertificateNotTrustedException.class)
+ .isThrownBy(() ->
+ service.validateResponderCertificate(untrustedButEkuValidHolder, VALIDATION_DATE_WITHIN_RESPONDER_VALIDITY));
+ }
+
+ @Test
+ void whenResponderCertificateHolderConversionFails_thenThrows() throws Exception {
+ FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration(
+ PRIMARY_FALLBACK_URI, aiaOcspResponderCert, true,
+ null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE);
+ FallbackOcspService service = new FallbackOcspService(configuration);
+ // Make JcaX509CertificateConverter.getCertificate(holder) fail: the converter calls holder.getEncoded()
+ // and wraps the resulting IOException into a CertificateException, which the service catches and rewraps.
+ X509CertificateHolder unconvertibleHolder = mock(X509CertificateHolder.class);
+ when(unconvertibleHolder.getEncoded()).thenThrow(new IOException("simulated encoding failure"));
+
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() ->
+ service.validateResponderCertificate(unconvertibleHolder, VALIDATION_DATE_WITHIN_RESPONDER_VALIDITY))
+ .withMessageContaining("X509CertificateHolder conversion to X509Certificate failed")
+ .withCauseInstanceOf(java.security.cert.CertificateException.class);
+ }
+
+ @Test
+ void whenResponderCertificateIsExpiredAtValidationDate_thenThrows() throws Exception {
+ FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration(
+ PRIMARY_FALLBACK_URI, aiaOcspResponderCert, true,
+ null, ISSUER_DN, trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE);
+ FallbackOcspService service = new FallbackOcspService(configuration);
+ X509CertificateHolder responderHolder = new X509CertificateHolder(aiaOcspResponderCert.getEncoded());
+ Date farFuture = new Date(4102444800000L); // 2100-01-01
+
+ assertThatExceptionOfType(CertificateExpiredException.class)
+ .isThrownBy(() ->
+ service.validateResponderCertificate(responderHolder, farFuture));
+ }
+}
diff --git a/src/test/java/eu/webeid/ocsp/service/OcspServiceConfigurationTest.java b/src/test/java/eu/webeid/ocsp/service/OcspServiceConfigurationTest.java
new file mode 100644
index 00000000..09c428fd
--- /dev/null
+++ b/src/test/java/eu/webeid/ocsp/service/OcspServiceConfigurationTest.java
@@ -0,0 +1,155 @@
+/*
+ * Copyright (c) 2020-2025 Estonian Information System Authority
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package eu.webeid.ocsp.service;
+
+import eu.webeid.ocsp.exceptions.OCSPCertificateException;
+import eu.webeid.security.certificate.CertificateValidator;
+import org.bouncycastle.asn1.x509.BasicConstraints;
+import org.bouncycastle.asn1.x509.ExtendedKeyUsage;
+import org.bouncycastle.asn1.x509.Extension;
+import org.bouncycastle.asn1.x509.KeyPurposeId;
+import org.bouncycastle.asn1.x509.KeyUsage;
+import org.junit.jupiter.api.Test;
+
+import java.net.URI;
+import java.security.cert.CertStore;
+import java.security.cert.TrustAnchor;
+import java.security.cert.X509Certificate;
+import java.util.List;
+import java.util.Set;
+
+import static eu.webeid.ocsp.OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE;
+import static eu.webeid.ocsp.OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE;
+import static eu.webeid.security.testutil.Certificates.getTestEsteid2018CA;
+import static eu.webeid.security.testutil.TestCertificateBuilder.buildCertificate;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+
+class OcspServiceConfigurationTest {
+
+ private static final URI OCSP_URL = URI.create("http://demo.sk.ee/ocsp");
+
+ @Test
+ void whenDesignatedOcspServiceConfigurationResponderCertIsCA_thenConstructorThrows() throws Exception {
+ final X509Certificate caResponder = buildCertificate(
+ new Extension(Extension.basicConstraints, true, new BasicConstraints(0).getEncoded()));
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() -> newDesignatedOcspServiceConfiguration(caResponder))
+ .withMessage("Certificate " + caResponder.getSubjectX500Principal() +
+ " must not be a CA certificate (Basic Constraints CA:TRUE is not allowed for OCSP responder)");
+ }
+
+ @Test
+ void whenDesignatedOcspServiceConfigurationResponderCertMissingKeyUsageDigitalSignature_thenConstructorThrows() throws Exception {
+ final X509Certificate responder = buildCertificate(
+ new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.nonRepudiation).getEncoded()));
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() -> newDesignatedOcspServiceConfiguration(responder))
+ .withMessage("Certificate " + responder.getSubjectX500Principal() +
+ " Key Usage extension does not contain Digital Signature, which is required for OCSP response signing");
+ }
+
+ @Test
+ void whenDesignatedOcspServiceConfigurationResponderCertHasKeyCertSign_thenConstructorThrows() throws Exception {
+ final X509Certificate responder = buildCertificate(
+ new Extension(Extension.keyUsage, true,
+ new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyCertSign).getEncoded()),
+ new Extension(Extension.extendedKeyUsage, true,
+ new ExtendedKeyUsage(KeyPurposeId.id_kp_OCSPSigning).getEncoded()));
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() -> newDesignatedOcspServiceConfiguration(responder))
+ .withMessage("Certificate " + responder.getSubjectX500Principal() +
+ " Key Usage extension contains Certificate Signing, which is not allowed for OCSP responder");
+ }
+
+ @Test
+ void whenDesignatedOcspServiceConfigurationResponderCertMissingOcspSigningEku_thenConstructorThrows() throws Exception {
+ final X509Certificate responder = buildCertificate(
+ new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature).getEncoded()),
+ new Extension(Extension.extendedKeyUsage, true,
+ new ExtendedKeyUsage(KeyPurposeId.id_kp_clientAuth).getEncoded()));
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() -> newDesignatedOcspServiceConfiguration(responder))
+ .withMessage("Certificate " + responder.getSubjectX500Principal() +
+ " Extended Key Usage extension does not contain OCSP Signing, which is required for OCSP response signing");
+ }
+
+ @Test
+ void whenFallbackOcspServiceConfigurationResponderCertIsCA_thenConstructorThrows() throws Exception {
+ final X509Certificate caResponder = buildCertificate(
+ new Extension(Extension.basicConstraints, true, new BasicConstraints(0).getEncoded()));
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() -> newFallbackOcspServiceConfiguration(caResponder))
+ .withMessage("Certificate " + caResponder.getSubjectX500Principal() +
+ " must not be a CA certificate (Basic Constraints CA:TRUE is not allowed for OCSP responder)");
+ }
+
+ @Test
+ void whenFallbackOcspServiceConfigurationResponderCertMissingKeyUsageDigitalSignature_thenConstructorThrows() throws Exception {
+ final X509Certificate responder = buildCertificate(
+ new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.nonRepudiation).getEncoded()));
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() -> newFallbackOcspServiceConfiguration(responder))
+ .withMessage("Certificate " + responder.getSubjectX500Principal() +
+ " Key Usage extension does not contain Digital Signature, which is required for OCSP response signing");
+ }
+
+ @Test
+ void whenFallbackOcspServiceConfigurationResponderCertHasKeyCertSign_thenConstructorThrows() throws Exception {
+ final X509Certificate responder = buildCertificate(
+ new Extension(Extension.keyUsage, true,
+ new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyCertSign).getEncoded()),
+ new Extension(Extension.extendedKeyUsage, true,
+ new ExtendedKeyUsage(KeyPurposeId.id_kp_OCSPSigning).getEncoded()));
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() -> newFallbackOcspServiceConfiguration(responder))
+ .withMessage("Certificate " + responder.getSubjectX500Principal() +
+ " Key Usage extension contains Certificate Signing, which is not allowed for OCSP responder");
+ }
+
+ @Test
+ void whenFallbackOcspServiceConfigurationResponderCertMissingOcspSigningEku_thenConstructorThrows() throws Exception {
+ final X509Certificate responder = buildCertificate(
+ new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature).getEncoded()),
+ new Extension(Extension.extendedKeyUsage, true,
+ new ExtendedKeyUsage(KeyPurposeId.id_kp_clientAuth).getEncoded()));
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() -> newFallbackOcspServiceConfiguration(responder))
+ .withMessage("Certificate " + responder.getSubjectX500Principal() +
+ " Extended Key Usage extension does not contain OCSP Signing, which is required for OCSP response signing");
+ }
+
+ private static void newDesignatedOcspServiceConfiguration(X509Certificate responder) throws Exception {
+ new DesignatedOcspServiceConfiguration(
+ OCSP_URL, responder, List.of(getTestEsteid2018CA()), true,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE);
+ }
+
+ private static void newFallbackOcspServiceConfiguration(X509Certificate responder) throws Exception {
+ final List trustedCAs = List.of(getTestEsteid2018CA());
+ final Set trustAnchors = CertificateValidator.buildTrustAnchorsFromCertificates(trustedCAs);
+ final CertStore certStore = CertificateValidator.buildCertStoreFromCertificates(trustedCAs);
+ new FallbackOcspServiceConfiguration(OCSP_URL, responder, true, null, null, trustAnchors, certStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE);
+ }
+
+}
diff --git a/src/test/java/eu/webeid/security/testutil/OcspServiceMaker.java b/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java
similarity index 77%
rename from src/test/java/eu/webeid/security/testutil/OcspServiceMaker.java
rename to src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java
index c8cf1120..46a4b2b5 100644
--- a/src/test/java/eu/webeid/security/testutil/OcspServiceMaker.java
+++ b/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java
@@ -1,36 +1,37 @@
// 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 org.bouncycastle.asn1.x500.X500Name;
import java.io.IOException;
import java.net.URI;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
-import java.util.Arrays;
+import java.time.Duration;
import java.util.List;
+import java.util.Set;
import static eu.webeid.security.testutil.Certificates.getTestEsteid2015CA;
import static eu.webeid.security.testutil.Certificates.getTestEsteid2018CA;
-import static eu.webeid.security.testutil.Certificates.getTestSkOcspResponder2020;
-import static eu.webeid.security.util.Collections.newHashSet;
+import static eu.webeid.security.testutil.Certificates.getTestSelfSignedOcspResponder;
public class OcspServiceMaker {
+ public static final Duration MAX_THIS_UPDATE_AGE = Duration.ofMinutes(3);
+ public static final Duration MAX_NEXT_UPDATE_AGE = Duration.ofMinutes(20);
+
private static final String TEST_OCSP_ACCESS_LOCATION = "http://demo.sk.ee/ocsp";
private static final List TRUSTED_CA_CERTIFICATES;
- private static final URI TEST_ESTEID_2015 = URI.create("http://aia.demo.sk.ee/esteid2015");
+ private static final X500Name ISSUER_DN = new X500Name("CN=TEST of ESTEID-SK 2015, OID.2.5.4.97=NTREE-10747013, O=AS Sertifitseerimiskeskus, C=EE");
static {
try {
- TRUSTED_CA_CERTIFICATES = Arrays.asList(getTestEsteid2018CA(), getTestEsteid2015CA());
+ TRUSTED_CA_CERTIFICATES = List.of(getTestEsteid2018CA(), getTestEsteid2015CA());
} catch (CertificateException | IOException e) {
throw new ExceptionInInitializerError(e);
}
@@ -54,9 +55,11 @@ public static OcspServiceProvider getDesignatedOcspServiceProvider(String ocspSe
private static AiaOcspServiceConfiguration getAiaOcspServiceConfiguration() throws JceException {
return new AiaOcspServiceConfiguration(
- newHashSet(TEST_ESTEID_2015),
+ Set.of(ISSUER_DN),
CertificateValidator.buildTrustAnchorsFromCertificates(TRUSTED_CA_CERTIFICATES),
- CertificateValidator.buildCertStoreFromCertificates(TRUSTED_CA_CERTIFICATES));
+ CertificateValidator.buildCertStoreFromCertificates(TRUSTED_CA_CERTIFICATES),
+ MAX_THIS_UPDATE_AGE,
+ MAX_NEXT_UPDATE_AGE);
}
public static DesignatedOcspServiceConfiguration getDesignatedOcspServiceConfiguration() throws CertificateException, IOException, OCSPCertificateException {
@@ -70,9 +73,11 @@ private static DesignatedOcspServiceConfiguration getDesignatedOcspServiceConfig
private static DesignatedOcspServiceConfiguration getDesignatedOcspServiceConfiguration(boolean doesSupportNonce, String ocspServiceAccessLocation) throws CertificateException, IOException, OCSPCertificateException {
return new DesignatedOcspServiceConfiguration(
URI.create(ocspServiceAccessLocation),
- getTestSkOcspResponder2020(),
+ getTestSelfSignedOcspResponder(),
TRUSTED_CA_CERTIFICATES,
- doesSupportNonce);
+ doesSupportNonce,
+ MAX_THIS_UPDATE_AGE,
+ MAX_NEXT_UPDATE_AGE);
}
}
diff --git a/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java b/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java
new file mode 100644
index 00000000..2036e730
--- /dev/null
+++ b/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java
@@ -0,0 +1,347 @@
+// SPDX-FileCopyrightText: Estonian Information System Authority
+// SPDX-License-Identifier: MIT
+
+package eu.webeid.ocsp.service;
+
+import eu.webeid.ocsp.OcspCertificateRevocationChecker;
+import eu.webeid.ocsp.exceptions.OCSPCertificateException;
+import eu.webeid.security.certificate.CertificateValidator;
+import eu.webeid.security.exceptions.CertificateNotTrustedException;
+import org.bouncycastle.asn1.x500.X500Name;
+import org.bouncycastle.asn1.x509.BasicConstraints;
+import org.bouncycastle.asn1.x509.ExtendedKeyUsage;
+import org.bouncycastle.asn1.x509.Extension;
+import org.bouncycastle.asn1.x509.KeyPurposeId;
+import org.bouncycastle.asn1.x509.KeyUsage;
+import org.bouncycastle.cert.X509CertificateHolder;
+import org.junit.jupiter.api.Test;
+
+import java.net.URI;
+import java.security.cert.CertStore;
+import java.security.cert.TrustAnchor;
+import java.security.cert.X509Certificate;
+import java.time.Duration;
+import java.util.Date;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+import static eu.webeid.ocsp.protocol.IssuerDistinguishedName.getIssuerDistinguishedName;
+import static eu.webeid.ocsp.service.OcspServiceMaker.getAiaOcspServiceProvider;
+import static eu.webeid.ocsp.service.OcspServiceMaker.getDesignatedOcspServiceConfiguration;
+import static eu.webeid.ocsp.service.OcspServiceMaker.getDesignatedOcspServiceProvider;
+import static eu.webeid.security.testutil.Certificates.getDemoEsteidSk2018AiaOcspResponder;
+import static eu.webeid.security.testutil.Certificates.getJaakKristjanEsteid2018Cert;
+import static eu.webeid.security.testutil.Certificates.getTestEsteid2015CA;
+import static eu.webeid.security.testutil.Certificates.getTestEsteid2018CA;
+import static eu.webeid.security.testutil.Certificates.getTestSelfSignedOcspResponder;
+import static eu.webeid.security.testutil.TestCertificateBuilder.buildCertificate;
+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());
+ assertThat(service.getAccessLocation()).isEqualTo(new URI("http://demo.sk.ee/ocsp"));
+ assertThat(service.doesSupportNonce()).isTrue();
+ assertThat(service.getMaxThisUpdateAge()).isEqualTo(Duration.ofMinutes(3));
+ assertThat(service.getMaxNextUpdateAge()).isEqualTo(Duration.ofMinutes(20));
+ assertThatCode(() ->
+ service.validateResponderCertificate(new X509CertificateHolder(getTestSelfSignedOcspResponder().getEncoded()), new Date(1630000000000L)))
+ .doesNotThrowAnyException();
+ 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();
+ assertThat(service2018.getMaxThisUpdateAge()).isEqualTo(Duration.ofMinutes(3));
+ assertThat(service2018.getMaxNextUpdateAge()).isEqualTo(Duration.ofMinutes(20));
+ assertThatCode(() ->
+ service2018.validateResponderCertificate(new X509CertificateHolder(getDemoEsteidSk2018AiaOcspResponder().getEncoded()), new Date(1630000000000L)))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void whenAiaOcspServiceConfigurationDoesNotHaveResponderCertTrustedCA_thenThrows() throws Exception {
+ final OcspServiceProvider ocspServiceProvider = getAiaOcspServiceProvider();
+ final OcspService service2018 = ocspServiceProvider.getService(getJaakKristjanEsteid2018Cert());
+ final X509Certificate untrustedResponder = getTestSelfSignedOcspResponder();
+ final X509CertificateHolder untrustedResponderCert = new X509CertificateHolder(untrustedResponder.getEncoded());
+ assertThatExceptionOfType(CertificateNotTrustedException.class)
+ .isThrownBy(() ->
+ service2018.validateResponderCertificate(untrustedResponderCert, new Date(1630000000000L)))
+ .withMessage("Certificate " + untrustedResponder.getSubjectX500Principal() + " is not trusted");
+ }
+
+ @Test
+ void whenAiaOcspResponderCertIsCA_thenThrows() throws Exception {
+ final OcspServiceProvider ocspServiceProvider = getAiaOcspServiceProvider();
+ final OcspService service2018 = ocspServiceProvider.getService(getJaakKristjanEsteid2018Cert());
+ final X509Certificate caResponder = buildCertificate(
+ new Extension(Extension.basicConstraints, true, new BasicConstraints(0).getEncoded()));
+ final X509CertificateHolder caResponderCert = new X509CertificateHolder(caResponder.getEncoded());
+
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() ->
+ service2018.validateResponderCertificate(caResponderCert, new Date()))
+ .withMessage("Certificate " + caResponder.getSubjectX500Principal() +
+ " must not be a CA certificate (Basic Constraints CA:TRUE is not allowed for OCSP responder)");
+ }
+
+ @Test
+ void whenAiaOcspResponderCertMissingKeyUsageDigitalSignature_thenThrows() throws Exception {
+ final OcspServiceProvider ocspServiceProvider = getAiaOcspServiceProvider();
+ final OcspService service2018 = ocspServiceProvider.getService(getJaakKristjanEsteid2018Cert());
+ final X509Certificate responderWithoutDigitalSignature = buildCertificate(
+ new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.nonRepudiation).getEncoded()));
+ final X509CertificateHolder responderCert = new X509CertificateHolder(responderWithoutDigitalSignature.getEncoded());
+
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() ->
+ service2018.validateResponderCertificate(responderCert, new Date()))
+ .withMessage("Certificate " + responderWithoutDigitalSignature.getSubjectX500Principal() +
+ " Key Usage extension does not contain Digital Signature, which is required for OCSP response signing");
+ }
+
+ @Test
+ void whenAiaOcspResponderCertMissingOcspSigningEku_thenThrows() throws Exception {
+ final OcspServiceProvider ocspServiceProvider = getAiaOcspServiceProvider();
+ final OcspService service2018 = ocspServiceProvider.getService(getJaakKristjanEsteid2018Cert());
+ final X509Certificate responderWithoutOcspSigning = buildCertificate(
+ new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature).getEncoded()),
+ new Extension(Extension.extendedKeyUsage, true,
+ new ExtendedKeyUsage(KeyPurposeId.id_kp_clientAuth).getEncoded()));
+ final X509CertificateHolder responderCert = new X509CertificateHolder(responderWithoutOcspSigning.getEncoded());
+
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() ->
+ service2018.validateResponderCertificate(responderCert, new Date()))
+ .withMessage("Certificate " + responderWithoutOcspSigning.getSubjectX500Principal() +
+ " Extended Key Usage extension does not contain OCSP Signing, which is required for OCSP response signing");
+ }
+
+ @Test
+ void whenFallbackOcspResponderCertIsCA_thenThrows() throws Exception {
+ final FallbackOcspService service = newFallbackOcspServiceWithoutPinnedResponder();
+ final X509Certificate caResponder = buildCertificate(
+ new Extension(Extension.basicConstraints, true, new BasicConstraints(0).getEncoded()));
+ final X509CertificateHolder caResponderCert = new X509CertificateHolder(caResponder.getEncoded());
+
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() ->
+ service.validateResponderCertificate(caResponderCert, new Date()))
+ .withMessage("Certificate " + caResponder.getSubjectX500Principal() +
+ " must not be a CA certificate (Basic Constraints CA:TRUE is not allowed for OCSP responder)");
+ }
+
+ @Test
+ void whenFallbackOcspResponderCertMissingKeyUsageDigitalSignature_thenThrows() throws Exception {
+ final FallbackOcspService service = newFallbackOcspServiceWithoutPinnedResponder();
+ final X509Certificate responderWithoutDigitalSignature = buildCertificate(
+ new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.nonRepudiation).getEncoded()));
+ final X509CertificateHolder responderCert = new X509CertificateHolder(responderWithoutDigitalSignature.getEncoded());
+
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() ->
+ service.validateResponderCertificate(responderCert, new Date()))
+ .withMessage("Certificate " + responderWithoutDigitalSignature.getSubjectX500Principal() +
+ " Key Usage extension does not contain Digital Signature, which is required for OCSP response signing");
+ }
+
+ @Test
+ void whenFallbackOcspResponderCertMissingOcspSigningEku_thenThrows() throws Exception {
+ final FallbackOcspService service = newFallbackOcspServiceWithoutPinnedResponder();
+ final X509Certificate responderWithoutOcspSigning = buildCertificate(
+ new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature).getEncoded()),
+ new Extension(Extension.extendedKeyUsage, true,
+ new ExtendedKeyUsage(KeyPurposeId.id_kp_clientAuth).getEncoded()));
+ final X509CertificateHolder responderCert = new X509CertificateHolder(responderWithoutOcspSigning.getEncoded());
+
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() ->
+ service.validateResponderCertificate(responderCert, new Date()))
+ .withMessage("Certificate " + responderWithoutOcspSigning.getSubjectX500Principal() +
+ " Extended Key Usage extension does not contain OCSP Signing, which is required for OCSP response signing");
+ }
+
+ private static FallbackOcspService newFallbackOcspServiceWithoutPinnedResponder() throws Exception {
+ final List trustedCAs = List.of(getTestEsteid2018CA());
+ final Set trustAnchors = CertificateValidator.buildTrustAnchorsFromCertificates(trustedCAs);
+ final CertStore certStore = CertificateValidator.buildCertStoreFromCertificates(trustedCAs);
+ final FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration(
+ URI.create("http://fallback.demo.sk.ee/ocsp"), null, true, null, new X500Name("CN=TEST ISSUER"), trustAnchors, certStore,
+ OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE,
+ OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE);
+ return new FallbackOcspService(configuration);
+ }
+
+ @Test
+ void whenFallbackOcspServiceConfigurationProvided_thenAiaServiceCarriesMatchingFallback() throws Exception {
+ X509Certificate userCert = getJaakKristjanEsteid2018Cert();
+ X500Name issuerDN = getIssuerDistinguishedName(userCert);
+ List trustedCertificates = List.of(getTestEsteid2018CA(), getTestEsteid2015CA());
+ Set trustedAnchors =
+ CertificateValidator.buildTrustAnchorsFromCertificates(trustedCertificates);
+ java.security.cert.CertStore trustedStore =
+ CertificateValidator.buildCertStoreFromCertificates(trustedCertificates);
+ URI fallbackUri = URI.create("http://fallback.test/ocsp");
+ FallbackOcspServiceConfiguration fallbackConfiguration = new FallbackOcspServiceConfiguration(
+ fallbackUri, getDemoEsteidSk2018AiaOcspResponder(), true,
+ null, issuerDN, trustedAnchors, trustedStore,
+ OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE,
+ OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE);
+
+ OcspServiceProvider provider = new OcspServiceProvider(null, getAiaOcspServiceProvider2018Configuration(),
+ List.of(fallbackConfiguration));
+ OcspService service = provider.getService(userCert);
+
+ assertThat(service).isInstanceOf(AiaOcspService.class);
+ Optional fallbackOpt = service.getFallbackService();
+ assertThat(fallbackOpt).isPresent();
+ FallbackOcspService fallback = fallbackOpt.get();
+ assertThat(fallback.getAccessLocation()).isEqualTo(fallbackUri);
+ assertThat(fallback.doesSupportNonce()).isTrue();
+ Date validationDate = new Date(1630000000000L);
+ assertThatCode(() ->
+ fallback.validateResponderCertificate(new X509CertificateHolder(getDemoEsteidSk2018AiaOcspResponder().getEncoded()), validationDate))
+ .doesNotThrowAnyException();
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() ->
+ fallback.validateResponderCertificate(new X509CertificateHolder(getTestEsteid2018CA().getEncoded()), validationDate))
+ .withMessage("Responder certificate from the OCSP response is not equal to the configured fallback OCSP responder certificate");
+ }
+
+ @Test
+ void whenFallbackOcspServiceConfigurationDoesNotMatchIssuer_thenAiaServiceCarriesNoFallback() throws Exception {
+ X509Certificate userCert = getJaakKristjanEsteid2018Cert();
+ List trustedCertificates = List.of(getTestEsteid2018CA(), getTestEsteid2015CA());
+ Set trustedAnchors =
+ CertificateValidator.buildTrustAnchorsFromCertificates(trustedCertificates);
+ java.security.cert.CertStore trustedStore =
+ CertificateValidator.buildCertStoreFromCertificates(trustedCertificates);
+ X500Name unrelatedIssuerDN = new X500Name("CN=Unrelated CA");
+ FallbackOcspServiceConfiguration fallbackConfiguration = new FallbackOcspServiceConfiguration(
+ URI.create("http://fallback.test/ocsp"), null, true,
+ null, unrelatedIssuerDN, trustedAnchors, trustedStore,
+ OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE,
+ OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE);
+
+ OcspServiceProvider provider = new OcspServiceProvider(null, getAiaOcspServiceProvider2018Configuration(),
+ List.of(fallbackConfiguration));
+ OcspService service = provider.getService(userCert);
+
+ assertThat(service.getFallbackService()).isEmpty();
+ }
+
+ @Test
+ void whenFallbackConfigurationsIsNull_thenServiceHasNoFallback() throws Exception {
+ X509Certificate userCert = getJaakKristjanEsteid2018Cert();
+ OcspServiceProvider provider = new OcspServiceProvider(null, getAiaOcspServiceProvider2018Configuration(), null);
+
+ OcspService service = provider.getService(userCert);
+
+ assertThat(service).isInstanceOf(AiaOcspService.class);
+ assertThat(service.getAccessLocation()).isEqualTo(new URI("http://aia.demo.sk.ee/esteid2018"));
+ assertThat(service.getFallbackService()).isEmpty();
+ }
+
+ @Test
+ void whenFallbackConfigurationsIsEmpty_thenServiceHasNoFallback() throws Exception {
+ X509Certificate userCert = getJaakKristjanEsteid2018Cert();
+ OcspServiceProvider provider = new OcspServiceProvider(null, getAiaOcspServiceProvider2018Configuration(), List.of());
+
+ OcspService service = provider.getService(userCert);
+
+ assertThat(service).isInstanceOf(AiaOcspService.class);
+ assertThat(service.getFallbackService()).isEmpty();
+ }
+
+ @Test
+ void whenDesignatedServiceSupportsIssuer_thenDesignatedTakesPrecedenceOverFallback() throws Exception {
+ X509Certificate userCert = getJaakKristjanEsteid2018Cert();
+ X500Name issuerDN = getIssuerDistinguishedName(userCert);
+ List trustedCertificates = List.of(getTestEsteid2018CA(), getTestEsteid2015CA());
+ Set trustedAnchors =
+ CertificateValidator.buildTrustAnchorsFromCertificates(trustedCertificates);
+ java.security.cert.CertStore trustedStore =
+ CertificateValidator.buildCertStoreFromCertificates(trustedCertificates);
+ FallbackOcspServiceConfiguration fallbackConfiguration = new FallbackOcspServiceConfiguration(
+ URI.create("http://fallback.test/ocsp"), getDemoEsteidSk2018AiaOcspResponder(), true,
+ null, issuerDN, trustedAnchors, trustedStore,
+ OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE,
+ OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE);
+
+ OcspServiceProvider provider = new OcspServiceProvider(getDesignatedOcspServiceConfiguration(),
+ getAiaOcspServiceProvider2018Configuration(), List.of(fallbackConfiguration));
+ OcspService service = provider.getService(userCert);
+
+ assertThat(service).isInstanceOf(DesignatedOcspService.class);
+ assertThat(service.getAccessLocation()).isEqualTo(new URI("http://demo.sk.ee/ocsp"));
+ assertThat(service.getFallbackService()).isEmpty();
+ }
+
+ @Test
+ void whenDuplicateIssuerFallbackConfigurations_thenLastOneWins() throws Exception {
+ X509Certificate userCert = getJaakKristjanEsteid2018Cert();
+ X500Name issuerDN = getIssuerDistinguishedName(userCert);
+ List trustedCertificates = List.of(getTestEsteid2018CA(), getTestEsteid2015CA());
+ Set trustedAnchors =
+ CertificateValidator.buildTrustAnchorsFromCertificates(trustedCertificates);
+ java.security.cert.CertStore trustedStore =
+ CertificateValidator.buildCertStoreFromCertificates(trustedCertificates);
+ URI firstFallbackUri = URI.create("http://fallback-first.test/ocsp");
+ URI lastFallbackUri = URI.create("http://fallback-last.test/ocsp");
+ FallbackOcspServiceConfiguration firstConfiguration = new FallbackOcspServiceConfiguration(
+ firstFallbackUri, getDemoEsteidSk2018AiaOcspResponder(), true,
+ null, issuerDN, trustedAnchors, trustedStore,
+ OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE,
+ OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE);
+ FallbackOcspServiceConfiguration lastConfiguration = new FallbackOcspServiceConfiguration(
+ lastFallbackUri, getDemoEsteidSk2018AiaOcspResponder(), true,
+ null, issuerDN, trustedAnchors, trustedStore,
+ OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE,
+ OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE);
+
+ OcspServiceProvider provider = new OcspServiceProvider(null, getAiaOcspServiceProvider2018Configuration(),
+ List.of(firstConfiguration, lastConfiguration));
+ OcspService service = provider.getService(userCert);
+
+ Optional fallbackOpt = service.getFallbackService();
+ assertThat(fallbackOpt).isPresent();
+ assertThat(fallbackOpt.get().getAccessLocation()).isEqualTo(lastFallbackUri);
+ }
+
+ private static AiaOcspServiceConfiguration getAiaOcspServiceProvider2018Configuration() throws Exception {
+ List trustedCertificates = List.of(getTestEsteid2018CA(), getTestEsteid2015CA());
+ return new AiaOcspServiceConfiguration(
+ Set.of(),
+ CertificateValidator.buildTrustAnchorsFromCertificates(trustedCertificates),
+ CertificateValidator.buildCertStoreFromCertificates(trustedCertificates),
+ OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE,
+ OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE);
+ }
+}
+
+// Old disabled example AuthTokenValidator test with designated OCSP check.
+//
+// @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);
+// }
diff --git a/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java b/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java
new file mode 100644
index 00000000..84c1fdb9
--- /dev/null
+++ b/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java
@@ -0,0 +1,1140 @@
+/*
+ * Copyright (c) 2020-2025 Estonian Information System Authority
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package eu.webeid.resilientocsp;
+
+import eu.webeid.ocsp.OcspCertificateRevocationChecker;
+import eu.webeid.ocsp.client.OcspClient;
+import eu.webeid.ocsp.exceptions.OCSPClientException;
+import eu.webeid.ocsp.exceptions.UserCertificateOCSPException;
+import eu.webeid.ocsp.service.FallbackOcspService;
+import eu.webeid.ocsp.service.OcspService;
+import eu.webeid.ocsp.service.OcspServiceProvider;
+import eu.webeid.resilientocsp.ResilientOcspCertificateRevocationChecker.CircuitBreakerStatistics;
+import eu.webeid.resilientocsp.exceptions.ResilientUserCertificateOCSPCheckFailedException;
+import eu.webeid.resilientocsp.exceptions.ResilientUserCertificateRevokedException;
+import eu.webeid.security.authtoken.WebEidAuthToken;
+import eu.webeid.security.util.DateAndTime;
+import eu.webeid.security.validator.AuthTokenValidator;
+import eu.webeid.security.validator.revocationcheck.RevocationInfo;
+import io.github.resilience4j.circuitbreaker.CircuitBreaker;
+import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
+import io.github.resilience4j.retry.RetryConfig;
+import org.bouncycastle.asn1.ocsp.OCSPResponseStatus;
+import org.bouncycastle.cert.ocsp.BasicOCSPResp;
+import org.bouncycastle.cert.ocsp.CertificateStatus;
+import org.bouncycastle.cert.ocsp.OCSPResp;
+import org.bouncycastle.cert.ocsp.RevokedStatus;
+import org.bouncycastle.cert.ocsp.SingleResp;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+import java.net.URI;
+import java.security.cert.CertificateEncodingException;
+import java.security.cert.X509Certificate;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static eu.webeid.security.testutil.AbstractTestWithValidator.VALID_AUTH_TOKEN;
+import static eu.webeid.security.testutil.AbstractTestWithValidator.VALID_CHALLENGE_NONCE;
+import static eu.webeid.security.testutil.AuthTokenValidators.getDefaultAuthTokenValidatorBuilder;
+import static eu.webeid.security.testutil.Certificates.getJaakKristjanEsteid2018Cert;
+import static eu.webeid.security.testutil.Certificates.getTestEsteid2018CA;
+import static eu.webeid.security.testutil.DateMocker.mockDate;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static eu.webeid.security.testutil.ResourceUtil.bytesFromResource;
+import static org.awaitility.Awaitility.await;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class ResilientOcspCertificateRevocationCheckerTest {
+
+ private static final URI PRIMARY_URI = URI.create("http://primary.ocsp.test");
+ private static final URI FALLBACK_URI = URI.create("http://fallback.ocsp.test");
+ private static final URI SECOND_FALLBACK_URI = URI.create("http://second-fallback.ocsp.test");
+
+ private static final Duration LONG_THIS_UPDATE_AGE = Duration.ofDays(365 * 10);
+ private static final Duration LONG_NEXT_UPDATE_AGE = Duration.ofDays(365 * 10);
+
+ // The OCSP DER fixtures do not share one thisUpdate. Each fixture carries its own:
+ // ocsp_response.der 2021-09-17T18:25:24
+ // ocsp_response_revoked.der 2021-09-18T00:13:43
+ // ocsp_response_unknown.der 2021-09-18T00:16:25
+ // The two constants below belong to ocsp_response.der, and every test that uses them pairs them with
+ // that fixture. Do not pair them with the other two fixtures: a clock set from these values is earlier
+ // than their thisUpdate by more than the allowed time skew, so the library rejects the response as
+ // issued too far in the future and the test fails. The unknown-status tests use
+ // WITHIN_RESPONDER_CERT_VALIDITY instead, and the revoked-status tests do not mock the clock at all.
+ private static final String DER_THIS_UPDATE = "2021-09-17T18:25:24";
+ private static final String FIVE_MIN_AFTER_THIS_UPDATE = "2021-09-17T18:30:24";
+ // Used by the unknown-status tests, where the age limit is the relaxed LONG_THIS_UPDATE_AGE, so only the
+ // OCSP responder certificate validity window matters (this value sits within it).
+ private static final String WITHIN_RESPONDER_CERT_VALIDITY = "2021-09-18T00:16:25";
+
+ private X509Certificate estEid2018Cert;
+ private X509Certificate testEsteid2018CA;
+
+ private OCSPResp ocspRespGood;
+ private OCSPResp ocspRespRevoked;
+ private OCSPResp ocspRespUnknown;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ estEid2018Cert = getJaakKristjanEsteid2018Cert();
+ testEsteid2018CA = getTestEsteid2018CA();
+ ocspRespGood = new OCSPResp(bytesFromResource("ocsp_response.der"));
+ ocspRespRevoked = new OCSPResp(bytesFromResource("ocsp_response_revoked.der"));
+ ocspRespUnknown = new OCSPResp(bytesFromResource("ocsp_response_unknown.der"));
+ }
+
+ @Test
+ void whenMultipleValidationCalls_thenPreviousResultsAreNotModified() throws Exception {
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any()))
+ .thenThrow(new OCSPClientException("Primary OCSP service unavailable (call1)"))
+ .thenThrow(new OCSPClientException("Primary OCSP service unavailable (call2)"));
+ when(ocspClient.request(eq(FALLBACK_URI), any()))
+ .thenThrow(new OCSPClientException("Fallback OCSP service unavailable (call1)"))
+ .thenThrow(new OCSPClientException("Fallback OCSP service unavailable (call2)"));
+ when(ocspClient.request(eq(SECOND_FALLBACK_URI), any()))
+ .thenThrow(new OCSPClientException("Secondary fallback OCSP service unavailable (call1)"))
+ .thenThrow(new OCSPClientException("Secondary fallback OCSP service unavailable (call2)"));
+ ResilientOcspCertificateRevocationChecker resilientChecker = checkerBuilder(ocspClient).build();
+ AuthTokenValidator validator = getDefaultAuthTokenValidatorBuilder()
+ .withCertificateRevocationChecker(resilientChecker)
+ .build();
+ WebEidAuthToken authToken = validator.parse(VALID_AUTH_TOKEN);
+
+ ResilientUserCertificateOCSPCheckFailedException ex1 = assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() -> validator.validate(authToken, VALID_CHALLENGE_NONCE))
+ .actual();
+ List revocationInfo1 = ex1.getValidationInfo().revocationInfoList();
+ assertThat(revocationInfo1).hasSize(3);
+ assertThat(revocationInfo1)
+ .extracting(ri -> ((OCSPClientException) ri.ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR)).getMessage())
+ .containsExactly(
+ "Primary OCSP service unavailable (call1)",
+ "Fallback OCSP service unavailable (call1)",
+ "Secondary fallback OCSP service unavailable (call1)"
+ );
+ ResilientUserCertificateOCSPCheckFailedException ex2 = assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() -> validator.validate(authToken, VALID_CHALLENGE_NONCE))
+ .actual();
+ List revocationInfo2 = ex2.getValidationInfo().revocationInfoList();
+ assertThat(revocationInfo2).hasSize(3);
+ assertThat(revocationInfo2)
+ .extracting(ri -> ((OCSPClientException) ri.ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR)).getMessage())
+ .containsExactly(
+ "Primary OCSP service unavailable (call2)",
+ "Fallback OCSP service unavailable (call2)",
+ "Secondary fallback OCSP service unavailable (call2)"
+ );
+ assertThat(revocationInfo1).hasSize(3);
+ assertThat(revocationInfo1)
+ .extracting(ri -> ((OCSPClientException) ri.ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR)).getMessage())
+ .containsExactly(
+ "Primary OCSP service unavailable (call1)",
+ "Fallback OCSP service unavailable (call1)",
+ "Secondary fallback OCSP service unavailable (call1)"
+ );
+ }
+
+ @Test
+ void whenMaxAttemptsIsTwoAndAllCallsFail_thenRevocationInfoListRecordsRetriedPrimaryThenBothFallbacks() throws Exception {
+ // The Retry decorator wraps only the primary supplier, so maxAttempts(2) records two primary attempts
+ // before the two fallbacks. Asserting the responder order (primary, primary, fallback, second fallback)
+ // and the two distinct primary error messages proves the fourth element comes from the retried primary,
+ // not just that the list happens to have four elements.
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any()))
+ .thenThrow(new OCSPClientException("primary attempt 1"))
+ .thenThrow(new OCSPClientException("primary attempt 2"));
+ when(ocspClient.request(eq(FALLBACK_URI), any()))
+ .thenThrow(new OCSPClientException());
+ when(ocspClient.request(eq(SECOND_FALLBACK_URI), any()))
+ .thenThrow(new OCSPClientException());
+
+ RetryConfig retryConfig = RetryConfig.custom()
+ .maxAttempts(2)
+ .build();
+
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withRetryConfig(retryConfig).build();
+ assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .satisfies(ex -> {
+ List revocationInfoList = ex.getValidationInfo().revocationInfoList();
+ assertThat(revocationInfoList).hasSize(4);
+ assertThat(revocationInfoList).extracting(RevocationInfo::ocspResponderUri)
+ .containsExactly(PRIMARY_URI, PRIMARY_URI, FALLBACK_URI, SECOND_FALLBACK_URI);
+ assertThat(((OCSPClientException) revocationInfoList.get(0).ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR)).getMessage())
+ .isEqualTo("primary attempt 1");
+ assertThat(((OCSPClientException) revocationInfoList.get(1).ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR)).getMessage())
+ .isEqualTo("primary attempt 2");
+ });
+ }
+
+ @Test
+ void whenMaxAttemptsIsTwoAndFirstCallFails_thenTwoCallsToPrimaryShouldBeRecorded() throws Exception {
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any()))
+ .thenThrow(new OCSPClientException("Primary OCSP service unavailable (call1)"))
+ .thenReturn(ocspRespGood);
+
+ RetryConfig retryConfig = RetryConfig.custom()
+ .maxAttempts(2)
+ .build();
+
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withRetryConfig(retryConfig).build();
+ List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+ assertThat(revocationInfoList.size()).isEqualTo(2);
+
+ Map firstResponseAttributes = revocationInfoList.get(0).ocspResponseAttributes();
+ OCSPClientException ex1 = (OCSPClientException) firstResponseAttributes.get(RevocationInfo.KEY_OCSP_ERROR);
+ assertThat(ex1.getMessage()).isEqualTo("Primary OCSP service unavailable (call1)");
+
+ assertThat(getCertificateStatus(revocationInfoList.get(1))).isEqualTo(CertificateStatus.GOOD);
+ }
+
+ @Test
+ void whenPrimaryReturnsRevoked_thenRevocationInfoListShouldHaveOneElementAndItShouldHaveRevokedStatus() throws Exception {
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any()))
+ .thenReturn(ocspRespRevoked);
+
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build();
+ assertThatExceptionOfType(ResilientUserCertificateRevokedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .satisfies(ex -> {
+ List revocationInfoList = ex.getValidationInfo().revocationInfoList();
+ assertThat(revocationInfoList.size()).isEqualTo(1);
+ assertThat(getCertificateStatus(revocationInfoList.get(0))).isInstanceOf(RevokedStatus.class);
+ });
+ }
+
+ @Test
+ void whenCallerConfigRecordsAllExceptions_thenRevokedVerdictDoesNotOpenCircuitBreaker() throws Exception {
+ // A revoked verdict is a definitive OCSP answer, so it must never count as a circuit breaker failure.
+ // CircuitBreakerConfig.from() copies both the record predicate and the recordExceptions array of the
+ // caller configuration, and the array is combined with our own predicate by OR. A caller that records
+ // every exception must therefore not be able to make a revoked verdict open the circuit breaker.
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any()))
+ .thenReturn(ocspRespRevoked);
+ when(ocspClient.request(eq(FALLBACK_URI), any()))
+ .thenReturn(ocspRespGood);
+ CircuitBreakerConfig callerConfig = CircuitBreakerConfig.custom()
+ .recordExceptions(Throwable.class)
+ .slidingWindowSize(2)
+ .minimumNumberOfCalls(2)
+ .failureRateThreshold(50)
+ .permittedNumberOfCallsInHalfOpenState(1)
+ .build();
+
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient)
+ .withFallbacks(FALLBACK_URI)
+ .withCircuitBreakerConfig(callerConfig)
+ .build();
+
+ // The configuration above would open the circuit breaker after two recorded failures. All three calls
+ // must still report revocation from the primary service, and no call must reach the fallback service.
+ for (int i = 0; i < 3; i++) {
+ assertThatExceptionOfType(ResilientUserCertificateRevokedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA));
+ }
+ verify(ocspClient, times(3)).request(eq(PRIMARY_URI), any());
+ verify(ocspClient, never()).request(eq(FALLBACK_URI), any());
+ }
+
+ @Test
+ void whenOneFallbackIsConfiguredAndPrimaryAndFallbackFail_thenRevocationInfoListShouldHaveTwoElements() throws Exception {
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any()))
+ .thenThrow(new OCSPClientException());
+ when(ocspClient.request(eq(FALLBACK_URI), any()))
+ .thenThrow(new OCSPClientException());
+
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withFallbacks(FALLBACK_URI).build();
+
+ assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .satisfies(ex -> {
+ List revocationInfoList = ex.getValidationInfo().revocationInfoList();
+ assertThat(revocationInfoList.size()).isEqualTo(2);
+ });
+ }
+
+ @Test
+ void whenNoFallbacksAreConfigured_thenRevocationInfoListShouldHaveOneElement() throws Exception {
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any()))
+ .thenThrow(new OCSPClientException());
+
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withoutFallbacks().build();
+
+ assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .satisfies(ex -> {
+ List revocationInfoList = ex.getValidationInfo().revocationInfoList();
+ assertThat(revocationInfoList.size()).isEqualTo(1);
+ });
+ }
+
+ @Test
+ void whenPrimaryReturnsUnauthorizedOcspResponseStatus_thenWrapsResponseStatusError() throws Exception {
+ OCSPResp ocspRespStatusUnauthorized = new OCSPResp(bytesFromResource("ocsp_response_unauthorized.der"));
+
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any()))
+ .thenReturn(ocspRespStatusUnauthorized);
+
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build();
+ assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .satisfies(ex -> {
+ Map responseAttributes = ex.getValidationInfo().revocationInfoList().get(0).ocspResponseAttributes();
+ ResilientUserCertificateOCSPCheckFailedException firstException = (ResilientUserCertificateOCSPCheckFailedException) responseAttributes.get(RevocationInfo.KEY_OCSP_ERROR);
+ assertThat(firstException.getMessage()).isEqualTo("Response status: unauthorized");
+ });
+ }
+
+ @Test
+ void whenOcspServiceProviderThrowsCertificateException_thenThrows() throws Exception {
+ UserCertificateOCSPException encodingException = new UserCertificateOCSPException("Resolving primary OCSP service from subject certificate failed");
+ OcspServiceProvider ocspServiceProvider = mock(OcspServiceProvider.class);
+ when(ocspServiceProvider.getService(any())).thenThrow(encodingException);
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(mock(OcspClient.class))
+ .withOcspServiceProvider(ocspServiceProvider)
+ .build();
+
+ assertThatExceptionOfType(UserCertificateOCSPException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .isExactlyInstanceOf(UserCertificateOCSPException.class)
+ .withMessage("Resolving primary OCSP service from subject certificate failed");
+ }
+
+ @Test
+ void whenCertificateIdComputationFails_thenThrows() throws Exception {
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(mock(OcspClient.class)).build();
+ X509Certificate badIssuer = mock(X509Certificate.class);
+ CertificateEncodingException encodingException = new CertificateEncodingException("bad issuer");
+ when(badIssuer.getEncoded()).thenThrow(encodingException);
+
+ assertThatExceptionOfType(UserCertificateOCSPException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, badIssuer))
+ .isExactlyInstanceOf(UserCertificateOCSPException.class)
+ .withMessage("Unable to compute certificateId for subject certificate")
+ .withCause(encodingException);
+ }
+
+ @Test
+ void whenNoFallbackConfiguredAndPrimarySucceeds_thenPrimaryResponseIsReturned() throws Exception {
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespGood);
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withoutFallbacks().build();
+
+ List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+
+ assertThat(revocationInfoList).hasSize(1);
+ assertThat(revocationInfoList.get(0).ocspResponderUri()).isEqualTo(PRIMARY_URI);
+ assertThat(getCertificateStatus(revocationInfoList.get(0))).isEqualTo(CertificateStatus.GOOD);
+ }
+
+ @Test
+ void whenPrimaryReturnsRevoked_thenNotRetried() throws Exception {
+ // A revoked verdict is a definitive answer; the Retry config ignores
+ // ResilientUserCertificateRevokedException, so the primary is queried exactly once even with maxAttempts(2).
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespRevoked);
+ RetryConfig retryConfig = RetryConfig.custom().maxAttempts(2).waitDuration(Duration.ZERO).build();
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withRetryConfig(retryConfig).build();
+
+ assertThatExceptionOfType(ResilientUserCertificateRevokedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA));
+ verify(ocspClient, times(1)).request(eq(PRIMARY_URI), any());
+ verify(ocspClient, never()).request(eq(FALLBACK_URI), any());
+ }
+
+ @Test
+ void whenPrimaryReturnsRevoked_thenCircuitBreakerDoesNotOpen() throws Exception {
+ // The CircuitBreaker config ignores ResilientUserCertificateRevokedException, so repeated revoked
+ // verdicts are not counted as failures and the breaker stays closed. With a config that would trip
+ // after two real failures, the primary is still queried on the third call.
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespRevoked);
+ CircuitBreakerConfig tightCircuitBreakerConfig = CircuitBreakerConfig.custom()
+ .slidingWindowSize(2)
+ .minimumNumberOfCalls(2)
+ .failureRateThreshold(50)
+ .permittedNumberOfCallsInHalfOpenState(1)
+ .build();
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient)
+ .withFallbacks(FALLBACK_URI)
+ .withCircuitBreakerConfig(tightCircuitBreakerConfig)
+ .build();
+
+ assertThatExceptionOfType(ResilientUserCertificateRevokedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA));
+ assertThatExceptionOfType(ResilientUserCertificateRevokedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA));
+ assertThatExceptionOfType(ResilientUserCertificateRevokedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA));
+
+ verify(ocspClient, times(3)).request(eq(PRIMARY_URI), any());
+ verify(ocspClient, never()).request(eq(FALLBACK_URI), any());
+ }
+
+ @Test
+ void whenPrimaryFailsAndFirstFallbackReturnsRevoked_thenListContainsBothEntries() throws Exception {
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any()))
+ .thenThrow(new OCSPClientException("Primary OCSP service unavailable"));
+ when(ocspClient.request(eq(FALLBACK_URI), any())).thenReturn(ocspRespRevoked);
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build();
+
+ assertThatExceptionOfType(ResilientUserCertificateRevokedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .withMessage("User certificate has been revoked")
+ .satisfies(ex -> {
+ List revocationInfoList = ex.getValidationInfo().revocationInfoList();
+ assertThat(revocationInfoList).hasSize(2);
+ assertThat(revocationInfoList).extracting(RevocationInfo::ocspResponderUri)
+ .containsExactly(PRIMARY_URI, FALLBACK_URI);
+ assertThat(revocationInfoList.get(0).ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR))
+ .isInstanceOf(OCSPClientException.class);
+ assertThat(getCertificateStatus(revocationInfoList.get(1))).isInstanceOf(RevokedStatus.class);
+ });
+ verify(ocspClient, never()).request(eq(SECOND_FALLBACK_URI), any());
+ }
+
+ @Test
+ void whenPrimaryReturnsMissingBasicOcspResponse_thenThrows() throws Exception {
+ OCSPResp response = mock(OCSPResp.class);
+ when(response.getStatus()).thenReturn(OCSPResponseStatus.SUCCESSFUL);
+ when(response.getResponseObject()).thenReturn(null);
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(response);
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build();
+
+ assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .satisfies(ex -> {
+ Map primaryAttributes = ex.getValidationInfo().revocationInfoList().get(0).ocspResponseAttributes();
+ ResilientUserCertificateOCSPCheckFailedException primaryError =
+ (ResilientUserCertificateOCSPCheckFailedException) primaryAttributes.get(RevocationInfo.KEY_OCSP_ERROR);
+ assertThat(primaryError.getMessage()).isEqualTo("Missing Basic OCSP Response");
+ });
+ }
+
+ @Test
+ void whenPrimaryReturnsUnknown_thenFallbackHandlesTheCall() throws Exception {
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespUnknown);
+ when(ocspClient.request(eq(FALLBACK_URI), any())).thenReturn(ocspRespGood);
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build();
+
+ try (var mockedClock = mockStaticClockAt(WITHIN_RESPONDER_CERT_VALIDITY)) {
+ List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+
+ assertThat(revocationInfoList).hasSize(2);
+ verify(ocspClient).request(eq(FALLBACK_URI), any());
+ }
+ }
+
+ @Test
+ void whenNonceEnabledAndResponseNonceDiffers_thenThrows() throws Exception {
+ // primaryService advertises nonce support; ocspRespGood was signed with a different nonce.
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespGood);
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient)
+ .withoutFallbacks()
+ .withPrimaryNonceSupport()
+ .build();
+
+ try (var ignored = mockStaticClockAt(DER_THIS_UPDATE)) {
+ assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .satisfies(ex -> {
+ Throwable originalError = (Throwable) ex.getValidationInfo().revocationInfoList().get(0)
+ .ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR);
+ assertThat(originalError).hasMessageContaining("OCSP request and response nonces differ");
+ });
+ }
+ }
+
+ @Test
+ void whenCircuitBreakerIsOpenAndRecoveryTimeElapses_thenPrimaryIsTriedAgainInHalfOpenState() throws Exception {
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any()))
+ .thenThrow(new OCSPClientException("Primary OCSP service unavailable"))
+ .thenThrow(new OCSPClientException("Primary OCSP service unavailable"))
+ .thenReturn(ocspRespGood);
+ when(ocspClient.request(eq(FALLBACK_URI), any())).thenReturn(ocspRespGood);
+ CircuitBreakerConfig recoverableCircuitBreakerConfig = CircuitBreakerConfig.custom()
+ .slidingWindowSize(2)
+ .minimumNumberOfCalls(2)
+ .failureRateThreshold(50)
+ .waitDurationInOpenState(Duration.ofSeconds(1))
+ .permittedNumberOfCallsInHalfOpenState(1)
+ .build();
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient)
+ .withFallbacks(FALLBACK_URI)
+ .withCircuitBreakerConfig(recoverableCircuitBreakerConfig)
+ .build();
+
+ checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+ checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+
+ List openStateRevocationInfoList =
+ checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+
+ assertThat(openStateRevocationInfoList).hasSize(1);
+ assertThat(openStateRevocationInfoList.get(0).ocspResponderUri()).isEqualTo(FALLBACK_URI);
+ verify(ocspClient, times(2)).request(eq(PRIMARY_URI), any());
+
+ await().atMost(Duration.ofSeconds(5))
+ .pollInterval(Duration.ofMillis(50))
+ .untilAsserted(() -> {
+ List halfOpenRevocationInfoList =
+ checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+ assertThat(halfOpenRevocationInfoList).hasSize(1);
+ assertThat(halfOpenRevocationInfoList.get(0).ocspResponderUri()).isEqualTo(PRIMARY_URI);
+ });
+ verify(ocspClient, times(3)).request(eq(PRIMARY_URI), any());
+ }
+
+ @Test
+ void whenPrimaryAnswersInHalfOpenState_thenStatisticsReportHalfOpenState() throws Exception {
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any()))
+ .thenThrow(new OCSPClientException("Primary OCSP service unavailable"))
+ .thenThrow(new OCSPClientException("Primary OCSP service unavailable"))
+ .thenReturn(ocspRespGood);
+ when(ocspClient.request(eq(FALLBACK_URI), any())).thenReturn(ocspRespGood);
+ CircuitBreakerConfig recoverableCircuitBreakerConfig = CircuitBreakerConfig.custom()
+ .slidingWindowSize(2)
+ .minimumNumberOfCalls(2)
+ .failureRateThreshold(50)
+ .waitDurationInOpenState(Duration.ofSeconds(1))
+ .permittedNumberOfCallsInHalfOpenState(1)
+ .build();
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient)
+ .withFallbacks(FALLBACK_URI)
+ .withCircuitBreakerConfig(recoverableCircuitBreakerConfig)
+ .build();
+ // The first two calls fail on the primary and trip the breaker.
+ checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+ checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+ // Let the open state expire without calling the checker, so that the next call is the one that the
+ // circuit breaker permits in half open state.
+ await().pollDelay(waitLongerThan(Duration.ofSeconds(1)))
+ .atMost(Duration.ofSeconds(10))
+ .until(() -> true);
+
+ List revocationInfoList =
+ checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+
+ assertThat(revocationInfoList.get(0).ocspResponderUri()).isEqualTo(PRIMARY_URI);
+ // The primary answered, so the circuit breaker permitted the call in half open state.
+ // The reported state must not contradict which responder answered.
+ assertThat(getCircuitBreakerStatistics(revocationInfoList.get(0)).state())
+ .isEqualTo(CircuitBreaker.State.HALF_OPEN);
+ }
+
+ @Test
+ void whenPrimaryFailsInHalfOpenStateAndFallbackAnswers_thenStatisticsReportHalfOpenState() throws Exception {
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenThrow(new OCSPClientException("Primary OCSP service unavailable"));
+ when(ocspClient.request(eq(FALLBACK_URI), any())).thenReturn(ocspRespGood);
+ CircuitBreakerConfig recoverableCircuitBreakerConfig = CircuitBreakerConfig.custom()
+ .slidingWindowSize(2)
+ .minimumNumberOfCalls(2)
+ .failureRateThreshold(50)
+ .waitDurationInOpenState(Duration.ofSeconds(1))
+ .permittedNumberOfCallsInHalfOpenState(1)
+ .build();
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient)
+ .withFallbacks(FALLBACK_URI)
+ .withCircuitBreakerConfig(recoverableCircuitBreakerConfig)
+ .build();
+ // The first two calls fail on the primary and trip the breaker.
+ checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+ checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+ // Let the open state expire without calling the checker, so that the next call is the one that the
+ // circuit breaker permits in half open state.
+ await().pollDelay(waitLongerThan(Duration.ofSeconds(1)))
+ .atMost(Duration.ofSeconds(10))
+ .until(() -> true);
+
+ List revocationInfoList =
+ checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+ // The next call finds the freshly re-opened breaker: the primary is not called and the statistics
+ // are a new snapshot that reports the OPEN state that rejected the call.
+ List reopenedRevocationInfoList =
+ checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+
+ assertThat(revocationInfoList).hasSize(2);
+ assertThat(revocationInfoList.get(0).ocspResponderUri()).isEqualTo(PRIMARY_URI);
+ assertThat(revocationInfoList.get(1).ocspResponderUri()).isEqualTo(FALLBACK_URI);
+ // The breaker permitted the primary call in half open state and re-opened when the call failed,
+ // before the fallback ran. The statistics must keep the snapshot taken when the primary request
+ // started (HALF_OPEN): neither the expired OPEN state that a snapshot taken before the call would
+ // report, nor the re-opened OPEN state that a capture in the fallback path would take.
+ assertThat(getCircuitBreakerStatistics(revocationInfoList.get(0)).state())
+ .isEqualTo(CircuitBreaker.State.HALF_OPEN);
+ assertThat(reopenedRevocationInfoList).hasSize(1);
+ assertThat(reopenedRevocationInfoList.get(0).ocspResponderUri()).isEqualTo(FALLBACK_URI);
+ assertThat(getCircuitBreakerStatistics(reopenedRevocationInfoList.get(0)).state())
+ .isEqualTo(CircuitBreaker.State.OPEN);
+ verify(ocspClient, times(3)).request(eq(PRIMARY_URI), any());
+ }
+
+ @Test
+ void whenPrimaryFailsInClosedState_thenStatisticsDoNotIncludeCurrentCallFailure() throws Exception {
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenThrow(new OCSPClientException("Primary OCSP service unavailable"));
+ when(ocspClient.request(eq(FALLBACK_URI), any())).thenReturn(ocspRespGood);
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient)
+ .withFallbacks(FALLBACK_URI)
+ .build();
+
+ List firstCallRevocationInfoList =
+ checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+ List secondCallRevocationInfoList =
+ checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+
+ // The statistics are captured when the primary request starts, so the outcome of the very call they
+ // are attached to is not yet included: the first call reports no recorded calls at all and the
+ // second call reports only the first call's failure.
+ CircuitBreakerStatistics firstCallStatistics =
+ getCircuitBreakerStatistics(firstCallRevocationInfoList.get(0));
+ assertThat(firstCallStatistics.state()).isEqualTo(CircuitBreaker.State.CLOSED);
+ assertThat(firstCallStatistics.numberOfBufferedCalls()).isZero();
+ assertThat(firstCallStatistics.numberOfFailedCalls()).isZero();
+ CircuitBreakerStatistics secondCallStatistics =
+ getCircuitBreakerStatistics(secondCallRevocationInfoList.get(0));
+ assertThat(secondCallStatistics.state()).isEqualTo(CircuitBreaker.State.CLOSED);
+ assertThat(secondCallStatistics.numberOfBufferedCalls()).isEqualTo(1);
+ assertThat(secondCallStatistics.numberOfFailedCalls()).isEqualTo(1);
+ }
+
+ @Test
+ void whenOcspRequestFailsWithStatusCode_thenRevocationInfoContainsHttpStatusCodeAndResponseBody() throws Exception {
+ byte[] responseBody = "error".getBytes();
+ OCSPClientException ocspClientException = new OCSPClientException("OCSP request was not successful", responseBody, 503);
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenThrow(ocspClientException);
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withoutFallbacks().build();
+
+ assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .satisfies(ex -> {
+ Map attributes = ex.getValidationInfo().revocationInfoList().get(0).ocspResponseAttributes();
+ assertThat(attributes.get(RevocationInfo.KEY_HTTP_STATUS_CODE)).isEqualTo(503);
+ assertThat(attributes.get(RevocationInfo.KEY_OCSP_RESPONSE)).isEqualTo(responseBody);
+ });
+ }
+
+ @Test
+ void whenPrimaryThrowsRuntimeExceptionThatIsNotOCSPClientException_thenWrapsAsResilientUserCertificateOCSPCheckFailedException() throws Exception {
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenThrow(new NullPointerException());
+ when(ocspClient.request(eq(FALLBACK_URI), any())).thenThrow(new NullPointerException());
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withFallbacks(FALLBACK_URI).build();
+
+ assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .satisfies(ex -> {
+ assertThat(ex.getValidationInfo().revocationInfoList()).hasSize(2);
+ Throwable primaryError = (Throwable) ex.getValidationInfo().revocationInfoList().get(0)
+ .ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR);
+ assertThat(primaryError).isInstanceOf(NullPointerException.class);
+ });
+ }
+
+ @Test
+ void whenOcspClientReturnsNullResponse_thenWrapsAsResilientUserCertificateOCSPCheckFailedException() throws Exception {
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(null);
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withoutFallbacks().build();
+
+ assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .satisfies(ex -> {
+ Throwable primaryError = (Throwable) ex.getValidationInfo().revocationInfoList().get(0)
+ .ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR);
+ assertThat(primaryError).isInstanceOf(NullPointerException.class);
+ });
+ }
+
+ @Test
+ void whenPrimaryOcspServiceAccessLocationIsNull_thenWrapsAsResilientUserCertificateOCSPCheckFailedException() throws Exception {
+ NullPointerException nullUriRejectedByClient = new NullPointerException("uri");
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(isNull(), any())).thenThrow(nullUriRejectedByClient);
+ OcspService primaryService = mock(OcspService.class);
+ when(primaryService.getAccessLocation()).thenReturn(null);
+ when(primaryService.doesSupportNonce()).thenReturn(false);
+ when(primaryService.getFallbackService()).thenReturn(Optional.empty());
+ OcspServiceProvider ocspServiceProvider = mock(OcspServiceProvider.class);
+ when(ocspServiceProvider.getService(any())).thenReturn(primaryService);
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient)
+ .withOcspServiceProvider(ocspServiceProvider)
+ .build();
+
+ assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .satisfies(ex -> {
+ RevocationInfo revocationInfo = ex.getValidationInfo().revocationInfoList().get(0);
+ assertThat(revocationInfo.ocspResponderUri()).isNull();
+ Map attributes = revocationInfo.ocspResponseAttributes();
+ assertThat(attributes.get(RevocationInfo.KEY_OCSP_ERROR)).isSameAs(nullUriRejectedByClient);
+ // getOcspRequest() builds the request before the responder is contacted, so the request
+ // is recorded even when the call itself fails.
+ assertThat(attributes).containsKey(RevocationInfo.KEY_OCSP_REQUEST);
+ assertThat(attributes).doesNotContainKey(RevocationInfo.KEY_OCSP_RESPONSE);
+ });
+ verify(ocspClient).request(isNull(), any());
+ }
+
+ @Test
+ void whenPrimarySucceeds_thenRevocationInfoListContainsExpectedResponderUrisAndAttributes() throws Exception {
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespGood);
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build();
+
+ List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+
+ assertThat(revocationInfoList).hasSize(1);
+
+ RevocationInfo primary = revocationInfoList.get(0);
+ assertThat(primary.ocspResponderUri()).isEqualTo(PRIMARY_URI);
+ assertThat(getCertificateStatus(primary)).isEqualTo(CertificateStatus.GOOD);
+ assertThat(primary.ocspResponseAttributes())
+ .doesNotContainKey(RevocationInfo.KEY_OCSP_ERROR)
+ .containsKey(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS)
+ .containsKey(RevocationInfo.KEY_REQUEST_DURATION)
+ .containsKey(RevocationInfo.KEY_OCSP_RESPONSE_TIME);
+ assertThat(primary.ocspResponseAttributes().get(RevocationInfo.KEY_REQUEST_DURATION)).isInstanceOf(Duration.class);
+ assertThat((Duration) primary.ocspResponseAttributes().get(RevocationInfo.KEY_REQUEST_DURATION))
+ .isGreaterThanOrEqualTo(Duration.ZERO);
+ assertThat(primary.ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_RESPONSE_TIME)).isInstanceOf(Instant.class);
+ }
+
+ @Test
+ void whenPrimaryFailsAndFirstFallbackSucceeds_thenRevocationInfoListContainsExpectedResponderUrisAndAttributes() throws Exception {
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenThrow(new OCSPClientException("primary"));
+ when(ocspClient.request(eq(FALLBACK_URI), any())).thenReturn(ocspRespGood);
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build();
+
+ List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+
+ assertThat(revocationInfoList).hasSize(2);
+
+ RevocationInfo primary = revocationInfoList.get(0);
+ assertThat(primary.ocspResponderUri()).isEqualTo(PRIMARY_URI);
+ assertThat(primary.ocspResponseAttributes())
+ .containsKey(RevocationInfo.KEY_OCSP_ERROR)
+ .containsKey(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS);
+
+ RevocationInfo fallback = revocationInfoList.get(1);
+ assertThat(fallback.ocspResponderUri()).isEqualTo(FALLBACK_URI);
+ assertThat(getCertificateStatus(fallback)).isEqualTo(CertificateStatus.GOOD);
+ assertThat(fallback.ocspResponseAttributes())
+ .doesNotContainKey(RevocationInfo.KEY_OCSP_ERROR)
+ .doesNotContainKey(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS)
+ .containsKey(RevocationInfo.KEY_REQUEST_DURATION)
+ .containsKey(RevocationInfo.KEY_OCSP_RESPONSE_TIME);
+ assertThat(fallback.ocspResponseAttributes().get(RevocationInfo.KEY_REQUEST_DURATION)).isInstanceOf(Duration.class);
+ assertThat(fallback.ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_RESPONSE_TIME)).isInstanceOf(Instant.class);
+
+ verify(ocspClient, never()).request(eq(SECOND_FALLBACK_URI), any());
+ }
+
+ @Test
+ void whenPrimaryAndFirstFallbackFailAndSecondFallbackSucceeds_thenRevocationInfoListContainsExpectedResponderUrisAndAttributes() throws Exception {
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenThrow(new OCSPClientException("Primary OCSP service unavailable"));
+ when(ocspClient.request(eq(FALLBACK_URI), any())).thenThrow(new OCSPClientException("Fallback OCSP service unavailable"));
+ when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())).thenReturn(ocspRespGood);
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build();
+
+ List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+
+ assertThat(revocationInfoList).hasSize(3);
+
+ RevocationInfo primary = revocationInfoList.get(0);
+ assertThat(primary.ocspResponderUri()).isEqualTo(PRIMARY_URI);
+ assertThat(primary.ocspResponseAttributes())
+ .containsKey(RevocationInfo.KEY_OCSP_ERROR)
+ .containsKey(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS);
+
+ RevocationInfo firstFallback = revocationInfoList.get(1);
+ assertThat(firstFallback.ocspResponderUri()).isEqualTo(FALLBACK_URI);
+ assertThat(firstFallback.ocspResponseAttributes())
+ .containsKey(RevocationInfo.KEY_OCSP_ERROR)
+ .doesNotContainKey(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS);
+
+ RevocationInfo secondFallback = revocationInfoList.get(2);
+ assertThat(secondFallback.ocspResponderUri()).isEqualTo(SECOND_FALLBACK_URI);
+ assertThat(secondFallback.ocspResponseAttributes())
+ .doesNotContainKey(RevocationInfo.KEY_OCSP_ERROR)
+ .doesNotContainKey(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS);
+ }
+
+ @Test
+ void whenAllFail_thenRevocationInfoListContainsExpectedResponderUrisAndAttributes() throws Exception {
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenThrow(new OCSPClientException("primary"));
+ when(ocspClient.request(eq(FALLBACK_URI), any())).thenThrow(new OCSPClientException("fallback"));
+ when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())).thenThrow(new OCSPClientException("second"));
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build();
+
+ assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .satisfies(ex -> {
+ assertThat(ex.getValidationInfo()).isNotNull();
+ List revocationInfoList = ex.getValidationInfo().revocationInfoList();
+
+ assertThat(revocationInfoList).hasSize(3);
+
+ RevocationInfo primary = revocationInfoList.get(0);
+ assertThat(primary.ocspResponderUri()).isEqualTo(PRIMARY_URI);
+ assertThat(primary.ocspResponseAttributes())
+ .containsKey(RevocationInfo.KEY_OCSP_ERROR)
+ .containsKey(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS);
+
+ RevocationInfo firstFallback = revocationInfoList.get(1);
+ assertThat(firstFallback.ocspResponderUri()).isEqualTo(FALLBACK_URI);
+ assertThat(firstFallback.ocspResponseAttributes())
+ .containsKey(RevocationInfo.KEY_OCSP_ERROR)
+ .doesNotContainKey(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS);
+
+ RevocationInfo secondFallback = revocationInfoList.get(2);
+ assertThat(secondFallback.ocspResponderUri()).isEqualTo(SECOND_FALLBACK_URI);
+ assertThat(secondFallback.ocspResponseAttributes())
+ .containsKey(RevocationInfo.KEY_OCSP_ERROR)
+ .doesNotContainKey(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS);
+ });
+ }
+
+ @Test
+ void whenPrimaryResponseIsTooOldForPrimaryAgeLimit_thenFallbackAcceptsItUnderFallbackAgeLimit() throws Exception {
+ // The same response (thisUpdate 2021-09-17T18:25:24) is served by both responders and the clock is mocked
+ // 5 minutes later. The primary applies the stricter 2-minute limit and rejects it as too old, while the
+ // fallback applies the more lenient 10-minute limit and accepts it. This proves that each responder
+ // applies the maxThisUpdateAge of its own OCSP service; swapping the two values would flip the outcome
+ // and fail this test.
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespGood);
+ when(ocspClient.request(eq(FALLBACK_URI), any())).thenReturn(ocspRespGood);
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient)
+ .withFallbacks(FALLBACK_URI)
+ .withPrimaryMaxThisUpdateAge(Duration.ofMinutes(2))
+ .withFallbackMaxThisUpdateAge(Duration.ofMinutes(10))
+ .build();
+
+ try (var ignored = mockStaticClockAt(FIVE_MIN_AFTER_THIS_UPDATE)) {
+ List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+
+ assertThat(revocationInfoList).hasSize(2);
+
+ RevocationInfo primary = revocationInfoList.get(0);
+ assertThat(primary.ocspResponderUri()).isEqualTo(PRIMARY_URI);
+ Throwable primaryError = (Throwable) primary.ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR);
+ assertThat(primaryError).hasMessageContaining("thisUpdate").hasMessageContaining("is too old");
+
+ RevocationInfo fallback = revocationInfoList.get(1);
+ assertThat(fallback.ocspResponderUri()).isEqualTo(FALLBACK_URI);
+ assertThat(getCertificateStatus(fallback)).isEqualTo(CertificateStatus.GOOD);
+ }
+ }
+
+ @Test
+ void whenCircuitBreakerOpens_thenFallbackHandlesCallAndStatisticsReflectOpenState() throws Exception {
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenThrow(new OCSPClientException("Primary OCSP service unavailable"));
+ when(ocspClient.request(eq(FALLBACK_URI), any())).thenReturn(ocspRespGood);
+ CircuitBreakerConfig tightCircuitBreakerConfig = CircuitBreakerConfig.custom()
+ .slidingWindowSize(2)
+ .minimumNumberOfCalls(2)
+ .failureRateThreshold(50)
+ .permittedNumberOfCallsInHalfOpenState(1)
+ .build();
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient)
+ .withFallbacks(FALLBACK_URI)
+ .withCircuitBreakerConfig(tightCircuitBreakerConfig)
+ .build();
+
+ // The first two calls fail on the primary and trip the breaker; the third call sees it already open.
+ checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+ checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+ List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+
+ assertThat(revocationInfoList).hasSize(1);
+ assertThat(revocationInfoList.get(0).ocspResponderUri()).isEqualTo(FALLBACK_URI);
+ CircuitBreakerStatistics statistics =
+ (CircuitBreakerStatistics)
+ revocationInfoList.get(0).ocspResponseAttributes().get(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS);
+ assertThat(statistics).isNotNull();
+ assertThat(statistics.state()).isEqualTo(CircuitBreaker.State.OPEN);
+ assertThat(statistics.numberOfFailedCalls()).isEqualTo(2);
+ // The circuit breaker rejected the primary call, so the snapshot is taken when the fallback request
+ // starts and includes the rejection of this very call.
+ assertThat(statistics.numberOfNotPermittedCalls()).isEqualTo(1);
+ }
+
+ @Test
+ void whenNoFallbackConfiguredAndPrimaryReturnsRevoked_thenRevokedPropagatesWithSingleEntry() throws Exception {
+ // The no-fallback branch returns directly from request() without going through processResult, so this
+ // exercises ResilientUserCertificateRevokedException propagating straight out of validateCertificateNotRevoked.
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespRevoked);
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withoutFallbacks().build();
+
+ assertThatExceptionOfType(ResilientUserCertificateRevokedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .satisfies(ex -> {
+ List revocationInfoList = ex.getValidationInfo().revocationInfoList();
+ assertThat(revocationInfoList).hasSize(1);
+ assertThat(revocationInfoList.get(0).ocspResponderUri()).isEqualTo(PRIMARY_URI);
+ assertThat(getCertificateStatus(revocationInfoList.get(0))).isInstanceOf(RevokedStatus.class);
+ });
+ }
+
+ @Test
+ void whenNoFallbackConfiguredAndPrimaryReturnsUnknown_thenCheckFailedPropagates() throws Exception {
+ // The unknown status fails the OCSP check.
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespUnknown);
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withoutFallbacks().build();
+
+ try (var ignored = mockStaticClockAt(WITHIN_RESPONDER_CERT_VALIDITY)) {
+ assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .satisfies(ex -> assertThat(ex.getValidationInfo().revocationInfoList()).hasSize(1));
+ }
+ }
+
+ @Test
+ void whenNoFallbackConfigured_thenRetryAndCircuitBreakerAreNotApplied() throws Exception {
+ // The class contract states retry and circuit breaker apply only when a fallback is configured. With no
+ // fallback the primary must be queried exactly once (no retry) and no circuit breaker statistics attached.
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenThrow(new OCSPClientException("Primary OCSP service unavailable"));
+ RetryConfig retryConfig = RetryConfig.custom().maxAttempts(2).waitDuration(Duration.ZERO).build();
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient)
+ .withoutFallbacks()
+ .withRetryConfig(retryConfig)
+ .build();
+
+ assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .satisfies(ex -> {
+ List revocationInfoList = ex.getValidationInfo().revocationInfoList();
+ assertThat(revocationInfoList).hasSize(1);
+ assertThat(revocationInfoList.get(0).ocspResponseAttributes())
+ .doesNotContainKey(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS);
+ });
+ verify(ocspClient, times(1)).request(eq(PRIMARY_URI), any());
+ }
+
+ @Test
+ void whenPrimaryAndFirstFallbackFailAndSecondFallbackReturnsRevoked_thenRevokedPropagates() throws Exception {
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenThrow(new OCSPClientException("Primary OCSP service unavailable"));
+ when(ocspClient.request(eq(FALLBACK_URI), any())).thenThrow(new OCSPClientException("Fallback OCSP service unavailable"));
+ when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())).thenReturn(ocspRespRevoked);
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build();
+
+ assertThatExceptionOfType(ResilientUserCertificateRevokedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .satisfies(ex -> {
+ List revocationInfoList = ex.getValidationInfo().revocationInfoList();
+ assertThat(revocationInfoList).hasSize(3);
+ assertThat(revocationInfoList).extracting(RevocationInfo::ocspResponderUri)
+ .containsExactly(PRIMARY_URI, FALLBACK_URI, SECOND_FALLBACK_URI);
+ assertThat(getCertificateStatus(revocationInfoList.get(2))).isInstanceOf(RevokedStatus.class);
+ });
+ }
+
+ @Test
+ void whenFallbackResponseIsTooOldForFallbackAgeLimit_thenOcspCheckFails() throws Exception {
+ // The response thisUpdate is 2021-09-17T18:25:24 and the clock is mocked 5 minutes later, while the fallback
+ // age limit is only 2 minutes, so the fallback rejects the response as too old. This exercises the failure
+ // direction of the fallback service maxThisUpdateAge (the accepting direction is covered elsewhere).
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenThrow(new OCSPClientException("Primary OCSP service unavailable"));
+ when(ocspClient.request(eq(FALLBACK_URI), any())).thenReturn(ocspRespGood);
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient)
+ .withFallbacks(FALLBACK_URI)
+ .withFallbackMaxThisUpdateAge(Duration.ofMinutes(2))
+ .build();
+
+ try (var ignored = mockStaticClockAt(FIVE_MIN_AFTER_THIS_UPDATE)) {
+ assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .satisfies(ex -> {
+ List revocationInfoList = ex.getValidationInfo().revocationInfoList();
+ assertThat(revocationInfoList).hasSize(2);
+ Throwable fallbackError = (Throwable) revocationInfoList.get(1)
+ .ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR);
+ assertThat(fallbackError).hasMessageContaining("thisUpdate").hasMessageContaining("is too old");
+ });
+ }
+ }
+
+ private static CheckerBuilder checkerBuilder(OcspClient ocspClient) {
+ return new CheckerBuilder(ocspClient);
+ }
+
+ /**
+ * Builds a {@link ResilientOcspCertificateRevocationChecker} with, by default, a primary OCSP service
+ * with two chained fallbacks: PRIMARY_URI -> FALLBACK_URI -> SECOND_FALLBACK_URI. The age limits are
+ * per OCSP service, so they are stubbed on the mocked services and are relaxed by default.
+ */
+ private static final class CheckerBuilder {
+
+ private final OcspClient ocspClient;
+ private OcspServiceProvider ocspServiceProvider;
+ private URI[] fallbackUris = {FALLBACK_URI, SECOND_FALLBACK_URI};
+ private boolean primarySupportsNonce;
+ private CircuitBreakerConfig circuitBreakerConfig = CircuitBreakerConfig.ofDefaults();
+ private RetryConfig retryConfig;
+ private Duration primaryMaxThisUpdateAge = LONG_THIS_UPDATE_AGE;
+ private Duration fallbackMaxThisUpdateAge = LONG_THIS_UPDATE_AGE;
+
+ private CheckerBuilder(OcspClient ocspClient) {
+ this.ocspClient = ocspClient;
+ }
+
+ private CheckerBuilder withFallbacks(URI... fallbackUris) {
+ this.fallbackUris = fallbackUris;
+ return this;
+ }
+
+ private CheckerBuilder withoutFallbacks() {
+ return withFallbacks();
+ }
+
+ private CheckerBuilder withPrimaryNonceSupport() {
+ this.primarySupportsNonce = true;
+ return this;
+ }
+
+ private CheckerBuilder withCircuitBreakerConfig(CircuitBreakerConfig circuitBreakerConfig) {
+ this.circuitBreakerConfig = circuitBreakerConfig;
+ return this;
+ }
+
+ private CheckerBuilder withRetryConfig(RetryConfig retryConfig) {
+ this.retryConfig = retryConfig;
+ return this;
+ }
+
+ private CheckerBuilder withOcspServiceProvider(OcspServiceProvider ocspServiceProvider) {
+ this.ocspServiceProvider = ocspServiceProvider;
+ return this;
+ }
+
+ private CheckerBuilder withPrimaryMaxThisUpdateAge(Duration primaryMaxThisUpdateAge) {
+ this.primaryMaxThisUpdateAge = primaryMaxThisUpdateAge;
+ return this;
+ }
+
+ private CheckerBuilder withFallbackMaxThisUpdateAge(Duration fallbackMaxThisUpdateAge) {
+ this.fallbackMaxThisUpdateAge = fallbackMaxThisUpdateAge;
+ return this;
+ }
+
+ private ResilientOcspCertificateRevocationChecker build() throws Exception {
+ OcspServiceProvider serviceProvider = ocspServiceProvider != null ? ocspServiceProvider : buildMockServiceProvider();
+ return new ResilientOcspCertificateRevocationChecker(
+ ocspClient,
+ serviceProvider,
+ circuitBreakerConfig,
+ retryConfig,
+ OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW
+ );
+ }
+
+ private OcspServiceProvider buildMockServiceProvider() throws Exception {
+ FallbackOcspService nextFallback = null;
+ for (int i = fallbackUris.length - 1; i >= 0; i--) {
+ FallbackOcspService fallbackService = mock(FallbackOcspService.class);
+ when(fallbackService.getAccessLocation()).thenReturn(fallbackUris[i]);
+ when(fallbackService.doesSupportNonce()).thenReturn(false);
+ when(fallbackService.getNextFallback()).thenReturn(nextFallback);
+ when(fallbackService.getMaxThisUpdateAge()).thenReturn(fallbackMaxThisUpdateAge);
+ when(fallbackService.getMaxNextUpdateAge()).thenReturn(LONG_NEXT_UPDATE_AGE);
+ nextFallback = fallbackService;
+ }
+
+ OcspService primaryService = mock(OcspService.class);
+ when(primaryService.getAccessLocation()).thenReturn(PRIMARY_URI);
+ when(primaryService.doesSupportNonce()).thenReturn(primarySupportsNonce);
+ when(primaryService.getFallbackService()).thenReturn(Optional.ofNullable(nextFallback));
+ when(primaryService.getMaxThisUpdateAge()).thenReturn(primaryMaxThisUpdateAge);
+ when(primaryService.getMaxNextUpdateAge()).thenReturn(LONG_NEXT_UPDATE_AGE);
+
+ OcspServiceProvider serviceProvider = mock(OcspServiceProvider.class);
+ when(serviceProvider.getService(any())).thenReturn(primaryService);
+ return serviceProvider;
+ }
+ }
+
+ private static MockedStatic mockStaticClockAt(String isoDateTime) {
+ MockedStatic mockedClock = Mockito.mockStatic(DateAndTime.DefaultClock.class);
+ mockDate(isoDateTime, mockedClock);
+ return mockedClock;
+ }
+
+ // Overshooting the open-state duration is safe: the breaker transitions to HALF_OPEN lazily, when the
+ // next call asks for permission, not on a timer.
+ private static Duration waitLongerThan(Duration waitDurationInOpenState) {
+ return waitDurationInOpenState.plusMillis(500);
+ }
+
+ private static CircuitBreakerStatistics getCircuitBreakerStatistics(RevocationInfo revocationInfo) {
+ return (CircuitBreakerStatistics)
+ revocationInfo.ocspResponseAttributes().get(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS);
+ }
+
+ private static CertificateStatus getCertificateStatus(RevocationInfo revocationInfo) throws Exception {
+ OCSPResp ocspResp = (OCSPResp) revocationInfo.ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_RESPONSE);
+ final BasicOCSPResp basicResponse = (BasicOCSPResp) ocspResp.getResponseObject();
+ final SingleResp certStatusResponse = basicResponse.getResponses()[0];
+ return certStatusResponse.getCertStatus();
+ }
+}
diff --git a/src/test/java/eu/webeid/security/certificate/CertificateValidatorTest.java b/src/test/java/eu/webeid/security/certificate/CertificateValidatorTest.java
new file mode 100644
index 00000000..c91b9a15
--- /dev/null
+++ b/src/test/java/eu/webeid/security/certificate/CertificateValidatorTest.java
@@ -0,0 +1,516 @@
+/*
+ * Copyright (c) 2020-2025 Estonian Information System Authority
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package eu.webeid.security.certificate;
+
+import eu.webeid.security.exceptions.CertificateNotTrustedException;
+import eu.webeid.security.exceptions.CertificateRevocationCheckFailedException;
+import eu.webeid.security.exceptions.CertificateRevokedException;
+import eu.webeid.security.testutil.Certificates;
+import eu.webeid.security.validator.revocationcheck.RevocationInfo;
+import eu.webeid.security.validator.revocationcheck.RevocationMode;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.net.URI;
+import java.security.cert.CertPathValidator;
+import java.security.cert.CertPathValidatorException;
+import java.security.cert.CertStore;
+import java.security.cert.Certificate;
+import java.security.cert.PKIXRevocationChecker;
+import java.security.cert.TrustAnchor;
+import java.security.cert.X509Certificate;
+import java.util.Collection;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static java.util.Objects.requireNonNull;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class CertificateValidatorTest {
+
+ private static final Date NOW = new Date(1627776000000L);
+ private static final Date OCSP_RESPONSE_DATE = new Date(1631903124000L);
+ private static final Date REVOKED_OCSP_RESPONSE_DATE = new Date(1631924023000L);
+
+ @Test
+ void whenRevocationDisabled_thenValidationSucceedsWithoutRevocationInfo() throws Exception {
+ final X509Certificate subject = Certificates.getJaakKristjanEsteid2018Cert();
+ final X509Certificate issuer = Certificates.getTestEsteid2018CA();
+
+ final List revocationInfo = CertificateValidator.validateCertificateTrustAndRevocation(
+ subject,
+ trustAnchors(issuer),
+ certStore(issuer),
+ NOW,
+ RevocationMode.DISABLED,
+ null,
+ null,
+ true
+ );
+
+ assertThat(revocationInfo).isEmpty();
+ }
+
+ @Test
+ void whenRevocationDisabledAndCheckerProvided_thenThrows() throws Exception {
+ final X509Certificate subject = Certificates.getJaakKristjanEsteid2018Cert();
+ final X509Certificate issuer = Certificates.getTestEsteid2018CA();
+
+ assertThatThrownBy(() -> CertificateValidator.validateCertificateTrustAndRevocation(
+ subject,
+ trustAnchors(issuer),
+ certStore(issuer),
+ NOW,
+ RevocationMode.DISABLED,
+ (s, i) -> List.of(),
+ null,
+ true
+ ))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageStartingWith("customPkixRevocationChecker and certificateRevocationChecker must be null when revocationMode is DISABLED");
+ }
+
+ @Test
+ void whenCustomCheckerMissing_thenThrows() throws Exception {
+ final X509Certificate subject = Certificates.getJaakKristjanEsteid2018Cert();
+ final X509Certificate issuer = Certificates.getTestEsteid2018CA();
+
+ assertThatThrownBy(() -> CertificateValidator.validateCertificateTrustAndRevocation(
+ subject,
+ trustAnchors(issuer),
+ certStore(issuer),
+ NOW,
+ RevocationMode.CUSTOM_CHECKER,
+ null,
+ null,
+ true
+ ))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageStartingWith("certificateRevocationChecker must be provided when revocationMode is CUSTOM_CHECKER");
+ }
+
+ @Test
+ void whenCustomCheckerAndCustomPkixProvided_thenThrows() throws Exception {
+ final X509Certificate subject = Certificates.getJaakKristjanEsteid2018Cert();
+ final X509Certificate issuer = Certificates.getTestEsteid2018CA();
+
+ assertThatThrownBy(() -> CertificateValidator.validateCertificateTrustAndRevocation(
+ subject,
+ trustAnchors(issuer),
+ certStore(issuer),
+ NOW,
+ RevocationMode.CUSTOM_CHECKER,
+ (s, i) -> List.of(),
+ new NoopPkixRevocationChecker(),
+ true
+ ))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageStartingWith("customPkixRevocationChecker must be null when revocationMode is CUSTOM_CHECKER");
+ }
+
+ @Test
+ void whenCustomCheckerReturnsRevocationInfo_thenItIsReturned() throws Exception {
+ final X509Certificate subject = Certificates.getJaakKristjanEsteid2018Cert();
+ final X509Certificate issuer = Certificates.getTestEsteid2018CA();
+ final RevocationInfo expected = new RevocationInfo(
+ URI.create("http://ocsp.example"),
+ null
+ );
+
+ final List revocationInfo = CertificateValidator.validateCertificateTrustAndRevocation(
+ subject,
+ trustAnchors(issuer),
+ certStore(issuer),
+ NOW,
+ RevocationMode.CUSTOM_CHECKER,
+ (s, i) -> List.of(expected),
+ null,
+ true
+ );
+
+ assertThat(revocationInfo).containsExactly(expected);
+ }
+
+ @Test
+ void whenCustomPkixMissing_thenThrows() throws Exception {
+ final X509Certificate subject = Certificates.getJaakKristjanEsteid2018Cert();
+ final X509Certificate issuer = Certificates.getTestEsteid2018CA();
+
+ assertThatThrownBy(() -> CertificateValidator.validateCertificateTrustAndRevocation(
+ subject,
+ trustAnchors(issuer),
+ certStore(issuer),
+ NOW,
+ RevocationMode.CUSTOM_PKIX,
+ null,
+ null,
+ true
+ ))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageStartingWith("customPkixRevocationChecker must be provided when revocationMode is CUSTOM_PKIX");
+ }
+
+ @Test
+ void whenCustomPkixAndCustomCheckerProvided_thenThrows() throws Exception {
+ final X509Certificate subject = Certificates.getJaakKristjanEsteid2018Cert();
+ final X509Certificate issuer = Certificates.getTestEsteid2018CA();
+
+ assertThatThrownBy(() -> CertificateValidator.validateCertificateTrustAndRevocation(
+ subject,
+ trustAnchors(issuer),
+ certStore(issuer),
+ NOW,
+ RevocationMode.CUSTOM_PKIX,
+ (s, i) -> List.of(),
+ new NoopPkixRevocationChecker(),
+ true
+ ))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageStartingWith("certificateRevocationChecker must be null when revocationMode is CUSTOM_PKIX");
+ }
+
+ @Test
+ void whenCustomPkixWithOcspResponder_thenRevocationInfoContainsResponder() throws Exception {
+ final X509Certificate subject = Certificates.getJaakKristjanEsteid2018Cert();
+ final X509Certificate issuer = Certificates.getTestEsteid2018CA();
+ final NoopPkixRevocationChecker checker = new NoopPkixRevocationChecker();
+ checker.setOcspResponder(URI.create("http://ocsp.example"));
+
+ final List revocationInfo = CertificateValidator.validateCertificateTrustAndRevocation(
+ subject,
+ trustAnchors(issuer),
+ certStore(issuer),
+ NOW,
+ RevocationMode.CUSTOM_PKIX,
+ null,
+ checker,
+ true
+ );
+
+ assertThat(revocationInfo).containsExactly(new RevocationInfo(checker.getOcspResponder(), null));
+ }
+
+ @Test
+ void whenCustomPkixUsesBundledOcspResponse_thenValidationSucceeds() throws Exception {
+ final X509Certificate subject = Certificates.getJaakKristjanEsteid2018Cert();
+ final X509Certificate issuer = Certificates.getTestEsteid2018CA();
+
+ assertThat(CertificateValidator.validateCertificateTrustAndRevocation(
+ subject,
+ trustAnchors(issuer),
+ certStore(issuer),
+ OCSP_RESPONSE_DATE,
+ RevocationMode.CUSTOM_PKIX,
+ null,
+ pkixCheckerWithOcspResponse(subject, "/ocsp_response.der"),
+ true
+ )).isEmpty();
+ }
+
+ @Test
+ void whenCustomPkixUsesBundledRevokedOcspResponse_thenThrowsCertificateRevoked() throws Exception {
+ final X509Certificate subject = Certificates.getJaakKristjanEsteid2018Cert();
+ final X509Certificate issuer = Certificates.getTestEsteid2018CA();
+
+ assertThatThrownBy(() -> CertificateValidator.validateCertificateTrustAndRevocation(
+ subject,
+ trustAnchors(issuer),
+ certStore(issuer),
+ REVOKED_OCSP_RESPONSE_DATE,
+ RevocationMode.CUSTOM_PKIX,
+ null,
+ pkixCheckerWithOcspResponse(subject, "/ocsp_response_revoked.der"),
+ true
+ ))
+ .isInstanceOf(CertificateRevokedException.class)
+ .hasCauseInstanceOf(CertPathValidatorException.class);
+ }
+
+ @Test
+ void whenCustomPkixCannotDetermineRevocationStatus_thenThrowsRevocationCheckFailedWithCause() throws Exception {
+ final X509Certificate subject = Certificates.getJaakKristjanEsteid2018Cert();
+ final X509Certificate issuer = Certificates.getTestEsteid2018CA();
+ final CertPathValidatorException checkerFailure = new CertPathValidatorException(
+ "OCSP responder returned TRY_LATER",
+ null,
+ null,
+ -1,
+ CertPathValidatorException.BasicReason.UNDETERMINED_REVOCATION_STATUS
+ );
+
+ assertThatThrownBy(() -> CertificateValidator.validateCertificateTrustAndRevocation(
+ subject,
+ trustAnchors(issuer),
+ certStore(issuer),
+ NOW,
+ RevocationMode.CUSTOM_PKIX,
+ null,
+ new FailingPkixRevocationChecker(checkerFailure),
+ true
+ ))
+ .isInstanceOf(CertificateRevocationCheckFailedException.class)
+ .hasMessageContaining("OCSP responder returned TRY_LATER")
+ .hasCause(checkerFailure);
+ }
+
+ @Test
+ void whenCustomPkixReportsRevoked_thenThrowsCertificateRevokedWithCause() throws Exception {
+ final X509Certificate subject = Certificates.getJaakKristjanEsteid2018Cert();
+ final X509Certificate issuer = Certificates.getTestEsteid2018CA();
+ final CertPathValidatorException checkerFailure = new CertPathValidatorException(
+ "Certificate has been revoked",
+ null,
+ null,
+ -1,
+ CertPathValidatorException.BasicReason.REVOKED
+ );
+
+ assertThatThrownBy(() -> CertificateValidator.validateCertificateTrustAndRevocation(
+ subject,
+ trustAnchors(issuer),
+ certStore(issuer),
+ NOW,
+ RevocationMode.CUSTOM_PKIX,
+ null,
+ new FailingPkixRevocationChecker(checkerFailure),
+ true
+ ))
+ .isInstanceOf(CertificateRevokedException.class)
+ .hasCause(checkerFailure);
+ }
+
+ @Test
+ void whenCustomPkixReportsUnspecifiedFailure_thenThrowsRevocationCheckFailedWithCause() throws Exception {
+ final X509Certificate subject = Certificates.getJaakKristjanEsteid2018Cert();
+ final X509Certificate issuer = Certificates.getTestEsteid2018CA();
+ final CertPathValidatorException checkerFailure = new CertPathValidatorException(
+ "Certificate does not specify OCSP responder"
+ );
+
+ assertThatThrownBy(() -> CertificateValidator.validateCertificateTrustAndRevocation(
+ subject,
+ trustAnchors(issuer),
+ certStore(issuer),
+ NOW,
+ RevocationMode.CUSTOM_PKIX,
+ null,
+ new FailingPkixRevocationChecker(checkerFailure),
+ true
+ ))
+ .isInstanceOf(CertificateRevocationCheckFailedException.class)
+ .hasMessageContaining("Certificate does not specify OCSP responder")
+ .hasCause(checkerFailure);
+ }
+
+ @Test
+ void whenCustomPkixReportsNonRevocationValidationFailure_thenThrowsCertificateNotTrusted() throws Exception {
+ final X509Certificate subject = Certificates.getJaakKristjanEsteid2018Cert();
+ final X509Certificate issuer = Certificates.getTestEsteid2018CA();
+ final CertPathValidatorException checkerFailure = new CertPathValidatorException(
+ "Invalid certificate signature",
+ null,
+ null,
+ -1,
+ CertPathValidatorException.BasicReason.INVALID_SIGNATURE
+ );
+
+ assertThatThrownBy(() -> CertificateValidator.validateCertificateTrustAndRevocation(
+ subject,
+ trustAnchors(issuer),
+ certStore(issuer),
+ NOW,
+ RevocationMode.CUSTOM_PKIX,
+ null,
+ new FailingPkixRevocationChecker(checkerFailure),
+ true
+ ))
+ .isInstanceOf(CertificateNotTrustedException.class)
+ .hasCause(checkerFailure);
+ }
+
+ @Test
+ void whenPlatformOcspHasCustomChecker_thenThrows() throws Exception {
+ final X509Certificate subject = Certificates.getJaakKristjanEsteid2018Cert();
+ final X509Certificate issuer = Certificates.getTestEsteid2018CA();
+
+ assertThatThrownBy(() -> CertificateValidator.validateCertificateTrustAndRevocation(
+ subject,
+ trustAnchors(issuer),
+ certStore(issuer),
+ NOW,
+ RevocationMode.PLATFORM_OCSP,
+ (s, i) -> List.of(),
+ null,
+ true
+ ))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageStartingWith("customPkixRevocationChecker and certificateRevocationChecker must be null when revocationMode is PLATFORM_OCSP");
+ }
+
+ @Test
+ void whenJdkOcspNoncePropertyIsNotSet_thenFreshApplicationNonceIsConfigured() {
+ final NoopPkixRevocationChecker firstChecker = new NoopPkixRevocationChecker();
+ final NoopPkixRevocationChecker secondChecker = new NoopPkixRevocationChecker();
+
+ CertificateValidator.configureOcspNonce(firstChecker, true, null);
+ CertificateValidator.configureOcspNonce(secondChecker, true, null);
+
+ assertThat(firstChecker.getOcspExtensions())
+ .singleElement()
+ .isInstanceOf(OcspNonceExtension.class);
+ assertThat(firstChecker.getOcspExtensions().get(0).getValue())
+ .isNotEqualTo(secondChecker.getOcspExtensions().get(0).getValue());
+ }
+
+ @Test
+ void whenPlatformOcspNonceIsDisabledAndJdkPropertyIsUnset_thenApplicationNonceIsNotConfigured() {
+ final NoopPkixRevocationChecker checker = new NoopPkixRevocationChecker();
+
+ CertificateValidator.configureOcspNonce(checker, false, null);
+
+ assertThat(checker.getOcspExtensions()).isEmpty();
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {true, false})
+ void whenJdkOcspNoncePropertyIsTrue_thenNonceGenerationIsLeftToJdk(boolean platformOcspNonceEnabled) {
+ final NoopPkixRevocationChecker checker = new NoopPkixRevocationChecker();
+
+ CertificateValidator.configureOcspNonce(checker, platformOcspNonceEnabled, "true");
+
+ assertThat(checker.getOcspExtensions()).isEmpty();
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {true, false})
+ void whenJdkOcspNoncePropertyIsFalse_thenApplicationNonceIsNotConfigured(boolean platformOcspNonceEnabled) {
+ final NoopPkixRevocationChecker checker = new NoopPkixRevocationChecker();
+
+ CertificateValidator.configureOcspNonce(checker, platformOcspNonceEnabled, "false");
+
+ assertThat(checker.getOcspExtensions()).isEmpty();
+ }
+
+ @Test
+ void whenPlatformOcspWithUntrustedIssuer_thenThrowsCertificateNotTrusted() throws Exception {
+ final X509Certificate subject = Certificates.getJaakKristjanEsteid2018Cert();
+ final X509Certificate wrongIssuer = Certificates.getTestEsteid2015CA();
+
+ assertThatThrownBy(() -> CertificateValidator.validateCertificateTrustAndRevocation(
+ subject,
+ trustAnchors(wrongIssuer),
+ certStore(wrongIssuer),
+ NOW,
+ RevocationMode.PLATFORM_OCSP,
+ null,
+ null,
+ true
+ ))
+ .isInstanceOf(CertificateNotTrustedException.class);
+ }
+
+ private static Set trustAnchors(X509Certificate issuer) {
+ return CertificateValidator.buildTrustAnchorsFromCertificates(List.of(issuer));
+ }
+
+ private static CertStore certStore(X509Certificate issuer) throws Exception {
+ return CertificateValidator.buildCertStoreFromCertificates(List.of(issuer));
+ }
+
+ private static PKIXRevocationChecker pkixCheckerWithOcspResponse(X509Certificate subject,
+ String responseResource) throws Exception {
+ final PKIXRevocationChecker checker = (PKIXRevocationChecker) CertPathValidator
+ .getInstance(CertPathValidator.getDefaultType())
+ .getRevocationChecker();
+ checker.setOptions(Set.of(
+ PKIXRevocationChecker.Option.ONLY_END_ENTITY,
+ PKIXRevocationChecker.Option.NO_FALLBACK
+ ));
+ checker.setOcspResponses(Map.of(
+ subject,
+ requireNonNull(CertificateValidatorTest.class.getResourceAsStream(responseResource)).readAllBytes()
+ ));
+ return checker;
+ }
+
+ private static final class NoopPkixRevocationChecker extends PKIXRevocationChecker {
+ @Override
+ public void init(boolean forward) {
+ }
+
+ @Override
+ public boolean isForwardCheckingSupported() {
+ return false;
+ }
+
+ @Override
+ public Set getSupportedExtensions() {
+ return null;
+ }
+
+ @Override
+ public void check(Certificate cert, Collection unresolvedCritExts) {
+ }
+
+ @Override
+ public List getSoftFailExceptions() {
+ return List.of();
+ }
+ }
+
+ private static final class FailingPkixRevocationChecker extends PKIXRevocationChecker {
+ private final CertPathValidatorException failure;
+
+ private FailingPkixRevocationChecker(CertPathValidatorException failure) {
+ this.failure = failure;
+ }
+
+ @Override
+ public void init(boolean forward) {
+ }
+
+ @Override
+ public boolean isForwardCheckingSupported() {
+ return false;
+ }
+
+ @Override
+ public Set getSupportedExtensions() {
+ return null;
+ }
+
+ @Override
+ public void check(Certificate cert, Collection unresolvedCritExts) throws CertPathValidatorException {
+ throw failure;
+ }
+
+ @Override
+ public List getSoftFailExceptions() {
+ return List.of();
+ }
+ }
+}
diff --git a/src/test/java/eu/webeid/security/certificate/OcspNetworkTest.java b/src/test/java/eu/webeid/security/certificate/OcspNetworkTest.java
new file mode 100644
index 00000000..5f58e929
--- /dev/null
+++ b/src/test/java/eu/webeid/security/certificate/OcspNetworkTest.java
@@ -0,0 +1,310 @@
+// SPDX-FileCopyrightText: Estonian Information System Authority
+// SPDX-License-Identifier: MIT
+
+package eu.webeid.security.certificate;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import eu.webeid.ocsp.OcspCertificateRevocationChecker;
+import eu.webeid.ocsp.client.OcspClientImpl;
+import eu.webeid.ocsp.exceptions.UserCertificateOCSPCheckFailedException;
+import eu.webeid.ocsp.service.AiaOcspServiceConfiguration;
+import eu.webeid.ocsp.service.DesignatedOcspServiceConfiguration;
+import eu.webeid.ocsp.service.OcspServiceProvider;
+import eu.webeid.security.exceptions.CertificateRevocationCheckFailedException;
+import eu.webeid.security.exceptions.CertificateRevokedException;
+import eu.webeid.security.validator.revocationcheck.RevocationInfo;
+import eu.webeid.security.validator.revocationcheck.RevocationMode;
+import org.bouncycastle.asn1.ASN1ObjectIdentifier;
+import org.bouncycastle.asn1.ASN1OctetString;
+import org.bouncycastle.asn1.DEROctetString;
+import org.bouncycastle.asn1.ocsp.OCSPObjectIdentifiers;
+import org.bouncycastle.asn1.ocsp.OCSPResponse;
+import org.bouncycastle.asn1.ocsp.OCSPResponseStatus;
+import org.bouncycastle.asn1.ocsp.ResponseBytes;
+import org.bouncycastle.asn1.x500.X500Name;
+import org.bouncycastle.asn1.x509.AccessDescription;
+import org.bouncycastle.asn1.x509.AuthorityInformationAccess;
+import org.bouncycastle.asn1.x509.BasicConstraints;
+import org.bouncycastle.asn1.x509.CRLReason;
+import org.bouncycastle.asn1.x509.ExtendedKeyUsage;
+import org.bouncycastle.asn1.x509.Extension;
+import org.bouncycastle.asn1.x509.Extensions;
+import org.bouncycastle.asn1.x509.GeneralName;
+import org.bouncycastle.asn1.x509.KeyPurposeId;
+import org.bouncycastle.asn1.x509.KeyUsage;
+import org.bouncycastle.cert.X509CertificateHolder;
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
+import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder;
+import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
+import org.bouncycastle.cert.ocsp.CertificateID;
+import org.bouncycastle.cert.ocsp.CertificateStatus;
+import org.bouncycastle.cert.ocsp.OCSPReq;
+import org.bouncycastle.cert.ocsp.OCSPResp;
+import org.bouncycastle.cert.ocsp.OCSPRespBuilder;
+import org.bouncycastle.cert.ocsp.RevokedStatus;
+import org.bouncycastle.cert.ocsp.jcajce.JcaBasicOCSPRespBuilder;
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
+import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.io.IOException;
+import java.math.BigInteger;
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.cert.X509Certificate;
+import java.security.spec.ECGenParameterSpec;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Base64;
+import java.util.Date;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Exercises the real JDK and bundled OCSP HTTP clients against a local, signing responder. */
+class OcspNetworkTest {
+
+ private final Instant now = Instant.now();
+ private final AtomicInteger requestCount = new AtomicInteger();
+ private final AtomicReference receivedRequest = new AtomicReference<>();
+ private final AtomicReference receivedPath = new AtomicReference<>();
+ private final AtomicReference serverFailure = new AtomicReference<>();
+ private HttpServer server;
+ private URI aiaUri;
+ private URI designatedUri;
+ private X509Certificate issuer;
+ private X509Certificate subject;
+ private X509Certificate responder;
+ private KeyPair responderKeys;
+ private volatile Reply reply = Reply.GOOD;
+ private volatile boolean includeNonce = true;
+
+ private enum Reply { GOOD, REVOKED, TRY_LATER, DISCONNECT, UNSUPPORTED_TYPE, MISSING_RESPONSE }
+
+ @BeforeEach
+ void setup() throws Exception {
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ aiaUri = URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/aia");
+ designatedUri = aiaUri.resolve("/designated");
+
+ final KeyPair issuerKeys = newKeys();
+ responderKeys = newKeys();
+ final X500Name issuerName = new X500Name("CN=Local OCSP test CA");
+ final var issuerBuilder = certificateBuilder(issuerName, issuerName, issuerKeys, 1);
+ issuerBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(true));
+ issuerBuilder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign | KeyUsage.cRLSign));
+ issuer = sign(issuerBuilder, issuerKeys);
+
+ final var subjectBuilder = certificateBuilder(issuerName, new X500Name("CN=Local OCSP test subject"), newKeys(), 2);
+ subjectBuilder.addExtension(Extension.authorityInfoAccess, false, new AuthorityInformationAccess(
+ AccessDescription.id_ad_ocsp, new GeneralName(GeneralName.uniformResourceIdentifier, aiaUri.toString())));
+ subject = sign(subjectBuilder, issuerKeys);
+
+ final var responderBuilder = certificateBuilder(issuerName, new X500Name("CN=Local OCSP test responder"), responderKeys, 3);
+ responderBuilder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature));
+ responderBuilder.addExtension(Extension.extendedKeyUsage, false, new ExtendedKeyUsage(KeyPurposeId.id_kp_OCSPSigning));
+ responder = sign(responderBuilder, issuerKeys);
+
+ server.createContext("/aia", this::respond);
+ server.createContext("/designated", this::respond);
+ server.start();
+ }
+
+ @AfterEach
+ void stopServer() {
+ if (server != null) {
+ server.stop(0);
+ }
+ assertThat(serverFailure.get()).as("Local responder failure").isNull();
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = RevocationMode.class, names = {"PLATFORM_OCSP", "CUSTOM_CHECKER"})
+ void whenResponderReturnsGood_thenValidationSendsRequestAndSucceeds(RevocationMode mode) throws Exception {
+ final List info = validate(mode, true);
+
+ assertRequestReachedResponder(mode);
+ assertThat(receivedRequest.get().getExtension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce)).isNotNull();
+ if (mode == RevocationMode.CUSTOM_CHECKER) {
+ assertThat(info).singleElement().extracting(RevocationInfo::ocspResponderUri).isEqualTo(designatedUri);
+ } else {
+ assertThat(info).isEmpty();
+ }
+ }
+
+ @Test
+ void whenPlatformMakesTwoChecks_thenRequestsContainDifferent32ByteNonces() throws Exception {
+ validate(RevocationMode.PLATFORM_OCSP, true);
+ final byte[] firstNonce = nonce(receivedRequest.get());
+ validate(RevocationMode.PLATFORM_OCSP, true);
+
+ assertThat(requestCount.get()).isEqualTo(2);
+ assertThat(firstNonce).hasSize(32).isNotEqualTo(nonce(receivedRequest.get()));
+ assertThat(nonce(receivedRequest.get())).hasSize(32);
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = RevocationMode.class, names = {"PLATFORM_OCSP", "CUSTOM_CHECKER"})
+ void whenResponderReturnsRevoked_thenValidationRejectsCertificate(RevocationMode mode) {
+ reply = Reply.REVOKED;
+
+ assertThatThrownBy(() -> validate(mode, true)).isInstanceOf(CertificateRevokedException.class);
+ assertRequestReachedResponder(mode);
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = RevocationMode.class, names = {"PLATFORM_OCSP", "CUSTOM_CHECKER"})
+ void whenResponderReturnsTryLater_thenValidationReportsFailure(RevocationMode mode) {
+ reply = Reply.TRY_LATER;
+
+ assertThatThrownBy(() -> validate(mode, true)).isInstanceOf(CertificateRevocationCheckFailedException.class);
+ assertRequestReachedResponder(mode);
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = RevocationMode.class, names = {"PLATFORM_OCSP", "CUSTOM_CHECKER"})
+ void whenResponderDisconnects_thenValidationReportsFailureWithCause(RevocationMode mode) {
+ reply = Reply.DISCONNECT;
+
+ assertThatThrownBy(() -> validate(mode, true))
+ .isInstanceOf(CertificateRevocationCheckFailedException.class)
+ .hasRootCauseInstanceOf(IOException.class);
+ assertRequestReachedResponder(mode);
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = Reply.class, names = {"UNSUPPORTED_TYPE", "MISSING_RESPONSE"})
+ void whenBasicResponseIsMissingOrUnsupported_thenCustomCheckerReportsFailure(Reply response) {
+ reply = response;
+
+ assertThatThrownBy(() -> validate(RevocationMode.CUSTOM_CHECKER, true))
+ .isInstanceOf(UserCertificateOCSPCheckFailedException.class)
+ .hasMessageContaining("Missing or unsupported Basic OCSP Response");
+ assertRequestReachedResponder(RevocationMode.CUSTOM_CHECKER);
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {true, false})
+ void whenDesignatedResponderOmitsNonce_thenConfiguredPolicyIsEnforced(boolean nonceEnabled) throws Exception {
+ includeNonce = false;
+
+ if (nonceEnabled) {
+ assertThatThrownBy(() -> validate(RevocationMode.CUSTOM_CHECKER, true))
+ .isInstanceOf(UserCertificateOCSPCheckFailedException.class)
+ .hasMessageContaining("nonce extension missing");
+ } else {
+ validate(RevocationMode.CUSTOM_CHECKER, false);
+ assertThat(receivedRequest.get().getExtension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce)).isNull();
+ }
+ assertRequestReachedResponder(RevocationMode.CUSTOM_CHECKER);
+ }
+
+ private List validate(RevocationMode mode, boolean nonceEnabled) throws Exception {
+ final var anchors = CertificateValidator.buildTrustAnchorsFromCertificates(List.of(issuer));
+ final var store = CertificateValidator.buildCertStoreFromCertificates(List.of(issuer));
+ final OcspCertificateRevocationChecker checker = mode == RevocationMode.CUSTOM_CHECKER
+ ? new OcspCertificateRevocationChecker(
+ OcspClientImpl.build(Duration.ofSeconds(2)),
+ new OcspServiceProvider(
+ new DesignatedOcspServiceConfiguration(designatedUri, responder, List.of(issuer), nonceEnabled,
+ OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE),
+ new AiaOcspServiceConfiguration(Set.of(), anchors, store,
+ OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE)),
+ OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW)
+ : null;
+ return CertificateValidator.validateCertificateTrustAndRevocation(
+ subject, anchors, store, Date.from(now), mode, checker, null, nonceEnabled);
+ }
+
+ private void assertRequestReachedResponder(RevocationMode mode) {
+ assertThat(requestCount.get()).isPositive();
+ assertThat(receivedRequest.get().getRequestList()).hasSize(1);
+ assertThat(receivedRequest.get().getRequestList()[0].getCertID().getSerialNumber()).isEqualTo(subject.getSerialNumber());
+ assertThat(receivedPath.get()).startsWith(mode == RevocationMode.PLATFORM_OCSP ? "/aia" : "/designated");
+ }
+
+ private void respond(HttpExchange exchange) throws IOException {
+ try {
+ // The JDK may use GET for small requests; the bundled client uses POST.
+ final byte[] bytes = exchange.getRequestMethod().equals("GET")
+ ? Base64.getDecoder().decode(URLDecoder.decode(exchange.getRequestURI().getRawPath()
+ .substring(exchange.getHttpContext().getPath().length() + 1), StandardCharsets.UTF_8))
+ : exchange.getRequestBody().readAllBytes();
+ final OCSPReq request = new OCSPReq(bytes);
+ receivedRequest.set(request);
+ receivedPath.set(exchange.getRequestURI().getPath());
+ requestCount.incrementAndGet();
+ if (reply == Reply.DISCONNECT) {
+ return;
+ }
+ final byte[] response = response(request).getEncoded();
+ exchange.getResponseHeaders().set("Content-Type", "application/ocsp-response");
+ exchange.sendResponseHeaders(200, response.length);
+ exchange.getResponseBody().write(response);
+ } catch (Exception e) {
+ serverFailure.set(e);
+ exchange.sendResponseHeaders(500, -1);
+ } finally {
+ exchange.close();
+ }
+ }
+
+ private OCSPResp response(OCSPReq request) throws Exception {
+ if (reply == Reply.TRY_LATER) {
+ return new OCSPRespBuilder().build(OCSPResp.TRY_LATER, null);
+ }
+ if (reply == Reply.MISSING_RESPONSE || reply == Reply.UNSUPPORTED_TYPE) {
+ return new OCSPResp(new OCSPResponse(new OCSPResponseStatus(0), reply == Reply.MISSING_RESPONSE ? null
+ : new ResponseBytes(new ASN1ObjectIdentifier("1.2.3.4"), new DEROctetString(new byte[0]))));
+ }
+ final var builder = new JcaBasicOCSPRespBuilder(responder.getPublicKey(),
+ new JcaDigestCalculatorProviderBuilder().build().get(CertificateID.HASH_SHA1));
+ final CertificateStatus status = reply == Reply.REVOKED
+ ? new RevokedStatus(Date.from(now.minusSeconds(60)), CRLReason.keyCompromise) : CertificateStatus.GOOD;
+ builder.addResponse(request.getRequestList()[0].getCertID(), status,
+ Date.from(now.minusSeconds(1)), Date.from(now.plusSeconds(60)), null);
+ final Extension nonce = request.getExtension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce);
+ if (includeNonce && nonce != null) {
+ builder.setResponseExtensions(new Extensions(nonce));
+ }
+ return new OCSPRespBuilder().build(OCSPResp.SUCCESSFUL, builder.build(
+ new JcaContentSignerBuilder("SHA256withECDSA").build(responderKeys.getPrivate()),
+ new X509CertificateHolder[] {new JcaX509CertificateHolder(responder)}, Date.from(now)));
+ }
+
+ private static byte[] nonce(OCSPReq request) throws IOException {
+ return ASN1OctetString.getInstance(request.getExtension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce)
+ .getExtnValue().getOctets()).getOctets();
+ }
+
+ private JcaX509v3CertificateBuilder certificateBuilder(X500Name issuerName, X500Name subjectName, KeyPair keys, int serial) {
+ // Generate certificates around the test time so fixtures do not expire or depend on responder rotation.
+ return new JcaX509v3CertificateBuilder(issuerName, BigInteger.valueOf(serial),
+ Date.from(now.minusSeconds(3600)), Date.from(now.plusSeconds(3600)), subjectName, keys.getPublic());
+ }
+
+ private static X509Certificate sign(JcaX509v3CertificateBuilder builder, KeyPair issuerKeys) throws Exception {
+ return new JcaX509CertificateConverter().getCertificate(builder.build(
+ new JcaContentSignerBuilder("SHA256withECDSA").build(issuerKeys.getPrivate())));
+ }
+
+ private static KeyPair newKeys() throws Exception {
+ final KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
+ generator.initialize(new ECGenParameterSpec("secp256r1"));
+ return generator.generateKeyPair();
+ }
+}
diff --git a/src/test/java/eu/webeid/security/certificate/OcspNonceExtensionTest.java b/src/test/java/eu/webeid/security/certificate/OcspNonceExtensionTest.java
new file mode 100644
index 00000000..4b9fb3d9
--- /dev/null
+++ b/src/test/java/eu/webeid/security/certificate/OcspNonceExtensionTest.java
@@ -0,0 +1,55 @@
+// SPDX-FileCopyrightText: Estonian Information System Authority
+// SPDX-License-Identifier: MIT
+
+package eu.webeid.security.certificate;
+
+import org.bouncycastle.asn1.DEROctetString;
+import org.bouncycastle.asn1.ocsp.OCSPObjectIdentifiers;
+import org.bouncycastle.asn1.x509.Extension;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.util.HexFormat;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class OcspNonceExtensionTest {
+
+ @Test
+ void whenNonceExtensionIsCreated_thenItIsEncodedAsNonCriticalOcspNonceExtension() throws Exception {
+ final byte[] nonce = HexFormat.of().parseHex(
+ "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
+ );
+ final OcspNonceExtension extension = new OcspNonceExtension(
+ Extension.create(OCSPObjectIdentifiers.id_pkix_ocsp_nonce, false, new DEROctetString(nonce))
+ );
+ final ByteArrayOutputStream encodedExtension = new ByteArrayOutputStream();
+
+ extension.encode(encodedExtension);
+
+ assertThat(extension.getId()).isEqualTo("1.3.6.1.5.5.7.48.1.2");
+ assertThat(extension.isCritical()).isFalse();
+ assertThat(extension.getValue()).containsExactly(
+ HexFormat.of().parseHex(
+ "0420000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
+ )
+ );
+ assertThat(encodedExtension.toByteArray()).containsExactly(
+ HexFormat.of().parseHex(
+ "302f06092b060105050730010204220420" +
+ "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
+ )
+ );
+ }
+
+ @Test
+ void whenValueIsReturned_thenItCannotBeUsedToModifyExtension() {
+ final OcspNonceExtension extension = OcspNonceExtension.create();
+ final byte[] originalValue = extension.getValue();
+
+ final byte[] value = extension.getValue();
+ value[2] ^= 1;
+
+ assertThat(extension.getValue()).containsExactly(originalValue);
+ }
+}
diff --git a/src/test/java/eu/webeid/security/testutil/AbstractTestWithValidator.java b/src/test/java/eu/webeid/security/testutil/AbstractTestWithValidator.java
index 5724d74e..c8cb25f3 100644
--- a/src/test/java/eu/webeid/security/testutil/AbstractTestWithValidator.java
+++ b/src/test/java/eu/webeid/security/testutil/AbstractTestWithValidator.java
@@ -3,27 +3,41 @@
package eu.webeid.security.testutil;
-import org.junit.jupiter.api.BeforeEach;
import eu.webeid.security.authtoken.WebEidAuthToken;
import eu.webeid.security.exceptions.AuthTokenException;
import eu.webeid.security.validator.AuthTokenValidator;
+import org.junit.jupiter.api.BeforeEach;
import java.io.IOException;
import java.security.cert.CertificateException;
-import static eu.webeid.security.testutil.AuthTokenValidators.getAuthTokenValidator;
-
public abstract class AbstractTestWithValidator {
- /*
+ /*
+ * 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_RS256_AUTH_TOKEN = "{\"algorithm\":\"RS256\"," +
+ "\"unverifiedCertificate\":\"MIIGvjCCBKagAwIBAgIQT7aXeR+zWlBb2Gbar+AFaTANBgkqhkiG9w0BAQsFADCBgzELMAkGA1UEBhMCTFYxOTA3BgNVBAoMMFZBUyBMYXR2aWphcyBWYWxzdHMgcmFkaW8gdW4gdGVsZXbEq3ppamFzIGNlbnRyczEaMBgGA1UEYQwRTlRSTFYtNDAwMDMwMTEyMDMxHTAbBgNVBAMMFERFTU8gTFYgZUlEIElDQSAyMDE3MB4XDTE4MTAzMDE0MTI0MloXDTIzMTAzMDE0MTI0MlowcDELMAkGA1UEBhMCTFYxHDAaBgNVBAMME0FORFJJUyBQQVJBVURaScWFxaAxFTATBgNVBAQMDFBBUkFVRFpJxYXFoDEPMA0GA1UEKgwGQU5EUklTMRswGQYDVQQFExJQTk9MVi0zMjE5MjItMzMwMzIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDXkra3rDOOt5K6OnJcg/Xt6JOogPAUBX2kT9zWelze7WSuPx2Ofs//0JoBQ575IVdh3JpLhfh7g60YYi41M6vNACVSNaFOxiEvE9amSFizMiLk5+dp+79rymqOsVQG8CSu8/RjGGlDsALeb3N/4pUSTGXUwSB64QuFhOWjAcmKPhHeYtry0hK3MbwwHzFhYfGpo/w+PL14PEdJlpL1UX/aPyT0Zq76Z4T/Z3PqbTmQp09+2b0thC0JIacSkyJuTu8fVRQvse+8UtYC6Kt3TBLZbPtqfAFSXWbuE47Lc2o840NkVlMHVAesoRAfiQxsK35YWFT0rHPWbLjX6ySiaL25AgMBAAGjggI+MIICOjAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAjAdBgNVHQ4EFgQUHZWimPze2GXULNaP4EFVdF+MWKQwHwYDVR0jBBgwFoAUj2jOvOLHQCFTCUK75Z4djEvNvTgwgfsGA1UdIASB8zCB8DA7BgYEAI96AQIwMTAvBggrBgEFBQcCARYjaHR0cHM6Ly93d3cuZXBhcmFrc3RzLmx2L3JlcG9zaXRvcnkwgbAGDCsGAQQBgfo9AgECATCBnzAvBggrBgEFBQcCARYjaHR0cHM6Ly93d3cuZXBhcmFrc3RzLmx2L3JlcG9zaXRvcnkwbAYIKwYBBQUHAgIwYAxexaBpcyBzZXJ0aWZpa8SBdHMgaXIgaWVrxLxhdXRzIExhdHZpamFzIFJlcHVibGlrYXMgaXpzbmllZ3TEgSBwZXJzb251IGFwbGllY2lub8WhxIEgZG9rdW1lbnTEgTB9BggrBgEFBQcBAQRxMG8wQgYIKwYBBQUHMAKGNmh0dHA6Ly9kZW1vLmVwYXJha3N0cy5sdi9jZXJ0L2RlbW9fTFZfZUlEX0lDQV8yMDE3LmNydDApBggrBgEFBQcwAYYdaHR0cDovL29jc3AucHJlcC5lcGFyYWtzdHMubHYwSAYDVR0fBEEwPzA9oDugOYY3aHR0cDovL2RlbW8uZXBhcmFrc3RzLmx2L2NybC9kZW1vX0xWX2VJRF9JQ0FfMjAxN18zLmNybDANBgkqhkiG9w0BAQsFAAOCAgEAAOVoRbnMv2UXWYHgnmO9Zg9u8F1YvJiZPMeTYE2CVaiq0nXe4Mq0X5tWcsEiRpGQF9e0dWC6V5m6EmAsHxIRL4chZKRrIrPEiWtP3zyRI1/X2y5GwSUyZmgxkuSOHHw3UjzjrnOoI9izpC0OSNeumqpjT/tLAi35sktGkK0onEUPWGQnZLqd/hzykm+H/dmD27nOnfCJOSqbegLSbhV2w/WAII+IUD3vJ06F6rf9ZN8xbrGkPO8VMCIDIt0eBKFxBdSOgpsTfbERbjQJ+nFEDYhD0bFNYMsFSGnZiWpNaCcZSkk4mtNUa8sNXyaFQGIZk6NjQ/fsBANhUoxFz7rUKrRYqk356i8KFDZ+MJqUyodKKyW9oz+IO5eJxnL78zRbxD+EfAUmrLXOjmGIzU95RR1smS4cirrrPHqGAWojBk8hKbjNTJl9Tfbnsbc9/FUBJLVZAkCi631KfRLQ66bn8N0mbtKlNtdX0G47PXTy7SJtWwDtKQ8+qVpduc8xHLntbdAzie3mWyxA1SBhQuZ9BPf5SPBImWCNpmZNCTmI2e+4yyCnmG/kVNilUAaODH/fgQXFGdsKO/XATFohiies28twkEzqtlVZvZbpBhbJCHYVnQXMhMKcnblkDqXWcSWd3QAKig2yMH95uz/wZhiV+7tZ7cTgwcbCzIDCfpwBC3E=\"," +
+ "\"issuerApp\":\"https://web-eid.eu/web-eid-app/releases/2.0.0+0\"," +
+ "\"signature\":\"xsjXsQvVYXWcdV0YPhxLthJxtf0//R8p9WFFlYJGRARrl1ruyoAUwl0xeHgeZOKeJtwiCYCNWJzCG3VM3ydgt92bKhhk1u0JXIPVqvOkmDY72OCN4q73Y8iGSPVTgjk93TgquHlodf7YcqZNhutwNNf3oldHEWJD5zmkdwdpBFXgeOwTAdFwGljDQZbHr3h1Dr+apUDuloS0WuIzUuu8YXN2b8lh8FCTlF0G0DEjhHd/MGx8dbe3UTLHmD7K9DXv4zLJs6EF9i2v/C10SIBQDkPBSVPqMxCDPECjbEPi2+ds94eU7ThOhOQlFFtJ4KjQNTUa2crSixH7cYZF2rNNmA==\"," +
+ "\"format\":\"web-eid:1.0\"}";
public static final String VALID_CHALLENGE_NONCE = "12345678123456781234567812345678912356789123";
protected AuthTokenValidator validator;
diff --git a/src/test/java/eu/webeid/security/testutil/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/Certificates.java b/src/test/java/eu/webeid/security/testutil/Certificates.java
index ec3dd235..cc5479c9 100644
--- a/src/test/java/eu/webeid/security/testutil/Certificates.java
+++ b/src/test/java/eu/webeid/security/testutil/Certificates.java
@@ -18,17 +18,29 @@ public class Certificates {
private static X509Certificate testEsteid2018CA;
private static X509Certificate testEsteid2015CA;
+ private static X509Certificate testEeCertCentreRootCA;
private static X509Certificate jaakKristjanEsteid2018Cert;
private static X509Certificate mariliisEsteid2015Cert;
private static X509Certificate organizationCert;
private static X509Certificate testSkOcspResponder2020;
+ private static X509Certificate testSelfSignedOcspResponder;
+ private static X509Certificate demoEsteidSk2018AiaOcspResponder;
static void loadCertificates() throws CertificateException, IOException {
- X509Certificate[] certificates = CertificateLoader.loadCertificatesFromResources("TEST_of_ESTEID-SK_2015.cer", "TEST_of_ESTEID2018.cer", "TEST_of_SK_OCSP_RESPONDER_2020.cer");
+ X509Certificate[] certificates = CertificateLoader.loadCertificatesFromResources(
+ "TEST_of_ESTEID-SK_2015.cer",
+ "TEST_of_ESTEID2018.cer",
+ "TEST_of_SK_OCSP_RESPONDER_2020.cer",
+ "TEST_of_self-signed_OCSP_RESPONDER.cer",
+ "DEMO_of_ESTEID-SK_2018_AIA_OCSP_RESPONDER_2018.cer",
+ "TEST_of_EE_Certification_Centre_Root_CA.cer");
testEsteid2015CA = certificates[0];
testEsteid2018CA = certificates[1];
testSkOcspResponder2020 = certificates[2];
+ testSelfSignedOcspResponder = certificates[3];
+ demoEsteidSk2018AiaOcspResponder = certificates[4];
+ testEeCertCentreRootCA = certificates[5];
}
public static X509Certificate getTestEsteid2018CA() throws CertificateException, IOException {
@@ -52,6 +64,27 @@ public static X509Certificate getTestSkOcspResponder2020() throws CertificateExc
return testSkOcspResponder2020;
}
+ public static X509Certificate getTestSelfSignedOcspResponder() throws CertificateException, IOException {
+ if (testSelfSignedOcspResponder == null) {
+ loadCertificates();
+ }
+ return testSelfSignedOcspResponder;
+ }
+
+ public static X509Certificate getDemoEsteidSk2018AiaOcspResponder() throws CertificateException, IOException {
+ if (demoEsteidSk2018AiaOcspResponder == null) {
+ loadCertificates();
+ }
+ return demoEsteidSk2018AiaOcspResponder;
+ }
+
+ public static X509Certificate getTestEeCertCentreRootCA() throws CertificateException, IOException {
+ if (testEeCertCentreRootCA == null) {
+ loadCertificates();
+ }
+ return testEeCertCentreRootCA;
+ }
+
public static X509Certificate getJaakKristjanEsteid2018Cert() throws CertificateDecodingException {
if (jaakKristjanEsteid2018Cert == null) {
jaakKristjanEsteid2018Cert = CertificateLoader.decodeCertificateFromBase64(JAAK_KRISTJAN_ESTEID2018_CERT);
diff --git a/src/test/java/eu/webeid/security/testutil/ResourceUtil.java b/src/test/java/eu/webeid/security/testutil/ResourceUtil.java
new file mode 100644
index 00000000..74da75bc
--- /dev/null
+++ b/src/test/java/eu/webeid/security/testutil/ResourceUtil.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2020-2025 Estonian Information System Authority
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package eu.webeid.security.testutil;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Objects;
+
+public final class ResourceUtil {
+
+ private ResourceUtil() {
+ }
+
+ public static byte[] bytesFromResource(String resource) throws IOException {
+ try (final InputStream resourceAsStream = ClassLoader.getSystemResourceAsStream(resource)) {
+ Objects.requireNonNull(resourceAsStream, () -> "Resource not found: " + resource);
+ return resourceAsStream.readAllBytes();
+ }
+ }
+}
diff --git a/src/test/java/eu/webeid/security/testutil/TestCertificateBuilder.java b/src/test/java/eu/webeid/security/testutil/TestCertificateBuilder.java
new file mode 100644
index 00000000..149a23e0
--- /dev/null
+++ b/src/test/java/eu/webeid/security/testutil/TestCertificateBuilder.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright (c) 2020-2025 Estonian Information System Authority
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package eu.webeid.security.testutil;
+
+import org.bouncycastle.asn1.x500.X500Name;
+import org.bouncycastle.asn1.x509.Extension;
+import org.bouncycastle.cert.X509v3CertificateBuilder;
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
+import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
+import org.bouncycastle.operator.ContentSigner;
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
+
+import java.math.BigInteger;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.cert.X509Certificate;
+import java.time.Instant;
+import java.util.Date;
+
+public final class TestCertificateBuilder {
+
+ private static final KeyPair TEST_KEY_PAIR = generateKeyPair();
+
+ public static X509Certificate buildCertificate(Extension... extensions) throws Exception {
+ final X500Name name = new X500Name("CN=Test OCSP Responder");
+ final Instant now = Instant.now();
+ final X509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder(
+ name, BigInteger.ONE, Date.from(now.minusSeconds(60)), Date.from(now.plusSeconds(3600)),
+ name, TEST_KEY_PAIR.getPublic());
+ for (final Extension extension : extensions) {
+ builder.addExtension(extension);
+ }
+ final ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(TEST_KEY_PAIR.getPrivate());
+ return new JcaX509CertificateConverter().getCertificate(builder.build(signer));
+ }
+
+ private static KeyPair generateKeyPair() {
+ try {
+ final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
+ keyPairGenerator.initialize(2048);
+ return keyPairGenerator.generateKeyPair();
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ private TestCertificateBuilder() {
+ throw new IllegalStateException("Utility class");
+ }
+}
diff --git a/src/test/java/eu/webeid/security/validator/AuthTokenAlgorithmTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenAlgorithmTest.java
index 280fd898..a4cc8461 100644
--- a/src/test/java/eu/webeid/security/validator/AuthTokenAlgorithmTest.java
+++ b/src/test/java/eu/webeid/security/validator/AuthTokenAlgorithmTest.java
@@ -26,7 +26,7 @@ protected void setup() {
super.setup();
mockedClock = mockStatic(DateAndTime.DefaultClock.class);
// Ensure that the certificates do not expire.
- mockDate("2021-07-23", mockedClock);
+ mockDate(VALID_AUTH_TOKEN_TEST_DATE, mockedClock);
}
@AfterEach
diff --git a/src/test/java/eu/webeid/security/validator/AuthTokenCertificateTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenCertificateTest.java
index a13d0e3d..0ff15224 100644
--- a/src/test/java/eu/webeid/security/validator/AuthTokenCertificateTest.java
+++ b/src/test/java/eu/webeid/security/validator/AuthTokenCertificateTest.java
@@ -4,6 +4,7 @@
package eu.webeid.security.validator;
import com.fasterxml.jackson.databind.exc.MismatchedInputException;
+import eu.webeid.ocsp.exceptions.UserCertificateRevokedException;
import eu.webeid.security.authtoken.WebEidAuthToken;
import eu.webeid.security.exceptions.AuthTokenException;
import eu.webeid.security.exceptions.AuthTokenParseException;
@@ -13,7 +14,6 @@
import eu.webeid.security.exceptions.CertificateNotYetValidException;
import eu.webeid.security.exceptions.UserCertificateDisallowedPolicyException;
import eu.webeid.security.exceptions.UserCertificateMissingPurposeException;
-import eu.webeid.security.exceptions.UserCertificateRevokedException;
import eu.webeid.security.exceptions.UserCertificateWrongPurposeException;
import eu.webeid.security.testutil.AbstractTestWithValidator;
import eu.webeid.security.testutil.AuthTokenValidators;
@@ -45,8 +45,6 @@ class AuthTokenCertificateTest extends AbstractTestWithValidator {
private static final String OLD_MOBILE_ID_CERT = "MIIE/TCCAuWgAwIBAgIQKbCN+05vfp1XOXVu6HMXRTANBgkqhkiG9w0BAQsFADBjMQswCQYDVQQGEwJFRTEiMCAGA1UECgwZQVMgU2VydGlmaXRzZWVyaW1pc2tlc2t1czEXMBUGA1UEYQwOTlRSRUUtMTA3NDcwMTMxFzAVBgNVBAMMDkVTVEVJRC1TSyAyMDE1MB4XDTE2MDUxNjA3MjMyNloXDTIxMDUxNjIwNTk1OVowgZsxCzAJBgNVBAYTAkVFMRswGQYDVQQKDBJFU1RFSUQgKE1PQklJTC1JRCkxFzAVBgNVBAsMDmF1dGhlbnRpY2F0aW9uMSAwHgYDVQQDDBdLQVNTLEFSVFVSSSwzNjAxMjM0NTY3ODENMAsGA1UEBAwES0FTUzEPMA0GA1UEKgwGQVJUVVJJMRQwEgYDVQQFEwszNjAxMjM0NTY3ODBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABBMstpyGLcAEXkctwF8TkyPUl1IizQb6nBvfEIaayMmNzZFRCNLZfV6z4AXJN58Mjs4d4RXsARU1vjBsi8yJMaCjggE9MIIBOTAJBgNVHRMEAjAAMA4GA1UdDwEB/wQEAwIEsDBbBgNVHSAEVDBSMFAGCisGAQQBzh8BAwMwQjAdBggrBgEFBQcCAjARDA9Db250cmFjdCAxLjExLTkwIQYIKwYBBQUHAgEWFWh0dHBzOi8vd3d3LnNrLmVlL2NwczAfBgNVHREEGDAWgRRoZWlra2kua2l0dEBlZXN0aS5lZTAdBgNVHQ4EFgQUkNR/r9m77o9Gsp04JFIBqEATVGQwIAYDVR0lAQH/BBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMEMB8GA1UdIwQYMBaAFLOriLyZ1WKkhSoIzbQdcjuDckdRMDwGA1UdHwQ1MDMwMaAvoC2GK2h0dHA6Ly93d3cuc2suZWUvY3Jscy9lc3RlaWQvZXN0ZWlkMjAxNS5jcmwwDQYJKoZIhvcNAQELBQADggIBAEJCCMR6IQ/Xncjyj9jtlFm1UBuf4g71GfSQMtL1g3ZxLBc77aKwBBtTuOTmPDUdY5+xA5dDfj6nFRdZZwWedps9Tm5T98G+y0477TCfP29UlnpQ55EtdXGdgzeDMlCpmRpsu2YceajdpQncp6A8tJsG+hW733O6W2dqrQV2e7Dnofm20KVHHlUa+ma+pGvaHDYXHgYAD1o58Ro+JHy7Bw3jo4Lg+j/CuBzTk4T6D05Ybnvv58/PBrPjASwKVhjNNvHYwgDmeGzQKocmDWUSRjTiWAxP9PYxwuiP2epoypyv9VPJEe3dy3EWgY+iPfMN2BuFdmZtKSHdWSeqCK+jro4kzjWrGYY+JbxSYmfF3GWWAj5sq/+/S2SgiFWGHdvtr1kVr7DaLDmz8N/QyrNjVVz4bYkzj9OPM7ofJs1QDE/2yGCkpJ628zB1ATpEjtVsit5hti0d3k1cIBTbtiUuSrOGHTyxwRenvIZ/MckvLFmTKZ6m255ASjtwqTf2Z+Jo13Adr5zhRvQvY+qGfZb/E4KhVSrcUI8OgTXzkPGIdm6tYETKqmdgevG16noPxzmzf6DJsjnbrOwuEhRykUzSRWO+h0pA7lvjLN8SKbCn/uWARN1XsbarA0yAnW484Gsu8psOaGpZ+VzM7z5/yv/BY2aTPP0hIeI0cqRtko7Zs6vH";
private static final String NEW_MOBILE_ID_CERT = "MIIFmDCCA4CgAwIBAgIQMwcy8Yggv2lfTTKLQOhNYTANBgkqhkiG9w0BAQsFADBjMQswCQYDVQQGEwJFRTEiMCAGA1UECgwZQVMgU2VydGlmaXRzZWVyaW1pc2tlc2t1czEXMBUGA1UEYQwOTlRSRUUtMTA3NDcwMTMxFzAVBgNVBAMMDkVTVEVJRC1TSyAyMDE1MB4XDTIwMDgzMTE3MjUzMVoXDTI1MDgzMTIwNTk1OVowcTELMAkGA1UEBhMCRUUxIzAhBgNVBAMMGkvDlVZFUlNBUixNQVRJLDM4MzA5MTQwNDIwMRIwEAYDVQQEDAlLw5VWRVJTQVIxDTALBgNVBCoMBE1BVEkxGjAYBgNVBAUTEVBOT0VFLTM4MzA5MTQwNDIwMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEJcgIjZEEIyIMGVli1xp88YlS8PnWBCcXdBiBTDNiVT/4OlTTteBBIePn2vKWOgUNDOWyFsBQRPy93Pig2thAzqOCAgMwggH/MAkGA1UdEwQCMAAwDgYDVR0PAQH/BAQDAgOIMHIGA1UdIARrMGkwXQYJKwYBBAHOHwEDMFAwLwYIKwYBBQUHAgEWI2h0dHBzOi8vd3d3LnNrLmVlL3JlcG9zaXRvb3JpdW0vQ1BTMB0GCCsGAQUFBwICMBEaD0NvbnRyYWN0IDEuMTEtOTAIBgYEAI96AQIwIQYDVR0RBBowGIEWbWF0aS5rb3ZlcnNhckBlZXN0aS5lZTAdBgNVHQ4EFgQUaNW6n29aMRbULPtIyvmRq50g8g8wHwYDVR0jBBgwFoAUs6uIvJnVYqSFKgjNtB1yO4NyR1EwagYIKwYBBQUHAQEEXjBcMCcGCCsGAQUFBzABhhtodHRwOi8vYWlhLnNrLmVlL2VzdGVpZDIwMTUwMQYIKwYBBQUHMAKGJWh0dHA6Ly9jLnNrLmVlL0VTVEVJRC1TS18yMDE1LmRlci5jcnQwYQYIKwYBBQUHAQMEVTBTMFEGBgQAjkYBBTBHMEUWP2h0dHBzOi8vc2suZWUvZW4vcmVwb3NpdG9yeS9jb25kaXRpb25zLWZvci11c2Utb2YtY2VydGlmaWNhdGVzLxMCRU4wPAYDVR0fBDUwMzAxoC+gLYYraHR0cDovL3d3dy5zay5lZS9jcmxzL2VzdGVpZC9lc3RlaWQyMDE1LmNybDANBgkqhkiG9w0BAQsFAAOCAgEAFvYmmaeNvh6vakizkv6HvXYXXDvTIVA7Z3WsXN9ZffvqiqM6wg9iIBiWjUE5qKh0sVa6qlxTHJ90keltgWsLkPbyCeJvsl7npI+/3NvHjwR83dHzmi8V21zLAVwg3nGpbklg2uTPAagQfRwa91D3+SPu/2Uo6a/vwJVTaMZP+PEHEkCmNuwPAECgnniNgcwlCK0mhNK9urDWewsDcZqRIi61QyQzHde9l0IUlTcI8nPzIma5f831xVXGqi98WQZpqfHsdPL14wwAP5UjszvDe3DOvx0eARhoSrm8MLj3Y9oN82oM0XBIc6uRw0KNp8lunHMIAL2b30ULJkXMLLdA/FK4KS2Mvlt+dO3x+tqKUGX4wrxPtNmWvvLFKfPpzjLKDl/JA6fBcD2LHcKSDMK8Jgcokl3tzI8zG0RKy3yDCpA+c3CP7pIDLBb0fpNzjUAtTS72mgAzRbFNXctN05uekYmThU2Z71MvUCw0JixN6G7DmiOe3cp9kA01f0RBlM76f2x6YmZ7XCdI0JNQm8SpyctVX/2Sbed0kbjpOV05CFEtWWYlBO3oHf7Sf0jqYrs3SN999MHHCg4wdsWUJiGuILyMJi+gQphID/PgjDW9qxLd0kqK1cBLKybwgBScdt5KZjrTKYWOSTwvh5FhFKVVwsCMeOh/+ojKWhX6uKhwMOQ=";
- private static final String EXPIRED_RSA_CERT = "MIIE3DCCA8SgAwIBAgIQbLnhZj25xUtSW9CfBn46KjANBgkqhkiG9w0BAQUFADBkMQswCQYDVQQGEwJFRTEiMCAGA1UECgwZQVMgU2VydGlmaXRzZWVyaW1pc2tlc2t1czEXMBUGA1UEAwwORVNURUlELVNLIDIwMTExGDAWBgkqhkiG9w0BCQEWCXBraUBzay5lZTAeFw0xMzEwMTQxMTA4MTVaFw0xODEwMTEyMDU5NTlaMIGPMQswCQYDVQQGEwJFRTEPMA0GA1UECgwGRVNURUlEMRcwFQYDVQQLDA5hdXRoZW50aWNhdGlvbjEgMB4GA1UEAwwXS0lUVCxIRUlLS0ksMzc3MTIzMDAyNTUxDTALBgNVBAQMBEtJVFQxDzANBgNVBCoMBkhFSUtLSTEUMBIGA1UEBRMLMzc3MTIzMDAyNTUwggEjMA0GCSqGSIb3DQEBAQUAA4IBEAAwggELAoIBAQBopcNoApF/o+YyVcHaonVhCbUYfUhDtoP2VDOKXNytBNIFO5uEL86mMOcfTURfOssrpvQBVgKWgQ0wjhq09qkfPJM9NbPz0VytcsGARKSNcPh1BKgnUnfd0M6SwSl1rFl2zvbDBfZTMDtQbROS4eV1wBXwa8XeHqQmTOZK/4mv+6fj0q/LzPmxUHP/LJbyjm07MAVzTAGFvanICPdTY9YQUyNCtp+r8RxjNEk/FjVDi9zgER7Tg/v/VEnjUdZG4pLZXnV+4EsBcH2Y/XoPq3Ou0ts3IG02iz83UFR0o3TYQnHnW9fMwToJRQzS3Bnd+NZee+yZZNKOUvxmn8f4dsDdAgR3CME3o4IBWzCCAVcwCQYDVR0TBAIwADAOBgNVHQ8BAf8EBAMCBLAwUQYDVR0gBEowSDBGBgsrBgEEAc4fAQEDAzA3MBIGCCsGAQUFBwICMAYaBG5vbmUwIQYIKwYBBQUHAgEWFWh0dHA6Ly93d3cuc2suZWUvY3BzLzAfBgNVHREEGDAWgRRoZWlra2kua2l0dEBlZXN0aS5lZTAdBgNVHQ4EFgQUC8nhz1ziuRJnO6hJIBYthupzYkYwIAYDVR0lAQH/BBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMEMCIGCCsGAQUFBwEDBBYwFDAIBgYEAI5GAQEwCAYGBACORgEEMB8GA1UdIwQYMBaAFHtq8lVQXLjZegiHQa76ois9W1d2MEAGA1UdHwQ5MDcwNaAzoDGGL2h0dHA6Ly93d3cuc2suZWUvcmVwb3NpdG9yeS9jcmxzL2VzdGVpZDIwMTEuY3JsMA0GCSqGSIb3DQEBBQUAA4IBAQBV7ohEG05MXcxHEeXOb2hNuLrVVT2dhOVwp21M13sOsG9l/GN8KEUR4JQcJo4zEK49zHCaA1qKg+4/mubWfMKz5eUS9njs7cuit2FjlTJUrm7ye9dKndGiv5o4T4ycvbUF4NJ6AvxXZLolfLTaF6Ge/c15Jz1WmBv0x+0C00d0qWkE3VVjwqYxUw9gJlWfbLLxqsT1pUXaf9JcsxdKXkhKKr9eQ7r00PwbARkKyeU/ylHGfOQlZeGXfyWxX1q1ZALicwJe6/UbQTqQeLn5Mviw/49H2rLb9BImFIJ30QYBlj9SGSHSZ5k11XPRaw2GfLrgoBqOjMUyKhfRxqJwb/xL";
- private static final String EXPIRED_ECDSA_CERT = "MIIF0TCCA7mgAwIBAgIQMBVFXroEt3hZ8FHcKKE65TANBgkqhkiG9w0BAQsFADBjMQswCQYDVQQGEwJFRTEiMCAGA1UECgwZQVMgU2VydGlmaXRzZWVyaW1pc2tlc2t1czEXMBUGA1UEYQwOTlRSRUUtMTA3NDcwMTMxFzAVBgNVBAMMDkVTVEVJRC1TSyAyMDE1MB4XDTE3MTAyNTA4NTcwMFoXDTIxMDIxMDIxNTk1OVowgYsxCzAJBgNVBAYTAkVFMQ8wDQYDVQQKDAZFU1RFSUQxFzAVBgNVBAsMDmF1dGhlbnRpY2F0aW9uMR4wHAYDVQQDDBVUT09NLE1BUlQsMzc2MDIwNDAzMzQxDTALBgNVBAQMBFRPT00xDTALBgNVBCoMBE1BUlQxFDASBgNVBAUTCzM3NjAyMDQwMzM0MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAExS1YQQBDLVvOi0a2GA5Y34AXODpx0AL8eKDOB7BjwBc/FAyVExhfb6O+lT5Tnaec3GnT4JNRyeV8d82L8cyOgFn4PWc+5cjFdmcZjJbtCvgyBOQQ831tteIDL2XSrvZEo4ICBDCCAgAwCQYDVR0TBAIwADAOBgNVHQ8BAf8EBAMCA4gwUwYDVR0gBEwwSjA+BgkrBgEEAc4fAQEwMTAvBggrBgEFBQcCARYjaHR0cHM6Ly93d3cuc2suZWUvcmVwb3NpdG9vcml1bS9DUFMwCAYGBACPegECMB8GA1UdEQQYMBaBFG1hcnQudG9vbS4zQGVlc3RpLmVlMB0GA1UdDgQWBBSzneoLqtqbvHvJ19cjhp2XR5ovQTAgBgNVHSUBAf8EFjAUBggrBgEFBQcDAgYIKwYBBQUHAwQwHwYDVR0jBBgwFoAUs6uIvJnVYqSFKgjNtB1yO4NyR1EwYQYIKwYBBQUHAQMEVTBTMFEGBgQAjkYBBTBHMEUWP2h0dHBzOi8vc2suZWUvZW4vcmVwb3NpdG9yeS9jb25kaXRpb25zLWZvci11c2Utb2YtY2VydGlmaWNhdGVzLxMCRU4wagYIKwYBBQUHAQEEXjBcMCcGCCsGAQUFBzABhhtodHRwOi8vYWlhLnNrLmVlL2VzdGVpZDIwMTUwMQYIKwYBBQUHMAKGJWh0dHA6Ly9jLnNrLmVlL0VTVEVJRC1TS18yMDE1LmRlci5jcnQwPAYDVR0fBDUwMzAxoC+gLYYraHR0cDovL3d3dy5zay5lZS9jcmxzL2VzdGVpZC9lc3RlaWQyMDE1LmNybDANBgkqhkiG9w0BAQsFAAOCAgEAOXTvktUXqPgaK/uxzgH0xSEYClBAWIQaNgpqY5lwsQtgQnpfKlsADqMZxCp7UuuMvQmpDbBxv1kIr0oG1uUXrUtPw81XOH1ClwPPXWpg9VRTAetNbHTBbHDyzuXQMNeDmrntChs+BteletejGD+aYG39HGMlrMbGQZOgvQrpYHMDek0ckCPEsZRXqUP0g7Ie7uBQhz5At7l4EDAeOW8xGoI6t+Ke4GedccXKef60w2ZIIDzvOFHPTc6POCsIlFtF/nCKwVi7GoQKjbUbM5OdBLZ0jyLq2LvzZuT86Jo8wObziuSzApGlBexHAqLrR83q+/Xl61yPnFf3w2kAfS9kBjeunzTH7Jm3pNT3Zq9JRLvEDqtpOPqr4zm9nG6OSghFU6tySkpQ5HiakGpMcnt5o5KuXhQ+Dg317tdXPyQkSiuJ9NfEBW0ijrwO12SVRzYo/jRl4ZQUkAEEUSMEsC6gTsZypPdIsLDVoQWTytHDU89s1xJDn4HulPl12dFnrhlLeX4RxOjDxppZxdjBU0FoJoDB0qwEAN2TMAPJWh+Pp9mFuS/u0dht9sKvAkpx+o0Z7v7QMz03XlzCHOLTIK+f81Rjokl8f+wiog5Ojj0wZkDe6DuQC9L5uDey3PJHv3naVovhs7jrEJu+yrsLue/OHhAgWRh2S75/wlVPHPEE44k=";
private static final String REVOKED_CERT = "MIIERDCCA6agAwIBAgIQSs8/WoDixVxbKRhNnF/GEzAKBggqhkjOPQQDBDBgMQswCQYDVQQGEwJFRTEbMBkGA1UECgwSU0sgSUQgU29sdXRpb25zIEFTMRcwFQYDVQRhDA5OVFJFRS0xMDc0NzAxMzEbMBkGA1UEAwwSVEVTVCBvZiBFU1RFSUQyMDE4MB4XDTE4MDYxOTE0NTA1M1oXDTIwMDEwMjIxNTk1OVowfzELMAkGA1UEBhMCRUUxKjAoBgNVBAMMIUrDlUVPUkcsSkFBSy1LUklTVEpBTiwzODAwMTA4NTcxODEQMA4GA1UEBAwHSsOVRU9SRzEWMBQGA1UEKgwNSkFBSy1LUklTVEpBTjEaMBgGA1UEBRMRUE5PRUUtMzgwMDEwODU3MTgwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAR/jopNG3KL0ZQUvO4OGSvcaqUtFDm3azOtsM2VRp666r0d36Zh0Zx/xej8f+SzEfWvvDT1HQLo3USiSbYn1FyNHTNxifV+Zvf6StXJAkdu24d1UvKbf+LylglO/yS7o4ijggIEMIICADAJBgNVHRMEAjAAMA4GA1UdDwEB/wQEAwIDiDBHBgNVHSAEQDA+MDIGCysGAQQBg5F/AQIBMCMwIQYIKwYBBQUHAgEWFWh0dHBzOi8vd3d3LnNrLmVlL0NQUzAIBgYEAI96AQIwHwYDVR0RBBgwFoEUMzgwMDEwODU3MThAZWVzdGkuZWUwHQYDVR0OBBYEFEQA6/1GXJtp+6czUzorhEJ7B95pMGEGCCsGAQUFBwEDBFUwUzBRBgYEAI5GAQUwRzBFFj9odHRwczovL3NrLmVlL2VuL3JlcG9zaXRvcnkvY29uZGl0aW9ucy1mb3ItdXNlLW9mLWNlcnRpZmljYXRlcy8TAkVOMCAGA1UdJQEB/wQWMBQGCCsGAQUFBwMCBggrBgEFBQcDBDAfBgNVHSMEGDAWgBTAhJkpxE6fOwI09pnhClYACCk+ezB/BggrBgEFBQcBAQRzMHEwLAYIKwYBBQUHMAGGIGh0dHA6Ly9haWEuZGVtby5zay5lZS9lc3RlaWQyMDE4MEEGCCsGAQUFBzAChjVodHRwczovL3NrLmVlL3VwbG9hZC9maWxlcy9URVNUX29mX0VTVEVJRDIwMTguZGVyLmNydDAzBgNVHR8ELDAqMCigJqAkhiJodHRwOi8vYy5zay5lZS90ZXN0X2VzdGVpZDIwMTguY3JsMAoGCCqGSM49BAMEA4GLADCBhwJBcmcfLC+HcSJ6BuRrDGL+K+7BAW8BfAiiWWAuBV4ebLkbbAWmkc9dSKgr4BEGEt90xDTQ85yW4SjGulFXu9C3yQsCQgETaXTs3Hp6vDAcQYL8Bx4BO3DwJbDuD4BUJyT0+9HQiFCQmTQ4xrNjeaeOwRWyMOM9z5ORMeJCiQUyil1x4YPIbg==";
private MockedStatic mockedClock;
@@ -59,6 +57,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
@@ -177,19 +182,18 @@ void whenUsingNewMobileIdCertificate_thenValidationFails() throws AuthTokenExcep
}
@Test
- void whenCertificateIsExpiredRsa_thenValidationFails() throws AuthTokenException {
- final WebEidAuthToken token = replaceTokenField(AUTH_TOKEN, "X5C", EXPIRED_RSA_CERT);
- assertThatThrownBy(() -> validator
- .validate(token, VALID_CHALLENGE_NONCE))
+ void whenCertificateIsExpiredRsa_thenValidationFails() throws Exception {
+ mockDate("2099-01-01", mockedClock);
+ final WebEidAuthToken token = validator.parse(VALID_RS256_AUTH_TOKEN);
+ assertThatThrownBy(() -> validator.validate(token, VALID_CHALLENGE_NONCE))
.isInstanceOf(CertificateExpiredException.class)
.hasMessage("User certificate has expired");
}
@Test
- void whenCertificateIsExpiredEcdsa_thenValidationFails() throws AuthTokenException {
- final WebEidAuthToken token = replaceTokenField(AUTH_TOKEN, "X5C", EXPIRED_ECDSA_CERT);
- assertThatThrownBy(() -> validator
- .validate(token, VALID_CHALLENGE_NONCE))
+ void whenCertificateIsExpiredEcdsa_thenValidationFails() throws Exception {
+ mockDate("2099-01-01", mockedClock);
+ assertThatThrownBy(() -> validator.validate(validAuthToken, VALID_CHALLENGE_NONCE))
.isInstanceOf(CertificateExpiredException.class)
.hasMessage("User certificate has expired");
}
@@ -262,17 +266,6 @@ void whenCertificateIsRevoked_thenOcspCheckFails() throws Exception {
.isInstanceOf(UserCertificateRevokedException.class);
}
- @Test
- @Disabled("A new designated test OCSP responder certificate was issued whose validity period no longer overlaps with the revoked certificate")
- void whenCertificateIsRevoked_thenOcspCheckWithDesignatedOcspServiceFails() throws Exception {
- mockDate("2020-01-01", mockedClock);
- final AuthTokenValidator validatorWithOcspCheck = AuthTokenValidators.getAuthTokenValidatorWithDesignatedOcspCheck();
- final WebEidAuthToken token = replaceTokenField(AUTH_TOKEN, "X5C", REVOKED_CERT);
- assertThatThrownBy(() -> validatorWithOcspCheck
- .validate(token, VALID_CHALLENGE_NONCE))
- .isInstanceOf(UserCertificateRevokedException.class);
- }
-
@Test
void whenCertificateCaIsNotPartOfTrustChain_thenValidationFails() throws Exception {
final AuthTokenValidator validatorWithWrongTrustedCA = AuthTokenValidators.getAuthTokenValidatorWithWrongTrustedCA();
diff --git a/src/test/java/eu/webeid/security/validator/AuthTokenSignatureTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenSignatureTest.java
index 50b9010c..5e01bdd3 100644
--- a/src/test/java/eu/webeid/security/validator/AuthTokenSignatureTest.java
+++ b/src/test/java/eu/webeid/security/validator/AuthTokenSignatureTest.java
@@ -5,6 +5,7 @@
import eu.webeid.security.authtoken.WebEidAuthToken;
import eu.webeid.security.certificate.CertificateData;
+import eu.webeid.security.exceptions.AuthTokenException;
import eu.webeid.security.exceptions.AuthTokenSignatureValidationException;
import eu.webeid.security.testutil.AbstractTestWithValidator;
import eu.webeid.security.testutil.AuthTokenValidators;
@@ -39,6 +40,13 @@ protected void setup() {
mockedClock = mockStatic(DateAndTime.DefaultClock.class);
// Ensure that the certificates do not expire.
mockDate("2021-07-23", mockedClock);
+ // The deliberately wrong certificate above and the refreshed token have no overlapping validity period,
+ // so this fixture family stays on the older authentication token.
+ try {
+ validAuthToken = validator.parse(LEGACY_AUTH_TOKEN_2021);
+ } catch (AuthTokenException e) {
+ throw new RuntimeException(e);
+ }
}
@AfterEach
@@ -48,7 +56,7 @@ void tearDown() {
@Test
void whenValidTokenAndNonce_thenValidationSucceeds() throws Exception {
- final X509Certificate result = validator.validate(validAuthToken, VALID_CHALLENGE_NONCE);
+ final X509Certificate result = validator.validate(validAuthToken, VALID_CHALLENGE_NONCE).subjectCertificate();
assertThat(CertificateData.getSubjectCN(result).orElseThrow())
.isEqualTo("JÕEORG\\,JAAK-KRISTJAN\\,38001085718");
diff --git a/src/test/java/eu/webeid/security/validator/AuthTokenSignatureValidatorTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenSignatureValidatorTest.java
index 773b096e..dcbd0baa 100644
--- a/src/test/java/eu/webeid/security/validator/AuthTokenSignatureValidatorTest.java
+++ b/src/test/java/eu/webeid/security/validator/AuthTokenSignatureValidatorTest.java
@@ -5,26 +5,51 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectReader;
+import eu.webeid.security.authtoken.WebEidAuthToken;
import eu.webeid.security.certificate.CertificateLoader;
+import eu.webeid.security.exceptions.AuthTokenException;
+import eu.webeid.security.exceptions.AuthTokenParseException;
+import eu.webeid.security.exceptions.AuthTokenSignatureValidationException;
+import io.jsonwebtoken.security.SignatureException;
import org.junit.jupiter.api.Test;
-import eu.webeid.security.authtoken.WebEidAuthToken;
import java.net.URI;
+import java.security.KeyPairGenerator;
+import java.security.PublicKey;
import java.security.cert.X509Certificate;
+import java.security.spec.ECGenParameterSpec;
+import static eu.webeid.security.testutil.AbstractTestWithValidator.VALID_AUTH_TOKEN;
+import static eu.webeid.security.testutil.AbstractTestWithValidator.VALID_CHALLENGE_NONCE;
+import static eu.webeid.security.testutil.AbstractTestWithValidator.VALID_RS256_AUTH_TOKEN;
import static org.assertj.core.api.Assertions.assertThatCode;
-import static eu.webeid.security.validator.AuthTokenSignatureTest.VALID_AUTH_TOKEN;
-import static eu.webeid.security.validator.AuthTokenSignatureTest.VALID_CHALLENGE_NONCE;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
class AuthTokenSignatureValidatorTest {
private static final ObjectReader OBJECT_READER = new ObjectMapper().readerFor(WebEidAuthToken.class);
- private static final String VALID_RS256_AUTH_TOKEN = "{\"algorithm\":\"RS256\"," +
- "\"unverifiedCertificate\":\"MIIGvjCCBKagAwIBAgIQT7aXeR+zWlBb2Gbar+AFaTANBgkqhkiG9w0BAQsFADCBgzELMAkGA1UEBhMCTFYxOTA3BgNVBAoMMFZBUyBMYXR2aWphcyBWYWxzdHMgcmFkaW8gdW4gdGVsZXbEq3ppamFzIGNlbnRyczEaMBgGA1UEYQwRTlRSTFYtNDAwMDMwMTEyMDMxHTAbBgNVBAMMFERFTU8gTFYgZUlEIElDQSAyMDE3MB4XDTE4MTAzMDE0MTI0MloXDTIzMTAzMDE0MTI0MlowcDELMAkGA1UEBhMCTFYxHDAaBgNVBAMME0FORFJJUyBQQVJBVURaScWFxaAxFTATBgNVBAQMDFBBUkFVRFpJxYXFoDEPMA0GA1UEKgwGQU5EUklTMRswGQYDVQQFExJQTk9MVi0zMjE5MjItMzMwMzIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDXkra3rDOOt5K6OnJcg/Xt6JOogPAUBX2kT9zWelze7WSuPx2Ofs//0JoBQ575IVdh3JpLhfh7g60YYi41M6vNACVSNaFOxiEvE9amSFizMiLk5+dp+79rymqOsVQG8CSu8/RjGGlDsALeb3N/4pUSTGXUwSB64QuFhOWjAcmKPhHeYtry0hK3MbwwHzFhYfGpo/w+PL14PEdJlpL1UX/aPyT0Zq76Z4T/Z3PqbTmQp09+2b0thC0JIacSkyJuTu8fVRQvse+8UtYC6Kt3TBLZbPtqfAFSXWbuE47Lc2o840NkVlMHVAesoRAfiQxsK35YWFT0rHPWbLjX6ySiaL25AgMBAAGjggI+MIICOjAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAjAdBgNVHQ4EFgQUHZWimPze2GXULNaP4EFVdF+MWKQwHwYDVR0jBBgwFoAUj2jOvOLHQCFTCUK75Z4djEvNvTgwgfsGA1UdIASB8zCB8DA7BgYEAI96AQIwMTAvBggrBgEFBQcCARYjaHR0cHM6Ly93d3cuZXBhcmFrc3RzLmx2L3JlcG9zaXRvcnkwgbAGDCsGAQQBgfo9AgECATCBnzAvBggrBgEFBQcCARYjaHR0cHM6Ly93d3cuZXBhcmFrc3RzLmx2L3JlcG9zaXRvcnkwbAYIKwYBBQUHAgIwYAxexaBpcyBzZXJ0aWZpa8SBdHMgaXIgaWVrxLxhdXRzIExhdHZpamFzIFJlcHVibGlrYXMgaXpzbmllZ3TEgSBwZXJzb251IGFwbGllY2lub8WhxIEgZG9rdW1lbnTEgTB9BggrBgEFBQcBAQRxMG8wQgYIKwYBBQUHMAKGNmh0dHA6Ly9kZW1vLmVwYXJha3N0cy5sdi9jZXJ0L2RlbW9fTFZfZUlEX0lDQV8yMDE3LmNydDApBggrBgEFBQcwAYYdaHR0cDovL29jc3AucHJlcC5lcGFyYWtzdHMubHYwSAYDVR0fBEEwPzA9oDugOYY3aHR0cDovL2RlbW8uZXBhcmFrc3RzLmx2L2NybC9kZW1vX0xWX2VJRF9JQ0FfMjAxN18zLmNybDANBgkqhkiG9w0BAQsFAAOCAgEAAOVoRbnMv2UXWYHgnmO9Zg9u8F1YvJiZPMeTYE2CVaiq0nXe4Mq0X5tWcsEiRpGQF9e0dWC6V5m6EmAsHxIRL4chZKRrIrPEiWtP3zyRI1/X2y5GwSUyZmgxkuSOHHw3UjzjrnOoI9izpC0OSNeumqpjT/tLAi35sktGkK0onEUPWGQnZLqd/hzykm+H/dmD27nOnfCJOSqbegLSbhV2w/WAII+IUD3vJ06F6rf9ZN8xbrGkPO8VMCIDIt0eBKFxBdSOgpsTfbERbjQJ+nFEDYhD0bFNYMsFSGnZiWpNaCcZSkk4mtNUa8sNXyaFQGIZk6NjQ/fsBANhUoxFz7rUKrRYqk356i8KFDZ+MJqUyodKKyW9oz+IO5eJxnL78zRbxD+EfAUmrLXOjmGIzU95RR1smS4cirrrPHqGAWojBk8hKbjNTJl9Tfbnsbc9/FUBJLVZAkCi631KfRLQ66bn8N0mbtKlNtdX0G47PXTy7SJtWwDtKQ8+qVpduc8xHLntbdAzie3mWyxA1SBhQuZ9BPf5SPBImWCNpmZNCTmI2e+4yyCnmG/kVNilUAaODH/fgQXFGdsKO/XATFohiies28twkEzqtlVZvZbpBhbJCHYVnQXMhMKcnblkDqXWcSWd3QAKig2yMH95uz/wZhiV+7tZ7cTgwcbCzIDCfpwBC3E=\"," +
- "\"issuerApp\":\"https://web-eid.eu/web-eid-app/releases/2.0.0+0\"," +
- "\"signature\":\"xsjXsQvVYXWcdV0YPhxLthJxtf0//R8p9WFFlYJGRARrl1ruyoAUwl0xeHgeZOKeJtwiCYCNWJzCG3VM3ydgt92bKhhk1u0JXIPVqvOkmDY72OCN4q73Y8iGSPVTgjk93TgquHlodf7YcqZNhutwNNf3oldHEWJD5zmkdwdpBFXgeOwTAdFwGljDQZbHr3h1Dr+apUDuloS0WuIzUuu8YXN2b8lh8FCTlF0G0DEjhHd/MGx8dbe3UTLHmD7K9DXv4zLJs6EF9i2v/C10SIBQDkPBSVPqMxCDPECjbEPi2+ds94eU7ThOhOQlFFtJ4KjQNTUa2crSixH7cYZF2rNNmA==\"," +
- "\"format\":\"web-eid:1.0\"}";
+ @Test
+ void 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);
+ }
@Test
void whenValidES384Signature_thenSucceeds() throws Exception {
@@ -32,10 +57,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,11 +70,46 @@ 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();
}
+ @Test
+ void whenRsaKeyIsTooShortForAlgorithm_thenThrowsAuthTokenSignatureValidationException() throws Exception {
+ final AuthTokenSignatureValidator signatureValidator =
+ new AuthTokenSignatureValidator(URI.create("https://ria.ee"));
+
+ final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
+ keyPairGenerator.initialize(1024);
+ final PublicKey weakPublicKey = keyPairGenerator.generateKeyPair().getPublic();
+
+ final WebEidAuthToken authToken = OBJECT_READER.readValue(VALID_RS256_AUTH_TOKEN);
+
+ // The public key comes from the unverified certificate of the token, so a key that JJWT refuses
+ // to use must fail with a checked AuthTokenException, not with an unchecked JJWT exception.
+ assertThatThrownBy(() -> signatureValidator
+ .validate("RS256", authToken.signature(), weakPublicKey, VALID_CHALLENGE_NONCE))
+ .isInstanceOf(AuthTokenSignatureValidationException.class);
+ }
+
+ @Test
+ void whenEcKeyDoesNotMatchAlgorithm_thenThrowsAuthTokenSignatureValidationException() throws Exception {
+ final AuthTokenSignatureValidator signatureValidator =
+ new AuthTokenSignatureValidator(URI.create("https://ria.ee"));
+
+ final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC");
+ keyPairGenerator.initialize(new ECGenParameterSpec("secp256r1"));
+ final PublicKey p256PublicKey = keyPairGenerator.generateKeyPair().getPublic();
+
+ final WebEidAuthToken authToken = OBJECT_READER.readValue(VALID_AUTH_TOKEN);
+
+ // ES512 requires a P-521 key, so JJWT rejects the P-256 key of the unverified certificate.
+ assertThatThrownBy(() -> signatureValidator
+ .validate("ES512", authToken.signature(), p256PublicKey, VALID_CHALLENGE_NONCE))
+ .isInstanceOf(AuthTokenException.class);
+ }
+
}
diff --git a/src/test/java/eu/webeid/security/validator/AuthTokenValidationConfigurationTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenValidationConfigurationTest.java
new file mode 100644
index 00000000..f70eae22
--- /dev/null
+++ b/src/test/java/eu/webeid/security/validator/AuthTokenValidationConfigurationTest.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright (c) 2020-2025 Estonian Information System Authority
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package eu.webeid.security.validator;
+
+import eu.webeid.security.testutil.Certificates;
+import eu.webeid.security.validator.revocationcheck.CertificateRevocationChecker;
+import eu.webeid.security.validator.revocationcheck.RevocationMode;
+import org.junit.jupiter.api.Test;
+
+import java.net.URI;
+import java.security.cert.PKIXRevocationChecker;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+
+class AuthTokenValidationConfigurationTest {
+
+ @Test
+ void whenNoCustomCheckerProvided_thenRevocationModeIsPlatformOcsp() throws Exception {
+ final AuthTokenValidationConfiguration configuration = getValidConfiguration();
+ configuration.validate();
+ assertThat(configuration.getRevocationMode()).isEqualTo(RevocationMode.PLATFORM_OCSP);
+ }
+
+ @Test
+ void whenRevocationCheckDisabled_thenRevocationModeIsDisabled() throws Exception {
+ final AuthTokenValidationConfiguration configuration = getValidConfiguration();
+ configuration.setUserCertificateRevocationCheckDisabled();
+ configuration.validate();
+ assertThat(configuration.getRevocationMode()).isEqualTo(RevocationMode.DISABLED);
+ }
+
+ @Test
+ void whenCustomCheckerConfigured_thenRevocationModeIsCustomChecker() throws Exception {
+ final AuthTokenValidationConfiguration configuration = getValidConfiguration();
+ configuration.setCertificateRevocationChecker(getNoopChecker());
+ configuration.validate();
+ assertThat(configuration.getRevocationMode()).isEqualTo(RevocationMode.CUSTOM_CHECKER);
+ }
+
+ @Test
+ void whenCustomPkixCheckerConfigured_thenRevocationModeIsCustomPkix() throws Exception {
+ final AuthTokenValidationConfiguration configuration = getValidConfiguration();
+ configuration.setPkixRevocationChecker(mock(PKIXRevocationChecker.class));
+ configuration.validate();
+ assertThat(configuration.getRevocationMode()).isEqualTo(RevocationMode.CUSTOM_PKIX);
+ }
+
+ private static AuthTokenValidationConfiguration getValidConfiguration() throws Exception {
+ final AuthTokenValidationConfiguration configuration = new AuthTokenValidationConfiguration();
+ configuration.setSiteOrigin(URI.create("https://ria.ee"));
+ configuration.getTrustedCACertificates().add(Certificates.getTestEsteid2018CA());
+ return configuration;
+ }
+
+ private static CertificateRevocationChecker getNoopChecker() {
+ return (subjectCertificate, issuerCertificate) -> List.of();
+ }
+}
diff --git a/src/test/java/eu/webeid/security/validator/AuthTokenValidationInfoTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenValidationInfoTest.java
new file mode 100644
index 00000000..9a38f131
--- /dev/null
+++ b/src/test/java/eu/webeid/security/validator/AuthTokenValidationInfoTest.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright (c) 2020-2025 Estonian Information System Authority
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package eu.webeid.security.validator;
+
+import eu.webeid.security.authtoken.WebEidAuthToken;
+import eu.webeid.security.testutil.AuthTokenValidators;
+import eu.webeid.security.testutil.DateMocker;
+import eu.webeid.security.util.DateAndTime;
+import eu.webeid.security.validator.revocationcheck.CertificateRevocationChecker;
+import eu.webeid.security.validator.revocationcheck.RevocationInfo;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import static eu.webeid.security.testutil.AbstractTestWithValidator.VALID_AUTH_TOKEN;
+import static eu.webeid.security.testutil.AbstractTestWithValidator.VALID_AUTH_TOKEN_TEST_DATE;
+import static eu.webeid.security.testutil.AbstractTestWithValidator.VALID_CHALLENGE_NONCE;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mockStatic;
+
+class AuthTokenValidationInfoTest {
+
+ private MockedStatic mockedClock;
+
+ @BeforeEach
+ void setup() {
+ mockedClock = mockStatic(DateAndTime.DefaultClock.class);
+ DateMocker.mockDate(VALID_AUTH_TOKEN_TEST_DATE, mockedClock);
+ }
+
+ @AfterEach
+ void tearDown() {
+ mockedClock.close();
+ }
+
+ @Test
+ void whenCustomRevocationCheckerProvidesInfo_thenValidationInfoContainsIt() throws Exception {
+ final RevocationInfo expectedInfo = new RevocationInfo(
+ URI.create("https://ocsp.example"),
+ Map.of(RevocationInfo.KEY_OCSP_RESPONSE, "dummy-response")
+ );
+ final List checkerResults = new ArrayList<>(List.of(expectedInfo));
+ final CertificateRevocationChecker checker = (subjectCertificate, issuerCertificate) -> checkerResults;
+ final AuthTokenValidator validator = AuthTokenValidators.getDefaultAuthTokenValidatorBuilder()
+ .withCertificateRevocationChecker(checker)
+ .build();
+
+ final WebEidAuthToken token = validator.parse(VALID_AUTH_TOKEN);
+ final ValidationInfo validationInfo = validator.validate(token, VALID_CHALLENGE_NONCE);
+
+ checkerResults.clear();
+
+ assertThat(validationInfo.revocationInfoList()).containsExactly(expectedInfo);
+ assertThat(validationInfo.subjectCertificate()).isNotNull();
+ assertThatThrownBy(() -> validationInfo.revocationInfoList().clear()).isInstanceOf(UnsupportedOperationException.class);
+ }
+}
diff --git a/src/test/java/eu/webeid/security/validator/AuthTokenValidatorBuilderTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenValidatorBuilderTest.java
index fcf88f79..7db6dde6 100644
--- a/src/test/java/eu/webeid/security/validator/AuthTokenValidatorBuilderTest.java
+++ b/src/test/java/eu/webeid/security/validator/AuthTokenValidatorBuilderTest.java
@@ -3,13 +3,28 @@
package eu.webeid.security.validator;
+import eu.webeid.security.certificate.CertificateValidator;
import eu.webeid.security.testutil.AuthTokenValidators;
+import eu.webeid.security.validator.revocationcheck.CertificateRevocationChecker;
+import eu.webeid.security.validator.revocationcheck.RevocationMode;
import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
import java.net.URI;
-import java.time.Duration;
-
+import java.security.cert.PKIXRevocationChecker;
+import java.util.List;
+
+import static eu.webeid.security.testutil.AbstractTestWithValidator.VALID_AUTH_TOKEN;
+import static eu.webeid.security.testutil.AbstractTestWithValidator.VALID_CHALLENGE_NONCE;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anySet;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.times;
public class AuthTokenValidatorBuilderTest {
@@ -22,7 +37,7 @@ public class AuthTokenValidatorBuilderTest {
@Test
void testOriginMissing() {
assertThatThrownBy(builder::build)
- .isInstanceOf(NullPointerException.class)
+ .isInstanceOf(IllegalArgumentException.class)
.hasMessageStartingWith("Origin URI must not be null");
}
@@ -68,29 +83,62 @@ void testValidatorOriginNotValidSyntax() {
}
@Test
- void testInvalidOcspResponseTimeSkew() throws Exception {
- final AuthTokenValidatorBuilder builderWithInvalidOcspResponseTimeSkew = AuthTokenValidators.getDefaultAuthTokenValidatorBuilder()
- .withAllowedOcspResponseTimeSkew(Duration.ofMinutes(-1));
- assertThatThrownBy(builderWithInvalidOcspResponseTimeSkew::build)
+ void whenRevocationCheckDisabledAndCustomCheckerConfigured_thenBuildFails() throws Exception {
+ final AuthTokenValidatorBuilder builderWithRevocationDisabled = AuthTokenValidators.getDefaultAuthTokenValidatorBuilder()
+ .withoutUserCertificateRevocationCheck()
+ .withCertificateRevocationChecker(getNoopChecker());
+ assertThatThrownBy(builderWithRevocationDisabled::build)
.isInstanceOf(IllegalArgumentException.class)
- .hasMessageStartingWith("Allowed OCSP response time-skew must be greater than zero");
+ .hasMessageStartingWith("User certificate revocation check is disabled, but a revocation checker was configured");
}
@Test
- void testInvalidMaxOcspResponseThisUpdateAge() throws Exception {
- final AuthTokenValidatorBuilder builderWithInvalidOcspResponseTimeSkew = AuthTokenValidators.getDefaultAuthTokenValidatorBuilder()
- .withMaxOcspResponseThisUpdateAge(Duration.ZERO);
- assertThatThrownBy(builderWithInvalidOcspResponseTimeSkew::build)
+ void whenRevocationCheckDisabledAndPkixCheckerConfigured_thenBuildFails() throws Exception {
+ final AuthTokenValidatorBuilder builderWithRevocationDisabled = AuthTokenValidators.getDefaultAuthTokenValidatorBuilder()
+ .withoutUserCertificateRevocationCheck()
+ .withPKIXRevocationChecker(mock(PKIXRevocationChecker.class));
+ assertThatThrownBy(builderWithRevocationDisabled::build)
.isInstanceOf(IllegalArgumentException.class)
- .hasMessageStartingWith("Max OCSP response thisUpdate age must be greater than zero");
+ .hasMessageStartingWith("User certificate revocation check is disabled, but a revocation checker was configured");
}
@Test
- void testInvalidOcspRequestTimeout() throws Exception {
- final AuthTokenValidatorBuilder builderWithInvalidOcspResponseTimeSkew = AuthTokenValidators.getDefaultAuthTokenValidatorBuilder()
- .withOcspRequestTimeout(Duration.ofMinutes(-1));
- assertThatThrownBy(builderWithInvalidOcspResponseTimeSkew::build)
+ void whenCustomCheckerAndPkixCheckerConfigured_thenBuildFails() throws Exception {
+ final AuthTokenValidatorBuilder builderWithConflictingCheckers = AuthTokenValidators.getDefaultAuthTokenValidatorBuilder()
+ .withCertificateRevocationChecker(getNoopChecker())
+ .withPKIXRevocationChecker(mock(PKIXRevocationChecker.class));
+ assertThatThrownBy(builderWithConflictingCheckers::build)
.isInstanceOf(IllegalArgumentException.class)
- .hasMessageStartingWith("OCSP request timeout must be greater than zero");
+ .hasMessageStartingWith("Only one of OcspCertificateRevocationChecker or PKIXRevocationChecker may be configured");
+ }
+
+ @Test
+ void whenPlatformOcspNonceSettingChanges_thenBuiltValidatorsRetainTheirSetting() throws Exception {
+ final AuthTokenValidatorBuilder configurableBuilder = AuthTokenValidators.getDefaultAuthTokenValidatorBuilder();
+ final AuthTokenValidator defaultValidator = configurableBuilder.build();
+ final AuthTokenValidator nonceDisabledValidator = configurableBuilder.withPlatformOcspNonceEnabled(false).build();
+ final AuthTokenValidator nonceEnabledValidator = configurableBuilder.withPlatformOcspNonceEnabled(true).build();
+
+ try (MockedStatic certificateValidator = mockStatic(CertificateValidator.class)) {
+ certificateValidator.when(() -> CertificateValidator.validateCertificateTrustAndRevocation(
+ any(), anySet(), any(), any(), eq(RevocationMode.PLATFORM_OCSP), isNull(), isNull(), anyBoolean()
+ )).thenReturn(List.of());
+
+ for (AuthTokenValidator validator : List.of(defaultValidator, nonceDisabledValidator, nonceEnabledValidator)) {
+ validator.validate(validator.parse(VALID_AUTH_TOKEN), VALID_CHALLENGE_NONCE);
+ }
+
+ certificateValidator.verify(() -> CertificateValidator.validateCertificateTrustAndRevocation(
+ any(), anySet(), any(), any(), eq(RevocationMode.PLATFORM_OCSP), isNull(), isNull(), eq(true)
+ ), times(2));
+ certificateValidator.verify(() -> CertificateValidator.validateCertificateTrustAndRevocation(
+ any(), anySet(), any(), any(), eq(RevocationMode.PLATFORM_OCSP), isNull(), isNull(), eq(false)
+ ));
+ }
+ }
+
+ private static CertificateRevocationChecker getNoopChecker() {
+ return (subjectCertificate, issuerCertificate) -> List.of();
}
+
}
diff --git a/src/test/java/eu/webeid/security/validator/AuthTokenValidatorValidationOrderTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenValidatorValidationOrderTest.java
new file mode 100644
index 00000000..68874aa1
--- /dev/null
+++ b/src/test/java/eu/webeid/security/validator/AuthTokenValidatorValidationOrderTest.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright (c) 2020-2025 Estonian Information System Authority
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package eu.webeid.security.validator;
+
+import eu.webeid.security.authtoken.WebEidAuthToken;
+import eu.webeid.security.certificate.CertificateData;
+import eu.webeid.security.exceptions.AuthTokenSignatureValidationException;
+import eu.webeid.security.testutil.AuthTokenValidators;
+import eu.webeid.security.util.DateAndTime;
+import eu.webeid.security.validator.revocationcheck.CertificateRevocationChecker;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.MockedStatic;
+
+import java.security.cert.X509Certificate;
+import java.util.List;
+
+import static eu.webeid.security.testutil.AbstractTestWithValidator.VALID_AUTH_TOKEN;
+import static eu.webeid.security.testutil.AbstractTestWithValidator.VALID_AUTH_TOKEN_TEST_DATE;
+import static eu.webeid.security.testutil.AbstractTestWithValidator.VALID_CHALLENGE_NONCE;
+import static eu.webeid.security.testutil.DateMocker.mockDate;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class AuthTokenValidatorValidationOrderTest {
+
+ private MockedStatic mockedClock;
+
+ @BeforeEach
+ void setUp() {
+ mockedClock = mockStatic(DateAndTime.DefaultClock.class);
+ // Ensure that the certificates do not expire.
+ mockDate(VALID_AUTH_TOKEN_TEST_DATE, mockedClock);
+ }
+
+ @AfterEach
+ void tearDown() {
+ mockedClock.close();
+ }
+
+ @Test
+ void whenSignatureIsInvalid_thenRevocationCheckerIsNotInvoked() throws Exception {
+ CertificateRevocationChecker checker = mock(CertificateRevocationChecker.class);
+ AuthTokenValidator validator = AuthTokenValidators.getDefaultAuthTokenValidatorBuilder()
+ .withCertificateRevocationChecker(checker)
+ .build();
+ WebEidAuthToken token = validator.parse(VALID_AUTH_TOKEN);
+
+ assertThatThrownBy(() -> validator.validate(token, "invalidToken"))
+ .isInstanceOf(AuthTokenSignatureValidationException.class);
+ verify(checker, never()).validateCertificateNotRevoked(any(), any());
+ }
+
+ @Test
+ void whenSignatureIsValid_thenRevocationCheckerIsInvoked() throws Exception {
+ CertificateRevocationChecker checker = mock(CertificateRevocationChecker.class);
+ when(checker.validateCertificateNotRevoked(any(), any())).thenReturn(List.of());
+ AuthTokenValidator validator = AuthTokenValidators.getDefaultAuthTokenValidatorBuilder()
+ .withCertificateRevocationChecker(checker)
+ .build();
+ WebEidAuthToken token = validator.parse(VALID_AUTH_TOKEN);
+
+ assertThatCode(() -> validator.validate(token, VALID_CHALLENGE_NONCE))
+ .doesNotThrowAnyException();
+ ArgumentCaptor subjectCaptor = ArgumentCaptor.forClass(X509Certificate.class);
+ verify(checker).validateCertificateNotRevoked(subjectCaptor.capture(), any());
+ assertThat(CertificateData.getSubjectCN(subjectCaptor.getValue()).orElseThrow())
+ .isEqualTo("JÕEORG\\,JAAK-KRISTJAN\\,38001085718");
+ }
+}
diff --git a/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePolicyValidatorTest.java b/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePolicyValidatorTest.java
new file mode 100644
index 00000000..22d82df8
--- /dev/null
+++ b/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePolicyValidatorTest.java
@@ -0,0 +1,61 @@
+// SPDX-FileCopyrightText: Estonian Information System Authority
+// SPDX-License-Identifier: MIT
+
+package eu.webeid.security.validator.certvalidators;
+
+import eu.webeid.security.exceptions.UserCertificateParseException;
+import org.bouncycastle.asn1.ASN1Exception;
+import org.bouncycastle.asn1.ASN1Integer;
+import org.bouncycastle.asn1.DEROctetString;
+import org.bouncycastle.asn1.x509.Extension;
+import org.junit.jupiter.api.Test;
+
+import java.security.cert.CertificateParsingException;
+import java.security.cert.X509Certificate;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class SubjectCertificatePolicyValidatorTest {
+
+ private final X509Certificate certificate = mock(X509Certificate.class);
+ private final SubjectCertificatePolicyValidator validator = new SubjectCertificatePolicyValidator(Set.of());
+
+ @Test
+ void whenPoliciesAreMissing_thenThrowsCertificateParseException() {
+ assertThatThrownBy(() -> validator.validateCertificatePolicies(certificate))
+ .isInstanceOf(UserCertificateParseException.class)
+ .hasCauseInstanceOf(CertificateParsingException.class);
+ }
+
+ @Test
+ void whenPoliciesHaveInvalidEncoding_thenPreservesParsingFailure() {
+ when(certificate.getExtensionValue(Extension.certificatePolicies.getId())).thenReturn(new byte[] {4, 2, 4, 5});
+
+ assertThatThrownBy(() -> validator.validateCertificatePolicies(certificate))
+ .isInstanceOf(UserCertificateParseException.class)
+ .hasCauseExactlyInstanceOf(ASN1Exception.class);
+ }
+
+ @Test
+ void whenPoliciesValueIsEmpty_thenThrowsCertificateParseException() throws Exception {
+ when(certificate.getExtensionValue(Extension.certificatePolicies.getId()))
+ .thenReturn(new DEROctetString(new byte[0]).getEncoded());
+
+ assertThatThrownBy(() -> validator.validateCertificatePolicies(certificate))
+ .isInstanceOf(UserCertificateParseException.class)
+ .hasCauseInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void whenPoliciesHaveWrongAsn1Type_thenPreservesParsingFailure() throws Exception {
+ when(certificate.getExtensionValue(Extension.certificatePolicies.getId()))
+ .thenReturn(new DEROctetString(new ASN1Integer(1)).getEncoded());
+
+ assertThatThrownBy(() -> validator.validateCertificatePolicies(certificate))
+ .isInstanceOf(UserCertificateParseException.class)
+ .hasCauseInstanceOf(IllegalArgumentException.class);
+ }
+}
diff --git a/src/test/java/eu/webeid/security/validator/ocsp/OcspResponseValidatorTest.java b/src/test/java/eu/webeid/security/validator/ocsp/OcspResponseValidatorTest.java
deleted file mode 100644
index db66ada5..00000000
--- a/src/test/java/eu/webeid/security/validator/ocsp/OcspResponseValidatorTest.java
+++ /dev/null
@@ -1,103 +0,0 @@
-// SPDX-FileCopyrightText: Estonian Information System Authority
-// SPDX-License-Identifier: MIT
-
-package eu.webeid.security.validator.ocsp;
-
-import eu.webeid.security.exceptions.UserCertificateOCSPCheckFailedException;
-import org.bouncycastle.cert.ocsp.SingleResp;
-import org.junit.jupiter.api.Test;
-
-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 org.assertj.core.api.Assertions.assertThatCode;
-import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.when;
-
-class OcspResponseValidatorTest {
-
- private static final Duration TIME_SKEW = CONFIGURATION.getAllowedOcspResponseTimeSkew();
- private static final Duration THIS_UPDATE_AGE = CONFIGURATION.getMaxOcspResponseThisUpdateAge();
-
- @Test
- void whenThisAndNextUpdateWithinSkew_thenValidationSucceeds() {
- final SingleResp mockResponse = mock(SingleResp.class);
- var now = Instant.now();
- var thisUpdateWithinAgeLimit = getThisUpdateWithinAgeLimit(now);
- var nextUpdateWithinAgeLimit = Date.from(now.minus(THIS_UPDATE_AGE.minusSeconds(2)));
- when(mockResponse.getThisUpdate()).thenReturn(thisUpdateWithinAgeLimit);
- when(mockResponse.getNextUpdate()).thenReturn(nextUpdateWithinAgeLimit);
- assertThatCode(() -> validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE))
- .doesNotThrowAnyException();
- }
-
- @Test
- void whenNextUpdateBeforeThisUpdate_thenThrows() {
- final SingleResp mockResponse = mock(SingleResp.class);
- var now = Instant.now();
- var thisUpdateWithinAgeLimit = getThisUpdateWithinAgeLimit(now);
- var beforeThisUpdate = new Date(thisUpdateWithinAgeLimit.getTime() - 1000);
- when(mockResponse.getThisUpdate()).thenReturn(thisUpdateWithinAgeLimit);
- when(mockResponse.getNextUpdate()).thenReturn(beforeThisUpdate);
- assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class)
- .isThrownBy(() ->
- validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE))
- .withMessageStartingWith("User certificate revocation check has failed: "
- + "Certificate status update time check failed: "
- + "nextUpdate '" + beforeThisUpdate.toInstant() + "' is before thisUpdate '" + thisUpdateWithinAgeLimit.toInstant() + "'");
- }
-
- @Test
- void whenThisUpdateHalfHourBeforeNow_thenThrows() {
- final SingleResp mockResponse = mock(SingleResp.class);
- var now = Instant.now();
- var halfHourBeforeNow = Date.from(now.minus(30, ChronoUnit.MINUTES));
- when(mockResponse.getThisUpdate()).thenReturn(halfHourBeforeNow);
- assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class)
- .isThrownBy(() ->
- validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE))
- .withMessageStartingWith("User certificate revocation check has failed: "
- + "Certificate status update time check failed: "
- + "thisUpdate '" + halfHourBeforeNow.toInstant() + "' is too old, minimum time allowed: ");
- }
-
- @Test
- void whenThisUpdateHalfHourAfterNow_thenThrows() {
- final SingleResp mockResponse = mock(SingleResp.class);
- var now = Instant.now();
- var halfHourAfterNow = Date.from(now.plus(30, ChronoUnit.MINUTES));
- when(mockResponse.getThisUpdate()).thenReturn(halfHourAfterNow);
- assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class)
- .isThrownBy(() ->
- validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE))
- .withMessageStartingWith("User certificate revocation check has failed: "
- + "Certificate status update time check failed: "
- + "thisUpdate '" + halfHourAfterNow.toInstant() + "' is too far in the future, latest allowed: ");
- }
-
- @Test
- void whenNextUpdateHalfHourBeforeNow_thenThrows() {
- final SingleResp mockResponse = mock(SingleResp.class);
- var now = Instant.now();
- var thisUpdateWithinAgeLimit = getThisUpdateWithinAgeLimit(now);
- var halfHourBeforeNow = Date.from(now.minus(30, ChronoUnit.MINUTES));
- when(mockResponse.getThisUpdate()).thenReturn(thisUpdateWithinAgeLimit);
- when(mockResponse.getNextUpdate()).thenReturn(halfHourBeforeNow);
- assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class)
- .isThrownBy(() ->
- validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE))
- .withMessage("User certificate revocation check has failed: "
- + "Certificate status update time check failed: "
- + "nextUpdate '" + halfHourBeforeNow.toInstant() + "' is in the past");
- }
-
- private static Date getThisUpdateWithinAgeLimit(Instant now) {
- return Date.from(now.minus(THIS_UPDATE_AGE.minusSeconds(1)));
- }
-
-}
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)));
- }
-
-}
diff --git a/src/test/resources/DEMO_of_ESTEID-SK_2018_AIA_OCSP_RESPONDER_2018.cer b/src/test/resources/DEMO_of_ESTEID-SK_2018_AIA_OCSP_RESPONDER_2018.cer
new file mode 100644
index 00000000..30ce32b0
Binary files /dev/null and b/src/test/resources/DEMO_of_ESTEID-SK_2018_AIA_OCSP_RESPONDER_2018.cer differ
diff --git a/src/test/resources/TEST_of_EE_Certification_Centre_Root_CA.cer b/src/test/resources/TEST_of_EE_Certification_Centre_Root_CA.cer
new file mode 100644
index 00000000..f51a8580
Binary files /dev/null and b/src/test/resources/TEST_of_EE_Certification_Centre_Root_CA.cer differ
diff --git a/src/test/resources/TEST_of_self-signed_OCSP_RESPONDER.cer b/src/test/resources/TEST_of_self-signed_OCSP_RESPONDER.cer
new file mode 100644
index 00000000..86fbcab1
Binary files /dev/null and b/src/test/resources/TEST_of_self-signed_OCSP_RESPONDER.cer differ
diff --git a/src/test/resources/ocsp_response_unauthorized.der b/src/test/resources/ocsp_response_unauthorized.der
new file mode 100644
index 00000000..d6ea0659
--- /dev/null
+++ b/src/test/resources/ocsp_response_unauthorized.der
@@ -0,0 +1,2 @@
+0
+
\ No newline at end of file
diff --git a/src/test/resources/ocsp_response_unknown_self_signed.der b/src/test/resources/ocsp_response_unknown_self_signed.der
new file mode 100644
index 00000000..9dee6fc4
Binary files /dev/null and b/src/test/resources/ocsp_response_unknown_self_signed.der differ