diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a7b10fed..13991d821 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ All notable changes to this project will be documented in this file. ## 4.1.0 +- Support additional JWT signature algorithms in `JwtSignatureValidator`. In addition to the previously supported `RS256`, tokens signed with the following RSA-based algorithms (RFC 7518 §3.3 / §3.5) can now be validated: + - `RS384`, `RS512` (RSASSA-PKCS1-v1_5 with SHA-384 / SHA-512) + - `PS256`, `PS384`, `PS512` (RSASSA-PSS with SHA-256 / SHA-384 / SHA-512). The corresponding `PSSParameterSpec` is set automatically before signature verification. + - Selection is driven by the JWT header `alg` value. Unknown values continue to be rejected with the existing "is not supported" error. - Update dependencies: - Spring Boot: 4.0.6 → 4.1.0 - Spring Framework: 7.0.7 → 7.0.8 diff --git a/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/JwtSignatureAlgorithm.java b/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/JwtSignatureAlgorithm.java index 4c6bf31f2..9c3073115 100644 --- a/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/JwtSignatureAlgorithm.java +++ b/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/JwtSignatureAlgorithm.java @@ -5,21 +5,50 @@ */ package com.sap.cloud.security.token.validation.validators; +import jakarta.annotation.Nullable; +import java.security.spec.AlgorithmParameterSpec; +import java.security.spec.MGF1ParameterSpec; +import java.security.spec.PSSParameterSpec; + /** - * This is represented by "kty" (Key Type) Parameter. https://www.rfc-editor.org/rfc/rfc7518.html#section-6.1 + * JWT signature algorithms supported during token validation, as specified in + * RFC 7518 §3.1. + *

+ * Each entry carries: + *

*/ public enum JwtSignatureAlgorithm { - RS256("RSA", "RS256", "SHA256withRSA")/* , ES256("EC", "ES256", "SHA256withECDSA")// Eliptic curve */; + + RS256("RSA", "RS256", "SHA256withRSA", null, 0), + RS384("RSA", "RS384", "SHA384withRSA", null, 0), + RS512("RSA", "RS512", "SHA512withRSA", null, 0), + + PS256("RSA", "PS256", "RSASSA-PSS", "SHA-256", 32), + PS384("RSA", "PS384", "RSASSA-PSS", "SHA-384", 48), + PS512("RSA", "PS512", "RSASSA-PSS", "SHA-512", 64); private final String type; private final String value; private final String javaSignatureAlgorithm; + @Nullable + private final AlgorithmParameterSpec parameterSpec; - JwtSignatureAlgorithm(String type, String algorithm, String javaSignatureAlgorithm) { + JwtSignatureAlgorithm(String type, String algorithm, String javaSignatureAlgorithm, + @Nullable String pssHashAlgorithm, int pssSaltLength) { this.type = type; this.value = algorithm; // jwks, jwt header this.javaSignatureAlgorithm = javaSignatureAlgorithm; + this.parameterSpec = pssHashAlgorithm == null + ? null + : new PSSParameterSpec(pssHashAlgorithm, "MGF1", + new MGF1ParameterSpec(pssHashAlgorithm), pssSaltLength, 1); } public String value() { @@ -34,6 +63,15 @@ public String type() { return type; } + /** + * @return additional JCA parameters required to verify a signature with this algorithm, or + * {@code null} if no parameters are needed (the default for everything except RSASSA-PSS). + */ + @Nullable + public AlgorithmParameterSpec parameterSpec() { + return parameterSpec; + } + public static JwtSignatureAlgorithm fromValue(String value) { for (JwtSignatureAlgorithm algorithm : values()) { if (algorithm.value.equals(value)) { @@ -43,12 +81,15 @@ public static JwtSignatureAlgorithm fromValue(String value) { return null; } + /** + * Returns the default algorithm for the given JWK key type. Used as a fallback when a JWK + * entry omits its {@code alg}. Falls back to {@link #RS256} for {@code RSA} keys; returns + * {@code null} for unknown types. + */ public static JwtSignatureAlgorithm fromType(String type) { - for (JwtSignatureAlgorithm algorithm : values()) { - if (algorithm.type.equals(type)) { - return algorithm; - } + if ("RSA".equals(type)) { + return RS256; } return null; } -} +} \ No newline at end of file diff --git a/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/JwtSignatureValidator.java b/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/JwtSignatureValidator.java index 90ed7c8f8..891b26434 100644 --- a/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/JwtSignatureValidator.java +++ b/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/JwtSignatureValidator.java @@ -14,6 +14,7 @@ import java.security.NoSuchAlgorithmException; import java.security.PublicKey; import java.security.Signature; +import java.security.spec.AlgorithmParameterSpec; import java.security.spec.InvalidKeySpecException; import java.util.Base64; @@ -102,6 +103,10 @@ protected ValidationResult validateSignature(Token token, PublicKey publicKey, J String headerAndPayload = tokenSections[0] + "." + tokenSections[1]; String signature = tokenSections[2]; try { + AlgorithmParameterSpec parameterSpec = algorithm.parameterSpec(); + if (parameterSpec != null) { + publicSignature.setParameter(parameterSpec); + } publicSignature.initVerify(publicKey); publicSignature.update(headerAndPayload.getBytes(UTF_8)); diff --git a/java-security/src/test/java/com/sap/cloud/security/token/validation/validators/JwtSignatureAlgorithmTest.java b/java-security/src/test/java/com/sap/cloud/security/token/validation/validators/JwtSignatureAlgorithmTest.java new file mode 100644 index 000000000..078923e4d --- /dev/null +++ b/java-security/src/test/java/com/sap/cloud/security/token/validation/validators/JwtSignatureAlgorithmTest.java @@ -0,0 +1,144 @@ +/** + * SPDX-FileCopyrightText: 2018-2023 SAP SE or an SAP affiliate company and Cloud Security Client Java contributors + *

+ * SPDX-License-Identifier: Apache-2.0 + */ +package com.sap.cloud.security.token.validation.validators; + +import com.sap.cloud.security.config.OAuth2ServiceConfiguration; +import com.sap.cloud.security.token.Token; +import com.sap.cloud.security.token.validation.ValidationResult; +import com.sap.cloud.security.xsuaa.client.OAuth2ServiceException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.Mockito; + +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PublicKey; +import java.security.Signature; +import java.security.spec.AlgorithmParameterSpec; +import java.util.Base64; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that {@link JwtSignatureValidator#validateSignature(Token, PublicKey, JwtSignatureAlgorithm)} + * accepts every algorithm declared in {@link JwtSignatureAlgorithm}. The test builds a real JWT-shaped + * input (header.payload.signature), signs it with a freshly generated key pair using the JCA algorithm + * the enum advertises, and feeds the result through {@code validateSignature}. + */ +class JwtSignatureAlgorithmTest { + + @Test + void enum_values_areTheExpectedSet() { + assertThat(JwtSignatureAlgorithm.values()) + .extracting(JwtSignatureAlgorithm::value) + .containsExactly("RS256", "RS384", "RS512", "PS256", "PS384", "PS512"); + } + + @Test + void fromValue_unknown_returnsNull() { + assertThat(JwtSignatureAlgorithm.fromValue("HS256")).isNull(); + } + + @Test + void fromType_rsa_returnsRs256() { + assertThat(JwtSignatureAlgorithm.fromType("RSA")).isEqualTo(JwtSignatureAlgorithm.RS256); + } + + @Test + void fromType_unknown_returnsNull() { + assertThat(JwtSignatureAlgorithm.fromType("EC")).isNull(); + assertThat(JwtSignatureAlgorithm.fromType("oct")).isNull(); + } + + @ParameterizedTest + @EnumSource(JwtSignatureAlgorithm.class) + void validateSignature_acceptsTokenSignedWithAlgorithm(JwtSignatureAlgorithm algorithm) throws Exception { + KeyPair keyPair = generateKeyPair(algorithm); + String signedToken = createSignedToken(algorithm, keyPair); + + Token token = Mockito.mock(Token.class); + Mockito.when(token.getTokenValue()).thenReturn(signedToken); + + ValidationResult result = new TestValidator(keyPair.getPublic()) + .validateSignature(token, keyPair.getPublic(), algorithm); + + assertThat(result.isValid()) + .as("validation should accept a token signed with %s", algorithm.value()) + .isTrue(); + } + + @ParameterizedTest + @EnumSource(JwtSignatureAlgorithm.class) + void validateSignature_rejectsTamperedToken(JwtSignatureAlgorithm algorithm) throws Exception { + KeyPair keyPair = generateKeyPair(algorithm); + String signedToken = createSignedToken(algorithm, keyPair); + // Flip a byte in the payload section to invalidate the signature + String[] parts = signedToken.split("\\."); + String tamperedPayload = parts[1].substring(0, parts[1].length() - 1) + + (parts[1].charAt(parts[1].length() - 1) == 'A' ? 'B' : 'A'); + String tampered = parts[0] + "." + tamperedPayload + "." + parts[2]; + + Token token = Mockito.mock(Token.class); + Mockito.when(token.getTokenValue()).thenReturn(tampered); + + ValidationResult result = new TestValidator(keyPair.getPublic()) + .validateSignature(token, keyPair.getPublic(), algorithm); + + assertThat(result.isValid()) + .as("tampered token must be rejected for %s", algorithm.value()) + .isFalse(); + } + + private static KeyPair generateKeyPair(JwtSignatureAlgorithm algorithm) throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance(algorithm.type()); + generator.initialize(2048); + return generator.generateKeyPair(); + } + + private static String createSignedToken(JwtSignatureAlgorithm algorithm, KeyPair keyPair) throws Exception { + String header = base64Url(("{\"alg\":\"" + algorithm.value() + "\",\"typ\":\"JWT\"}") + .getBytes(StandardCharsets.UTF_8)); + String payload = base64Url("{\"sub\":\"test\"}".getBytes(StandardCharsets.UTF_8)); + String signingInput = header + "." + payload; + + Signature signature = Signature.getInstance(algorithm.javaSignature()); + AlgorithmParameterSpec parameterSpec = algorithm.parameterSpec(); + if (parameterSpec != null) { + signature.setParameter(parameterSpec); + } + signature.initSign(keyPair.getPrivate()); + signature.update(signingInput.getBytes(StandardCharsets.UTF_8)); + byte[] signatureBytes = signature.sign(); + + return signingInput + "." + base64Url(signatureBytes); + } + + private static String base64Url(byte[] bytes) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + /** + * Concrete {@link JwtSignatureValidator} subclass that bypasses the JWKS lookup so we can + * exercise {@code validateSignature} in isolation. + */ + private static final class TestValidator extends JwtSignatureValidator { + private final PublicKey publicKey; + + TestValidator(PublicKey publicKey) { + super(Mockito.mock(OAuth2ServiceConfiguration.class), + Mockito.mock(OAuth2TokenKeyServiceWithCache.class), + Mockito.mock(OidcConfigurationServiceWithCache.class)); + this.publicKey = publicKey; + } + + @Override + protected PublicKey getPublicKey(Token token, JwtSignatureAlgorithm algorithm) throws OAuth2ServiceException { + return publicKey; + } + } +} \ No newline at end of file