From 0b0e0786dcc81f0ba98399c8520c925f1df6514f Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Sun, 28 Jun 2026 11:08:57 +0300 Subject: [PATCH 01/14] NFC-128 Remove redundant empty AuthTokenValidatorImpl class --- .../java/eu/webeid/security/validator/AuthTokenValidatorImpl.java | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java deleted file mode 100644 index e69de29b..00000000 From 5a67cbd864255533972e30fb5459803a24374588 Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Sun, 28 Jun 2026 11:11:14 +0300 Subject: [PATCH 02/14] NFC-128 Increase maximum allowed token size limit --- .../validator/AuthTokenValidatorManager.java | 2 +- .../security/validator/AuthTokenStructureTest.java | 14 +++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorManager.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorManager.java index 76404a5a..148de9e0 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorManager.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorManager.java @@ -48,7 +48,7 @@ final class AuthTokenValidatorManager implements AuthTokenValidator { // Use human-readable meaningful names for token length limits. private static final int TOKEN_MIN_LENGTH = 100; - private static final int TOKEN_MAX_LENGTH = 10000; + private static final int TOKEN_MAX_LENGTH = 65536; private static final ObjectReader TOKEN_READER = new ObjectMapper().readerFor(WebEidAuthToken.class); diff --git a/src/test/java/eu/webeid/security/validator/AuthTokenStructureTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenStructureTest.java index 4b38f822..b35e2cd8 100644 --- a/src/test/java/eu/webeid/security/validator/AuthTokenStructureTest.java +++ b/src/test/java/eu/webeid/security/validator/AuthTokenStructureTest.java @@ -26,7 +26,10 @@ import eu.webeid.security.exceptions.AuthTokenParseException; import eu.webeid.security.testutil.AbstractTestWithValidator; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class AuthTokenStructureTest extends AbstractTestWithValidator { @@ -58,11 +61,20 @@ void whenTokenTooShort_thenParsingFails() { @Test void whenTokenTooLong_thenParsingFails() { assertThatThrownBy(() -> validator - .parse(new String(new char[10001]))) + .parse(new String(new char[65537]))) .isInstanceOf(AuthTokenParseException.class) .hasMessage("Auth token is too long"); } + @ParameterizedTest + @ValueSource(ints = {10001, 65536}) + void whenTokenIsWithinIncreasedLengthLimit_thenParsingSucceeds(int tokenLength) throws Exception { + final String token = VALID_AUTH_TOKEN + " ".repeat(tokenLength - VALID_AUTH_TOKEN.length()); + + assertThat(validator.parse(token).getFormat()) + .isEqualTo("web-eid:1.0"); + } + @Test void whenUnknownTokenVersion_thenParsingFails() throws Exception { WebEidAuthToken token = replaceTokenField(VALID_AUTH_TOKEN, "web-eid:1", "invalid"); From 4a7d91036f2465896d25442a17f8efff8cd8f88f Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Sun, 28 Jun 2026 11:13:08 +0300 Subject: [PATCH 03/14] NFC-128 Add intermediate certificates to auth token model --- .../UnverifiedSigningCertificate.java | 9 +++ .../security/authtoken/WebEidAuthToken.java | 9 +++ .../certificate/CertificateLoader.java | 11 +++ .../certificate/CertificateLoaderTest.java | 80 +++++++++++++++++++ 4 files changed, 109 insertions(+) create mode 100644 src/test/java/eu/webeid/security/certificate/CertificateLoaderTest.java diff --git a/src/main/java/eu/webeid/security/authtoken/UnverifiedSigningCertificate.java b/src/main/java/eu/webeid/security/authtoken/UnverifiedSigningCertificate.java index 745600f8..7731b9f0 100644 --- a/src/main/java/eu/webeid/security/authtoken/UnverifiedSigningCertificate.java +++ b/src/main/java/eu/webeid/security/authtoken/UnverifiedSigningCertificate.java @@ -30,6 +30,7 @@ public class UnverifiedSigningCertificate { private String certificate; + private List intermediateCertificates; private List supportedSignatureAlgorithms; public String getCertificate() { @@ -40,6 +41,14 @@ public void setCertificate(String certificate) { this.certificate = certificate; } + public List getIntermediateCertificates() { + return intermediateCertificates; + } + + public void setIntermediateCertificates(List intermediateCertificates) { + this.intermediateCertificates = intermediateCertificates; + } + public List getSupportedSignatureAlgorithms() { return supportedSignatureAlgorithms; } diff --git a/src/main/java/eu/webeid/security/authtoken/WebEidAuthToken.java b/src/main/java/eu/webeid/security/authtoken/WebEidAuthToken.java index a476446e..0bd4845b 100644 --- a/src/main/java/eu/webeid/security/authtoken/WebEidAuthToken.java +++ b/src/main/java/eu/webeid/security/authtoken/WebEidAuthToken.java @@ -34,6 +34,7 @@ public class WebEidAuthToken { private String algorithm; private String format; + private List unverifiedIntermediateCertificates; private List unverifiedSigningCertificates; public String getUnverifiedCertificate() { @@ -44,6 +45,14 @@ public void setUnverifiedCertificate(String unverifiedCertificate) { this.unverifiedCertificate = unverifiedCertificate; } + public List getUnverifiedIntermediateCertificates() { + return unverifiedIntermediateCertificates; + } + + public void setUnverifiedIntermediateCertificates(List unverifiedIntermediateCertificates) { + this.unverifiedIntermediateCertificates = unverifiedIntermediateCertificates; + } + public String getSignature() { return signature; } diff --git a/src/main/java/eu/webeid/security/certificate/CertificateLoader.java b/src/main/java/eu/webeid/security/certificate/CertificateLoader.java index 0da0a670..c7f9ff38 100644 --- a/src/main/java/eu/webeid/security/certificate/CertificateLoader.java +++ b/src/main/java/eu/webeid/security/certificate/CertificateLoader.java @@ -63,6 +63,17 @@ public static X509Certificate decodeCertificateFromBase64(String certificateInBa } } + public static List decodeCertificatesFromBase64(List certificatesInBase64) throws CertificateDecodingException { + if (certificatesInBase64 == null || certificatesInBase64.isEmpty()) { + return List.of(); + } + final List decodedCertificates = new ArrayList<>(); + for (final String certificateInBase64 : certificatesInBase64) { + decodedCertificates.add(decodeCertificateFromBase64(certificateInBase64)); + } + return decodedCertificates; + } + private CertificateLoader() { throw new IllegalStateException("Utility class"); } diff --git a/src/test/java/eu/webeid/security/certificate/CertificateLoaderTest.java b/src/test/java/eu/webeid/security/certificate/CertificateLoaderTest.java new file mode 100644 index 00000000..3ce9d7db --- /dev/null +++ b/src/test/java/eu/webeid/security/certificate/CertificateLoaderTest.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2020-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package eu.webeid.security.certificate; + +import eu.webeid.security.exceptions.CertificateDecodingException; +import org.junit.jupiter.api.Test; + +import java.security.cert.X509Certificate; +import java.util.Base64; +import java.util.List; + +import static eu.webeid.security.testutil.Certificates.getJaakKristjanEsteid2018Cert; +import static eu.webeid.security.testutil.Certificates.getMariliisEsteid2015Cert; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +class CertificateLoaderTest { + + @Test + void whenNullList_thenReturnsEmptyList() throws Exception { + assertThat(CertificateLoader.decodeCertificatesFromBase64(null)) + .isEmpty(); + } + + @Test + void whenEmptyList_thenReturnsEmptyList() throws Exception { + assertThat(CertificateLoader.decodeCertificatesFromBase64(List.of())) + .isEmpty(); + } + + @Test + void whenValidBase64Certificates_thenDecodesAll() throws Exception { + final X509Certificate firstCert = getJaakKristjanEsteid2018Cert(); + final X509Certificate secondCert = getMariliisEsteid2015Cert(); + + final List decoded = CertificateLoader.decodeCertificatesFromBase64( + List.of(toBase64(firstCert), toBase64(secondCert))); + + assertThat(decoded) + .containsExactly(firstCert, secondCert); + } + + @Test + void whenNotBase64_thenThrows() { + assertThatExceptionOfType(CertificateDecodingException.class) + .isThrownBy(() -> CertificateLoader.decodeCertificatesFromBase64(List.of("not base64!"))); + } + + @Test + void whenBase64ButNotACertificate_thenThrows() { + final String notACertificate = Base64.getEncoder().encodeToString("this is not a certificate".getBytes()); + + assertThatExceptionOfType(CertificateDecodingException.class) + .isThrownBy(() -> CertificateLoader.decodeCertificatesFromBase64(List.of(notACertificate))); + } + + private static String toBase64(X509Certificate certificate) throws Exception { + return Base64.getEncoder().encodeToString(certificate.getEncoded()); + } +} From 9cde0df8d46f1ef9be63e9707d2bff8bfca480a2 Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Thu, 2 Jul 2026 19:24:13 +0300 Subject: [PATCH 04/14] NFC-128 Return the subject's direct issuer or the trust anchor Once intermediate certificates can sit between the leaf and the trust anchor, the OCSP CertificateID must be built from the certificate that directly issued the subject, and RFC 6960 responder authorization must reference the same certificate. Return the direct issuer from the built certification path; when the path has a single certificate, the trust anchor is the direct issuer. Behavior is unchanged for configurations where every configured CA is a trust anchor. --- .../certificate/CertificateValidator.java | 17 ++- .../certificate/CertificateValidatorTest.java | 138 ++++++++++++++++++ 2 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 src/test/java/eu/webeid/security/certificate/CertificateValidatorTest.java diff --git a/src/main/java/eu/webeid/security/certificate/CertificateValidator.java b/src/main/java/eu/webeid/security/certificate/CertificateValidator.java index 8a6701b3..984769f6 100644 --- a/src/main/java/eu/webeid/security/certificate/CertificateValidator.java +++ b/src/main/java/eu/webeid/security/certificate/CertificateValidator.java @@ -33,6 +33,7 @@ import java.security.cert.CertPathBuilder; import java.security.cert.CertPathBuilderException; import java.security.cert.CertStore; +import java.security.cert.Certificate; import java.security.cert.CollectionCertStoreParameters; import java.security.cert.PKIXBuilderParameters; import java.security.cert.PKIXCertPathBuilderResult; @@ -41,6 +42,7 @@ import java.security.cert.X509Certificate; import java.util.Collection; import java.util.Date; +import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -78,10 +80,11 @@ public static X509Certificate validateIsSignedByTrustedCA(X509Certificate certif final X509Certificate trustedCACert = result.getTrustAnchor().getTrustedCert(); - // Verify that the trusted CA cert is presently valid before returning the result. + // Verify that the trusted CA cert is presently valid before returning the result. The anchor is not part + // of the built path, so the PKIX date check above does not cover it. certificateIsValidOnDate(trustedCACert, now, "Trusted CA"); - return trustedCACert; + return getIssuerCertificate(result, trustedCACert); } catch (InvalidAlgorithmParameterException | NoSuchAlgorithmException e) { throw new JceException(e); @@ -90,6 +93,16 @@ public static X509Certificate validateIsSignedByTrustedCA(X509Certificate certif } } + /** + * Returns the certificate that directly issued the subject of the built certification path. + */ + private static X509Certificate getIssuerCertificate(PKIXCertPathBuilderResult path, X509Certificate trustedCACert) { + // The built path is ordered from the subject towards the anchor and excludes the anchor, so index 1 (when + // present) is the subject's direct issuer; otherwise the subject was issued directly by the trust anchor. + final List certificatePath = path.getCertPath().getCertificates(); + return certificatePath.size() > 1 ? (X509Certificate) certificatePath.get(1) : trustedCACert; + } + public static Set buildTrustAnchorsFromCertificates(Collection certificates) { return certificates.stream() .map(cert -> new TrustAnchor(cert, null)).collect(Collectors.toUnmodifiableSet()); diff --git a/src/test/java/eu/webeid/security/certificate/CertificateValidatorTest.java b/src/test/java/eu/webeid/security/certificate/CertificateValidatorTest.java new file mode 100644 index 00000000..6720bf25 --- /dev/null +++ b/src/test/java/eu/webeid/security/certificate/CertificateValidatorTest.java @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2020-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package eu.webeid.security.certificate; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.cert.CertStore; +import java.security.cert.TrustAnchor; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class CertificateValidatorTest { + + private static final Date NOW = new Date(); + private static final Date NOT_BEFORE = new Date(NOW.getTime() - 86_400_000L); + private static final Date NOT_AFTER = new Date(NOW.getTime() + 86_400_000L); + + private static X509Certificate rootCertificate; + private static X509Certificate intermediateCertificateC; // signed by root + private static X509Certificate intermediateCertificateB; // signed by C + private static X509Certificate intermediateCertificateA; // signed by B, direct issuer of the leaf + private static X509Certificate leafCertificate; // signed by A + + @BeforeAll + static void setUp() throws Exception { + // A single chain: root -> intermediateCertificateC -> intermediateCertificateB -> intermediateCertificateA -> leaf. + final KeyPair rootKeyPair = generateKeyPair(); + final KeyPair intermediateCKeyPair = generateKeyPair(); + final KeyPair intermediateBKeyPair = generateKeyPair(); + final KeyPair intermediateAKeyPair = generateKeyPair(); + final KeyPair leafKeyPair = generateKeyPair(); + + final X500Name rootName = new X500Name("CN=Test Root CA"); + final X500Name intermediateCName = new X500Name("CN=Test Intermediate CA C"); + final X500Name intermediateBName = new X500Name("CN=Test Intermediate CA B"); + final X500Name intermediateAName = new X500Name("CN=Test Intermediate CA A"); + final X500Name leafName = new X500Name("CN=Test Leaf"); + + rootCertificate = generateCertificate(rootName, rootKeyPair.getPublic(), + rootName, rootKeyPair.getPrivate(), rootKeyPair.getPublic(), true, BigInteger.valueOf(1)); + intermediateCertificateC = generateCertificate(intermediateCName, intermediateCKeyPair.getPublic(), + rootName, rootKeyPair.getPrivate(), rootKeyPair.getPublic(), true, BigInteger.valueOf(2)); + intermediateCertificateB = generateCertificate(intermediateBName, intermediateBKeyPair.getPublic(), + intermediateCName, intermediateCKeyPair.getPrivate(), intermediateCKeyPair.getPublic(), true, BigInteger.valueOf(3)); + intermediateCertificateA = generateCertificate(intermediateAName, intermediateAKeyPair.getPublic(), + intermediateBName, intermediateBKeyPair.getPrivate(), intermediateBKeyPair.getPublic(), true, BigInteger.valueOf(4)); + leafCertificate = generateCertificate(leafName, leafKeyPair.getPublic(), + intermediateAName, intermediateAKeyPair.getPrivate(), intermediateAKeyPair.getPublic(), false, BigInteger.valueOf(5)); + } + + @Test + void whenChainHasIntermediate_thenReturnsDirectIssuerNotTrustAnchor() throws Exception { + final Set anchors = Collections.singleton(new TrustAnchor(rootCertificate, null)); + final CertStore certStore = CertificateValidator.buildCertStoreFromCertificates( + Arrays.asList(intermediateCertificateA, intermediateCertificateB, intermediateCertificateC)); + + final X509Certificate issuer = CertificateValidator.validateIsSignedByTrustedCA( + leafCertificate, anchors, certStore, NOW); + + // The leaf is issued by intermediate A, whose chain (A -> B -> C) leads to the root trust anchor. The issuer + // used for OCSP must be the direct issuer (intermediate A), not the trust anchor (the root). + assertThat(issuer).isEqualTo(intermediateCertificateA); + } + + @Test + void whenSubjectIssuedDirectlyByTrustAnchor_thenReturnsTrustAnchor() throws Exception { + final Set anchors = Collections.singleton(new TrustAnchor(intermediateCertificateA, null)); + final CertStore emptyStore = CertificateValidator.buildCertStoreFromCertificates(Collections.emptyList()); + + final X509Certificate issuer = CertificateValidator.validateIsSignedByTrustedCA( + leafCertificate, anchors, emptyStore, NOW); + + // Single-hop chain: the direct issuer is the trust anchor itself. + assertThat(issuer).isEqualTo(intermediateCertificateA); + } + + private static KeyPair generateKeyPair() throws Exception { + final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + return keyPairGenerator.generateKeyPair(); + } + + private static X509Certificate generateCertificate(X500Name subject, PublicKey subjectPublicKey, + X500Name issuer, PrivateKey issuerPrivateKey, PublicKey issuerPublicKey, + boolean ca, BigInteger serial) throws Exception { + final JcaX509ExtensionUtils extensionUtils = new JcaX509ExtensionUtils(); + final JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( + issuer, serial, NOT_BEFORE, NOT_AFTER, subject, subjectPublicKey); + builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(ca)); + builder.addExtension(Extension.subjectKeyIdentifier, false, extensionUtils.createSubjectKeyIdentifier(subjectPublicKey)); + builder.addExtension(Extension.authorityKeyIdentifier, false, extensionUtils.createAuthorityKeyIdentifier(issuerPublicKey)); + if (ca) { + builder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign | KeyUsage.cRLSign)); + } + final ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(issuerPrivateKey); + return new JcaX509CertificateConverter().getCertificate(builder.build(signer)); + } +} From e2e190c1d8f0384c860d6bf2db00af0c54fadb44 Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Thu, 2 Jul 2026 19:28:36 +0300 Subject: [PATCH 05/14] NFC-128 Parameterize the leaf certificate role label in certification-path validation The shared certification-path validation labeled every leaf validity failure as a user-certificate failure, so the AIA OCSP responder flow had to duplicate the validity check beforehand to get a correctly attributed message. Let callers name the certificate role and drop the duplicate responder pre-check; validity is still checked first, inside the shared method. --- .../certificate/CertificateValidator.java | 26 +++++++++++++++++-- .../SubjectCertificateTrustedValidator.java | 1 + .../ocsp/service/AiaOcspService.java | 6 ++--- .../certificate/CertificateValidatorTest.java | 14 ++++++++++ 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/main/java/eu/webeid/security/certificate/CertificateValidator.java b/src/main/java/eu/webeid/security/certificate/CertificateValidator.java index 984769f6..d71b868c 100644 --- a/src/main/java/eu/webeid/security/certificate/CertificateValidator.java +++ b/src/main/java/eu/webeid/security/certificate/CertificateValidator.java @@ -62,14 +62,36 @@ public static X509Certificate validateIsSignedByTrustedCA(X509Certificate certif Set trustedCACertificateAnchors, CertStore trustedCACertificateCertStore, Date now) throws CertificateNotTrustedException, JceException, CertificateNotYetValidException, CertificateExpiredException { - certificateIsValidOnDate(certificate, now, "User"); + return validateIsSignedByTrustedCA(certificate, "User", trustedCACertificateAnchors, trustedCACertificateCertStore, now); + } + + /** + * Validates that the given certificate is signed by a trusted CA and returns the certificate that directly + * issued it. + * + * @param certificate the certificate whose certification path is validated + * @param certificateSubject the role of the certificate, e.g. "User" or "AIA OCSP responder", used in + * validity failure messages + * @param trustedCACertificateAnchors trusted CA certificates as trust anchors + * @param trustedCACertificateCertStore trusted CA certificates as a certificate store + * @param now validation date + * @return the certificate that directly issued the given certificate; the trust anchor when the anchor + * is the direct issuer + */ + public static X509Certificate validateIsSignedByTrustedCA(X509Certificate certificate, + String certificateSubject, + Set trustedCACertificateAnchors, + CertStore trustedCACertificateCertStore, + Date now) throws CertificateNotTrustedException, JceException, CertificateNotYetValidException, CertificateExpiredException { + certificateIsValidOnDate(certificate, now, certificateSubject); final X509CertSelector selector = new X509CertSelector(); selector.setCertificate(certificate); try { final PKIXBuilderParameters pkixBuilderParameters = new PKIXBuilderParameters(trustedCACertificateAnchors, selector); - // Certificate revocation check is intentionally disabled as we do the OCSP check with SubjectCertificateNotRevokedValidator ourselves. + // Revocation checking of the validated certificate is intentionally disabled here: each caller applies + // its own role-specific leaf revocation policy. pkixBuilderParameters.setRevocationEnabled(false); pkixBuilderParameters.setDate(now); pkixBuilderParameters.addCertStore(trustedCACertificateCertStore); diff --git a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateTrustedValidator.java b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateTrustedValidator.java index a706ffc4..0a528fc1 100644 --- a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateTrustedValidator.java +++ b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateTrustedValidator.java @@ -65,6 +65,7 @@ public void validateCertificateTrusted(X509Certificate subjectCertificate) throw final Date now = DateAndTime.DefaultClock.getInstance().now(); subjectCertificateIssuerCertificate = CertificateValidator.validateIsSignedByTrustedCA( subjectCertificate, + "User", trustedCACertificateAnchors, trustedCACertificateCertStore, now diff --git a/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java b/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java index e04823c3..8f591e06 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java +++ b/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java @@ -74,10 +74,10 @@ public URI getAccessLocation() { public void validateResponderCertificate(X509CertificateHolder cert, Date now) throws AuthTokenException { try { final X509Certificate certificate = certificateConverter.getCertificate(cert); - CertificateValidator.certificateIsValidOnDate(certificate, now, "AIA OCSP responder"); - // Trusted certificates' validity has been already verified in validateCertificateExpiry(). OcspResponseValidator.validateHasSigningExtension(certificate); - CertificateValidator.validateIsSignedByTrustedCA(certificate, trustedCACertificateAnchors, trustedCACertificateCertStore, now); + // The responder certificate's validity on the current date is checked as part of the certification + // path validation. + CertificateValidator.validateIsSignedByTrustedCA(certificate, "AIA OCSP responder", trustedCACertificateAnchors, trustedCACertificateCertStore, now); } catch (CertificateException e) { throw new OCSPCertificateException("Invalid responder certificate", e); } diff --git a/src/test/java/eu/webeid/security/certificate/CertificateValidatorTest.java b/src/test/java/eu/webeid/security/certificate/CertificateValidatorTest.java index 6720bf25..4abc05e5 100644 --- a/src/test/java/eu/webeid/security/certificate/CertificateValidatorTest.java +++ b/src/test/java/eu/webeid/security/certificate/CertificateValidatorTest.java @@ -22,6 +22,7 @@ package eu.webeid.security.certificate; +import eu.webeid.security.exceptions.CertificateExpiredException; import org.bouncycastle.asn1.x500.X500Name; import org.bouncycastle.asn1.x509.BasicConstraints; import org.bouncycastle.asn1.x509.Extension; @@ -48,6 +49,7 @@ import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; class CertificateValidatorTest { @@ -114,6 +116,18 @@ void whenSubjectIssuedDirectlyByTrustAnchor_thenReturnsTrustAnchor() throws Exce assertThat(issuer).isEqualTo(intermediateCertificateA); } + @Test + void whenCertificateExpired_thenMessageUsesProvidedSubject() throws Exception { + final Set anchors = Collections.singleton(new TrustAnchor(intermediateCertificateA, null)); + final CertStore emptyStore = CertificateValidator.buildCertStoreFromCertificates(Collections.emptyList()); + final Date afterExpiry = new Date(NOT_AFTER.getTime() + 86_400_000L); + + assertThatExceptionOfType(CertificateExpiredException.class) + .isThrownBy(() -> CertificateValidator.validateIsSignedByTrustedCA( + leafCertificate, "Signing", anchors, emptyStore, afterExpiry)) + .withMessage("Signing certificate has expired"); + } + private static KeyPair generateKeyPair() throws Exception { final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); keyPairGenerator.initialize(2048); From 38ebcbc4936c9e8a6604bc1c929c979ad39a5644 Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Thu, 2 Jul 2026 19:31:19 +0300 Subject: [PATCH 06/14] NFC-128 Build certification paths with token-supplied intermediate certificates Web eID 1.1 tokens may carry the intermediate CA certificates of the certificate chain. Accept them in certification-path validation as untrusted path candidates only: the path must still terminate at a configured trust anchor. No caller passes token data yet. --- .../certificate/CertificateValidator.java | 8 ++++- .../SubjectCertificateTrustedValidator.java | 2 ++ .../ocsp/service/AiaOcspService.java | 3 +- .../certificate/CertificateValidatorTest.java | 31 ++++++++++++++++++- 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/main/java/eu/webeid/security/certificate/CertificateValidator.java b/src/main/java/eu/webeid/security/certificate/CertificateValidator.java index d71b868c..c4b3786f 100644 --- a/src/main/java/eu/webeid/security/certificate/CertificateValidator.java +++ b/src/main/java/eu/webeid/security/certificate/CertificateValidator.java @@ -62,7 +62,7 @@ public static X509Certificate validateIsSignedByTrustedCA(X509Certificate certif Set trustedCACertificateAnchors, CertStore trustedCACertificateCertStore, Date now) throws CertificateNotTrustedException, JceException, CertificateNotYetValidException, CertificateExpiredException { - return validateIsSignedByTrustedCA(certificate, "User", trustedCACertificateAnchors, trustedCACertificateCertStore, now); + return validateIsSignedByTrustedCA(certificate, "User", trustedCACertificateAnchors, trustedCACertificateCertStore, List.of(), now); } /** @@ -74,6 +74,8 @@ public static X509Certificate validateIsSignedByTrustedCA(X509Certificate certif * validity failure messages * @param trustedCACertificateAnchors trusted CA certificates as trust anchors * @param trustedCACertificateCertStore trusted CA certificates as a certificate store + * @param additionalIntermediateCertificates untrusted intermediate certificates offered as certification-path + * candidates only; the path must still terminate at one of the trust anchors * @param now validation date * @return the certificate that directly issued the given certificate; the trust anchor when the anchor * is the direct issuer @@ -82,6 +84,7 @@ public static X509Certificate validateIsSignedByTrustedCA(X509Certificate certif String certificateSubject, Set trustedCACertificateAnchors, CertStore trustedCACertificateCertStore, + List additionalIntermediateCertificates, Date now) throws CertificateNotTrustedException, JceException, CertificateNotYetValidException, CertificateExpiredException { certificateIsValidOnDate(certificate, now, certificateSubject); @@ -95,6 +98,9 @@ public static X509Certificate validateIsSignedByTrustedCA(X509Certificate certif pkixBuilderParameters.setRevocationEnabled(false); pkixBuilderParameters.setDate(now); pkixBuilderParameters.addCertStore(trustedCACertificateCertStore); + if (additionalIntermediateCertificates != null && !additionalIntermediateCertificates.isEmpty()) { + pkixBuilderParameters.addCertStore(buildCertStoreFromCertificates(additionalIntermediateCertificates)); + } // See the comment in buildCertStoreFromCertificates() below why we use the default JCE provider. final CertPathBuilder certPathBuilder = CertPathBuilder.getInstance(CertPathBuilder.getDefaultType()); diff --git a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateTrustedValidator.java b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateTrustedValidator.java index 0a528fc1..25dfef79 100644 --- a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateTrustedValidator.java +++ b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateTrustedValidator.java @@ -35,6 +35,7 @@ import java.security.cert.TrustAnchor; import java.security.cert.X509Certificate; import java.util.Date; +import java.util.List; import java.util.Set; public final class SubjectCertificateTrustedValidator { @@ -68,6 +69,7 @@ public void validateCertificateTrusted(X509Certificate subjectCertificate) throw "User", trustedCACertificateAnchors, trustedCACertificateCertStore, + List.of(), now ); LOG.debug("Subject certificate is valid and signed by a trusted CA"); diff --git a/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java b/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java index 8f591e06..112b7cf9 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java +++ b/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java @@ -36,6 +36,7 @@ import java.security.cert.TrustAnchor; import java.security.cert.X509Certificate; import java.util.Date; +import java.util.List; import java.util.Objects; import java.util.Set; @@ -77,7 +78,7 @@ public void validateResponderCertificate(X509CertificateHolder cert, Date now) t OcspResponseValidator.validateHasSigningExtension(certificate); // The responder certificate's validity on the current date is checked as part of the certification // path validation. - CertificateValidator.validateIsSignedByTrustedCA(certificate, "AIA OCSP responder", trustedCACertificateAnchors, trustedCACertificateCertStore, now); + CertificateValidator.validateIsSignedByTrustedCA(certificate, "AIA OCSP responder", trustedCACertificateAnchors, trustedCACertificateCertStore, List.of(), now); } catch (CertificateException e) { throw new OCSPCertificateException("Invalid responder certificate", e); } diff --git a/src/test/java/eu/webeid/security/certificate/CertificateValidatorTest.java b/src/test/java/eu/webeid/security/certificate/CertificateValidatorTest.java index 4abc05e5..c11186b8 100644 --- a/src/test/java/eu/webeid/security/certificate/CertificateValidatorTest.java +++ b/src/test/java/eu/webeid/security/certificate/CertificateValidatorTest.java @@ -46,6 +46,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.Date; +import java.util.List; import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; @@ -116,6 +117,34 @@ void whenSubjectIssuedDirectlyByTrustAnchor_thenReturnsTrustAnchor() throws Exce assertThat(issuer).isEqualTo(intermediateCertificateA); } + @Test + void whenChainHasTokenSuppliedIntermediates_thenReturnsDirectIssuerNotTrustAnchor() throws Exception { + final Set anchors = Collections.singleton(new TrustAnchor(rootCertificate, null)); + final CertStore emptyStore = CertificateValidator.buildCertStoreFromCertificates(Collections.emptyList()); + + final X509Certificate issuer = CertificateValidator.validateIsSignedByTrustedCA( + leafCertificate, "User", anchors, emptyStore, + List.of(intermediateCertificateA, intermediateCertificateB, intermediateCertificateC), NOW); + + // The leaf is issued by intermediate A, whose chain (A -> B -> C) leads to the root trust anchor. The issuer + // used for OCSP must be the direct issuer (intermediate A), not the trust anchor (the root). + assertThat(issuer).isEqualTo(intermediateCertificateA); + } + + @Test + void whenChainHasMultipleTokenSuppliedIntermediatesAndGrandparentIsPinned_thenValidationSucceeds() throws Exception { + // The token supplies the full A -> B -> C intermediate chain and the top (C) is configured as the trust + // anchor. The path builds leaf -> A -> B, and the issuer returned for OCSP is the direct issuer (A). + final Set anchors = Collections.singleton(new TrustAnchor(intermediateCertificateC, null)); + final CertStore emptyStore = CertificateValidator.buildCertStoreFromCertificates(Collections.emptyList()); + + final X509Certificate issuer = CertificateValidator.validateIsSignedByTrustedCA( + leafCertificate, "User", anchors, emptyStore, + List.of(intermediateCertificateA, intermediateCertificateB, intermediateCertificateC), NOW); + + assertThat(issuer).isEqualTo(intermediateCertificateA); + } + @Test void whenCertificateExpired_thenMessageUsesProvidedSubject() throws Exception { final Set anchors = Collections.singleton(new TrustAnchor(intermediateCertificateA, null)); @@ -124,7 +153,7 @@ void whenCertificateExpired_thenMessageUsesProvidedSubject() throws Exception { assertThatExceptionOfType(CertificateExpiredException.class) .isThrownBy(() -> CertificateValidator.validateIsSignedByTrustedCA( - leafCertificate, "Signing", anchors, emptyStore, afterExpiry)) + leafCertificate, "Signing", anchors, emptyStore, Collections.emptyList(), afterExpiry)) .withMessage("Signing certificate has expired"); } From 5287b4f35bdc382c5735e10be4210f81e2e2a2f8 Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Thu, 2 Jul 2026 23:34:54 +0300 Subject: [PATCH 07/14] NFC-128 Add revocation checking for token-supplied intermediate CA certificates Token-supplied intermediates are attacker-controlled input, so a revoked intermediate must not become part of a trusted certification path. Check the CA suffix of the built path (the leaf's role-specific revocation policy stays with the caller) with the platform JCA PKIXRevocationChecker, which prefers OCSP and falls back to CRLs; SOFT_FAIL is deliberately not enabled. Revocation checking is an explicit caller choice: the authentication and signing certificate paths enable it, the AIA OCSP responder path does not, per RFC 6960 section 4.2.2.2.1 id-pkix-ocsp-nocheck semantics and because checking the responder's own chain via OCSP would be circular. The platform checker is used instead of the application OcspClient because intermediate CAs commonly publish CRLs without OCSP AIA records, the OcspClient freshness policy (nonce, time skew, thisUpdate age) encodes end-entity assumptions that are wrong for CA-tier data, and the WE2-1030 platform-OCSP refactor removes the OcspClient from core validation, so platform-based checking ports forward as a block. A revocation failure names the offending intermediate certificate instead of the validated leaf. --- .../certificate/CertificateValidator.java | 86 ++++- .../SubjectCertificateTrustedValidator.java | 3 + .../ocsp/service/AiaOcspService.java | 14 +- .../certificate/CertificateValidatorTest.java | 293 +++++++++++++++++- 4 files changed, 385 insertions(+), 11 deletions(-) diff --git a/src/main/java/eu/webeid/security/certificate/CertificateValidator.java b/src/main/java/eu/webeid/security/certificate/CertificateValidator.java index c4b3786f..49ba52e3 100644 --- a/src/main/java/eu/webeid/security/certificate/CertificateValidator.java +++ b/src/main/java/eu/webeid/security/certificate/CertificateValidator.java @@ -30,13 +30,20 @@ import java.security.GeneralSecurityException; import java.security.InvalidAlgorithmParameterException; import java.security.NoSuchAlgorithmException; +import java.security.cert.CertPath; import java.security.cert.CertPathBuilder; import java.security.cert.CertPathBuilderException; +import java.security.cert.CertPathValidator; +import java.security.cert.CertPathValidatorException; import java.security.cert.CertStore; import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; import java.security.cert.CollectionCertStoreParameters; import java.security.cert.PKIXBuilderParameters; import java.security.cert.PKIXCertPathBuilderResult; +import java.security.cert.PKIXParameters; +import java.security.cert.PKIXRevocationChecker; import java.security.cert.TrustAnchor; import java.security.cert.X509CertSelector; import java.security.cert.X509Certificate; @@ -48,6 +55,16 @@ public final class CertificateValidator { + /** + * Selects whether the non-anchor intermediate CA certificates of the built certification path are checked + * for revocation. The revocation policy of the validated certificate itself is always the caller's + * responsibility. + */ + public enum IntermediateRevocationCheck { + ENABLED, + DISABLED + } + public static void certificateIsValidOnDate(X509Certificate cert, Date date, String subject) throws CertificateNotYetValidException, CertificateExpiredException { try { cert.checkValidity(date); @@ -62,7 +79,7 @@ public static X509Certificate validateIsSignedByTrustedCA(X509Certificate certif Set trustedCACertificateAnchors, CertStore trustedCACertificateCertStore, Date now) throws CertificateNotTrustedException, JceException, CertificateNotYetValidException, CertificateExpiredException { - return validateIsSignedByTrustedCA(certificate, "User", trustedCACertificateAnchors, trustedCACertificateCertStore, List.of(), now); + return validateIsSignedByTrustedCA(certificate, "User", trustedCACertificateAnchors, trustedCACertificateCertStore, List.of(), IntermediateRevocationCheck.DISABLED, now); } /** @@ -76,6 +93,8 @@ public static X509Certificate validateIsSignedByTrustedCA(X509Certificate certif * @param trustedCACertificateCertStore trusted CA certificates as a certificate store * @param additionalIntermediateCertificates untrusted intermediate certificates offered as certification-path * candidates only; the path must still terminate at one of the trust anchors + * @param intermediateRevocationCheck whether the non-anchor intermediate CA certificates of the built path + * are checked for revocation * @param now validation date * @return the certificate that directly issued the given certificate; the trust anchor when the anchor * is the direct issuer @@ -85,6 +104,7 @@ public static X509Certificate validateIsSignedByTrustedCA(X509Certificate certif Set trustedCACertificateAnchors, CertStore trustedCACertificateCertStore, List additionalIntermediateCertificates, + IntermediateRevocationCheck intermediateRevocationCheck, Date now) throws CertificateNotTrustedException, JceException, CertificateNotYetValidException, CertificateExpiredException { certificateIsValidOnDate(certificate, now, certificateSubject); @@ -106,6 +126,16 @@ public static X509Certificate validateIsSignedByTrustedCA(X509Certificate certif final CertPathBuilder certPathBuilder = CertPathBuilder.getInstance(CertPathBuilder.getDefaultType()); final PKIXCertPathBuilderResult result = (PKIXCertPathBuilderResult) certPathBuilder.build(pkixBuilderParameters); + if (intermediateRevocationCheck == IntermediateRevocationCheck.ENABLED) { + validateIntermediateCertificatesNotRevoked( + result, + trustedCACertificateAnchors, + trustedCACertificateCertStore, + additionalIntermediateCertificates, + now + ); + } + final X509Certificate trustedCACert = result.getTrustAnchor().getTrustedCert(); // Verify that the trusted CA cert is presently valid before returning the result. The anchor is not part @@ -121,6 +151,60 @@ public static X509Certificate validateIsSignedByTrustedCA(X509Certificate certif } } + /** + * Validates that the non-anchor intermediate CA certificates of the built certification path are not revoked. + */ + private static void validateIntermediateCertificatesNotRevoked( + PKIXCertPathBuilderResult pathBuilderResult, + Set trustedCACertificateAnchors, + CertStore trustedCACertificateCertStore, + List additionalIntermediateCertificates, + Date now + ) throws CertificateNotTrustedException, JceException { + final List certificatePath = pathBuilderResult.getCertPath().getCertificates(); + if (certificatePath.size() <= 1) { + return; // The leaf chains directly to a trust anchor; there is no non-anchor intermediate to validate. + } + + // Validate only the CA suffix of the built path, excluding the leaf at index 0, whose revocation policy + // is role-specific and applied by the caller, and the trust anchor, which is not part of the built path. + final List intermediateCertificates = certificatePath.subList(1, certificatePath.size()); + try { + final CertPath intermediateCertificatePath = CertificateFactory.getInstance("X.509") + .generateCertPath(intermediateCertificates); + final PKIXParameters revocationCheckingParameters = new PKIXParameters(trustedCACertificateAnchors); + revocationCheckingParameters.setDate(now); + // An explicitly added checker is active regardless of this flag and avoids installing a second + // default checker. + revocationCheckingParameters.setRevocationEnabled(false); + revocationCheckingParameters.addCertStore(trustedCACertificateCertStore); + if (additionalIntermediateCertificates != null && !additionalIntermediateCertificates.isEmpty()) { + revocationCheckingParameters.addCertStore(buildCertStoreFromCertificates(additionalIntermediateCertificates)); + } + + final CertPathValidator certPathValidator = CertPathValidator.getInstance(CertPathValidator.getDefaultType()); + final PKIXRevocationChecker revocationChecker = + (PKIXRevocationChecker) certPathValidator.getRevocationChecker(); + // The default checker prefers OCSP and falls back to CRLs. SOFT_FAIL is deliberately not enabled: an + // intermediate whose revocation status cannot be established must not become part of a trusted path. + revocationCheckingParameters.addCertPathChecker(revocationChecker); + certPathValidator.validate(intermediateCertificatePath, revocationCheckingParameters); + } catch (CertPathValidatorException e) { + throw new CertificateNotTrustedException(getOffendingCertificate(certificatePath, e), e); + } catch (InvalidAlgorithmParameterException | NoSuchAlgorithmException | CertificateException e) { + throw new JceException(e); + } + } + + /** + * Returns the intermediate certificate that failed the revocation check, or the leaf when the failing + * certificate cannot be determined. + */ + private static X509Certificate getOffendingCertificate(List certificatePath, CertPathValidatorException e) { + // The validated intermediate path starts at index 1 of the built certification path. + return (X509Certificate) (e.getIndex() >= 0 ? certificatePath.get(e.getIndex() + 1) : certificatePath.get(0)); + } + /** * Returns the certificate that directly issued the subject of the built certification path. */ diff --git a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateTrustedValidator.java b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateTrustedValidator.java index 25dfef79..cd212afc 100644 --- a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateTrustedValidator.java +++ b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateTrustedValidator.java @@ -70,6 +70,9 @@ public void validateCertificateTrusted(X509Certificate subjectCertificate) throw trustedCACertificateAnchors, trustedCACertificateCertStore, List.of(), + // Intermediate CA certificates require revocation checks here because they are not checked elsewhere. + // Subject certificate revocation is handled separately by SubjectCertificateNotRevokedValidator. + CertificateValidator.IntermediateRevocationCheck.ENABLED, now ); LOG.debug("Subject certificate is valid and signed by a trusted CA"); diff --git a/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java b/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java index 112b7cf9..0a04df55 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java +++ b/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java @@ -77,8 +77,18 @@ public void validateResponderCertificate(X509CertificateHolder cert, Date now) t final X509Certificate certificate = certificateConverter.getCertificate(cert); OcspResponseValidator.validateHasSigningExtension(certificate); // The responder certificate's validity on the current date is checked as part of the certification - // path validation. - CertificateValidator.validateIsSignedByTrustedCA(certificate, "AIA OCSP responder", trustedCACertificateAnchors, trustedCACertificateCertStore, List.of(), now); + // path validation. Responder certificates are deliberately not revocation-checked: RFC 6960 section + // 4.2.2.2.1 lets CAs vouch for their responders with id-pkix-ocsp-nocheck instead, and asking an OCSP + // service whether its own signer is revoked would be circular. + CertificateValidator.validateIsSignedByTrustedCA( + certificate, + "AIA OCSP responder", + trustedCACertificateAnchors, + trustedCACertificateCertStore, + List.of(), + CertificateValidator.IntermediateRevocationCheck.DISABLED, + now + ); } catch (CertificateException e) { throw new OCSPCertificateException("Invalid responder certificate", e); } diff --git a/src/test/java/eu/webeid/security/certificate/CertificateValidatorTest.java b/src/test/java/eu/webeid/security/certificate/CertificateValidatorTest.java index c11186b8..9812cd9c 100644 --- a/src/test/java/eu/webeid/security/certificate/CertificateValidatorTest.java +++ b/src/test/java/eu/webeid/security/certificate/CertificateValidatorTest.java @@ -22,11 +22,16 @@ package eu.webeid.security.certificate; +import eu.webeid.security.certificate.CertificateValidator.IntermediateRevocationCheck; import eu.webeid.security.exceptions.CertificateExpiredException; +import eu.webeid.security.exceptions.CertificateNotTrustedException; +import eu.webeid.security.exceptions.JceException; import org.bouncycastle.asn1.x500.X500Name; import org.bouncycastle.asn1.x509.BasicConstraints; import org.bouncycastle.asn1.x509.Extension; import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.cert.X509v2CRLBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CRLConverter; import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils; import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; @@ -34,15 +39,28 @@ import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.mockito.MockedStatic; import java.math.BigInteger; import java.security.KeyPair; import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.PublicKey; +import java.security.cert.CertPath; +import java.security.cert.CertPathBuilderException; +import java.security.cert.CertPathValidator; +import java.security.cert.CertPathValidatorException; import java.security.cert.CertStore; +import java.security.cert.CollectionCertStoreParameters; +import java.security.cert.PKIXParameters; +import java.security.cert.PKIXRevocationChecker; import java.security.cert.TrustAnchor; +import java.security.cert.X509CRL; import java.security.cert.X509Certificate; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Date; @@ -51,6 +69,11 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; class CertificateValidatorTest { @@ -63,6 +86,10 @@ class CertificateValidatorTest { private static X509Certificate intermediateCertificateB; // signed by C private static X509Certificate intermediateCertificateA; // signed by B, direct issuer of the leaf private static X509Certificate leafCertificate; // signed by A + private static X509CRL rootCrl; + private static X509CRL intermediateCCrl; + private static X509CRL intermediateBCrl; + private static X509CRL intermediateBRevokingACrl; @BeforeAll static void setUp() throws Exception { @@ -89,6 +116,12 @@ static void setUp() throws Exception { intermediateBName, intermediateBKeyPair.getPrivate(), intermediateBKeyPair.getPublic(), true, BigInteger.valueOf(4)); leafCertificate = generateCertificate(leafName, leafKeyPair.getPublic(), intermediateAName, intermediateAKeyPair.getPrivate(), intermediateAKeyPair.getPublic(), false, BigInteger.valueOf(5)); + + rootCrl = generateCrl(rootName, rootKeyPair.getPrivate()); + intermediateCCrl = generateCrl(intermediateCName, intermediateCKeyPair.getPrivate()); + intermediateBCrl = generateCrl(intermediateBName, intermediateBKeyPair.getPrivate()); + intermediateBRevokingACrl = generateCrl( + intermediateBName, intermediateBKeyPair.getPrivate(), intermediateCertificateA.getSerialNumber()); } @Test @@ -120,11 +153,12 @@ void whenSubjectIssuedDirectlyByTrustAnchor_thenReturnsTrustAnchor() throws Exce @Test void whenChainHasTokenSuppliedIntermediates_thenReturnsDirectIssuerNotTrustAnchor() throws Exception { final Set anchors = Collections.singleton(new TrustAnchor(rootCertificate, null)); - final CertStore emptyStore = CertificateValidator.buildCertStoreFromCertificates(Collections.emptyList()); + final CertStore crlStore = buildCrlStore(rootCrl, intermediateCCrl, intermediateBCrl); final X509Certificate issuer = CertificateValidator.validateIsSignedByTrustedCA( - leafCertificate, "User", anchors, emptyStore, - List.of(intermediateCertificateA, intermediateCertificateB, intermediateCertificateC), NOW); + leafCertificate, "User", anchors, crlStore, + List.of(intermediateCertificateA, intermediateCertificateB, intermediateCertificateC), + IntermediateRevocationCheck.ENABLED, NOW); // The leaf is issued by intermediate A, whose chain (A -> B -> C) leads to the root trust anchor. The issuer // used for OCSP must be the direct issuer (intermediate A), not the trust anchor (the root). @@ -136,15 +170,116 @@ void whenChainHasMultipleTokenSuppliedIntermediatesAndGrandparentIsPinned_thenVa // The token supplies the full A -> B -> C intermediate chain and the top (C) is configured as the trust // anchor. The path builds leaf -> A -> B, and the issuer returned for OCSP is the direct issuer (A). final Set anchors = Collections.singleton(new TrustAnchor(intermediateCertificateC, null)); - final CertStore emptyStore = CertificateValidator.buildCertStoreFromCertificates(Collections.emptyList()); + final CertStore crlStore = buildCrlStore(intermediateCCrl, intermediateBCrl); final X509Certificate issuer = CertificateValidator.validateIsSignedByTrustedCA( - leafCertificate, "User", anchors, emptyStore, - List.of(intermediateCertificateA, intermediateCertificateB, intermediateCertificateC), NOW); + leafCertificate, "User", anchors, crlStore, + List.of(intermediateCertificateA, intermediateCertificateB, intermediateCertificateC), + IntermediateRevocationCheck.ENABLED, NOW); + + assertThat(issuer).isEqualTo(intermediateCertificateA); + } + + @ParameterizedTest + @NullAndEmptySource + void whenNoTokenSuppliedIntermediatesAndChainBuiltFromTrustedStore_thenIntermediateRevocationCheckSucceeds( + List additionalIntermediateCertificates) throws Exception { + final Set anchors = Collections.singleton(new TrustAnchor(rootCertificate, null)); + final CertStore trustedStoreWithCrls = buildCertificateAndCrlStore( + List.of(intermediateCertificateA, intermediateCertificateB, intermediateCertificateC), + List.of(rootCrl, intermediateCCrl, intermediateBCrl)); + + final X509Certificate issuer = CertificateValidator.validateIsSignedByTrustedCA( + leafCertificate, "User", anchors, trustedStoreWithCrls, + additionalIntermediateCertificates, + IntermediateRevocationCheck.ENABLED, NOW); assertThat(issuer).isEqualTo(intermediateCertificateA); } + @Test + void whenTokenSuppliedIntermediateIsRevoked_thenRejectsCertificateChain() throws Exception { + final Set anchors = Collections.singleton(new TrustAnchor(rootCertificate, null)); + final CertStore crlStore = buildCrlStore(rootCrl, intermediateCCrl, intermediateBRevokingACrl); + + assertThatThrownBy(() -> CertificateValidator.validateIsSignedByTrustedCA( + leafCertificate, "User", anchors, crlStore, + List.of(intermediateCertificateA, intermediateCertificateB, intermediateCertificateC), + IntermediateRevocationCheck.ENABLED, NOW)) + .isInstanceOf(CertificateNotTrustedException.class) + // The exception names the offending intermediate, not the leaf. + .hasMessage("Certificate CN=Test Intermediate CA A is not trusted") + .satisfies(exception -> { + final CertPathValidatorException validationException = + (CertPathValidatorException) exception.getCause(); + assertThat(validationException.getReason()) + .isEqualTo(CertPathValidatorException.BasicReason.REVOKED); + }); + } + + @Test + void whenTokenSuppliedIntermediateRevocationStatusIsUnknown_thenRejectsCertificateChain() throws Exception { + final Set anchors = Collections.singleton(new TrustAnchor(rootCertificate, null)); + final CertStore emptyStore = CertificateValidator.buildCertStoreFromCertificates(Collections.emptyList()); + + assertThatThrownBy(() -> CertificateValidator.validateIsSignedByTrustedCA( + leafCertificate, "User", anchors, emptyStore, + List.of(intermediateCertificateA, intermediateCertificateB, intermediateCertificateC), + IntermediateRevocationCheck.ENABLED, NOW)) + .isInstanceOf(CertificateNotTrustedException.class) + .hasCauseInstanceOf(CertPathValidatorException.class); + } + + @Test + void whenRevocationFailureDoesNotIdentifyCertificate_thenReportsLeafCertificate() throws Exception { + final Set anchors = Collections.singleton(new TrustAnchor(rootCertificate, null)); + final CertStore crlStore = buildCrlStore(rootCrl, intermediateCCrl, intermediateBCrl); + final String validatorType = CertPathValidator.getDefaultType(); + final CertPathValidator certPathValidator = mock(CertPathValidator.class); + final PKIXRevocationChecker revocationChecker = mock(PKIXRevocationChecker.class); + when(certPathValidator.getRevocationChecker()).thenReturn(revocationChecker); + when(certPathValidator.validate(any(CertPath.class), any(PKIXParameters.class))) + .thenThrow(new CertPathValidatorException("Revocation failure without certificate index")); + + try (MockedStatic mockedValidator = mockStatic(CertPathValidator.class)) { + mockedValidator.when(CertPathValidator::getDefaultType) + .thenReturn(validatorType); + mockedValidator.when(() -> CertPathValidator.getInstance(validatorType)) + .thenReturn(certPathValidator); + + assertThatThrownBy(() -> CertificateValidator.validateIsSignedByTrustedCA( + leafCertificate, "User", anchors, crlStore, + List.of(intermediateCertificateA, intermediateCertificateB, intermediateCertificateC), + IntermediateRevocationCheck.ENABLED, NOW)) + .isInstanceOf(CertificateNotTrustedException.class) + .hasMessage("Certificate CN=Test Leaf is not trusted") + .hasCauseInstanceOf(CertPathValidatorException.class); + } + } + + @Test + void whenRevocationValidatorIsUnavailable_thenWrapsFailureAsJceException() throws Exception { + final Set anchors = Collections.singleton(new TrustAnchor(rootCertificate, null)); + final CertStore crlStore = buildCrlStore(rootCrl, intermediateCCrl, intermediateBCrl); + final String validatorType = CertPathValidator.getDefaultType(); + final NoSuchAlgorithmException cause = new NoSuchAlgorithmException("PKIX validator unavailable"); + + try (MockedStatic mockedValidator = mockStatic(CertPathValidator.class)) { + mockedValidator.when(CertPathValidator::getDefaultType) + .thenReturn(validatorType); + mockedValidator.when(() -> CertPathValidator.getInstance(validatorType)) + .thenThrow(cause); + + assertThatExceptionOfType(JceException.class) + .isThrownBy(() -> CertificateValidator.validateIsSignedByTrustedCA( + leafCertificate, "User", anchors, crlStore, + List.of(intermediateCertificateA, intermediateCertificateB, intermediateCertificateC), + IntermediateRevocationCheck.ENABLED, NOW)) + .withMessage("Java Cryptography Extension loading or configuration failed") + .withCause(cause); + } + } + @Test void whenCertificateExpired_thenMessageUsesProvidedSubject() throws Exception { final Set anchors = Collections.singleton(new TrustAnchor(intermediateCertificateA, null)); @@ -153,10 +288,123 @@ void whenCertificateExpired_thenMessageUsesProvidedSubject() throws Exception { assertThatExceptionOfType(CertificateExpiredException.class) .isThrownBy(() -> CertificateValidator.validateIsSignedByTrustedCA( - leafCertificate, "Signing", anchors, emptyStore, Collections.emptyList(), afterExpiry)) + leafCertificate, "Signing", anchors, emptyStore, Collections.emptyList(), + IntermediateRevocationCheck.DISABLED, afterExpiry)) .withMessage("Signing certificate has expired"); } + @Test + void whenTokenSuppliedChainTerminatesAtUntrustedRoot_thenRejectsCertificateChain() throws Exception { + // The token supplies a complete, internally consistent chain whose self-signed root is not a configured + // trust anchor. Token-supplied certificates are certification-path candidates only, never trust anchors, + // so the chain must be rejected even though every signature in it verifies. + final KeyPair rogueRootKeyPair = generateKeyPair(); + final KeyPair rogueIntermediateKeyPair = generateKeyPair(); + final X500Name rogueRootName = new X500Name("CN=Rogue Root CA"); + final X500Name rogueIntermediateName = new X500Name("CN=Rogue Intermediate CA"); + final X509Certificate rogueRootCertificate = generateCertificate(rogueRootName, rogueRootKeyPair.getPublic(), + rogueRootName, rogueRootKeyPair.getPrivate(), rogueRootKeyPair.getPublic(), true, BigInteger.valueOf(100)); + final X509Certificate rogueIntermediateCertificate = generateCertificate( + rogueIntermediateName, rogueIntermediateKeyPair.getPublic(), + rogueRootName, rogueRootKeyPair.getPrivate(), rogueRootKeyPair.getPublic(), true, BigInteger.valueOf(101)); + final X509Certificate rogueLeafCertificate = generateCertificate( + new X500Name("CN=Rogue Leaf"), generateKeyPair().getPublic(), + rogueIntermediateName, rogueIntermediateKeyPair.getPrivate(), rogueIntermediateKeyPair.getPublic(), + false, BigInteger.valueOf(102)); + + final Set anchors = Collections.singleton(new TrustAnchor(rootCertificate, null)); + final CertStore emptyStore = CertificateValidator.buildCertStoreFromCertificates(Collections.emptyList()); + + assertThatThrownBy(() -> CertificateValidator.validateIsSignedByTrustedCA( + rogueLeafCertificate, "User", anchors, emptyStore, + List.of(rogueIntermediateCertificate, rogueRootCertificate), + IntermediateRevocationCheck.ENABLED, NOW)) + .isInstanceOf(CertificateNotTrustedException.class) + .hasMessage("Certificate CN=Rogue Leaf is not trusted") + .hasCauseInstanceOf(CertPathBuilderException.class); + } + + @Test + void whenTokenSuppliedIntermediateIsExpired_thenRejectsCertificateChain() throws Exception { + // Only the token-supplied intermediate is outside its validity window; the leaf itself is currently valid. + final KeyPair localRootKeyPair = generateKeyPair(); + final KeyPair expiredIntermediateKeyPair = generateKeyPair(); + final X500Name localRootName = new X500Name("CN=Local Root CA"); + final X500Name expiredIntermediateName = new X500Name("CN=Expired Intermediate CA"); + final X509Certificate localRootCertificate = generateCertificate(localRootName, localRootKeyPair.getPublic(), + localRootName, localRootKeyPair.getPrivate(), localRootKeyPair.getPublic(), true, BigInteger.valueOf(110)); + final X509Certificate expiredIntermediateCertificate = generateCertificate( + expiredIntermediateName, expiredIntermediateKeyPair.getPublic(), + localRootName, localRootKeyPair.getPrivate(), localRootKeyPair.getPublic(), true, BigInteger.valueOf(111), + new Date(NOW.getTime() - 172_800_000L), new Date(NOW.getTime() - 86_400_000L)); + final X509Certificate currentLeafCertificate = generateCertificate( + new X500Name("CN=Current Leaf"), generateKeyPair().getPublic(), + expiredIntermediateName, expiredIntermediateKeyPair.getPrivate(), expiredIntermediateKeyPair.getPublic(), + false, BigInteger.valueOf(112)); + + final Set anchors = Collections.singleton(new TrustAnchor(localRootCertificate, null)); + final CertStore emptyStore = CertificateValidator.buildCertStoreFromCertificates(Collections.emptyList()); + + assertThatThrownBy(() -> CertificateValidator.validateIsSignedByTrustedCA( + currentLeafCertificate, "User", anchors, emptyStore, + List.of(expiredIntermediateCertificate), + IntermediateRevocationCheck.ENABLED, NOW)) + .isInstanceOf(CertificateNotTrustedException.class); + } + + @Test + void whenTokenSuppliedIntermediateIsNotYetValid_thenRejectsCertificateChain() throws Exception { + // Only the token-supplied intermediate is outside its validity window; the leaf itself is currently valid. + final KeyPair localRootKeyPair = generateKeyPair(); + final KeyPair notYetValidIntermediateKeyPair = generateKeyPair(); + final X500Name localRootName = new X500Name("CN=Local Root CA"); + final X500Name notYetValidIntermediateName = new X500Name("CN=Not Yet Valid Intermediate CA"); + final X509Certificate localRootCertificate = generateCertificate(localRootName, localRootKeyPair.getPublic(), + localRootName, localRootKeyPair.getPrivate(), localRootKeyPair.getPublic(), true, BigInteger.valueOf(113)); + final X509Certificate notYetValidIntermediateCertificate = generateCertificate( + notYetValidIntermediateName, notYetValidIntermediateKeyPair.getPublic(), + localRootName, localRootKeyPair.getPrivate(), localRootKeyPair.getPublic(), true, BigInteger.valueOf(114), + new Date(NOW.getTime() + 86_400_000L), new Date(NOW.getTime() + 172_800_000L)); + final X509Certificate currentLeafCertificate = generateCertificate( + new X500Name("CN=Current Leaf"), generateKeyPair().getPublic(), + notYetValidIntermediateName, notYetValidIntermediateKeyPair.getPrivate(), notYetValidIntermediateKeyPair.getPublic(), + false, BigInteger.valueOf(115)); + + final Set anchors = Collections.singleton(new TrustAnchor(localRootCertificate, null)); + final CertStore emptyStore = CertificateValidator.buildCertStoreFromCertificates(Collections.emptyList()); + + assertThatThrownBy(() -> CertificateValidator.validateIsSignedByTrustedCA( + currentLeafCertificate, "User", anchors, emptyStore, + List.of(notYetValidIntermediateCertificate), + IntermediateRevocationCheck.ENABLED, NOW)) + .isInstanceOf(CertificateNotTrustedException.class); + } + + @Test + void whenTrustAnchorIsExpired_thenThrowsCertificateExpiredException() throws Exception { + // The trust anchor is not part of the built certification path, so PKIX path validation does not check its + // validity; the explicit anchor validity check must reject it while the leaf itself is currently valid. + final KeyPair expiredRootKeyPair = generateKeyPair(); + final X500Name expiredRootName = new X500Name("CN=Expired Root CA"); + final X509Certificate expiredRootCertificate = generateCertificate( + expiredRootName, expiredRootKeyPair.getPublic(), + expiredRootName, expiredRootKeyPair.getPrivate(), expiredRootKeyPair.getPublic(), true, BigInteger.valueOf(116), + new Date(NOW.getTime() - 172_800_000L), new Date(NOW.getTime() - 86_400_000L)); + final X509Certificate currentLeafCertificate = generateCertificate( + new X500Name("CN=Current Leaf"), generateKeyPair().getPublic(), + expiredRootName, expiredRootKeyPair.getPrivate(), expiredRootKeyPair.getPublic(), + false, BigInteger.valueOf(117)); + + final Set anchors = Collections.singleton(new TrustAnchor(expiredRootCertificate, null)); + final CertStore emptyStore = CertificateValidator.buildCertStoreFromCertificates(Collections.emptyList()); + + assertThatExceptionOfType(CertificateExpiredException.class) + .isThrownBy(() -> CertificateValidator.validateIsSignedByTrustedCA( + currentLeafCertificate, "User", anchors, emptyStore, Collections.emptyList(), + IntermediateRevocationCheck.DISABLED, NOW)) + .withMessage("Trusted CA certificate has expired"); + } + private static KeyPair generateKeyPair() throws Exception { final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); keyPairGenerator.initialize(2048); @@ -166,9 +414,17 @@ private static KeyPair generateKeyPair() throws Exception { private static X509Certificate generateCertificate(X500Name subject, PublicKey subjectPublicKey, X500Name issuer, PrivateKey issuerPrivateKey, PublicKey issuerPublicKey, boolean ca, BigInteger serial) throws Exception { + return generateCertificate(subject, subjectPublicKey, issuer, issuerPrivateKey, issuerPublicKey, + ca, serial, NOT_BEFORE, NOT_AFTER); + } + + private static X509Certificate generateCertificate(X500Name subject, PublicKey subjectPublicKey, + X500Name issuer, PrivateKey issuerPrivateKey, PublicKey issuerPublicKey, + boolean ca, BigInteger serial, + Date notBefore, Date notAfter) throws Exception { final JcaX509ExtensionUtils extensionUtils = new JcaX509ExtensionUtils(); final JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( - issuer, serial, NOT_BEFORE, NOT_AFTER, subject, subjectPublicKey); + issuer, serial, notBefore, notAfter, subject, subjectPublicKey); builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(ca)); builder.addExtension(Extension.subjectKeyIdentifier, false, extensionUtils.createSubjectKeyIdentifier(subjectPublicKey)); builder.addExtension(Extension.authorityKeyIdentifier, false, extensionUtils.createAuthorityKeyIdentifier(issuerPublicKey)); @@ -178,4 +434,25 @@ private static X509Certificate generateCertificate(X500Name subject, PublicKey s final ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(issuerPrivateKey); return new JcaX509CertificateConverter().getCertificate(builder.build(signer)); } + + private static X509CRL generateCrl(X500Name issuer, PrivateKey issuerPrivateKey, + BigInteger... revokedCertificateSerials) throws Exception { + final X509v2CRLBuilder builder = new X509v2CRLBuilder(issuer, NOT_BEFORE); + builder.setNextUpdate(NOT_AFTER); + for (final BigInteger serial : revokedCertificateSerials) { + builder.addCRLEntry(serial, NOT_BEFORE, 0); + } + final ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(issuerPrivateKey); + return new JcaX509CRLConverter().getCRL(builder.build(signer)); + } + + private static CertStore buildCrlStore(X509CRL... crls) throws Exception { + return CertStore.getInstance("Collection", new CollectionCertStoreParameters(List.of(crls))); + } + + private static CertStore buildCertificateAndCrlStore(List certificates, List crls) throws Exception { + final List storeContents = new ArrayList<>(certificates); + storeContents.addAll(crls); + return CertStore.getInstance("Collection", new CollectionCertStoreParameters(storeContents)); + } } From 3b17308da1bc22349aba82b18845093fc033e2b6 Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Thu, 2 Jul 2026 23:42:33 +0300 Subject: [PATCH 08/14] NFC-128 Validate authentication certificate chains with token-supplied intermediates --- .../SubjectCertificateTrustedValidator.java | 10 ++++- .../SubjectCertificateValidatorBatch.java | 3 +- .../AuthTokenVersion1Validator.java | 37 ++++++++++++++-- .../AuthTokenV11CertificateTest.java | 43 +++++++++++++++++++ .../AuthTokenVersion1ValidatorTest.java | 13 ++++++ 5 files changed, 100 insertions(+), 6 deletions(-) diff --git a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateTrustedValidator.java b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateTrustedValidator.java index cd212afc..2501d69a 100644 --- a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateTrustedValidator.java +++ b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateTrustedValidator.java @@ -44,11 +44,19 @@ public final class SubjectCertificateTrustedValidator { private final Set trustedCACertificateAnchors; private final CertStore trustedCACertificateCertStore; + private final List additionalIntermediateCertificates; private X509Certificate subjectCertificateIssuerCertificate; public SubjectCertificateTrustedValidator(Set trustedCACertificateAnchors, CertStore trustedCACertificateCertStore) { + this(trustedCACertificateAnchors, trustedCACertificateCertStore, List.of()); + } + + public SubjectCertificateTrustedValidator(Set trustedCACertificateAnchors, + CertStore trustedCACertificateCertStore, + List additionalIntermediateCertificates) { this.trustedCACertificateAnchors = trustedCACertificateAnchors; this.trustedCACertificateCertStore = trustedCACertificateCertStore; + this.additionalIntermediateCertificates = additionalIntermediateCertificates; } /** @@ -69,7 +77,7 @@ public void validateCertificateTrusted(X509Certificate subjectCertificate) throw "User", trustedCACertificateAnchors, trustedCACertificateCertStore, - List.of(), + additionalIntermediateCertificates, // Intermediate CA certificates require revocation checks here because they are not checked elsewhere. // Subject certificate revocation is handled separately by SubjectCertificateNotRevokedValidator. CertificateValidator.IntermediateRevocationCheck.ENABLED, diff --git a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateValidatorBatch.java b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateValidatorBatch.java index 7fd3bc36..edfd80e3 100644 --- a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateValidatorBatch.java +++ b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateValidatorBatch.java @@ -74,11 +74,12 @@ public static SubjectCertificateValidatorBatch forTrustValidation( AuthTokenValidationConfiguration configuration, Set trustedCACertificateAnchors, CertStore trustedCACertificateCertStore, + List additionalIntermediateCertificates, OcspClient ocspClient, OcspServiceProvider ocspServiceProvider) { final SubjectCertificateTrustedValidator certTrustedValidator = - new SubjectCertificateTrustedValidator(trustedCACertificateAnchors, trustedCACertificateCertStore); + new SubjectCertificateTrustedValidator(trustedCACertificateAnchors, trustedCACertificateCertStore, additionalIntermediateCertificates); return SubjectCertificateValidatorBatch.createFrom( certTrustedValidator::validateCertificateTrusted diff --git a/src/main/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion1Validator.java b/src/main/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion1Validator.java index f2c2105a..a38c4b64 100644 --- a/src/main/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion1Validator.java +++ b/src/main/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion1Validator.java @@ -26,6 +26,7 @@ import eu.webeid.security.certificate.CertificateLoader; import eu.webeid.security.exceptions.AuthTokenException; import eu.webeid.security.exceptions.AuthTokenParseException; +import eu.webeid.security.util.Strings; import eu.webeid.security.validator.AuthTokenSignatureValidator; import eu.webeid.security.validator.AuthTokenValidationConfiguration; import eu.webeid.security.validator.certvalidators.SubjectCertificateValidatorBatch; @@ -35,6 +36,7 @@ import java.security.cert.CertStore; import java.security.cert.TrustAnchor; import java.security.cert.X509Certificate; +import java.util.List; import java.util.Set; import java.util.regex.Pattern; @@ -79,10 +81,17 @@ protected Pattern getSupportedFormatPattern() { @Override public X509Certificate validate(WebEidAuthToken token, String currentChallengeNonce) throws AuthTokenException { - if (isExactV10Format(token.getFormat()) && token.getUnverifiedSigningCertificates() != null) { - throw new AuthTokenParseException( - "'unverifiedSigningCertificates' field is not allowed for format '" + token.getFormat() + "'" - ); + if (isExactV10Format(token.getFormat())) { + if (token.getUnverifiedSigningCertificates() != null) { + throw new AuthTokenParseException( + "'unverifiedSigningCertificates' field is not allowed for format '" + token.getFormat() + "'" + ); + } + if (token.getUnverifiedIntermediateCertificates() != null) { + throw new AuthTokenParseException( + "'unverifiedIntermediateCertificates' field is not allowed for format '" + token.getFormat() + "'" + ); + } } if (token.getUnverifiedCertificate() == null || token.getUnverifiedCertificate().isEmpty()) { @@ -90,6 +99,7 @@ public X509Certificate validate(WebEidAuthToken token, String currentChallengeNo } final X509Certificate subjectCertificate = CertificateLoader.decodeCertificateFromBase64(token.getUnverifiedCertificate()); + final List additionalIntermediateCertificates = decodeAdditionalIntermediateCertificates(token); simpleSubjectCertificateValidators.executeFor(subjectCertificate); @@ -97,6 +107,7 @@ public X509Certificate validate(WebEidAuthToken token, String currentChallengeNo configuration, trustedCACertificateAnchors, trustedCACertificateCertStore, + additionalIntermediateCertificates, ocspClient, ocspServiceProvider ).executeFor(subjectCertificate); @@ -116,4 +127,22 @@ public X509Certificate validate(WebEidAuthToken token, String currentChallengeNo private static boolean isExactV10Format(String format) { return V1_SUPPORTED_TOKEN_FORMAT_PREFIX.equals(format) || "web-eid:1.0".equals(format); } + + private static List decodeAdditionalIntermediateCertificates(WebEidAuthToken token) throws AuthTokenException { + validateIntermediateCertificatesField(token.getUnverifiedIntermediateCertificates(), + "unverifiedIntermediateCertificates", token.getFormat()); + return CertificateLoader.decodeCertificatesFromBase64(token.getUnverifiedIntermediateCertificates()); + } + + static void validateIntermediateCertificatesField(List intermediateCertificates, String fieldName, String format) throws AuthTokenParseException { + if (intermediateCertificates == null) { + return; + } + if (intermediateCertificates.isEmpty()) { + throw new AuthTokenParseException("'" + fieldName + "' must not be empty for format '" + format + "'"); + } + if (intermediateCertificates.stream().anyMatch(Strings::isNullOrEmpty)) { + throw new AuthTokenParseException("'" + fieldName + "' must not contain null or empty entries for format '" + format + "'"); + } + } } diff --git a/src/test/java/eu/webeid/security/validator/versionvalidators/AuthTokenV11CertificateTest.java b/src/test/java/eu/webeid/security/validator/versionvalidators/AuthTokenV11CertificateTest.java index 3956db5f..f26b6d6c 100644 --- a/src/test/java/eu/webeid/security/validator/versionvalidators/AuthTokenV11CertificateTest.java +++ b/src/test/java/eu/webeid/security/validator/versionvalidators/AuthTokenV11CertificateTest.java @@ -49,6 +49,8 @@ import java.security.cert.CertificateException; import java.security.cert.TrustAnchor; import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.Base64; import java.util.Collections; import java.util.Set; @@ -248,6 +250,47 @@ void whenV11SigningCertificateNotSuitableForSigning_thenValidationFails() throws } } + @Test + void whenValidV11TokenWithUnverifiedIntermediateCertificates_thenValidationSucceeds() throws Exception { + mockDate("2023-10-01", mockedClock); + + validV11AuthToken.setUnverifiedIntermediateCertificates(Arrays.asList(esteid2018CaCertificateInBase64())); + + assertThatCode(() -> validator.validate(validV11AuthToken, VALID_CHALLENGE_NONCE)) + .doesNotThrowAnyException(); + } + + @Test + void whenUnverifiedIntermediateCertificatesEmpty_thenValidationFails() { + validV11AuthToken.setUnverifiedIntermediateCertificates(Collections.emptyList()); + + assertThatThrownBy(() -> validator.validate(validV11AuthToken, VALID_CHALLENGE_NONCE)) + .isInstanceOf(AuthTokenParseException.class) + .hasMessage("'unverifiedIntermediateCertificates' must not be empty for format 'web-eid:1.1'"); + } + + @Test + void whenUnverifiedIntermediateCertificateContainsEmptyEntry_thenValidationFails() { + validV11AuthToken.setUnverifiedIntermediateCertificates(Arrays.asList("")); + + assertThatThrownBy(() -> validator.validate(validV11AuthToken, VALID_CHALLENGE_NONCE)) + .isInstanceOf(AuthTokenParseException.class) + .hasMessage("'unverifiedIntermediateCertificates' must not contain null or empty entries for format 'web-eid:1.1'"); + } + + @Test + void whenUnverifiedIntermediateCertificateIsNotBase64_thenValidationFails() { + validV11AuthToken.setUnverifiedIntermediateCertificates(Arrays.asList("This is not a certificate")); + + assertThatThrownBy(() -> validator.validate(validV11AuthToken, VALID_CHALLENGE_NONCE)) + .isInstanceOf(CertificateDecodingException.class); + } + + private static String esteid2018CaCertificateInBase64() throws Exception { + X509Certificate caCertificate = CertificateLoader.loadCertificatesFromResources("TEST_of_ESTEID2018.cer")[0]; + return Base64.getEncoder().encodeToString(caCertificate.getEncoded()); + } + private AuthTokenVersion11Validator spyAuthTokenVersion11Validator() { return Mockito.spy(new AuthTokenVersion11Validator( scvb, diff --git a/src/test/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion1ValidatorTest.java b/src/test/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion1ValidatorTest.java index 271f5ed3..e1455324 100644 --- a/src/test/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion1ValidatorTest.java +++ b/src/test/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion1ValidatorTest.java @@ -78,6 +78,7 @@ void whenUnverifiedCertificateMissing_thenValidationFails() { WebEidAuthToken token = mock(WebEidAuthToken.class); when(token.getFormat()).thenReturn("web-eid:1"); when(token.getUnverifiedSigningCertificates()).thenReturn(null); + when(token.getUnverifiedIntermediateCertificates()).thenReturn(null); when(token.getUnverifiedCertificate()).thenReturn(null); assertThatThrownBy(() -> validator.validate(token, "nonce")) @@ -95,4 +96,16 @@ void whenUnverifiedSigningCertificatesPresentForV1_thenValidationFails() { .isInstanceOf(AuthTokenParseException.class) .hasMessageContaining("'unverifiedSigningCertificates' field is not allowed for format 'web-eid:1'"); } + + @Test + void whenUnverifiedIntermediateCertificatesPresentForV1_thenValidationFails() { + WebEidAuthToken token = mock(WebEidAuthToken.class); + when(token.getFormat()).thenReturn("web-eid:1"); + when(token.getUnverifiedSigningCertificates()).thenReturn(null); + when(token.getUnverifiedIntermediateCertificates()).thenReturn(List.of("intermediate")); + + assertThatThrownBy(() -> validator.validate(token, "nonce")) + .isInstanceOf(AuthTokenParseException.class) + .hasMessageContaining("'unverifiedIntermediateCertificates' field is not allowed for format 'web-eid:1'"); + } } From 76364f9d3d491ba971bd44ff05b91fc31b59b742 Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Thu, 2 Jul 2026 23:47:34 +0300 Subject: [PATCH 09/14] NFC-128 Validate signing certificate chains with token-supplied intermediates --- .../AuthTokenVersion11Validator.java | 70 ++++++++-------- .../AuthTokenVersionValidatorFactory.java | 3 +- .../AuthTokenV11CertificateTest.java | 58 ++++++++++++++ .../AuthTokenVersion11ValidatorTest.java | 79 +++++++++++++++++++ 4 files changed, 172 insertions(+), 38 deletions(-) diff --git a/src/main/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion11Validator.java b/src/main/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion11Validator.java index db39fade..352d2c7f 100644 --- a/src/main/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion11Validator.java +++ b/src/main/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion11Validator.java @@ -26,9 +26,10 @@ import eu.webeid.security.authtoken.UnverifiedSigningCertificate; import eu.webeid.security.authtoken.WebEidAuthToken; import eu.webeid.security.certificate.CertificateLoader; +import eu.webeid.security.certificate.CertificateValidator; import eu.webeid.security.exceptions.AuthTokenException; import eu.webeid.security.exceptions.AuthTokenParseException; -import eu.webeid.security.exceptions.CertificateDecodingException; +import eu.webeid.security.util.DateAndTime; import eu.webeid.security.validator.AuthTokenSignatureValidator; import eu.webeid.security.validator.AuthTokenValidationConfiguration; import eu.webeid.security.validator.certvalidators.SubjectCertificateValidatorBatch; @@ -41,17 +42,11 @@ import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils; import javax.security.auth.x500.X500Principal; -import java.security.cert.CertPath; -import java.security.cert.CertPathValidator; import java.security.cert.CertStore; -import java.security.cert.CertificateExpiredException; -import java.security.cert.CertificateFactory; -import java.security.cert.CertificateNotYetValidException; -import java.security.cert.PKIXParameters; import java.security.cert.TrustAnchor; import java.security.cert.X509Certificate; -import java.util.ArrayList; import java.util.Arrays; +import java.util.Date; import java.util.List; import java.util.Set; import java.util.regex.Pattern; @@ -102,13 +97,14 @@ protected Pattern getSupportedFormatPattern() { @Override public X509Certificate validate(WebEidAuthToken token, String currentChallengeNonce) throws AuthTokenException { final X509Certificate subjectCertificate = validateV1(token, currentChallengeNonce); - final List signingCertificates = validateSigningCertificates(token); - for (X509Certificate signingCertificate : signingCertificates) { + for (final UnverifiedSigningCertificate unverifiedSigningCertificate : validateSigningCertificates(token)) { + final X509Certificate signingCertificate = + CertificateLoader.decodeCertificateFromBase64(unverifiedSigningCertificate.getCertificate()); validateSameSubject(subjectCertificate, signingCertificate); validateSameIssuer(subjectCertificate, signingCertificate); - validateSigningCertificateValidity(signingCertificate); validateKeyUsage(signingCertificate); - validateSigningCertificateChain(signingCertificate); + validateSigningCertificateChain(signingCertificate, + CertificateLoader.decodeCertificatesFromBase64(unverifiedSigningCertificate.getIntermediateCertificates())); } return subjectCertificate; @@ -136,24 +132,29 @@ private static void validateSupportedSignatureAlgorithms(UnverifiedSigningCertif } } - private static List validateSigningCertificates(WebEidAuthToken token) throws AuthTokenParseException, CertificateDecodingException { + private static List validateSigningCertificates(WebEidAuthToken token) throws AuthTokenParseException { List signingCertificates = token.getUnverifiedSigningCertificates(); + List intermediateCertificates = token.getUnverifiedIntermediateCertificates(); + // When the authentication certificate's intermediate certificates are present, signing certificates + // are optional. + if (signingCertificates == null && intermediateCertificates != null && !intermediateCertificates.isEmpty()) { + return List.of(); + } if (signingCertificates == null || signingCertificates.isEmpty()) { throw new AuthTokenParseException("'unverifiedSigningCertificates' field is missing, null or empty for format 'web-eid:1.1'"); } - List result = new ArrayList<>(); - for (UnverifiedSigningCertificate certificate : signingCertificates) { if (certificate == null || isNullOrEmpty(certificate.getCertificate())) { throw new AuthTokenParseException("'unverifiedSigningCertificates' contains a null or empty entry for format 'web-eid:1.1'"); } validateSupportedSignatureAlgorithms(certificate); - result.add(CertificateLoader.decodeCertificateFromBase64(certificate.getCertificate())); + validateIntermediateCertificatesField(certificate.getIntermediateCertificates(), + "intermediateCertificates", token.getFormat()); } - return result; + return signingCertificates; } private static void validateSameSubject(X509Certificate subjectCertificate, X509Certificate signingCertificate) @@ -178,15 +179,6 @@ private static void validateSameIssuer(X509Certificate subjectCertificate, X509C } } - private static void validateSigningCertificateValidity(X509Certificate signingCertificate) - throws AuthTokenParseException { - try { - signingCertificate.checkValidity(); - } catch (CertificateExpiredException | CertificateNotYetValidException e) { - throw new AuthTokenParseException("Signing certificate is not valid: " + e.getMessage(), e); - } - } - private static void validateKeyUsage(X509Certificate signingCertificate) throws AuthTokenParseException { boolean[] keyUsage = signingCertificate.getKeyUsage(); @@ -195,19 +187,23 @@ private static void validateKeyUsage(X509Certificate signingCertificate) } } - private void validateSigningCertificateChain(X509Certificate signingCertificate) + private void validateSigningCertificateChain(X509Certificate signingCertificate, List intermediateCertificates) throws AuthTokenParseException { + // Use the clock instance so that the date can be mocked in tests. + final Date now = DateAndTime.DefaultClock.getInstance().now(); try { - CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); - - CertPath certPath = certificateFactory.generateCertPath(List.of(signingCertificate)); - - PKIXParameters parameters = new PKIXParameters(trustedCACertificateAnchors); - parameters.addCertStore(trustedCACertificateCertStore); - parameters.setRevocationEnabled(false); - - CertPathValidator validator = CertPathValidator.getInstance("PKIX"); - validator.validate(certPath, parameters); + // The signing certificate itself deliberately gets no revocation check during authentication: its + // revocation status matters at signing time and is the signature validation service's concern. + // Token-supplied intermediate certificates in its path are checked for revocation. + CertificateValidator.validateIsSignedByTrustedCA( + signingCertificate, + "Signing", + trustedCACertificateAnchors, + trustedCACertificateCertStore, + intermediateCertificates, + CertificateValidator.IntermediateRevocationCheck.ENABLED, + now + ); } catch (Exception e) { throw new AuthTokenParseException("Signing certificate chain validation failed", e); } diff --git a/src/main/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersionValidatorFactory.java b/src/main/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersionValidatorFactory.java index 9d6be378..a8eec4c4 100644 --- a/src/main/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersionValidatorFactory.java +++ b/src/main/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersionValidatorFactory.java @@ -63,7 +63,8 @@ public static AuthTokenVersionValidatorFactory create(AuthTokenValidationConfigu // Copy the configuration object to make AuthTokenVersionValidatorFactory immutable and thread-safe. final AuthTokenValidationConfiguration validationConfig = configuration.copy(); - // Create and cache trusted CA certificate JCA objects for SubjectCertificateTrustedValidator and AiaOcspService. + // Create and cache trusted CA certificate JCA objects for SubjectCertificateTrustedValidator, + // AiaOcspService and the signing certificate chain validation in AuthTokenVersion11Validator. final Set trustedCACertificateAnchors = CertificateValidator.buildTrustAnchorsFromCertificates(validationConfig.getTrustedCACertificates()); final CertStore trustedCACertificateCertStore = CertificateValidator.buildCertStoreFromCertificates(validationConfig.getTrustedCACertificates()); diff --git a/src/test/java/eu/webeid/security/validator/versionvalidators/AuthTokenV11CertificateTest.java b/src/test/java/eu/webeid/security/validator/versionvalidators/AuthTokenV11CertificateTest.java index f26b6d6c..22b4c81d 100644 --- a/src/test/java/eu/webeid/security/validator/versionvalidators/AuthTokenV11CertificateTest.java +++ b/src/test/java/eu/webeid/security/validator/versionvalidators/AuthTokenV11CertificateTest.java @@ -31,6 +31,8 @@ import eu.webeid.security.certificate.CertificateLoader; import eu.webeid.security.exceptions.AuthTokenParseException; import eu.webeid.security.exceptions.CertificateDecodingException; +import eu.webeid.security.exceptions.CertificateExpiredException; +import eu.webeid.security.exceptions.CertificateNotYetValidException; import eu.webeid.security.testutil.AbstractTestWithValidator; import eu.webeid.security.util.DateAndTime; import eu.webeid.security.validator.AuthTokenSignatureValidator; @@ -286,6 +288,62 @@ void whenUnverifiedIntermediateCertificateIsNotBase64_thenValidationFails() { .isInstanceOf(CertificateDecodingException.class); } + @Test + void whenUnverifiedSigningCertificatesAbsentButUnverifiedIntermediateCertificatesPresent_thenValidationSucceeds() throws Exception { + mockDate("2023-10-01", mockedClock); + + validV11AuthToken.setUnverifiedSigningCertificates(null); + validV11AuthToken.setUnverifiedIntermediateCertificates(Arrays.asList(esteid2018CaCertificateInBase64())); + + assertThatCode(() -> validator.validate(validV11AuthToken, VALID_CHALLENGE_NONCE)) + .doesNotThrowAnyException(); + } + + @Test + void whenValidV11TokenWithSigningIntermediateCertificates_thenValidationSucceeds() throws Exception { + mockDate("2023-10-01", mockedClock); + + validV11AuthToken.getUnverifiedSigningCertificates().get(0) + .setIntermediateCertificates(Arrays.asList(esteid2018CaCertificateInBase64())); + + assertThatCode(() -> validator.validate(validV11AuthToken, VALID_CHALLENGE_NONCE)) + .doesNotThrowAnyException(); + } + + @Test + void whenV11SigningCertificateExpired_thenValidationFails() throws Exception { + // Move the clock past the signing certificate's validity window so that + // CertificateValidator.validateIsSignedByTrustedCA() rejects it as expired. + mockDate("2099-01-01", mockedClock); + AuthTokenVersion11Validator spyValidator = spyAuthTokenVersion11Validator(); + X509Certificate realSubjectCert = CertificateLoader.decodeCertificateFromBase64(validV11AuthToken.getUnverifiedCertificate()); + doReturn(realSubjectCert).when(spyValidator).validateV1(any(), any()); + + assertThatThrownBy(() -> spyValidator.validate(validV11AuthToken, VALID_CHALLENGE_NONCE)) + .isInstanceOf(AuthTokenParseException.class) + .hasMessage("Signing certificate chain validation failed") + .cause() + .isInstanceOf(CertificateExpiredException.class) + .hasMessage("Signing certificate has expired"); + } + + @Test + void whenV11SigningCertificateNotYetValid_thenValidationFails() throws Exception { + // Move the clock before the signing certificate's validity window so that + // CertificateValidator.validateIsSignedByTrustedCA() rejects it as not yet valid. + mockDate("2000-01-01", mockedClock); + AuthTokenVersion11Validator spyValidator = spyAuthTokenVersion11Validator(); + X509Certificate realSubjectCert = CertificateLoader.decodeCertificateFromBase64(validV11AuthToken.getUnverifiedCertificate()); + doReturn(realSubjectCert).when(spyValidator).validateV1(any(), any()); + + assertThatThrownBy(() -> spyValidator.validate(validV11AuthToken, VALID_CHALLENGE_NONCE)) + .isInstanceOf(AuthTokenParseException.class) + .hasMessage("Signing certificate chain validation failed") + .cause() + .isInstanceOf(CertificateNotYetValidException.class) + .hasMessage("Signing certificate is not yet valid"); + } + private static String esteid2018CaCertificateInBase64() throws Exception { X509Certificate caCertificate = CertificateLoader.loadCertificatesFromResources("TEST_of_ESTEID2018.cer")[0]; return Base64.getEncoder().encodeToString(caCertificate.getEncoded()); diff --git a/src/test/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion11ValidatorTest.java b/src/test/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion11ValidatorTest.java index 12005ede..67ba7a13 100644 --- a/src/test/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion11ValidatorTest.java +++ b/src/test/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion11ValidatorTest.java @@ -46,9 +46,11 @@ import java.security.cert.TrustAnchor; import java.security.cert.X509Certificate; import java.util.Collections; +import java.util.List; import java.util.Set; 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.doReturn; @@ -108,6 +110,83 @@ void whenUnverifiedSigningCertificatesMissing_thenValidationFails() throws Excep .hasMessage("'unverifiedSigningCertificates' field is missing, null or empty for format 'web-eid:1.1'"); } + @Test + void whenUnverifiedSigningCertificatesMissingButIntermediateCertificatesPresent_thenValidationSucceeds() throws Exception { + WebEidAuthToken token = mock(WebEidAuthToken.class); + when(token.getFormat()).thenReturn("web-eid:1.1"); + when(token.getUnverifiedSigningCertificates()).thenReturn(null); + when(token.getUnverifiedIntermediateCertificates()).thenReturn(List.of("intermediate")); + + AuthTokenVersion11Validator spyValidator = Mockito.spy(validator); + doReturn(mock(X509Certificate.class)).when(spyValidator).validateV1(any(), any()); + + assertThatCode(() -> spyValidator.validate(token, "nonce")) + .doesNotThrowAnyException(); + } + + @Test + void whenUnverifiedSigningCertificatesEmpty_thenValidationFails() throws Exception { + WebEidAuthToken token = mock(WebEidAuthToken.class); + when(token.getFormat()).thenReturn("web-eid:1.1"); + when(token.getUnverifiedSigningCertificates()).thenReturn(Collections.emptyList()); + when(token.getUnverifiedIntermediateCertificates()).thenReturn(List.of("intermediate")); + + AuthTokenVersion11Validator spyValidator = Mockito.spy(validator); + doReturn(mock(X509Certificate.class)).when(spyValidator).validateV1(any(), any()); + + assertThatThrownBy(() -> spyValidator.validate(token, "nonce")) + .isInstanceOf(AuthTokenParseException.class) + .hasMessage("'unverifiedSigningCertificates' field is missing, null or empty for format 'web-eid:1.1'"); + } + + @Test + void whenSigningCertificateIntermediateCertificatesEmpty_thenValidationFails() throws Exception { + WebEidAuthToken token = mock(WebEidAuthToken.class); + when(token.getFormat()).thenReturn("web-eid:1.1"); + + UnverifiedSigningCertificate certificate = new UnverifiedSigningCertificate(); + certificate.setCertificate("abc"); + certificate.setSupportedSignatureAlgorithms(Collections.singletonList(supportedSignatureAlgorithm())); + certificate.setIntermediateCertificates(Collections.emptyList()); + + when(token.getUnverifiedSigningCertificates()).thenReturn(Collections.singletonList(certificate)); + + AuthTokenVersion11Validator spyValidator = Mockito.spy(validator); + doReturn(mock(X509Certificate.class)).when(spyValidator).validateV1(any(), any()); + + assertThatThrownBy(() -> spyValidator.validate(token, "nonce")) + .isInstanceOf(AuthTokenParseException.class) + .hasMessage("'intermediateCertificates' must not be empty for format 'web-eid:1.1'"); + } + + @Test + void whenSigningCertificateIntermediateCertificatesContainsEmptyEntry_thenValidationFails() throws Exception { + WebEidAuthToken token = mock(WebEidAuthToken.class); + when(token.getFormat()).thenReturn("web-eid:1.1"); + + UnverifiedSigningCertificate certificate = new UnverifiedSigningCertificate(); + certificate.setCertificate("abc"); + certificate.setSupportedSignatureAlgorithms(Collections.singletonList(supportedSignatureAlgorithm())); + certificate.setIntermediateCertificates(Collections.singletonList("")); + + when(token.getUnverifiedSigningCertificates()).thenReturn(Collections.singletonList(certificate)); + + AuthTokenVersion11Validator spyValidator = Mockito.spy(validator); + doReturn(mock(X509Certificate.class)).when(spyValidator).validateV1(any(), any()); + + assertThatThrownBy(() -> spyValidator.validate(token, "nonce")) + .isInstanceOf(AuthTokenParseException.class) + .hasMessage("'intermediateCertificates' must not contain null or empty entries for format 'web-eid:1.1'"); + } + + private static SupportedSignatureAlgorithm supportedSignatureAlgorithm() { + SupportedSignatureAlgorithm algorithm = new SupportedSignatureAlgorithm(); + algorithm.setCryptoAlgorithm("RSA"); + algorithm.setHashFunction("SHA-256"); + algorithm.setPaddingScheme("PKCS1.5"); + return algorithm; + } + @Test void whenUnverifiedSigningCertificatesContainsNullEntry_thenValidationFails() throws Exception { WebEidAuthToken token = mock(WebEidAuthToken.class); From a30bbf4cac9dc5ced7c41aafb6937d8bc1d803b0 Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Thu, 2 Jul 2026 23:55:01 +0300 Subject: [PATCH 10/14] NFC-128 Authorize AIA OCSP responders against the subject certificate issuer --- ...SubjectCertificateNotRevokedValidator.java | 18 +- .../SubjectCertificateTrustedValidator.java | 4 + .../SubjectCertificateValidatorBatch.java | 3 +- .../validator/ocsp/OcspServiceProvider.java | 14 +- .../ocsp/service/AiaOcspService.java | 63 ++++- ...ectCertificateNotRevokedValidatorTest.java | 30 ++ .../ocsp/OcspServiceProviderTest.java | 9 +- .../ocsp/service/AiaOcspServiceTest.java | 257 ++++++++++++++++++ 8 files changed, 384 insertions(+), 14 deletions(-) create mode 100644 src/test/java/eu/webeid/security/validator/ocsp/service/AiaOcspServiceTest.java diff --git a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidator.java b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidator.java index c460d405..4ef52b5f 100644 --- a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidator.java +++ b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidator.java @@ -55,6 +55,7 @@ import java.security.cert.X509Certificate; import java.time.Duration; import java.util.Date; +import java.util.List; import java.util.Objects; public final class SubjectCertificateNotRevokedValidator { @@ -66,6 +67,7 @@ public final class SubjectCertificateNotRevokedValidator { private final OcspServiceProvider ocspServiceProvider; private final Duration allowedOcspResponseTimeSkew; private final Duration maxOcspResponseThisUpdateAge; + private final List additionalIntermediateCertificates; static { Security.addProvider(new BouncyCastleProvider()); @@ -76,11 +78,22 @@ public SubjectCertificateNotRevokedValidator(SubjectCertificateTrustedValidator OcspServiceProvider ocspServiceProvider, Duration allowedOcspResponseTimeSkew, Duration maxOcspResponseThisUpdateAge) { + this(trustValidator, ocspClient, ocspServiceProvider, allowedOcspResponseTimeSkew, + maxOcspResponseThisUpdateAge, List.of()); + } + + public SubjectCertificateNotRevokedValidator(SubjectCertificateTrustedValidator trustValidator, + OcspClient ocspClient, + OcspServiceProvider ocspServiceProvider, + Duration allowedOcspResponseTimeSkew, + Duration maxOcspResponseThisUpdateAge, + List additionalIntermediateCertificates) { this.trustValidator = trustValidator; this.ocspClient = ocspClient; this.ocspServiceProvider = ocspServiceProvider; this.allowedOcspResponseTimeSkew = allowedOcspResponseTimeSkew; this.maxOcspResponseThisUpdateAge = maxOcspResponseThisUpdateAge; + this.additionalIntermediateCertificates = additionalIntermediateCertificates; } /** @@ -91,7 +104,10 @@ public SubjectCertificateNotRevokedValidator(SubjectCertificateTrustedValidator */ public void validateCertificateNotRevoked(X509Certificate subjectCertificate) throws AuthTokenException { try { - OcspService ocspService = ocspServiceProvider.getService(subjectCertificate); + OcspService ocspService = ocspServiceProvider.getService( + subjectCertificate, + Objects.requireNonNull(trustValidator.getSubjectCertificateIssuerCertificate()), + additionalIntermediateCertificates); final CertificateID certificateId = getCertificateId(subjectCertificate, Objects.requireNonNull(trustValidator.getSubjectCertificateIssuerCertificate())); diff --git a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateTrustedValidator.java b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateTrustedValidator.java index 2501d69a..6ccd1710 100644 --- a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateTrustedValidator.java +++ b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateTrustedValidator.java @@ -86,6 +86,10 @@ public void validateCertificateTrusted(X509Certificate subjectCertificate) throw LOG.debug("Subject certificate is valid and signed by a trusted CA"); } + /** + * Returns the certificate that directly issued the subject certificate, or the trust anchor when the anchor + * is the direct issuer. Available after {@link #validateCertificateTrusted(X509Certificate)} has succeeded. + */ public X509Certificate getSubjectCertificateIssuerCertificate() { return subjectCertificateIssuerCertificate; } diff --git a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateValidatorBatch.java b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateValidatorBatch.java index edfd80e3..4cb2a22c 100644 --- a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateValidatorBatch.java +++ b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificateValidatorBatch.java @@ -88,7 +88,8 @@ public static SubjectCertificateValidatorBatch forTrustValidation( certTrustedValidator, ocspClient, ocspServiceProvider, configuration.getAllowedOcspResponseTimeSkew(), - configuration.getMaxOcspResponseThisUpdateAge() + configuration.getMaxOcspResponseThisUpdateAge(), + additionalIntermediateCertificates )::validateCertificateNotRevoked ); } diff --git a/src/main/java/eu/webeid/security/validator/ocsp/OcspServiceProvider.java b/src/main/java/eu/webeid/security/validator/ocsp/OcspServiceProvider.java index 5f83c1d9..4a6ea659 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/OcspServiceProvider.java +++ b/src/main/java/eu/webeid/security/validator/ocsp/OcspServiceProvider.java @@ -31,6 +31,7 @@ import java.security.cert.CertificateEncodingException; import java.security.cert.X509Certificate; +import java.util.List; import java.util.Objects; public class OcspServiceProvider { @@ -48,17 +49,26 @@ public OcspServiceProvider(DesignatedOcspServiceConfiguration designatedOcspServ /** * A static factory method that returns either the designated or AIA OCSP service instance depending on whether * the designated OCSP service is configured and supports the issuer of the certificate. + *

+ * An AIA OCSP service instance is created for a single validation run of the given certificate. * * @param certificate subject certificate that is to be checked with OCSP + * @param certificateIssuerCertificate the certificate that directly issued the subject certificate + * @param additionalIntermediateCertificates untrusted, token-supplied intermediate certificates that may be + * needed to build the OCSP responder's certification path to a trusted CA; may be empty * @return either the designated or AIA OCSP service instance * @throws AuthTokenException when AIA URL is not found in certificate * @throws CertificateEncodingException when certificate is invalid */ - public OcspService getService(X509Certificate certificate) throws AuthTokenException, CertificateEncodingException { + public OcspService getService(X509Certificate certificate, + X509Certificate certificateIssuerCertificate, + List additionalIntermediateCertificates) throws AuthTokenException, CertificateEncodingException { if (designatedOcspService != null && designatedOcspService.supportsIssuerOf(certificate)) { + // The designated responder is pinned by equality, so the subject issuer and token-supplied + // intermediate certificates are not needed for its validation. return designatedOcspService; } - return new AiaOcspService(aiaOcspServiceConfiguration, certificate); + return new AiaOcspService(aiaOcspServiceConfiguration, certificate, certificateIssuerCertificate, additionalIntermediateCertificates); } } diff --git a/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java b/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java index 0a04df55..5e4a8054 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java +++ b/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java @@ -24,6 +24,7 @@ import eu.webeid.security.certificate.CertificateValidator; import eu.webeid.security.exceptions.AuthTokenException; +import eu.webeid.security.exceptions.CertificateNotTrustedException; import eu.webeid.security.exceptions.OCSPCertificateException; import eu.webeid.security.exceptions.UserCertificateOCSPCheckFailedException; import eu.webeid.security.validator.ocsp.OcspResponseValidator; @@ -35,6 +36,7 @@ import java.security.cert.CertificateException; import java.security.cert.TrustAnchor; import java.security.cert.X509Certificate; +import java.util.Arrays; import java.util.Date; import java.util.List; import java.util.Objects; @@ -50,13 +52,30 @@ public class AiaOcspService implements OcspService { private final JcaX509CertificateConverter certificateConverter = new JcaX509CertificateConverter(); private final Set trustedCACertificateAnchors; private final CertStore trustedCACertificateCertStore; + private final X509Certificate certificateIssuerCertificate; + private final List additionalIntermediateCertificates; private final URI url; private final boolean supportsNonce; - public AiaOcspService(AiaOcspServiceConfiguration configuration, X509Certificate certificate) throws AuthTokenException { + /** + * Creates an AIA OCSP service for a single validation run of the given certificate. + * + * @param configuration AIA OCSP service configuration + * @param certificate the certificate whose revocation status the service answers for + * @param certificateIssuerCertificate the certificate that directly issued the given certificate; the OCSP + * responder must be authorized by it + * @param additionalIntermediateCertificates untrusted, token-supplied intermediate certificates that may be + * needed to build the responder's certification path to a trusted CA; may be empty + */ + public AiaOcspService(AiaOcspServiceConfiguration configuration, + X509Certificate certificate, + X509Certificate certificateIssuerCertificate, + List additionalIntermediateCertificates) throws AuthTokenException { Objects.requireNonNull(configuration); this.trustedCACertificateAnchors = configuration.getTrustedCACertificateAnchors(); this.trustedCACertificateCertStore = configuration.getTrustedCACertificateCertStore(); + this.certificateIssuerCertificate = Objects.requireNonNull(certificateIssuerCertificate); + this.additionalIntermediateCertificates = Objects.requireNonNull(additionalIntermediateCertificates); this.url = getOcspAiaUrlFromCertificate(Objects.requireNonNull(certificate)); this.supportsNonce = !configuration.getNonceDisabledOcspUrls().contains(this.url); } @@ -77,23 +96,55 @@ public void validateResponderCertificate(X509CertificateHolder cert, Date now) t final X509Certificate certificate = certificateConverter.getCertificate(cert); OcspResponseValidator.validateHasSigningExtension(certificate); // The responder certificate's validity on the current date is checked as part of the certification - // path validation. Responder certificates are deliberately not revocation-checked: RFC 6960 section - // 4.2.2.2.1 lets CAs vouch for their responders with id-pkix-ocsp-nocheck instead, and asking an OCSP - // service whether its own signer is revoked would be circular. - CertificateValidator.validateIsSignedByTrustedCA( + // path validation. A responder may be issued by a token-supplied intermediate that is not itself + // trusted, so the intermediates are offered as path candidates; the path must still terminate at a + // trusted anchor. Revocation is deliberately not checked anywhere in this path, for two distinct + // reasons: + // + // - The responder certificate itself is never revocation-checked, whatever revocation policy the + // CA has chosen for it under RFC 6960 section 4.2.2.2.1: OCSP-checking a responder against its + // own service would be circular, and no CRL-based check of the responder certificate is + // implemented (the only CRL use in the library is the default JDK checker's CRL fallback in + // CertificateValidator.validateIntermediateCertificatesNotRevoked). In practice all production + // Estonian, Belgian and Finnish AIA responder certificates carry id-pkix-ocsp-nocheck, which + // tells clients to skip the check anyway. + // + // - The intermediate CA certificates of the path are not revocation-checked either, hence the + // IntermediateRevocationCheck.DISABLED argument. The representsSameCA checks in this method only + // accept a responder that is, or is directly delegated by, the subject certificate's issuer, + // matched by subject and public key. This validation run has already vetted that issuer while + // validating the subject certificate, as either a configured trust anchor or a token-supplied + // intermediate that was revocation-checked then. When the responder's path is built through the + // same certificates, re-checking it here would only repeat those checks; when it is built + // through an equivalent cross-certificate of the issuer (accepted by the subject and public key + // match), the distinct certificates in that path are knowingly left unchecked. + final X509Certificate responderIssuerCertificate = CertificateValidator.validateIsSignedByTrustedCA( certificate, "AIA OCSP responder", trustedCACertificateAnchors, trustedCACertificateCertStore, - List.of(), + additionalIntermediateCertificates, CertificateValidator.IntermediateRevocationCheck.DISABLED, now ); + // RFC 6960 section 4.2.2.2: the responder must be the CA that issued the subject certificate or be + // directly delegated by it. CA identity is compared by subject and public key so that equivalent + // cross-certificates for the same CA are accepted. + if (!representsSameCA(certificate, certificateIssuerCertificate) + && !representsSameCA(responderIssuerCertificate, certificateIssuerCertificate)) { + throw new CertificateNotTrustedException(certificate, + new CertificateException("OCSP responder is not authorized by the subject certificate issuer")); + } } catch (CertificateException e) { throw new OCSPCertificateException("Invalid responder certificate", e); } } + private static boolean representsSameCA(X509Certificate first, X509Certificate second) { + return first.getSubjectX500Principal().equals(second.getSubjectX500Principal()) + && Arrays.equals(first.getPublicKey().getEncoded(), second.getPublicKey().getEncoded()); + } + private static URI getOcspAiaUrlFromCertificate(X509Certificate certificate) throws AuthTokenException { return getOcspUri(certificate).orElseThrow(() -> new UserCertificateOCSPCheckFailedException("Getting the AIA OCSP responder field from the certificate failed") diff --git a/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidatorTest.java b/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidatorTest.java index 771c3018..8af8b10e 100644 --- a/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidatorTest.java +++ b/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificateNotRevokedValidatorTest.java @@ -63,6 +63,7 @@ import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class SubjectCertificateNotRevokedValidatorTest { @@ -153,6 +154,35 @@ void whenOcspResponseIsNotSuccessful_thenThrows() throws Exception { .withMessage("User certificate revocation check has failed: Response status: internal error"); } + @Test + void whenAdditionalIntermediateCertificatesProvided_thenForwardsThemToOcspServiceProvider() throws Exception { + final X509Certificate issuerCertificate = trustedValidator.getSubjectCertificateIssuerCertificate(); + final List additionalIntermediateCertificates = List.of(getTestEsteid2018CA()); + final OcspServiceProvider ocspServiceProvider = mock(OcspServiceProvider.class); + when(ocspServiceProvider.getService( + estEid2018Cert, + issuerCertificate, + additionalIntermediateCertificates + )).thenThrow(new UserCertificateOCSPCheckFailedException("stop after service selection")); + final SubjectCertificateNotRevokedValidator validator = new SubjectCertificateNotRevokedValidator( + trustedValidator, + ocspClient, + ocspServiceProvider, + CONFIGURATION.getAllowedOcspResponseTimeSkew(), + CONFIGURATION.getMaxOcspResponseThisUpdateAge(), + additionalIntermediateCertificates + ); + + assertThatExceptionOfType(UserCertificateOCSPCheckFailedException.class) + .isThrownBy(() -> validator.validateCertificateNotRevoked(estEid2018Cert)) + .withMessage("User certificate revocation check has failed: stop after service selection"); + verify(ocspServiceProvider).getService( + estEid2018Cert, + issuerCertificate, + additionalIntermediateCertificates + ); + } + @Test void whenOcspResponseHasInvalidCertificateId_thenThrows() throws Exception { final SubjectCertificateNotRevokedValidator validator = getSubjectCertificateNotRevokedValidatorWithAiaOcsp( diff --git a/src/test/java/eu/webeid/security/validator/ocsp/OcspServiceProviderTest.java b/src/test/java/eu/webeid/security/validator/ocsp/OcspServiceProviderTest.java index c038f2a2..908ad486 100644 --- a/src/test/java/eu/webeid/security/validator/ocsp/OcspServiceProviderTest.java +++ b/src/test/java/eu/webeid/security/validator/ocsp/OcspServiceProviderTest.java @@ -29,6 +29,7 @@ import java.net.URI; import java.util.Date; +import java.util.List; import static eu.webeid.security.testutil.Certificates.getJaakKristjanEsteid2018Cert; import static eu.webeid.security.testutil.Certificates.getMariliisEsteid2015Cert; @@ -46,7 +47,7 @@ class OcspServiceProviderTest { @Test void whenDesignatedOcspServiceConfigurationProvided_thenCreatesDesignatedOcspService() throws Exception { final OcspServiceProvider ocspServiceProvider = getDesignatedOcspServiceProvider(); - final OcspService service = ocspServiceProvider.getService(getJaakKristjanEsteid2018Cert()); + final OcspService service = ocspServiceProvider.getService(getJaakKristjanEsteid2018Cert(), getTestEsteid2018CA(), List.of()); assertThat(service.getAccessLocation()).isEqualTo(new URI("http://demo.sk.ee/ocsp")); assertThat(service.doesSupportNonce()).isTrue(); assertThatCode(() -> @@ -61,7 +62,7 @@ void whenDesignatedOcspServiceConfigurationProvided_thenCreatesDesignatedOcspSer @Test void whenAiaOcspServiceConfigurationProvided_thenCreatesAiaOcspService() throws Exception { final OcspServiceProvider ocspServiceProvider = getAiaOcspServiceProvider(); - final OcspService service2018 = ocspServiceProvider.getService(getJaakKristjanEsteid2018Cert()); + final OcspService service2018 = ocspServiceProvider.getService(getJaakKristjanEsteid2018Cert(), getTestEsteid2018CA(), List.of()); assertThat(service2018.getAccessLocation()).isEqualTo(new URI("http://aia.demo.sk.ee/esteid2018")); assertThat(service2018.doesSupportNonce()).isTrue(); assertThatCode(() -> @@ -69,7 +70,7 @@ void whenAiaOcspServiceConfigurationProvided_thenCreatesAiaOcspService() throws service2018.validateResponderCertificate(new X509CertificateHolder(getTestEsteid2018CA().getEncoded()), new Date(1630000000000L))) .doesNotThrowAnyException(); - final OcspService service2015 = ocspServiceProvider.getService(getMariliisEsteid2015Cert()); + final OcspService service2015 = ocspServiceProvider.getService(getMariliisEsteid2015Cert(), getTestEsteid2015CA(), List.of()); assertThat(service2015.getAccessLocation()).isEqualTo(new URI("http://aia.demo.sk.ee/esteid2015")); assertThat(service2015.doesSupportNonce()).isFalse(); assertThatCode(() -> @@ -81,7 +82,7 @@ void whenAiaOcspServiceConfigurationProvided_thenCreatesAiaOcspService() throws @Test void whenAiaOcspServiceConfigurationDoesNotHaveResponderCertTrustedCA_thenThrows() throws Exception { final OcspServiceProvider ocspServiceProvider = getAiaOcspServiceProvider(); - final OcspService service2018 = ocspServiceProvider.getService(getJaakKristjanEsteid2018Cert()); + final OcspService service2018 = ocspServiceProvider.getService(getJaakKristjanEsteid2018Cert(), getTestEsteid2018CA(), List.of()); final X509CertificateHolder wrongResponderCert = new X509CertificateHolder(getMariliisEsteid2015Cert().getEncoded()); assertThatExceptionOfType(OCSPCertificateException.class) .isThrownBy(() -> diff --git a/src/test/java/eu/webeid/security/validator/ocsp/service/AiaOcspServiceTest.java b/src/test/java/eu/webeid/security/validator/ocsp/service/AiaOcspServiceTest.java new file mode 100644 index 00000000..19cf67a0 --- /dev/null +++ b/src/test/java/eu/webeid/security/validator/ocsp/service/AiaOcspServiceTest.java @@ -0,0 +1,257 @@ +/* + * Copyright (c) 2020-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package eu.webeid.security.validator.ocsp.service; + +import eu.webeid.security.certificate.CertificateValidator; +import eu.webeid.security.exceptions.CertificateNotTrustedException; +import eu.webeid.security.validator.ocsp.OcspServiceProvider; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.AccessDescription; +import org.bouncycastle.asn1.x509.AuthorityInformationAccess; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.ExtendedKeyUsage; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; +import java.net.URI; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PublicKey; +import java.security.cert.CertificateException; +import java.security.cert.TrustAnchor; +import java.security.cert.X509Certificate; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Verifies AIA responder path building through token-supplied intermediates and the authorization boundary between + * CA-delegated AIA responders and explicitly configured designated responders. + */ +class AiaOcspServiceTest { + + private static final Date NOW = new Date(); + private static final Date NOT_BEFORE = new Date(NOW.getTime() - 86_400_000L); + private static final Date NOT_AFTER = new Date(NOW.getTime() + 86_400_000L); + private static final String OCSP_URL = "http://ocsp.example/responder"; + + private static X509Certificate intermediateCertificate; + private static X509Certificate crossIntermediateCertificate; + private static X509Certificate impostorIntermediateCertificate; + private static X509Certificate impostorResponderCertificate; + private static X509Certificate responderCertificate; + private static X509Certificate rootIssuedResponderCertificate; + private static X509Certificate siblingIntermediateCertificate; + private static X509Certificate siblingResponderCertificate; + private static X509Certificate subjectCertificate; + private static AiaOcspServiceConfiguration aiaOcspServiceConfiguration; + + @BeforeAll + static void setUp() throws Exception { + final KeyPair rootKeyPair = generateKeyPair(); + final KeyPair intermediateKeyPair = generateKeyPair(); + final KeyPair responderKeyPair = generateKeyPair(); + final KeyPair rootIssuedResponderKeyPair = generateKeyPair(); + final KeyPair siblingIntermediateKeyPair = generateKeyPair(); + final KeyPair siblingResponderKeyPair = generateKeyPair(); + final KeyPair subjectKeyPair = generateKeyPair(); + + final X509Certificate rootCertificate = generateCertificate( + "Test Root CA", rootKeyPair.getPublic(), "Test Root CA", rootKeyPair, 1, true, false, null); + intermediateCertificate = generateCertificate( + "Test Intermediate CA", intermediateKeyPair.getPublic(), "Test Root CA", rootKeyPair, 2, true, false, null); + // An equivalent cross-certificate for the intermediate CA: same subject and public key as + // intermediateCertificate, but a distinct certificate (different serial). RFC 6960 authorization must accept it. + crossIntermediateCertificate = generateCertificate( + "Test Intermediate CA", intermediateKeyPair.getPublic(), "Test Root CA", rootKeyPair, 7, true, false, null); + // An impostor CA with the same subject name as the intermediate CA but a different key pair. A responder + // delegated by it must not be treated as authorized by the subject certificate's issuer. + final KeyPair impostorIntermediateKeyPair = generateKeyPair(); + impostorIntermediateCertificate = generateCertificate( + "Test Intermediate CA", impostorIntermediateKeyPair.getPublic(), "Test Root CA", rootKeyPair, 10, true, false, null); + impostorResponderCertificate = generateCertificate( + "Impostor OCSP Responder", generateKeyPair().getPublic(), "Test Intermediate CA", + impostorIntermediateKeyPair, 11, false, true, null); + // The OCSP responder is delegated by the intermediate CA (RFC 6960 CA-designated responder). + responderCertificate = generateCertificate( + "Test OCSP Responder", responderKeyPair.getPublic(), "Test Intermediate CA", intermediateKeyPair, 3, false, true, null); + // This responder is trusted through the same root, but it is not delegated by the subject certificate's issuer. + // It can only be used as a locally configured trusted responder (RFC 6960 section 4.2.2.2, criterion 1). + rootIssuedResponderCertificate = generateCertificate( + "Root-Issued OCSP Responder", rootIssuedResponderKeyPair.getPublic(), "Test Root CA", + rootKeyPair, 8, false, true, null); + siblingIntermediateCertificate = generateCertificate( + "Sibling Intermediate CA", siblingIntermediateKeyPair.getPublic(), "Test Root CA", rootKeyPair, 5, true, false, null); + siblingResponderCertificate = generateCertificate( + "Sibling OCSP Responder", siblingResponderKeyPair.getPublic(), "Sibling Intermediate CA", + siblingIntermediateKeyPair, 6, false, true, null); + // The subject certificate serves two purposes: AiaOcspService reads the AIA OCSP URL from it, and the + // designated-responder test needs its issuer name to match intermediateCertificate's subject so that + // DesignatedOcspServiceConfiguration.supportsIssuerOf selects the designated service. + subjectCertificate = generateCertificate( + "Test Subject", subjectKeyPair.getPublic(), "Test Intermediate CA", intermediateKeyPair, 4, false, false, OCSP_URL); + + // Trust only the root. The intermediate that issued both the subject and the responder is NOT configured as + // trusted, exactly like a deployment that relies on the token-supplied intermediate to build the chain. + final Set anchors = Collections.singleton(new TrustAnchor(rootCertificate, null)); + aiaOcspServiceConfiguration = new AiaOcspServiceConfiguration( + Collections.emptySet(), anchors, CertificateValidator.buildCertStoreFromCertificates(Collections.emptyList())); + } + + @Test + void whenResponderChainsViaTokenIntermediate_thenValidationSucceeds() throws Exception { + final AiaOcspService service = aiaServiceFor(intermediateCertificate, Collections.singletonList(intermediateCertificate)); + final X509CertificateHolder responderHolder = new X509CertificateHolder(responderCertificate.getEncoded()); + + assertThatCode(() -> service.validateResponderCertificate(responderHolder, NOW)) + .doesNotThrowAnyException(); + } + + @Test + void whenResponderChainsViaTokenIntermediateButIntermediateMissing_thenValidationFails() throws Exception { + final AiaOcspService service = aiaServiceFor(intermediateCertificate, Collections.emptyList()); + final X509CertificateHolder responderHolder = new X509CertificateHolder(responderCertificate.getEncoded()); + + // Without the token-supplied intermediate, the responder -> intermediate -> root path cannot be built. + assertThatExceptionOfType(CertificateNotTrustedException.class) + .isThrownBy(() -> service.validateResponderCertificate(responderHolder, NOW)); + } + + @Test + void whenResponderIssuerIsEquivalentCrossCertificate_thenValidationSucceeds() throws Exception { + // The responder still chains to the root via the real intermediate, so its issuer in the built path is + // intermediateCertificate. The subject issuer is passed as the equivalent cross-certificate (same subject and + // public key, different certificate), which representsSameCA must treat as the same CA. + final AiaOcspService service = aiaServiceFor(crossIntermediateCertificate, Collections.singletonList(intermediateCertificate)); + final X509CertificateHolder responderHolder = new X509CertificateHolder(responderCertificate.getEncoded()); + + assertThatCode(() -> service.validateResponderCertificate(responderHolder, NOW)) + .doesNotThrowAnyException(); + } + + @Test + void whenResponderIsIssuedBySiblingIntermediate_thenValidationFails() throws Exception { + final AiaOcspService service = aiaServiceFor(intermediateCertificate, + List.of(intermediateCertificate, siblingIntermediateCertificate)); + final X509CertificateHolder responderHolder = + new X509CertificateHolder(siblingResponderCertificate.getEncoded()); + + assertThatExceptionOfType(CertificateNotTrustedException.class) + .isThrownBy(() -> service.validateResponderCertificate(responderHolder, NOW)) + .withCauseInstanceOf(CertificateException.class); + } + + @Test + void whenResponderIssuerHasSameNameButDifferentKeyThanSubjectIssuer_thenValidationFails() throws Exception { + final AiaOcspService service = aiaServiceFor(intermediateCertificate, + Collections.singletonList(impostorIntermediateCertificate)); + final X509CertificateHolder responderHolder = + new X509CertificateHolder(impostorResponderCertificate.getEncoded()); + + assertThatExceptionOfType(CertificateNotTrustedException.class) + .isThrownBy(() -> service.validateResponderCertificate(responderHolder, NOW)) + .withCauseInstanceOf(CertificateException.class) + .havingCause() + .withMessage("OCSP responder is not authorized by the subject certificate issuer"); + } + + @Test + void whenResponderIsIssuedByRootInsteadOfSubjectIssuer_thenAiaValidationFails() throws Exception { + final AiaOcspService service = aiaServiceFor(intermediateCertificate, Collections.emptyList()); + final X509CertificateHolder responderHolder = + new X509CertificateHolder(rootIssuedResponderCertificate.getEncoded()); + + assertThatExceptionOfType(CertificateNotTrustedException.class) + .isThrownBy(() -> service.validateResponderCertificate(responderHolder, NOW)) + .withCauseInstanceOf(CertificateException.class); + } + + @Test + void whenRootIssuedResponderIsExplicitlyDesignatedForSubjectIssuer_thenValidationSucceeds() throws Exception { + final DesignatedOcspServiceConfiguration designatedConfiguration = + new DesignatedOcspServiceConfiguration( + URI.create(OCSP_URL), rootIssuedResponderCertificate, List.of(intermediateCertificate), true); + final OcspServiceProvider provider = + new OcspServiceProvider(designatedConfiguration, aiaOcspServiceConfiguration); + final OcspService service = provider.getService(subjectCertificate, intermediateCertificate, Collections.emptyList()); + final X509CertificateHolder responderHolder = + new X509CertificateHolder(rootIssuedResponderCertificate.getEncoded()); + + assertThat(service).isInstanceOf(DesignatedOcspService.class); + assertThatCode(() -> service.validateResponderCertificate(responderHolder, NOW)) + .doesNotThrowAnyException(); + } + + private static AiaOcspService aiaServiceFor(X509Certificate certificateIssuerCertificate, + List additionalIntermediateCertificates) throws Exception { + return new AiaOcspService(aiaOcspServiceConfiguration, subjectCertificate, + certificateIssuerCertificate, additionalIntermediateCertificates); + } + + private static KeyPair generateKeyPair() throws Exception { + final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + return keyPairGenerator.generateKeyPair(); + } + + private static X509Certificate generateCertificate(String subjectCn, PublicKey subjectPublicKey, + String issuerCn, KeyPair issuerKeyPair, long serial, + boolean ca, boolean ocspSigning, String ocspUrl) throws Exception { + final JcaX509ExtensionUtils extensionUtils = new JcaX509ExtensionUtils(); + final JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( + new X500Name("CN=" + issuerCn), BigInteger.valueOf(serial), NOT_BEFORE, NOT_AFTER, + new X500Name("CN=" + subjectCn), subjectPublicKey); + builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(ca)); + builder.addExtension(Extension.subjectKeyIdentifier, false, extensionUtils.createSubjectKeyIdentifier(subjectPublicKey)); + builder.addExtension(Extension.authorityKeyIdentifier, false, extensionUtils.createAuthorityKeyIdentifier(issuerKeyPair.getPublic())); + if (ca) { + builder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign | KeyUsage.cRLSign)); + } + if (ocspSigning) { + builder.addExtension(Extension.extendedKeyUsage, false, new ExtendedKeyUsage(KeyPurposeId.id_kp_OCSPSigning)); + } + if (ocspUrl != null) { + builder.addExtension(Extension.authorityInfoAccess, false, + new AuthorityInformationAccess(new AccessDescription(AccessDescription.id_ad_ocsp, + new GeneralName(GeneralName.uniformResourceIdentifier, ocspUrl)))); + } + final ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(issuerKeyPair.getPrivate()); + return new JcaX509CertificateConverter().getCertificate(builder.build(signer)); + } +} From e7486438f51b558a2496177d1e91c9d47a0b1989 Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Thu, 2 Jul 2026 23:57:18 +0300 Subject: [PATCH 11/14] NFC-128 Require the OCSP-signing extended key usage only for delegated responder certificates --- .../validator/ocsp/OcspResponseValidator.java | 5 ++-- .../ocsp/service/AiaOcspService.java | 15 ++++++---- .../ocsp/service/AiaOcspServiceTest.java | 28 +++++++++++++++++++ 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/main/java/eu/webeid/security/validator/ocsp/OcspResponseValidator.java b/src/main/java/eu/webeid/security/validator/ocsp/OcspResponseValidator.java index 0dc4fda5..ae62c3e0 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/OcspResponseValidator.java +++ b/src/main/java/eu/webeid/security/validator/ocsp/OcspResponseValidator.java @@ -59,7 +59,7 @@ public static void validateHasSigningExtension(X509Certificate certificate) thro try { if (certificate.getExtendedKeyUsage() == null || !certificate.getExtendedKeyUsage().contains(OID_OCSP_SIGNING)) { throw new OCSPCertificateException("Certificate " + certificate.getSubjectX500Principal() + - " does not contain the key usage extension for OCSP response signing"); + " does not contain the extended key usage extension value for OCSP response signing"); } } catch (CertificateParsingException e) { throw new OCSPCertificateException("Certificate parsing failed:", e); @@ -121,8 +121,7 @@ public static void validateSubjectCertificateStatus(SingleResp certStatusRespons if (status == null) { return; } - if (status instanceof RevokedStatus) { - RevokedStatus revokedStatus = (RevokedStatus) status; + if (status instanceof RevokedStatus revokedStatus) { throw (revokedStatus.hasRevocationReason() ? new UserCertificateRevokedException("Revocation reason: " + revokedStatus.getRevocationReason()) : new UserCertificateRevokedException()); diff --git a/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java b/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java index 5e4a8054..537e9f7f 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java +++ b/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java @@ -94,7 +94,6 @@ public URI getAccessLocation() { public void validateResponderCertificate(X509CertificateHolder cert, Date now) throws AuthTokenException { try { final X509Certificate certificate = certificateConverter.getCertificate(cert); - OcspResponseValidator.validateHasSigningExtension(certificate); // The responder certificate's validity on the current date is checked as part of the certification // path validation. A responder may be issued by a token-supplied intermediate that is not itself // trusted, so the intermediates are offered as path candidates; the path must still terminate at a @@ -127,11 +126,17 @@ public void validateResponderCertificate(X509CertificateHolder cert, Date now) t CertificateValidator.IntermediateRevocationCheck.DISABLED, now ); - // RFC 6960 section 4.2.2.2: the responder must be the CA that issued the subject certificate or be - // directly delegated by it. CA identity is compared by subject and public key so that equivalent + // RFC 6960 section 4.2.2.2: the response must be signed by the CA that issued the subject certificate + // or by a responder directly delegated by it; a locally configured responder is handled by + // DesignatedOcspService. CA identity is compared by subject and public key so that equivalent // cross-certificates for the same CA are accepted. - if (!representsSameCA(certificate, certificateIssuerCertificate) - && !representsSameCA(responderIssuerCertificate, certificateIssuerCertificate)) { + if (representsSameCA(certificate, certificateIssuerCertificate)) { + // The response is signed by the issuing CA itself; the OCSP-signing extended key usage is required + // only for delegated responder certificates. + return; + } + OcspResponseValidator.validateHasSigningExtension(certificate); + if (!representsSameCA(responderIssuerCertificate, certificateIssuerCertificate)) { throw new CertificateNotTrustedException(certificate, new CertificateException("OCSP responder is not authorized by the subject certificate issuer")); } diff --git a/src/test/java/eu/webeid/security/validator/ocsp/service/AiaOcspServiceTest.java b/src/test/java/eu/webeid/security/validator/ocsp/service/AiaOcspServiceTest.java index 19cf67a0..99294743 100644 --- a/src/test/java/eu/webeid/security/validator/ocsp/service/AiaOcspServiceTest.java +++ b/src/test/java/eu/webeid/security/validator/ocsp/service/AiaOcspServiceTest.java @@ -24,6 +24,7 @@ import eu.webeid.security.certificate.CertificateValidator; import eu.webeid.security.exceptions.CertificateNotTrustedException; +import eu.webeid.security.exceptions.OCSPCertificateException; import eu.webeid.security.validator.ocsp.OcspServiceProvider; import org.bouncycastle.asn1.x500.X500Name; import org.bouncycastle.asn1.x509.AccessDescription; @@ -76,6 +77,7 @@ class AiaOcspServiceTest { private static X509Certificate impostorIntermediateCertificate; private static X509Certificate impostorResponderCertificate; private static X509Certificate responderCertificate; + private static X509Certificate noEkuResponderCertificate; private static X509Certificate rootIssuedResponderCertificate; private static X509Certificate siblingIntermediateCertificate; private static X509Certificate siblingResponderCertificate; @@ -111,6 +113,10 @@ static void setUp() throws Exception { // The OCSP responder is delegated by the intermediate CA (RFC 6960 CA-designated responder). responderCertificate = generateCertificate( "Test OCSP Responder", responderKeyPair.getPublic(), "Test Intermediate CA", intermediateKeyPair, 3, false, true, null); + // A responder issued by the intermediate CA but without the OCSP-signing extended key usage; a delegated + // responder must carry it. + noEkuResponderCertificate = generateCertificate( + "No EKU OCSP Responder", generateKeyPair().getPublic(), "Test Intermediate CA", intermediateKeyPair, 9, false, false, null); // This responder is trusted through the same root, but it is not delegated by the subject certificate's issuer. // It can only be used as a locally configured trusted responder (RFC 6960 section 4.2.2.2, criterion 1). rootIssuedResponderCertificate = generateCertificate( @@ -202,6 +208,28 @@ void whenResponderIsIssuedByRootInsteadOfSubjectIssuer_thenAiaValidationFails() .withCauseInstanceOf(CertificateException.class); } + @Test + void whenResponseSignedByIssuingCaWithoutOcspSigningEku_thenValidationSucceeds() throws Exception { + // RFC 6960 section 4.2.2.2: a response signed by the CA that issued the subject certificate is authorized + // by CA identity alone; the OCSP-signing extended key usage is required only for delegated responders. + // The intermediate CA certificate does not carry the extended key usage. + final AiaOcspService service = aiaServiceFor(intermediateCertificate, Collections.singletonList(intermediateCertificate)); + final X509CertificateHolder responderHolder = new X509CertificateHolder(intermediateCertificate.getEncoded()); + + assertThatCode(() -> service.validateResponderCertificate(responderHolder, NOW)) + .doesNotThrowAnyException(); + } + + @Test + void whenDelegatedResponderLacksOcspSigningEku_thenValidationFails() throws Exception { + final AiaOcspService service = aiaServiceFor(intermediateCertificate, Collections.singletonList(intermediateCertificate)); + final X509CertificateHolder responderHolder = new X509CertificateHolder(noEkuResponderCertificate.getEncoded()); + + assertThatExceptionOfType(OCSPCertificateException.class) + .isThrownBy(() -> service.validateResponderCertificate(responderHolder, NOW)) + .withMessageContaining("does not contain the extended key usage extension value for OCSP response signing"); + } + @Test void whenRootIssuedResponderIsExplicitlyDesignatedForSubjectIssuer_thenValidationSucceeds() throws Exception { final DesignatedOcspServiceConfiguration designatedConfiguration = From 2381ae5a55e1645751825a40173d7e9a5feb2fae Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Thu, 2 Jul 2026 23:58:22 +0300 Subject: [PATCH 12/14] NFC-128 Document intermediate certificate token fields --- README.md | 48 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e3aa6986..efbcf87b 100644 --- a/README.md +++ b/README.md @@ -309,12 +309,52 @@ It contains the following fields: - `signature`: the base64-encoded signature of the token (see the description below), -- `format`: the type identifier and version of the token format separated by a colon character '`:`', `web-eid:1.0` as of now; the version number consists of the major and minor number separated by a dot, major version changes are incompatible with previous versions, minor version changes are backwards-compatible within the given major version, +- `format`: the type identifier and version of the token format separated by a colon character '`:`', either `web-eid:1.0` or `web-eid:1.1`; the version number consists of the major and minor number separated by a dot, major version changes are incompatible with previous versions, minor version changes are backwards-compatible within the given major version, - `appVersion`: the URL identifying the name and version of the application that issued the token; informative purpose, can be used to identify the affected application in case of faulty tokens. The value that is signed by the user’s authentication private key and included in the `signature` field is `hash(origin)+hash(challenge)`. The hash function is used before concatenation to ensure field separation as the hash of a value is guaranteed to have a fixed length. Otherwise the origin `example.com` with challenge nonce `.eu1234` and another origin `example.com.eu` with challenge nonce `1234` would result in the same value after concatenation. The hash function `hash` is the same hash function that is used in the signature algorithm, for example SHA256 in case of RS256. +Starting from format version `web-eid:1.1`, the authentication token may additionally carry the intermediate CA certificates needed to build the trust chains of the certificates it contains, as well as the eID user's signing certificates. An extended token looks like the following example: + +```json +{ + "unverifiedCertificate": "MIIFozCCA4ugAwIBAgIQHFpdK-zCQsFW4...", + "unverifiedIntermediateCertificates": ["MIIFfhzYIBAAwIBAgIQHFHFp-AwIBFW4..."], + "algorithm": "RS256", + "signature": "HBjNXIaUskXbfhzYQHvwjKDUWfNu4yxXZha...", + "unverifiedSigningCertificates": [ + { + "certificate": "MIIFoXIaUskXbfhzYIBAgIjKDUsdK-zQHFUKz...", + "intermediateCertificates": ["MIIFfhzYIBAAwIBAgIQHFHFp-AwIBFW4..."], + "supportedSignatureAlgorithms": [ + { + "cryptoAlgorithm": "ECC", + "hashFunction": "SHA-384", + "paddingScheme": "NONE" + } + ] + } + ], + "format": "web-eid:1.1", + "appVersion": "https://web-eid.eu/web-eid-app/releases/v2.0.0" +} +``` + +In addition to the fields described above, the `web-eid:1.1` format defines the following additional fields: + +- `unverifiedIntermediateCertificates`: an array of base64-encoded DER intermediate CA certificates that make up the trust chain of the authentication certificate in `unverifiedCertificate`. Like the authentication certificate, these certificates are received from the client side and cannot be trusted; they are only used as candidate certificates when building the certification path, which must still terminate at a trusted certificate authority. The field is optional, but when present it must not be empty. When it is present, `unverifiedSigningCertificates` may be omitted. + +- `unverifiedSigningCertificates`: an array of the eID user's signing certificates, presented alongside the authentication certificate. For `web-eid:1.1` tokens this field is required unless `unverifiedIntermediateCertificates` is present, and when present it must not be empty. Each entry contains: + + - `certificate`: the base64-encoded DER signing certificate. During validation it must have the same subject and issuer as the authentication certificate, be valid, contain the non-repudiation key usage bit and be signed by a trusted certificate authority. + + - `intermediateCertificates`: an optional array of base64-encoded DER intermediate CA certificates that make up the trust chain of this signing certificate. When present it must not be empty and, as with `unverifiedIntermediateCertificates`, the certificates are only used as candidates when building the certification path to a trusted CA. + + - `supportedSignatureAlgorithms`: the signature algorithms that the signing certificate's key supports. Each entry has a `cryptoAlgorithm` (`ECC` or `RSA`), a `hashFunction` (one of `SHA-224`, `SHA-256`, `SHA-384`, `SHA-512`, `SHA3-224`, `SHA3-256`, `SHA3-384`, `SHA3-512`) and a `paddingScheme` (`NONE`, `PKCS1.5` or `PSS`). + +The signature is always created with the authentication private key and verified using `unverifiedCertificate`; the signing certificates are not involved in the signature verification. + # Authentication token validation @@ -323,6 +363,12 @@ The authentication token validation process consists of two stages: - First, **user certificate validation**: the validator parses the token and extracts the user certificate from the *unverifiedCertificate* field. Then it checks the certificate expiration, purpose and policies. Next it checks that the certificate is signed by a trusted CA and checks the certificate status with OCSP. - Second, **token signature validation**: the validator validates that the token signature was created using the provided user certificate by reconstructing the signed data `hash(origin)+hash(challenge)` and using the public key from the certificate to verify the signature in the `signature` field. If the signature verification succeeds, then the origin and challenge nonce have been implicitly and correctly verified without the need to implement any additional security checks. +When the token is in the `web-eid:1.1` format and contains `unverifiedSigningCertificates`, the validator additionally validates each signing certificate: that it has the same subject and issuer as the authentication certificate, is currently valid, is suitable for digital signatures (contains the non-repudiation key usage bit) and is signed by a trusted certificate authority, building the chain from the certificate's `intermediateCertificates` to a trusted CA. The signing certificate itself is deliberately not checked for revocation during authentication: its revocation status matters at signing time and is validated by the signature creation and validation services. + +When a token supplies intermediate CA certificates and a certification path is built through them, the validator checks the revocation status of the intermediate CA certificates in the path with the Java platform's revocation checker, which prefers OCSP and falls back to CRLs, and rejects the token when the status of an intermediate is revoked or cannot be established. This check runs during path validation for both the authentication and the signing certificate chains, independently of the user certificate OCSP check configuration, because token-supplied intermediates are untrusted input. Deployments that need to accept tokens with intermediate certificates must therefore allow the network access that OCSP or CRL fetching requires. + +When the user certificate OCSP check uses an AIA OCSP responder, the responder is authorized according to [RFC 6960 section 4.2.2.2](https://datatracker.ietf.org/doc/html/rfc6960#section-4.2.2.2): the response must be signed either by the CA that issued the user certificate, in which case the OCSP-signing extended key usage is not required, or by a responder whose certificate contains the id-kp-OCSPSigning extended key usage and chains to a trusted CA through the certificate that issued the user certificate. Responder certificates are deliberately not checked for revocation, following the id-pkix-ocsp-nocheck convention of RFC 6960 section 4.2.2.2.1 and because querying an OCSP service about its own signer would be circular. + The website back end must lookup the challenge nonce from its local store using an identifier specific to the browser session, to guarantee that the authentication token was received from the same browser to which the corresponding challenge nonce was issued. The website back end must guarantee that the challenge nonce lifetime is limited and that its expiration is checked, and that it can be used only once by removing it from the store during validation. ## Basic usage From 96ea16b65368d36380cce015ed97eb2cb96986a6 Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Sat, 4 Jul 2026 10:03:23 +0300 Subject: [PATCH 13/14] NFC-128 Make AIA OCSP issuer matching configurable --- README.md | 3 + .../AuthTokenValidationConfiguration.java | 12 ++ .../validator/AuthTokenValidatorBuilder.java | 18 +++ .../ocsp/service/AiaOcspService.java | 67 ++++++---- .../service/AiaOcspServiceConfiguration.java | 28 ++++ .../AuthTokenVersionValidatorFactory.java | 3 +- .../AuthTokenValidatorBuilderTest.java | 82 ++++++++++++ .../ocsp/service/AiaOcspServiceTest.java | 125 ++++++++++++++++-- 8 files changed, 302 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index efbcf87b..f45672da 100644 --- a/README.md +++ b/README.md @@ -415,6 +415,7 @@ The following additional configuration options are available in `AuthTokenValida - `withOcspRequestTimeout(Duration ocspRequestTimeout)` – sets both the connection and response timeout of user certificate revocation check OCSP requests. Default is 5 seconds. - `withDisallowedCertificatePolicies(ASN1ObjectIdentifier... policies)` – adds the given policies to the list of disallowed user certificate policies. In order for the user certificate to be considered valid, it must not contain any policies present in this list. Contains the Estonian Mobile-ID policies by default as it must not be possible to authenticate with a Mobile-ID certificate when an eID smart card is expected. - `withNonceDisabledOcspUrls(URI... urls)` – adds the given URLs to the list of OCSP responder access location URLs for which the nonce protocol extension will be disabled. Some OCSP responders don't support the nonce extension. +- `withAiaOcspResponderIssuerMatchingPolicy(ResponderIssuerMatchingPolicy matchingPolicy)` – controls how an AIA OCSP responder's issuer is matched against the user certificate's issuer. The default `EXACT_CERTIFICATE` policy requires the same X.509 certificate. Use `SUBJECT_AND_PUBLIC_KEY` to accept equivalent cross-certificates with the same subject and public key; this also enables revocation checking for non-anchor intermediate certificates in the responder's certification path. - `withAllowedOcspResponseTimeSkew(Duration allowedTimeSkew)` – sets the allowed time skew for OCSP response's `thisUpdate` and `nextUpdate` times to allow discrepancies between the system clock and the OCSP responder's clock or revocation updates that are not published in real time. The default allowed time skew is 15 minutes. The relatively long default is specifically chosen to account for one particular OCSP responder that used CRLs for authoritative revocation info, these CRLs were updated every 15 minutes. - `withMaxOcspResponseThisUpdateAge(Duration maxThisUpdateAge)` – sets the maximum age for the OCSP response's `thisUpdate` time before it is considered too old to rely on. The default maximum age is 2 minutes. @@ -436,6 +437,8 @@ AuthTokenValidator validator = new AuthTokenValidatorBuilder() Unless a designated OCSP responder service is in use, it is required that the AIA extension that contains the certificate’s OCSP responder access location is present in the user certificate. The AIA OCSP URL will be used to check the certificate revocation status with OCSP. +By default, the certificate that directly signs an AIA OCSP response, or issues a delegated AIA OCSP responder, must exactly match the certificate that issued the user certificate. Deployments that require equivalent cross-certificates can explicitly select `ResponderIssuerMatchingPolicy.SUBJECT_AND_PUBLIC_KEY` with `withAiaOcspResponderIssuerMatchingPolicy()`. This policy also requires the revocation status of every non-anchor intermediate certificate in the responder's certification path to be established. + Note that there may be limitations to using AIA URLs as the services behind these URLs provide different security and SLA guarantees than dedicated OCSP responder services. In case you need a SLA guarantee, use a designated OCSP responder service. ## Possible validation errors diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java index a48c1266..03f25d2e 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java @@ -23,6 +23,7 @@ package eu.webeid.security.validator; import eu.webeid.security.certificate.SubjectCertificatePolicies; +import eu.webeid.security.validator.ocsp.service.AiaOcspServiceConfiguration.ResponderIssuerMatchingPolicy; import eu.webeid.security.validator.ocsp.service.DesignatedOcspServiceConfiguration; import org.bouncycastle.asn1.ASN1ObjectIdentifier; @@ -59,6 +60,8 @@ public final class AuthTokenValidationConfiguration { SubjectCertificatePolicies.ESTEID_SK_2015_MOBILE_ID_POLICY ); private Collection nonceDisabledOcspUrls = new HashSet<>(); + private ResponderIssuerMatchingPolicy aiaOcspResponderIssuerMatchingPolicy = + ResponderIssuerMatchingPolicy.EXACT_CERTIFICATE; AuthTokenValidationConfiguration() { } @@ -73,6 +76,7 @@ private AuthTokenValidationConfiguration(AuthTokenValidationConfiguration other) this.designatedOcspServiceConfiguration = other.designatedOcspServiceConfiguration; this.disallowedSubjectCertificatePolicies = Set.copyOf(other.disallowedSubjectCertificatePolicies); this.nonceDisabledOcspUrls = Set.copyOf(other.nonceDisabledOcspUrls); + this.aiaOcspResponderIssuerMatchingPolicy = other.aiaOcspResponderIssuerMatchingPolicy; } void setSiteOrigin(URI siteOrigin) { @@ -135,6 +139,14 @@ public Collection getNonceDisabledOcspUrls() { return nonceDisabledOcspUrls; } + public ResponderIssuerMatchingPolicy getAiaOcspResponderIssuerMatchingPolicy() { + return aiaOcspResponderIssuerMatchingPolicy; + } + + void setAiaOcspResponderIssuerMatchingPolicy(ResponderIssuerMatchingPolicy matchingPolicy) { + this.aiaOcspResponderIssuerMatchingPolicy = Objects.requireNonNull(matchingPolicy); + } + /** * Checks that the configuration parameters are valid. * diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java index 2e7ae1ab..8faaa7e4 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java @@ -25,6 +25,7 @@ import eu.webeid.security.exceptions.JceException; import eu.webeid.security.validator.ocsp.OcspClient; import eu.webeid.security.validator.ocsp.OcspClientImpl; +import eu.webeid.security.validator.ocsp.service.AiaOcspServiceConfiguration.ResponderIssuerMatchingPolicy; import eu.webeid.security.validator.ocsp.service.DesignatedOcspServiceConfiguration; import org.bouncycastle.asn1.ASN1ObjectIdentifier; import org.slf4j.Logger; @@ -172,6 +173,23 @@ public AuthTokenValidatorBuilder withNonceDisabledOcspUrls(URI... urls) { return this; } + /** + * Sets how an AIA OCSP responder certificate issuer is matched against the issuer of the subject certificate. + * The default is {@link ResponderIssuerMatchingPolicy#EXACT_CERTIFICATE}. Use + * {@link ResponderIssuerMatchingPolicy#SUBJECT_AND_PUBLIC_KEY} only when equivalent cross-certificates must be + * accepted. Under that policy, non-anchor intermediate certificates in the responder's certification path are + * checked for revocation. + * + * @param matchingPolicy AIA OCSP responder issuer matching policy + * @return the builder instance for method chaining + */ + public AuthTokenValidatorBuilder withAiaOcspResponderIssuerMatchingPolicy( + ResponderIssuerMatchingPolicy matchingPolicy) { + configuration.setAiaOcspResponderIssuerMatchingPolicy(matchingPolicy); + LOG.debug("AIA OCSP responder issuer matching policy set to {}", matchingPolicy); + return this; + } + /** * Activates the provided designated OCSP service for user certificate revocation check with OCSP. * The designated service is only used for checking the status of the certificates whose issuers are diff --git a/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java b/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java index 537e9f7f..9757b090 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java +++ b/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspService.java @@ -56,6 +56,7 @@ public class AiaOcspService implements OcspService { private final List additionalIntermediateCertificates; private final URI url; private final boolean supportsNonce; + private final AiaOcspServiceConfiguration.ResponderIssuerMatchingPolicy responderIssuerMatchingPolicy; /** * Creates an AIA OCSP service for a single validation run of the given certificate. @@ -78,6 +79,7 @@ public AiaOcspService(AiaOcspServiceConfiguration configuration, this.additionalIntermediateCertificates = Objects.requireNonNull(additionalIntermediateCertificates); this.url = getOcspAiaUrlFromCertificate(Objects.requireNonNull(certificate)); this.supportsNonce = !configuration.getNonceDisabledOcspUrls().contains(this.url); + this.responderIssuerMatchingPolicy = configuration.getResponderIssuerMatchingPolicy(); } @Override @@ -97,46 +99,49 @@ public void validateResponderCertificate(X509CertificateHolder cert, Date now) t // The responder certificate's validity on the current date is checked as part of the certification // path validation. A responder may be issued by a token-supplied intermediate that is not itself // trusted, so the intermediates are offered as path candidates; the path must still terminate at a - // trusted anchor. Revocation is deliberately not checked anywhere in this path, for two distinct - // reasons: + // trusted anchor. The responder certificate itself is never revocation-checked, whatever revocation + // policy the CA has chosen for it under RFC 6960 section 4.2.2.2.1: OCSP-checking a responder against + // its own service would be circular, and no CRL-based check of the responder certificate is implemented + // (the only CRL use in the library is the default JDK checker's CRL fallback in + // CertificateValidator.validateIntermediateCertificatesNotRevoked). In practice all production Estonian, + // Belgian and Finnish AIA responder certificates carry id-pkix-ocsp-nocheck, which tells clients to skip + // the check anyway. // - // - The responder certificate itself is never revocation-checked, whatever revocation policy the - // CA has chosen for it under RFC 6960 section 4.2.2.2.1: OCSP-checking a responder against its - // own service would be circular, and no CRL-based check of the responder certificate is - // implemented (the only CRL use in the library is the default JDK checker's CRL fallback in - // CertificateValidator.validateIntermediateCertificatesNotRevoked). In practice all production - // Estonian, Belgian and Finnish AIA responder certificates carry id-pkix-ocsp-nocheck, which - // tells clients to skip the check anyway. - // - // - The intermediate CA certificates of the path are not revocation-checked either, hence the - // IntermediateRevocationCheck.DISABLED argument. The representsSameCA checks in this method only - // accept a responder that is, or is directly delegated by, the subject certificate's issuer, - // matched by subject and public key. This validation run has already vetted that issuer while - // validating the subject certificate, as either a configured trust anchor or a token-supplied - // intermediate that was revocation-checked then. When the responder's path is built through the - // same certificates, re-checking it here would only repeat those checks; when it is built - // through an equivalent cross-certificate of the issuer (accepted by the subject and public key - // match), the distinct certificates in that path are knowingly left unchecked. + // With exact issuer matching, the intermediate CA certificates are not checked again: this validation + // run has already vetted the exact issuer while validating the subject certificate, as either a + // configured trust anchor or a token-supplied intermediate that was revocation-checked then. With + // subject-and-public-key matching, however, the responder path may use a different equivalent + // cross-certificate, so every non-anchor intermediate in that path is revocation-checked. final X509Certificate responderIssuerCertificate = CertificateValidator.validateIsSignedByTrustedCA( certificate, "AIA OCSP responder", trustedCACertificateAnchors, trustedCACertificateCertStore, additionalIntermediateCertificates, - CertificateValidator.IntermediateRevocationCheck.DISABLED, + getIntermediateRevocationCheckByPolicy(), now ); // RFC 6960 section 4.2.2.2: the response must be signed by the CA that issued the subject certificate // or by a responder directly delegated by it; a locally configured responder is handled by - // DesignatedOcspService. CA identity is compared by subject and public key so that equivalent - // cross-certificates for the same CA are accepted. - if (representsSameCA(certificate, certificateIssuerCertificate)) { + // DesignatedOcspService. + if (matchesCertificateIssuer(certificate, certificateIssuerCertificate)) { // The response is signed by the issuing CA itself; the OCSP-signing extended key usage is required // only for delegated responder certificates. return; } + if (representsSameCA(certificate, certificateIssuerCertificate)) { + // The response is signed directly by the issuing CA, but with a certificate that is only equivalent + // to (same subject and public key), not identical with, the subject certificate's issuer certificate. + // This can only happen under the EXACT_CERTIFICATE policy. Report it explicitly, because otherwise + // control falls through to the delegated-responder branch below and fails with a misleading + // missing-OCSP-signing-extended-key-usage error; the SUBJECT_AND_PUBLIC_KEY policy accepts it. + throw new CertificateNotTrustedException(certificate, + new CertificateException("OCSP response is signed by a certificate equivalent to but not the " + + "same as the subject certificate issuer; the exact-certificate responder issuer matching " + + "policy requires the issuer certificate itself")); + } OcspResponseValidator.validateHasSigningExtension(certificate); - if (!representsSameCA(responderIssuerCertificate, certificateIssuerCertificate)) { + if (!matchesCertificateIssuer(responderIssuerCertificate, certificateIssuerCertificate)) { throw new CertificateNotTrustedException(certificate, new CertificateException("OCSP responder is not authorized by the subject certificate issuer")); } @@ -150,6 +155,20 @@ private static boolean representsSameCA(X509Certificate first, X509Certificate s && Arrays.equals(first.getPublicKey().getEncoded(), second.getPublicKey().getEncoded()); } + private boolean matchesCertificateIssuer(X509Certificate first, X509Certificate second) { + return switch (responderIssuerMatchingPolicy) { + case EXACT_CERTIFICATE -> first.equals(second); + case SUBJECT_AND_PUBLIC_KEY -> representsSameCA(first, second); + }; + } + + private CertificateValidator.IntermediateRevocationCheck getIntermediateRevocationCheckByPolicy() { + return switch (responderIssuerMatchingPolicy) { + case EXACT_CERTIFICATE -> CertificateValidator.IntermediateRevocationCheck.DISABLED; + case SUBJECT_AND_PUBLIC_KEY -> CertificateValidator.IntermediateRevocationCheck.ENABLED; + }; + } + private static URI getOcspAiaUrlFromCertificate(X509Certificate certificate) throws AuthTokenException { return getOcspUri(certificate).orElseThrow(() -> new UserCertificateOCSPCheckFailedException("Getting the AIA OCSP responder field from the certificate failed") diff --git a/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspServiceConfiguration.java b/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspServiceConfiguration.java index 8781e3a5..efd26874 100644 --- a/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspServiceConfiguration.java +++ b/src/main/java/eu/webeid/security/validator/ocsp/service/AiaOcspServiceConfiguration.java @@ -31,14 +31,38 @@ public class AiaOcspServiceConfiguration { + /** + * Determines how the issuer authorizing an AIA OCSP responder is matched against the subject certificate's + * issuer. + */ + public enum ResponderIssuerMatchingPolicy { + /** Requires the same encoded X.509 certificate. */ + EXACT_CERTIFICATE, + /** + * Accepts different certificates that contain the same subject and public key. Non-anchor intermediate + * certificates in the responder's certification path are checked for revocation. + */ + SUBJECT_AND_PUBLIC_KEY + } + private final Collection nonceDisabledOcspUrls; private final Set trustedCACertificateAnchors; private final CertStore trustedCACertificateCertStore; + private final ResponderIssuerMatchingPolicy responderIssuerMatchingPolicy; public AiaOcspServiceConfiguration(Collection nonceDisabledOcspUrls, Set trustedCACertificateAnchors, CertStore trustedCACertificateCertStore) { + this(nonceDisabledOcspUrls, trustedCACertificateAnchors, trustedCACertificateCertStore, + ResponderIssuerMatchingPolicy.EXACT_CERTIFICATE); + } + + public AiaOcspServiceConfiguration(Collection nonceDisabledOcspUrls, + Set trustedCACertificateAnchors, + CertStore trustedCACertificateCertStore, + ResponderIssuerMatchingPolicy responderIssuerMatchingPolicy) { this.nonceDisabledOcspUrls = Objects.requireNonNull(nonceDisabledOcspUrls); this.trustedCACertificateAnchors = Objects.requireNonNull(trustedCACertificateAnchors); this.trustedCACertificateCertStore = Objects.requireNonNull(trustedCACertificateCertStore); + this.responderIssuerMatchingPolicy = Objects.requireNonNull(responderIssuerMatchingPolicy); } public Collection getNonceDisabledOcspUrls() { @@ -53,4 +77,8 @@ public CertStore getTrustedCACertificateCertStore() { return trustedCACertificateCertStore; } + public ResponderIssuerMatchingPolicy getResponderIssuerMatchingPolicy() { + return responderIssuerMatchingPolicy; + } + } diff --git a/src/main/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersionValidatorFactory.java b/src/main/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersionValidatorFactory.java index a8eec4c4..fa9ecc82 100644 --- a/src/main/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersionValidatorFactory.java +++ b/src/main/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersionValidatorFactory.java @@ -84,7 +84,8 @@ public static AuthTokenVersionValidatorFactory create(AuthTokenValidationConfigu new AiaOcspServiceConfiguration( validationConfig.getNonceDisabledOcspUrls(), trustedCACertificateAnchors, - trustedCACertificateCertStore)); + trustedCACertificateCertStore, + validationConfig.getAiaOcspResponderIssuerMatchingPolicy())); } final AuthTokenSignatureValidator authTokenSignatureValidator = diff --git a/src/test/java/eu/webeid/security/validator/AuthTokenValidatorBuilderTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenValidatorBuilderTest.java index 8e58466f..3af1e2d5 100644 --- a/src/test/java/eu/webeid/security/validator/AuthTokenValidatorBuilderTest.java +++ b/src/test/java/eu/webeid/security/validator/AuthTokenValidatorBuilderTest.java @@ -23,11 +23,23 @@ package eu.webeid.security.validator; import eu.webeid.security.testutil.AuthTokenValidators; +import eu.webeid.security.validator.ocsp.OcspServiceProvider; +import eu.webeid.security.validator.ocsp.service.AiaOcspService; +import eu.webeid.security.validator.ocsp.service.AiaOcspServiceConfiguration.ResponderIssuerMatchingPolicy; +import eu.webeid.security.validator.ocsp.service.OcspService; +import eu.webeid.security.validator.versionvalidators.AuthTokenVersionValidatorFactory; import org.junit.jupiter.api.Test; +import java.lang.reflect.Field; import java.net.URI; import java.time.Duration; +import java.util.List; +import static eu.webeid.security.validator.ocsp.service.AiaOcspServiceConfiguration.ResponderIssuerMatchingPolicy.EXACT_CERTIFICATE; +import static eu.webeid.security.validator.ocsp.service.AiaOcspServiceConfiguration.ResponderIssuerMatchingPolicy.SUBJECT_AND_PUBLIC_KEY; +import static eu.webeid.security.testutil.Certificates.getJaakKristjanEsteid2018Cert; +import static eu.webeid.security.testutil.Certificates.getTestEsteid2018CA; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; public class AuthTokenValidatorBuilderTest { @@ -112,4 +124,74 @@ void testInvalidOcspRequestTimeout() throws Exception { .isInstanceOf(IllegalArgumentException.class) .hasMessageStartingWith("OCSP request timeout must be greater than zero"); } + + @Test + void aiaOcspResponderIssuerMatchingPolicyDefaultsToExactCertificate() { + final AuthTokenValidationConfiguration configuration = new AuthTokenValidationConfiguration(); + + assertThat(configuration.getAiaOcspResponderIssuerMatchingPolicy()).isEqualTo(EXACT_CERTIFICATE); + assertThat(configuration.copy().getAiaOcspResponderIssuerMatchingPolicy()).isEqualTo(EXACT_CERTIFICATE); + } + + @Test + void aiaOcspResponderIssuerMatchingPolicyIsCopied() { + final AuthTokenValidationConfiguration configuration = new AuthTokenValidationConfiguration(); + + configuration.setAiaOcspResponderIssuerMatchingPolicy(SUBJECT_AND_PUBLIC_KEY); + + assertThat(configuration.copy().getAiaOcspResponderIssuerMatchingPolicy()) + .isEqualTo(SUBJECT_AND_PUBLIC_KEY); + } + + @Test + void configuredAiaOcspResponderIssuerMatchingPolicyReachesAiaService() throws Exception { + final AuthTokenValidator validator = AuthTokenValidators.getDefaultAuthTokenValidatorBuilder() + .withAiaOcspResponderIssuerMatchingPolicy(SUBJECT_AND_PUBLIC_KEY) + .withOcspClient((url, request) -> null) + .build(); + + final OcspServiceProvider serviceProvider = ocspServiceProviderOf(validator); + final OcspService aiaService = serviceProvider.getService( + getJaakKristjanEsteid2018Cert(), getTestEsteid2018CA(), List.of()); + + assertThat(aiaService).isInstanceOf(AiaOcspService.class); + final ResponderIssuerMatchingPolicy matchingPolicy = getField(aiaService, "responderIssuerMatchingPolicy"); + assertThat(matchingPolicy).isEqualTo(SUBJECT_AND_PUBLIC_KEY); + } + + // The version validators all share a single OcspServiceProvider instance; locate it without depending on the + // validator list order or on which class in the validator hierarchy declares the field. + private static OcspServiceProvider ocspServiceProviderOf(AuthTokenValidator validator) throws Exception { + final AuthTokenVersionValidatorFactory factory = getField(validator, "tokenValidatorFactory"); + final List validators = getField(factory, "validators"); + for (final Object versionValidator : validators) { + final Field field = findField(versionValidator.getClass(), "ocspServiceProvider"); + if (field != null) { + field.setAccessible(true); + return (OcspServiceProvider) field.get(versionValidator); + } + } + throw new AssertionError("No version validator exposes an OcspServiceProvider field"); + } + + @SuppressWarnings("unchecked") + private static T getField(Object instance, String fieldName) throws Exception { + final Field field = findField(instance.getClass(), fieldName); + if (field == null) { + throw new NoSuchFieldException(fieldName); + } + field.setAccessible(true); + return (T) field.get(instance); + } + + private static Field findField(Class type, String fieldName) { + for (Class currentClass = type; currentClass != null; currentClass = currentClass.getSuperclass()) { + try { + return currentClass.getDeclaredField(fieldName); + } catch (NoSuchFieldException ignored) { + // Not declared here; search the superclass. + } + } + return null; + } } diff --git a/src/test/java/eu/webeid/security/validator/ocsp/service/AiaOcspServiceTest.java b/src/test/java/eu/webeid/security/validator/ocsp/service/AiaOcspServiceTest.java index 99294743..027679c8 100644 --- a/src/test/java/eu/webeid/security/validator/ocsp/service/AiaOcspServiceTest.java +++ b/src/test/java/eu/webeid/security/validator/ocsp/service/AiaOcspServiceTest.java @@ -36,6 +36,8 @@ import org.bouncycastle.asn1.x509.KeyPurposeId; import org.bouncycastle.asn1.x509.KeyUsage; import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.X509v2CRLBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CRLConverter; import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils; import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; @@ -43,20 +45,28 @@ import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import java.math.BigInteger; import java.net.URI; import java.security.KeyPair; import java.security.KeyPairGenerator; +import java.security.PrivateKey; import java.security.PublicKey; +import java.security.cert.CertStore; import java.security.cert.CertificateException; +import java.security.cert.CollectionCertStoreParameters; import java.security.cert.TrustAnchor; +import java.security.cert.X509CRL; import java.security.cert.X509Certificate; import java.util.Collections; import java.util.Date; import java.util.List; import java.util.Set; +import static eu.webeid.security.validator.ocsp.service.AiaOcspServiceConfiguration.ResponderIssuerMatchingPolicy.EXACT_CERTIFICATE; +import static eu.webeid.security.validator.ocsp.service.AiaOcspServiceConfiguration.ResponderIssuerMatchingPolicy.SUBJECT_AND_PUBLIC_KEY; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; @@ -96,6 +106,7 @@ static void setUp() throws Exception { final X509Certificate rootCertificate = generateCertificate( "Test Root CA", rootKeyPair.getPublic(), "Test Root CA", rootKeyPair, 1, true, false, null); + final X509CRL rootCrl = generateCrl(new X500Name("CN=Test Root CA"), rootKeyPair.getPrivate()); intermediateCertificate = generateCertificate( "Test Intermediate CA", intermediateKeyPair.getPublic(), "Test Root CA", rootKeyPair, 2, true, false, null); // An equivalent cross-certificate for the intermediate CA: same subject and public key as @@ -137,18 +148,47 @@ static void setUp() throws Exception { // trusted, exactly like a deployment that relies on the token-supplied intermediate to build the chain. final Set anchors = Collections.singleton(new TrustAnchor(rootCertificate, null)); aiaOcspServiceConfiguration = new AiaOcspServiceConfiguration( - Collections.emptySet(), anchors, CertificateValidator.buildCertStoreFromCertificates(Collections.emptyList())); + Collections.emptySet(), anchors, buildCrlStore(rootCrl)); } @Test - void whenResponderChainsViaTokenIntermediate_thenValidationSucceeds() throws Exception { - final AiaOcspService service = aiaServiceFor(intermediateCertificate, Collections.singletonList(intermediateCertificate)); + void whenMatchingPolicyIsNotSpecified_thenExactCertificateMatchingIsUsed() { + assertThat(aiaOcspServiceConfiguration.getResponderIssuerMatchingPolicy()).isEqualTo(EXACT_CERTIFICATE); + } + + @ParameterizedTest + @EnumSource(AiaOcspServiceConfiguration.ResponderIssuerMatchingPolicy.class) + void whenResponderChainsViaTokenIntermediate_thenValidationSucceeds( + AiaOcspServiceConfiguration.ResponderIssuerMatchingPolicy matchingPolicy) throws Exception { + final AiaOcspService service = aiaServiceFor(intermediateCertificate, + Collections.singletonList(intermediateCertificate), matchingPolicy); final X509CertificateHolder responderHolder = new X509CertificateHolder(responderCertificate.getEncoded()); assertThatCode(() -> service.validateResponderCertificate(responderHolder, NOW)) .doesNotThrowAnyException(); } + @Test + void whenIntermediateRevocationStatusIsUnknown_thenOnlySubjectAndPublicKeyPolicyFails() throws Exception { + final CertStore emptyStore = CertificateValidator.buildCertStoreFromCertificates(Collections.emptyList()); + final AiaOcspServiceConfiguration exactConfiguration = new AiaOcspServiceConfiguration( + Collections.emptySet(), aiaOcspServiceConfiguration.getTrustedCACertificateAnchors(), emptyStore, + EXACT_CERTIFICATE); + final AiaOcspServiceConfiguration subjectAndPublicKeyConfiguration = new AiaOcspServiceConfiguration( + Collections.emptySet(), aiaOcspServiceConfiguration.getTrustedCACertificateAnchors(), emptyStore, + SUBJECT_AND_PUBLIC_KEY); + final AiaOcspService exactService = new AiaOcspService(exactConfiguration, subjectCertificate, + intermediateCertificate, Collections.singletonList(intermediateCertificate)); + final AiaOcspService subjectAndPublicKeyService = new AiaOcspService(subjectAndPublicKeyConfiguration, + subjectCertificate, intermediateCertificate, Collections.singletonList(intermediateCertificate)); + final X509CertificateHolder responderHolder = new X509CertificateHolder(responderCertificate.getEncoded()); + + assertThatCode(() -> exactService.validateResponderCertificate(responderHolder, NOW)) + .doesNotThrowAnyException(); + assertThatExceptionOfType(CertificateNotTrustedException.class) + .isThrownBy(() -> subjectAndPublicKeyService.validateResponderCertificate(responderHolder, NOW)); + } + @Test void whenResponderChainsViaTokenIntermediateButIntermediateMissing_thenValidationFails() throws Exception { final AiaOcspService service = aiaServiceFor(intermediateCertificate, Collections.emptyList()); @@ -160,13 +200,47 @@ void whenResponderChainsViaTokenIntermediateButIntermediateMissing_thenValidatio } @Test - void whenResponderIssuerIsEquivalentCrossCertificate_thenValidationSucceeds() throws Exception { + void whenResponderIssuerIsEquivalentCrossCertificateWithDefaultPolicy_thenValidationFails() throws Exception { // The responder still chains to the root via the real intermediate, so its issuer in the built path is // intermediateCertificate. The subject issuer is passed as the equivalent cross-certificate (same subject and - // public key, different certificate), which representsSameCA must treat as the same CA. + // public key, different certificate), which the default exact-certificate policy must reject. final AiaOcspService service = aiaServiceFor(crossIntermediateCertificate, Collections.singletonList(intermediateCertificate)); final X509CertificateHolder responderHolder = new X509CertificateHolder(responderCertificate.getEncoded()); + assertThatExceptionOfType(CertificateNotTrustedException.class) + .isThrownBy(() -> service.validateResponderCertificate(responderHolder, NOW)) + .withCauseInstanceOf(CertificateException.class); + } + + @Test + void whenResponderIssuerIsEquivalentCrossCertificateWithSubjectAndPublicKeyPolicy_thenValidationSucceeds() throws Exception { + final AiaOcspService service = aiaServiceFor(crossIntermediateCertificate, + Collections.singletonList(intermediateCertificate), SUBJECT_AND_PUBLIC_KEY); + final X509CertificateHolder responderHolder = new X509CertificateHolder(responderCertificate.getEncoded()); + + assertThatCode(() -> service.validateResponderCertificate(responderHolder, NOW)) + .doesNotThrowAnyException(); + } + + @Test + void whenResponseIsSignedByEquivalentCrossCertificateWithDefaultPolicy_thenValidationFails() throws Exception { + final AiaOcspService service = aiaServiceFor(intermediateCertificate, Collections.emptyList()); + final X509CertificateHolder responderHolder = new X509CertificateHolder(crossIntermediateCertificate.getEncoded()); + + assertThatExceptionOfType(CertificateNotTrustedException.class) + .isThrownBy(() -> service.validateResponderCertificate(responderHolder, NOW)) + .withCauseInstanceOf(CertificateException.class) + .havingCause() + .withMessageContaining("equivalent to but not the same as the subject certificate issuer"); + } + + @Test + void whenResponseIsSignedByEquivalentCrossCertificateWithSubjectAndPublicKeyPolicy_thenValidationSucceeds() + throws Exception { + final AiaOcspService service = aiaServiceFor(intermediateCertificate, + Collections.emptyList(), SUBJECT_AND_PUBLIC_KEY); + final X509CertificateHolder responderHolder = new X509CertificateHolder(crossIntermediateCertificate.getEncoded()); + assertThatCode(() -> service.validateResponderCertificate(responderHolder, NOW)) .doesNotThrowAnyException(); } @@ -183,10 +257,12 @@ void whenResponderIsIssuedBySiblingIntermediate_thenValidationFails() throws Exc .withCauseInstanceOf(CertificateException.class); } - @Test - void whenResponderIssuerHasSameNameButDifferentKeyThanSubjectIssuer_thenValidationFails() throws Exception { + @ParameterizedTest + @EnumSource(AiaOcspServiceConfiguration.ResponderIssuerMatchingPolicy.class) + void whenResponderIssuerHasSameNameButDifferentKeyThanSubjectIssuer_thenValidationFails( + AiaOcspServiceConfiguration.ResponderIssuerMatchingPolicy matchingPolicy) throws Exception { final AiaOcspService service = aiaServiceFor(intermediateCertificate, - Collections.singletonList(impostorIntermediateCertificate)); + Collections.singletonList(impostorIntermediateCertificate), matchingPolicy); final X509CertificateHolder responderHolder = new X509CertificateHolder(impostorResponderCertificate.getEncoded()); @@ -208,12 +284,15 @@ void whenResponderIsIssuedByRootInsteadOfSubjectIssuer_thenAiaValidationFails() .withCauseInstanceOf(CertificateException.class); } - @Test - void whenResponseSignedByIssuingCaWithoutOcspSigningEku_thenValidationSucceeds() throws Exception { + @ParameterizedTest + @EnumSource(AiaOcspServiceConfiguration.ResponderIssuerMatchingPolicy.class) + void whenResponseSignedByIssuingCaWithoutOcspSigningEku_thenValidationSucceeds( + AiaOcspServiceConfiguration.ResponderIssuerMatchingPolicy matchingPolicy) throws Exception { // RFC 6960 section 4.2.2.2: a response signed by the CA that issued the subject certificate is authorized // by CA identity alone; the OCSP-signing extended key usage is required only for delegated responders. // The intermediate CA certificate does not carry the extended key usage. - final AiaOcspService service = aiaServiceFor(intermediateCertificate, Collections.singletonList(intermediateCertificate)); + final AiaOcspService service = aiaServiceFor(intermediateCertificate, + Collections.singletonList(intermediateCertificate), matchingPolicy); final X509CertificateHolder responderHolder = new X509CertificateHolder(intermediateCertificate.getEncoded()); assertThatCode(() -> service.validateResponderCertificate(responderHolder, NOW)) @@ -252,6 +331,19 @@ private static AiaOcspService aiaServiceFor(X509Certificate certificateIssuerCer certificateIssuerCertificate, additionalIntermediateCertificates); } + private static AiaOcspService aiaServiceFor(X509Certificate certificateIssuerCertificate, + List additionalIntermediateCertificates, + AiaOcspServiceConfiguration.ResponderIssuerMatchingPolicy matchingPolicy) + throws Exception { + final AiaOcspServiceConfiguration configuration = new AiaOcspServiceConfiguration( + aiaOcspServiceConfiguration.getNonceDisabledOcspUrls(), + aiaOcspServiceConfiguration.getTrustedCACertificateAnchors(), + aiaOcspServiceConfiguration.getTrustedCACertificateCertStore(), + matchingPolicy); + return new AiaOcspService(configuration, subjectCertificate, + certificateIssuerCertificate, additionalIntermediateCertificates); + } + private static KeyPair generateKeyPair() throws Exception { final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); keyPairGenerator.initialize(2048); @@ -282,4 +374,15 @@ private static X509Certificate generateCertificate(String subjectCn, PublicKey s final ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(issuerKeyPair.getPrivate()); return new JcaX509CertificateConverter().getCertificate(builder.build(signer)); } + + private static X509CRL generateCrl(X500Name issuer, PrivateKey issuerPrivateKey) throws Exception { + final X509v2CRLBuilder builder = new X509v2CRLBuilder(issuer, NOT_BEFORE); + builder.setNextUpdate(NOT_AFTER); + final ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(issuerPrivateKey); + return new JcaX509CRLConverter().getCRL(builder.build(signer)); + } + + private static CertStore buildCrlStore(X509CRL... crls) throws Exception { + return CertStore.getInstance("Collection", new CollectionCertStoreParameters(List.of(crls))); + } } From 79d06f7f6fa351c8e3ea1d93d71e63a7463a125a Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Fri, 3 Jul 2026 00:02:50 +0300 Subject: [PATCH 14/14] NFC-128 Add OCSP tests for Belgian and Finnish test cards --- .../testutil/AuthTokenValidators.java | 20 +++++++++++ ...AuthTokenCertificateBelgianIdCardTest.java | 33 ++++++++++++++++++ ...AuthTokenCertificateFinnishIdCardTest.java | 33 ++++++++++++++++++ .../ocsp_response_belgian_test_id_card.der | Bin 0 -> 2026 bytes .../ocsp_response_finnish_test_id_card.der | Bin 0 -> 2287 bytes 5 files changed, 86 insertions(+) create mode 100644 src/test/resources/ocsp_response_belgian_test_id_card.der create mode 100644 src/test/resources/ocsp_response_finnish_test_id_card.der diff --git a/src/test/java/eu/webeid/security/testutil/AuthTokenValidators.java b/src/test/java/eu/webeid/security/testutil/AuthTokenValidators.java index ec977e71..5d7d43b9 100644 --- a/src/test/java/eu/webeid/security/testutil/AuthTokenValidators.java +++ b/src/test/java/eu/webeid/security/testutil/AuthTokenValidators.java @@ -108,6 +108,26 @@ public static AuthTokenValidator getAuthTokenValidatorForFinnishIdCard() throws ); } + public static AuthTokenValidator getAuthTokenValidatorForBelgianIdCardWithOcspCheck(OcspClient ocspClient) throws CertificateException, IOException, JceException { + return getAuthTokenValidatorBuilder( + "https://47f0-46-131-86-189.ngrok-free.app", + CertificateLoader.loadCertificatesFromResources("eID TEST EC Citizen CA.cer")) + // The recorded OCSP response used in tests was created without a nonce. + .withNonceDisabledOcspUrls(URI.create("http://eiddevcards.zetescards.be:8888")) + .withOcspClient(ocspClient) + .build(); + } + + public static AuthTokenValidator getAuthTokenValidatorForFinnishIdCardWithOcspCheck(OcspClient ocspClient) throws CertificateException, IOException, JceException { + return getAuthTokenValidatorBuilder( + "https://47f0-46-131-86-189.ngrok-free.app", + CertificateLoader.loadCertificatesFromResources("DVV TEST Certificates - G5E.crt", "VRK TEST CA for Test Purposes - G4.crt")) + // The recorded OCSP response used in tests was created without a nonce. + .withNonceDisabledOcspUrls(URI.create("http://ocsptest.fineid.fi/dvvtp5ec")) + .withOcspClient(ocspClient) + .build(); + } + public static AuthTokenValidatorBuilder getDefaultAuthTokenValidatorBuilder() throws CertificateException, IOException { return getAuthTokenValidatorBuilder(TOKEN_ORIGIN_URL, getCACertificates()); } diff --git a/src/test/java/eu/webeid/security/validator/AuthTokenCertificateBelgianIdCardTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenCertificateBelgianIdCardTest.java index 2a5b7770..962ad02c 100644 --- a/src/test/java/eu/webeid/security/validator/AuthTokenCertificateBelgianIdCardTest.java +++ b/src/test/java/eu/webeid/security/validator/AuthTokenCertificateBelgianIdCardTest.java @@ -27,13 +27,18 @@ import eu.webeid.security.testutil.AbstractTestWithValidator; import eu.webeid.security.testutil.AuthTokenValidators; import eu.webeid.security.util.DateAndTime; +import eu.webeid.security.validator.ocsp.OcspClient; +import org.bouncycastle.cert.ocsp.OCSPResp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; import java.io.IOException; +import java.io.InputStream; import java.security.cert.CertificateException; +import java.util.Objects; import static eu.webeid.security.testutil.DateMocker.mockDate; import static org.assertj.core.api.Assertions.assertThatCode; @@ -95,4 +100,32 @@ void whenIdCardWithRSASignatureCertificateIsValidated_thenValidationSucceeds() t .doesNotThrowAnyException(); } + @Disabled(""" + Disabled because the certificate ID equality check in SubjectCertificateNotRevokedValidator rejects the OCSP + response: CertificateID.equals() compares the complete ASN.1 structure, which treats absent and explicit NULL SHA-1 + AlgorithmIdentifier parameters as different. CertID.equals() treats those forms as equivalent while still comparing + the algorithm identifier, issuer hashes and certificate serial number.""") + @Test + void whenIdCardIsValidatedWithAiaOcspCheck_thenDelegatedResponderIsAuthorizedAndValidationSucceeds() throws AuthTokenException, CertificateException, IOException { + // The OCSP response was recorded from the card's AIA OCSP responder at http://eiddevcards.zetescards.be:8888 + // on 2026-07-02. Its responder certificate is issued by eID TEST EC Citizen CA, the issuer of the + // authentication certificate, so the RFC 6960 delegated-responder authorization check in AiaOcspService + // must accept it. The clock is set to the recording time as the response thisUpdate age is limited. + mockDate("2026-07-02T08:39:30Z", mockedClock); + final OcspClient recordedResponseClient = (url, request) -> + new OCSPResp(getOcspResponseBytesFromResources("ocsp_response_belgian_test_id_card.der")); + final AuthTokenValidator validator = AuthTokenValidators.getAuthTokenValidatorForBelgianIdCardWithOcspCheck(recordedResponseClient); + final WebEidAuthToken token = validator.parse(BELGIAN_TEST_ID_CARD_AUTH_TOKEN_ECC); + + assertThatCode(() -> validator + .validate(token, "iMeEwP2cgUINY2XoO/lqEpOUn7z/ysHRqGXkGKC4VXE=")) + .doesNotThrowAnyException(); + } + + private static byte[] getOcspResponseBytesFromResources(String resource) throws IOException { + try (final InputStream resourceAsStream = ClassLoader.getSystemResourceAsStream(resource)) { + return Objects.requireNonNull(resourceAsStream).readAllBytes(); + } + } + } diff --git a/src/test/java/eu/webeid/security/validator/AuthTokenCertificateFinnishIdCardTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenCertificateFinnishIdCardTest.java index 786aaade..bf283bc1 100644 --- a/src/test/java/eu/webeid/security/validator/AuthTokenCertificateFinnishIdCardTest.java +++ b/src/test/java/eu/webeid/security/validator/AuthTokenCertificateFinnishIdCardTest.java @@ -27,13 +27,18 @@ import eu.webeid.security.testutil.AbstractTestWithValidator; import eu.webeid.security.testutil.AuthTokenValidators; import eu.webeid.security.util.DateAndTime; +import eu.webeid.security.validator.ocsp.OcspClient; +import org.bouncycastle.cert.ocsp.OCSPResp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; import java.io.IOException; +import java.io.InputStream; import java.security.cert.CertificateException; +import java.util.Objects; import static eu.webeid.security.testutil.DateMocker.mockDate; import static org.assertj.core.api.Assertions.assertThatCode; @@ -96,4 +101,32 @@ void whenIdCardSignatureCertificateWithG4RootCertificateIsValidated_thenValidati .doesNotThrowAnyException(); } + @Disabled(""" + Disabled because the certificate ID equality check in SubjectCertificateNotRevokedValidator rejects the OCSP + response: CertificateID.equals() compares the complete ASN.1 structure, which treats absent and explicit NULL SHA-1 + AlgorithmIdentifier parameters as different. CertID.equals() treats those forms as equivalent while still comparing + the algorithm identifier, issuer hashes and certificate serial number.""") + @Test + void whenIdCardIsValidatedWithAiaOcspCheck_thenDelegatedResponderIsAuthorizedAndValidationSucceeds() throws AuthTokenException, CertificateException, IOException { + // The OCSP response was recorded from the card's AIA OCSP responder at http://ocsptest.fineid.fi/dvvtp5ec + // on 2026-07-02. Its responder certificate is issued by DVV TEST Certificates - G5E, the issuer of the + // authentication certificate, so the RFC 6960 delegated-responder authorization check in AiaOcspService + // must accept it. The clock is set to the recording time as the response thisUpdate age is limited. + mockDate("2026-07-02T08:39:30Z", mockedClock); + final OcspClient recordedResponseClient = (url, request) -> + new OCSPResp(getOcspResponseBytesFromResources("ocsp_response_finnish_test_id_card.der")); + final AuthTokenValidator validator = AuthTokenValidators.getAuthTokenValidatorForFinnishIdCardWithOcspCheck(recordedResponseClient); + final WebEidAuthToken token = validator.parse(FINNISH_TEST_ID_CARD_BACKMAN_JUHANI_AUTH_TOKEN); + + assertThatCode(() -> validator + .validate(token, "x9qZDRO/ao2zprt3Z0bkW4CvvE/gALFtUIf3tcC0XxY=")) + .doesNotThrowAnyException(); + } + + private static byte[] getOcspResponseBytesFromResources(String resource) throws IOException { + try (final InputStream resourceAsStream = ClassLoader.getSystemResourceAsStream(resource)) { + return Objects.requireNonNull(resourceAsStream).readAllBytes(); + } + } + } diff --git a/src/test/resources/ocsp_response_belgian_test_id_card.der b/src/test/resources/ocsp_response_belgian_test_id_card.der new file mode 100644 index 0000000000000000000000000000000000000000..3cf707b54a5e2786ccd3786223dad31167d79a3b GIT binary patch literal 2026 zcmbW22~ZPP7{_-vn*@*}NDN04h(St~>m?vWP>>*sCy0qr0gJ$LROAQ&QKU!|v|L3L z2Qwa#Krl6;C^%x(f=CfWqM~?!Xa$i0t;GWigV2o(6-Q^B_RZ|P_rBTv-h2E1{l5n! zML0!$c+q9Kdk~E`S5Lj&s<~>p%jC2aEAEL>qkq zVn&2eDx;!!FtNFBiM~iKe{xnLe5C^k8@j5h6%n81gO%3|tQ=bYXH#Na|s) z_>;qpX-4#KkCm->RLYc8_@Lpdl~=lyuO9~UQlhsp)bbkc`?g588JG$?cC`Fp;gjwo z(xQALtp{mubjnOv`(q2$iHdVh+lKCu%m+Pr%AC}2`EKeZzrRjHW~P=m;=5Z;1jEIi zSB8r75YMBm!725D=~dcqd>-H-W&vKvrVSKA%J>TTW=E$jrb_qQcUfihPtuX(N z!n(gVI)|*eII14LV-s9kzquq=-s}9Q-2QTWG`mPh+H>jJ<`sh1(z-o2E+f&x;Av^6 zpb{(M(X&Dw=Dv$7?9|h@2|3F7Rr`Atji-!aeBbCO-3;_(65;@m5MKZZashvMG6Di4 zh&I%4C{p@0&KAE|F8rkFl(}s@CPLgAUv*vXe5;g-goEe)|QRfVDA+-)j7J?R`0ZuDpo2u+++m-lwZ`RmE!x{Z>a5*G;#U%u`}(4TjtWuZLcQTD%Y!|?GJi> z&Pi-xRsRyO{`H^-b;`=B$(sQ-pW;ggX5;u8O~%(hHdg!@ zyPR=$xf*sqz>Ej}vm25Cg+%_~B?QLnUOEb~2q6$ULn~L-Z4tU;#HCm;_i|_AwmlJN zfMU-nueNw&PS@(+J7+z0IOe&sm6dhX=TMN^ttW(L2XJo`PNLyUMfDRJrq_ssO>`7BW!%sC z(Y5eKABJEKp_oWKzB4P72B;I?5|DpKDBx)pvOwLy9N;3GEpyCde?6@z+ChXX z8M?vS%RW>T4bsym65`icB7Xh2Ps{<5$@zLde4CRKia#C`z?ok?rYje3V5;UEMjAV5 z0TSLJ@E6R&hPqh0=@~bRuMkwX%QKtn&K6imoqt`kkl#=VT{6F(^TPY?z{N!V@TSGZ zH3AfLB_klXVEK#FZFMz*?4huFlO~6;Gtl1QB=+fA(d^PS$#lgVgZ>ajxqs!${DW%x H!6Df{B0s*K literal 0 HcmV?d00001 diff --git a/src/test/resources/ocsp_response_finnish_test_id_card.der b/src/test/resources/ocsp_response_finnish_test_id_card.der new file mode 100644 index 0000000000000000000000000000000000000000..44dfa1b7117ea0f8d43dee0b26ae0079b2e6668b GIT binary patch literal 2287 zcmd5;c~p~E7SET>u%;m(JEUciotKb=#WH{(iijYx!%#m-z(61n(g0eOgdHp>s02o2 z8-*4nSP?9OQ&~j;0dWC5DnjWf!hjZ$iPU~U>p17k{5j{$AMd<(fA^ku?|tX~?tMT^ zm{WvMRbs*n5EGu@6)f=(4z~e75LQeWL7+h(hBT^0SwJMFDggyNCJ>7!qdmQ-=3tXt zP*kRPaAG*tlvtP|gxSdgo`Ay^@Pr&bjGQR}TlWM|wZKNXsgg1=05RYQVSYS2LBM7U zsA}Ldxdum805m#-Zf9>zqv`-HIfhXt`MU4%qxiFv6L|?NHlIRu(<0IUjS1`l4LI02 zQkh{O2C#uM5*E%<6@%8np|I)?WJ!bsJ@;kTq1rlK*+Thk@#|2my4so6r)GVxPQ(^; z`1JnT$t?BXLO^5jdJ6jKsDHcFfyxN0B7KDH)*Ugzs!4zdRQ~lm3Rsj}w8-9`y`lm^ zN;niojD}E!-Xw*u;Lo4-JE+S@+MoZ0c~YdUdS`d)bJjJjCExk&%Y9QK*~8rN>Bqln zNz$u#|ZXFz*m*k^AE52x?RU@p8y8K#Gs@AfQzErwc_?wG4^>BBS z_gWO6f8N~`T-q`k!Fti7)aq@I6_y;nMMk-mK_RP4=I-Z@kQ^O+xD!6vC0WtmYZ+*m zL0=z?aXy=o=af~M|HkTD!!IL^>MMV|s6V9VJ>N5#lVyMMs-?iinz`!o^D#+Wb8qaX zx-zpMG&ujB7FJ(_g9B40POKlZon^RbZ! ztpAa0Nc9%gR9#q0OfV7BS|XxJ#^v3QVl{3zti;_K9ZSuIG)jZV)cb|$W6c6D+3`Z+ z`KF6X-=g~X7L_mKZP71^X5L)07(Q{Ge%7_KC2fhlKhJn7_uR>TRB{j0kr{df3TX^) zMEgH|T=?YiJvfbvXx(SB+S+UC--~n>YH;i>XC#^8(hywUXt3R))~ z#huL;aH2U;uz;ORv8HTeYy}3IDl|JlqXDWN01QSLB03-w3oJb};@nY2S*v0)ZIstc2X>?Vk#F~$n z+;8R3*4zeqHvxPjtngYF@XAS6A7Hdx&aG z?8J|r(CVvZYhH))6u=gbw;b`B-xV1c zuGIvWhLl22R*qG^A*QmpcbwJQcQ38pXWDd1nt$0>D0|W)Q?t02q~{*8+~aut9l8Dj zx!6uwYEk;gYt%iT)j7Q|Sd_8Xduy(=!Q$Bsl)`{zc|);*$~^nbsU^#88{Zm!lg2Of zWxY9Rl+mZ2me&4)uh{b1tsrp0jNEf&CB;ogR! z^2tNGg}VaFJl@9VO!x`NgHvs{&)t>fk&90Gy4mlPCZ4&nCOJ27x4mZIhe(j}&|H#v zG^ggzCTI+Vf@;N38#0_)fFV-$`bJn}%B26tJ`eQe2CCSNSoKNauJH!7TRrs@3)g(Z zfnM`qV^yFkR}&%V9Ttm0j{~ia>k!M4~ zk^xCXjN|ZQVK|=W$`T5ZNC8oV1|N1+e9hGx9570y?1m zAp>-_=?Bk=d|qnWr*8n;B0dC(40e=F6kmXN`uMfLVbM6$r|l549|hQG;BC0$$G>r3 z=52P6;292$`W|2hzh;Vm;HTMQrOg;=n$?tEcwxh!fool{uk&=iE}Dw^#pY%)eX66m uD}B)IhI^3-)@9GQ_Rc>=XY6uQ%R1Mawj;6r@4ov|yJ+ESi!+CryMG7Ir&xOc literal 0 HcmV?d00001