diff --git a/src/main/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion.java b/src/main/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion.java new file mode 100644 index 00000000..bcf0cf35 --- /dev/null +++ b/src/main/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion.java @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2020-2025 Estonian Information System Authority + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package eu.webeid.security.validator.versionvalidators; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Utility for matching Web eID authentication token format version strings of the form + * {@code web-eid:[.]}, e.g. {@code web-eid:1} or {@code web-eid:1.1}. + */ +final class AuthTokenVersion { + + // Matches 'web-eid:' with an optional canonical '.', where both numbers have no leading + // zeros ('0' or '[1-9]\d*') and are at most 9 digits so that they always fit in an int. Non-canonical + // spellings such as 'web-eid:1.00' or 'web-eid:01' are rejected so that ambiguous version numbers cannot + // bypass the more specific validators. + private static final Pattern TOKEN_FORMAT_PATTERN = + Pattern.compile("^web-eid:(0|[1-9]\\d{0,8})(?:\\.(0|[1-9]\\d{0,8}))?$"); + + private AuthTokenVersion() { + throw new IllegalStateException("Utility class"); + } + + /** + * Returns whether the given token format has exactly the required major version and a minor version that + * is greater than or equal to the required minor version. A missing minor version is treated as 0. + * Backwards-compatible minor version changes are supported within the same major version, while an + * incompatible major version change is not. + */ + static boolean supports(String format, int requiredExactMajorVersion, int requiredMinimalMinorVersion) { + final Version version = parse(format); + return version != null + && version.major() == requiredExactMajorVersion + && version.minor() >= requiredMinimalMinorVersion; + } + + /** + * Returns whether the given token format has exactly the required major version and exactly the required + * minor version. A missing minor version is treated as 0. + */ + static boolean supportsExactly(String format, int requiredExactMajorVersion, int requiredExactMinorVersion) { + final Version version = parse(format); + return version != null + && version.major() == requiredExactMajorVersion + && version.minor() == requiredExactMinorVersion; + } + + private static Version parse(String format) { + if (format == null) { + return null; + } + final Matcher matcher = TOKEN_FORMAT_PATTERN.matcher(format); + if (!matcher.matches()) { + return null; + } + final int major = Integer.parseInt(matcher.group(1)); + final int minor = matcher.group(2) == null ? 0 : Integer.parseInt(matcher.group(2)); + return new Version(major, minor); + } + + private record Version(int major, int minor) { + } +} 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..5b4f91f8 100644 --- a/src/main/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion11Validator.java +++ b/src/main/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion11Validator.java @@ -54,13 +54,12 @@ import java.util.Arrays; import java.util.List; import java.util.Set; -import java.util.regex.Pattern; import static eu.webeid.security.util.Strings.isNullOrEmpty; class AuthTokenVersion11Validator extends AuthTokenVersion1Validator implements AuthTokenVersionValidator { - private static final Pattern V11_SUPPORTED_TOKEN_FORMAT_PATTERN = Pattern.compile("^web-eid:1\\.1$"); + private static final int SUPPORTED_MINIMAL_MINOR_VERSION = 1; private static final Set SUPPORTED_SIGNING_CRYPTO_ALGORITHMS = Set.of("ECC", "RSA"); private static final Set SUPPORTED_SIGNING_PADDING_SCHEMES = Set.of("NONE", "PKCS1.5", "PSS"); private static final Set SUPPORTED_SIGNING_HASH_FUNCTIONS = Set.of( @@ -95,8 +94,8 @@ public AuthTokenVersion11Validator( } @Override - protected Pattern getSupportedFormatPattern() { - return V11_SUPPORTED_TOKEN_FORMAT_PATTERN; + public boolean supports(String format) { + return AuthTokenVersion.supports(format, SUPPORTED_EXACT_MAJOR_VERSION, SUPPORTED_MINIMAL_MINOR_VERSION); } @Override @@ -140,14 +139,14 @@ private static List validateSigningCertificates(WebEidAuthToken List signingCertificates = token.getUnverifiedSigningCertificates(); if (signingCertificates == null || signingCertificates.isEmpty()) { - throw new AuthTokenParseException("'unverifiedSigningCertificates' field is missing, null or empty for format 'web-eid:1.1'"); + throw new AuthTokenParseException("'unverifiedSigningCertificates' field is missing, null or empty for format '" + token.getFormat() + "'"); } 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'"); + throw new AuthTokenParseException("'unverifiedSigningCertificates' contains a null or empty entry for format '" + token.getFormat() + "'"); } validateSupportedSignatureAlgorithms(certificate); result.add(CertificateLoader.decodeCertificateFromBase64(certificate.getCertificate())); 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..bdffae8a 100644 --- a/src/main/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion1Validator.java +++ b/src/main/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion1Validator.java @@ -36,12 +36,10 @@ import java.security.cert.TrustAnchor; import java.security.cert.X509Certificate; import java.util.Set; -import java.util.regex.Pattern; class AuthTokenVersion1Validator implements AuthTokenVersionValidator { - - private static final String V1_SUPPORTED_TOKEN_FORMAT_PREFIX = "web-eid:1"; - private static final Pattern V1_SUPPORTED_TOKEN_FORMAT_PATTERN = Pattern.compile("^web-eid:1(?:\\.\\d+)?$"); + static final int SUPPORTED_EXACT_MAJOR_VERSION = 1; + private static final int SUPPORTED_MINIMAL_MINOR_VERSION = 0; private final SubjectCertificateValidatorBatch simpleSubjectCertificateValidators; private final Set trustedCACertificateAnchors; private final CertStore trustedCACertificateCertStore; @@ -70,16 +68,12 @@ public AuthTokenVersion1Validator( @Override public boolean supports(String format) { - return format != null && getSupportedFormatPattern().matcher(format).matches(); - } - - protected Pattern getSupportedFormatPattern() { - return V1_SUPPORTED_TOKEN_FORMAT_PATTERN; + return AuthTokenVersion.supports(format, SUPPORTED_EXACT_MAJOR_VERSION, SUPPORTED_MINIMAL_MINOR_VERSION); } @Override public X509Certificate validate(WebEidAuthToken token, String currentChallengeNonce) throws AuthTokenException { - if (isExactV10Format(token.getFormat()) && token.getUnverifiedSigningCertificates() != null) { + if (AuthTokenVersion.supportsExactly(token.getFormat(), SUPPORTED_EXACT_MAJOR_VERSION, SUPPORTED_MINIMAL_MINOR_VERSION) && token.getUnverifiedSigningCertificates() != null) { throw new AuthTokenParseException( "'unverifiedSigningCertificates' field is not allowed for format '" + token.getFormat() + "'" ); @@ -112,8 +106,4 @@ public X509Certificate validate(WebEidAuthToken token, String currentChallengeNo return subjectCertificate; } - - private static boolean isExactV10Format(String format) { - return V1_SUPPORTED_TOKEN_FORMAT_PREFIX.equals(format) || "web-eid:1.0".equals(format); - } } 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..3d884d51 100644 --- a/src/test/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion11ValidatorTest.java +++ b/src/test/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion11ValidatorTest.java @@ -82,15 +82,15 @@ void setUp() { } @ParameterizedTest - @ValueSource(strings = {"web-eid:1.1"}) - void whenFormatIsV11_thenSupportsReturnsTrue(String format) { + @ValueSource(strings = {"web-eid:1.1", "web-eid:1.2", "web-eid:1.10", "web-eid:1.999"}) + void whenFormatIsV11OrHigherMinorVersion_thenSupportsReturnsTrue(String format) { assertThat(validator.supports(format)).isTrue(); } @ParameterizedTest @NullAndEmptySource - @ValueSource(strings = {"web-eid:1", "web-eid:1.0", "web-eid:1.1.0", "web-eid:1.10", "web-eid:1.2", "web-eid:2", "webauthn:1.1"}) - void whenFormatIsNullEmptyOrNotV11_thenSupportsReturnsFalse(String format) { + @ValueSource(strings = {"web-eid:1", "web-eid:1.0", "web-eid:1.", "web-eid:1.0TEST", "web-eid:1.00", "web-eid:1.1.0", "web-eid:2", "web-eid:0.9", "webauthn:1.1"}) + void whenFormatIsNullEmptyMinorVersion0NonCanonicalOrMalformed_thenSupportsReturnsFalse(String format) { assertThat(validator.supports(format)).isFalse(); } 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..bc5c8f9b 100644 --- a/src/test/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion1ValidatorTest.java +++ b/src/test/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersion1ValidatorTest.java @@ -61,14 +61,14 @@ class AuthTokenVersion1ValidatorTest { ); @ParameterizedTest - @ValueSource(strings = {"web-eid:1", "web-eid:1.0", "web-eid:1.1", "web-eid:1.10", "web-eid:1.999"}) + @ValueSource(strings = {"web-eid:1", "web-eid:1.0", "web-eid:1.1", "web-eid:1.2", "web-eid:1.10", "web-eid:1.999"}) void whenFormatIsValidMajorV1Format_thenSupportsReturnsTrue(String format) { assertThat(validator.supports(format)).isTrue(); } @ParameterizedTest @NullAndEmptySource - @ValueSource(strings = {"web-eid", "web-eid:1.", "web-eid:1.0TEST", "web-eid:1.1.0", "web-eid:0.9", "web-eid:2", "webauthn:1"}) + @ValueSource(strings = {"web-eid", "web-eid:1.", "web-eid:1.0TEST", "web-eid:1.00", "web-eid:1.000", "web-eid:01", "web-eid:1.1.0", "web-eid:0.9", "web-eid:2", "webauthn:1"}) void whenFormatIsNullEmptyOrMalformedOrNotV1_thenSupportsReturnsFalse(String format) { assertThat(validator.supports(format)).isFalse(); } diff --git a/src/test/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersionTest.java b/src/test/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersionTest.java new file mode 100644 index 00000000..68531ece --- /dev/null +++ b/src/test/java/eu/webeid/security/validator/versionvalidators/AuthTokenVersionTest.java @@ -0,0 +1,87 @@ +/* + * 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.versionvalidators; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class AuthTokenVersionTest { + + @ParameterizedTest + @CsvSource({ + "web-eid:1, 1, 0, true", + "web-eid:1.0, 1, 0, true", + "web-eid:1.1, 1, 0, true", + "web-eid:1.1, 1, 1, true", + "web-eid:1.999, 1, 1, true", + "web-eid:2.0, 2, 0, true", + "web-eid:2.3, 2, 1, true", + "web-eid:1.0, 1, 1, false", + "web-eid:1, 1, 1, false", + "web-eid:2, 1, 0, false", + "web-eid:1.5, 2, 0, false", + "web-eid:1.00, 1, 0, false", + "web-eid:1.000, 1, 0, false", + "web-eid:01, 1, 0, false", + "web-eid:1., 1, 0, false", + "web-eid:1.1.0, 1, 0, false", + "web-eid:0.9, 1, 0, false", + "webauthn:1, 1, 0, false" + }) + void whenFormatMatchesRequiredMajorAndAtLeastRequiredMinor_thenSupportsReturnsExpected( + String format, int requiredMajorVersion, int requiredMinorVersion, boolean expected) { + assertThat(AuthTokenVersion.supports(format, requiredMajorVersion, requiredMinorVersion)).isEqualTo(expected); + } + + @Test + void whenFormatIsNull_thenSupportsReturnsFalse() { + assertThat(AuthTokenVersion.supports(null, 1, 0)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ + "web-eid:1, 1, 0, true", + "web-eid:1.0, 1, 0, true", + "web-eid:1.1, 1, 1, true", + "web-eid:2.0, 2, 0, true", + "web-eid:1.1, 1, 0, false", + "web-eid:1.2, 1, 1, false", + "web-eid:1.0, 1, 1, false", + "web-eid:1, 2, 0, false", + "web-eid:1.00, 1, 0, false", + "web-eid:01, 1, 0, false", + "webauthn:1, 1, 0, false" + }) + void whenFormatMatchesRequiredMajorAndExactMinor_thenSupportsExactlyReturnsExpected( + String format, int requiredMajorVersion, int requiredMinorVersion, boolean expected) { + assertThat(AuthTokenVersion.supportsExactly(format, requiredMajorVersion, requiredMinorVersion)).isEqualTo(expected); + } + + @Test + void whenFormatIsNull_thenSupportsExactlyReturnsFalse() { + assertThat(AuthTokenVersion.supportsExactly(null, 1, 0)).isFalse(); + } +}