From 7eef64f45c96953338a91851fc43b7c62cd76f87 Mon Sep 17 00:00:00 2001 From: Sven Mitt Date: Wed, 14 Oct 2026 19:43:24 +0300 Subject: [PATCH 1/4] feat: add whitelisting to subject certificate policies and collect known Estonian production policies WE2-1308 Signed-off-by: Sven Mitt --- README.md | 18 ++++++++- .../SubjectCertificatePolicies.java | 29 ++++++++++++++ ...rCertificateDisallowedPolicyException.java | 2 +- .../AuthTokenValidationConfiguration.java | 15 ++++++++ .../validator/AuthTokenValidatorBuilder.java | 17 +++++++++ .../validator/AuthTokenValidatorImpl.java | 5 ++- .../SubjectCertificatePolicyValidator.java | 38 ++++++++++++++++--- .../SubjectCertificatePoliciesTest.java | 22 +++++++++++ .../validator/AuthTokenCertificateTest.java | 29 ++++++++++++++ .../AuthTokenValidationConfigurationTest.java | 26 +++++++++++++ ...SubjectCertificatePolicyValidatorTest.java | 32 ++++++++++++++++ 11 files changed, 225 insertions(+), 8 deletions(-) create mode 100644 src/test/java/eu/webeid/security/certificate/SubjectCertificatePoliciesTest.java diff --git a/README.md b/README.md index f4c5105a..be6be1b1 100644 --- a/README.md +++ b/README.md @@ -320,7 +320,23 @@ toTitleCase(CertificateData.getSubjectSurname(userCertificate).orElseThrow()); / The default validator uses the platform OCSP implementation to check certificate revocation. A revoked certificate or an unsuccessful revocation check causes authentication to fail. -Use `withDisallowedCertificatePolicies(ASN1ObjectIdentifier... policies)` to add disallowed certificate policies. Estonian Mobile-ID policies are disallowed by default because smart-card authentication must not accept Mobile-ID certificates. +Use `withAllowedCertificatePolicies(ASN1ObjectIdentifier... policies)` to require one of the configured certificate policies. A non-empty allow-list takes precedence over the deny-list. The deprecated `withDisallowedCertificatePolicies(ASN1ObjectIdentifier... policies)` method remains as a compatibility fallback when no allowed policies are configured. Estonian Mobile-ID policies are disallowed by default because smart-card authentication must not accept Mobile-ID certificates. + +For example, restrict authentication to production Estonian ID cards, digital ID cards, residence permit cards and diplomatic identity cards: + +```java +import static eu.webeid.security.certificate.SubjectCertificatePolicies.ESTEID_PRODUCTION_DOCUMENT_POLICIES; + +... + AuthTokenValidator tokenValidator = new AuthTokenValidatorBuilder() + .withSiteOrigin(URI.create("https://example.org")) + .withTrustedCertificateAuthorities(trustedIntermediateCACertificates()) + .withAllowedCertificatePolicies(ESTEID_PRODUCTION_DOCUMENT_POLICIES) + .build(); +... +``` + +See the [certificate policy allow-list guide](readme-policy.md) for an Estonian identity-document production whitelist and its rationale. For more advanced revocation requirements, supply a `CertificateRevocationChecker` with `withCertificateRevocationChecker(...)`. The [OCSP configuration guide](src/main/java/eu/webeid/ocsp/README.md) covers custom implementations, the bundled OCSP checker, custom PKIX checkers, responder selection, HTTP settings, and nonce policies. diff --git a/src/main/java/eu/webeid/security/certificate/SubjectCertificatePolicies.java b/src/main/java/eu/webeid/security/certificate/SubjectCertificatePolicies.java index 11ef5442..3eb94026 100644 --- a/src/main/java/eu/webeid/security/certificate/SubjectCertificatePolicies.java +++ b/src/main/java/eu/webeid/security/certificate/SubjectCertificatePolicies.java @@ -5,6 +5,8 @@ import org.bouncycastle.asn1.ASN1ObjectIdentifier; +import java.util.Set; + public final class SubjectCertificatePolicies { private static final String ESTEID_SK_2015_MOBILE_ID_POLICY_PREFIX = "1.3.6.1.4.1.10015.1.3"; @@ -18,6 +20,33 @@ public final class SubjectCertificatePolicies { public static final ASN1ObjectIdentifier ESTEID_SK_2015_MOBILE_ID_POLICY_V3 = new ASN1ObjectIdentifier(ESTEID_SK_2015_MOBILE_ID_POLICY_PREFIX + ".3"); + /** + * Production document policies for Estonian ID cards, digital ID cards, residence permit cards and diplomatic + * identity cards issued under the ESTEID2018 and eID certificate-policy generations. + * + * @see + * Estonian identity-document certificate policies + */ + public static final Set ESTEID_PRODUCTION_DOCUMENT_POLICIES = Set.of( + // ESTEID2018 / IDEMIA generation + new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.1.1.1"), + new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.1.1.2"), + new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.1.1.3"), + new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.1.1.4"), + new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.1.1.5"), + new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.1.1.6"), + new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.1.1.7"), + new ASN1ObjectIdentifier("1.3.6.1.4.1.51455.1.1.1"), + // eID / Thales generation + new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.2.1.1"), + new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.2.1.2"), + new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.2.1.3"), + new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.2.1.4"), + new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.2.1.5"), + new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.2.1.6"), + new ASN1ObjectIdentifier("1.3.6.1.4.1.51455.2.1.1") + ); + private SubjectCertificatePolicies() { throw new IllegalStateException("Constants class"); } diff --git a/src/main/java/eu/webeid/security/exceptions/UserCertificateDisallowedPolicyException.java b/src/main/java/eu/webeid/security/exceptions/UserCertificateDisallowedPolicyException.java index 1a41cedd..ef68091a 100644 --- a/src/main/java/eu/webeid/security/exceptions/UserCertificateDisallowedPolicyException.java +++ b/src/main/java/eu/webeid/security/exceptions/UserCertificateDisallowedPolicyException.java @@ -4,7 +4,7 @@ package eu.webeid.security.exceptions; /** - * Thrown when any of the configured disallowed policies is present in the user certificate. + * Thrown when the user certificate does not satisfy the configured allowed or disallowed policy restrictions. */ public class UserCertificateDisallowedPolicyException extends AuthTokenException { public UserCertificateDisallowedPolicyException() { diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java index 4dbfcb54..6344ac4d 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java @@ -24,7 +24,9 @@ public final class AuthTokenValidationConfiguration { private URI siteOrigin; private Collection trustedCACertificates = new HashSet<>(); + private Collection allowedSubjectCertificatePolicies = new HashSet<>(); // Don't allow Estonian Mobile-ID policy by default. + @Deprecated(since = "4.0.0") private Collection disallowedSubjectCertificatePolicies = new HashSet<>(Set.of( SubjectCertificatePolicies.ESTEID_SK_2015_MOBILE_ID_POLICY_V1, SubjectCertificatePolicies.ESTEID_SK_2015_MOBILE_ID_POLICY_V2, @@ -43,6 +45,7 @@ public final class AuthTokenValidationConfiguration { private AuthTokenValidationConfiguration(AuthTokenValidationConfiguration other) { this.siteOrigin = other.siteOrigin; this.trustedCACertificates = Set.copyOf(other.trustedCACertificates); + this.allowedSubjectCertificatePolicies = Set.copyOf(other.allowedSubjectCertificatePolicies); this.disallowedSubjectCertificatePolicies = Set.copyOf(other.disallowedSubjectCertificatePolicies); this.isUserCertificateRevocationCheckEnabled = other.isUserCertificateRevocationCheckEnabled; this.platformOcspNonceEnabled = other.platformOcspNonceEnabled; @@ -63,6 +66,18 @@ Collection getTrustedCACertificates() { return trustedCACertificates; } + public Collection getAllowedSubjectCertificatePolicies() { + return allowedSubjectCertificatePolicies; + } + + void setAllowedSubjectCertificatePolicies(Collection allowedSubjectCertificatePolicies) { + this.allowedSubjectCertificatePolicies = new HashSet<>(allowedSubjectCertificatePolicies); + } + + /** + * @deprecated Configure allowed certificate policies instead. + */ + @Deprecated(since = "4.0.0") public Collection getDisallowedSubjectCertificatePolicies() { return disallowedSubjectCertificatePolicies; } diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java index d072979f..6aa6ae96 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java @@ -12,6 +12,7 @@ import java.net.URI; import java.security.cert.PKIXRevocationChecker; import java.security.cert.X509Certificate; +import java.util.Arrays; import java.util.Collections; /** @@ -60,6 +61,20 @@ public AuthTokenValidatorBuilder withTrustedCertificateAuthorities(X509Certifica return this; } + /** + * Sets the list of allowed user certificate policies. + * When this list is not empty, the user certificate must contain at least one allowed policy and the configured + * disallowed policies are ignored. When this list is empty, validation falls back to the disallowed policy list. + * + * @param policies allowed user certificate policies + * @return the builder instance for method chaining + */ + public AuthTokenValidatorBuilder withAllowedCertificatePolicies(ASN1ObjectIdentifier... policies) { + configuration.setAllowedSubjectCertificatePolicies(Arrays.asList(policies)); + LOG.debug("Allowed subject certificate policies set to {}", configuration.getAllowedSubjectCertificatePolicies()); + return this; + } + /** * 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 @@ -67,7 +82,9 @@ public AuthTokenValidatorBuilder withTrustedCertificateAuthorities(X509Certifica * * @param policies disallowed user certificate policies * @return the builder instance for method chaining + * @deprecated Use {@link #withAllowedCertificatePolicies(ASN1ObjectIdentifier...)} instead. */ + @Deprecated(since = "4.0.0") public AuthTokenValidatorBuilder withDisallowedCertificatePolicies(ASN1ObjectIdentifier... policies) { Collections.addAll(configuration.getDisallowedSubjectCertificatePolicies(), policies); LOG.debug("Disallowed subject certificate policies set to {}", configuration.getDisallowedSubjectCertificatePolicies()); diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java index afae8093..a11245db 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorImpl.java @@ -54,7 +54,10 @@ final class AuthTokenValidatorImpl implements AuthTokenValidator { trustedCACertificateAnchors = CertificateValidator.buildTrustAnchorsFromCertificates(configuration.getTrustedCACertificates()); trustedCACertificateCertStore = CertificateValidator.buildCertStoreFromCertificates(configuration.getTrustedCACertificates()); - subjectCertificatePolicyValidator = new SubjectCertificatePolicyValidator(configuration.getDisallowedSubjectCertificatePolicies()); + subjectCertificatePolicyValidator = new SubjectCertificatePolicyValidator( + this.configuration.getAllowedSubjectCertificatePolicies(), + this.configuration.getDisallowedSubjectCertificatePolicies() + ); authTokenSignatureValidator = new AuthTokenSignatureValidator(configuration.getSiteOrigin()); } diff --git a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePolicyValidator.java b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePolicyValidator.java index f05109b2..3a002ede 100644 --- a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePolicyValidator.java +++ b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePolicyValidator.java @@ -17,28 +17,47 @@ import java.security.cert.X509Certificate; import java.util.Arrays; import java.util.Collection; +import java.util.Set; public final class SubjectCertificatePolicyValidator { private static final Logger LOG = LoggerFactory.getLogger(SubjectCertificatePolicyValidator.class); + private final Collection allowedSubjectCertificatePolicies; private final Collection disallowedSubjectCertificatePolicies; + /** + * @deprecated Configure both allowed and fallback disallowed policies with + * {@link #SubjectCertificatePolicyValidator(Collection, Collection)}. + */ + @Deprecated(since = "4.0.0") public SubjectCertificatePolicyValidator(Collection disallowedSubjectCertificatePolicies) { - this.disallowedSubjectCertificatePolicies = disallowedSubjectCertificatePolicies; + this(Set.of(), disallowedSubjectCertificatePolicies); + } + + public SubjectCertificatePolicyValidator( + Collection allowedSubjectCertificatePolicies, + Collection disallowedSubjectCertificatePolicies) { + this.allowedSubjectCertificatePolicies = Set.copyOf(allowedSubjectCertificatePolicies); + this.disallowedSubjectCertificatePolicies = Set.copyOf(disallowedSubjectCertificatePolicies); } /** - * Rejects any configured disallowed policy present in the certificate policies extension. - * A certificate without the certificate policies extension does not contain disallowed policies and passes validation. + * Requires a configured allowed policy when the allow-list is not empty. Otherwise, rejects any configured + * disallowed policy present in the certificate policies extension. + * A certificate without the certificate policies extension passes only when the allow-list is empty. * * @param subjectCertificate user certificate to be validated - * @throws UserCertificateDisallowedPolicyException when a disallowed policy is present. + * @throws UserCertificateDisallowedPolicyException when no allowed policy is present or a disallowed policy is present. * @throws UserCertificateParseException when the certificate policies extension is invalid. */ public void validateCertificatePolicies(X509Certificate subjectCertificate) throws AuthTokenException { final byte[] extensionValue = subjectCertificate.getExtensionValue(Extension.certificatePolicies.getId()); if (extensionValue == null) { + if (!allowedSubjectCertificatePolicies.isEmpty()) { + LOG.debug("User certificate does not contain the certificate policies extension, hence it does not contain an allowed policy."); + throw new UserCertificateDisallowedPolicyException(); + } LOG.debug("User certificate does not contain the certificate policies extension, hence it does not contain disallowed policies."); return; } @@ -49,9 +68,18 @@ public void validateCertificatePolicies(X509Certificate subjectCertificate) thro if (policies == null) { throw new IllegalArgumentException("Certificate policies extension is empty"); } + if (!allowedSubjectCertificatePolicies.isEmpty()) { + if (Arrays.stream(policies.getPolicyInformation()) + .noneMatch(policyInformation -> + allowedSubjectCertificatePolicies.contains(policyInformation.getPolicyIdentifier()))) { + throw new UserCertificateDisallowedPolicyException(); + } + LOG.debug("User certificate contains an allowed policy."); + return; + } if (Arrays.stream(policies.getPolicyInformation()) .anyMatch(policyInformation -> - disallowedSubjectCertificatePolicies.contains(policyInformation.getPolicyIdentifier()))) { + disallowedSubjectCertificatePolicies.contains(policyInformation.getPolicyIdentifier()))) { throw new UserCertificateDisallowedPolicyException(); } } catch (IOException | IllegalArgumentException e) { diff --git a/src/test/java/eu/webeid/security/certificate/SubjectCertificatePoliciesTest.java b/src/test/java/eu/webeid/security/certificate/SubjectCertificatePoliciesTest.java new file mode 100644 index 00000000..2b9e4542 --- /dev/null +++ b/src/test/java/eu/webeid/security/certificate/SubjectCertificatePoliciesTest.java @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.security.certificate; + +import org.bouncycastle.asn1.ASN1ObjectIdentifier; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class SubjectCertificatePoliciesTest { + + @Test + void esteidIdCardPoliciesContainOnlyProductionIdCardPolicies() { + assertThat(SubjectCertificatePolicies.ESTEID_ID_CARD_POLICIES).containsExactlyInAnyOrder( + new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.1.1.1"), + new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.1.1.2"), + new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.2.1.1"), + new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.2.1.2") + ); + } +} diff --git a/src/test/java/eu/webeid/security/validator/AuthTokenCertificateTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenCertificateTest.java index c5043a2d..7ea65123 100644 --- a/src/test/java/eu/webeid/security/validator/AuthTokenCertificateTest.java +++ b/src/test/java/eu/webeid/security/validator/AuthTokenCertificateTest.java @@ -18,6 +18,7 @@ import eu.webeid.security.testutil.AbstractTestWithValidator; import eu.webeid.security.testutil.AuthTokenValidators; import eu.webeid.security.util.DateAndTime; +import org.bouncycastle.asn1.ASN1ObjectIdentifier; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; @@ -35,6 +36,10 @@ class AuthTokenCertificateTest extends AbstractTestWithValidator { + private static final ASN1ObjectIdentifier ESTEID2018_TEST_POLICY = + new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.1.2.1"); + private static final ASN1ObjectIdentifier UNRELATED_POLICY = new ASN1ObjectIdentifier("1.2.3"); + private static final String AUTH_TOKEN = "{\"algorithm\":\"ES384\"," + "\"unverifiedCertificate\":\"X5C\"," + "\"appVersion\":\"https://web-eid.eu/web-eid-app/releases/2.0.0+0\"," + @@ -178,6 +183,30 @@ void whenCertificatePolicyIsDisallowed_thenValidationFails() throws Exception { .isInstanceOf(UserCertificateDisallowedPolicyException.class); } + @Test + void whenCertificatePolicyIsAllowed_thenValidationSucceeds() throws Exception { + final AuthTokenValidator validatorWithAllowedESTEIDPolicy = AuthTokenValidators + .getDefaultAuthTokenValidatorBuilder() + .withAllowedCertificatePolicies(ESTEID2018_TEST_POLICY) + .withoutUserCertificateRevocationCheck() + .build(); + + assertThatCode(() -> validatorWithAllowedESTEIDPolicy.validate(validAuthToken, VALID_CHALLENGE_NONCE)) + .doesNotThrowAnyException(); + } + + @Test + void whenCertificatePolicyIsNotAllowed_thenValidationFails() throws Exception { + final AuthTokenValidator validatorWithUnrelatedAllowedPolicy = AuthTokenValidators + .getDefaultAuthTokenValidatorBuilder() + .withAllowedCertificatePolicies(UNRELATED_POLICY) + .withoutUserCertificateRevocationCheck() + .build(); + + assertThatThrownBy(() -> validatorWithUnrelatedAllowedPolicy.validate(validAuthToken, VALID_CHALLENGE_NONCE)) + .isInstanceOf(UserCertificateDisallowedPolicyException.class); + } + @Test void whenUsingOldMobileIdCertificate_thenValidationFails() throws AuthTokenException { mockDate("2021-03-01", mockedClock); diff --git a/src/test/java/eu/webeid/security/validator/AuthTokenValidationConfigurationTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenValidationConfigurationTest.java index f70eae22..e6d111b0 100644 --- a/src/test/java/eu/webeid/security/validator/AuthTokenValidationConfigurationTest.java +++ b/src/test/java/eu/webeid/security/validator/AuthTokenValidationConfigurationTest.java @@ -25,10 +25,12 @@ import eu.webeid.security.testutil.Certificates; import eu.webeid.security.validator.revocationcheck.CertificateRevocationChecker; import eu.webeid.security.validator.revocationcheck.RevocationMode; +import org.bouncycastle.asn1.ASN1ObjectIdentifier; import org.junit.jupiter.api.Test; import java.net.URI; import java.security.cert.PKIXRevocationChecker; +import java.util.ArrayList; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -36,6 +38,30 @@ class AuthTokenValidationConfigurationTest { + private static final ASN1ObjectIdentifier ALLOWED_POLICY = new ASN1ObjectIdentifier("1.2.3"); + + @Test + void copyRetainsAllowedPoliciesIndependently() { + final AuthTokenValidationConfiguration configuration = new AuthTokenValidationConfiguration(); + configuration.setAllowedSubjectCertificatePolicies(List.of(ALLOWED_POLICY)); + + final AuthTokenValidationConfiguration copy = configuration.copy(); + configuration.getAllowedSubjectCertificatePolicies().clear(); + + assertThat(copy.getAllowedSubjectCertificatePolicies()).containsExactly(ALLOWED_POLICY); + } + + @Test + void setAllowedPoliciesCopiesInputCollection() { + final List policies = new ArrayList<>(List.of(ALLOWED_POLICY)); + final AuthTokenValidationConfiguration configuration = new AuthTokenValidationConfiguration(); + + configuration.setAllowedSubjectCertificatePolicies(policies); + policies.clear(); + + assertThat(configuration.getAllowedSubjectCertificatePolicies()).containsExactly(ALLOWED_POLICY); + } + @Test void whenNoCustomCheckerProvided_thenRevocationModeIsPlatformOcsp() throws Exception { final AuthTokenValidationConfiguration configuration = getValidConfiguration(); diff --git a/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePolicyValidatorTest.java b/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePolicyValidatorTest.java index 4dd7fe40..e4557e63 100644 --- a/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePolicyValidatorTest.java +++ b/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePolicyValidatorTest.java @@ -46,6 +46,30 @@ void whenCertificateDoesNotContainDisallowedPolicies_thenValidationSucceeds() th .doesNotThrowAnyException(); } + @Test + void whenCertificateContainsAllowedPolicy_thenValidationSucceeds() throws Exception { + final SubjectCertificatePolicyValidator validator = + new SubjectCertificatePolicyValidator(List.of(ESTEID2018_POLICY), Set.of()); + assertThatCode(() -> validator.validateCertificatePolicies(getJaakKristjanEsteid2018Cert())) + .doesNotThrowAnyException(); + } + + @Test + void whenCertificateDoesNotContainAllowedPolicy_thenValidationFails() throws Exception { + final SubjectCertificatePolicyValidator validator = + new SubjectCertificatePolicyValidator(List.of(UNRELATED_POLICY), Set.of()); + assertThatExceptionOfType(UserCertificateDisallowedPolicyException.class) + .isThrownBy(() -> validator.validateCertificatePolicies(getJaakKristjanEsteid2018Cert())); + } + + @Test + void whenPolicyIsBothAllowedAndDisallowed_thenAllowListTakesPrecedence() throws Exception { + final SubjectCertificatePolicyValidator validator = + new SubjectCertificatePolicyValidator(List.of(ESTEID2018_POLICY), List.of(ESTEID2018_POLICY)); + assertThatCode(() -> validator.validateCertificatePolicies(getJaakKristjanEsteid2018Cert())) + .doesNotThrowAnyException(); + } + @Test void whenCertificateDoesNotContainCertificatePoliciesExtension_thenValidationSucceeds() throws Exception { final SubjectCertificatePolicyValidator validator = new SubjectCertificatePolicyValidator(List.of(ESTEID2018_POLICY)); @@ -53,6 +77,14 @@ void whenCertificateDoesNotContainCertificatePoliciesExtension_thenValidationSuc .doesNotThrowAnyException(); } + @Test + void whenAllowListIsConfiguredAndCertificatePoliciesExtensionIsMissing_thenValidationFails() throws Exception { + final SubjectCertificatePolicyValidator validator = + new SubjectCertificatePolicyValidator(List.of(ESTEID2018_POLICY), Set.of()); + assertThatExceptionOfType(UserCertificateDisallowedPolicyException.class) + .isThrownBy(() -> validator.validateCertificatePolicies(getCertificateWithoutCertificatePolicies())); + } + @Test void whenPoliciesHaveInvalidEncoding_thenPreservesParsingFailure() { when(certificate.getExtensionValue(Extension.certificatePolicies.getId())).thenReturn(new byte[] {4, 2, 4, 5}); From d55854e89461166e03a0751e7c780d7ac1a7dc7b Mon Sep 17 00:00:00 2001 From: Sven Mitt Date: Wed, 14 Oct 2026 20:05:18 +0300 Subject: [PATCH 2/4] feat: limit whitelist to ID-cards WE2-1308 Signed-off-by: Sven Mitt --- README.md | 15 ++++--- .../SubjectCertificatePolicies.java | 41 ++++++++++--------- 2 files changed, 30 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index be6be1b1..fc499db0 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ import eu.webeid.security.challenge.ChallengeNonceStore; You must explicitly specify which **intermediate** certificate authorities (CAs) are trusted to issue the eID authentication and OCSP responder certificates. CA certificates can be loaded from either the truststore file, resources or any stream source. We use the [`CertificateLoader`](src/main/java/eu/webeid/security/certificate/CertificateLoader.java) helper class to load CA certificates from resources here, but consider using [the truststore file](example/src/main/java/eu/webeid/example/config/ValidationConfiguration.java) instead. -First, copy the trusted certificates, for example `ESTEID2018.cer`, to `resources/cacerts/`, then load the certificates as follows: +To accept both ID-card generations that are currently in use, copy the `ESTEID2018` intermediate certificate from [SK ID Solutions](https://www.skidsolutions.eu/resources/certificates/) and the `ESTEID2025` intermediate certificate from [Zetes](https://repository.eidpki.ee/crt/) to `resources/cacerts/`, then load both certificates as follows: ```java import java.io.IOException; @@ -111,7 +111,10 @@ import eu.webeid.security.certificate.CertificateLoader; ... private X509Certificate[] trustedIntermediateCACertificates() throws CertificateException, IOException { - return CertificateLoader.loadCertificatesFromResources("cacerts/ESTEID2018.cer"); + return CertificateLoader.loadCertificatesFromResources( + "cacerts/ESTEID2018.cer", + "cacerts/ESTEID2025.crt" + ); } ... ``` @@ -322,21 +325,21 @@ The default validator uses the platform OCSP implementation to check certificate Use `withAllowedCertificatePolicies(ASN1ObjectIdentifier... policies)` to require one of the configured certificate policies. A non-empty allow-list takes precedence over the deny-list. The deprecated `withDisallowedCertificatePolicies(ASN1ObjectIdentifier... policies)` method remains as a compatibility fallback when no allowed policies are configured. Estonian Mobile-ID policies are disallowed by default because smart-card authentication must not accept Mobile-ID certificates. -For example, restrict authentication to production Estonian ID cards, digital ID cards, residence permit cards and diplomatic identity cards: +For example, restrict authentication to production Estonian and European Union citizen ID cards: ```java -import static eu.webeid.security.certificate.SubjectCertificatePolicies.ESTEID_PRODUCTION_DOCUMENT_POLICIES; +import static eu.webeid.security.certificate.SubjectCertificatePolicies.ESTEID_ID_CARD_POLICIES; ... AuthTokenValidator tokenValidator = new AuthTokenValidatorBuilder() .withSiteOrigin(URI.create("https://example.org")) .withTrustedCertificateAuthorities(trustedIntermediateCACertificates()) - .withAllowedCertificatePolicies(ESTEID_PRODUCTION_DOCUMENT_POLICIES) + .withAllowedCertificatePolicies(ESTEID_ID_CARD_POLICIES) .build(); ... ``` -See the [certificate policy allow-list guide](readme-policy.md) for an Estonian identity-document production whitelist and its rationale. +See the [certificate policy allow-list guide](readme-policy.md) for the Estonian ID-card whitelist and its rationale. For more advanced revocation requirements, supply a `CertificateRevocationChecker` with `withCertificateRevocationChecker(...)`. The [OCSP configuration guide](src/main/java/eu/webeid/ocsp/README.md) covers custom implementations, the bundled OCSP checker, custom PKIX checkers, responder selection, HTTP settings, and nonce policies. diff --git a/src/main/java/eu/webeid/security/certificate/SubjectCertificatePolicies.java b/src/main/java/eu/webeid/security/certificate/SubjectCertificatePolicies.java index 3eb94026..f7769274 100644 --- a/src/main/java/eu/webeid/security/certificate/SubjectCertificatePolicies.java +++ b/src/main/java/eu/webeid/security/certificate/SubjectCertificatePolicies.java @@ -5,8 +5,6 @@ import org.bouncycastle.asn1.ASN1ObjectIdentifier; -import java.util.Set; - public final class SubjectCertificatePolicies { private static final String ESTEID_SK_2015_MOBILE_ID_POLICY_PREFIX = "1.3.6.1.4.1.10015.1.3"; @@ -21,31 +19,34 @@ public final class SubjectCertificatePolicies { new ASN1ObjectIdentifier(ESTEID_SK_2015_MOBILE_ID_POLICY_PREFIX + ".3"); /** - * Production document policies for Estonian ID cards, digital ID cards, residence permit cards and diplomatic - * identity cards issued under the ESTEID2018 and eID certificate-policy generations. + * Production document policies for Estonian and European Union citizen ID cards issued under the ESTEID2018 + * and eID certificate-policy generations. * - * @see - * Estonian identity-document certificate policies + * @see + * ID.ee certificate policy index for Estonian identity documents + * @see + * IDEMIA-generation certificate policy + * @see + * SK ID Solutions certificate policies + * @see + * SK ID Solutions certificate profiles + * @see + * ESTEID2018 certificate profile + * @see + * Thales-generation certificate policy + * @see + * Zetes Estonia trust-service repository + * @see + * ESTEID2025 certificate profile */ - public static final Set ESTEID_PRODUCTION_DOCUMENT_POLICIES = Set.of( + public static final ASN1ObjectIdentifier[] ESTEID_ID_CARD_POLICIES = { // ESTEID2018 / IDEMIA generation new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.1.1.1"), new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.1.1.2"), - new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.1.1.3"), - new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.1.1.4"), - new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.1.1.5"), - new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.1.1.6"), - new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.1.1.7"), - new ASN1ObjectIdentifier("1.3.6.1.4.1.51455.1.1.1"), // eID / Thales generation new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.2.1.1"), - new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.2.1.2"), - new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.2.1.3"), - new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.2.1.4"), - new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.2.1.5"), - new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.2.1.6"), - new ASN1ObjectIdentifier("1.3.6.1.4.1.51455.2.1.1") - ); + new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.2.1.2") + }; private SubjectCertificatePolicies() { throw new IllegalStateException("Constants class"); From 52fa2f6d7547cb036b77e8ef9d05502016a926ca Mon Sep 17 00:00:00 2001 From: Sven Mitt Date: Fri, 16 Oct 2026 11:01:02 +0300 Subject: [PATCH 3/4] feat: change allowed issuers to map and only apply when issuer is present as map key WE2-1308 Signed-off-by: Sven Mitt --- README.md | 29 ++++++---- .../config/TestValidatorConfiguration.java | 9 ++++ .../SubjectCertificatePolicies.java | 30 +++++++---- .../AuthTokenValidationConfiguration.java | 35 ++++++++++-- .../validator/AuthTokenValidatorBuilder.java | 29 ++++++---- .../SubjectCertificatePolicyValidator.java | 46 +++++++++++----- .../SubjectCertificatePoliciesTest.java | 19 ++++++- .../testutil/AuthTokenValidators.java | 22 ++++++-- .../validator/AuthTokenCertificateTest.java | 8 ++- .../AuthTokenValidationConfigurationTest.java | 51 ++++++++++++++++-- .../AuthTokenValidatorBuilderTest.java | 52 +++++++++++++++++- ...SubjectCertificatePolicyValidatorTest.java | 54 +++++++++++++++++-- 12 files changed, 318 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index fc499db0..caa0a53a 100644 --- a/README.md +++ b/README.md @@ -323,22 +323,29 @@ toTitleCase(CertificateData.getSubjectSurname(userCertificate).orElseThrow()); / The default validator uses the platform OCSP implementation to check certificate revocation. A revoked certificate or an unsuccessful revocation check causes authentication to fail. -Use `withAllowedCertificatePolicies(ASN1ObjectIdentifier... policies)` to require one of the configured certificate policies. A non-empty allow-list takes precedence over the deny-list. The deprecated `withDisallowedCertificatePolicies(ASN1ObjectIdentifier... policies)` method remains as a compatibility fallback when no allowed policies are configured. Estonian Mobile-ID policies are disallowed by default because smart-card authentication must not accept Mobile-ID certificates. +Certificate policy allow-lists are selected by the subject certificate's exact issuer `X500Principal`. At build time, production Estonian ID-card policies from `ESTEID_ID_CARD_POLICIES` are used when the configured allow-list is empty. When one or more issuer entries are configured, only those entries are used. A matching issuer must contain one of its configured policy OIDs; other issuers fall back to the deprecated deny-list, which disallows Estonian Mobile-ID policies by default. Calling `withAllowedCertificatePolicies(issuer)` without policy OIDs removes that issuer's entry. If no entries remain, the Estonian defaults are used. -For example, restrict authentication to production Estonian and European Union citizen ID cards: +Use `withAllowedCertificatePolicies(X500Principal issuer, ASN1ObjectIdentifier... policies)` to define a custom allow-list. Repeated calls configure all supported issuers on the same validator. The following example explicitly configures both Estonian ID-card generations in one builder chain. ```java -import static eu.webeid.security.certificate.SubjectCertificatePolicies.ESTEID_ID_CARD_POLICIES; - -... - AuthTokenValidator tokenValidator = new AuthTokenValidatorBuilder() - .withSiteOrigin(URI.create("https://example.org")) - .withTrustedCertificateAuthorities(trustedIntermediateCACertificates()) - .withAllowedCertificatePolicies(ESTEID_ID_CARD_POLICIES) - .build(); -... +AuthTokenValidator tokenValidator = new AuthTokenValidatorBuilder() + .withSiteOrigin(URI.create("https://example.org")) + .withTrustedCertificateAuthorities(trustedIntermediateCACertificates()) + .withAllowedCertificatePolicies( + new X500Principal("CN=ESTEID2018,2.5.4.97=#0c0e4e545245452d3130373437303133,O=SK ID Solutions AS,C=EE"), + new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.1.1.1"), + new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.1.1.2") + ) + .withAllowedCertificatePolicies( + new X500Principal("C=EE,O=Zetes Estonia OÜ,2.5.4.97=#0c0e4e545245452d3137303636303439,CN=ESTEID2025"), + new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.2.1.1"), + new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.2.1.2") + ) + .build(); ``` +For another country, add each supported issuer and its policy OIDs to the same builder chain and provide the corresponding trusted intermediate CAs. + See the [certificate policy allow-list guide](readme-policy.md) for the Estonian ID-card whitelist and its rationale. For more advanced revocation requirements, supply a `CertificateRevocationChecker` with `withCertificateRevocationChecker(...)`. The [OCSP configuration guide](src/main/java/eu/webeid/ocsp/README.md) covers custom implementations, the bundled OCSP checker, custom PKIX checkers, responder selection, HTTP settings, and nonce policies. diff --git a/example/src/test/java/eu/webeid/example/config/TestValidatorConfiguration.java b/example/src/test/java/eu/webeid/example/config/TestValidatorConfiguration.java index f838a2da..6ca7e9ef 100644 --- a/example/src/test/java/eu/webeid/example/config/TestValidatorConfiguration.java +++ b/example/src/test/java/eu/webeid/example/config/TestValidatorConfiguration.java @@ -6,10 +6,12 @@ import eu.webeid.security.exceptions.JceException; import eu.webeid.security.validator.AuthTokenValidator; import eu.webeid.security.validator.AuthTokenValidatorBuilder; +import org.bouncycastle.asn1.ASN1ObjectIdentifier; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Primary; +import javax.security.auth.x500.X500Principal; import java.net.URI; import java.util.List; @@ -30,6 +32,13 @@ AuthTokenValidator validatorWithoutOcspCall(ValidationConfiguration validationCo .withSiteOrigin(URI.create(yamlConfig.getLocalOrigin())) .withTrustedCertificateAuthorities(validationConfiguration.loadTrustedCACertificatesFromCerFiles()) .withTrustedCertificateAuthorities(validationConfiguration.loadTrustedCACertificatesFromTrustStore(yamlConfig)) + .withAllowedCertificatePolicies( + new X500Principal( + "CN=TEST of ESTEID2018,2.5.4.97=#0c0e4e545245452d3130373437303133," + + "O=SK ID Solutions AS,C=EE" + ), + new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.1.2.1") + ) // Do not call the real OCSP service in tests. .withCertificateRevocationChecker((subjectCertificate, issuerCertificate) -> List.of()) .build(); diff --git a/src/main/java/eu/webeid/security/certificate/SubjectCertificatePolicies.java b/src/main/java/eu/webeid/security/certificate/SubjectCertificatePolicies.java index f7769274..0fa5b027 100644 --- a/src/main/java/eu/webeid/security/certificate/SubjectCertificatePolicies.java +++ b/src/main/java/eu/webeid/security/certificate/SubjectCertificatePolicies.java @@ -5,6 +5,10 @@ import org.bouncycastle.asn1.ASN1ObjectIdentifier; +import javax.security.auth.x500.X500Principal; +import java.util.Map; +import java.util.Set; + public final class SubjectCertificatePolicies { private static final String ESTEID_SK_2015_MOBILE_ID_POLICY_PREFIX = "1.3.6.1.4.1.10015.1.3"; @@ -20,7 +24,7 @@ public final class SubjectCertificatePolicies { /** * Production document policies for Estonian and European Union citizen ID cards issued under the ESTEID2018 - * and eID certificate-policy generations. + * and eID certificate-policy generations, grouped by issuer distinguished name. * * @see * ID.ee certificate policy index for Estonian identity documents @@ -39,14 +43,22 @@ public final class SubjectCertificatePolicies { * @see * ESTEID2025 certificate profile */ - public static final ASN1ObjectIdentifier[] ESTEID_ID_CARD_POLICIES = { - // ESTEID2018 / IDEMIA generation - new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.1.1.1"), - new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.1.1.2"), - // eID / Thales generation - new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.2.1.1"), - new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.2.1.2") - }; + public static final Map> ESTEID_ID_CARD_POLICIES = Map.of( + new X500Principal( + "CN=ESTEID2018,2.5.4.97=#0c0e4e545245452d3130373437303133,O=SK ID Solutions AS,C=EE" + ), Set.of( + // ESTEID2018 / IDEMIA generation + new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.1.1.1"), + new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.1.1.2") + ), + new X500Principal( + "C=EE,O=Zetes Estonia OÜ,2.5.4.97=#0c0e4e545245452d3137303636303439,CN=ESTEID2025" + ), Set.of( + // eID / Thales generation + new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.2.1.1"), + new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.2.1.2") + ) + ); private SubjectCertificatePolicies() { throw new IllegalStateException("Constants class"); diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java index 6344ac4d..1e772e8e 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java @@ -8,13 +8,17 @@ import eu.webeid.security.validator.revocationcheck.RevocationMode; import org.bouncycastle.asn1.ASN1ObjectIdentifier; +import javax.security.auth.x500.X500Principal; import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; import java.security.cert.PKIXRevocationChecker; import java.security.cert.X509Certificate; import java.util.Collection; +import java.util.HashMap; import java.util.HashSet; +import java.util.Map; +import java.util.Objects; import java.util.Set; /** @@ -24,7 +28,7 @@ public final class AuthTokenValidationConfiguration { private URI siteOrigin; private Collection trustedCACertificates = new HashSet<>(); - private Collection allowedSubjectCertificatePolicies = new HashSet<>(); + private Map> allowedSubjectCertificatePolicies = new HashMap<>(); // Don't allow Estonian Mobile-ID policy by default. @Deprecated(since = "4.0.0") private Collection disallowedSubjectCertificatePolicies = new HashSet<>(Set.of( @@ -45,7 +49,8 @@ public final class AuthTokenValidationConfiguration { private AuthTokenValidationConfiguration(AuthTokenValidationConfiguration other) { this.siteOrigin = other.siteOrigin; this.trustedCACertificates = Set.copyOf(other.trustedCACertificates); - this.allowedSubjectCertificatePolicies = Set.copyOf(other.allowedSubjectCertificatePolicies); + this.allowedSubjectCertificatePolicies = + copyAllowedSubjectCertificatePolicies(other.allowedSubjectCertificatePolicies); this.disallowedSubjectCertificatePolicies = Set.copyOf(other.disallowedSubjectCertificatePolicies); this.isUserCertificateRevocationCheckEnabled = other.isUserCertificateRevocationCheckEnabled; this.platformOcspNonceEnabled = other.platformOcspNonceEnabled; @@ -66,12 +71,22 @@ Collection getTrustedCACertificates() { return trustedCACertificates; } - public Collection getAllowedSubjectCertificatePolicies() { + public Map> getAllowedSubjectCertificatePolicies() { return allowedSubjectCertificatePolicies; } - void setAllowedSubjectCertificatePolicies(Collection allowedSubjectCertificatePolicies) { - this.allowedSubjectCertificatePolicies = new HashSet<>(allowedSubjectCertificatePolicies); + void setAllowedSubjectCertificatePolicies( + X500Principal issuer, Collection allowedSubjectCertificatePolicies) { + Objects.requireNonNull(issuer, "Issuer must not be null"); + final Set policies = Set.copyOf(Objects.requireNonNull( + allowedSubjectCertificatePolicies, + "Allowed certificate policies must not be null" + )); + if (policies.isEmpty()) { + this.allowedSubjectCertificatePolicies.remove(issuer); + } else { + this.allowedSubjectCertificatePolicies.put(issuer, policies); + } } /** @@ -135,6 +150,16 @@ AuthTokenValidationConfiguration copy() { return new AuthTokenValidationConfiguration(this); } + private static Map> copyAllowedSubjectCertificatePolicies( + Map> policiesByIssuer) { + final Map> copy = new HashMap<>(); + policiesByIssuer.forEach((issuer, policies) -> copy.put( + Objects.requireNonNull(issuer, "Issuer must not be null"), + Set.copyOf(Objects.requireNonNull(policies, "Allowed certificate policies must not be null")) + )); + return copy; + } + /** * Validates that the given URI is an origin URL as defined in MDN, * in the form of {@code "://" [ ":" ]}. diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java index 6aa6ae96..8ddb0dae 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java @@ -3,12 +3,14 @@ package eu.webeid.security.validator; +import eu.webeid.security.certificate.SubjectCertificatePolicies; import eu.webeid.security.exceptions.JceException; import eu.webeid.security.validator.revocationcheck.CertificateRevocationChecker; import org.bouncycastle.asn1.ASN1ObjectIdentifier; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import javax.security.auth.x500.X500Principal; import java.net.URI; import java.security.cert.PKIXRevocationChecker; import java.security.cert.X509Certificate; @@ -62,15 +64,19 @@ public AuthTokenValidatorBuilder withTrustedCertificateAuthorities(X509Certifica } /** - * Sets the list of allowed user certificate policies. - * When this list is not empty, the user certificate must contain at least one allowed policy and the configured - * disallowed policies are ignored. When this list is empty, validation falls back to the disallowed policy list. + * Adds or replaces the allowed user certificate policies for the given issuer. When no policies are configured, + * {@link SubjectCertificatePolicies#ESTEID_ID_CARD_POLICIES} is used by default. + * When the subject certificate's issuer matches a configured issuer, the certificate must contain at least one + * of that issuer's allowed policies and the configured disallowed policies are ignored. Otherwise, validation + * falls back to the disallowed policy list. Passing no policies removes the issuer's allow-list entry. * - * @param policies allowed user certificate policies + * @param issuer exact subject certificate issuer distinguished name + * @param policies allowed user certificate policies for the issuer * @return the builder instance for method chaining */ - public AuthTokenValidatorBuilder withAllowedCertificatePolicies(ASN1ObjectIdentifier... policies) { - configuration.setAllowedSubjectCertificatePolicies(Arrays.asList(policies)); + public AuthTokenValidatorBuilder withAllowedCertificatePolicies( + X500Principal issuer, ASN1ObjectIdentifier... policies) { + configuration.setAllowedSubjectCertificatePolicies(issuer, Arrays.asList(policies)); LOG.debug("Allowed subject certificate policies set to {}", configuration.getAllowedSubjectCertificatePolicies()); return this; } @@ -82,7 +88,7 @@ public AuthTokenValidatorBuilder withAllowedCertificatePolicies(ASN1ObjectIdenti * * @param policies disallowed user certificate policies * @return the builder instance for method chaining - * @deprecated Use {@link #withAllowedCertificatePolicies(ASN1ObjectIdentifier...)} instead. + * @deprecated Use {@link #withAllowedCertificatePolicies(X500Principal, ASN1ObjectIdentifier...)} instead. */ @Deprecated(since = "4.0.0") public AuthTokenValidatorBuilder withDisallowedCertificatePolicies(ASN1ObjectIdentifier... policies) { @@ -164,8 +170,13 @@ public AuthTokenValidatorBuilder withPKIXRevocationChecker(PKIXRevocationChecker * @throws JceException when JCE configuration is invalid */ public AuthTokenValidator build() throws IllegalArgumentException, JceException { - configuration.validate(); - return new AuthTokenValidatorImpl(configuration); + final AuthTokenValidationConfiguration configurationSnapshot = configuration.copy(); + if (configurationSnapshot.getAllowedSubjectCertificatePolicies().isEmpty()) { + configurationSnapshot.getAllowedSubjectCertificatePolicies() + .putAll(SubjectCertificatePolicies.ESTEID_ID_CARD_POLICIES); + } + configurationSnapshot.validate(); + return new AuthTokenValidatorImpl(configurationSnapshot); } } diff --git a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePolicyValidator.java b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePolicyValidator.java index 3a002ede..4ed930d4 100644 --- a/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePolicyValidator.java +++ b/src/main/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePolicyValidator.java @@ -4,63 +4,71 @@ package eu.webeid.security.validator.certvalidators; import eu.webeid.security.exceptions.AuthTokenException; +import eu.webeid.security.exceptions.UserCertificateDisallowedPolicyException; +import eu.webeid.security.exceptions.UserCertificateParseException; import org.bouncycastle.asn1.ASN1ObjectIdentifier; import org.bouncycastle.asn1.x509.CertificatePolicies; import org.bouncycastle.asn1.x509.Extension; import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils; -import eu.webeid.security.exceptions.UserCertificateDisallowedPolicyException; -import eu.webeid.security.exceptions.UserCertificateParseException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import javax.security.auth.x500.X500Principal; import java.io.IOException; import java.security.cert.X509Certificate; import java.util.Arrays; import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; import java.util.Set; public final class SubjectCertificatePolicyValidator { private static final Logger LOG = LoggerFactory.getLogger(SubjectCertificatePolicyValidator.class); - private final Collection allowedSubjectCertificatePolicies; + private final Map> allowedSubjectCertificatePolicies; private final Collection disallowedSubjectCertificatePolicies; /** * @deprecated Configure both allowed and fallback disallowed policies with - * {@link #SubjectCertificatePolicyValidator(Collection, Collection)}. + * {@link #SubjectCertificatePolicyValidator(Map, Collection)}. */ @Deprecated(since = "4.0.0") public SubjectCertificatePolicyValidator(Collection disallowedSubjectCertificatePolicies) { - this(Set.of(), disallowedSubjectCertificatePolicies); + this(Map.of(), disallowedSubjectCertificatePolicies); } public SubjectCertificatePolicyValidator( - Collection allowedSubjectCertificatePolicies, + Map> allowedSubjectCertificatePolicies, Collection disallowedSubjectCertificatePolicies) { - this.allowedSubjectCertificatePolicies = Set.copyOf(allowedSubjectCertificatePolicies); + this.allowedSubjectCertificatePolicies = copyAllowedSubjectCertificatePolicies(allowedSubjectCertificatePolicies); this.disallowedSubjectCertificatePolicies = Set.copyOf(disallowedSubjectCertificatePolicies); } /** - * Requires a configured allowed policy when the allow-list is not empty. Otherwise, rejects any configured - * disallowed policy present in the certificate policies extension. - * A certificate without the certificate policies extension passes only when the allow-list is empty. + * Requires a configured allowed policy when the certificate issuer has an allow-list entry. + * Otherwise, rejects any configured disallowed policy present in the certificate policies extension. + * A certificate without the certificate policies extension passes only when its issuer has no allow-list entry. * * @param subjectCertificate user certificate to be validated * @throws UserCertificateDisallowedPolicyException when no allowed policy is present or a disallowed policy is present. * @throws UserCertificateParseException when the certificate policies extension is invalid. */ public void validateCertificatePolicies(X509Certificate subjectCertificate) throws AuthTokenException { + final X500Principal issuer = subjectCertificate.getIssuerX500Principal(); final byte[] extensionValue = subjectCertificate.getExtensionValue(Extension.certificatePolicies.getId()); + final Collection issuerAllowedPolicies = allowedSubjectCertificatePolicies.getOrDefault(issuer, Set.of()); + if (extensionValue == null) { - if (!allowedSubjectCertificatePolicies.isEmpty()) { + if (!issuerAllowedPolicies.isEmpty()) { LOG.debug("User certificate does not contain the certificate policies extension, hence it does not contain an allowed policy."); throw new UserCertificateDisallowedPolicyException(); } LOG.debug("User certificate does not contain the certificate policies extension, hence it does not contain disallowed policies."); return; } + try { final CertificatePolicies policies = CertificatePolicies.getInstance( JcaX509ExtensionUtils.parseExtensionValue(extensionValue) @@ -68,13 +76,13 @@ public void validateCertificatePolicies(X509Certificate subjectCertificate) thro if (policies == null) { throw new IllegalArgumentException("Certificate policies extension is empty"); } - if (!allowedSubjectCertificatePolicies.isEmpty()) { + if (!issuerAllowedPolicies.isEmpty()) { if (Arrays.stream(policies.getPolicyInformation()) .noneMatch(policyInformation -> - allowedSubjectCertificatePolicies.contains(policyInformation.getPolicyIdentifier()))) { + issuerAllowedPolicies.contains(policyInformation.getPolicyIdentifier()))) { throw new UserCertificateDisallowedPolicyException(); } - LOG.debug("User certificate contains an allowed policy."); + LOG.debug("User certificate contains a policy allowed for issuer '{}'.", issuer); return; } if (Arrays.stream(policies.getPolicyInformation()) @@ -87,4 +95,14 @@ public void validateCertificatePolicies(X509Certificate subjectCertificate) thro } LOG.debug("User certificate does not contain disallowed policies."); } + + private static Map> copyAllowedSubjectCertificatePolicies( + Map> policiesByIssuer) { + final Map> copy = new HashMap<>(); + policiesByIssuer.forEach((issuer, policies) -> copy.put( + Objects.requireNonNull(issuer, "Issuer must not be null"), + Set.copyOf(Objects.requireNonNull(policies, "Allowed certificate policies must not be null")) + )); + return Map.copyOf(copy); + } } diff --git a/src/test/java/eu/webeid/security/certificate/SubjectCertificatePoliciesTest.java b/src/test/java/eu/webeid/security/certificate/SubjectCertificatePoliciesTest.java index 2b9e4542..f6f0e06a 100644 --- a/src/test/java/eu/webeid/security/certificate/SubjectCertificatePoliciesTest.java +++ b/src/test/java/eu/webeid/security/certificate/SubjectCertificatePoliciesTest.java @@ -6,15 +6,30 @@ import org.bouncycastle.asn1.ASN1ObjectIdentifier; import org.junit.jupiter.api.Test; +import javax.security.auth.x500.X500Principal; + import static org.assertj.core.api.Assertions.assertThat; class SubjectCertificatePoliciesTest { + private static final X500Principal ESTEID2018_ISSUER = new X500Principal( + "CN=ESTEID2018,2.5.4.97=#0c0e4e545245452d3130373437303133,O=SK ID Solutions AS,C=EE" + ); + private static final X500Principal ESTEID2025_ISSUER = new X500Principal( + "C=EE,O=Zetes Estonia OÜ,2.5.4.97=#0c0e4e545245452d3137303636303439,CN=ESTEID2025" + ); + @Test void esteidIdCardPoliciesContainOnlyProductionIdCardPolicies() { - assertThat(SubjectCertificatePolicies.ESTEID_ID_CARD_POLICIES).containsExactlyInAnyOrder( + assertThat(SubjectCertificatePolicies.ESTEID_ID_CARD_POLICIES) + .containsOnlyKeys(ESTEID2018_ISSUER, ESTEID2025_ISSUER); + assertThat(SubjectCertificatePolicies.ESTEID_ID_CARD_POLICIES.get(ESTEID2018_ISSUER)) + .containsExactlyInAnyOrder( new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.1.1.1"), - new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.1.1.2"), + new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.1.1.2") + ); + assertThat(SubjectCertificatePolicies.ESTEID_ID_CARD_POLICIES.get(ESTEID2025_ISSUER)) + .containsExactlyInAnyOrder( new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.2.1.1"), new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.2.1.2") ); diff --git a/src/test/java/eu/webeid/security/testutil/AuthTokenValidators.java b/src/test/java/eu/webeid/security/testutil/AuthTokenValidators.java index fd7348e2..5ea9922d 100644 --- a/src/test/java/eu/webeid/security/testutil/AuthTokenValidators.java +++ b/src/test/java/eu/webeid/security/testutil/AuthTokenValidators.java @@ -9,6 +9,7 @@ import eu.webeid.security.validator.AuthTokenValidatorBuilder; import org.bouncycastle.asn1.ASN1ObjectIdentifier; +import javax.security.auth.x500.X500Principal; import java.io.IOException; import java.net.URI; import java.security.cert.CertificateException; @@ -17,7 +18,13 @@ public final class AuthTokenValidators { private static final String TOKEN_ORIGIN_URL = "https://ria.ee"; - private static final ASN1ObjectIdentifier EST_IDEMIA_POLICY = new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.1.2.1"); + private static final X500Principal EST_IDEMIA_TEST_ISSUER = new X500Principal( + "CN=TEST of ESTEID2018,2.5.4.97=#0c0e4e545245452d3130373437303133,O=SK ID Solutions AS,C=EE" + ); + private static final ASN1ObjectIdentifier EST_IDEMIA_PRODUCTION_POLICY = + new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.1.1.1"); + private static final ASN1ObjectIdentifier EST_IDEMIA_TEST_POLICY = + new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.1.2.1"); public static AuthTokenValidator getAuthTokenValidator() throws CertificateException, JceException, IOException { return getAuthTokenValidator(TOKEN_ORIGIN_URL); @@ -49,8 +56,10 @@ public static AuthTokenValidator getAuthTokenValidatorWithJuly2024ExpiredUnrelat } public static AuthTokenValidator getAuthTokenValidatorWithDisallowedESTEIDPolicy() throws CertificateException, JceException, IOException { - return getAuthTokenValidatorBuilder(TOKEN_ORIGIN_URL, getCACertificates()) - .withDisallowedCertificatePolicies(EST_IDEMIA_POLICY) + return new AuthTokenValidatorBuilder() + .withSiteOrigin(URI.create(TOKEN_ORIGIN_URL)) + .withTrustedCertificateAuthorities(getCACertificates()) + .withDisallowedCertificatePolicies(EST_IDEMIA_TEST_POLICY) .withoutUserCertificateRevocationCheck() .build(); } @@ -76,7 +85,12 @@ public static AuthTokenValidatorBuilder getDefaultAuthTokenValidatorBuilder() th private static AuthTokenValidatorBuilder getAuthTokenValidatorBuilder(String uri, X509Certificate[] certificates) { return new AuthTokenValidatorBuilder() .withSiteOrigin(URI.create(uri)) - .withTrustedCertificateAuthorities(certificates); + .withTrustedCertificateAuthorities(certificates) + .withAllowedCertificatePolicies( + EST_IDEMIA_TEST_ISSUER, + EST_IDEMIA_PRODUCTION_POLICY, + EST_IDEMIA_TEST_POLICY + ); } private static X509Certificate[] getCACertificates() throws CertificateException, IOException { diff --git a/src/test/java/eu/webeid/security/validator/AuthTokenCertificateTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenCertificateTest.java index 7ea65123..4b149d0c 100644 --- a/src/test/java/eu/webeid/security/validator/AuthTokenCertificateTest.java +++ b/src/test/java/eu/webeid/security/validator/AuthTokenCertificateTest.java @@ -25,6 +25,7 @@ import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; +import javax.security.auth.x500.X500Principal; import java.security.cert.CertificateException; import java.util.Base64; @@ -39,6 +40,9 @@ class AuthTokenCertificateTest extends AbstractTestWithValidator { private static final ASN1ObjectIdentifier ESTEID2018_TEST_POLICY = new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.1.2.1"); private static final ASN1ObjectIdentifier UNRELATED_POLICY = new ASN1ObjectIdentifier("1.2.3"); + private static final X500Principal ESTEID2018_TEST_ISSUER = new X500Principal( + "CN=TEST of ESTEID2018,2.5.4.97=#0c0e4e545245452d3130373437303133,O=SK ID Solutions AS,C=EE" + ); private static final String AUTH_TOKEN = "{\"algorithm\":\"ES384\"," + "\"unverifiedCertificate\":\"X5C\"," + @@ -187,7 +191,7 @@ void whenCertificatePolicyIsDisallowed_thenValidationFails() throws Exception { void whenCertificatePolicyIsAllowed_thenValidationSucceeds() throws Exception { final AuthTokenValidator validatorWithAllowedESTEIDPolicy = AuthTokenValidators .getDefaultAuthTokenValidatorBuilder() - .withAllowedCertificatePolicies(ESTEID2018_TEST_POLICY) + .withAllowedCertificatePolicies(ESTEID2018_TEST_ISSUER, ESTEID2018_TEST_POLICY) .withoutUserCertificateRevocationCheck() .build(); @@ -199,7 +203,7 @@ void whenCertificatePolicyIsAllowed_thenValidationSucceeds() throws Exception { void whenCertificatePolicyIsNotAllowed_thenValidationFails() throws Exception { final AuthTokenValidator validatorWithUnrelatedAllowedPolicy = AuthTokenValidators .getDefaultAuthTokenValidatorBuilder() - .withAllowedCertificatePolicies(UNRELATED_POLICY) + .withAllowedCertificatePolicies(ESTEID2018_TEST_ISSUER, UNRELATED_POLICY) .withoutUserCertificateRevocationCheck() .build(); diff --git a/src/test/java/eu/webeid/security/validator/AuthTokenValidationConfigurationTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenValidationConfigurationTest.java index e6d111b0..92ebafca 100644 --- a/src/test/java/eu/webeid/security/validator/AuthTokenValidationConfigurationTest.java +++ b/src/test/java/eu/webeid/security/validator/AuthTokenValidationConfigurationTest.java @@ -22,33 +22,47 @@ package eu.webeid.security.validator; +import eu.webeid.security.certificate.SubjectCertificatePolicies; import eu.webeid.security.testutil.Certificates; import eu.webeid.security.validator.revocationcheck.CertificateRevocationChecker; import eu.webeid.security.validator.revocationcheck.RevocationMode; import org.bouncycastle.asn1.ASN1ObjectIdentifier; import org.junit.jupiter.api.Test; +import javax.security.auth.x500.X500Principal; import java.net.URI; import java.security.cert.PKIXRevocationChecker; import java.util.ArrayList; import java.util.List; +import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.entry; import static org.mockito.Mockito.mock; class AuthTokenValidationConfigurationTest { private static final ASN1ObjectIdentifier ALLOWED_POLICY = new ASN1ObjectIdentifier("1.2.3"); + private static final X500Principal ISSUER = new X500Principal("CN=Example CA"); + + @Test + void allowedPoliciesAreEmptyByDefault() { + final AuthTokenValidationConfiguration configuration = new AuthTokenValidationConfiguration(); + + assertThat(configuration.getAllowedSubjectCertificatePolicies()) + .isEmpty(); + } @Test void copyRetainsAllowedPoliciesIndependently() { final AuthTokenValidationConfiguration configuration = new AuthTokenValidationConfiguration(); - configuration.setAllowedSubjectCertificatePolicies(List.of(ALLOWED_POLICY)); + configuration.setAllowedSubjectCertificatePolicies(ISSUER, List.of(ALLOWED_POLICY)); final AuthTokenValidationConfiguration copy = configuration.copy(); configuration.getAllowedSubjectCertificatePolicies().clear(); - assertThat(copy.getAllowedSubjectCertificatePolicies()).containsExactly(ALLOWED_POLICY); + assertThat(copy.getAllowedSubjectCertificatePolicies()) + .containsEntry(ISSUER, Set.of(ALLOWED_POLICY)); } @Test @@ -56,10 +70,39 @@ void setAllowedPoliciesCopiesInputCollection() { final List policies = new ArrayList<>(List.of(ALLOWED_POLICY)); final AuthTokenValidationConfiguration configuration = new AuthTokenValidationConfiguration(); - configuration.setAllowedSubjectCertificatePolicies(policies); + configuration.setAllowedSubjectCertificatePolicies(ISSUER, policies); policies.clear(); - assertThat(configuration.getAllowedSubjectCertificatePolicies()).containsExactly(ALLOWED_POLICY); + assertThat(configuration.getAllowedSubjectCertificatePolicies()) + .containsOnly(entry(ISSUER, Set.of(ALLOWED_POLICY))); + } + + @Test + void subsequentAllowedPolicyConfigurationAddsAnotherIssuer() { + final X500Principal anotherIssuer = new X500Principal("CN=Another CA"); + final ASN1ObjectIdentifier anotherPolicy = new ASN1ObjectIdentifier("1.2.4"); + final AuthTokenValidationConfiguration configuration = new AuthTokenValidationConfiguration(); + + configuration.setAllowedSubjectCertificatePolicies(ISSUER, List.of(ALLOWED_POLICY)); + configuration.setAllowedSubjectCertificatePolicies(anotherIssuer, List.of(anotherPolicy)); + + assertThat(configuration.getAllowedSubjectCertificatePolicies()) + .containsOnly( + entry(ISSUER, Set.of(ALLOWED_POLICY)), + entry(anotherIssuer, Set.of(anotherPolicy)) + ); + } + + @Test + void settingNoAllowedPoliciesRemovesIssuerEntry() { + final X500Principal defaultIssuer = SubjectCertificatePolicies.ESTEID_ID_CARD_POLICIES + .keySet().iterator().next(); + final AuthTokenValidationConfiguration configuration = new AuthTokenValidationConfiguration(); + + configuration.setAllowedSubjectCertificatePolicies(defaultIssuer, List.of()); + + assertThat(configuration.getAllowedSubjectCertificatePolicies()) + .doesNotContainKey(defaultIssuer); } @Test diff --git a/src/test/java/eu/webeid/security/validator/AuthTokenValidatorBuilderTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenValidatorBuilderTest.java index 5aa9e0dc..17e681e4 100644 --- a/src/test/java/eu/webeid/security/validator/AuthTokenValidatorBuilderTest.java +++ b/src/test/java/eu/webeid/security/validator/AuthTokenValidatorBuilderTest.java @@ -4,25 +4,35 @@ package eu.webeid.security.validator; import eu.webeid.security.certificate.CertificateValidator; +import eu.webeid.security.certificate.SubjectCertificatePolicies; import eu.webeid.security.testutil.AuthTokenValidators; import eu.webeid.security.validator.revocationcheck.CertificateRevocationChecker; import eu.webeid.security.validator.revocationcheck.RevocationMode; +import org.bouncycastle.asn1.ASN1ObjectIdentifier; import org.junit.jupiter.api.Test; +import org.mockito.MockedConstruction; import org.mockito.MockedStatic; +import javax.security.auth.x500.X500Principal; import java.net.URI; import java.security.cert.PKIXRevocationChecker; +import java.security.cert.X509Certificate; import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; import static eu.webeid.security.testutil.AbstractTestWithValidator.VALID_AUTH_TOKEN; import static eu.webeid.security.testutil.AbstractTestWithValidator.VALID_CHALLENGE_NONCE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.entry; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anySet; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.times; @@ -137,6 +147,46 @@ void whenPlatformOcspNonceSettingChanges_thenBuiltValidatorsRetainTheirSetting() } } + @Test + void whenAllowedPoliciesAreEmpty_thenBuildUsesEsteidDefaults() throws Exception { + final AuthTokenValidationConfiguration builtConfiguration = captureBuiltConfiguration(validBuilder()); + + assertThat(builtConfiguration.getAllowedSubjectCertificatePolicies()) + .isEqualTo(SubjectCertificatePolicies.ESTEID_ID_CARD_POLICIES); + } + + @Test + void whenAllowedPoliciesAreConfigured_thenBuildUsesOnlyConfiguredPolicies() throws Exception { + final X500Principal issuer = new X500Principal("CN=Example CA"); + final ASN1ObjectIdentifier policy = new ASN1ObjectIdentifier("1.2.3"); + + final AuthTokenValidationConfiguration builtConfiguration = captureBuiltConfiguration( + validBuilder().withAllowedCertificatePolicies(issuer, policy) + ); + + assertThat(builtConfiguration.getAllowedSubjectCertificatePolicies()) + .containsOnly(entry(issuer, Set.of(policy))); + } + + private static AuthTokenValidationConfiguration captureBuiltConfiguration( + AuthTokenValidatorBuilder builder) throws Exception { + final AtomicReference capturedConfiguration = new AtomicReference<>(); + try (MockedConstruction ignored = mockConstruction( + AuthTokenValidatorImpl.class, + (mock, context) -> capturedConfiguration.set( + (AuthTokenValidationConfiguration) context.arguments().get(0) + ))) { + builder.build(); + } + return capturedConfiguration.get(); + } + + private static AuthTokenValidatorBuilder validBuilder() { + return new AuthTokenValidatorBuilder() + .withSiteOrigin(URI.create("https://ria.ee")) + .withTrustedCertificateAuthorities(mock(X509Certificate.class)); + } + private static CertificateRevocationChecker getNoopChecker() { return (subjectCertificate, issuerCertificate) -> List.of(); } diff --git a/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePolicyValidatorTest.java b/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePolicyValidatorTest.java index e4557e63..8650efd9 100644 --- a/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePolicyValidatorTest.java +++ b/src/test/java/eu/webeid/security/validator/certvalidators/SubjectCertificatePolicyValidatorTest.java @@ -10,12 +10,16 @@ import org.bouncycastle.asn1.ASN1Integer; import org.bouncycastle.asn1.DEROctetString; import org.bouncycastle.asn1.x509.Extension; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import java.security.cert.X509Certificate; import java.util.List; +import java.util.Map; import java.util.Set; +import javax.security.auth.x500.X500Principal; + import static eu.webeid.security.testutil.Certificates.getCertificateWithoutCertificatePolicies; import static eu.webeid.security.testutil.Certificates.getJaakKristjanEsteid2018Cert; import static org.assertj.core.api.Assertions.assertThatCode; @@ -28,10 +32,18 @@ class SubjectCertificatePolicyValidatorTest { private static final ASN1ObjectIdentifier ESTEID2018_POLICY = new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.1.2.1"); private static final ASN1ObjectIdentifier UNRELATED_POLICY = new ASN1ObjectIdentifier("1.3.6.1.4.1.51361.1.2.2"); + private static final X500Principal ESTEID2018_TEST_ISSUER = new X500Principal( + "CN=TEST of ESTEID2018,2.5.4.97=#0c0e4e545245452d3130373437303133,O=SK ID Solutions AS,C=EE" + ); private final X509Certificate certificate = mock(X509Certificate.class); private final SubjectCertificatePolicyValidator validator = new SubjectCertificatePolicyValidator(Set.of()); + @BeforeEach + void setUpCertificateIssuer() { + when(certificate.getIssuerX500Principal()).thenReturn(new X500Principal("CN=Unconfigured CA")); + } + @Test void whenCertificateContainsDisallowedPolicy_thenValidationFails() throws Exception { final SubjectCertificatePolicyValidator validator = new SubjectCertificatePolicyValidator(List.of(ESTEID2018_POLICY)); @@ -49,7 +61,7 @@ void whenCertificateDoesNotContainDisallowedPolicies_thenValidationSucceeds() th @Test void whenCertificateContainsAllowedPolicy_thenValidationSucceeds() throws Exception { final SubjectCertificatePolicyValidator validator = - new SubjectCertificatePolicyValidator(List.of(ESTEID2018_POLICY), Set.of()); + new SubjectCertificatePolicyValidator(allowedPolicies(ESTEID2018_POLICY), Set.of()); assertThatCode(() -> validator.validateCertificatePolicies(getJaakKristjanEsteid2018Cert())) .doesNotThrowAnyException(); } @@ -57,7 +69,7 @@ void whenCertificateContainsAllowedPolicy_thenValidationSucceeds() throws Except @Test void whenCertificateDoesNotContainAllowedPolicy_thenValidationFails() throws Exception { final SubjectCertificatePolicyValidator validator = - new SubjectCertificatePolicyValidator(List.of(UNRELATED_POLICY), Set.of()); + new SubjectCertificatePolicyValidator(allowedPolicies(UNRELATED_POLICY), Set.of()); assertThatExceptionOfType(UserCertificateDisallowedPolicyException.class) .isThrownBy(() -> validator.validateCertificatePolicies(getJaakKristjanEsteid2018Cert())); } @@ -65,11 +77,36 @@ void whenCertificateDoesNotContainAllowedPolicy_thenValidationFails() throws Exc @Test void whenPolicyIsBothAllowedAndDisallowed_thenAllowListTakesPrecedence() throws Exception { final SubjectCertificatePolicyValidator validator = - new SubjectCertificatePolicyValidator(List.of(ESTEID2018_POLICY), List.of(ESTEID2018_POLICY)); + new SubjectCertificatePolicyValidator( + allowedPolicies(ESTEID2018_POLICY), + List.of(ESTEID2018_POLICY) + ); assertThatCode(() -> validator.validateCertificatePolicies(getJaakKristjanEsteid2018Cert())) .doesNotThrowAnyException(); } + @Test + void whenIssuerHasNoAllowListEntry_thenValidationFallsBackToDisallowedPolicies() throws Exception { + final SubjectCertificatePolicyValidator validator = new SubjectCertificatePolicyValidator( + Map.of(new X500Principal("CN=Another CA"), Set.of(ESTEID2018_POLICY)), + List.of(ESTEID2018_POLICY) + ); + + assertThatExceptionOfType(UserCertificateDisallowedPolicyException.class) + .isThrownBy(() -> validator.validateCertificatePolicies(getJaakKristjanEsteid2018Cert())); + } + + @Test + void whenIssuerAllowListIsEmpty_thenValidationFallsBackToDisallowedPolicies() throws Exception { + final SubjectCertificatePolicyValidator validator = new SubjectCertificatePolicyValidator( + Map.of(ESTEID2018_TEST_ISSUER, Set.of()), + List.of(ESTEID2018_POLICY) + ); + + assertThatExceptionOfType(UserCertificateDisallowedPolicyException.class) + .isThrownBy(() -> validator.validateCertificatePolicies(getJaakKristjanEsteid2018Cert())); + } + @Test void whenCertificateDoesNotContainCertificatePoliciesExtension_thenValidationSucceeds() throws Exception { final SubjectCertificatePolicyValidator validator = new SubjectCertificatePolicyValidator(List.of(ESTEID2018_POLICY)); @@ -79,10 +116,12 @@ void whenCertificateDoesNotContainCertificatePoliciesExtension_thenValidationSuc @Test void whenAllowListIsConfiguredAndCertificatePoliciesExtensionIsMissing_thenValidationFails() throws Exception { + when(certificate.getIssuerX500Principal()) + .thenReturn(ESTEID2018_TEST_ISSUER); final SubjectCertificatePolicyValidator validator = - new SubjectCertificatePolicyValidator(List.of(ESTEID2018_POLICY), Set.of()); + new SubjectCertificatePolicyValidator(allowedPolicies(ESTEID2018_POLICY), Set.of()); assertThatExceptionOfType(UserCertificateDisallowedPolicyException.class) - .isThrownBy(() -> validator.validateCertificatePolicies(getCertificateWithoutCertificatePolicies())); + .isThrownBy(() -> validator.validateCertificatePolicies(certificate)); } @Test @@ -113,4 +152,9 @@ void whenPoliciesHaveWrongAsn1Type_thenPreservesParsingFailure() throws Exceptio .isInstanceOf(UserCertificateParseException.class) .hasCauseInstanceOf(IllegalArgumentException.class); } + + private static Map> allowedPolicies( + ASN1ObjectIdentifier... policies) { + return Map.of(ESTEID2018_TEST_ISSUER, Set.of(policies)); + } } From 52f86e8d3c30871a1ccbd985fae671537f72b617 Mon Sep 17 00:00:00 2001 From: Sven Mitt Date: Fri, 16 Oct 2026 12:49:01 +0300 Subject: [PATCH 4/4] feat: remove ESTEID_SK_2015_MOBILE_ID disallowed policy from configuration WE2-1308 Signed-off-by: Sven Mitt --- README.md | 16 +++++++- .../AuthTokenValidationConfiguration.java | 18 +++++---- .../validator/AuthTokenValidatorBuilder.java | 4 +- .../validator/AuthTokenCertificateTest.java | 38 +++++++++++++++++++ 4 files changed, 65 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index caa0a53a..6622a660 100644 --- a/README.md +++ b/README.md @@ -323,7 +323,7 @@ toTitleCase(CertificateData.getSubjectSurname(userCertificate).orElseThrow()); / The default validator uses the platform OCSP implementation to check certificate revocation. A revoked certificate or an unsuccessful revocation check causes authentication to fail. -Certificate policy allow-lists are selected by the subject certificate's exact issuer `X500Principal`. At build time, production Estonian ID-card policies from `ESTEID_ID_CARD_POLICIES` are used when the configured allow-list is empty. When one or more issuer entries are configured, only those entries are used. A matching issuer must contain one of its configured policy OIDs; other issuers fall back to the deprecated deny-list, which disallows Estonian Mobile-ID policies by default. Calling `withAllowedCertificatePolicies(issuer)` without policy OIDs removes that issuer's entry. If no entries remain, the Estonian defaults are used. +Certificate policy allow-lists are selected by the subject certificate's exact issuer `X500Principal`. At build time, production Estonian ID-card policies from `ESTEID_ID_CARD_POLICIES` are used when the configured allow-list is empty. When one or more issuer entries are configured, only those entries are used. A matching issuer must contain one of its configured policy OIDs; other issuers fall back to the deprecated deny-list, which is empty by default. Calling `withAllowedCertificatePolicies(issuer)` without policy OIDs removes that issuer's entry. If no entries remain, the Estonian defaults are used. Use `withAllowedCertificatePolicies(X500Principal issuer, ASN1ObjectIdentifier... policies)` to define a custom allow-list. Repeated calls configure all supported issuers on the same validator. The following example explicitly configures both Estonian ID-card generations in one builder chain. @@ -346,6 +346,20 @@ AuthTokenValidator tokenValidator = new AuthTokenValidatorBuilder() For another country, add each supported issuer and its policy OIDs to the same builder chain and provide the corresponding trusted intermediate CAs. +To restore old behaviour of deny list, use +```java +AuthTokenValidator tokenValidator = new AuthTokenValidatorBuilder() + .withSiteOrigin(URI.create("https://example.org")) + .withTrustedCertificateAuthorities(trustedIntermediateCACertificates()) + .withDisallowedCertificatePolicies( + SubjectCertificatePolicies.ESTEID_SK_2015_MOBILE_ID_POLICY_V1, + SubjectCertificatePolicies.ESTEID_SK_2015_MOBILE_ID_POLICY_V2, + SubjectCertificatePolicies.ESTEID_SK_2015_MOBILE_ID_POLICY_V3, + SubjectCertificatePolicies.ESTEID_SK_2015_MOBILE_ID_POLICY + ) + .build(); +``` + See the [certificate policy allow-list guide](readme-policy.md) for the Estonian ID-card whitelist and its rationale. For more advanced revocation requirements, supply a `CertificateRevocationChecker` with `withCertificateRevocationChecker(...)`. The [OCSP configuration guide](src/main/java/eu/webeid/ocsp/README.md) covers custom implementations, the bundled OCSP checker, custom PKIX checkers, responder selection, HTTP settings, and nonce policies. diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java index 1e772e8e..15a7aba8 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidationConfiguration.java @@ -3,7 +3,6 @@ package eu.webeid.security.validator; -import eu.webeid.security.certificate.SubjectCertificatePolicies; import eu.webeid.security.validator.revocationcheck.CertificateRevocationChecker; import eu.webeid.security.validator.revocationcheck.RevocationMode; import org.bouncycastle.asn1.ASN1ObjectIdentifier; @@ -29,14 +28,8 @@ public final class AuthTokenValidationConfiguration { private URI siteOrigin; private Collection trustedCACertificates = new HashSet<>(); private Map> allowedSubjectCertificatePolicies = new HashMap<>(); - // Don't allow Estonian Mobile-ID policy by default. @Deprecated(since = "4.0.0") - private Collection disallowedSubjectCertificatePolicies = new HashSet<>(Set.of( - SubjectCertificatePolicies.ESTEID_SK_2015_MOBILE_ID_POLICY_V1, - SubjectCertificatePolicies.ESTEID_SK_2015_MOBILE_ID_POLICY_V2, - SubjectCertificatePolicies.ESTEID_SK_2015_MOBILE_ID_POLICY_V3, - SubjectCertificatePolicies.ESTEID_SK_2015_MOBILE_ID_POLICY - )); + private Collection disallowedSubjectCertificatePolicies = new HashSet<>(); private boolean isUserCertificateRevocationCheckEnabled = true; private boolean platformOcspNonceEnabled = true; private CertificateRevocationChecker certificateRevocationChecker; @@ -97,6 +90,15 @@ public Collection getDisallowedSubjectCertificatePolicies( return disallowedSubjectCertificatePolicies; } + /** + * @deprecated Configure allowed certificate policies instead. + */ + @Deprecated(since = "4.0.0") + void setDisallowedSubjectCertificatePolicies( + Collection disallowedSubjectCertificatePolicies) { + this.disallowedSubjectCertificatePolicies = new HashSet<>(disallowedSubjectCertificatePolicies); + } + boolean isUserCertificateRevocationCheckEnabled() { return isUserCertificateRevocationCheckEnabled; } diff --git a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java index 8ddb0dae..93ea9867 100644 --- a/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java +++ b/src/main/java/eu/webeid/security/validator/AuthTokenValidatorBuilder.java @@ -82,7 +82,7 @@ public AuthTokenValidatorBuilder withAllowedCertificatePolicies( } /** - * Adds the given policies to the list of disallowed user certificate policies. + * Sets 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. * @@ -92,7 +92,7 @@ public AuthTokenValidatorBuilder withAllowedCertificatePolicies( */ @Deprecated(since = "4.0.0") public AuthTokenValidatorBuilder withDisallowedCertificatePolicies(ASN1ObjectIdentifier... policies) { - Collections.addAll(configuration.getDisallowedSubjectCertificatePolicies(), policies); + configuration.setDisallowedSubjectCertificatePolicies(Arrays.asList(policies)); LOG.debug("Disallowed subject certificate policies set to {}", configuration.getDisallowedSubjectCertificatePolicies()); return this; } diff --git a/src/test/java/eu/webeid/security/validator/AuthTokenCertificateTest.java b/src/test/java/eu/webeid/security/validator/AuthTokenCertificateTest.java index 4b149d0c..84310f70 100644 --- a/src/test/java/eu/webeid/security/validator/AuthTokenCertificateTest.java +++ b/src/test/java/eu/webeid/security/validator/AuthTokenCertificateTest.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.exc.MismatchedInputException; import eu.webeid.security.authtoken.WebEidAuthToken; +import eu.webeid.security.certificate.SubjectCertificatePolicies; import eu.webeid.security.exceptions.AuthTokenException; import eu.webeid.security.exceptions.AuthTokenParseException; import eu.webeid.security.exceptions.CertificateDecodingException; @@ -217,6 +218,25 @@ void whenUsingOldMobileIdCertificate_thenValidationFails() throws AuthTokenExcep final WebEidAuthToken token = replaceTokenField(AUTH_TOKEN, "X5C", OLD_MOBILE_ID_CERT); assertThatThrownBy(() -> validator .validate(token, VALID_CHALLENGE_NONCE)) + .isInstanceOf(CertificateNotTrustedException.class); + } + + @Test + void whenUsingOldMobileIdCertificateWithLegacyDenyList_thenPolicyValidationFails() throws Exception { + mockDate("2021-03-01", mockedClock); + final AuthTokenValidator validatorWithLegacyDenyList = AuthTokenValidators + .getDefaultAuthTokenValidatorBuilder() + .withDisallowedCertificatePolicies( + SubjectCertificatePolicies.ESTEID_SK_2015_MOBILE_ID_POLICY_V1, + SubjectCertificatePolicies.ESTEID_SK_2015_MOBILE_ID_POLICY_V2, + SubjectCertificatePolicies.ESTEID_SK_2015_MOBILE_ID_POLICY_V3, + SubjectCertificatePolicies.ESTEID_SK_2015_MOBILE_ID_POLICY + ) + .withoutUserCertificateRevocationCheck() + .build(); + final WebEidAuthToken token = replaceTokenField(AUTH_TOKEN, "X5C", OLD_MOBILE_ID_CERT); + + assertThatThrownBy(() -> validatorWithLegacyDenyList.validate(token, VALID_CHALLENGE_NONCE)) .isInstanceOf(UserCertificateDisallowedPolicyException.class); } @@ -225,6 +245,24 @@ void whenUsingNewMobileIdCertificate_thenValidationFails() throws AuthTokenExcep final WebEidAuthToken token = replaceTokenField(AUTH_TOKEN, "X5C", NEW_MOBILE_ID_CERT); assertThatThrownBy(() -> validator .validate(token, VALID_CHALLENGE_NONCE)) + .isInstanceOf(CertificateNotTrustedException.class); + } + + @Test + void whenUsingNewMobileIdCertificateWithLegacyDenyList_thenPolicyValidationFails() throws Exception { + final AuthTokenValidator validatorWithLegacyDenyList = AuthTokenValidators + .getDefaultAuthTokenValidatorBuilder() + .withDisallowedCertificatePolicies( + SubjectCertificatePolicies.ESTEID_SK_2015_MOBILE_ID_POLICY_V1, + SubjectCertificatePolicies.ESTEID_SK_2015_MOBILE_ID_POLICY_V2, + SubjectCertificatePolicies.ESTEID_SK_2015_MOBILE_ID_POLICY_V3, + SubjectCertificatePolicies.ESTEID_SK_2015_MOBILE_ID_POLICY + ) + .withoutUserCertificateRevocationCheck() + .build(); + final WebEidAuthToken token = replaceTokenField(AUTH_TOKEN, "X5C", NEW_MOBILE_ID_CERT); + + assertThatThrownBy(() -> validatorWithLegacyDenyList.validate(token, VALID_CHALLENGE_NONCE)) .isInstanceOf(UserCertificateDisallowedPolicyException.class); }