getDisallowedSubjectCertificatePolicies(
return disallowedSubjectCertificatePolicies;
}
+ boolean isDigitalSignatureKeyUsageRequired() {
+ return isDigitalSignatureKeyUsageRequired;
+ }
+
+ void setDigitalSignatureKeyUsageRequired(boolean required) {
+ isDigitalSignatureKeyUsageRequired = required;
+ }
+
boolean isUserCertificateRevocationCheckEnabled() {
return isUserCertificateRevocationCheckEnabled;
}
diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java
index d072979f..8418a5a3 100644
--- a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java
+++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java
@@ -74,6 +74,25 @@ public AuthTokenValidatorBuilder withDisallowedCertificatePolicies(ASN1ObjectIde
return this;
}
+ /**
+ * Controls whether the user certificate must have the Digital Signature bit set in its Key Usage extension.
+ * Enabled by default.
+ *
+ * Disable this only for eID schemes whose authentication certificates do not assert Digital Signature key usage.
+ * The Key Usage extension must still be present, and if the Extended Key Usage extension is present,
+ * it must still contain client authentication.
+ *
+ * @param required whether the Digital Signature key usage is required
+ * @return the builder instance for method chaining
+ */
+ public AuthTokenValidatorBuilder withDigitalSignatureKeyUsageRequired(boolean required) {
+ configuration.setDigitalSignatureKeyUsageRequired(required);
+ if (!required) {
+ LOG.warn("Digital Signature key usage requirement for the user certificate is disabled");
+ }
+ return this;
+ }
+
/**
* Turns off user certificate revocation check (with OCSP and/or CRL).
*
diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java
index afae8093..3d6aa8e0 100644
--- a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java
+++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java
@@ -41,6 +41,7 @@ final class AuthTokenValidatorImpl implements AuthTokenValidator {
private final Set trustedCACertificateAnchors;
private final CertStore trustedCACertificateCertStore;
private final AuthTokenSignatureValidator authTokenSignatureValidator;
+ private final SubjectCertificatePurposeValidator subjectCertificatePurposeValidator;
private final SubjectCertificatePolicyValidator subjectCertificatePolicyValidator;
/**
@@ -54,6 +55,7 @@ final class AuthTokenValidatorImpl implements AuthTokenValidator {
trustedCACertificateAnchors = CertificateValidator.buildTrustAnchorsFromCertificates(configuration.getTrustedCACertificates());
trustedCACertificateCertStore = CertificateValidator.buildCertStoreFromCertificates(configuration.getTrustedCACertificates());
+ subjectCertificatePurposeValidator = new SubjectCertificatePurposeValidator(configuration.isDigitalSignatureKeyUsageRequired());
subjectCertificatePolicyValidator = new SubjectCertificatePolicyValidator(configuration.getDisallowedSubjectCertificatePolicies());
authTokenSignatureValidator = new AuthTokenSignatureValidator(configuration.getSiteOrigin());
@@ -115,12 +117,22 @@ private ValidationInfo validateToken(WebEidAuthToken token, String currentChalle
}
final X509Certificate subjectCertificate = CertificateLoader.decodeCertificateFromBase64(token.unverifiedCertificate());
- SubjectCertificatePurposeValidator.validateCertificatePurpose(subjectCertificate);
+ subjectCertificatePurposeValidator.validateCertificatePurpose(subjectCertificate);
subjectCertificatePolicyValidator.validateCertificatePolicies(subjectCertificate);
// Use the clock instance so that the date can be mocked in tests.
final Date now = DateAndTime.DefaultClock.getInstance().now();
+ // It is guaranteed that if the signature verification succeeds, then the origin and challenge
+ // have been implicitly and correctly verified without the need to implement any additional checks.
+ authTokenSignatureValidator.validate(token.algorithm(),
+ token.signature(),
+ subjectCertificate.getPublicKey(),
+ currentChallengeNonce
+ );
+
+ // Revocation validation is the last step to ensure that all non-network checks
+ // are completed before any OCSP requests are made.
final List revocationInfoList = CertificateValidator.validateCertificateTrustAndRevocation(
subjectCertificate,
trustedCACertificateAnchors,
@@ -133,14 +145,6 @@ private ValidationInfo validateToken(WebEidAuthToken token, String currentChalle
);
LOG.debug("Subject certificate is valid and signed by a trusted CA");
- // It is guaranteed that if the signature verification succeeds, then the origin and challenge
- // have been implicitly and correctly verified without the need to implement any additional checks.
- authTokenSignatureValidator.validate(token.algorithm(),
- token.signature(),
- subjectCertificate.getPublicKey(),
- currentChallengeNonce
- );
-
return new ValidationInfo(subjectCertificate, revocationInfoList);
}
diff --git a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePurposeValidator.java b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePurposeValidator.java
index ffacf2b0..6910e3b7 100644
--- a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePurposeValidator.java
+++ b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePurposeValidator.java
@@ -19,19 +19,26 @@ public final class SubjectCertificatePurposeValidator {
private static final Logger LOG = LoggerFactory.getLogger(SubjectCertificatePurposeValidator.class);
private static final int KEY_USAGE_DIGITAL_SIGNATURE = 0;
private static final String EXTENDED_KEY_USAGE_CLIENT_AUTHENTICATION = "1.3.6.1.5.5.7.3.2";
+
+ private final boolean isDigitalSignatureKeyUsageRequired;
+
+ public SubjectCertificatePurposeValidator(boolean isDigitalSignatureKeyUsageRequired) {
+ this.isDigitalSignatureKeyUsageRequired = isDigitalSignatureKeyUsageRequired;
+ }
+
/**
* Validates that the purpose of the user certificate from the authentication token contains client authentication.
*
* @param subjectCertificate user certificate to be validated
* @throws AuthTokenException when the purpose of certificate does not contain client authentication
*/
- public static void validateCertificatePurpose(X509Certificate subjectCertificate) throws AuthTokenException {
+ public void validateCertificatePurpose(X509Certificate subjectCertificate) throws AuthTokenException {
try {
final boolean[] keyUsage = subjectCertificate.getKeyUsage();
if (keyUsage == null) {
throw new UserCertificateMissingPurposeException();
}
- if (!keyUsage[KEY_USAGE_DIGITAL_SIGNATURE]) {
+ if (isDigitalSignatureKeyUsageRequired && !keyUsage[KEY_USAGE_DIGITAL_SIGNATURE]) {
throw new UserCertificateWrongPurposeException();
}
final List usages = subjectCertificate.getExtendedKeyUsage();
@@ -49,9 +56,4 @@ public static void validateCertificatePurpose(X509Certificate subjectCertificate
}
LOG.debug("User certificate can be used for client authentication.");
}
-
- private SubjectCertificatePurposeValidator() {
- throw new IllegalStateException("Functional class");
- }
-
}
diff --git a/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerNetworkTest.java b/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerNetworkTest.java
index 0916071f..2c6b301d 100644
--- a/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerNetworkTest.java
+++ b/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerNetworkTest.java
@@ -116,7 +116,8 @@ void whenDesignatedResponderOmitsNonce_thenConfiguredPolicyIsEnforced(boolean no
@Test
void whenDesignatedResponderCertificateDiffers_thenFailurePreservesCertificateCause() throws Exception {
final var checker = customChecker(new DesignatedOcspServiceConfiguration(
- responder.designatedUri(), responder.responderCertificate(), List.of(responder.issuer()), true));
+ responder.designatedUri(), responder.responderCertificate(), List.of(responder.issuer()), true,
+ OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE));
responder.replaceResponderCertificate(true);
assertThatThrownBy(() -> checker.validateCertificateNotRevoked(responder.subject(), responder.issuer()))
@@ -138,7 +139,7 @@ void whenAiaResponderLacksSigningUsage_thenFailurePreservesCertificateCause() th
.hasMessageContaining(responder.aiaUri().toString())
.cause()
.isExactlyInstanceOf(OCSPCertificateException.class)
- .hasMessageContaining("does not contain the key usage extension for OCSP response signing");
+ .hasMessageContaining("does not contain the Key Usage extension required for OCSP response signing");
assertThat(responder.requestCount()).isEqualTo(1);
assertThat(responder.receivedPath()).isEqualTo("/aia");
}
@@ -239,9 +240,9 @@ private OcspCertificateRevocationChecker customChecker(DesignatedOcspServiceConf
OcspClientImpl.build(Duration.ofSeconds(2)),
new OcspServiceProvider(designated, new AiaOcspServiceConfiguration(Set.of(),
CertificateValidator.buildTrustAnchorsFromCertificates(anchors),
- CertificateValidator.buildCertStoreFromCertificates(intermediates))),
- OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW,
- OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE);
+ CertificateValidator.buildCertStoreFromCertificates(intermediates),
+ OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE)),
+ OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW);
}
private List validate() throws Exception {
@@ -250,7 +251,8 @@ private List validate() throws Exception {
private List validate(boolean nonceEnabled) throws Exception {
final var checker = customChecker(new DesignatedOcspServiceConfiguration(
- responder.designatedUri(), responder.responderCertificate(), List.of(responder.issuer()), nonceEnabled));
+ responder.designatedUri(), responder.responderCertificate(), List.of(responder.issuer()), nonceEnabled,
+ OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE));
return CertificateValidator.validateCertificateTrustAndRevocation(
responder.subject(),
CertificateValidator.buildTrustAnchorsFromCertificates(List.of(responder.issuer())),
diff --git a/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java b/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java
index 9b90c70e..48fe5067 100644
--- a/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java
+++ b/src/test/java/eu/webeid/ocsp/OcspCertificateRevocationCheckerTest.java
@@ -11,6 +11,7 @@
import eu.webeid.ocsp.exceptions.UserCertificateRevokedException;
import eu.webeid.security.testutil.AbstractTestWithValidator;
import eu.webeid.security.testutil.AuthTokenValidators;
+import eu.webeid.security.testutil.ResourceUtil;
import eu.webeid.security.util.DateAndTime;
import eu.webeid.ocsp.client.OcspClient;
import eu.webeid.ocsp.client.OcspClientImpl;
@@ -25,7 +26,6 @@
import org.junit.jupiter.api.Test;
import java.io.IOException;
-import java.io.InputStream;
import java.net.ConnectException;
import java.net.URI;
import java.net.URISyntaxException;
@@ -47,13 +47,11 @@
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
-import static org.junit.jupiter.api.Assertions.assertInstanceOf;
-import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
-public class OcspCertificateRevocationCheckerTest extends AbstractTestWithValidator {
+class OcspCertificateRevocationCheckerTest extends AbstractTestWithValidator {
private final OcspClient ocspClient = OcspClientImpl.build(Duration.ofSeconds(5));
private X509Certificate estEid2018Cert;
@@ -117,11 +115,13 @@ void whenOcspUrlIsInvalid_thenThrows() throws Exception {
void whenOcspRequestFails_thenThrows() throws Exception {
final OcspServiceProvider ocspServiceProvider = getDesignatedOcspServiceProvider("http://demo.sk.ee/ocsps");
final OcspCertificateRevocationChecker validator = getOcspCertificateRevocationChecker(ocspServiceProvider);
- UserCertificateOCSPCheckFailedException ex = assertThrows(UserCertificateOCSPCheckFailedException.class, () ->
- validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA));
- OCSPClientException ocspClientException = assertInstanceOf(OCSPClientException.class, ex.getCause());
- assertThat(ocspClientException).hasMessageStartingWith("OCSP request was not successful");
- assertThat(ocspClientException.getStatusCode()).isEqualTo(404);
+ assertThatThrownBy(() ->
+ validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .isInstanceOf(UserCertificateOCSPCheckFailedException.class)
+ .cause()
+ .isInstanceOf(OCSPClientException.class)
+ .hasMessageStartingWith("OCSP request was not successful")
+ .satisfies(cause -> assertThat(((OCSPClientException) cause).getStatusCode()).isEqualTo(404));
}
@Test
@@ -236,7 +236,7 @@ void whenOcspResponseRevoked_thenThrows() throws Exception {
@Test
void whenOcspResponseUnknown_thenThrows() throws Exception {
final OcspServiceProvider ocspServiceProvider = getDesignatedOcspServiceProvider("https://web-eid-test.free.beeceptor.com");
- final HttpResponse response = getMockedResponse(getOcspResponseBytesFromResources("ocsp_response_unknown.der"));
+ final HttpResponse response = getMockedResponse(getOcspResponseBytesFromResources("ocsp_response_unknown_self_signed.der"));
final OcspCertificateRevocationChecker validator = getOcspCertificateRevocationChecker(getMockClient(response), ocspServiceProvider);
try (var mockedClock = mockStatic(DateAndTime.DefaultClock.class)) {
mockDate("2021-09-18T00:16:25", mockedClock);
@@ -250,7 +250,7 @@ void whenOcspResponseUnknown_thenThrows() throws Exception {
@Test
void whenOcspResponseSignerIsNotIssuedBySubjectIssuer_thenThrows() throws Exception {
final OcspCertificateRevocationChecker validator = getOcspCertificateRevocationCheckerWithAiaOcsp(
- getMockedResponse(getOcspResponseBytesFromResources("ocsp_response_unknown.der"))
+ getMockedResponse(getOcspResponseBytesFromResources("ocsp_response_unknown_self_signed.der"))
);
try (var mockedClock = mockStatic(DateAndTime.DefaultClock.class)) {
mockDate("2021-09-18T00:16:25", mockedClock);
@@ -266,7 +266,7 @@ void whenOcspResponseSignerIsNotIssuedBySubjectIssuer_thenThrows() throws Except
@Test
void whenOcspResponseCACertExpired_thenThrows() throws Exception {
final OcspCertificateRevocationChecker validator = getOcspCertificateRevocationCheckerWithAiaOcsp(
- getMockedResponse(getOcspResponseBytesFromResources("ocsp_response_unknown.der"))
+ getMockedResponse(getOcspResponseBytesFromResources("ocsp_response_unknown_self_signed.der"))
);
assertThatThrownBy(() -> validator.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
.isInstanceOf(UserCertificateOCSPCheckFailedException.class)
@@ -292,25 +292,17 @@ void whenNonceDiffers_thenThrows() throws Exception {
@Test
void whenInvalidOcspResponseTimeSkew_thenThrows() {
- assertThatThrownBy(() -> getOcspCertificateRevocationCheckerWithTimeSkewAndUpdateAge(Duration.ofMinutes(-1), Duration.ofMinutes(1)))
+ assertThatThrownBy(() -> new OcspCertificateRevocationChecker(ocspClient, getAiaOcspServiceProvider(), Duration.ofMinutes(-1)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageStartingWith("allowedOcspResponseTimeSkew must be greater than zero");
}
- @Test
- void whenInvalidMaxOcspResponseThisUpdateAge_thenThrows() {
- assertThatThrownBy(() -> getOcspCertificateRevocationCheckerWithTimeSkewAndUpdateAge(Duration.ofMinutes(1), Duration.ZERO))
- .isInstanceOf(IllegalArgumentException.class)
- .hasMessageStartingWith("maxOcspResponseThisUpdateAge must be greater than zero");
- }
-
private static AuthTokenValidator getAuthTokenValidatorWithOcspCertificateRevocationChecker() throws CertificateException, JceException, IOException {
return AuthTokenValidators.getDefaultAuthTokenValidatorBuilder()
.withCertificateRevocationChecker(new OcspCertificateRevocationChecker(
OcspClientImpl.build(Duration.ofSeconds(5)),
getAiaOcspServiceProvider(),
- OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW,
- OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE
+ OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW
)).build();
}
@@ -356,10 +348,8 @@ private static byte[] getOcspResponseBytesFromResources() throws IOException {
return getOcspResponseBytesFromResources("ocsp_response.der");
}
- public static byte[] getOcspResponseBytesFromResources(String resource) throws IOException {
- try (final InputStream resourceAsStream = ClassLoader.getSystemResourceAsStream(resource)) {
- return toByteArray(resourceAsStream);
- }
+ private static byte[] getOcspResponseBytesFromResources(String resource) throws IOException {
+ return ResourceUtil.bytesFromResource(resource);
}
private OcspCertificateRevocationChecker getOcspCertificateRevocationCheckerWithAiaOcsp(HttpResponse response) throws JceException {
@@ -371,11 +361,7 @@ private OcspCertificateRevocationChecker getOcspCertificateRevocationChecker(Ocs
}
private OcspCertificateRevocationChecker getOcspCertificateRevocationChecker(OcspClient client, OcspServiceProvider ocspServiceProvider) {
- return new OcspCertificateRevocationChecker(client, ocspServiceProvider, OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW, OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE);
- }
-
- private void getOcspCertificateRevocationCheckerWithTimeSkewAndUpdateAge(Duration timeSkew, Duration updateAge) throws JceException {
- new OcspCertificateRevocationChecker(ocspClient, getAiaOcspServiceProvider(), timeSkew, updateAge);
+ return new OcspCertificateRevocationChecker(client, ocspServiceProvider, OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW);
}
private HttpResponse getMockedResponse(byte[] bodyContent) throws URISyntaxException {
@@ -405,15 +391,4 @@ private OcspClient getMockClient(HttpResponse response) {
};
}
- private static byte[] toByteArray(InputStream resourceAsStream) throws IOException {
- Objects.requireNonNull(resourceAsStream);
- int bytesAvailable = resourceAsStream.available();
- byte[] result = new byte[bytesAvailable];
- int bytesRead = resourceAsStream.read(result);
- if (bytesRead != bytesAvailable) {
- throw new RuntimeException("Short read while loading resources");
- }
- return result;
- }
-
}
diff --git a/src/test/java/eu/webeid/ocsp/client/OcspClientImplTest.java b/src/test/java/eu/webeid/ocsp/client/OcspClientImplTest.java
new file mode 100644
index 00000000..c735923e
--- /dev/null
+++ b/src/test/java/eu/webeid/ocsp/client/OcspClientImplTest.java
@@ -0,0 +1,207 @@
+// SPDX-FileCopyrightText: Estonian Information System Authority
+// SPDX-License-Identifier: MIT
+
+package eu.webeid.ocsp.client;
+
+import eu.webeid.ocsp.exceptions.OCSPClientException;
+import eu.webeid.security.testutil.ResourceUtil;
+import org.bouncycastle.cert.ocsp.OCSPReq;
+import org.bouncycastle.cert.ocsp.OCSPResp;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpHeaders;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.ByteBuffer;
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Flow;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class OcspClientImplTest {
+
+ private static final URI OCSP_URI = URI.create("http://ocsp.test/");
+ private static final Duration TIMEOUT = Duration.ofSeconds(5);
+
+ @Test
+ void whenHttpResponseStatusIsNot200_thenThrowsWithStatusCodeAndBody() throws Exception {
+ byte[] body = "not-found".getBytes();
+ HttpClient httpClient = mockHttpClient(mockResponse(404, body, "text/plain"));
+ OcspClientImpl client = new OcspClientImpl(httpClient, TIMEOUT);
+
+ assertThatExceptionOfType(OCSPClientException.class)
+ .isThrownBy(() -> client.request(OCSP_URI, encodableOcspReq()))
+ .withMessageStartingWith("OCSP request was not successful")
+ .satisfies(ex -> {
+ assertThat(ex.getStatusCode()).isEqualTo(404);
+ assertThat(ex.getResponseBody()).isEqualTo(body);
+ });
+ }
+
+ @Test
+ void whenContentTypeIsNotOcspResponse_thenThrows() throws Exception {
+ HttpClient httpClient = mockHttpClient(mockResponse(200, new byte[]{0x01}, "text/html"));
+ OcspClientImpl client = new OcspClientImpl(httpClient, TIMEOUT);
+
+ assertThatExceptionOfType(OCSPClientException.class)
+ .isThrownBy(() -> client.request(OCSP_URI, encodableOcspReq()))
+ .withMessage("OCSP response content type is not application/ocsp-response");
+ }
+
+ @Test
+ void whenHttpClientThrowsInterruptedException_thenRestoresInterruptFlagAndThrows() throws Exception {
+ HttpClient httpClient = mock(HttpClient.class);
+ when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)))
+ .thenThrow(new InterruptedException("interrupted"));
+ OcspClientImpl client = new OcspClientImpl(httpClient, TIMEOUT);
+
+ try {
+ assertThatExceptionOfType(OCSPClientException.class)
+ .isThrownBy(() -> client.request(OCSP_URI, encodableOcspReq()))
+ .withMessage("Interrupted while sending OCSP request")
+ .withCauseInstanceOf(InterruptedException.class);
+ } finally {
+ // Always clear so a failing assertion above doesn't leak the interrupt flag to other tests.
+ assertThat(Thread.interrupted()).as("interrupt flag must be set by InterruptedException handling").isTrue();
+ }
+ }
+
+ @Test
+ void whenHttpClientThrowsIOException_thenThrows() throws Exception {
+ HttpClient httpClient = mock(HttpClient.class);
+ when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)))
+ .thenThrow(new IOException("network down"));
+ OcspClientImpl client = new OcspClientImpl(httpClient, TIMEOUT);
+
+ assertThatExceptionOfType(OCSPClientException.class)
+ .isThrownBy(() -> client.request(OCSP_URI, encodableOcspReq()))
+ .withCauseInstanceOf(IOException.class);
+ }
+
+ @Test
+ void whenOcspReqGetEncodedThrowsIOException_thenThrows() throws Exception {
+ OCSPReq ocspReq = mock(OCSPReq.class);
+ when(ocspReq.getEncoded()).thenThrow(new IOException("encoding failed"));
+ OcspClientImpl client = new OcspClientImpl(mock(HttpClient.class), TIMEOUT);
+
+ assertThatExceptionOfType(OCSPClientException.class)
+ .isThrownBy(() -> client.request(OCSP_URI, ocspReq))
+ .withCauseInstanceOf(IOException.class);
+ }
+
+ @Test
+ void whenResponseBodyIsInvalidOcsp_thenThrowsWithIoExceptionCause() throws Exception {
+ HttpClient httpClient = mockHttpClient(mockResponse(200, "not-an-ocsp-response".getBytes(), "application/ocsp-response"));
+ OcspClientImpl client = new OcspClientImpl(httpClient, TIMEOUT);
+
+ assertThatExceptionOfType(OCSPClientException.class)
+ .isThrownBy(() -> client.request(OCSP_URI, encodableOcspReq()))
+ .withCauseInstanceOf(IOException.class);
+ }
+
+ @Test
+ void whenRequestIsSent_thenOcspRequestIsPostedWithCorrectUriHeaderAndBody() throws Exception {
+ byte[] responseBody = ResourceUtil.bytesFromResource("ocsp_response.der");
+ HttpClient httpClient = mockHttpClient(mockResponse(200, responseBody, "application/ocsp-response"));
+ OcspClientImpl client = new OcspClientImpl(httpClient, TIMEOUT);
+ OCSPReq ocspReq = encodableOcspReq();
+
+ client.request(OCSP_URI, ocspReq);
+
+ ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
+ verify(httpClient).send(requestCaptor.capture(), any(HttpResponse.BodyHandler.class));
+ HttpRequest request = requestCaptor.getValue();
+
+ assertThat(request.uri()).isEqualTo(OCSP_URI);
+ assertThat(request.method()).isEqualTo("POST");
+ assertThat(request.headers().firstValue("Content-Type")).contains("application/ocsp-request");
+ assertThat(request.bodyPublisher()).isPresent();
+ assertThat(request.bodyPublisher().get().contentLength()).isEqualTo(ocspReq.getEncoded().length);
+ assertThat(readBodyPublisher(request.bodyPublisher().get())).isEqualTo(ocspReq.getEncoded());
+ }
+
+ @Test
+ void whenResponseIsValidOcsp_thenReturnsParsedOcspResp() throws Exception {
+ byte[] responseBody = ResourceUtil.bytesFromResource("ocsp_response.der");
+ HttpClient httpClient = mockHttpClient(mockResponse(200, responseBody, "application/ocsp-response"));
+ OcspClientImpl client = new OcspClientImpl(httpClient, TIMEOUT);
+
+ OCSPResp ocspResp = client.request(OCSP_URI, encodableOcspReq());
+
+ assertThat(ocspResp).isNotNull();
+ assertThat(ocspResp.getStatus()).isEqualTo(OCSPResp.SUCCESSFUL);
+ }
+
+ @Test
+ void whenHttpClientIsNull_thenThrows() {
+ assertThatExceptionOfType(NullPointerException.class)
+ .isThrownBy(() -> new OcspClientImpl(null, TIMEOUT));
+ }
+
+ @SuppressWarnings("unchecked")
+ private static HttpClient mockHttpClient(HttpResponse response) throws Exception {
+ HttpClient httpClient = mock(HttpClient.class);
+ when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))).thenReturn(response);
+ return httpClient;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static HttpResponse mockResponse(int statusCode, byte[] body, String contentType) {
+ HttpResponse response = mock(HttpResponse.class);
+ when(response.statusCode()).thenReturn(statusCode);
+ when(response.body()).thenReturn(body);
+ HttpHeaders headers = HttpHeaders.of(Map.of("Content-Type", List.of(contentType)), (k, v) -> true);
+ when(response.headers()).thenReturn(headers);
+ return response;
+ }
+
+ private static OCSPReq encodableOcspReq() throws IOException {
+ OCSPReq ocspReq = mock(OCSPReq.class);
+ when(ocspReq.getEncoded()).thenReturn(new byte[]{0x30, 0x00});
+ return ocspReq;
+ }
+
+ private static byte[] readBodyPublisher(HttpRequest.BodyPublisher bodyPublisher) {
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ CompletableFuture result = new CompletableFuture<>();
+
+ bodyPublisher.subscribe(new Flow.Subscriber<>() {
+ @Override
+ public void onSubscribe(Flow.Subscription subscription) {
+ subscription.request(Long.MAX_VALUE);
+ }
+
+ @Override
+ public void onNext(ByteBuffer item) {
+ byte[] chunk = new byte[item.remaining()];
+ item.get(chunk);
+ output.write(chunk, 0, chunk.length);
+ }
+
+ @Override
+ public void onError(Throwable throwable) {
+ result.completeExceptionally(throwable);
+ }
+
+ @Override
+ public void onComplete() {
+ result.complete(output.toByteArray());
+ }
+ });
+
+ return result.join();
+ }
+}
diff --git a/src/test/java/eu/webeid/ocsp/client/OcspClientOverrideTest.java b/src/test/java/eu/webeid/ocsp/client/OcspClientOverrideTest.java
index 828d2915..f09aca39 100644
--- a/src/test/java/eu/webeid/ocsp/client/OcspClientOverrideTest.java
+++ b/src/test/java/eu/webeid/ocsp/client/OcspClientOverrideTest.java
@@ -56,8 +56,7 @@ private static AuthTokenValidator getAuthTokenValidatorWithOverriddenOcspClient(
.withCertificateRevocationChecker(new OcspCertificateRevocationChecker(
ocspClient,
getAiaOcspServiceProvider(),
- OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW,
- OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE
+ OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW
)).build();
}
diff --git a/src/test/java/eu/webeid/ocsp/protocol/IssuerDistinguishedNameTest.java b/src/test/java/eu/webeid/ocsp/protocol/IssuerDistinguishedNameTest.java
new file mode 100644
index 00000000..9f42d5be
--- /dev/null
+++ b/src/test/java/eu/webeid/ocsp/protocol/IssuerDistinguishedNameTest.java
@@ -0,0 +1,27 @@
+// SPDX-FileCopyrightText: Estonian Information System Authority
+// SPDX-License-Identifier: MIT
+
+package eu.webeid.ocsp.protocol;
+
+import org.bouncycastle.asn1.x500.X500Name;
+import org.junit.jupiter.api.Test;
+
+import static eu.webeid.ocsp.protocol.IssuerDistinguishedName.getIssuerDistinguishedName;
+import static eu.webeid.security.testutil.Certificates.getMariliisEsteid2015Cert;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatNullPointerException;
+
+class IssuerDistinguishedNameTest {
+
+ private static final X500Name ISSUER_DN = new X500Name("CN=TEST of ESTEID-SK 2015, OID.2.5.4.97=NTREE-10747013, O=AS Sertifitseerimiskeskus, C=EE");
+
+ @Test
+ void whenCertificateGiven_thenReturnsIssuerDistinguishedName() throws Exception {
+ assertThat(getIssuerDistinguishedName(getMariliisEsteid2015Cert())).isEqualTo(ISSUER_DN);
+ }
+
+ @Test
+ void whenCertificateIsNull_thenThrows() {
+ assertThatNullPointerException().isThrownBy(() -> getIssuerDistinguishedName(null));
+ }
+}
diff --git a/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java b/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java
index 677bc8dd..8a53653b 100644
--- a/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java
+++ b/src/test/java/eu/webeid/ocsp/protocol/OcspResponseValidatorTest.java
@@ -4,24 +4,40 @@
package eu.webeid.ocsp.protocol;
import eu.webeid.ocsp.OcspCertificateRevocationChecker;
+import eu.webeid.ocsp.exceptions.OCSPCertificateException;
import eu.webeid.ocsp.exceptions.UserCertificateOCSPCheckFailedException;
import eu.webeid.ocsp.exceptions.UserCertificateRevokedException;
+import org.bouncycastle.asn1.DLBitString;
+import org.bouncycastle.asn1.x509.BasicConstraints;
+import org.bouncycastle.asn1.x509.ExtendedKeyUsage;
+import org.bouncycastle.asn1.x509.Extension;
+import org.bouncycastle.asn1.x509.KeyPurposeId;
+import org.bouncycastle.asn1.x509.KeyUsage;
import org.bouncycastle.cert.ocsp.BasicOCSPResp;
+import org.bouncycastle.cert.ocsp.CertificateStatus;
import org.bouncycastle.cert.ocsp.OCSPResp;
+import org.bouncycastle.cert.ocsp.RevokedStatus;
import org.bouncycastle.cert.ocsp.SingleResp;
import org.junit.jupiter.api.Test;
import java.net.URI;
+import java.security.cert.X509Certificate;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
-import static eu.webeid.ocsp.OcspCertificateRevocationCheckerTest.getOcspResponseBytesFromResources;
+import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateBasicConstraintsNotCA;
+import static eu.webeid.security.testutil.ResourceUtil.bytesFromResource;
import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateCertificateStatusUpdateTime;
+import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateExtendedKeyUsageOcspSigning;
+import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateKeyUsageDigitalSignature;
+import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateKeyUsageNotCertificateSigning;
import static eu.webeid.ocsp.protocol.OcspResponseValidator.validateSubjectCertificateStatus;
+import static eu.webeid.security.testutil.TestCertificateBuilder.buildCertificate;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatNullPointerException;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@@ -29,17 +45,22 @@ class OcspResponseValidatorTest {
private static final Duration TIME_SKEW = OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW;
private static final Duration THIS_UPDATE_AGE = OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE;
+ private static final Duration NEXT_UPDATE_AGE = OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE;
+ private static final Duration LONG_THIS_UPDATE_AGE = Duration.ofDays(365);
+ private static final Duration LONG_NEXT_UPDATE_AGE = Duration.ofDays(365);
+ /** Shorter than {@link #TIME_SKEW}, to show that the nextUpdate age check is independent of the time skew. */
+ private static final Duration SHORT_NEXT_UPDATE_AGE = Duration.ofMinutes(2);
private static final URI OCSP_URL = URI.create("https://example.org");
@Test
- void whenThisAndNextUpdateWithinSkew_thenValidationSucceeds() {
+ void whenThisAndNextUpdateWithinAgeLimits_thenValidationSucceeds() {
final SingleResp mockResponse = mock(SingleResp.class);
var now = Instant.now();
var thisUpdateWithinAgeLimit = getThisUpdateWithinAgeLimit(now);
var nextUpdateWithinAgeLimit = Date.from(now.minus(THIS_UPDATE_AGE.minusSeconds(2)));
when(mockResponse.getThisUpdate()).thenReturn(thisUpdateWithinAgeLimit);
when(mockResponse.getNextUpdate()).thenReturn(nextUpdateWithinAgeLimit);
- assertThatCode(() -> validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, OCSP_URL))
+ assertThatCode(() -> validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, NEXT_UPDATE_AGE, OCSP_URL))
.doesNotThrowAnyException();
}
@@ -53,7 +74,7 @@ void whenNextUpdateBeforeThisUpdate_thenThrows() {
when(mockResponse.getNextUpdate()).thenReturn(beforeThisUpdate);
assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class)
.isThrownBy(() ->
- validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, OCSP_URL))
+ validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, LONG_NEXT_UPDATE_AGE, OCSP_URL))
.withMessageStartingWith("User certificate revocation check has failed: "
+ "Certificate status update time check failed: "
+ "nextUpdate '" + beforeThisUpdate.toInstant() + "' is before thisUpdate '" + thisUpdateWithinAgeLimit.toInstant() + "'");
@@ -67,7 +88,7 @@ void whenThisUpdateHalfHourBeforeNow_thenThrows() {
when(mockResponse.getThisUpdate()).thenReturn(halfHourBeforeNow);
assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class)
.isThrownBy(() ->
- validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, OCSP_URL))
+ validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, NEXT_UPDATE_AGE, OCSP_URL))
.withMessageStartingWith("User certificate revocation check has failed: "
+ "Certificate status update time check failed: "
+ "thisUpdate '" + halfHourBeforeNow.toInstant() + "' is too old, minimum time allowed: ");
@@ -81,12 +102,22 @@ void whenThisUpdateHalfHourAfterNow_thenThrows() {
when(mockResponse.getThisUpdate()).thenReturn(halfHourAfterNow);
assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class)
.isThrownBy(() ->
- validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, OCSP_URL))
+ validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, NEXT_UPDATE_AGE, OCSP_URL))
.withMessageStartingWith("User certificate revocation check has failed: "
+ "Certificate status update time check failed: "
+ "thisUpdate '" + halfHourAfterNow.toInstant() + "' is too far in the future, latest allowed: ");
}
+ @Test
+ void whenNextUpdateIsNull_thenValidationSucceeds() {
+ final SingleResp mockResponse = mock(SingleResp.class);
+ var now = Instant.now();
+ when(mockResponse.getThisUpdate()).thenReturn(getThisUpdateWithinAgeLimit(now));
+ when(mockResponse.getNextUpdate()).thenReturn(null);
+ assertThatCode(() -> validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, NEXT_UPDATE_AGE, OCSP_URL))
+ .doesNotThrowAnyException();
+ }
+
@Test
void whenNextUpdateHalfHourBeforeNow_thenThrows() {
final SingleResp mockResponse = mock(SingleResp.class);
@@ -97,11 +128,39 @@ void whenNextUpdateHalfHourBeforeNow_thenThrows() {
when(mockResponse.getNextUpdate()).thenReturn(halfHourBeforeNow);
assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class)
.isThrownBy(() ->
- validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, OCSP_URL))
- .withMessage("User certificate revocation check has failed: "
+ validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, THIS_UPDATE_AGE, NEXT_UPDATE_AGE, OCSP_URL))
+ .withMessageStartingWith("User certificate revocation check has failed: "
+ "Certificate status update time check failed: "
- + "nextUpdate '" + halfHourBeforeNow.toInstant() + "' is in the past"
- + " (OCSP responder: https://example.org)");
+ + "nextUpdate '" + halfHourBeforeNow.toInstant() + "' is too old, minimum time allowed: '");
+ }
+
+ @Test
+ void whenNextUpdateOlderThanMaxNextUpdateAgeButWithinTimeSkew_thenThrows() {
+ final SingleResp mockResponse = mock(SingleResp.class);
+ var now = Instant.now();
+ var thisUpdateBeforeNextUpdate = Date.from(now.minus(14, ChronoUnit.MINUTES));
+ var nextUpdateWithinTimeSkewButTooOld = Date.from(now.minus(10, ChronoUnit.MINUTES));
+ when(mockResponse.getThisUpdate()).thenReturn(thisUpdateBeforeNextUpdate);
+ when(mockResponse.getNextUpdate()).thenReturn(nextUpdateWithinTimeSkewButTooOld);
+ assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() ->
+ validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, TIME_SKEW, SHORT_NEXT_UPDATE_AGE, OCSP_URL))
+ .withMessageStartingWith("User certificate revocation check has failed: "
+ + "Certificate status update time check failed: "
+ + "nextUpdate '" + nextUpdateWithinTimeSkewButTooOld.toInstant() + "' is too old, minimum time allowed: '");
+ }
+
+ @Test
+ void whenNextUpdateOlderThanTimeSkewButWithinMaxNextUpdateAge_thenValidationSucceeds() {
+ final SingleResp mockResponse = mock(SingleResp.class);
+ var now = Instant.now();
+ var thisUpdateOlderThanTimeSkew = Date.from(now.minus(25, ChronoUnit.MINUTES));
+ var nextUpdateOlderThanTimeSkew = Date.from(now.minus(20, ChronoUnit.MINUTES));
+ when(mockResponse.getThisUpdate()).thenReturn(thisUpdateOlderThanTimeSkew);
+ when(mockResponse.getNextUpdate()).thenReturn(nextUpdateOlderThanTimeSkew);
+ assertThatCode(() ->
+ validateCertificateStatusUpdateTime(mockResponse, TIME_SKEW, LONG_THIS_UPDATE_AGE, LONG_NEXT_UPDATE_AGE, OCSP_URL))
+ .doesNotThrowAnyException();
}
@Test
@@ -113,14 +172,189 @@ void whenOcspResponseStatusIsUnknown_ThenThrowsUserCertificateOCSPCheckFailedExc
.withMessage("User certificate revocation check has failed: Unknown status (OCSP responder: https://example.org)");
}
+ @Test
+ void whenCertIsNotCA_thenBasicConstraintsValidationSucceeds() throws Exception {
+ final X509Certificate cert = buildCertificate(
+ new Extension(Extension.basicConstraints, true, new BasicConstraints(false).getEncoded()));
+ assertThatCode(() -> validateBasicConstraintsNotCA(cert)).doesNotThrowAnyException();
+ }
+
+ @Test
+ void whenBasicConstraintsExtensionAbsent_thenBasicConstraintsValidationSucceeds() throws Exception {
+ final X509Certificate cert = buildCertificate();
+ assertThatCode(() -> validateBasicConstraintsNotCA(cert)).doesNotThrowAnyException();
+ }
+
+ @Test
+ void whenCertIsCA_thenBasicConstraintsValidationThrows() throws Exception {
+ final X509Certificate cert = buildCertificate(
+ new Extension(Extension.basicConstraints, true, new BasicConstraints(0).getEncoded()));
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() -> validateBasicConstraintsNotCA(cert))
+ .withMessage("Certificate " + cert.getSubjectX500Principal() +
+ " must not be a CA certificate (Basic Constraints CA:TRUE is not allowed for OCSP responder)");
+ }
+
+ @Test
+ void whenCertIsNull_thenBasicConstraintsValidationThrowsNullPointerException() {
+ assertThatNullPointerException().isThrownBy(() -> validateBasicConstraintsNotCA(null))
+ .withMessage("certificate");
+ }
+
+ @Test
+ void whenCertHasKeyUsageDigitalSignature_thenKeyUsageValidationSucceeds() throws Exception {
+ final X509Certificate cert = buildCertificate(
+ new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature).getEncoded()));
+ assertThatCode(() -> validateKeyUsageDigitalSignature(cert)).doesNotThrowAnyException();
+ }
+
+ @Test
+ void whenKeyUsageExtensionAbsent_thenKeyUsageValidationThrows() throws Exception {
+ final X509Certificate cert = buildCertificate();
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() -> validateKeyUsageDigitalSignature(cert))
+ .withMessage("Certificate " + cert.getSubjectX500Principal() +
+ " does not contain the Key Usage extension required for OCSP response signing");
+ }
+
+ @Test
+ void whenCertMissingKeyUsageDigitalSignature_thenKeyUsageValidationThrows() throws Exception {
+ final X509Certificate cert = buildCertificate(
+ new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.nonRepudiation).getEncoded()));
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() -> validateKeyUsageDigitalSignature(cert))
+ .withMessage("Certificate " + cert.getSubjectX500Principal() +
+ " Key Usage extension does not contain Digital Signature, which is required for OCSP response signing");
+ }
+
+ @Test
+ void whenCertKeyUsageBitStringEmpty_thenKeyUsageValidationThrows() throws Exception {
+ final X509Certificate cert = buildCertificate(
+ new Extension(Extension.keyUsage, true, new DLBitString(new byte[0]).getEncoded()));
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() -> validateKeyUsageDigitalSignature(cert))
+ .withMessage("Certificate " + cert.getSubjectX500Principal() +
+ " Key Usage extension does not contain Digital Signature, which is required for OCSP response signing");
+ }
+
+ @Test
+ void whenCertIsNull_thenKeyUsageValidationThrowsNullPointerException() {
+ assertThatNullPointerException().isThrownBy(() -> validateKeyUsageDigitalSignature(null))
+ .withMessage("certificate");
+ }
+
+ @Test
+ void whenCertHasNoKeyUsageKeyCertSign_thenKeyCertSignValidationSucceeds() throws Exception {
+ final X509Certificate cert = buildCertificate(
+ new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature).getEncoded()));
+ assertThatCode(() -> validateKeyUsageNotCertificateSigning(cert)).doesNotThrowAnyException();
+ }
+
+ @Test
+ void whenKeyUsageExtensionAbsent_thenKeyCertSignValidationSucceeds() throws Exception {
+ final X509Certificate cert = buildCertificate();
+ assertThatCode(() -> validateKeyUsageNotCertificateSigning(cert)).doesNotThrowAnyException();
+ }
+
+ @Test
+ void whenCertKeyUsageBitStringEmpty_thenKeyCertSignValidationSucceeds() throws Exception {
+ final X509Certificate cert = buildCertificate(
+ new Extension(Extension.keyUsage, true, new DLBitString(new byte[0]).getEncoded()));
+ assertThatCode(() -> validateKeyUsageNotCertificateSigning(cert)).doesNotThrowAnyException();
+ }
+
+ @Test
+ void whenCertHasKeyUsageKeyCertSign_thenKeyCertSignValidationThrows() throws Exception {
+ final X509Certificate cert = buildCertificate(
+ new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign).getEncoded()));
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() -> validateKeyUsageNotCertificateSigning(cert))
+ .withMessage("Certificate " + cert.getSubjectX500Principal() +
+ " Key Usage extension contains Certificate Signing, which is not allowed for OCSP responder");
+ }
+
+ @Test
+ void whenCertHasKeyUsageKeyCertSignCombinedWithDigitalSignature_thenKeyCertSignValidationThrows() throws Exception {
+ final X509Certificate cert = buildCertificate(
+ new Extension(Extension.keyUsage, true,
+ new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyCertSign).getEncoded()));
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() -> validateKeyUsageNotCertificateSigning(cert))
+ .withMessage("Certificate " + cert.getSubjectX500Principal() +
+ " Key Usage extension contains Certificate Signing, which is not allowed for OCSP responder");
+ }
+
+ @Test
+ void whenCertIsNull_thenKeyCertSignValidationThrowsNullPointerException() {
+ assertThatNullPointerException().isThrownBy(() -> validateKeyUsageNotCertificateSigning(null))
+ .withMessage("certificate");
+ }
+
+ @Test
+ void whenCertHasOcspSigningEku_thenExtendedKeyUsageValidationSucceeds() throws Exception {
+ final X509Certificate cert = buildCertificate(
+ new Extension(Extension.extendedKeyUsage, true,
+ new ExtendedKeyUsage(KeyPurposeId.id_kp_OCSPSigning).getEncoded()));
+ assertThatCode(() -> validateExtendedKeyUsageOcspSigning(cert)).doesNotThrowAnyException();
+ }
+
+ @Test
+ void whenExtendedKeyUsageExtensionAbsent_thenExtendedKeyUsageValidationThrows() throws Exception {
+ final X509Certificate cert = buildCertificate();
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() -> validateExtendedKeyUsageOcspSigning(cert))
+ .withMessage("Certificate " + cert.getSubjectX500Principal() +
+ " does not contain the Extended Key Usage extension required for OCSP response signing");
+ }
+
+ @Test
+ void whenCertMissingOcspSigningEku_thenExtendedKeyUsageValidationThrows() throws Exception {
+ final X509Certificate cert = buildCertificate(
+ new Extension(Extension.extendedKeyUsage, true,
+ new ExtendedKeyUsage(KeyPurposeId.id_kp_clientAuth).getEncoded()));
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() -> validateExtendedKeyUsageOcspSigning(cert))
+ .withMessage("Certificate " + cert.getSubjectX500Principal() +
+ " Extended Key Usage extension does not contain OCSP Signing, which is required for OCSP response signing");
+ }
+
+ @Test
+ void whenCertIsNull_thenExtendedKeyUsageValidationThrowsNullPointerException() {
+ assertThatNullPointerException().isThrownBy(() -> validateExtendedKeyUsageOcspSigning(null))
+ .withMessage("certificate");
+ }
+
+ @Test
+ void whenRevokedStatusHasNoReason_thenThrows() {
+ final SingleResp mockResponse = mock(SingleResp.class);
+ when(mockResponse.getCertStatus()).thenReturn(new RevokedStatus(new Date()));
+ assertThatExceptionOfType(UserCertificateRevokedException.class)
+ .isThrownBy(() ->
+ validateSubjectCertificateStatus(mockResponse, OCSP_URL))
+ .withMessage("User certificate has been revoked (OCSP responder: https://example.org)");
+ }
+
+ @Test
+ void whenStatusIsNeitherGoodRevokedNorUnknown_thenThrowsUserCertificateOCSPCheckFailedException() {
+ final SingleResp mockResponse = mock(SingleResp.class);
+ when(mockResponse.getCertStatus()).thenReturn(new UnexpectedCertificateStatus());
+ assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() ->
+ validateSubjectCertificateStatus(mockResponse, OCSP_URL))
+ .withMessage("User certificate revocation check has failed: Status is neither good, revoked nor unknown (OCSP responder: https://example.org)");
+ }
+
private static Date getThisUpdateWithinAgeLimit(Instant now) {
return Date.from(now.minus(THIS_UPDATE_AGE.minusSeconds(1)));
}
private static SingleResp getUnknownCertStatusResponse() throws Exception {
- final OCSPResp ocspRespUnknown = new OCSPResp(getOcspResponseBytesFromResources("ocsp_response_unknown.der"));
+ final OCSPResp ocspRespUnknown = new OCSPResp(bytesFromResource("ocsp_response_unknown.der"));
final BasicOCSPResp basicResponse = (BasicOCSPResp) ocspRespUnknown.getResponseObject();
return basicResponse.getResponses()[0];
}
+ private static class UnexpectedCertificateStatus implements CertificateStatus {
+ }
+
}
diff --git a/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceConfigurationTest.java b/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceConfigurationTest.java
index 06d79020..e94703f6 100644
--- a/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceConfigurationTest.java
+++ b/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceConfigurationTest.java
@@ -3,6 +3,7 @@
package eu.webeid.ocsp.service;
+import eu.webeid.ocsp.OcspCertificateRevocationChecker;
import eu.webeid.security.certificate.CertificateValidator;
import org.bouncycastle.asn1.x500.X500Name;
import org.junit.jupiter.api.Test;
@@ -25,7 +26,7 @@ void whenCallerMutatesCollections_thenConfigurationRemainsUnchanged() throws Exc
final TrustAnchor anchor = new TrustAnchor(getTestEsteid2018CA(), null);
final Set anchors = new HashSet<>(Set.of(anchor));
final AiaOcspServiceConfiguration configuration = new AiaOcspServiceConfiguration(
- nonceDisabledIssuerDNs, anchors, CertificateValidator.buildCertStoreFromCertificates(List.of(getTestEsteid2018CA())));
+ nonceDisabledIssuerDNs, anchors, CertificateValidator.buildCertStoreFromCertificates(List.of(getTestEsteid2018CA())), OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE);
nonceDisabledIssuerDNs.clear();
anchors.clear();
diff --git a/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceTest.java b/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceTest.java
new file mode 100644
index 00000000..e010ad08
--- /dev/null
+++ b/src/test/java/eu/webeid/ocsp/service/AiaOcspServiceTest.java
@@ -0,0 +1,115 @@
+// SPDX-FileCopyrightText: Estonian Information System Authority
+// SPDX-License-Identifier: MIT
+
+package eu.webeid.ocsp.service;
+
+import eu.webeid.security.certificate.CertificateValidator;
+import org.bouncycastle.asn1.x500.X500Name;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.net.URI;
+import java.security.cert.CertStore;
+import java.security.cert.TrustAnchor;
+import java.security.cert.X509Certificate;
+import java.util.List;
+import java.util.Set;
+
+import static eu.webeid.ocsp.OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE;
+import static eu.webeid.ocsp.OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE;
+import static eu.webeid.ocsp.protocol.IssuerDistinguishedName.getIssuerDistinguishedName;
+import static eu.webeid.security.testutil.Certificates.getJaakKristjanEsteid2018Cert;
+import static eu.webeid.security.testutil.Certificates.getMariliisEsteid2015Cert;
+import static eu.webeid.security.testutil.Certificates.getTestEsteid2015CA;
+import static eu.webeid.security.testutil.Certificates.getTestEsteid2018CA;
+import static eu.webeid.security.testutil.Certificates.getDemoEsteidSk2018AiaOcspResponder;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+
+/**
+ * Unit tests for the {@link AiaOcspService} behaviour introduced in commit e2bd57e3:
+ * nonce support is now keyed on the certificate's issuer distinguished name (previously the OCSP URL),
+ * and the service carries an optional {@link FallbackOcspService}.
+ */
+class AiaOcspServiceTest {
+
+ private static final URI ESTEID2018_AIA_OCSP_URI = URI.create("http://aia.demo.sk.ee/esteid2018");
+ private static final URI ESTEID2015_AIA_OCSP_URI = URI.create("http://aia.demo.sk.ee/esteid2015");
+ private static final URI FALLBACK_URI = URI.create("http://fallback.ocsp.test");
+
+ private static Set trustedCaAnchors;
+ private static CertStore trustedCaCertStore;
+ private static X509Certificate esteid2018UserCert;
+ private static X509Certificate esteid2015UserCert;
+ private static X500Name esteid2018IssuerDN;
+ private static X500Name esteid2015IssuerDN;
+
+ @BeforeAll
+ static void setUpFixtures() throws Exception {
+ List trustedCaCertificates = List.of(getTestEsteid2018CA(), getTestEsteid2015CA());
+ trustedCaAnchors = CertificateValidator.buildTrustAnchorsFromCertificates(trustedCaCertificates);
+ trustedCaCertStore = CertificateValidator.buildCertStoreFromCertificates(trustedCaCertificates);
+ esteid2018UserCert = getJaakKristjanEsteid2018Cert();
+ esteid2015UserCert = getMariliisEsteid2015Cert();
+ esteid2018IssuerDN = getIssuerDistinguishedName(esteid2018UserCert);
+ esteid2015IssuerDN = getIssuerDistinguishedName(esteid2015UserCert);
+ }
+
+ private static AiaOcspServiceConfiguration configurationWithNonceDisabledFor(X500Name... nonceDisabledIssuerDNs) {
+ return new AiaOcspServiceConfiguration(Set.of(nonceDisabledIssuerDNs), trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE);
+ }
+
+ @Test
+ void whenIssuerDnIsInNonceDisabledSet_thenDoesNotSupportNonce() throws Exception {
+ AiaOcspServiceConfiguration configuration = configurationWithNonceDisabledFor(esteid2015IssuerDN);
+
+ AiaOcspService service = new AiaOcspService(configuration, esteid2015UserCert, null);
+
+ assertThat(service.getAccessLocation()).isEqualTo(ESTEID2015_AIA_OCSP_URI);
+ assertThat(service.doesSupportNonce()).isFalse();
+ }
+
+ @Test
+ void whenIssuerDnIsNotInNonceDisabledSet_thenSupportsNonce() throws Exception {
+ // Only the ESTEID-SK 2015 issuer is nonce-disabled, so a certificate from the 2018 issuer must still support nonce.
+ AiaOcspServiceConfiguration configuration = configurationWithNonceDisabledFor(esteid2015IssuerDN);
+
+ AiaOcspService service = new AiaOcspService(configuration, esteid2018UserCert, null);
+
+ assertThat(service.getAccessLocation()).isEqualTo(ESTEID2018_AIA_OCSP_URI);
+ assertThat(service.doesSupportNonce()).isTrue();
+ }
+
+ @Test
+ void whenFallbackServiceProvided_thenGetFallbackServiceReturnsIt() throws Exception {
+ AiaOcspServiceConfiguration configuration = configurationWithNonceDisabledFor();
+ FallbackOcspServiceConfiguration fallbackConfiguration = new FallbackOcspServiceConfiguration(
+ FALLBACK_URI, getDemoEsteidSk2018AiaOcspResponder(), true,
+ null, getTestEsteid2018CA(), trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE);
+ FallbackOcspService fallback = new FallbackOcspService(fallbackConfiguration);
+
+ AiaOcspService service = new AiaOcspService(configuration, esteid2018UserCert, fallback);
+
+ assertThat(service.getFallbackService()).containsSame(fallback);
+ assertThat(service.getFallbackService().get().getAccessLocation()).isEqualTo(FALLBACK_URI);
+ }
+
+ @Test
+ void whenFallbackServiceIsNull_thenGetFallbackServiceIsEmpty() throws Exception {
+ AiaOcspServiceConfiguration configuration = configurationWithNonceDisabledFor();
+
+ AiaOcspService service = new AiaOcspService(configuration, esteid2018UserCert, null);
+
+ assertThat(service.getFallbackService()).isEmpty();
+ }
+
+ @Test
+ void whenCertificateIsNull_thenThrows() {
+ AiaOcspServiceConfiguration configuration = configurationWithNonceDisabledFor();
+
+ assertThatExceptionOfType(NullPointerException.class)
+ .isThrownBy(() -> new AiaOcspService(configuration, null, null));
+ }
+}
diff --git a/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceConfigurationTest.java b/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceConfigurationTest.java
index 819ea3a0..960f1a72 100644
--- a/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceConfigurationTest.java
+++ b/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceConfigurationTest.java
@@ -3,33 +3,205 @@
package eu.webeid.ocsp.service;
+import eu.webeid.ocsp.exceptions.OCSPCertificateException;
import eu.webeid.security.certificate.CertificateValidator;
+import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.net.URI;
+import java.security.cert.CertStore;
import java.security.cert.TrustAnchor;
+import java.security.cert.X509Certificate;
+import java.time.Duration;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
+import static eu.webeid.ocsp.OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE;
+import static eu.webeid.ocsp.OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE;
+
+import static eu.webeid.security.testutil.Certificates.getDemoEsteidSk2018AiaOcspResponder;
+import static eu.webeid.security.testutil.Certificates.getJaakKristjanEsteid2018Cert;
+import static eu.webeid.security.testutil.Certificates.getTestEsteid2015CA;
import static eu.webeid.security.testutil.Certificates.getTestEsteid2018CA;
+import static eu.webeid.security.testutil.Certificates.getTestSkOcspResponder2020;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.assertj.core.api.Assertions.assertThatNullPointerException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class FallbackOcspServiceConfigurationTest {
+ private static final URI FALLBACK_URI = URI.create("http://fallback.ocsp.test");
+
+ private static Set trustedCaAnchors;
+ private static CertStore trustedCaCertStore;
+ private static X509Certificate aiaOcspResponderCert;
+ private static X509Certificate ocspResponder2020Cert;
+ private static X509Certificate nonSigningUserCert;
+
+ @BeforeAll
+ static void setUpFixtures() throws Exception {
+ List trustedCaCertificates = List.of(getTestEsteid2018CA(), getTestEsteid2015CA());
+ trustedCaAnchors = CertificateValidator.buildTrustAnchorsFromCertificates(trustedCaCertificates);
+ trustedCaCertStore = CertificateValidator.buildCertStoreFromCertificates(trustedCaCertificates);
+ // The DEMO AIA responder certificate satisfies every OCSP responder extension requirement.
+ aiaOcspResponderCert = getDemoEsteidSk2018AiaOcspResponder();
+ // TEST of SK OCSP RESPONDER 2020 carries no Key Usage extension at all.
+ ocspResponder2020Cert = getTestSkOcspResponder2020();
+ nonSigningUserCert = getJaakKristjanEsteid2018Cert();
+ }
+
+ @Test
+ void whenAccessLocationIsNull_thenThrows() {
+ assertThatNullPointerException()
+ .isThrownBy(() -> new FallbackOcspServiceConfiguration(
+ null, null, true, null, getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE))
+ .withMessage("Fallback OCSP service access location");
+ }
+
+ @Test
+ void whenIssuerDnIsNull_thenThrows() {
+ assertThatNullPointerException()
+ .isThrownBy(() -> new FallbackOcspServiceConfiguration(
+ FALLBACK_URI, null, true, null, null, trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE))
+ .withMessage("issuerCertificate");
+ }
+
+ @Test
+ void whenTrustedCaAnchorsIsNull_thenThrows() {
+ assertThatNullPointerException()
+ .isThrownBy(() -> new FallbackOcspServiceConfiguration(
+ FALLBACK_URI, null, true, null, getTestEsteid2015CA(), null, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE))
+ .withMessage("trustedCACertificateAnchors");
+ }
+
+ @Test
+ void whenTrustedCaCertStoreIsNull_thenThrows() {
+ assertThatNullPointerException()
+ .isThrownBy(() -> new FallbackOcspServiceConfiguration(
+ FALLBACK_URI, null, true, null, getTestEsteid2015CA(), trustedCaAnchors, null,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE))
+ .withMessage("trustedCACertificateCertStore");
+ }
+
+ @Test
+ void whenMaxThisUpdateAgeIsNull_thenThrows() {
+ assertThatNullPointerException()
+ .isThrownBy(() -> new FallbackOcspServiceConfiguration(
+ FALLBACK_URI, null, true, null, getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore,
+ null, DEFAULT_NEXT_UPDATE_AGE))
+ .withMessage("maxThisUpdateAge must not be null");
+ }
+
+ @Test
+ void whenMaxNextUpdateAgeIsNull_thenThrows() {
+ assertThatNullPointerException()
+ .isThrownBy(() -> new FallbackOcspServiceConfiguration(
+ FALLBACK_URI, null, true, null, getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, null))
+ .withMessage("maxNextUpdateAge must not be null");
+ }
+
+ @Test
+ void whenMaxThisUpdateAgeIsZero_thenThrows() {
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> new FallbackOcspServiceConfiguration(
+ FALLBACK_URI, null, true, null, getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore,
+ Duration.ZERO, DEFAULT_NEXT_UPDATE_AGE))
+ .withMessage("maxThisUpdateAge must be greater than zero");
+ }
+
+ @Test
+ void whenMaxThisUpdateAgeIsNegative_thenThrows() {
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> new FallbackOcspServiceConfiguration(
+ FALLBACK_URI, null, true, null, getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore,
+ Duration.ofMinutes(-1), DEFAULT_NEXT_UPDATE_AGE))
+ .withMessage("maxThisUpdateAge must be greater than zero");
+ }
+
+ @Test
+ void whenMaxNextUpdateAgeIsZero_thenThrows() {
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> new FallbackOcspServiceConfiguration(
+ FALLBACK_URI, null, true, null, getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, Duration.ZERO))
+ .withMessage("maxNextUpdateAge must be greater than zero");
+ }
+
+ @Test
+ void whenMaxNextUpdateAgeIsNegative_thenThrows() {
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> new FallbackOcspServiceConfiguration(
+ FALLBACK_URI, null, true, null, getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, Duration.ofMinutes(-1)))
+ .withMessage("maxNextUpdateAge must be greater than zero");
+ }
+
+ @Test
+ void whenResponderCertificateLacksSigningExtension_thenThrows() {
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() -> new FallbackOcspServiceConfiguration(
+ FALLBACK_URI, nonSigningUserCert, true, null, getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE))
+ .withMessageContaining("Extended Key Usage extension does not contain OCSP Signing, "
+ + "which is required for OCSP response signing");
+ }
+
+ @Test
+ void whenResponderCertificateLacksKeyUsageExtension_thenThrows() {
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() -> new FallbackOcspServiceConfiguration(
+ FALLBACK_URI, ocspResponder2020Cert, true, null, getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE))
+ .withMessageContaining("does not contain the Key Usage extension required for OCSP response signing");
+ }
+
+ @Test
+ void whenResponderCertificateIsNull_thenConstructionSucceeds() {
+ assertThatCode(() -> new FallbackOcspServiceConfiguration(
+ FALLBACK_URI, null, true, null, getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void whenResponderCertificateHasSigningExtension_thenConstructionSucceedsAndAccessorsReturnConfiguredValues() throws Exception {
+ FallbackOcspServiceConfiguration nextFallback = new FallbackOcspServiceConfiguration(
+ URI.create("http://next.fallback.ocsp.test"), null, false, null,
+ getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE);
+
+ FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration(
+ FALLBACK_URI, aiaOcspResponderCert, true, nextFallback,
+ getTestEsteid2015CA(), trustedCaAnchors, trustedCaCertStore,
+ Duration.ofMinutes(3), Duration.ofMinutes(4));
+
+ assertThat(configuration.getAccessLocation()).isEqualTo(FALLBACK_URI);
+ assertThat(configuration.getResponderCertificate()).isEqualTo(aiaOcspResponderCert);
+ assertThat(configuration.doesSupportNonce()).isTrue();
+ assertThat(configuration.getNextFallbackConfiguration()).isSameAs(nextFallback);
+ assertThat(configuration.getIssuerCertificate()).isEqualTo(getTestEsteid2015CA());
+ assertThat(configuration.getTrustedCACertificateAnchors()).isSameAs(trustedCaAnchors);
+ assertThat(configuration.getTrustedCACertificateCertStore()).isSameAs(trustedCaCertStore);
+ assertThat(configuration.getMaxThisUpdateAge()).isEqualTo(Duration.ofMinutes(3));
+ assertThat(configuration.getMaxNextUpdateAge()).isEqualTo(Duration.ofMinutes(4));
+ }
+
@Test
void whenCallerMutatesCollections_thenConfigurationRemainsUnchanged() throws Exception {
final TrustAnchor anchor = new TrustAnchor(getTestEsteid2018CA(), null);
final Set anchors = new HashSet<>(Set.of(anchor));
final FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration(
- URI.create("http://fallback.ocsp.test"),
- null,
- true,
- null,
- getTestEsteid2018CA(),
- anchors,
- CertificateValidator.buildCertStoreFromCertificates(List.of(getTestEsteid2018CA())));
+ FALLBACK_URI, null, true, null, getTestEsteid2018CA(), anchors,
+ CertificateValidator.buildCertStoreFromCertificates(List.of(getTestEsteid2018CA())),
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE);
anchors.clear();
diff --git a/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceTest.java b/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceTest.java
new file mode 100644
index 00000000..eae6696a
--- /dev/null
+++ b/src/test/java/eu/webeid/ocsp/service/FallbackOcspServiceTest.java
@@ -0,0 +1,231 @@
+// SPDX-FileCopyrightText: Estonian Information System Authority
+// SPDX-License-Identifier: MIT
+
+package eu.webeid.ocsp.service;
+
+import eu.webeid.ocsp.exceptions.OCSPCertificateException;
+import eu.webeid.security.certificate.CertificateValidator;
+import eu.webeid.security.exceptions.CertificateExpiredException;
+import org.bouncycastle.cert.X509CertificateHolder;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.net.URI;
+import java.security.cert.CertStore;
+import java.security.cert.TrustAnchor;
+import java.security.cert.X509Certificate;
+import java.util.Date;
+import java.util.List;
+import java.util.Set;
+
+import static eu.webeid.ocsp.OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE;
+import static eu.webeid.ocsp.OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE;
+import static eu.webeid.security.testutil.Certificates.getJaakKristjanEsteid2018Cert;
+import static eu.webeid.security.testutil.Certificates.getDemoEsteidSk2018AiaOcspResponder;
+import static eu.webeid.security.testutil.Certificates.getTestEsteid2015CA;
+import static eu.webeid.security.testutil.Certificates.getTestEsteid2018CA;
+import static eu.webeid.security.testutil.Certificates.getTestSelfSignedOcspResponder;
+import static eu.webeid.security.testutil.Certificates.getTestSkOcspResponder2020;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class FallbackOcspServiceTest {
+
+ private static final URI PRIMARY_FALLBACK_URI = URI.create("http://primary-fallback.ocsp.test");
+ private static final URI SECONDARY_FALLBACK_URI = URI.create("http://secondary-fallback.ocsp.test");
+ private static final Date VALIDATION_DATE_WITHIN_RESPONDER_VALIDITY = new Date(1630000000000L);
+
+ private static Set trustedCaAnchors;
+ private static CertStore trustedCaCertStore;
+ private static X509Certificate aiaOcspResponderCert;
+ private static X509Certificate selfSignedOcspResponderCert;
+ private static X509Certificate ocspResponder2020Cert;
+ private static X509Certificate esteid2018CaCert;
+ private static X509Certificate nonSigningUserCert;
+
+ @BeforeAll
+ static void setUpFixtures() throws Exception {
+ List trustedCaCertificates = List.of(getTestEsteid2018CA(), getTestEsteid2015CA());
+ trustedCaAnchors = CertificateValidator.buildTrustAnchorsFromCertificates(trustedCaCertificates);
+ trustedCaCertStore = CertificateValidator.buildCertStoreFromCertificates(trustedCaCertificates);
+ // The DEMO AIA responder certificate satisfies the OCSP responder extension requirements
+ // (not a CA, Key Usage Digital Signature, no Certificate Signing, Extended Key Usage OCSP Signing)
+ // and it chains to the TEST of ESTEID2018 CA, which is one of the trusted CA anchors above.
+ aiaOcspResponderCert = getDemoEsteidSk2018AiaOcspResponder();
+ // The self-signed responder certificate satisfies the same extension requirements, but no
+ // trusted CA issued it. Use it when the PKIX trust check must be the failure.
+ selfSignedOcspResponderCert = getTestSelfSignedOcspResponder();
+ // TEST of SK OCSP RESPONDER 2020 carries no Key Usage extension at all. Use it only where the
+ // Key Usage check must reject the certificate.
+ ocspResponder2020Cert = getTestSkOcspResponder2020();
+ esteid2018CaCert = getTestEsteid2018CA();
+ nonSigningUserCert = getJaakKristjanEsteid2018Cert();
+ }
+
+ @Test
+ void whenConfigurationIsProvided_thenAccessorsReturnConfiguredValues() throws Exception {
+ FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration(
+ PRIMARY_FALLBACK_URI, aiaOcspResponderCert, true,
+ null, getTestEsteid2018CA(), trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE);
+
+ FallbackOcspService service = new FallbackOcspService(configuration);
+
+ assertThat(service.getAccessLocation()).isEqualTo(PRIMARY_FALLBACK_URI);
+ assertThat(service.doesSupportNonce()).isTrue();
+ assertThat(service.getNextFallback()).isNull();
+ }
+
+ @Test
+ void whenNextFallbackConfigurationProvided_thenChainIsBuiltRecursively() throws Exception {
+ FallbackOcspServiceConfiguration secondaryConfiguration = new FallbackOcspServiceConfiguration(
+ SECONDARY_FALLBACK_URI, null, false,
+ null, getTestEsteid2018CA(), trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE);
+ FallbackOcspServiceConfiguration primaryConfiguration = new FallbackOcspServiceConfiguration(
+ PRIMARY_FALLBACK_URI, null, true,
+ secondaryConfiguration, getTestEsteid2018CA(), trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE);
+
+ FallbackOcspService primary = new FallbackOcspService(primaryConfiguration);
+
+ FallbackOcspService secondary = primary.getNextFallback();
+ assertThat(secondary).isNotNull();
+ assertThat(secondary.getAccessLocation()).isEqualTo(SECONDARY_FALLBACK_URI);
+ assertThat(secondary.doesSupportNonce()).isFalse();
+ assertThat(secondary.getNextFallback()).isNull();
+ }
+
+ @Test
+ void whenResponderCertificateIsPinnedAndMatches_thenValidationSucceeds() throws Exception {
+ FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration(
+ PRIMARY_FALLBACK_URI, aiaOcspResponderCert, true,
+ null, getTestEsteid2018CA(), trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE);
+ FallbackOcspService service = new FallbackOcspService(configuration);
+ X509CertificateHolder matchingHolder = new X509CertificateHolder(aiaOcspResponderCert.getEncoded());
+
+ assertThatCode(() ->
+ service.validateResponderCertificate(matchingHolder, getTestEsteid2018CA(), VALIDATION_DATE_WITHIN_RESPONDER_VALIDITY))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void whenResponderCertificateIsPinnedAndDiffers_thenThrows() throws Exception {
+ FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration(
+ PRIMARY_FALLBACK_URI, aiaOcspResponderCert, true,
+ null, getTestEsteid2018CA(), trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE);
+ FallbackOcspService service = new FallbackOcspService(configuration);
+ X509CertificateHolder differentHolder = new X509CertificateHolder(esteid2018CaCert.getEncoded());
+
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() ->
+ service.validateResponderCertificate(differentHolder, getTestEsteid2018CA(), VALIDATION_DATE_WITHIN_RESPONDER_VALIDITY))
+ .withMessage("Responder certificate from the OCSP response is not equal to the configured fallback OCSP responder certificate");
+ }
+
+ @Test
+ void whenResponderCertificateIsNotPinnedButTrustedByCa_thenValidationSucceeds() throws Exception {
+ FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration(
+ PRIMARY_FALLBACK_URI, null, true,
+ null, getTestEsteid2018CA(), trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE);
+ FallbackOcspService service = new FallbackOcspService(configuration);
+ // The DEMO AIA responder certificate chains to the TEST of ESTEID2018 CA, which is a trusted anchor.
+ X509CertificateHolder responderHolder = new X509CertificateHolder(aiaOcspResponderCert.getEncoded());
+
+ assertThatCode(() ->
+ service.validateResponderCertificate(responderHolder, getTestEsteid2018CA(), VALIDATION_DATE_WITHIN_RESPONDER_VALIDITY))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void whenResponderCertificateIsNotPinnedAndLacksSigningExtension_thenThrows() throws Exception {
+ FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration(
+ PRIMARY_FALLBACK_URI, null, true,
+ null, getTestEsteid2018CA(), trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE);
+ FallbackOcspService service = new FallbackOcspService(configuration);
+ X509CertificateHolder nonSigningHolder = new X509CertificateHolder(nonSigningUserCert.getEncoded());
+
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() ->
+ service.validateResponderCertificate(nonSigningHolder, getTestEsteid2018CA(), VALIDATION_DATE_WITHIN_RESPONDER_VALIDITY))
+ .withMessageContaining("Extended Key Usage extension does not contain OCSP Signing, "
+ + "which is required for OCSP response signing");
+ }
+
+ @Test
+ void whenResponderCertificateLacksKeyUsageExtension_thenThrows() throws Exception {
+ // TEST of SK OCSP RESPONDER 2020 carries the OCSP-signing EKU, but it has no Key Usage extension.
+ // FallbackOcspService runs the certificate extension checks before the PKIX trust check, so the
+ // Key Usage check rejects this certificate whatever the configured trust anchors are.
+ FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration(
+ PRIMARY_FALLBACK_URI, null, true,
+ null, getTestEsteid2018CA(), trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE);
+ FallbackOcspService service = new FallbackOcspService(configuration);
+ X509CertificateHolder noKeyUsageHolder = new X509CertificateHolder(ocspResponder2020Cert.getEncoded());
+
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() ->
+ service.validateResponderCertificate(noKeyUsageHolder, getTestEsteid2018CA(), VALIDATION_DATE_WITHIN_RESPONDER_VALIDITY))
+ .withMessageContaining("does not contain the Key Usage extension required for OCSP response signing");
+ }
+
+ @Test
+ void whenResponderCertificateIsNotIssuedBySubjectIssuer_thenThrows() throws Exception {
+ FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration(
+ PRIMARY_FALLBACK_URI, null, true,
+ null, getTestEsteid2018CA(), trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE);
+ FallbackOcspService service = new FallbackOcspService(configuration);
+ // The self-signed responder certificate satisfies every OCSP responder extension requirement, but it was
+ // not issued by the subject certificate's issuer, so it is rejected before PKIX path building.
+ X509CertificateHolder untrustedButEkuValidHolder = new X509CertificateHolder(selfSignedOcspResponderCert.getEncoded());
+
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() ->
+ service.validateResponderCertificate(untrustedButEkuValidHolder, getTestEsteid2018CA(), VALIDATION_DATE_WITHIN_RESPONDER_VALIDITY))
+ .withMessage("Fallback OCSP responder is not issued by the subject certificate's issuer");
+ }
+
+ @Test
+ void whenResponderCertificateHolderConversionFails_thenThrows() throws Exception {
+ FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration(
+ PRIMARY_FALLBACK_URI, aiaOcspResponderCert, true,
+ null, getTestEsteid2018CA(), trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE);
+ FallbackOcspService service = new FallbackOcspService(configuration);
+ // Make JcaX509CertificateConverter.getCertificate(holder) fail: the converter calls holder.getEncoded()
+ // and wraps the resulting IOException into a CertificateException, which the service catches and rewraps.
+ X509CertificateHolder unconvertibleHolder = mock(X509CertificateHolder.class);
+ when(unconvertibleHolder.getEncoded()).thenThrow(new IOException("simulated encoding failure"));
+
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() ->
+ service.validateResponderCertificate(unconvertibleHolder, getTestEsteid2018CA(), VALIDATION_DATE_WITHIN_RESPONDER_VALIDITY))
+ .withMessage("Invalid responder certificate")
+ .withCauseInstanceOf(java.security.cert.CertificateException.class);
+ }
+
+ @Test
+ void whenResponderCertificateIsExpiredAtValidationDate_thenThrows() throws Exception {
+ FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration(
+ PRIMARY_FALLBACK_URI, aiaOcspResponderCert, true,
+ null, getTestEsteid2018CA(), trustedCaAnchors, trustedCaCertStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE);
+ FallbackOcspService service = new FallbackOcspService(configuration);
+ X509CertificateHolder responderHolder = new X509CertificateHolder(aiaOcspResponderCert.getEncoded());
+ Date farFuture = new Date(4102444800000L); // 2100-01-01
+
+ assertThatExceptionOfType(CertificateExpiredException.class)
+ .isThrownBy(() ->
+ service.validateResponderCertificate(responderHolder, getTestEsteid2018CA(), farFuture));
+ }
+}
diff --git a/src/test/java/eu/webeid/ocsp/service/OcspServiceConfigurationTest.java b/src/test/java/eu/webeid/ocsp/service/OcspServiceConfigurationTest.java
new file mode 100644
index 00000000..7470a4a5
--- /dev/null
+++ b/src/test/java/eu/webeid/ocsp/service/OcspServiceConfigurationTest.java
@@ -0,0 +1,136 @@
+// SPDX-FileCopyrightText: Estonian Information System Authority
+// SPDX-License-Identifier: MIT
+
+package eu.webeid.ocsp.service;
+
+import eu.webeid.ocsp.exceptions.OCSPCertificateException;
+import eu.webeid.security.certificate.CertificateValidator;
+import org.bouncycastle.asn1.x509.BasicConstraints;
+import org.bouncycastle.asn1.x509.ExtendedKeyUsage;
+import org.bouncycastle.asn1.x509.Extension;
+import org.bouncycastle.asn1.x509.KeyPurposeId;
+import org.bouncycastle.asn1.x509.KeyUsage;
+import org.junit.jupiter.api.Test;
+
+import java.net.URI;
+import java.security.cert.CertStore;
+import java.security.cert.TrustAnchor;
+import java.security.cert.X509Certificate;
+import java.util.List;
+import java.util.Set;
+
+import static eu.webeid.ocsp.OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE;
+import static eu.webeid.ocsp.OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE;
+import static eu.webeid.security.testutil.Certificates.getTestEsteid2018CA;
+import static eu.webeid.security.testutil.TestCertificateBuilder.buildCertificate;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+
+class OcspServiceConfigurationTest {
+
+ private static final URI OCSP_URL = URI.create("http://demo.sk.ee/ocsp");
+
+ @Test
+ void whenDesignatedOcspServiceConfigurationResponderCertIsCA_thenConstructorThrows() throws Exception {
+ final X509Certificate caResponder = buildCertificate(
+ new Extension(Extension.basicConstraints, true, new BasicConstraints(0).getEncoded()));
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() -> newDesignatedOcspServiceConfiguration(caResponder))
+ .withMessage("Certificate " + caResponder.getSubjectX500Principal() +
+ " must not be a CA certificate (Basic Constraints CA:TRUE is not allowed for OCSP responder)");
+ }
+
+ @Test
+ void whenDesignatedOcspServiceConfigurationResponderCertMissingKeyUsageDigitalSignature_thenConstructorThrows() throws Exception {
+ final X509Certificate responder = buildCertificate(
+ new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.nonRepudiation).getEncoded()));
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() -> newDesignatedOcspServiceConfiguration(responder))
+ .withMessage("Certificate " + responder.getSubjectX500Principal() +
+ " Key Usage extension does not contain Digital Signature, which is required for OCSP response signing");
+ }
+
+ @Test
+ void whenDesignatedOcspServiceConfigurationResponderCertHasKeyCertSign_thenConstructorThrows() throws Exception {
+ final X509Certificate responder = buildCertificate(
+ new Extension(Extension.keyUsage, true,
+ new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyCertSign).getEncoded()),
+ new Extension(Extension.extendedKeyUsage, true,
+ new ExtendedKeyUsage(KeyPurposeId.id_kp_OCSPSigning).getEncoded()));
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() -> newDesignatedOcspServiceConfiguration(responder))
+ .withMessage("Certificate " + responder.getSubjectX500Principal() +
+ " Key Usage extension contains Certificate Signing, which is not allowed for OCSP responder");
+ }
+
+ @Test
+ void whenDesignatedOcspServiceConfigurationResponderCertMissingOcspSigningEku_thenConstructorThrows() throws Exception {
+ final X509Certificate responder = buildCertificate(
+ new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature).getEncoded()),
+ new Extension(Extension.extendedKeyUsage, true,
+ new ExtendedKeyUsage(KeyPurposeId.id_kp_clientAuth).getEncoded()));
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() -> newDesignatedOcspServiceConfiguration(responder))
+ .withMessage("Certificate " + responder.getSubjectX500Principal() +
+ " Extended Key Usage extension does not contain OCSP Signing, which is required for OCSP response signing");
+ }
+
+ @Test
+ void whenFallbackOcspServiceConfigurationResponderCertIsCA_thenConstructorThrows() throws Exception {
+ final X509Certificate caResponder = buildCertificate(
+ new Extension(Extension.basicConstraints, true, new BasicConstraints(0).getEncoded()));
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() -> newFallbackOcspServiceConfiguration(caResponder))
+ .withMessage("Certificate " + caResponder.getSubjectX500Principal() +
+ " must not be a CA certificate (Basic Constraints CA:TRUE is not allowed for OCSP responder)");
+ }
+
+ @Test
+ void whenFallbackOcspServiceConfigurationResponderCertMissingKeyUsageDigitalSignature_thenConstructorThrows() throws Exception {
+ final X509Certificate responder = buildCertificate(
+ new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.nonRepudiation).getEncoded()));
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() -> newFallbackOcspServiceConfiguration(responder))
+ .withMessage("Certificate " + responder.getSubjectX500Principal() +
+ " Key Usage extension does not contain Digital Signature, which is required for OCSP response signing");
+ }
+
+ @Test
+ void whenFallbackOcspServiceConfigurationResponderCertHasKeyCertSign_thenConstructorThrows() throws Exception {
+ final X509Certificate responder = buildCertificate(
+ new Extension(Extension.keyUsage, true,
+ new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyCertSign).getEncoded()),
+ new Extension(Extension.extendedKeyUsage, true,
+ new ExtendedKeyUsage(KeyPurposeId.id_kp_OCSPSigning).getEncoded()));
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() -> newFallbackOcspServiceConfiguration(responder))
+ .withMessage("Certificate " + responder.getSubjectX500Principal() +
+ " Key Usage extension contains Certificate Signing, which is not allowed for OCSP responder");
+ }
+
+ @Test
+ void whenFallbackOcspServiceConfigurationResponderCertMissingOcspSigningEku_thenConstructorThrows() throws Exception {
+ final X509Certificate responder = buildCertificate(
+ new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature).getEncoded()),
+ new Extension(Extension.extendedKeyUsage, true,
+ new ExtendedKeyUsage(KeyPurposeId.id_kp_clientAuth).getEncoded()));
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() -> newFallbackOcspServiceConfiguration(responder))
+ .withMessage("Certificate " + responder.getSubjectX500Principal() +
+ " Extended Key Usage extension does not contain OCSP Signing, which is required for OCSP response signing");
+ }
+
+ private static void newDesignatedOcspServiceConfiguration(X509Certificate responder) throws Exception {
+ new DesignatedOcspServiceConfiguration(
+ OCSP_URL, responder, List.of(getTestEsteid2018CA()), true,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE);
+ }
+
+ private static void newFallbackOcspServiceConfiguration(X509Certificate responder) throws Exception {
+ final List trustedCAs = List.of(getTestEsteid2018CA());
+ final Set trustAnchors = CertificateValidator.buildTrustAnchorsFromCertificates(trustedCAs);
+ final CertStore certStore = CertificateValidator.buildCertStoreFromCertificates(trustedCAs);
+ new FallbackOcspServiceConfiguration(OCSP_URL, responder, true, null, null, trustAnchors, certStore,
+ DEFAULT_THIS_UPDATE_AGE, DEFAULT_NEXT_UPDATE_AGE);
+ }
+
+}
diff --git a/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java b/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java
index 57417604..46a4b2b5 100644
--- a/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java
+++ b/src/test/java/eu/webeid/ocsp/service/OcspServiceMaker.java
@@ -12,15 +12,19 @@
import java.net.URI;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
+import java.time.Duration;
import java.util.List;
import java.util.Set;
import static eu.webeid.security.testutil.Certificates.getTestEsteid2015CA;
import static eu.webeid.security.testutil.Certificates.getTestEsteid2018CA;
-import static eu.webeid.security.testutil.Certificates.getTestSkOcspResponder2020;
+import static eu.webeid.security.testutil.Certificates.getTestSelfSignedOcspResponder;
public class OcspServiceMaker {
+ public static final Duration MAX_THIS_UPDATE_AGE = Duration.ofMinutes(3);
+ public static final Duration MAX_NEXT_UPDATE_AGE = Duration.ofMinutes(20);
+
private static final String TEST_OCSP_ACCESS_LOCATION = "http://demo.sk.ee/ocsp";
private static final List TRUSTED_CA_CERTIFICATES;
private static final X500Name ISSUER_DN = new X500Name("CN=TEST of ESTEID-SK 2015, OID.2.5.4.97=NTREE-10747013, O=AS Sertifitseerimiskeskus, C=EE");
@@ -53,7 +57,9 @@ private static AiaOcspServiceConfiguration getAiaOcspServiceConfiguration() thro
return new AiaOcspServiceConfiguration(
Set.of(ISSUER_DN),
CertificateValidator.buildTrustAnchorsFromCertificates(TRUSTED_CA_CERTIFICATES),
- CertificateValidator.buildCertStoreFromCertificates(TRUSTED_CA_CERTIFICATES));
+ CertificateValidator.buildCertStoreFromCertificates(TRUSTED_CA_CERTIFICATES),
+ MAX_THIS_UPDATE_AGE,
+ MAX_NEXT_UPDATE_AGE);
}
public static DesignatedOcspServiceConfiguration getDesignatedOcspServiceConfiguration() throws CertificateException, IOException, OCSPCertificateException {
@@ -67,9 +73,11 @@ private static DesignatedOcspServiceConfiguration getDesignatedOcspServiceConfig
private static DesignatedOcspServiceConfiguration getDesignatedOcspServiceConfiguration(boolean doesSupportNonce, String ocspServiceAccessLocation) throws CertificateException, IOException, OCSPCertificateException {
return new DesignatedOcspServiceConfiguration(
URI.create(ocspServiceAccessLocation),
- getTestSkOcspResponder2020(),
+ getTestSelfSignedOcspResponder(),
TRUSTED_CA_CERTIFICATES,
- doesSupportNonce);
+ doesSupportNonce,
+ MAX_THIS_UPDATE_AGE,
+ MAX_NEXT_UPDATE_AGE);
}
}
diff --git a/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java b/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java
index e9be3911..d3126803 100644
--- a/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java
+++ b/src/test/java/eu/webeid/ocsp/service/OcspServiceProviderTest.java
@@ -3,26 +3,40 @@
package eu.webeid.ocsp.service;
+import eu.webeid.ocsp.OcspCertificateRevocationChecker;
+import eu.webeid.ocsp.exceptions.OCSPCertificateException;
+import eu.webeid.security.certificate.CertificateValidator;
+import org.bouncycastle.asn1.x509.BasicConstraints;
+import org.bouncycastle.asn1.x509.ExtendedKeyUsage;
+import org.bouncycastle.asn1.x509.Extension;
+import org.bouncycastle.asn1.x509.KeyPurposeId;
+import org.bouncycastle.asn1.x509.KeyUsage;
import org.bouncycastle.cert.X509CertificateHolder;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
-import eu.webeid.ocsp.exceptions.OCSPCertificateException;
-import eu.webeid.security.certificate.CertificateValidator;
import eu.webeid.security.testutil.LocalOcspResponder;
import java.net.URI;
+import java.security.cert.CertStore;
+import java.security.cert.TrustAnchor;
+import java.security.cert.X509Certificate;
+import java.time.Duration;
import java.util.Date;
import java.util.List;
+import java.util.Optional;
import java.util.Set;
import static eu.webeid.ocsp.service.OcspServiceMaker.getAiaOcspServiceProvider;
+import static eu.webeid.ocsp.service.OcspServiceMaker.getDesignatedOcspServiceConfiguration;
import static eu.webeid.ocsp.service.OcspServiceMaker.getDesignatedOcspServiceProvider;
+import static eu.webeid.security.testutil.Certificates.getDemoEsteidSk2018AiaOcspResponder;
import static eu.webeid.security.testutil.Certificates.getJaakKristjanEsteid2018Cert;
+import static eu.webeid.security.testutil.Certificates.getTestEsteid2018CA;
import static eu.webeid.security.testutil.Certificates.getMariliisEsteid2015Cert;
import static eu.webeid.security.testutil.Certificates.getTestEsteid2015CA;
-import static eu.webeid.security.testutil.Certificates.getTestEsteid2018CA;
-import static eu.webeid.security.testutil.Certificates.getTestSkOcspResponder2020;
+import static eu.webeid.security.testutil.Certificates.getTestSelfSignedOcspResponder;
+import static eu.webeid.security.testutil.TestCertificateBuilder.buildCertificate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
@@ -35,8 +49,10 @@ void whenDesignatedOcspServiceConfigurationProvided_thenCreatesDesignatedOcspSer
final OcspService service = ocspServiceProvider.getService(getJaakKristjanEsteid2018Cert(), getTestEsteid2018CA());
assertThat(service.getAccessLocation()).isEqualTo(new URI("http://demo.sk.ee/ocsp"));
assertThat(service.doesSupportNonce()).isTrue();
+ assertThat(service.getMaxThisUpdateAge()).isEqualTo(Duration.ofMinutes(3));
+ assertThat(service.getMaxNextUpdateAge()).isEqualTo(Duration.ofMinutes(20));
assertThatCode(() ->
- service.validateResponderCertificate(new X509CertificateHolder(getTestSkOcspResponder2020().getEncoded()), getTestEsteid2018CA(), new Date(1630000000000L)))
+ service.validateResponderCertificate(new X509CertificateHolder(getTestSelfSignedOcspResponder().getEncoded()), getTestEsteid2018CA(), new Date(1630000000000L)))
.doesNotThrowAnyException();
assertThatCode(() ->
service.validateResponderCertificate(new X509CertificateHolder(getTestEsteid2018CA().getEncoded()), getTestEsteid2018CA(), new Date(1630000000000L)))
@@ -54,6 +70,11 @@ void whenAiaOcspServiceConfigurationProvided_thenCreatesAiaOcspService() throws
final OcspService service2015 = ocspServiceProvider.getService(getMariliisEsteid2015Cert(), getTestEsteid2015CA());
assertThat(service2015.getAccessLocation()).isEqualTo(new URI("http://aia.demo.sk.ee/esteid2015"));
assertThat(service2015.doesSupportNonce()).isFalse();
+ assertThat(service2018.getMaxThisUpdateAge()).isEqualTo(Duration.ofMinutes(3));
+ assertThat(service2018.getMaxNextUpdateAge()).isEqualTo(Duration.ofMinutes(20));
+ assertThatCode(() ->
+ service2018.validateResponderCertificate(new X509CertificateHolder(getDemoEsteidSk2018AiaOcspResponder().getEncoded()), getTestEsteid2018CA(), new Date(1630000000000L)))
+ .doesNotThrowAnyException();
}
@Test
@@ -64,7 +85,7 @@ void whenAiaResponderCertificateLacksOcspSigningUsage_thenThrows() throws Except
assertThatExceptionOfType(OCSPCertificateException.class)
.isThrownBy(() ->
service2018.validateResponderCertificate(wrongResponderCert, getTestEsteid2018CA(), new Date(1630000000000L)))
- .withMessageContaining("does not contain the key usage extension for OCSP response signing");
+ .withMessageContaining("Key Usage extension does not contain Digital Signature, which is required for OCSP response signing");
}
@Test
@@ -75,10 +96,12 @@ void whenDifferentIssuersHaveSameName_thenDesignatedServiceAppliesOnlyToConfigur
second.start();
final var authorities = List.of(first.issuer(), second.issuer());
final var designated = new DesignatedOcspServiceConfiguration(
- first.designatedUri(), first.responderCertificate(), List.of(first.issuer()), true);
+ first.designatedUri(), first.responderCertificate(), List.of(first.issuer()), true,
+ OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE);
final var aia = new AiaOcspServiceConfiguration(Set.of(),
CertificateValidator.buildTrustAnchorsFromCertificates(authorities),
- CertificateValidator.buildCertStoreFromCertificates(authorities));
+ CertificateValidator.buildCertStoreFromCertificates(authorities),
+ OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE);
final var provider = new OcspServiceProvider(designated, aia);
assertThat(first.issuer().getSubjectX500Principal()).isEqualTo(second.issuer().getSubjectX500Principal());
@@ -97,7 +120,8 @@ void whenDifferentIssuersHaveSameName_thenFallbackServiceAppliesOnlyToConfigured
final var authorities = List.of(first.issuer(), second.issuer());
final var aia = new AiaOcspServiceConfiguration(Set.of(),
CertificateValidator.buildTrustAnchorsFromCertificates(authorities),
- CertificateValidator.buildCertStoreFromCertificates(authorities));
+ CertificateValidator.buildCertStoreFromCertificates(authorities),
+ OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE);
final var fallback = new FallbackOcspServiceConfiguration(
first.designatedUri(),
first.responderCertificate(),
@@ -105,7 +129,8 @@ void whenDifferentIssuersHaveSameName_thenFallbackServiceAppliesOnlyToConfigured
null,
first.issuer(),
CertificateValidator.buildTrustAnchorsFromCertificates(List.of(first.issuer())),
- CertificateValidator.buildCertStoreFromCertificates(List.of(first.issuer())));
+ CertificateValidator.buildCertStoreFromCertificates(List.of(first.issuer())),
+ OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE);
final var provider = new OcspServiceProvider(null, aia, List.of(fallback));
assertThat(first.issuer().getSubjectX500Principal()).isEqualTo(second.issuer().getSubjectX500Principal());
@@ -128,7 +153,8 @@ void whenFallbackResponderIsDelegatedByAnotherTrustedCa_thenThrows(boolean sameI
null,
responder.issuer(),
CertificateValidator.buildTrustAnchorsFromCertificates(List.of(responder.issuer(), responder.otherIssuer())),
- CertificateValidator.buildCertStoreFromCertificates(List.of(responder.issuer(), responder.otherIssuer())));
+ CertificateValidator.buildCertStoreFromCertificates(List.of(responder.issuer(), responder.otherIssuer())),
+ OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE);
final var service = new FallbackOcspService(configuration);
final var responderCertificate = new X509CertificateHolder(responder.responderCertificate().getEncoded());
@@ -151,7 +177,8 @@ void whenFallbackServiceIsUsedForDifferentIssuer_thenThrows() throws Exception {
null,
first.issuer(),
CertificateValidator.buildTrustAnchorsFromCertificates(List.of(first.issuer())),
- CertificateValidator.buildCertStoreFromCertificates(List.of(first.issuer())));
+ CertificateValidator.buildCertStoreFromCertificates(List.of(first.issuer())),
+ OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE, OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE);
final var service = new FallbackOcspService(configuration);
final var responderCertificate = new X509CertificateHolder(first.responderCertificate().getEncoded());
@@ -162,6 +189,249 @@ void whenFallbackServiceIsUsedForDifferentIssuer_thenThrows() throws Exception {
}
}
+ @Test
+ void whenAiaOcspResponderCertIsCA_thenThrows() throws Exception {
+ final OcspServiceProvider ocspServiceProvider = getAiaOcspServiceProvider();
+ final OcspService service2018 = ocspServiceProvider.getService(getJaakKristjanEsteid2018Cert(), getTestEsteid2018CA());
+ final X509Certificate caResponder = buildCertificate(
+ new Extension(Extension.basicConstraints, true, new BasicConstraints(0).getEncoded()));
+ final X509CertificateHolder caResponderCert = new X509CertificateHolder(caResponder.getEncoded());
+
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() ->
+ service2018.validateResponderCertificate(caResponderCert, getTestEsteid2018CA(), new Date()))
+ .withMessage("Certificate " + caResponder.getSubjectX500Principal() +
+ " must not be a CA certificate (Basic Constraints CA:TRUE is not allowed for OCSP responder)");
+ }
+
+ @Test
+ void whenAiaOcspResponderCertMissingKeyUsageDigitalSignature_thenThrows() throws Exception {
+ final OcspServiceProvider ocspServiceProvider = getAiaOcspServiceProvider();
+ final OcspService service2018 = ocspServiceProvider.getService(getJaakKristjanEsteid2018Cert(), getTestEsteid2018CA());
+ final X509Certificate responderWithoutDigitalSignature = buildCertificate(
+ new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.nonRepudiation).getEncoded()));
+ final X509CertificateHolder responderCert = new X509CertificateHolder(responderWithoutDigitalSignature.getEncoded());
+
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() ->
+ service2018.validateResponderCertificate(responderCert, getTestEsteid2018CA(), new Date()))
+ .withMessage("Certificate " + responderWithoutDigitalSignature.getSubjectX500Principal() +
+ " Key Usage extension does not contain Digital Signature, which is required for OCSP response signing");
+ }
+
+ @Test
+ void whenAiaOcspResponderCertMissingOcspSigningEku_thenThrows() throws Exception {
+ final OcspServiceProvider ocspServiceProvider = getAiaOcspServiceProvider();
+ final OcspService service2018 = ocspServiceProvider.getService(getJaakKristjanEsteid2018Cert(), getTestEsteid2018CA());
+ final X509Certificate responderWithoutOcspSigning = buildCertificate(
+ new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature).getEncoded()),
+ new Extension(Extension.extendedKeyUsage, true,
+ new ExtendedKeyUsage(KeyPurposeId.id_kp_clientAuth).getEncoded()));
+ final X509CertificateHolder responderCert = new X509CertificateHolder(responderWithoutOcspSigning.getEncoded());
+
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() ->
+ service2018.validateResponderCertificate(responderCert, getTestEsteid2018CA(), new Date()))
+ .withMessage("Certificate " + responderWithoutOcspSigning.getSubjectX500Principal() +
+ " Extended Key Usage extension does not contain OCSP Signing, which is required for OCSP response signing");
+ }
+
+ @Test
+ void whenFallbackOcspResponderCertIsCA_thenThrows() throws Exception {
+ final FallbackOcspService service = newFallbackOcspServiceWithoutPinnedResponder();
+ final X509Certificate caResponder = buildCertificate(
+ new Extension(Extension.basicConstraints, true, new BasicConstraints(0).getEncoded()));
+ final X509CertificateHolder caResponderCert = new X509CertificateHolder(caResponder.getEncoded());
+
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() ->
+ service.validateResponderCertificate(caResponderCert, getTestEsteid2018CA(), new Date()))
+ .withMessage("Certificate " + caResponder.getSubjectX500Principal() +
+ " must not be a CA certificate (Basic Constraints CA:TRUE is not allowed for OCSP responder)");
+ }
+
+ @Test
+ void whenFallbackOcspResponderCertMissingKeyUsageDigitalSignature_thenThrows() throws Exception {
+ final FallbackOcspService service = newFallbackOcspServiceWithoutPinnedResponder();
+ final X509Certificate responderWithoutDigitalSignature = buildCertificate(
+ new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.nonRepudiation).getEncoded()));
+ final X509CertificateHolder responderCert = new X509CertificateHolder(responderWithoutDigitalSignature.getEncoded());
+
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() ->
+ service.validateResponderCertificate(responderCert, getTestEsteid2018CA(), new Date()))
+ .withMessage("Certificate " + responderWithoutDigitalSignature.getSubjectX500Principal() +
+ " Key Usage extension does not contain Digital Signature, which is required for OCSP response signing");
+ }
+
+ @Test
+ void whenFallbackOcspResponderCertMissingOcspSigningEku_thenThrows() throws Exception {
+ final FallbackOcspService service = newFallbackOcspServiceWithoutPinnedResponder();
+ final X509Certificate responderWithoutOcspSigning = buildCertificate(
+ new Extension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature).getEncoded()),
+ new Extension(Extension.extendedKeyUsage, true,
+ new ExtendedKeyUsage(KeyPurposeId.id_kp_clientAuth).getEncoded()));
+ final X509CertificateHolder responderCert = new X509CertificateHolder(responderWithoutOcspSigning.getEncoded());
+
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() ->
+ service.validateResponderCertificate(responderCert, getTestEsteid2018CA(), new Date()))
+ .withMessage("Certificate " + responderWithoutOcspSigning.getSubjectX500Principal() +
+ " Extended Key Usage extension does not contain OCSP Signing, which is required for OCSP response signing");
+ }
+
+ private static FallbackOcspService newFallbackOcspServiceWithoutPinnedResponder() throws Exception {
+ final List trustedCAs = List.of(getTestEsteid2018CA());
+ final Set trustAnchors = CertificateValidator.buildTrustAnchorsFromCertificates(trustedCAs);
+ final CertStore certStore = CertificateValidator.buildCertStoreFromCertificates(trustedCAs);
+ final FallbackOcspServiceConfiguration configuration = new FallbackOcspServiceConfiguration(
+ URI.create("http://fallback.demo.sk.ee/ocsp"), null, true, null, getTestEsteid2018CA(), trustAnchors, certStore,
+ OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE,
+ OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE);
+ return new FallbackOcspService(configuration);
+ }
+
+ @Test
+ void whenFallbackOcspServiceConfigurationProvided_thenAiaServiceCarriesMatchingFallback() throws Exception {
+ X509Certificate userCert = getJaakKristjanEsteid2018Cert();
+ List trustedCertificates = List.of(getTestEsteid2018CA(), getTestEsteid2015CA());
+ Set trustedAnchors =
+ CertificateValidator.buildTrustAnchorsFromCertificates(trustedCertificates);
+ java.security.cert.CertStore trustedStore =
+ CertificateValidator.buildCertStoreFromCertificates(trustedCertificates);
+ URI fallbackUri = URI.create("http://fallback.test/ocsp");
+ FallbackOcspServiceConfiguration fallbackConfiguration = new FallbackOcspServiceConfiguration(
+ fallbackUri, getDemoEsteidSk2018AiaOcspResponder(), true,
+ null, getTestEsteid2018CA(), trustedAnchors, trustedStore,
+ OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE,
+ OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE);
+
+ OcspServiceProvider provider = new OcspServiceProvider(null, getAiaOcspServiceProvider2018Configuration(),
+ List.of(fallbackConfiguration));
+ OcspService service = provider.getService(userCert, getTestEsteid2018CA());
+
+ assertThat(service).isInstanceOf(AiaOcspService.class);
+ Optional fallbackOpt = service.getFallbackService();
+ assertThat(fallbackOpt).isPresent();
+ FallbackOcspService fallback = fallbackOpt.get();
+ assertThat(fallback.getAccessLocation()).isEqualTo(fallbackUri);
+ assertThat(fallback.doesSupportNonce()).isTrue();
+ Date validationDate = new Date(1630000000000L);
+ assertThatCode(() ->
+ fallback.validateResponderCertificate(new X509CertificateHolder(getDemoEsteidSk2018AiaOcspResponder().getEncoded()), getTestEsteid2018CA(), validationDate))
+ .doesNotThrowAnyException();
+ assertThatExceptionOfType(OCSPCertificateException.class)
+ .isThrownBy(() ->
+ fallback.validateResponderCertificate(new X509CertificateHolder(getTestEsteid2018CA().getEncoded()), getTestEsteid2018CA(), validationDate))
+ .withMessage("Responder certificate from the OCSP response is not equal to the configured fallback OCSP responder certificate");
+ }
+
+ @Test
+ void whenFallbackOcspServiceConfigurationDoesNotMatchIssuer_thenAiaServiceCarriesNoFallback() throws Exception {
+ X509Certificate userCert = getJaakKristjanEsteid2018Cert();
+ List trustedCertificates = List.of(getTestEsteid2018CA(), getTestEsteid2015CA());
+ Set trustedAnchors =
+ CertificateValidator.buildTrustAnchorsFromCertificates(trustedCertificates);
+ java.security.cert.CertStore trustedStore =
+ CertificateValidator.buildCertStoreFromCertificates(trustedCertificates);
+ FallbackOcspServiceConfiguration fallbackConfiguration = new FallbackOcspServiceConfiguration(
+ URI.create("http://fallback.test/ocsp"), null, true,
+ null, getTestEsteid2015CA(), trustedAnchors, trustedStore,
+ OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE,
+ OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE);
+
+ OcspServiceProvider provider = new OcspServiceProvider(null, getAiaOcspServiceProvider2018Configuration(),
+ List.of(fallbackConfiguration));
+ OcspService service = provider.getService(userCert, getTestEsteid2018CA());
+
+ assertThat(service.getFallbackService()).isEmpty();
+ }
+
+ @Test
+ void whenFallbackConfigurationsIsNull_thenServiceHasNoFallback() throws Exception {
+ X509Certificate userCert = getJaakKristjanEsteid2018Cert();
+ OcspServiceProvider provider = new OcspServiceProvider(null, getAiaOcspServiceProvider2018Configuration(), null);
+
+ OcspService service = provider.getService(userCert, getTestEsteid2018CA());
+
+ assertThat(service).isInstanceOf(AiaOcspService.class);
+ assertThat(service.getAccessLocation()).isEqualTo(new URI("http://aia.demo.sk.ee/esteid2018"));
+ assertThat(service.getFallbackService()).isEmpty();
+ }
+
+ @Test
+ void whenFallbackConfigurationsIsEmpty_thenServiceHasNoFallback() throws Exception {
+ X509Certificate userCert = getJaakKristjanEsteid2018Cert();
+ OcspServiceProvider provider = new OcspServiceProvider(null, getAiaOcspServiceProvider2018Configuration(), List.of());
+
+ OcspService service = provider.getService(userCert, getTestEsteid2018CA());
+
+ assertThat(service).isInstanceOf(AiaOcspService.class);
+ assertThat(service.getFallbackService()).isEmpty();
+ }
+
+ @Test
+ void whenDesignatedServiceSupportsIssuer_thenDesignatedTakesPrecedenceOverFallback() throws Exception {
+ X509Certificate userCert = getJaakKristjanEsteid2018Cert();
+ List trustedCertificates = List.of(getTestEsteid2018CA(), getTestEsteid2015CA());
+ Set trustedAnchors =
+ CertificateValidator.buildTrustAnchorsFromCertificates(trustedCertificates);
+ java.security.cert.CertStore trustedStore =
+ CertificateValidator.buildCertStoreFromCertificates(trustedCertificates);
+ FallbackOcspServiceConfiguration fallbackConfiguration = new FallbackOcspServiceConfiguration(
+ URI.create("http://fallback.test/ocsp"), getDemoEsteidSk2018AiaOcspResponder(), true,
+ null, getTestEsteid2018CA(), trustedAnchors, trustedStore,
+ OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE,
+ OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE);
+
+ OcspServiceProvider provider = new OcspServiceProvider(getDesignatedOcspServiceConfiguration(),
+ getAiaOcspServiceProvider2018Configuration(), List.of(fallbackConfiguration));
+ OcspService service = provider.getService(userCert, getTestEsteid2018CA());
+
+ assertThat(service).isInstanceOf(DesignatedOcspService.class);
+ assertThat(service.getAccessLocation()).isEqualTo(new URI("http://demo.sk.ee/ocsp"));
+ assertThat(service.getFallbackService()).isEmpty();
+ }
+
+ @Test
+ void whenDuplicateIssuerFallbackConfigurations_thenLastOneWins() throws Exception {
+ X509Certificate userCert = getJaakKristjanEsteid2018Cert();
+ List trustedCertificates = List.of(getTestEsteid2018CA(), getTestEsteid2015CA());
+ Set trustedAnchors =
+ CertificateValidator.buildTrustAnchorsFromCertificates(trustedCertificates);
+ java.security.cert.CertStore trustedStore =
+ CertificateValidator.buildCertStoreFromCertificates(trustedCertificates);
+ URI firstFallbackUri = URI.create("http://fallback-first.test/ocsp");
+ URI lastFallbackUri = URI.create("http://fallback-last.test/ocsp");
+ FallbackOcspServiceConfiguration firstConfiguration = new FallbackOcspServiceConfiguration(
+ firstFallbackUri, getDemoEsteidSk2018AiaOcspResponder(), true,
+ null, getTestEsteid2018CA(), trustedAnchors, trustedStore,
+ OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE,
+ OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE);
+ FallbackOcspServiceConfiguration lastConfiguration = new FallbackOcspServiceConfiguration(
+ lastFallbackUri, getDemoEsteidSk2018AiaOcspResponder(), true,
+ null, getTestEsteid2018CA(), trustedAnchors, trustedStore,
+ OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE,
+ OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE);
+
+ OcspServiceProvider provider = new OcspServiceProvider(null, getAiaOcspServiceProvider2018Configuration(),
+ List.of(firstConfiguration, lastConfiguration));
+ OcspService service = provider.getService(userCert, getTestEsteid2018CA());
+
+ Optional fallbackOpt = service.getFallbackService();
+ assertThat(fallbackOpt).isPresent();
+ assertThat(fallbackOpt.get().getAccessLocation()).isEqualTo(lastFallbackUri);
+ }
+
+ private static AiaOcspServiceConfiguration getAiaOcspServiceProvider2018Configuration() throws Exception {
+ List trustedCertificates = List.of(getTestEsteid2018CA(), getTestEsteid2015CA());
+ return new AiaOcspServiceConfiguration(
+ Set.of(),
+ CertificateValidator.buildTrustAnchorsFromCertificates(trustedCertificates),
+ CertificateValidator.buildCertStoreFromCertificates(trustedCertificates),
+ OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE,
+ OcspCertificateRevocationChecker.DEFAULT_NEXT_UPDATE_AGE);
+ }
}
// Old disabled example AuthTokenValidator test with designated OCSP check.
diff --git a/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java b/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java
index e6fe74d1..7743e32e 100644
--- a/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java
+++ b/src/test/java/eu/webeid/resilientocsp/ResilientOcspCertificateRevocationCheckerTest.java
@@ -6,16 +6,22 @@
import eu.webeid.ocsp.OcspCertificateRevocationChecker;
import eu.webeid.ocsp.client.OcspClient;
import eu.webeid.ocsp.exceptions.OCSPClientException;
+import eu.webeid.ocsp.exceptions.UserCertificateOCSPException;
+import eu.webeid.ocsp.service.FallbackOcspService;
import eu.webeid.ocsp.service.OcspService;
+import eu.webeid.security.util.DateAndTime;
import eu.webeid.ocsp.service.OcspServiceProvider;
+import eu.webeid.resilientocsp.ResilientOcspCertificateRevocationChecker.CircuitBreakerStatistics;
import eu.webeid.resilientocsp.exceptions.ResilientUserCertificateOCSPCheckFailedException;
import eu.webeid.resilientocsp.exceptions.ResilientUserCertificateRevokedException;
-import eu.webeid.ocsp.service.FallbackOcspService;
import eu.webeid.security.authtoken.WebEidAuthToken;
+import eu.webeid.security.util.DateAndTime;
import eu.webeid.security.validator.AuthTokenValidator;
import eu.webeid.security.validator.revocationcheck.RevocationInfo;
+import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import io.github.resilience4j.retry.RetryConfig;
+import org.bouncycastle.asn1.ocsp.OCSPResponseStatus;
import org.bouncycastle.cert.ocsp.BasicOCSPResp;
import org.bouncycastle.cert.ocsp.CertificateStatus;
import org.bouncycastle.cert.ocsp.OCSPResp;
@@ -23,50 +29,75 @@
import org.bouncycastle.cert.ocsp.SingleResp;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
import java.net.URI;
+import java.security.cert.CertificateEncodingException;
import java.security.cert.X509Certificate;
import java.time.Duration;
+import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
-import static eu.webeid.ocsp.OcspCertificateRevocationCheckerTest.getOcspResponseBytesFromResources;
import static eu.webeid.security.testutil.AbstractTestWithValidator.VALID_AUTH_TOKEN;
import static eu.webeid.security.testutil.AbstractTestWithValidator.VALID_CHALLENGE_NONCE;
import static eu.webeid.security.testutil.AuthTokenValidators.getDefaultAuthTokenValidatorBuilder;
import static eu.webeid.security.testutil.Certificates.getJaakKristjanEsteid2018Cert;
import static eu.webeid.security.testutil.Certificates.getTestEsteid2018CA;
+import static eu.webeid.security.testutil.DateMocker.mockDate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
-import static org.junit.jupiter.api.Assertions.assertThrows;
+import static eu.webeid.security.testutil.ResourceUtil.bytesFromResource;
+import static org.awaitility.Awaitility.await;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
-public class ResilientOcspCertificateRevocationCheckerTest {
+class ResilientOcspCertificateRevocationCheckerTest {
private static final URI PRIMARY_URI = URI.create("http://primary.ocsp.test");
private static final URI FALLBACK_URI = URI.create("http://fallback.ocsp.test");
private static final URI SECOND_FALLBACK_URI = URI.create("http://second-fallback.ocsp.test");
private static final Duration LONG_THIS_UPDATE_AGE = Duration.ofDays(365 * 10);
+ private static final Duration LONG_NEXT_UPDATE_AGE = Duration.ofDays(365 * 10);
+
+ // The OCSP DER fixtures do not share one thisUpdate. Each fixture carries its own:
+ // ocsp_response.der 2021-09-17T18:25:24
+ // ocsp_response_revoked.der 2021-09-18T00:13:43
+ // ocsp_response_unknown.der 2021-09-18T00:16:25
+ // The two constants below belong to ocsp_response.der, and every test that uses them pairs them with
+ // that fixture. Do not pair them with the other two fixtures: a clock set from these values is earlier
+ // than their thisUpdate by more than the allowed time skew, so the library rejects the response as
+ // issued too far in the future and the test fails. The unknown-status tests use
+ // WITHIN_RESPONDER_CERT_VALIDITY instead, and the revoked-status tests do not mock the clock at all.
+ private static final String DER_THIS_UPDATE = "2021-09-17T18:25:24";
+ private static final String FIVE_MIN_AFTER_THIS_UPDATE = "2021-09-17T18:30:24";
+ // Used by the unknown-status tests, where the age limit is the relaxed LONG_THIS_UPDATE_AGE, so only the
+ // OCSP responder certificate validity window matters (this value sits within it).
+ private static final String WITHIN_RESPONDER_CERT_VALIDITY = "2021-09-18T00:16:25";
private X509Certificate estEid2018Cert;
private X509Certificate testEsteid2018CA;
private OCSPResp ocspRespGood;
private OCSPResp ocspRespRevoked;
+ private OCSPResp ocspRespUnknown;
@BeforeEach
void setUp() throws Exception {
estEid2018Cert = getJaakKristjanEsteid2018Cert();
testEsteid2018CA = getTestEsteid2018CA();
- ocspRespGood = new OCSPResp(getOcspResponseBytesFromResources("ocsp_response.der"));
- ocspRespRevoked = new OCSPResp(getOcspResponseBytesFromResources("ocsp_response_revoked.der"));
+ ocspRespGood = new OCSPResp(bytesFromResource("ocsp_response.der"));
+ ocspRespRevoked = new OCSPResp(bytesFromResource("ocsp_response_revoked.der"));
+ ocspRespUnknown = new OCSPResp(bytesFromResource("ocsp_response_unknown.der"));
}
@Test
@@ -81,29 +112,31 @@ void whenMultipleValidationCalls_thenPreviousResultsAreNotModified() throws Exce
when(ocspClient.request(eq(SECOND_FALLBACK_URI), any()))
.thenThrow(new OCSPClientException("Secondary fallback OCSP service unavailable (call1)"))
.thenThrow(new OCSPClientException("Secondary fallback OCSP service unavailable (call2)"));
- ResilientOcspCertificateRevocationChecker resilientChecker = buildChecker(ocspClient, null);
+ ResilientOcspCertificateRevocationChecker resilientChecker = checkerBuilder(ocspClient).build();
AuthTokenValidator validator = getDefaultAuthTokenValidatorBuilder()
.withCertificateRevocationChecker(resilientChecker)
.build();
WebEidAuthToken authToken = validator.parse(VALID_AUTH_TOKEN);
- ResilientUserCertificateOCSPCheckFailedException ex1 = assertThrows(ResilientUserCertificateOCSPCheckFailedException.class,
- () -> validator.validate(authToken, VALID_CHALLENGE_NONCE));
+ ResilientUserCertificateOCSPCheckFailedException ex1 = assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() -> validator.validate(authToken, VALID_CHALLENGE_NONCE))
+ .actual();
List revocationInfo1 = ex1.getValidationInfo().revocationInfoList();
assertThat(revocationInfo1).hasSize(3);
assertThat(revocationInfo1)
- .extracting(ri -> ((OCSPClientException) ri.ocspResponseAttributes().get("OCSP_ERROR")).getMessage())
+ .extracting(ri -> ((OCSPClientException) ri.ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR)).getMessage())
.containsExactly(
"Primary OCSP service unavailable (call1)",
"Fallback OCSP service unavailable (call1)",
"Secondary fallback OCSP service unavailable (call1)"
);
- ResilientUserCertificateOCSPCheckFailedException ex2 = assertThrows(ResilientUserCertificateOCSPCheckFailedException.class,
- () -> validator.validate(authToken, VALID_CHALLENGE_NONCE));
+ ResilientUserCertificateOCSPCheckFailedException ex2 = assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() -> validator.validate(authToken, VALID_CHALLENGE_NONCE))
+ .actual();
List revocationInfo2 = ex2.getValidationInfo().revocationInfoList();
assertThat(revocationInfo2).hasSize(3);
assertThat(revocationInfo2)
- .extracting(ri -> ((OCSPClientException) ri.ocspResponseAttributes().get("OCSP_ERROR")).getMessage())
+ .extracting(ri -> ((OCSPClientException) ri.ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR)).getMessage())
.containsExactly(
"Primary OCSP service unavailable (call2)",
"Fallback OCSP service unavailable (call2)",
@@ -111,7 +144,7 @@ void whenMultipleValidationCalls_thenPreviousResultsAreNotModified() throws Exce
);
assertThat(revocationInfo1).hasSize(3);
assertThat(revocationInfo1)
- .extracting(ri -> ((OCSPClientException) ri.ocspResponseAttributes().get("OCSP_ERROR")).getMessage())
+ .extracting(ri -> ((OCSPClientException) ri.ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR)).getMessage())
.containsExactly(
"Primary OCSP service unavailable (call1)",
"Fallback OCSP service unavailable (call1)",
@@ -120,245 +153,953 @@ void whenMultipleValidationCalls_thenPreviousResultsAreNotModified() throws Exce
}
@Test
- void whenFirstFallbackReturnsRevoked_thenRevocationPropagatesWithoutSecondFallback() throws Exception {
+ void whenMaxAttemptsIsTwoAndAllCallsFail_thenRevocationInfoListRecordsRetriedPrimaryThenBothFallbacks() throws Exception {
+ // The Retry decorator wraps only the primary supplier, so maxAttempts(2) records two primary attempts
+ // before the two fallbacks. Asserting the responder order (primary, primary, fallback, second fallback)
+ // and the two distinct primary error messages proves the fourth element comes from the retried primary,
+ // not just that the list happens to have four elements.
OcspClient ocspClient = mock(OcspClient.class);
when(ocspClient.request(eq(PRIMARY_URI), any()))
- .thenThrow(new OCSPClientException("Primary OCSP service unavailable"));
+ .thenThrow(new OCSPClientException("primary attempt 1"))
+ .thenThrow(new OCSPClientException("primary attempt 2"));
when(ocspClient.request(eq(FALLBACK_URI), any()))
- .thenReturn(ocspRespRevoked);
+ .thenThrow(new OCSPClientException());
when(ocspClient.request(eq(SECOND_FALLBACK_URI), any()))
+ .thenThrow(new OCSPClientException());
+
+ RetryConfig retryConfig = RetryConfig.custom()
+ .maxAttempts(2)
+ .build();
+
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withRetryConfig(retryConfig).build();
+ assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .satisfies(ex -> {
+ List revocationInfoList = ex.getValidationInfo().revocationInfoList();
+ assertThat(revocationInfoList).hasSize(4);
+ assertThat(revocationInfoList).extracting(RevocationInfo::ocspResponderUri)
+ .containsExactly(PRIMARY_URI, PRIMARY_URI, FALLBACK_URI, SECOND_FALLBACK_URI);
+ assertThat(((OCSPClientException) revocationInfoList.get(0).ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR)).getMessage())
+ .isEqualTo("primary attempt 1");
+ assertThat(((OCSPClientException) revocationInfoList.get(1).ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR)).getMessage())
+ .isEqualTo("primary attempt 2");
+ });
+ }
+
+ @Test
+ void whenMaxAttemptsIsTwoAndFirstCallFails_thenTwoCallsToPrimaryShouldBeRecorded() throws Exception {
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any()))
+ .thenThrow(new OCSPClientException("Primary OCSP service unavailable (call1)"))
.thenReturn(ocspRespGood);
- ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, null);
+ RetryConfig retryConfig = RetryConfig.custom()
+ .maxAttempts(2)
+ .build();
+
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withRetryConfig(retryConfig).build();
+ List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+ assertThat(revocationInfoList.size()).isEqualTo(2);
+
+ Map firstResponseAttributes = revocationInfoList.get(0).ocspResponseAttributes();
+ OCSPClientException ex1 = (OCSPClientException) firstResponseAttributes.get(RevocationInfo.KEY_OCSP_ERROR);
+ assertThat(ex1.getMessage()).isEqualTo("Primary OCSP service unavailable (call1)");
+
+ assertThat(getCertificateStatus(revocationInfoList.get(1))).isEqualTo(CertificateStatus.GOOD);
+ }
+ @Test
+ void whenPrimaryReturnsRevoked_thenRevocationInfoListShouldHaveOneElementAndItShouldHaveRevokedStatus() throws Exception {
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any()))
+ .thenReturn(ocspRespRevoked);
+
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build();
assertThatExceptionOfType(ResilientUserCertificateRevokedException.class)
.isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
- .withMessage("User certificate has been revoked");
-
- verify(ocspClient, never()).request(eq(SECOND_FALLBACK_URI), any());
+ .satisfies(ex -> {
+ List revocationInfoList = ex.getValidationInfo().revocationInfoList();
+ assertThat(revocationInfoList.size()).isEqualTo(1);
+ assertThat(getCertificateStatus(revocationInfoList.get(0))).isInstanceOf(RevokedStatus.class);
+ });
}
@Test
- void whenMaxAttemptsIsOneAndAllCallsFail_thenRevocationInfoListShouldHaveThreeElements() throws Exception {
+ void whenCallerConfigRecordsAllExceptions_thenRevokedVerdictDoesNotOpenCircuitBreaker() throws Exception {
+ // A revoked verdict is a definitive OCSP answer, so it must never count as a circuit breaker failure.
+ // CircuitBreakerConfig.from() copies both the record predicate and the recordExceptions array of the
+ // caller configuration, and the array is combined with our own predicate by OR. A caller that records
+ // every exception must therefore not be able to make a revoked verdict open the circuit breaker.
OcspClient ocspClient = mock(OcspClient.class);
when(ocspClient.request(eq(PRIMARY_URI), any()))
- .thenThrow(new OCSPClientException());
+ .thenReturn(ocspRespRevoked);
when(ocspClient.request(eq(FALLBACK_URI), any()))
- .thenThrow(new OCSPClientException());
- when(ocspClient.request(eq(SECOND_FALLBACK_URI), any()))
- .thenThrow(new OCSPClientException());
+ .thenReturn(ocspRespGood);
+ CircuitBreakerConfig callerConfig = CircuitBreakerConfig.custom()
+ .recordExceptions(Throwable.class)
+ .slidingWindowSize(2)
+ .minimumNumberOfCalls(2)
+ .failureRateThreshold(50)
+ .permittedNumberOfCallsInHalfOpenState(1)
+ .build();
- RetryConfig retryConfig = RetryConfig.custom()
- .maxAttempts(1)
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient)
+ .withFallbacks(FALLBACK_URI)
+ .withCircuitBreakerConfig(callerConfig)
.build();
- ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, retryConfig);
- ResilientUserCertificateOCSPCheckFailedException ex = assertThrows(ResilientUserCertificateOCSPCheckFailedException.class, () -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA));
- assertThat(ex.getValidationInfo().revocationInfoList().size()).isEqualTo(3);
+ // The configuration above would open the circuit breaker after two recorded failures. All three calls
+ // must still report revocation from the primary service, and no call must reach the fallback service.
+ for (int i = 0; i < 3; i++) {
+ assertThatExceptionOfType(ResilientUserCertificateRevokedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA));
+ }
+ verify(ocspClient, times(3)).request(eq(PRIMARY_URI), any());
+ verify(ocspClient, never()).request(eq(FALLBACK_URI), any());
}
@Test
- void whenMaxAttemptsIsTwoAndAllCallsFail_thenRevocationInfoListShouldHaveFourElements() throws Exception {
+ void whenOneFallbackIsConfiguredAndPrimaryAndFallbackFail_thenRevocationInfoListShouldHaveTwoElements() throws Exception {
OcspClient ocspClient = mock(OcspClient.class);
when(ocspClient.request(eq(PRIMARY_URI), any()))
.thenThrow(new OCSPClientException());
when(ocspClient.request(eq(FALLBACK_URI), any()))
.thenThrow(new OCSPClientException());
- when(ocspClient.request(eq(SECOND_FALLBACK_URI), any()))
+
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withFallbacks(FALLBACK_URI).build();
+
+ assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .satisfies(ex -> {
+ List revocationInfoList = ex.getValidationInfo().revocationInfoList();
+ assertThat(revocationInfoList.size()).isEqualTo(2);
+ });
+ }
+
+ @Test
+ void whenNoFallbacksAreConfigured_thenRevocationInfoListShouldHaveOneElement() throws Exception {
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any()))
.thenThrow(new OCSPClientException());
- RetryConfig retryConfig = RetryConfig.custom()
- .maxAttempts(2)
- .build();
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withoutFallbacks().build();
- ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, retryConfig);
- ResilientUserCertificateOCSPCheckFailedException ex = assertThrows(ResilientUserCertificateOCSPCheckFailedException.class, () -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA));
- assertThat(ex.getValidationInfo().revocationInfoList().size()).isEqualTo(4);
+ assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .satisfies(ex -> {
+ List revocationInfoList = ex.getValidationInfo().revocationInfoList();
+ assertThat(revocationInfoList.size()).isEqualTo(1);
+ });
}
@Test
- void whenMaxAttemptsIsTwoAndFirstCallFails_thenTwoCallsToPrimaryShouldBeRecorded() throws Exception {
+ void whenPrimaryReturnsUnauthorizedOcspResponseStatus_thenWrapsResponseStatusError() throws Exception {
+ OCSPResp ocspRespStatusUnauthorized = new OCSPResp(bytesFromResource("ocsp_response_unauthorized.der"));
+
OcspClient ocspClient = mock(OcspClient.class);
when(ocspClient.request(eq(PRIMARY_URI), any()))
- .thenThrow(new OCSPClientException("Primary OCSP service unavailable (call1)"))
- .thenReturn(ocspRespGood);
- when(ocspClient.request(eq(FALLBACK_URI), any()))
- .thenReturn(ocspRespRevoked);
- when(ocspClient.request(eq(SECOND_FALLBACK_URI), any()))
- .thenReturn(ocspRespRevoked);
+ .thenReturn(ocspRespStatusUnauthorized);
- RetryConfig retryConfig = RetryConfig.custom()
- .maxAttempts(2)
- .build();
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build();
+ assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .satisfies(ex -> {
+ Map responseAttributes = ex.getValidationInfo().revocationInfoList().get(0).ocspResponseAttributes();
+ ResilientUserCertificateOCSPCheckFailedException firstException = (ResilientUserCertificateOCSPCheckFailedException) responseAttributes.get(RevocationInfo.KEY_OCSP_ERROR);
+ assertThat(firstException.getMessage()).isEqualTo("Response status: unauthorized");
+ });
+ }
+
+ @Test
+ void whenCertificateIdComputationFails_thenThrows() throws Exception {
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(mock(OcspClient.class)).build();
+ X509Certificate badIssuer = mock(X509Certificate.class);
+ CertificateEncodingException encodingException = new CertificateEncodingException("bad issuer");
+ when(badIssuer.getEncoded()).thenThrow(encodingException);
+
+ assertThatExceptionOfType(UserCertificateOCSPException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, badIssuer))
+ .isExactlyInstanceOf(UserCertificateOCSPException.class)
+ .withMessage("Unable to compute certificateId for subject certificate")
+ .withCause(encodingException);
+ }
+
+ @Test
+ void whenNoFallbackConfiguredAndPrimarySucceeds_thenPrimaryResponseIsReturned() throws Exception {
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespGood);
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withoutFallbacks().build();
- ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, retryConfig);
List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
- assertThat(revocationInfoList.size()).isEqualTo(2);
- Map firstResponseAttributes = revocationInfoList.get(0).ocspResponseAttributes();
- OCSPClientException ex1 = (OCSPClientException) firstResponseAttributes.get("OCSP_ERROR");
- assertThat(ex1.getMessage()).isEqualTo("Primary OCSP service unavailable (call1)");
+ assertThat(revocationInfoList).hasSize(1);
+ assertThat(revocationInfoList.get(0).ocspResponderUri()).isEqualTo(PRIMARY_URI);
+ assertThat(getCertificateStatus(revocationInfoList.get(0))).isEqualTo(CertificateStatus.GOOD);
+ }
- Map secondResponseAttributes = revocationInfoList.get(1).ocspResponseAttributes();
- OCSPResp ocspResp = (OCSPResp) secondResponseAttributes.get("OCSP_RESPONSE");
- final BasicOCSPResp basicResponse = (BasicOCSPResp) ocspResp.getResponseObject();
- final SingleResp certStatusResponse = basicResponse.getResponses()[0];
- assertThat(certStatusResponse.getCertStatus()).isEqualTo(org.bouncycastle.cert.ocsp.CertificateStatus.GOOD);
+ @Test
+ void whenPrimaryReturnsRevoked_thenNotRetried() throws Exception {
+ // A revoked verdict is a definitive answer; the Retry config ignores
+ // ResilientUserCertificateRevokedException, so the primary is queried exactly once even with maxAttempts(2).
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespRevoked);
+ RetryConfig retryConfig = RetryConfig.custom().maxAttempts(2).waitDuration(Duration.ZERO).build();
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withRetryConfig(retryConfig).build();
+
+ assertThatExceptionOfType(ResilientUserCertificateRevokedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA));
+ verify(ocspClient, times(1)).request(eq(PRIMARY_URI), any());
+ verify(ocspClient, never()).request(eq(FALLBACK_URI), any());
}
@Test
- void whenFirstCallSucceeds_thenRevocationInfoListShouldHaveOneElementAndItShouldHaveGoodStatus() throws Exception {
+ void whenPrimaryReturnsRevoked_thenCircuitBreakerDoesNotOpen() throws Exception {
+ // The CircuitBreaker config ignores ResilientUserCertificateRevokedException, so repeated revoked
+ // verdicts are not counted as failures and the breaker stays closed. With a config that would trip
+ // after two real failures, the primary is still queried on the third call.
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespRevoked);
+ CircuitBreakerConfig tightCircuitBreakerConfig = CircuitBreakerConfig.custom()
+ .slidingWindowSize(2)
+ .minimumNumberOfCalls(2)
+ .failureRateThreshold(50)
+ .permittedNumberOfCallsInHalfOpenState(1)
+ .build();
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient)
+ .withFallbacks(FALLBACK_URI)
+ .withCircuitBreakerConfig(tightCircuitBreakerConfig)
+ .build();
+
+ assertThatExceptionOfType(ResilientUserCertificateRevokedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA));
+ assertThatExceptionOfType(ResilientUserCertificateRevokedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA));
+ assertThatExceptionOfType(ResilientUserCertificateRevokedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA));
+
+ verify(ocspClient, times(3)).request(eq(PRIMARY_URI), any());
+ verify(ocspClient, never()).request(eq(FALLBACK_URI), any());
+ }
+
+ @Test
+ void whenPrimaryFailsAndFirstFallbackReturnsRevoked_thenListContainsBothEntries() throws Exception {
OcspClient ocspClient = mock(OcspClient.class);
when(ocspClient.request(eq(PRIMARY_URI), any()))
- .thenReturn(ocspRespGood);
- when(ocspClient.request(eq(FALLBACK_URI), any()))
- .thenReturn(ocspRespRevoked);
- when(ocspClient.request(eq(SECOND_FALLBACK_URI), any()))
- .thenReturn(ocspRespRevoked);
+ .thenThrow(new OCSPClientException("Primary OCSP service unavailable"));
+ when(ocspClient.request(eq(FALLBACK_URI), any())).thenReturn(ocspRespRevoked);
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build();
- ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, null);
+ assertThatExceptionOfType(ResilientUserCertificateRevokedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .withMessage("User certificate has been revoked")
+ .satisfies(ex -> {
+ List revocationInfoList = ex.getValidationInfo().revocationInfoList();
+ assertThat(revocationInfoList).hasSize(2);
+ assertThat(revocationInfoList).extracting(RevocationInfo::ocspResponderUri)
+ .containsExactly(PRIMARY_URI, FALLBACK_URI);
+ assertThat(revocationInfoList.get(0).ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR))
+ .isInstanceOf(OCSPClientException.class);
+ assertThat(getCertificateStatus(revocationInfoList.get(1))).isInstanceOf(RevokedStatus.class);
+ });
+ verify(ocspClient, never()).request(eq(SECOND_FALLBACK_URI), any());
+ }
- List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
- assertThat(revocationInfoList.size()).isEqualTo(1);
- Map responseAttributes = revocationInfoList.get(0).ocspResponseAttributes();
- OCSPResp ocspResp = (OCSPResp) responseAttributes.get("OCSP_RESPONSE");
- CertificateStatus status = getCertificateStatus(ocspResp);
- assertThat(status).isEqualTo(org.bouncycastle.cert.ocsp.CertificateStatus.GOOD);
+ @Test
+ void whenPrimaryReturnsMissingBasicOcspResponse_thenThrows() throws Exception {
+ OCSPResp response = mock(OCSPResp.class);
+ when(response.getStatus()).thenReturn(OCSPResponseStatus.SUCCESSFUL);
+ when(response.getResponseObject()).thenReturn(null);
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(response);
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build();
+
+ assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .satisfies(ex -> {
+ Map primaryAttributes = ex.getValidationInfo().revocationInfoList().get(0).ocspResponseAttributes();
+ ResilientUserCertificateOCSPCheckFailedException primaryError =
+ (ResilientUserCertificateOCSPCheckFailedException) primaryAttributes.get(RevocationInfo.KEY_OCSP_ERROR);
+ assertThat(primaryError.getMessage()).isEqualTo("Missing or unsupported Basic OCSP Response");
+ });
+ }
+
+ @Test
+ void whenPrimaryReturnsUnknown_thenFallbackHandlesTheCall() throws Exception {
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespUnknown);
+ when(ocspClient.request(eq(FALLBACK_URI), any())).thenReturn(ocspRespGood);
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build();
+
+ try (var mockedClock = mockStaticClockAt(WITHIN_RESPONDER_CERT_VALIDITY)) {
+ List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+
+ assertThat(revocationInfoList).hasSize(2);
+ verify(ocspClient).request(eq(FALLBACK_URI), any());
+ }
+ }
+
+ @Test
+ void whenNonceEnabledAndResponseNonceDiffers_thenThrows() throws Exception {
+ // primaryService advertises nonce support; ocspRespGood was signed with a different nonce.
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespGood);
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient)
+ .withoutFallbacks()
+ .withPrimaryNonceSupport()
+ .build();
+
+ try (var ignored = mockStaticClockAt(DER_THIS_UPDATE)) {
+ assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .satisfies(ex -> {
+ Throwable originalError = (Throwable) ex.getValidationInfo().revocationInfoList().get(0)
+ .ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR);
+ assertThat(originalError).hasMessageContaining("OCSP request and response nonces differ");
+ });
+ }
}
@Test
- void whenFirstCallResultsInRevoked_thenRevocationInfoListShouldHaveOneElementAndItShouldHaveRevokedStatus() throws Exception {
+ void whenCircuitBreakerIsOpenAndRecoveryTimeElapses_thenPrimaryIsTriedAgainInHalfOpenState() throws Exception {
OcspClient ocspClient = mock(OcspClient.class);
when(ocspClient.request(eq(PRIMARY_URI), any()))
- .thenReturn(ocspRespRevoked);
- when(ocspClient.request(eq(FALLBACK_URI), any()))
- .thenReturn(ocspRespGood);
- when(ocspClient.request(eq(SECOND_FALLBACK_URI), any()))
+ .thenThrow(new OCSPClientException("Primary OCSP service unavailable"))
+ .thenThrow(new OCSPClientException("Primary OCSP service unavailable"))
.thenReturn(ocspRespGood);
+ when(ocspClient.request(eq(FALLBACK_URI), any())).thenReturn(ocspRespGood);
+ CircuitBreakerConfig recoverableCircuitBreakerConfig = CircuitBreakerConfig.custom()
+ .slidingWindowSize(2)
+ .minimumNumberOfCalls(2)
+ .failureRateThreshold(50)
+ .waitDurationInOpenState(Duration.ofSeconds(1))
+ .permittedNumberOfCallsInHalfOpenState(1)
+ .build();
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient)
+ .withFallbacks(FALLBACK_URI)
+ .withCircuitBreakerConfig(recoverableCircuitBreakerConfig)
+ .build();
+
+ checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+ checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+
+ List openStateRevocationInfoList =
+ checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+
+ assertThat(openStateRevocationInfoList).hasSize(1);
+ assertThat(openStateRevocationInfoList.get(0).ocspResponderUri()).isEqualTo(FALLBACK_URI);
+ verify(ocspClient, times(2)).request(eq(PRIMARY_URI), any());
- ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, null);
- ResilientUserCertificateRevokedException ex = assertThrows(ResilientUserCertificateRevokedException.class, () -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA));
- List revocationInfoList = ex.getValidationInfo().revocationInfoList();
- assertThat(revocationInfoList.size()).isEqualTo(1);
- Map responseAttributes = ex.getValidationInfo().revocationInfoList().get(0).ocspResponseAttributes();
- OCSPResp ocspResp = (OCSPResp) responseAttributes.get("OCSP_RESPONSE");
- CertificateStatus status = getCertificateStatus(ocspResp);
- assertThat(status).isInstanceOf(RevokedStatus.class);
+ await().atMost(Duration.ofSeconds(5))
+ .pollInterval(Duration.ofMillis(50))
+ .untilAsserted(() -> {
+ List halfOpenRevocationInfoList =
+ checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+ assertThat(halfOpenRevocationInfoList).hasSize(1);
+ assertThat(halfOpenRevocationInfoList.get(0).ocspResponderUri()).isEqualTo(PRIMARY_URI);
+ });
+ verify(ocspClient, times(3)).request(eq(PRIMARY_URI), any());
}
@Test
- void whenOneFallbackIsConfiguredAndPrimaryFails_thenRevocationInfoListShouldHaveTwoElements() throws Exception {
+ void whenPrimaryAnswersInHalfOpenState_thenStatisticsReportHalfOpenState() throws Exception {
OcspClient ocspClient = mock(OcspClient.class);
when(ocspClient.request(eq(PRIMARY_URI), any()))
- .thenThrow(new OCSPClientException());
- when(ocspClient.request(eq(FALLBACK_URI), any()))
- .thenThrow(new OCSPClientException());
+ .thenThrow(new OCSPClientException("Primary OCSP service unavailable"))
+ .thenThrow(new OCSPClientException("Primary OCSP service unavailable"))
+ .thenReturn(ocspRespGood);
+ when(ocspClient.request(eq(FALLBACK_URI), any())).thenReturn(ocspRespGood);
+ CircuitBreakerConfig recoverableCircuitBreakerConfig = CircuitBreakerConfig.custom()
+ .slidingWindowSize(2)
+ .minimumNumberOfCalls(2)
+ .failureRateThreshold(50)
+ .waitDurationInOpenState(Duration.ofSeconds(1))
+ .permittedNumberOfCallsInHalfOpenState(1)
+ .build();
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient)
+ .withFallbacks(FALLBACK_URI)
+ .withCircuitBreakerConfig(recoverableCircuitBreakerConfig)
+ .build();
+ // The first two calls fail on the primary and trip the breaker.
+ checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+ checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+ // Let the open state expire without calling the checker, so that the next call is the one that the
+ // circuit breaker permits in half open state.
+ await().pollDelay(waitLongerThan(Duration.ofSeconds(1)))
+ .atMost(Duration.ofSeconds(10))
+ .until(() -> true);
- FallbackOcspService fallbackService = mock(FallbackOcspService.class);
- when(fallbackService.getAccessLocation()).thenReturn(FALLBACK_URI);
- when(fallbackService.doesSupportNonce()).thenReturn(false);
- when(fallbackService.getNextFallback()).thenReturn(null);
+ List revocationInfoList =
+ checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
- OcspService primaryService = mock(OcspService.class);
- when(primaryService.getAccessLocation()).thenReturn(PRIMARY_URI);
- when(primaryService.doesSupportNonce()).thenReturn(false);
- when(primaryService.getFallbackService()).thenReturn(Optional.of(fallbackService));
+ assertThat(revocationInfoList.get(0).ocspResponderUri()).isEqualTo(PRIMARY_URI);
+ // The primary answered, so the circuit breaker permitted the call in half open state.
+ // The reported state must not contradict which responder answered.
+ assertThat(getCircuitBreakerStatistics(revocationInfoList.get(0)).state())
+ .isEqualTo(CircuitBreaker.State.HALF_OPEN);
+ }
- OcspServiceProvider ocspServiceProvider = mock(OcspServiceProvider.class);
- when(ocspServiceProvider.getService(any(), any())).thenReturn(primaryService);
+ @Test
+ void whenPrimaryFailsInHalfOpenStateAndFallbackAnswers_thenStatisticsReportHalfOpenState() throws Exception {
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenThrow(new OCSPClientException("Primary OCSP service unavailable"));
+ when(ocspClient.request(eq(FALLBACK_URI), any())).thenReturn(ocspRespGood);
+ CircuitBreakerConfig recoverableCircuitBreakerConfig = CircuitBreakerConfig.custom()
+ .slidingWindowSize(2)
+ .minimumNumberOfCalls(2)
+ .failureRateThreshold(50)
+ .waitDurationInOpenState(Duration.ofSeconds(1))
+ .permittedNumberOfCallsInHalfOpenState(1)
+ .build();
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient)
+ .withFallbacks(FALLBACK_URI)
+ .withCircuitBreakerConfig(recoverableCircuitBreakerConfig)
+ .build();
+ // The first two calls fail on the primary and trip the breaker.
+ checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+ checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+ // Let the open state expire without calling the checker, so that the next call is the one that the
+ // circuit breaker permits in half open state.
+ await().pollDelay(waitLongerThan(Duration.ofSeconds(1)))
+ .atMost(Duration.ofSeconds(10))
+ .until(() -> true);
- ResilientOcspCertificateRevocationChecker checker = new ResilientOcspCertificateRevocationChecker(
- ocspClient,
- ocspServiceProvider,
- CircuitBreakerConfig.ofDefaults(),
- null,
- OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW,
- OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE,
- LONG_THIS_UPDATE_AGE
- );
-
- ResilientUserCertificateOCSPCheckFailedException ex = assertThrows(ResilientUserCertificateOCSPCheckFailedException.class, () -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA));
- List revocationInfoList = ex.getValidationInfo().revocationInfoList();
- assertThat(revocationInfoList.size()).isEqualTo(2);
+ List revocationInfoList =
+ checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+ // The next call finds the freshly re-opened breaker: the primary is not called and the statistics
+ // are a new snapshot that reports the OPEN state that rejected the call.
+ List reopenedRevocationInfoList =
+ checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+
+ assertThat(revocationInfoList).hasSize(2);
+ assertThat(revocationInfoList.get(0).ocspResponderUri()).isEqualTo(PRIMARY_URI);
+ assertThat(revocationInfoList.get(1).ocspResponderUri()).isEqualTo(FALLBACK_URI);
+ // The breaker permitted the primary call in half open state and re-opened when the call failed,
+ // before the fallback ran. The statistics must keep the snapshot taken when the primary request
+ // started (HALF_OPEN): neither the expired OPEN state that a snapshot taken before the call would
+ // report, nor the re-opened OPEN state that a capture in the fallback path would take.
+ assertThat(getCircuitBreakerStatistics(revocationInfoList.get(0)).state())
+ .isEqualTo(CircuitBreaker.State.HALF_OPEN);
+ assertThat(reopenedRevocationInfoList).hasSize(1);
+ assertThat(reopenedRevocationInfoList.get(0).ocspResponderUri()).isEqualTo(FALLBACK_URI);
+ assertThat(getCircuitBreakerStatistics(reopenedRevocationInfoList.get(0)).state())
+ .isEqualTo(CircuitBreaker.State.OPEN);
+ verify(ocspClient, times(3)).request(eq(PRIMARY_URI), any());
}
@Test
- void whenNoFallbacksAreConfigured_thenRevocationInfoListShouldHaveOneElement() throws Exception {
+ void whenPrimaryFailsInClosedState_thenStatisticsDoNotIncludeCurrentCallFailure() throws Exception {
OcspClient ocspClient = mock(OcspClient.class);
- when(ocspClient.request(eq(PRIMARY_URI), any()))
- .thenThrow(new OCSPClientException());
- when(ocspClient.request(eq(FALLBACK_URI), any()))
- .thenThrow(new OCSPClientException());
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenThrow(new OCSPClientException("Primary OCSP service unavailable"));
+ when(ocspClient.request(eq(FALLBACK_URI), any())).thenReturn(ocspRespGood);
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient)
+ .withFallbacks(FALLBACK_URI)
+ .build();
+
+ List firstCallRevocationInfoList =
+ checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+ List secondCallRevocationInfoList =
+ checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+ // The statistics are captured when the primary request starts, so the outcome of the very call they
+ // are attached to is not yet included: the first call reports no recorded calls at all and the
+ // second call reports only the first call's failure.
+ CircuitBreakerStatistics firstCallStatistics =
+ getCircuitBreakerStatistics(firstCallRevocationInfoList.get(0));
+ assertThat(firstCallStatistics.state()).isEqualTo(CircuitBreaker.State.CLOSED);
+ assertThat(firstCallStatistics.numberOfBufferedCalls()).isZero();
+ assertThat(firstCallStatistics.numberOfFailedCalls()).isZero();
+ CircuitBreakerStatistics secondCallStatistics =
+ getCircuitBreakerStatistics(secondCallRevocationInfoList.get(0));
+ assertThat(secondCallStatistics.state()).isEqualTo(CircuitBreaker.State.CLOSED);
+ assertThat(secondCallStatistics.numberOfBufferedCalls()).isEqualTo(1);
+ assertThat(secondCallStatistics.numberOfFailedCalls()).isEqualTo(1);
+ }
+
+ @Test
+ void whenOcspRequestFailsWithStatusCode_thenRevocationInfoContainsHttpStatusCodeAndResponseBody() throws Exception {
+ byte[] responseBody = "error".getBytes();
+ OCSPClientException ocspClientException = new OCSPClientException("OCSP request was not successful", responseBody, 503);
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenThrow(ocspClientException);
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withoutFallbacks().build();
+
+ assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .satisfies(ex -> {
+ Map attributes = ex.getValidationInfo().revocationInfoList().get(0).ocspResponseAttributes();
+ assertThat(attributes.get(RevocationInfo.KEY_HTTP_STATUS_CODE)).isEqualTo(503);
+ assertThat(attributes.get(RevocationInfo.KEY_OCSP_RESPONSE)).isEqualTo(responseBody);
+ });
+ }
+
+ @Test
+ void whenPrimaryThrowsRuntimeExceptionThatIsNotOCSPClientException_thenWrapsAsResilientUserCertificateOCSPCheckFailedException() throws Exception {
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenThrow(new NullPointerException());
+ when(ocspClient.request(eq(FALLBACK_URI), any())).thenThrow(new NullPointerException());
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withFallbacks(FALLBACK_URI).build();
+
+ assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .satisfies(ex -> {
+ assertThat(ex.getValidationInfo().revocationInfoList()).hasSize(2);
+ Throwable primaryError = (Throwable) ex.getValidationInfo().revocationInfoList().get(0)
+ .ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR);
+ assertThat(primaryError).isInstanceOf(NullPointerException.class);
+ });
+ }
+
+ @Test
+ void whenOcspClientReturnsNullResponse_thenWrapsAsResilientUserCertificateOCSPCheckFailedException() throws Exception {
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(null);
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withoutFallbacks().build();
+
+ assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .satisfies(ex -> {
+ Throwable primaryError = (Throwable) ex.getValidationInfo().revocationInfoList().get(0)
+ .ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR);
+ assertThat(primaryError).isInstanceOf(NullPointerException.class);
+ });
+ }
+
+ @Test
+ void whenPrimaryOcspServiceAccessLocationIsNull_thenWrapsAsResilientUserCertificateOCSPCheckFailedException() throws Exception {
+ NullPointerException nullUriRejectedByClient = new NullPointerException("uri");
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(isNull(), any())).thenThrow(nullUriRejectedByClient);
OcspService primaryService = mock(OcspService.class);
- when(primaryService.getAccessLocation()).thenReturn(PRIMARY_URI);
+ when(primaryService.getAccessLocation()).thenReturn(null);
when(primaryService.doesSupportNonce()).thenReturn(false);
when(primaryService.getFallbackService()).thenReturn(Optional.empty());
-
OcspServiceProvider ocspServiceProvider = mock(OcspServiceProvider.class);
when(ocspServiceProvider.getService(any(), any())).thenReturn(primaryService);
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient)
+ .withOcspServiceProvider(ocspServiceProvider)
+ .build();
+
+ assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .satisfies(ex -> {
+ RevocationInfo revocationInfo = ex.getValidationInfo().revocationInfoList().get(0);
+ assertThat(revocationInfo.ocspResponderUri()).isNull();
+ Map attributes = revocationInfo.ocspResponseAttributes();
+ assertThat(attributes.get(RevocationInfo.KEY_OCSP_ERROR)).isSameAs(nullUriRejectedByClient);
+ // getOcspRequest() builds the request before the responder is contacted, so the request
+ // is recorded even when the call itself fails.
+ assertThat(attributes).containsKey(RevocationInfo.KEY_OCSP_REQUEST);
+ assertThat(attributes).doesNotContainKey(RevocationInfo.KEY_OCSP_RESPONSE);
+ });
+ verify(ocspClient).request(isNull(), any());
+ }
+
+ @Test
+ void whenPrimarySucceeds_thenRevocationInfoListContainsExpectedResponderUrisAndAttributes() throws Exception {
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespGood);
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build();
+
+ List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
- ResilientOcspCertificateRevocationChecker checker = new ResilientOcspCertificateRevocationChecker(
- ocspClient,
- ocspServiceProvider,
- CircuitBreakerConfig.ofDefaults(),
- null,
- OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW,
- OcspCertificateRevocationChecker.DEFAULT_THIS_UPDATE_AGE,
- LONG_THIS_UPDATE_AGE
- );
+ assertThat(revocationInfoList).hasSize(1);
- ResilientUserCertificateOCSPCheckFailedException ex = assertThrows(ResilientUserCertificateOCSPCheckFailedException.class, () -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA));
- List revocationInfoList = ex.getValidationInfo().revocationInfoList();
- assertThat(revocationInfoList.size()).isEqualTo(1);
+ RevocationInfo primary = revocationInfoList.get(0);
+ assertThat(primary.ocspResponderUri()).isEqualTo(PRIMARY_URI);
+ assertThat(getCertificateStatus(primary)).isEqualTo(CertificateStatus.GOOD);
+ assertThat(primary.ocspResponseAttributes())
+ .doesNotContainKey(RevocationInfo.KEY_OCSP_ERROR)
+ .containsKey(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS)
+ .containsKey(RevocationInfo.KEY_REQUEST_DURATION)
+ .containsKey(RevocationInfo.KEY_OCSP_RESPONSE_TIME);
+ assertThat(primary.ocspResponseAttributes().get(RevocationInfo.KEY_REQUEST_DURATION)).isInstanceOf(Duration.class);
+ assertThat((Duration) primary.ocspResponseAttributes().get(RevocationInfo.KEY_REQUEST_DURATION))
+ .isGreaterThanOrEqualTo(Duration.ZERO);
+ assertThat(primary.ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_RESPONSE_TIME)).isInstanceOf(Instant.class);
}
@Test
- void whenOcspResponseStatusIsUnauthorized_thenThrows() throws Exception {
- OCSPResp ocspRespStatusUnauthorized = new OCSPResp(getOcspResponseBytesFromResources("ocsp_response_unauthorized.der"));
+ void whenPrimaryFailsAndFirstFallbackSucceeds_thenRevocationInfoListContainsExpectedResponderUrisAndAttributes() throws Exception {
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenThrow(new OCSPClientException("primary"));
+ when(ocspClient.request(eq(FALLBACK_URI), any())).thenReturn(ocspRespGood);
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build();
+
+ List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+
+ assertThat(revocationInfoList).hasSize(2);
+
+ RevocationInfo primary = revocationInfoList.get(0);
+ assertThat(primary.ocspResponderUri()).isEqualTo(PRIMARY_URI);
+ assertThat(primary.ocspResponseAttributes())
+ .containsKey(RevocationInfo.KEY_OCSP_ERROR)
+ .containsKey(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS);
+
+ RevocationInfo fallback = revocationInfoList.get(1);
+ assertThat(fallback.ocspResponderUri()).isEqualTo(FALLBACK_URI);
+ assertThat(getCertificateStatus(fallback)).isEqualTo(CertificateStatus.GOOD);
+ assertThat(fallback.ocspResponseAttributes())
+ .doesNotContainKey(RevocationInfo.KEY_OCSP_ERROR)
+ .doesNotContainKey(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS)
+ .containsKey(RevocationInfo.KEY_REQUEST_DURATION)
+ .containsKey(RevocationInfo.KEY_OCSP_RESPONSE_TIME);
+ assertThat(fallback.ocspResponseAttributes().get(RevocationInfo.KEY_REQUEST_DURATION)).isInstanceOf(Duration.class);
+ assertThat(fallback.ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_RESPONSE_TIME)).isInstanceOf(Instant.class);
+
+ verify(ocspClient, never()).request(eq(SECOND_FALLBACK_URI), any());
+ }
+ @Test
+ void whenPrimaryAndFirstFallbackFailAndSecondFallbackSucceeds_thenRevocationInfoListContainsExpectedResponderUrisAndAttributes() throws Exception {
OcspClient ocspClient = mock(OcspClient.class);
- when(ocspClient.request(eq(PRIMARY_URI), any()))
- .thenReturn(ocspRespStatusUnauthorized);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenThrow(new OCSPClientException("Primary OCSP service unavailable"));
+ when(ocspClient.request(eq(FALLBACK_URI), any())).thenThrow(new OCSPClientException("Fallback OCSP service unavailable"));
+ when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())).thenReturn(ocspRespGood);
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build();
- ResilientOcspCertificateRevocationChecker checker = buildChecker(ocspClient, null);
- ResilientUserCertificateOCSPCheckFailedException ex = assertThrows(ResilientUserCertificateOCSPCheckFailedException.class, () -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA));
+ List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
- Map responseAttributes = ex.getValidationInfo().revocationInfoList().get(0).ocspResponseAttributes();
- ResilientUserCertificateOCSPCheckFailedException firstException = (ResilientUserCertificateOCSPCheckFailedException) responseAttributes.get(RevocationInfo.KEY_OCSP_ERROR);
- assertThat(firstException.getMessage()).isEqualTo("Response status: unauthorized");
+ assertThat(revocationInfoList).hasSize(3);
+
+ RevocationInfo primary = revocationInfoList.get(0);
+ assertThat(primary.ocspResponderUri()).isEqualTo(PRIMARY_URI);
+ assertThat(primary.ocspResponseAttributes())
+ .containsKey(RevocationInfo.KEY_OCSP_ERROR)
+ .containsKey(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS);
+
+ RevocationInfo firstFallback = revocationInfoList.get(1);
+ assertThat(firstFallback.ocspResponderUri()).isEqualTo(FALLBACK_URI);
+ assertThat(firstFallback.ocspResponseAttributes())
+ .containsKey(RevocationInfo.KEY_OCSP_ERROR)
+ .doesNotContainKey(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS);
+
+ RevocationInfo secondFallback = revocationInfoList.get(2);
+ assertThat(secondFallback.ocspResponderUri()).isEqualTo(SECOND_FALLBACK_URI);
+ assertThat(secondFallback.ocspResponseAttributes())
+ .doesNotContainKey(RevocationInfo.KEY_OCSP_ERROR)
+ .doesNotContainKey(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS);
}
- private ResilientOcspCertificateRevocationChecker buildChecker(OcspClient ocspClient, RetryConfig retryConfig) throws Exception {
- FallbackOcspService secondFallbackService = mock(FallbackOcspService.class);
- when(secondFallbackService.getAccessLocation()).thenReturn(SECOND_FALLBACK_URI);
- when(secondFallbackService.doesSupportNonce()).thenReturn(false);
+ @Test
+ void whenAllFail_thenRevocationInfoListContainsExpectedResponderUrisAndAttributes() throws Exception {
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenThrow(new OCSPClientException("primary"));
+ when(ocspClient.request(eq(FALLBACK_URI), any())).thenThrow(new OCSPClientException("fallback"));
+ when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())).thenThrow(new OCSPClientException("second"));
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build();
+
+ assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .satisfies(ex -> {
+ assertThat(ex.getValidationInfo()).isNotNull();
+ List revocationInfoList = ex.getValidationInfo().revocationInfoList();
- FallbackOcspService fallbackService = mock(FallbackOcspService.class);
- when(fallbackService.getAccessLocation()).thenReturn(FALLBACK_URI);
- when(fallbackService.doesSupportNonce()).thenReturn(false);
- when(fallbackService.getNextFallback()).thenReturn(secondFallbackService);
+ assertThat(revocationInfoList).hasSize(3);
- OcspService primaryService = mock(OcspService.class);
- when(primaryService.getAccessLocation()).thenReturn(PRIMARY_URI);
- when(primaryService.doesSupportNonce()).thenReturn(false);
- when(primaryService.getFallbackService()).thenReturn(Optional.of(fallbackService));
+ RevocationInfo primary = revocationInfoList.get(0);
+ assertThat(primary.ocspResponderUri()).isEqualTo(PRIMARY_URI);
+ assertThat(primary.ocspResponseAttributes())
+ .containsKey(RevocationInfo.KEY_OCSP_ERROR)
+ .containsKey(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS);
- OcspServiceProvider ocspServiceProvider = mock(OcspServiceProvider.class);
- when(ocspServiceProvider.getService(any(), any())).thenReturn(primaryService);
+ RevocationInfo firstFallback = revocationInfoList.get(1);
+ assertThat(firstFallback.ocspResponderUri()).isEqualTo(FALLBACK_URI);
+ assertThat(firstFallback.ocspResponseAttributes())
+ .containsKey(RevocationInfo.KEY_OCSP_ERROR)
+ .doesNotContainKey(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS);
+
+ RevocationInfo secondFallback = revocationInfoList.get(2);
+ assertThat(secondFallback.ocspResponderUri()).isEqualTo(SECOND_FALLBACK_URI);
+ assertThat(secondFallback.ocspResponseAttributes())
+ .containsKey(RevocationInfo.KEY_OCSP_ERROR)
+ .doesNotContainKey(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS);
+ });
+ }
+
+ @Test
+ void whenPrimaryResponseIsTooOldForPrimaryAgeLimit_thenFallbackAcceptsItUnderFallbackAgeLimit() throws Exception {
+ // The same response (thisUpdate 2021-09-17T18:25:24) is served by both responders and the clock is mocked
+ // 5 minutes later. The primary applies the stricter 2-minute limit and rejects it as too old, while the
+ // fallback applies the more lenient 10-minute limit and accepts it. This proves that each responder
+ // applies the maxThisUpdateAge of its own OCSP service; swapping the two values would flip the outcome
+ // and fail this test.
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespGood);
+ when(ocspClient.request(eq(FALLBACK_URI), any())).thenReturn(ocspRespGood);
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient)
+ .withFallbacks(FALLBACK_URI)
+ .withPrimaryMaxThisUpdateAge(Duration.ofMinutes(2))
+ .withFallbackMaxThisUpdateAge(Duration.ofMinutes(10))
+ .build();
+
+ try (var ignored = mockStaticClockAt(FIVE_MIN_AFTER_THIS_UPDATE)) {
+ List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+
+ assertThat(revocationInfoList).hasSize(2);
+
+ RevocationInfo primary = revocationInfoList.get(0);
+ assertThat(primary.ocspResponderUri()).isEqualTo(PRIMARY_URI);
+ Throwable primaryError = (Throwable) primary.ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR);
+ assertThat(primaryError).hasMessageContaining("thisUpdate").hasMessageContaining("is too old");
+
+ RevocationInfo fallback = revocationInfoList.get(1);
+ assertThat(fallback.ocspResponderUri()).isEqualTo(FALLBACK_URI);
+ assertThat(getCertificateStatus(fallback)).isEqualTo(CertificateStatus.GOOD);
+ }
+ }
+
+ @Test
+ void whenCircuitBreakerOpens_thenFallbackHandlesCallAndStatisticsReflectOpenState() throws Exception {
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenThrow(new OCSPClientException("Primary OCSP service unavailable"));
+ when(ocspClient.request(eq(FALLBACK_URI), any())).thenReturn(ocspRespGood);
+ CircuitBreakerConfig tightCircuitBreakerConfig = CircuitBreakerConfig.custom()
+ .slidingWindowSize(2)
+ .minimumNumberOfCalls(2)
+ .failureRateThreshold(50)
+ .permittedNumberOfCallsInHalfOpenState(1)
+ .build();
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient)
+ .withFallbacks(FALLBACK_URI)
+ .withCircuitBreakerConfig(tightCircuitBreakerConfig)
+ .build();
+
+ // The first two calls fail on the primary and trip the breaker; the third call sees it already open.
+ checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+ checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+ List revocationInfoList = checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA);
+
+ assertThat(revocationInfoList).hasSize(1);
+ assertThat(revocationInfoList.get(0).ocspResponderUri()).isEqualTo(FALLBACK_URI);
+ CircuitBreakerStatistics statistics =
+ (CircuitBreakerStatistics)
+ revocationInfoList.get(0).ocspResponseAttributes().get(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS);
+ assertThat(statistics).isNotNull();
+ assertThat(statistics.state()).isEqualTo(CircuitBreaker.State.OPEN);
+ assertThat(statistics.numberOfFailedCalls()).isEqualTo(2);
+ // The circuit breaker rejected the primary call, so the snapshot is taken when the fallback request
+ // starts and includes the rejection of this very call.
+ assertThat(statistics.numberOfNotPermittedCalls()).isEqualTo(1);
+ }
+
+ @Test
+ void whenNoFallbackConfiguredAndPrimaryReturnsRevoked_thenRevokedPropagatesWithSingleEntry() throws Exception {
+ // The no-fallback branch returns directly from request() without going through processResult, so this
+ // exercises ResilientUserCertificateRevokedException propagating straight out of validateCertificateNotRevoked.
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespRevoked);
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withoutFallbacks().build();
+
+ assertThatExceptionOfType(ResilientUserCertificateRevokedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .satisfies(ex -> {
+ List revocationInfoList = ex.getValidationInfo().revocationInfoList();
+ assertThat(revocationInfoList).hasSize(1);
+ assertThat(revocationInfoList.get(0).ocspResponderUri()).isEqualTo(PRIMARY_URI);
+ assertThat(getCertificateStatus(revocationInfoList.get(0))).isInstanceOf(RevokedStatus.class);
+ });
+ }
+
+ @Test
+ void whenNoFallbackConfiguredAndPrimaryReturnsUnknown_thenCheckFailedPropagates() throws Exception {
+ // The unknown status fails the OCSP check.
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenReturn(ocspRespUnknown);
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).withoutFallbacks().build();
+
+ try (var ignored = mockStaticClockAt(WITHIN_RESPONDER_CERT_VALIDITY)) {
+ assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .satisfies(ex -> assertThat(ex.getValidationInfo().revocationInfoList()).hasSize(1));
+ }
+ }
+
+ @Test
+ void whenNoFallbackConfigured_thenRetryAndCircuitBreakerAreNotApplied() throws Exception {
+ // The class contract states retry and circuit breaker apply only when a fallback is configured. With no
+ // fallback the primary must be queried exactly once (no retry) and no circuit breaker statistics attached.
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenThrow(new OCSPClientException("Primary OCSP service unavailable"));
+ RetryConfig retryConfig = RetryConfig.custom().maxAttempts(2).waitDuration(Duration.ZERO).build();
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient)
+ .withoutFallbacks()
+ .withRetryConfig(retryConfig)
+ .build();
+
+ assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .satisfies(ex -> {
+ List revocationInfoList = ex.getValidationInfo().revocationInfoList();
+ assertThat(revocationInfoList).hasSize(1);
+ assertThat(revocationInfoList.get(0).ocspResponseAttributes())
+ .doesNotContainKey(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS);
+ });
+ verify(ocspClient, times(1)).request(eq(PRIMARY_URI), any());
+ }
+
+ @Test
+ void whenPrimaryAndFirstFallbackFailAndSecondFallbackReturnsRevoked_thenRevokedPropagates() throws Exception {
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenThrow(new OCSPClientException("Primary OCSP service unavailable"));
+ when(ocspClient.request(eq(FALLBACK_URI), any())).thenThrow(new OCSPClientException("Fallback OCSP service unavailable"));
+ when(ocspClient.request(eq(SECOND_FALLBACK_URI), any())).thenReturn(ocspRespRevoked);
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient).build();
+
+ assertThatExceptionOfType(ResilientUserCertificateRevokedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .satisfies(ex -> {
+ List revocationInfoList = ex.getValidationInfo().revocationInfoList();
+ assertThat(revocationInfoList).hasSize(3);
+ assertThat(revocationInfoList).extracting(RevocationInfo::ocspResponderUri)
+ .containsExactly(PRIMARY_URI, FALLBACK_URI, SECOND_FALLBACK_URI);
+ assertThat(getCertificateStatus(revocationInfoList.get(2))).isInstanceOf(RevokedStatus.class);
+ });
+ }
+
+ @Test
+ void whenFallbackResponseIsTooOldForFallbackAgeLimit_thenOcspCheckFails() throws Exception {
+ // The response thisUpdate is 2021-09-17T18:25:24 and the clock is mocked 5 minutes later, while the fallback
+ // age limit is only 2 minutes, so the fallback rejects the response as too old. This exercises the failure
+ // direction of the fallback service maxThisUpdateAge (the accepting direction is covered elsewhere).
+ OcspClient ocspClient = mock(OcspClient.class);
+ when(ocspClient.request(eq(PRIMARY_URI), any())).thenThrow(new OCSPClientException("Primary OCSP service unavailable"));
+ when(ocspClient.request(eq(FALLBACK_URI), any())).thenReturn(ocspRespGood);
+ ResilientOcspCertificateRevocationChecker checker = checkerBuilder(ocspClient)
+ .withFallbacks(FALLBACK_URI)
+ .withFallbackMaxThisUpdateAge(Duration.ofMinutes(2))
+ .build();
+
+ try (var ignored = mockStaticClockAt(FIVE_MIN_AFTER_THIS_UPDATE)) {
+ assertThatExceptionOfType(ResilientUserCertificateOCSPCheckFailedException.class)
+ .isThrownBy(() -> checker.validateCertificateNotRevoked(estEid2018Cert, testEsteid2018CA))
+ .satisfies(ex -> {
+ List revocationInfoList = ex.getValidationInfo().revocationInfoList();
+ assertThat(revocationInfoList).hasSize(2);
+ Throwable fallbackError = (Throwable) revocationInfoList.get(1)
+ .ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_ERROR);
+ assertThat(fallbackError).hasMessageContaining("thisUpdate").hasMessageContaining("is too old");
+ });
+ }
+ }
+
+ private static CheckerBuilder checkerBuilder(OcspClient ocspClient) {
+ return new CheckerBuilder(ocspClient);
+ }
+
+ /**
+ * Builds a {@link ResilientOcspCertificateRevocationChecker} with, by default, a primary OCSP service
+ * with two chained fallbacks: PRIMARY_URI -> FALLBACK_URI -> SECOND_FALLBACK_URI. The age limits are
+ * per OCSP service, so they are stubbed on the mocked services and are relaxed by default.
+ */
+ private static final class CheckerBuilder {
+
+ private final OcspClient ocspClient;
+ private OcspServiceProvider ocspServiceProvider;
+ private URI[] fallbackUris = {FALLBACK_URI, SECOND_FALLBACK_URI};
+ private boolean primarySupportsNonce;
+ private CircuitBreakerConfig circuitBreakerConfig = CircuitBreakerConfig.ofDefaults();
+ private RetryConfig retryConfig;
+ private Duration primaryMaxThisUpdateAge = LONG_THIS_UPDATE_AGE;
+ private Duration fallbackMaxThisUpdateAge = LONG_THIS_UPDATE_AGE;
+
+ private CheckerBuilder(OcspClient ocspClient) {
+ this.ocspClient = ocspClient;
+ }
+
+ private CheckerBuilder withFallbacks(URI... fallbackUris) {
+ this.fallbackUris = fallbackUris;
+ return this;
+ }
+
+ private CheckerBuilder withoutFallbacks() {
+ return withFallbacks();
+ }
+
+ private CheckerBuilder withPrimaryNonceSupport() {
+ this.primarySupportsNonce = true;
+ return this;
+ }
+
+ private CheckerBuilder withCircuitBreakerConfig(CircuitBreakerConfig circuitBreakerConfig) {
+ this.circuitBreakerConfig = circuitBreakerConfig;
+ return this;
+ }
+
+ private CheckerBuilder withRetryConfig(RetryConfig retryConfig) {
+ this.retryConfig = retryConfig;
+ return this;
+ }
+
+ private CheckerBuilder withOcspServiceProvider(OcspServiceProvider ocspServiceProvider) {
+ this.ocspServiceProvider = ocspServiceProvider;
+ return this;
+ }
+
+ private CheckerBuilder withPrimaryMaxThisUpdateAge(Duration primaryMaxThisUpdateAge) {
+ this.primaryMaxThisUpdateAge = primaryMaxThisUpdateAge;
+ return this;
+ }
+
+ private CheckerBuilder withFallbackMaxThisUpdateAge(Duration fallbackMaxThisUpdateAge) {
+ this.fallbackMaxThisUpdateAge = fallbackMaxThisUpdateAge;
+ return this;
+ }
+
+ private ResilientOcspCertificateRevocationChecker build() throws Exception {
+ OcspServiceProvider serviceProvider = ocspServiceProvider != null ? ocspServiceProvider : buildMockServiceProvider();
+ return new ResilientOcspCertificateRevocationChecker(
+ ocspClient,
+ serviceProvider,
+ circuitBreakerConfig,
+ retryConfig,
+ OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW
+ );
+ }
+
+ private OcspServiceProvider buildMockServiceProvider() throws Exception {
+ FallbackOcspService nextFallback = null;
+ for (int i = fallbackUris.length - 1; i >= 0; i--) {
+ FallbackOcspService fallbackService = mock(FallbackOcspService.class);
+ when(fallbackService.getAccessLocation()).thenReturn(fallbackUris[i]);
+ when(fallbackService.doesSupportNonce()).thenReturn(false);
+ when(fallbackService.getNextFallback()).thenReturn(nextFallback);
+ when(fallbackService.getMaxThisUpdateAge()).thenReturn(fallbackMaxThisUpdateAge);
+ when(fallbackService.getMaxNextUpdateAge()).thenReturn(LONG_NEXT_UPDATE_AGE);
+ nextFallback = fallbackService;
+ }
+
+ OcspService primaryService = mock(OcspService.class);
+ when(primaryService.getAccessLocation()).thenReturn(PRIMARY_URI);
+ when(primaryService.doesSupportNonce()).thenReturn(primarySupportsNonce);
+ when(primaryService.getFallbackService()).thenReturn(Optional.ofNullable(nextFallback));
+ when(primaryService.getMaxThisUpdateAge()).thenReturn(primaryMaxThisUpdateAge);
+ when(primaryService.getMaxNextUpdateAge()).thenReturn(LONG_NEXT_UPDATE_AGE);
+
+ OcspServiceProvider serviceProvider = mock(OcspServiceProvider.class);
+ when(serviceProvider.getService(any(), any())).thenReturn(primaryService);
+ return serviceProvider;
+ }
+ }
+
+ private static MockedStatic mockStaticClockAt(String isoDateTime) {
+ MockedStatic mockedClock = Mockito.mockStatic(DateAndTime.DefaultClock.class);
+ mockDate(isoDateTime, mockedClock);
+ return mockedClock;
+ }
+
+ // Overshooting the open-state duration is safe: the breaker transitions to HALF_OPEN lazily, when the
+ // next call asks for permission, not on a timer.
+ private static Duration waitLongerThan(Duration waitDurationInOpenState) {
+ return waitDurationInOpenState.plusMillis(500);
+ }
- return new ResilientOcspCertificateRevocationChecker(
- ocspClient,
- ocspServiceProvider,
- CircuitBreakerConfig.ofDefaults(),
- retryConfig,
- OcspCertificateRevocationChecker.DEFAULT_TIME_SKEW,
- LONG_THIS_UPDATE_AGE,
- LONG_THIS_UPDATE_AGE
- );
+ private static CircuitBreakerStatistics getCircuitBreakerStatistics(RevocationInfo revocationInfo) {
+ return (CircuitBreakerStatistics)
+ revocationInfo.ocspResponseAttributes().get(RevocationInfo.KEY_CIRCUIT_BREAKER_STATISTICS);
}
- private CertificateStatus getCertificateStatus(OCSPResp ocspResp) throws Exception {
+ private static CertificateStatus getCertificateStatus(RevocationInfo revocationInfo) throws Exception {
+ OCSPResp ocspResp = (OCSPResp) revocationInfo.ocspResponseAttributes().get(RevocationInfo.KEY_OCSP_RESPONSE);
final BasicOCSPResp basicResponse = (BasicOCSPResp) ocspResp.getResponseObject();
final SingleResp certStatusResponse = basicResponse.getResponses()[0];
return certStatusResponse.getCertStatus();
diff --git a/src/test/java/eu/webeid/security/testutil/AbstractTestWithValidator.java b/src/test/java/eu/webeid/security/testutil/AbstractTestWithValidator.java
index 0a5588a2..c8cb25f3 100644
--- a/src/test/java/eu/webeid/security/testutil/AbstractTestWithValidator.java
+++ b/src/test/java/eu/webeid/security/testutil/AbstractTestWithValidator.java
@@ -3,16 +3,14 @@
package eu.webeid.security.testutil;
-import org.junit.jupiter.api.BeforeEach;
import eu.webeid.security.authtoken.WebEidAuthToken;
import eu.webeid.security.exceptions.AuthTokenException;
import eu.webeid.security.validator.AuthTokenValidator;
+import org.junit.jupiter.api.BeforeEach;
import java.io.IOException;
import java.security.cert.CertificateException;
-import static eu.webeid.security.testutil.AuthTokenValidators.getAuthTokenValidator;
-
public abstract class AbstractTestWithValidator {
/*
@@ -25,7 +23,7 @@ public abstract class AbstractTestWithValidator {
"\"signature\":\"pHkO+vRxBkP/zZLFvXcwR9kme/HT/DBRLk5RJDp7lPrfr6Qlb5Fu3/C3Up6Qw8P0KE2992as1lG9L3tbvqwa3dUCUz0osfRNEUXgkx1oPJrfII50/6L3mNnmexRnVSl2\"," +
"\"format\":\"web-eid:1.0\"}";
- /*
+ /*
* notBefore Time UTCTime 2021-07-22 12:43:08 UTC
* notAfter Time UTCTime 2026-07-09 21:59:59 UTC
*/
@@ -35,6 +33,11 @@ public abstract class AbstractTestWithValidator {
"\"signature\":\"0Ov7ME6pTY1K2GXMj8Wxov/o2fGIMEds8OMY5dKdkB0nrqQX7fG1E5mnsbvyHpMDecMUH6Yg+p1HXdgB/lLqOcFZjt/OVXPjAAApC5d1YgRYATDcxsR1zqQwiNcHdmWn\"," +
"\"format\":\"web-eid:1.0\"}";
public static final String VALID_AUTH_TOKEN_TEST_DATE = "2026-01-01";
+ public static final String VALID_RS256_AUTH_TOKEN = "{\"algorithm\":\"RS256\"," +
+ "\"unverifiedCertificate\":\"MIIGvjCCBKagAwIBAgIQT7aXeR+zWlBb2Gbar+AFaTANBgkqhkiG9w0BAQsFADCBgzELMAkGA1UEBhMCTFYxOTA3BgNVBAoMMFZBUyBMYXR2aWphcyBWYWxzdHMgcmFkaW8gdW4gdGVsZXbEq3ppamFzIGNlbnRyczEaMBgGA1UEYQwRTlRSTFYtNDAwMDMwMTEyMDMxHTAbBgNVBAMMFERFTU8gTFYgZUlEIElDQSAyMDE3MB4XDTE4MTAzMDE0MTI0MloXDTIzMTAzMDE0MTI0MlowcDELMAkGA1UEBhMCTFYxHDAaBgNVBAMME0FORFJJUyBQQVJBVURaScWFxaAxFTATBgNVBAQMDFBBUkFVRFpJxYXFoDEPMA0GA1UEKgwGQU5EUklTMRswGQYDVQQFExJQTk9MVi0zMjE5MjItMzMwMzIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDXkra3rDOOt5K6OnJcg/Xt6JOogPAUBX2kT9zWelze7WSuPx2Ofs//0JoBQ575IVdh3JpLhfh7g60YYi41M6vNACVSNaFOxiEvE9amSFizMiLk5+dp+79rymqOsVQG8CSu8/RjGGlDsALeb3N/4pUSTGXUwSB64QuFhOWjAcmKPhHeYtry0hK3MbwwHzFhYfGpo/w+PL14PEdJlpL1UX/aPyT0Zq76Z4T/Z3PqbTmQp09+2b0thC0JIacSkyJuTu8fVRQvse+8UtYC6Kt3TBLZbPtqfAFSXWbuE47Lc2o840NkVlMHVAesoRAfiQxsK35YWFT0rHPWbLjX6ySiaL25AgMBAAGjggI+MIICOjAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAjAdBgNVHQ4EFgQUHZWimPze2GXULNaP4EFVdF+MWKQwHwYDVR0jBBgwFoAUj2jOvOLHQCFTCUK75Z4djEvNvTgwgfsGA1UdIASB8zCB8DA7BgYEAI96AQIwMTAvBggrBgEFBQcCARYjaHR0cHM6Ly93d3cuZXBhcmFrc3RzLmx2L3JlcG9zaXRvcnkwgbAGDCsGAQQBgfo9AgECATCBnzAvBggrBgEFBQcCARYjaHR0cHM6Ly93d3cuZXBhcmFrc3RzLmx2L3JlcG9zaXRvcnkwbAYIKwYBBQUHAgIwYAxexaBpcyBzZXJ0aWZpa8SBdHMgaXIgaWVrxLxhdXRzIExhdHZpamFzIFJlcHVibGlrYXMgaXpzbmllZ3TEgSBwZXJzb251IGFwbGllY2lub8WhxIEgZG9rdW1lbnTEgTB9BggrBgEFBQcBAQRxMG8wQgYIKwYBBQUHMAKGNmh0dHA6Ly9kZW1vLmVwYXJha3N0cy5sdi9jZXJ0L2RlbW9fTFZfZUlEX0lDQV8yMDE3LmNydDApBggrBgEFBQcwAYYdaHR0cDovL29jc3AucHJlcC5lcGFyYWtzdHMubHYwSAYDVR0fBEEwPzA9oDugOYY3aHR0cDovL2RlbW8uZXBhcmFrc3RzLmx2L2NybC9kZW1vX0xWX2VJRF9JQ0FfMjAxN18zLmNybDANBgkqhkiG9w0BAQsFAAOCAgEAAOVoRbnMv2UXWYHgnmO9Zg9u8F1YvJiZPMeTYE2CVaiq0nXe4Mq0X5tWcsEiRpGQF9e0dWC6V5m6EmAsHxIRL4chZKRrIrPEiWtP3zyRI1/X2y5GwSUyZmgxkuSOHHw3UjzjrnOoI9izpC0OSNeumqpjT/tLAi35sktGkK0onEUPWGQnZLqd/hzykm+H/dmD27nOnfCJOSqbegLSbhV2w/WAII+IUD3vJ06F6rf9ZN8xbrGkPO8VMCIDIt0eBKFxBdSOgpsTfbERbjQJ+nFEDYhD0bFNYMsFSGnZiWpNaCcZSkk4mtNUa8sNXyaFQGIZk6NjQ/fsBANhUoxFz7rUKrRYqk356i8KFDZ+MJqUyodKKyW9oz+IO5eJxnL78zRbxD+EfAUmrLXOjmGIzU95RR1smS4cirrrPHqGAWojBk8hKbjNTJl9Tfbnsbc9/FUBJLVZAkCi631KfRLQ66bn8N0mbtKlNtdX0G47PXTy7SJtWwDtKQ8+qVpduc8xHLntbdAzie3mWyxA1SBhQuZ9BPf5SPBImWCNpmZNCTmI2e+4yyCnmG/kVNilUAaODH/fgQXFGdsKO/XATFohiies28twkEzqtlVZvZbpBhbJCHYVnQXMhMKcnblkDqXWcSWd3QAKig2yMH95uz/wZhiV+7tZ7cTgwcbCzIDCfpwBC3E=\"," +
+ "\"issuerApp\":\"https://web-eid.eu/web-eid-app/releases/2.0.0+0\"," +
+ "\"signature\":\"xsjXsQvVYXWcdV0YPhxLthJxtf0//R8p9WFFlYJGRARrl1ruyoAUwl0xeHgeZOKeJtwiCYCNWJzCG3VM3ydgt92bKhhk1u0JXIPVqvOkmDY72OCN4q73Y8iGSPVTgjk93TgquHlodf7YcqZNhutwNNf3oldHEWJD5zmkdwdpBFXgeOwTAdFwGljDQZbHr3h1Dr+apUDuloS0WuIzUuu8YXN2b8lh8FCTlF0G0DEjhHd/MGx8dbe3UTLHmD7K9DXv4zLJs6EF9i2v/C10SIBQDkPBSVPqMxCDPECjbEPi2+ds94eU7ThOhOQlFFtJ4KjQNTUa2crSixH7cYZF2rNNmA==\"," +
+ "\"format\":\"web-eid:1.0\"}";
public static final String VALID_CHALLENGE_NONCE = "12345678123456781234567812345678912356789123";
protected AuthTokenValidator validator;
diff --git a/src/test/java/eu/webeid/security/testutil/Certificates.java b/src/test/java/eu/webeid/security/testutil/Certificates.java
index 737a3ae1..f09dad3d 100644
--- a/src/test/java/eu/webeid/security/testutil/Certificates.java
+++ b/src/test/java/eu/webeid/security/testutil/Certificates.java
@@ -19,18 +19,30 @@ public class Certificates {
private static X509Certificate testEsteid2018CA;
private static X509Certificate testEsteid2015CA;
+ private static X509Certificate testEeCertCentreRootCA;
private static X509Certificate jaakKristjanEsteid2018Cert;
private static X509Certificate mariliisEsteid2015Cert;
private static X509Certificate organizationCert;
private static X509Certificate certificateWithoutCertificatePolicies;
private static X509Certificate testSkOcspResponder2020;
+ private static X509Certificate testSelfSignedOcspResponder;
+ private static X509Certificate demoEsteidSk2018AiaOcspResponder;
static void loadCertificates() throws CertificateException, IOException {
- X509Certificate[] certificates = CertificateLoader.loadCertificatesFromResources("TEST_of_ESTEID-SK_2015.cer", "TEST_of_ESTEID2018.cer", "TEST_of_SK_OCSP_RESPONDER_2020.cer");
+ X509Certificate[] certificates = CertificateLoader.loadCertificatesFromResources(
+ "TEST_of_ESTEID-SK_2015.cer",
+ "TEST_of_ESTEID2018.cer",
+ "TEST_of_SK_OCSP_RESPONDER_2020.cer",
+ "TEST_of_self-signed_OCSP_RESPONDER.cer",
+ "DEMO_of_ESTEID-SK_2018_AIA_OCSP_RESPONDER_2018.cer",
+ "TEST_of_EE_Certification_Centre_Root_CA.cer");
testEsteid2015CA = certificates[0];
testEsteid2018CA = certificates[1];
testSkOcspResponder2020 = certificates[2];
+ testSelfSignedOcspResponder = certificates[3];
+ demoEsteidSk2018AiaOcspResponder = certificates[4];
+ testEeCertCentreRootCA = certificates[5];
}
public static X509Certificate getTestEsteid2018CA() throws CertificateException, IOException {
@@ -54,6 +66,27 @@ public static X509Certificate getTestSkOcspResponder2020() throws CertificateExc
return testSkOcspResponder2020;
}
+ public static X509Certificate getTestSelfSignedOcspResponder() throws CertificateException, IOException {
+ if (testSelfSignedOcspResponder == null) {
+ loadCertificates();
+ }
+ return testSelfSignedOcspResponder;
+ }
+
+ public static X509Certificate getDemoEsteidSk2018AiaOcspResponder() throws CertificateException, IOException {
+ if (demoEsteidSk2018AiaOcspResponder == null) {
+ loadCertificates();
+ }
+ return demoEsteidSk2018AiaOcspResponder;
+ }
+
+ public static X509Certificate getTestEeCertCentreRootCA() throws CertificateException, IOException {
+ if (testEeCertCentreRootCA == null) {
+ loadCertificates();
+ }
+ return testEeCertCentreRootCA;
+ }
+
public static X509Certificate getJaakKristjanEsteid2018Cert() throws CertificateDecodingException {
if (jaakKristjanEsteid2018Cert == null) {
jaakKristjanEsteid2018Cert = CertificateLoader.decodeCertificateFromBase64(JAAK_KRISTJAN_ESTEID2018_CERT);
diff --git a/src/test/java/eu/webeid/security/testutil/ResourceUtil.java b/src/test/java/eu/webeid/security/testutil/ResourceUtil.java
new file mode 100644
index 00000000..cd6f29fc
--- /dev/null
+++ b/src/test/java/eu/webeid/security/testutil/ResourceUtil.java
@@ -0,0 +1,21 @@
+// SPDX-FileCopyrightText: Estonian Information System Authority
+// SPDX-License-Identifier: MIT
+
+package eu.webeid.security.testutil;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Objects;
+
+public final class ResourceUtil {
+
+ private ResourceUtil() {
+ }
+
+ public static byte[] bytesFromResource(String resource) throws IOException {
+ try (final InputStream resourceAsStream = ClassLoader.getSystemResourceAsStream(resource)) {
+ Objects.requireNonNull(resourceAsStream, () -> "Resource not found: " + resource);
+ return resourceAsStream.readAllBytes();
+ }
+ }
+}
diff --git a/src/test/java/eu/webeid/security/testutil/TestCertificateBuilder.java b/src/test/java/eu/webeid/security/testutil/TestCertificateBuilder.java
new file mode 100644
index 00000000..cf3a842a
--- /dev/null
+++ b/src/test/java/eu/webeid/security/testutil/TestCertificateBuilder.java
@@ -0,0 +1,51 @@
+// SPDX-FileCopyrightText: Estonian Information System Authority
+// SPDX-License-Identifier: MIT
+
+package eu.webeid.security.testutil;
+
+import org.bouncycastle.asn1.x500.X500Name;
+import org.bouncycastle.asn1.x509.Extension;
+import org.bouncycastle.cert.X509v3CertificateBuilder;
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
+import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
+import org.bouncycastle.operator.ContentSigner;
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
+
+import java.math.BigInteger;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.cert.X509Certificate;
+import java.time.Instant;
+import java.util.Date;
+
+public final class TestCertificateBuilder {
+
+ private static final KeyPair TEST_KEY_PAIR = generateKeyPair();
+
+ public static X509Certificate buildCertificate(Extension... extensions) throws Exception {
+ final X500Name name = new X500Name("CN=Test OCSP Responder");
+ final Instant now = Instant.now();
+ final X509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder(
+ name, BigInteger.ONE, Date.from(now.minusSeconds(60)), Date.from(now.plusSeconds(3600)),
+ name, TEST_KEY_PAIR.getPublic());
+ for (final Extension extension : extensions) {
+ builder.addExtension(extension);
+ }
+ final ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(TEST_KEY_PAIR.getPrivate());
+ return new JcaX509CertificateConverter().getCertificate(builder.build(signer));
+ }
+
+ private static KeyPair generateKeyPair() {
+ try {
+ final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
+ keyPairGenerator.initialize(2048);
+ return keyPairGenerator.generateKeyPair();
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ private TestCertificateBuilder() {
+ throw new IllegalStateException("Utility class");
+ }
+}
diff --git a/src/test/java/eu/webeid/security/validator/AuthTokenCertificateTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenCertificateTest.java
index c5043a2d..e5be8f46 100644
--- a/src/test/java/eu/webeid/security/validator/AuthTokenCertificateTest.java
+++ b/src/test/java/eu/webeid/security/validator/AuthTokenCertificateTest.java
@@ -4,8 +4,10 @@
package eu.webeid.security.validator;
import com.fasterxml.jackson.databind.exc.MismatchedInputException;
+import eu.webeid.ocsp.exceptions.UserCertificateRevokedException;
import eu.webeid.security.authtoken.WebEidAuthToken;
import eu.webeid.security.exceptions.AuthTokenException;
+import eu.webeid.security.exceptions.AuthTokenSignatureValidationException;
import eu.webeid.security.exceptions.AuthTokenParseException;
import eu.webeid.security.exceptions.CertificateDecodingException;
import eu.webeid.security.exceptions.CertificateExpiredException;
@@ -47,8 +49,6 @@ class AuthTokenCertificateTest extends AbstractTestWithValidator {
private static final String OLD_MOBILE_ID_CERT = "MIIE/TCCAuWgAwIBAgIQKbCN+05vfp1XOXVu6HMXRTANBgkqhkiG9w0BAQsFADBjMQswCQYDVQQGEwJFRTEiMCAGA1UECgwZQVMgU2VydGlmaXRzZWVyaW1pc2tlc2t1czEXMBUGA1UEYQwOTlRSRUUtMTA3NDcwMTMxFzAVBgNVBAMMDkVTVEVJRC1TSyAyMDE1MB4XDTE2MDUxNjA3MjMyNloXDTIxMDUxNjIwNTk1OVowgZsxCzAJBgNVBAYTAkVFMRswGQYDVQQKDBJFU1RFSUQgKE1PQklJTC1JRCkxFzAVBgNVBAsMDmF1dGhlbnRpY2F0aW9uMSAwHgYDVQQDDBdLQVNTLEFSVFVSSSwzNjAxMjM0NTY3ODENMAsGA1UEBAwES0FTUzEPMA0GA1UEKgwGQVJUVVJJMRQwEgYDVQQFEwszNjAxMjM0NTY3ODBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABBMstpyGLcAEXkctwF8TkyPUl1IizQb6nBvfEIaayMmNzZFRCNLZfV6z4AXJN58Mjs4d4RXsARU1vjBsi8yJMaCjggE9MIIBOTAJBgNVHRMEAjAAMA4GA1UdDwEB/wQEAwIEsDBbBgNVHSAEVDBSMFAGCisGAQQBzh8BAwMwQjAdBggrBgEFBQcCAjARDA9Db250cmFjdCAxLjExLTkwIQYIKwYBBQUHAgEWFWh0dHBzOi8vd3d3LnNrLmVlL2NwczAfBgNVHREEGDAWgRRoZWlra2kua2l0dEBlZXN0aS5lZTAdBgNVHQ4EFgQUkNR/r9m77o9Gsp04JFIBqEATVGQwIAYDVR0lAQH/BBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMEMB8GA1UdIwQYMBaAFLOriLyZ1WKkhSoIzbQdcjuDckdRMDwGA1UdHwQ1MDMwMaAvoC2GK2h0dHA6Ly93d3cuc2suZWUvY3Jscy9lc3RlaWQvZXN0ZWlkMjAxNS5jcmwwDQYJKoZIhvcNAQELBQADggIBAEJCCMR6IQ/Xncjyj9jtlFm1UBuf4g71GfSQMtL1g3ZxLBc77aKwBBtTuOTmPDUdY5+xA5dDfj6nFRdZZwWedps9Tm5T98G+y0477TCfP29UlnpQ55EtdXGdgzeDMlCpmRpsu2YceajdpQncp6A8tJsG+hW733O6W2dqrQV2e7Dnofm20KVHHlUa+ma+pGvaHDYXHgYAD1o58Ro+JHy7Bw3jo4Lg+j/CuBzTk4T6D05Ybnvv58/PBrPjASwKVhjNNvHYwgDmeGzQKocmDWUSRjTiWAxP9PYxwuiP2epoypyv9VPJEe3dy3EWgY+iPfMN2BuFdmZtKSHdWSeqCK+jro4kzjWrGYY+JbxSYmfF3GWWAj5sq/+/S2SgiFWGHdvtr1kVr7DaLDmz8N/QyrNjVVz4bYkzj9OPM7ofJs1QDE/2yGCkpJ628zB1ATpEjtVsit5hti0d3k1cIBTbtiUuSrOGHTyxwRenvIZ/MckvLFmTKZ6m255ASjtwqTf2Z+Jo13Adr5zhRvQvY+qGfZb/E4KhVSrcUI8OgTXzkPGIdm6tYETKqmdgevG16noPxzmzf6DJsjnbrOwuEhRykUzSRWO+h0pA7lvjLN8SKbCn/uWARN1XsbarA0yAnW484Gsu8psOaGpZ+VzM7z5/yv/BY2aTPP0hIeI0cqRtko7Zs6vH";
private static final String NEW_MOBILE_ID_CERT = "MIIFmDCCA4CgAwIBAgIQMwcy8Yggv2lfTTKLQOhNYTANBgkqhkiG9w0BAQsFADBjMQswCQYDVQQGEwJFRTEiMCAGA1UECgwZQVMgU2VydGlmaXRzZWVyaW1pc2tlc2t1czEXMBUGA1UEYQwOTlRSRUUtMTA3NDcwMTMxFzAVBgNVBAMMDkVTVEVJRC1TSyAyMDE1MB4XDTIwMDgzMTE3MjUzMVoXDTI1MDgzMTIwNTk1OVowcTELMAkGA1UEBhMCRUUxIzAhBgNVBAMMGkvDlVZFUlNBUixNQVRJLDM4MzA5MTQwNDIwMRIwEAYDVQQEDAlLw5VWRVJTQVIxDTALBgNVBCoMBE1BVEkxGjAYBgNVBAUTEVBOT0VFLTM4MzA5MTQwNDIwMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEJcgIjZEEIyIMGVli1xp88YlS8PnWBCcXdBiBTDNiVT/4OlTTteBBIePn2vKWOgUNDOWyFsBQRPy93Pig2thAzqOCAgMwggH/MAkGA1UdEwQCMAAwDgYDVR0PAQH/BAQDAgOIMHIGA1UdIARrMGkwXQYJKwYBBAHOHwEDMFAwLwYIKwYBBQUHAgEWI2h0dHBzOi8vd3d3LnNrLmVlL3JlcG9zaXRvb3JpdW0vQ1BTMB0GCCsGAQUFBwICMBEaD0NvbnRyYWN0IDEuMTEtOTAIBgYEAI96AQIwIQYDVR0RBBowGIEWbWF0aS5rb3ZlcnNhckBlZXN0aS5lZTAdBgNVHQ4EFgQUaNW6n29aMRbULPtIyvmRq50g8g8wHwYDVR0jBBgwFoAUs6uIvJnVYqSFKgjNtB1yO4NyR1EwagYIKwYBBQUHAQEEXjBcMCcGCCsGAQUFBzABhhtodHRwOi8vYWlhLnNrLmVlL2VzdGVpZDIwMTUwMQYIKwYBBQUHMAKGJWh0dHA6Ly9jLnNrLmVlL0VTVEVJRC1TS18yMDE1LmRlci5jcnQwYQYIKwYBBQUHAQMEVTBTMFEGBgQAjkYBBTBHMEUWP2h0dHBzOi8vc2suZWUvZW4vcmVwb3NpdG9yeS9jb25kaXRpb25zLWZvci11c2Utb2YtY2VydGlmaWNhdGVzLxMCRU4wPAYDVR0fBDUwMzAxoC+gLYYraHR0cDovL3d3dy5zay5lZS9jcmxzL2VzdGVpZC9lc3RlaWQyMDE1LmNybDANBgkqhkiG9w0BAQsFAAOCAgEAFvYmmaeNvh6vakizkv6HvXYXXDvTIVA7Z3WsXN9ZffvqiqM6wg9iIBiWjUE5qKh0sVa6qlxTHJ90keltgWsLkPbyCeJvsl7npI+/3NvHjwR83dHzmi8V21zLAVwg3nGpbklg2uTPAagQfRwa91D3+SPu/2Uo6a/vwJVTaMZP+PEHEkCmNuwPAECgnniNgcwlCK0mhNK9urDWewsDcZqRIi61QyQzHde9l0IUlTcI8nPzIma5f831xVXGqi98WQZpqfHsdPL14wwAP5UjszvDe3DOvx0eARhoSrm8MLj3Y9oN82oM0XBIc6uRw0KNp8lunHMIAL2b30ULJkXMLLdA/FK4KS2Mvlt+dO3x+tqKUGX4wrxPtNmWvvLFKfPpzjLKDl/JA6fBcD2LHcKSDMK8Jgcokl3tzI8zG0RKy3yDCpA+c3CP7pIDLBb0fpNzjUAtTS72mgAzRbFNXctN05uekYmThU2Z71MvUCw0JixN6G7DmiOe3cp9kA01f0RBlM76f2x6YmZ7XCdI0JNQm8SpyctVX/2Sbed0kbjpOV05CFEtWWYlBO3oHf7Sf0jqYrs3SN999MHHCg4wdsWUJiGuILyMJi+gQphID/PgjDW9qxLd0kqK1cBLKybwgBScdt5KZjrTKYWOSTwvh5FhFKVVwsCMeOh/+ojKWhX6uKhwMOQ=";
- private static final String EXPIRED_RSA_CERT = "MIIE3DCCA8SgAwIBAgIQbLnhZj25xUtSW9CfBn46KjANBgkqhkiG9w0BAQUFADBkMQswCQYDVQQGEwJFRTEiMCAGA1UECgwZQVMgU2VydGlmaXRzZWVyaW1pc2tlc2t1czEXMBUGA1UEAwwORVNURUlELVNLIDIwMTExGDAWBgkqhkiG9w0BCQEWCXBraUBzay5lZTAeFw0xMzEwMTQxMTA4MTVaFw0xODEwMTEyMDU5NTlaMIGPMQswCQYDVQQGEwJFRTEPMA0GA1UECgwGRVNURUlEMRcwFQYDVQQLDA5hdXRoZW50aWNhdGlvbjEgMB4GA1UEAwwXS0lUVCxIRUlLS0ksMzc3MTIzMDAyNTUxDTALBgNVBAQMBEtJVFQxDzANBgNVBCoMBkhFSUtLSTEUMBIGA1UEBRMLMzc3MTIzMDAyNTUwggEjMA0GCSqGSIb3DQEBAQUAA4IBEAAwggELAoIBAQBopcNoApF/o+YyVcHaonVhCbUYfUhDtoP2VDOKXNytBNIFO5uEL86mMOcfTURfOssrpvQBVgKWgQ0wjhq09qkfPJM9NbPz0VytcsGARKSNcPh1BKgnUnfd0M6SwSl1rFl2zvbDBfZTMDtQbROS4eV1wBXwa8XeHqQmTOZK/4mv+6fj0q/LzPmxUHP/LJbyjm07MAVzTAGFvanICPdTY9YQUyNCtp+r8RxjNEk/FjVDi9zgER7Tg/v/VEnjUdZG4pLZXnV+4EsBcH2Y/XoPq3Ou0ts3IG02iz83UFR0o3TYQnHnW9fMwToJRQzS3Bnd+NZee+yZZNKOUvxmn8f4dsDdAgR3CME3o4IBWzCCAVcwCQYDVR0TBAIwADAOBgNVHQ8BAf8EBAMCBLAwUQYDVR0gBEowSDBGBgsrBgEEAc4fAQEDAzA3MBIGCCsGAQUFBwICMAYaBG5vbmUwIQYIKwYBBQUHAgEWFWh0dHA6Ly93d3cuc2suZWUvY3BzLzAfBgNVHREEGDAWgRRoZWlra2kua2l0dEBlZXN0aS5lZTAdBgNVHQ4EFgQUC8nhz1ziuRJnO6hJIBYthupzYkYwIAYDVR0lAQH/BBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMEMCIGCCsGAQUFBwEDBBYwFDAIBgYEAI5GAQEwCAYGBACORgEEMB8GA1UdIwQYMBaAFHtq8lVQXLjZegiHQa76ois9W1d2MEAGA1UdHwQ5MDcwNaAzoDGGL2h0dHA6Ly93d3cuc2suZWUvcmVwb3NpdG9yeS9jcmxzL2VzdGVpZDIwMTEuY3JsMA0GCSqGSIb3DQEBBQUAA4IBAQBV7ohEG05MXcxHEeXOb2hNuLrVVT2dhOVwp21M13sOsG9l/GN8KEUR4JQcJo4zEK49zHCaA1qKg+4/mubWfMKz5eUS9njs7cuit2FjlTJUrm7ye9dKndGiv5o4T4ycvbUF4NJ6AvxXZLolfLTaF6Ge/c15Jz1WmBv0x+0C00d0qWkE3VVjwqYxUw9gJlWfbLLxqsT1pUXaf9JcsxdKXkhKKr9eQ7r00PwbARkKyeU/ylHGfOQlZeGXfyWxX1q1ZALicwJe6/UbQTqQeLn5Mviw/49H2rLb9BImFIJ30QYBlj9SGSHSZ5k11XPRaw2GfLrgoBqOjMUyKhfRxqJwb/xL";
- private static final String EXPIRED_ECDSA_CERT = "MIIF0TCCA7mgAwIBAgIQMBVFXroEt3hZ8FHcKKE65TANBgkqhkiG9w0BAQsFADBjMQswCQYDVQQGEwJFRTEiMCAGA1UECgwZQVMgU2VydGlmaXRzZWVyaW1pc2tlc2t1czEXMBUGA1UEYQwOTlRSRUUtMTA3NDcwMTMxFzAVBgNVBAMMDkVTVEVJRC1TSyAyMDE1MB4XDTE3MTAyNTA4NTcwMFoXDTIxMDIxMDIxNTk1OVowgYsxCzAJBgNVBAYTAkVFMQ8wDQYDVQQKDAZFU1RFSUQxFzAVBgNVBAsMDmF1dGhlbnRpY2F0aW9uMR4wHAYDVQQDDBVUT09NLE1BUlQsMzc2MDIwNDAzMzQxDTALBgNVBAQMBFRPT00xDTALBgNVBCoMBE1BUlQxFDASBgNVBAUTCzM3NjAyMDQwMzM0MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAExS1YQQBDLVvOi0a2GA5Y34AXODpx0AL8eKDOB7BjwBc/FAyVExhfb6O+lT5Tnaec3GnT4JNRyeV8d82L8cyOgFn4PWc+5cjFdmcZjJbtCvgyBOQQ831tteIDL2XSrvZEo4ICBDCCAgAwCQYDVR0TBAIwADAOBgNVHQ8BAf8EBAMCA4gwUwYDVR0gBEwwSjA+BgkrBgEEAc4fAQEwMTAvBggrBgEFBQcCARYjaHR0cHM6Ly93d3cuc2suZWUvcmVwb3NpdG9vcml1bS9DUFMwCAYGBACPegECMB8GA1UdEQQYMBaBFG1hcnQudG9vbS4zQGVlc3RpLmVlMB0GA1UdDgQWBBSzneoLqtqbvHvJ19cjhp2XR5ovQTAgBgNVHSUBAf8EFjAUBggrBgEFBQcDAgYIKwYBBQUHAwQwHwYDVR0jBBgwFoAUs6uIvJnVYqSFKgjNtB1yO4NyR1EwYQYIKwYBBQUHAQMEVTBTMFEGBgQAjkYBBTBHMEUWP2h0dHBzOi8vc2suZWUvZW4vcmVwb3NpdG9yeS9jb25kaXRpb25zLWZvci11c2Utb2YtY2VydGlmaWNhdGVzLxMCRU4wagYIKwYBBQUHAQEEXjBcMCcGCCsGAQUFBzABhhtodHRwOi8vYWlhLnNrLmVlL2VzdGVpZDIwMTUwMQYIKwYBBQUHMAKGJWh0dHA6Ly9jLnNrLmVlL0VTVEVJRC1TS18yMDE1LmRlci5jcnQwPAYDVR0fBDUwMzAxoC+gLYYraHR0cDovL3d3dy5zay5lZS9jcmxzL2VzdGVpZC9lc3RlaWQyMDE1LmNybDANBgkqhkiG9w0BAQsFAAOCAgEAOXTvktUXqPgaK/uxzgH0xSEYClBAWIQaNgpqY5lwsQtgQnpfKlsADqMZxCp7UuuMvQmpDbBxv1kIr0oG1uUXrUtPw81XOH1ClwPPXWpg9VRTAetNbHTBbHDyzuXQMNeDmrntChs+BteletejGD+aYG39HGMlrMbGQZOgvQrpYHMDek0ckCPEsZRXqUP0g7Ie7uBQhz5At7l4EDAeOW8xGoI6t+Ke4GedccXKef60w2ZIIDzvOFHPTc6POCsIlFtF/nCKwVi7GoQKjbUbM5OdBLZ0jyLq2LvzZuT86Jo8wObziuSzApGlBexHAqLrR83q+/Xl61yPnFf3w2kAfS9kBjeunzTH7Jm3pNT3Zq9JRLvEDqtpOPqr4zm9nG6OSghFU6tySkpQ5HiakGpMcnt5o5KuXhQ+Dg317tdXPyQkSiuJ9NfEBW0ijrwO12SVRzYo/jRl4ZQUkAEEUSMEsC6gTsZypPdIsLDVoQWTytHDU89s1xJDn4HulPl12dFnrhlLeX4RxOjDxppZxdjBU0FoJoDB0qwEAN2TMAPJWh+Pp9mFuS/u0dht9sKvAkpx+o0Z7v7QMz03XlzCHOLTIK+f81Rjokl8f+wiog5Ojj0wZkDe6DuQC9L5uDey3PJHv3naVovhs7jrEJu+yrsLue/OHhAgWRh2S75/wlVPHPEE44k=";
private static final String REVOKED_CERT = "MIIERDCCA6agAwIBAgIQSs8/WoDixVxbKRhNnF/GEzAKBggqhkjOPQQDBDBgMQswCQYDVQQGEwJFRTEbMBkGA1UECgwSU0sgSUQgU29sdXRpb25zIEFTMRcwFQYDVQRhDA5OVFJFRS0xMDc0NzAxMzEbMBkGA1UEAwwSVEVTVCBvZiBFU1RFSUQyMDE4MB4XDTE4MDYxOTE0NTA1M1oXDTIwMDEwMjIxNTk1OVowfzELMAkGA1UEBhMCRUUxKjAoBgNVBAMMIUrDlUVPUkcsSkFBSy1LUklTVEpBTiwzODAwMTA4NTcxODEQMA4GA1UEBAwHSsOVRU9SRzEWMBQGA1UEKgwNSkFBSy1LUklTVEpBTjEaMBgGA1UEBRMRUE5PRUUtMzgwMDEwODU3MTgwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAR/jopNG3KL0ZQUvO4OGSvcaqUtFDm3azOtsM2VRp666r0d36Zh0Zx/xej8f+SzEfWvvDT1HQLo3USiSbYn1FyNHTNxifV+Zvf6StXJAkdu24d1UvKbf+LylglO/yS7o4ijggIEMIICADAJBgNVHRMEAjAAMA4GA1UdDwEB/wQEAwIDiDBHBgNVHSAEQDA+MDIGCysGAQQBg5F/AQIBMCMwIQYIKwYBBQUHAgEWFWh0dHBzOi8vd3d3LnNrLmVlL0NQUzAIBgYEAI96AQIwHwYDVR0RBBgwFoEUMzgwMDEwODU3MThAZWVzdGkuZWUwHQYDVR0OBBYEFEQA6/1GXJtp+6czUzorhEJ7B95pMGEGCCsGAQUFBwEDBFUwUzBRBgYEAI5GAQUwRzBFFj9odHRwczovL3NrLmVlL2VuL3JlcG9zaXRvcnkvY29uZGl0aW9ucy1mb3ItdXNlLW9mLWNlcnRpZmljYXRlcy8TAkVOMCAGA1UdJQEB/wQWMBQGCCsGAQUFBwMCBggrBgEFBQcDBDAfBgNVHSMEGDAWgBTAhJkpxE6fOwI09pnhClYACCk+ezB/BggrBgEFBQcBAQRzMHEwLAYIKwYBBQUHMAGGIGh0dHA6Ly9haWEuZGVtby5zay5lZS9lc3RlaWQyMDE4MEEGCCsGAQUFBzAChjVodHRwczovL3NrLmVlL3VwbG9hZC9maWxlcy9URVNUX29mX0VTVEVJRDIwMTguZGVyLmNydDAzBgNVHR8ELDAqMCigJqAkhiJodHRwOi8vYy5zay5lZS90ZXN0X2VzdGVpZDIwMTguY3JsMAoGCCqGSM49BAMEA4GLADCBhwJBcmcfLC+HcSJ6BuRrDGL+K+7BAW8BfAiiWWAuBV4ebLkbbAWmkc9dSKgr4BEGEt90xDTQ85yW4SjGulFXu9C3yQsCQgETaXTs3Hp6vDAcQYL8Bx4BO3DwJbDuD4BUJyT0+9HQiFCQmTQ4xrNjeaeOwRWyMOM9z5ORMeJCiQUyil1x4YPIbg==";
private MockedStatic mockedClock;
@@ -161,13 +161,13 @@ void whenCertificatePolicyIsWrong_thenValidationFails() throws AuthTokenExceptio
}
@Test
- void whenCertificatePoliciesExtensionIsMissing_thenValidationContinuesToTrustCheck() throws Exception {
+ void whenCertificatePoliciesExtensionIsMissing_thenValidationContinuesToSignatureCheck() throws Exception {
final String certificateWithoutPolicies = Base64.getEncoder()
.encodeToString(getCertificateWithoutCertificatePolicies().getEncoded());
final WebEidAuthToken token = replaceTokenField(AUTH_TOKEN, "X5C", certificateWithoutPolicies);
assertThatThrownBy(() -> validator
.validate(token, VALID_CHALLENGE_NONCE))
- .isInstanceOf(CertificateNotTrustedException.class);
+ .isInstanceOf(AuthTokenSignatureValidationException.class);
}
@Test
@@ -196,19 +196,18 @@ void whenUsingNewMobileIdCertificate_thenValidationFails() throws AuthTokenExcep
}
@Test
- void whenCertificateIsExpiredRsa_thenValidationFails() throws AuthTokenException {
- final WebEidAuthToken token = replaceTokenField(AUTH_TOKEN, "X5C", EXPIRED_RSA_CERT);
- assertThatThrownBy(() -> validator
- .validate(token, VALID_CHALLENGE_NONCE))
+ void whenCertificateIsExpiredRsa_thenValidationFails() throws Exception {
+ mockDate("2099-01-01", mockedClock);
+ final WebEidAuthToken token = validator.parse(VALID_RS256_AUTH_TOKEN);
+ assertThatThrownBy(() -> validator.validate(token, VALID_CHALLENGE_NONCE))
.isInstanceOf(CertificateExpiredException.class)
.hasMessage("User certificate has expired");
}
@Test
- void whenCertificateIsExpiredEcdsa_thenValidationFails() throws AuthTokenException {
- final WebEidAuthToken token = replaceTokenField(AUTH_TOKEN, "X5C", EXPIRED_ECDSA_CERT);
- assertThatThrownBy(() -> validator
- .validate(token, VALID_CHALLENGE_NONCE))
+ void whenCertificateIsExpiredEcdsa_thenValidationFails() throws Exception {
+ mockDate("2099-01-01", mockedClock);
+ assertThatThrownBy(() -> validator.validate(validAuthToken, VALID_CHALLENGE_NONCE))
.isInstanceOf(CertificateExpiredException.class)
.hasMessage("User certificate has expired");
}
diff --git a/src/test/java/eu/webeid/security/validator/AuthTokenSignatureValidatorTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenSignatureValidatorTest.java
index 5e294d53..dcbd0baa 100644
--- a/src/test/java/eu/webeid/security/validator/AuthTokenSignatureValidatorTest.java
+++ b/src/test/java/eu/webeid/security/validator/AuthTokenSignatureValidatorTest.java
@@ -5,20 +5,25 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectReader;
+import eu.webeid.security.authtoken.WebEidAuthToken;
import eu.webeid.security.certificate.CertificateLoader;
+import eu.webeid.security.exceptions.AuthTokenException;
import eu.webeid.security.exceptions.AuthTokenParseException;
import eu.webeid.security.exceptions.AuthTokenSignatureValidationException;
import io.jsonwebtoken.security.SignatureException;
import org.junit.jupiter.api.Test;
-import eu.webeid.security.authtoken.WebEidAuthToken;
import java.net.URI;
+import java.security.KeyPairGenerator;
+import java.security.PublicKey;
import java.security.cert.X509Certificate;
+import java.security.spec.ECGenParameterSpec;
+import static eu.webeid.security.testutil.AbstractTestWithValidator.VALID_AUTH_TOKEN;
+import static eu.webeid.security.testutil.AbstractTestWithValidator.VALID_CHALLENGE_NONCE;
+import static eu.webeid.security.testutil.AbstractTestWithValidator.VALID_RS256_AUTH_TOKEN;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
-import static eu.webeid.security.validator.AuthTokenSignatureTest.VALID_AUTH_TOKEN;
-import static eu.webeid.security.validator.AuthTokenSignatureTest.VALID_CHALLENGE_NONCE;
class AuthTokenSignatureValidatorTest {
@@ -46,12 +51,6 @@ void whenRsaSignatureHasInvalidLength_thenThrowsSignatureValidationExceptionWith
.hasCauseInstanceOf(SignatureException.class);
}
- private static final String VALID_RS256_AUTH_TOKEN = "{\"algorithm\":\"RS256\"," +
- "\"unverifiedCertificate\":\"MIIGvjCCBKagAwIBAgIQT7aXeR+zWlBb2Gbar+AFaTANBgkqhkiG9w0BAQsFADCBgzELMAkGA1UEBhMCTFYxOTA3BgNVBAoMMFZBUyBMYXR2aWphcyBWYWxzdHMgcmFkaW8gdW4gdGVsZXbEq3ppamFzIGNlbnRyczEaMBgGA1UEYQwRTlRSTFYtNDAwMDMwMTEyMDMxHTAbBgNVBAMMFERFTU8gTFYgZUlEIElDQSAyMDE3MB4XDTE4MTAzMDE0MTI0MloXDTIzMTAzMDE0MTI0MlowcDELMAkGA1UEBhMCTFYxHDAaBgNVBAMME0FORFJJUyBQQVJBVURaScWFxaAxFTATBgNVBAQMDFBBUkFVRFpJxYXFoDEPMA0GA1UEKgwGQU5EUklTMRswGQYDVQQFExJQTk9MVi0zMjE5MjItMzMwMzIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDXkra3rDOOt5K6OnJcg/Xt6JOogPAUBX2kT9zWelze7WSuPx2Ofs//0JoBQ575IVdh3JpLhfh7g60YYi41M6vNACVSNaFOxiEvE9amSFizMiLk5+dp+79rymqOsVQG8CSu8/RjGGlDsALeb3N/4pUSTGXUwSB64QuFhOWjAcmKPhHeYtry0hK3MbwwHzFhYfGpo/w+PL14PEdJlpL1UX/aPyT0Zq76Z4T/Z3PqbTmQp09+2b0thC0JIacSkyJuTu8fVRQvse+8UtYC6Kt3TBLZbPtqfAFSXWbuE47Lc2o840NkVlMHVAesoRAfiQxsK35YWFT0rHPWbLjX6ySiaL25AgMBAAGjggI+MIICOjAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAjAdBgNVHQ4EFgQUHZWimPze2GXULNaP4EFVdF+MWKQwHwYDVR0jBBgwFoAUj2jOvOLHQCFTCUK75Z4djEvNvTgwgfsGA1UdIASB8zCB8DA7BgYEAI96AQIwMTAvBggrBgEFBQcCARYjaHR0cHM6Ly93d3cuZXBhcmFrc3RzLmx2L3JlcG9zaXRvcnkwgbAGDCsGAQQBgfo9AgECATCBnzAvBggrBgEFBQcCARYjaHR0cHM6Ly93d3cuZXBhcmFrc3RzLmx2L3JlcG9zaXRvcnkwbAYIKwYBBQUHAgIwYAxexaBpcyBzZXJ0aWZpa8SBdHMgaXIgaWVrxLxhdXRzIExhdHZpamFzIFJlcHVibGlrYXMgaXpzbmllZ3TEgSBwZXJzb251IGFwbGllY2lub8WhxIEgZG9rdW1lbnTEgTB9BggrBgEFBQcBAQRxMG8wQgYIKwYBBQUHMAKGNmh0dHA6Ly9kZW1vLmVwYXJha3N0cy5sdi9jZXJ0L2RlbW9fTFZfZUlEX0lDQV8yMDE3LmNydDApBggrBgEFBQcwAYYdaHR0cDovL29jc3AucHJlcC5lcGFyYWtzdHMubHYwSAYDVR0fBEEwPzA9oDugOYY3aHR0cDovL2RlbW8uZXBhcmFrc3RzLmx2L2NybC9kZW1vX0xWX2VJRF9JQ0FfMjAxN18zLmNybDANBgkqhkiG9w0BAQsFAAOCAgEAAOVoRbnMv2UXWYHgnmO9Zg9u8F1YvJiZPMeTYE2CVaiq0nXe4Mq0X5tWcsEiRpGQF9e0dWC6V5m6EmAsHxIRL4chZKRrIrPEiWtP3zyRI1/X2y5GwSUyZmgxkuSOHHw3UjzjrnOoI9izpC0OSNeumqpjT/tLAi35sktGkK0onEUPWGQnZLqd/hzykm+H/dmD27nOnfCJOSqbegLSbhV2w/WAII+IUD3vJ06F6rf9ZN8xbrGkPO8VMCIDIt0eBKFxBdSOgpsTfbERbjQJ+nFEDYhD0bFNYMsFSGnZiWpNaCcZSkk4mtNUa8sNXyaFQGIZk6NjQ/fsBANhUoxFz7rUKrRYqk356i8KFDZ+MJqUyodKKyW9oz+IO5eJxnL78zRbxD+EfAUmrLXOjmGIzU95RR1smS4cirrrPHqGAWojBk8hKbjNTJl9Tfbnsbc9/FUBJLVZAkCi631KfRLQ66bn8N0mbtKlNtdX0G47PXTy7SJtWwDtKQ8+qVpduc8xHLntbdAzie3mWyxA1SBhQuZ9BPf5SPBImWCNpmZNCTmI2e+4yyCnmG/kVNilUAaODH/fgQXFGdsKO/XATFohiies28twkEzqtlVZvZbpBhbJCHYVnQXMhMKcnblkDqXWcSWd3QAKig2yMH95uz/wZhiV+7tZ7cTgwcbCzIDCfpwBC3E=\"," +
- "\"issuerApp\":\"https://web-eid.eu/web-eid-app/releases/2.0.0+0\"," +
- "\"signature\":\"xsjXsQvVYXWcdV0YPhxLthJxtf0//R8p9WFFlYJGRARrl1ruyoAUwl0xeHgeZOKeJtwiCYCNWJzCG3VM3ydgt92bKhhk1u0JXIPVqvOkmDY72OCN4q73Y8iGSPVTgjk93TgquHlodf7YcqZNhutwNNf3oldHEWJD5zmkdwdpBFXgeOwTAdFwGljDQZbHr3h1Dr+apUDuloS0WuIzUuu8YXN2b8lh8FCTlF0G0DEjhHd/MGx8dbe3UTLHmD7K9DXv4zLJs6EF9i2v/C10SIBQDkPBSVPqMxCDPECjbEPi2+ds94eU7ThOhOQlFFtJ4KjQNTUa2crSixH7cYZF2rNNmA==\"," +
- "\"format\":\"web-eid:1.0\"}";
-
@Test
void whenValidES384Signature_thenSucceeds() throws Exception {
final AuthTokenSignatureValidator signatureValidator =
@@ -78,4 +77,39 @@ void whenValidRS256Signature_thenSucceeds() throws Exception {
.doesNotThrowAnyException();
}
+ @Test
+ void whenRsaKeyIsTooShortForAlgorithm_thenThrowsAuthTokenSignatureValidationException() throws Exception {
+ final AuthTokenSignatureValidator signatureValidator =
+ new AuthTokenSignatureValidator(URI.create("https://ria.ee"));
+
+ final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
+ keyPairGenerator.initialize(1024);
+ final PublicKey weakPublicKey = keyPairGenerator.generateKeyPair().getPublic();
+
+ final WebEidAuthToken authToken = OBJECT_READER.readValue(VALID_RS256_AUTH_TOKEN);
+
+ // The public key comes from the unverified certificate of the token, so a key that JJWT refuses
+ // to use must fail with a checked AuthTokenException, not with an unchecked JJWT exception.
+ assertThatThrownBy(() -> signatureValidator
+ .validate("RS256", authToken.signature(), weakPublicKey, VALID_CHALLENGE_NONCE))
+ .isInstanceOf(AuthTokenSignatureValidationException.class);
+ }
+
+ @Test
+ void whenEcKeyDoesNotMatchAlgorithm_thenThrowsAuthTokenSignatureValidationException() throws Exception {
+ final AuthTokenSignatureValidator signatureValidator =
+ new AuthTokenSignatureValidator(URI.create("https://ria.ee"));
+
+ final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC");
+ keyPairGenerator.initialize(new ECGenParameterSpec("secp256r1"));
+ final PublicKey p256PublicKey = keyPairGenerator.generateKeyPair().getPublic();
+
+ final WebEidAuthToken authToken = OBJECT_READER.readValue(VALID_AUTH_TOKEN);
+
+ // ES512 requires a P-521 key, so JJWT rejects the P-256 key of the unverified certificate.
+ assertThatThrownBy(() -> signatureValidator
+ .validate("ES512", authToken.signature(), p256PublicKey, VALID_CHALLENGE_NONCE))
+ .isInstanceOf(AuthTokenException.class);
+ }
+
}
diff --git a/src/test/java/eu/webeid/security/validator/AuthTokenValidatorValidationOrderTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenValidatorValidationOrderTest.java
new file mode 100644
index 00000000..a9b3515e
--- /dev/null
+++ b/src/test/java/eu/webeid/security/validator/AuthTokenValidatorValidationOrderTest.java
@@ -0,0 +1,80 @@
+// SPDX-FileCopyrightText: Estonian Information System Authority
+// SPDX-License-Identifier: MIT
+
+package eu.webeid.security.validator;
+
+import eu.webeid.security.authtoken.WebEidAuthToken;
+import eu.webeid.security.certificate.CertificateData;
+import eu.webeid.security.exceptions.AuthTokenSignatureValidationException;
+import eu.webeid.security.testutil.AuthTokenValidators;
+import eu.webeid.security.util.DateAndTime;
+import eu.webeid.security.validator.revocationcheck.CertificateRevocationChecker;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.MockedStatic;
+
+import java.security.cert.X509Certificate;
+import java.util.List;
+
+import static eu.webeid.security.testutil.AbstractTestWithValidator.VALID_AUTH_TOKEN;
+import static eu.webeid.security.testutil.AbstractTestWithValidator.VALID_AUTH_TOKEN_TEST_DATE;
+import static eu.webeid.security.testutil.AbstractTestWithValidator.VALID_CHALLENGE_NONCE;
+import static eu.webeid.security.testutil.DateMocker.mockDate;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class AuthTokenValidatorValidationOrderTest {
+
+ private MockedStatic mockedClock;
+
+ @BeforeEach
+ void setUp() {
+ mockedClock = mockStatic(DateAndTime.DefaultClock.class);
+ // Ensure that the certificates do not expire.
+ mockDate(VALID_AUTH_TOKEN_TEST_DATE, mockedClock);
+ }
+
+ @AfterEach
+ void tearDown() {
+ mockedClock.close();
+ }
+
+ @Test
+ void whenSignatureIsInvalid_thenRevocationCheckerIsNotInvoked() throws Exception {
+ CertificateRevocationChecker checker = mock(CertificateRevocationChecker.class);
+ AuthTokenValidator validator = AuthTokenValidators.getDefaultAuthTokenValidatorBuilder()
+ .withCertificateRevocationChecker(checker)
+ .build();
+ WebEidAuthToken token = validator.parse(VALID_AUTH_TOKEN);
+
+ assertThatThrownBy(() -> validator.validate(token, "invalidToken"))
+ .isInstanceOf(AuthTokenSignatureValidationException.class);
+ verify(checker, never()).validateCertificateNotRevoked(any(), any());
+ }
+
+ @Test
+ void whenSignatureIsValid_thenRevocationCheckerIsInvoked() throws Exception {
+ CertificateRevocationChecker checker = mock(CertificateRevocationChecker.class);
+ when(checker.validateCertificateNotRevoked(any(), any())).thenReturn(List.of());
+ AuthTokenValidator validator = AuthTokenValidators.getDefaultAuthTokenValidatorBuilder()
+ .withCertificateRevocationChecker(checker)
+ .build();
+ WebEidAuthToken token = validator.parse(VALID_AUTH_TOKEN);
+
+ assertThatCode(() -> validator.validate(token, VALID_CHALLENGE_NONCE))
+ .doesNotThrowAnyException();
+ ArgumentCaptor subjectCaptor = ArgumentCaptor.forClass(X509Certificate.class);
+ verify(checker).validateCertificateNotRevoked(subjectCaptor.capture(), any());
+ assertThat(CertificateData.getSubjectCN(subjectCaptor.getValue()).orElseThrow())
+ .isEqualTo("JÕEORG\\,JAAK-KRISTJAN\\,38001085718");
+ }
+}
diff --git a/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePurposeValidatorTest.java b/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePurposeValidatorTest.java
new file mode 100644
index 00000000..20404132
--- /dev/null
+++ b/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePurposeValidatorTest.java
@@ -0,0 +1,67 @@
+// SPDX-FileCopyrightText: Estonian Information System Authority
+// SPDX-License-Identifier: MIT
+
+package eu.webeid.security.validator.certvalidators;
+
+import eu.webeid.security.exceptions.UserCertificateMissingPurposeException;
+import eu.webeid.security.exceptions.UserCertificateWrongPurposeException;
+import org.junit.jupiter.api.Test;
+
+import java.security.cert.X509Certificate;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class SubjectCertificatePurposeValidatorTest {
+
+ private static final String EXTENDED_KEY_USAGE_CLIENT_AUTHENTICATION = "1.3.6.1.5.5.7.3.2";
+ private static final String EXTENDED_KEY_USAGE_EMAIL_PROTECTION = "1.3.6.1.5.5.7.3.4";
+ // Key Usage bits: digitalSignature(0), keyAgreement(4).
+ private static final boolean[] DIGITAL_SIGNATURE_KEY_USAGE = {true, false, false, false, false, false, false, false, false};
+ private static final boolean[] KEY_AGREEMENT_KEY_USAGE = {false, false, false, false, true, false, false, false, false};
+
+ private final X509Certificate certificate = mock(X509Certificate.class);
+
+ @Test
+ void whenDigitalSignatureKeyUsageIsRequiredAndPresent_thenValidationSucceeds() throws Exception {
+ when(certificate.getKeyUsage()).thenReturn(DIGITAL_SIGNATURE_KEY_USAGE);
+ when(certificate.getExtendedKeyUsage()).thenReturn(List.of(EXTENDED_KEY_USAGE_CLIENT_AUTHENTICATION));
+ assertThatCode(() -> new SubjectCertificatePurposeValidator(true).validateCertificatePurpose(certificate))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void whenDigitalSignatureKeyUsageIsRequiredAndMissing_thenValidationFails() throws Exception {
+ when(certificate.getKeyUsage()).thenReturn(KEY_AGREEMENT_KEY_USAGE);
+ when(certificate.getExtendedKeyUsage()).thenReturn(List.of(EXTENDED_KEY_USAGE_CLIENT_AUTHENTICATION));
+ assertThatExceptionOfType(UserCertificateWrongPurposeException.class)
+ .isThrownBy(() -> new SubjectCertificatePurposeValidator(true).validateCertificatePurpose(certificate));
+ }
+
+ @Test
+ void whenDigitalSignatureKeyUsageIsNotRequiredAndMissing_thenValidationSucceeds() throws Exception {
+ when(certificate.getKeyUsage()).thenReturn(KEY_AGREEMENT_KEY_USAGE);
+ when(certificate.getExtendedKeyUsage()).thenReturn(List.of(EXTENDED_KEY_USAGE_CLIENT_AUTHENTICATION));
+ assertThatCode(() -> new SubjectCertificatePurposeValidator(false).validateCertificatePurpose(certificate))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void whenDigitalSignatureKeyUsageIsNotRequiredAndKeyUsageExtensionIsMissing_thenValidationFails() {
+ when(certificate.getKeyUsage()).thenReturn(null);
+ assertThatExceptionOfType(UserCertificateMissingPurposeException.class)
+ .isThrownBy(() -> new SubjectCertificatePurposeValidator(false).validateCertificatePurpose(certificate));
+ }
+
+ @Test
+ void whenDigitalSignatureKeyUsageIsNotRequiredAndClientAuthenticationIsMissing_thenValidationFails() throws Exception {
+ when(certificate.getKeyUsage()).thenReturn(KEY_AGREEMENT_KEY_USAGE);
+ when(certificate.getExtendedKeyUsage()).thenReturn(List.of(EXTENDED_KEY_USAGE_EMAIL_PROTECTION));
+ assertThatExceptionOfType(UserCertificateWrongPurposeException.class)
+ .isThrownBy(() -> new SubjectCertificatePurposeValidator(false).validateCertificatePurpose(certificate));
+ }
+
+}
diff --git a/src/test/resources/DEMO_of_ESTEID-SK_2018_AIA_OCSP_RESPONDER_2018.cer b/src/test/resources/DEMO_of_ESTEID-SK_2018_AIA_OCSP_RESPONDER_2018.cer
new file mode 100644
index 00000000..30ce32b0
Binary files /dev/null and b/src/test/resources/DEMO_of_ESTEID-SK_2018_AIA_OCSP_RESPONDER_2018.cer differ
diff --git a/src/test/resources/TEST_of_EE_Certification_Centre_Root_CA.cer b/src/test/resources/TEST_of_EE_Certification_Centre_Root_CA.cer
new file mode 100644
index 00000000..f51a8580
Binary files /dev/null and b/src/test/resources/TEST_of_EE_Certification_Centre_Root_CA.cer differ
diff --git a/src/test/resources/TEST_of_self-signed_OCSP_RESPONDER.cer b/src/test/resources/TEST_of_self-signed_OCSP_RESPONDER.cer
new file mode 100644
index 00000000..86fbcab1
Binary files /dev/null and b/src/test/resources/TEST_of_self-signed_OCSP_RESPONDER.cer differ
diff --git a/src/test/resources/ocsp_response_unknown_self_signed.der b/src/test/resources/ocsp_response_unknown_self_signed.der
new file mode 100644
index 00000000..9dee6fc4
Binary files /dev/null and b/src/test/resources/ocsp_response_unknown_self_signed.der differ