From 304cc215235bd9b6d03daca98c5c3c43ee773a96 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:47:19 -0600 Subject: [PATCH 01/17] Add Tests for User and Device Code Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com> --- ...MemoryOAuth2AuthorizationServiceTests.java | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationServiceTests.java b/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationServiceTests.java index ad74c3fb89..4af45f11e8 100644 --- a/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationServiceTests.java +++ b/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationServiceTests.java @@ -25,7 +25,9 @@ import org.junit.jupiter.api.Test; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2DeviceCode; import org.springframework.security.oauth2.core.OAuth2RefreshToken; +import org.springframework.security.oauth2.core.OAuth2UserCode; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; import org.springframework.security.oauth2.core.oidc.OidcIdToken; import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames; @@ -60,6 +62,10 @@ public class InMemoryOAuth2AuthorizationServiceTests { private static final OAuth2TokenType ID_TOKEN_TOKEN_TYPE = new OAuth2TokenType(OidcParameterNames.ID_TOKEN); + private static final OAuth2TokenType DEVICE_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.DEVICE_CODE); + + private static final OAuth2TokenType USER_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.USER_CODE); + private InMemoryOAuth2AuthorizationService authorizationService; @BeforeEach @@ -313,6 +319,44 @@ public class InMemoryOAuth2AuthorizationServiceTests { assertThat(authorization).isEqualTo(result); } + @Test + public void findByTokenWhenDeviceCodeExistsThenFound() { + OAuth2DeviceCode deviceCode = new OAuth2DeviceCode("device-code", Instant.now(), + Instant.now().plus(5, ChronoUnit.MINUTES)); + OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT) + .id(ID) + .principalName(PRINCIPAL_NAME) + .authorizationGrantType(AUTHORIZATION_GRANT_TYPE) + .token(deviceCode) + .build(); + this.authorizationService.save(authorization); + + OAuth2Authorization result = this.authorizationService.findByToken(deviceCode.getTokenValue(), + DEVICE_CODE_TOKEN_TYPE); + assertThat(authorization).isEqualTo(result); + result = this.authorizationService.findByToken(deviceCode.getTokenValue(), null); + assertThat(authorization).isEqualTo(result); + } + + @Test + public void findByTokenWhenUserCodeExistsThenFound() { + OAuth2UserCode userCode = new OAuth2UserCode("user-code", Instant.now(), + Instant.now().plus(5, ChronoUnit.MINUTES)); + OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT) + .id(ID) + .principalName(PRINCIPAL_NAME) + .authorizationGrantType(AUTHORIZATION_GRANT_TYPE) + .token(userCode) + .build(); + this.authorizationService.save(authorization); + + OAuth2Authorization result = this.authorizationService.findByToken(userCode.getTokenValue(), + USER_CODE_TOKEN_TYPE); + assertThat(authorization).isEqualTo(result); + result = this.authorizationService.findByToken(userCode.getTokenValue(), null); + assertThat(authorization).isEqualTo(result); + } + @Test public void findByTokenWhenWrongTokenTypeThenNotFound() { OAuth2RefreshToken refreshToken = new OAuth2RefreshToken("refresh-token", Instant.now()); From 8830e39073294d5d2b8e580f90632a2b54d7d142 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:48:43 -0600 Subject: [PATCH 02/17] Use MessageDigest#isEqual This commit favors constant-time comparison to mitigate timing attacks Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com> --- .../InMemoryOAuth2AuthorizationService.java | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationService.java b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationService.java index 1ec19f897c..d8cac347d2 100644 --- a/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationService.java +++ b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationService.java @@ -16,6 +16,8 @@ package org.springframework.security.oauth2.server.authorization; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; @@ -192,38 +194,43 @@ public final class InMemoryOAuth2AuthorizationService implements OAuth2Authoriza } private static boolean matchesState(OAuth2Authorization authorization, String token) { - return token.equals(authorization.getAttribute(OAuth2ParameterNames.STATE)); + return isEqual(authorization.getAttribute(OAuth2ParameterNames.STATE), token); } private static boolean matchesAuthorizationCode(OAuth2Authorization authorization, String token) { OAuth2Authorization.Token authorizationCode = authorization .getToken(OAuth2AuthorizationCode.class); - return authorizationCode != null && authorizationCode.getToken().getTokenValue().equals(token); + return authorizationCode != null && isEqual(authorizationCode.getToken().getTokenValue(), token); } private static boolean matchesAccessToken(OAuth2Authorization authorization, String token) { OAuth2Authorization.Token accessToken = authorization.getToken(OAuth2AccessToken.class); - return accessToken != null && accessToken.getToken().getTokenValue().equals(token); + return accessToken != null && isEqual(accessToken.getToken().getTokenValue(), token); } private static boolean matchesRefreshToken(OAuth2Authorization authorization, String token) { OAuth2Authorization.Token refreshToken = authorization.getToken(OAuth2RefreshToken.class); - return refreshToken != null && refreshToken.getToken().getTokenValue().equals(token); + return refreshToken != null && isEqual(refreshToken.getToken().getTokenValue(), token); } private static boolean matchesIdToken(OAuth2Authorization authorization, String token) { OAuth2Authorization.Token idToken = authorization.getToken(OidcIdToken.class); - return idToken != null && idToken.getToken().getTokenValue().equals(token); + return idToken != null && isEqual(idToken.getToken().getTokenValue(), token); } private static boolean matchesDeviceCode(OAuth2Authorization authorization, String token) { OAuth2Authorization.Token deviceCode = authorization.getToken(OAuth2DeviceCode.class); - return deviceCode != null && deviceCode.getToken().getTokenValue().equals(token); + return deviceCode != null && isEqual(deviceCode.getToken().getTokenValue(), token); } private static boolean matchesUserCode(OAuth2Authorization authorization, String token) { OAuth2Authorization.Token userCode = authorization.getToken(OAuth2UserCode.class); - return userCode != null && userCode.getToken().getTokenValue().equals(token); + return userCode != null && isEqual(userCode.getToken().getTokenValue(), token); + } + + private static boolean isEqual(@Nullable String left, @Nullable String right) { + return left != null && right != null + && MessageDigest.isEqual(left.getBytes(StandardCharsets.UTF_8), right.getBytes(StandardCharsets.UTF_8)); } @SuppressWarnings("serial") From f8a61e66d10794836c22ed0e214a0f7a9f255eee Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:10:47 -0600 Subject: [PATCH 03/17] Add Utf8#isEqual This commit adds a constant-time equals method, useful for comparing password hashes or other sensitive material Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com> --- .../security/crypto/codec/Utf8.java | 20 +++++++++ .../crypto/password/PasswordEncoderUtils.java | 12 +---- .../security/crypto/codec/Utf8Tests.java | 44 +++++++++++++++++++ 3 files changed, 65 insertions(+), 11 deletions(-) diff --git a/crypto/src/main/java/org/springframework/security/crypto/codec/Utf8.java b/crypto/src/main/java/org/springframework/security/crypto/codec/Utf8.java index 543fdfd915..9800662817 100644 --- a/crypto/src/main/java/org/springframework/security/crypto/codec/Utf8.java +++ b/crypto/src/main/java/org/springframework/security/crypto/codec/Utf8.java @@ -21,6 +21,9 @@ import java.nio.CharBuffer; import java.nio.charset.CharacterCodingException; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +import org.jspecify.annotations.Nullable; /** * UTF-8 Charset encoder/decoder. @@ -66,4 +69,21 @@ public final class Utf8 { } } + /** + * Constant time comparison to prevent against timing attacks. + * @param expected the expected {@link CharSequence} + * @param actual the actual {@link CharSequence} + * @return true if {@code expected} and {@code actual} are equal, false otherwise + * @since 5.7.26 + */ + public static boolean isEqual(@Nullable CharSequence expected, @Nullable CharSequence actual) { + byte[] expectedBytes = bytesUtf8(expected); + byte[] actualBytes = bytesUtf8(actual); + return MessageDigest.isEqual(expectedBytes, actualBytes); + } + + private static byte @Nullable [] bytesUtf8(@Nullable CharSequence s) { + return (s != null) ? Utf8.encode(s) : null; + } + } diff --git a/crypto/src/main/java/org/springframework/security/crypto/password/PasswordEncoderUtils.java b/crypto/src/main/java/org/springframework/security/crypto/password/PasswordEncoderUtils.java index b91456689c..3175ff4ea5 100644 --- a/crypto/src/main/java/org/springframework/security/crypto/password/PasswordEncoderUtils.java +++ b/crypto/src/main/java/org/springframework/security/crypto/password/PasswordEncoderUtils.java @@ -16,8 +16,6 @@ package org.springframework.security.crypto.password; -import java.security.MessageDigest; - import org.jspecify.annotations.Nullable; import org.springframework.security.crypto.codec.Utf8; @@ -39,15 +37,7 @@ final class PasswordEncoderUtils { * @return */ static boolean equals(String expected, @Nullable String actual) { - byte[] expectedBytes = bytesUtf8(expected); - byte[] actualBytes = bytesUtf8(actual); - return MessageDigest.isEqual(expectedBytes, actualBytes); - } - - private static byte @Nullable [] bytesUtf8(@Nullable String s) { - // need to check if Utf8.encode() runs in constant time (probably not). - // This may leak length of string. - return (s != null) ? Utf8.encode(s) : null; + return Utf8.isEqual(expected, actual); } } diff --git a/crypto/src/test/java/org/springframework/security/crypto/codec/Utf8Tests.java b/crypto/src/test/java/org/springframework/security/crypto/codec/Utf8Tests.java index a7eb0c487a..6440e8f30c 100644 --- a/crypto/src/test/java/org/springframework/security/crypto/codec/Utf8Tests.java +++ b/crypto/src/test/java/org/springframework/security/crypto/codec/Utf8Tests.java @@ -37,4 +37,48 @@ public class Utf8Tests { assertThat(decoded).isEqualTo("6048b75ed560785c"); } + @Test + public void isEqualWhenDifferentLengthThenFalse() { + assertThat(Utf8.isEqual("abc", "a")).isFalse(); + assertThat(Utf8.isEqual("a", "abc")).isFalse(); + } + + @Test + public void isEqualWhenNullAndNotEmptyThenFalse() { + assertThat(Utf8.isEqual(null, "a")).isFalse(); + assertThat(Utf8.isEqual("a", null)).isFalse(); + } + + @Test + public void isEqualWhenNullAndNullThenTrue() { + assertThat(Utf8.isEqual(null, null)).isTrue(); + } + + @Test + public void isEqualWhenNullAndEmptyThenFalse() { + assertThat(Utf8.isEqual(null, "")).isFalse(); + assertThat(Utf8.isEqual("", null)).isFalse(); + } + + @Test + public void isEqualWhenNotEmptyAndEmptyThenFalse() { + assertThat(Utf8.isEqual("abc", "")).isFalse(); + assertThat(Utf8.isEqual("", "abc")).isFalse(); + } + + @Test + public void isEqualWhenEmptyAndEmptyThenTrue() { + assertThat(Utf8.isEqual("", "")).isTrue(); + } + + @Test + public void isEqualWhenDifferentCaseThenFalse() { + assertThat(Utf8.isEqual("aBc", "abc")).isFalse(); + } + + @Test + public void isEqualWhenSameThenTrue() { + assertThat(Utf8.isEqual("abcdef", "abcdef")).isTrue(); + } + } From 475af0d3dbc67e27878616e03c7bf00cb1bc8269 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:11:47 -0600 Subject: [PATCH 04/17] Use Constant-Time Equals This commit updates the filter to use constant-time equals for sensitive material Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com> --- .../web/authentication/www/DigestAuthenticationFilter.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/web/src/main/java/org/springframework/security/web/authentication/www/DigestAuthenticationFilter.java b/web/src/main/java/org/springframework/security/web/authentication/www/DigestAuthenticationFilter.java index 24c9d02f54..50a8332ce0 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/www/DigestAuthenticationFilter.java +++ b/web/src/main/java/org/springframework/security/web/authentication/www/DigestAuthenticationFilter.java @@ -51,6 +51,7 @@ import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.core.userdetails.cache.NullUserCache; +import org.springframework.security.crypto.codec.Utf8; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.security.web.context.RequestAttributeSecurityContextRepository; import org.springframework.security.web.context.SecurityContextRepository; @@ -167,7 +168,7 @@ public class DigestAuthenticationFilter extends GenericFilterBean implements Mes } serverDigestMd5 = digestAuth.calculateServerDigest(user.getPassword(), request.getMethod()); // If digest is incorrect, try refreshing from backend and recomputing - if (!serverDigestMd5.equals(digestAuth.getResponse()) && cacheWasUsed) { + if (!Utf8.isEqual(serverDigestMd5, digestAuth.getResponse()) && cacheWasUsed) { logger.debug("Digest comparison failure; trying to refresh user from DAO in case password had changed"); user = this.userDetailsService.loadUserByUsername(username); this.userCache.putUserInCache(user); @@ -181,7 +182,7 @@ public class DigestAuthenticationFilter extends GenericFilterBean implements Mes return; } // If digest is still incorrect, definitely reject authentication attempt - if (!serverDigestMd5.equals(digestAuth.getResponse())) { + if (!Utf8.isEqual(serverDigestMd5, digestAuth.getResponse())) { logger.debug(LogMessage.format( "Expected response: '%s' but received: '%s'; is AuthenticationDao returning clear text passwords?", serverDigestMd5, digestAuth.getResponse())); @@ -405,7 +406,7 @@ public class DigestAuthenticationFilter extends GenericFilterBean implements Mes } // Check signature of nonce matches this expiry time String expectedNonceSignature = DigestAuthUtils.md5Hex(this.nonceExpiryTime + ":" + entryPointKey); - if (!expectedNonceSignature.equals(nonceTokens[1])) { + if (!Utf8.isEqual(expectedNonceSignature, nonceTokens[1])) { throw new BadCredentialsException(DigestAuthenticationFilter.this.messages.getMessage( "DigestAuthenticationFilter.nonceCompromised", new Object[] { nonceAsPlainText }, "Nonce token compromised {0}")); From bbc6273abf60c6ba6f030bf6e15c69dd3d1a8d7e Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:15:10 -0600 Subject: [PATCH 05/17] Use Constant-Time Equals This commit updates the filter to use constant-time equals for sensitive material Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com> --- .../security/core/token/KeyBasedPersistenceTokenService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/java/org/springframework/security/core/token/KeyBasedPersistenceTokenService.java b/core/src/main/java/org/springframework/security/core/token/KeyBasedPersistenceTokenService.java index 05d6cef447..8f214b0147 100644 --- a/core/src/main/java/org/springframework/security/core/token/KeyBasedPersistenceTokenService.java +++ b/core/src/main/java/org/springframework/security/core/token/KeyBasedPersistenceTokenService.java @@ -132,7 +132,7 @@ public class KeyBasedPersistenceTokenService implements TokenService, Initializi // Verification String content = creationTime + ":" + pseudoRandomNumber + ":" + extendedInfo.toString(); String expectedSha512Hex = Sha512DigestUtils.shaHex(content + ":" + serverSecret); - Assert.isTrue(expectedSha512Hex.equals(sha1Hex), "Key verification failure"); + Assert.isTrue(Utf8.isEqual(expectedSha512Hex, sha1Hex), "Key verification failure"); return new DefaultToken(key, creationTime, extendedInfo.toString()); } From ae997a4f461f9014acdc8824a51cd23a99512c00 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:11:26 -0600 Subject: [PATCH 06/17] Use Utf8#isEqual Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com> --- .../InMemoryOAuth2AuthorizationService.java | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationService.java b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationService.java index d8cac347d2..b5302db940 100644 --- a/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationService.java +++ b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationService.java @@ -16,8 +16,6 @@ package org.springframework.security.oauth2.server.authorization; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; @@ -26,6 +24,7 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import org.springframework.lang.Nullable; +import org.springframework.security.crypto.codec.Utf8; import org.springframework.security.oauth2.core.OAuth2AccessToken; import org.springframework.security.oauth2.core.OAuth2DeviceCode; import org.springframework.security.oauth2.core.OAuth2RefreshToken; @@ -194,43 +193,38 @@ public final class InMemoryOAuth2AuthorizationService implements OAuth2Authoriza } private static boolean matchesState(OAuth2Authorization authorization, String token) { - return isEqual(authorization.getAttribute(OAuth2ParameterNames.STATE), token); + return Utf8.isEqual(authorization.getAttribute(OAuth2ParameterNames.STATE), token); } private static boolean matchesAuthorizationCode(OAuth2Authorization authorization, String token) { OAuth2Authorization.Token authorizationCode = authorization .getToken(OAuth2AuthorizationCode.class); - return authorizationCode != null && isEqual(authorizationCode.getToken().getTokenValue(), token); + return authorizationCode != null && Utf8.isEqual(authorizationCode.getToken().getTokenValue(), token); } private static boolean matchesAccessToken(OAuth2Authorization authorization, String token) { OAuth2Authorization.Token accessToken = authorization.getToken(OAuth2AccessToken.class); - return accessToken != null && isEqual(accessToken.getToken().getTokenValue(), token); + return accessToken != null && Utf8.isEqual(accessToken.getToken().getTokenValue(), token); } private static boolean matchesRefreshToken(OAuth2Authorization authorization, String token) { OAuth2Authorization.Token refreshToken = authorization.getToken(OAuth2RefreshToken.class); - return refreshToken != null && isEqual(refreshToken.getToken().getTokenValue(), token); + return refreshToken != null && Utf8.isEqual(refreshToken.getToken().getTokenValue(), token); } private static boolean matchesIdToken(OAuth2Authorization authorization, String token) { OAuth2Authorization.Token idToken = authorization.getToken(OidcIdToken.class); - return idToken != null && isEqual(idToken.getToken().getTokenValue(), token); + return idToken != null && Utf8.isEqual(idToken.getToken().getTokenValue(), token); } private static boolean matchesDeviceCode(OAuth2Authorization authorization, String token) { OAuth2Authorization.Token deviceCode = authorization.getToken(OAuth2DeviceCode.class); - return deviceCode != null && isEqual(deviceCode.getToken().getTokenValue(), token); + return deviceCode != null && Utf8.isEqual(deviceCode.getToken().getTokenValue(), token); } private static boolean matchesUserCode(OAuth2Authorization authorization, String token) { OAuth2Authorization.Token userCode = authorization.getToken(OAuth2UserCode.class); - return userCode != null && isEqual(userCode.getToken().getTokenValue(), token); - } - - private static boolean isEqual(@Nullable String left, @Nullable String right) { - return left != null && right != null - && MessageDigest.isEqual(left.getBytes(StandardCharsets.UTF_8), right.getBytes(StandardCharsets.UTF_8)); + return userCode != null && Utf8.isEqual(userCode.getToken().getTokenValue(), token); } @SuppressWarnings("serial") From d8769fb183b598de2f600672b125cc7d5a795949 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:16:12 -0600 Subject: [PATCH 07/17] Use Constant-Time Comparison This commit updates password encoders to use a constant-time comparison method to defend against timing attacks Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com> --- .../password4j/BalloonHashingPassword4jPasswordEncoder.java | 3 ++- .../crypto/password4j/Pbkdf2Password4jPasswordEncoder.java | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/crypto/src/main/java/org/springframework/security/crypto/password4j/BalloonHashingPassword4jPasswordEncoder.java b/crypto/src/main/java/org/springframework/security/crypto/password4j/BalloonHashingPassword4jPasswordEncoder.java index 54735f19b2..8fa55069ba 100644 --- a/crypto/src/main/java/org/springframework/security/crypto/password4j/BalloonHashingPassword4jPasswordEncoder.java +++ b/crypto/src/main/java/org/springframework/security/crypto/password4j/BalloonHashingPassword4jPasswordEncoder.java @@ -24,6 +24,7 @@ import com.password4j.BalloonHashingFunction; import com.password4j.Hash; import com.password4j.Password; +import org.springframework.security.crypto.codec.Utf8; import org.springframework.security.crypto.password.AbstractValidatingPasswordEncoder; import org.springframework.util.Assert; @@ -140,7 +141,7 @@ public class BalloonHashingPassword4jPasswordEncoder extends AbstractValidatingP String expectedHash = parts[1]; Hash hash = Password.hash(rawPassword).addSalt(salt).with(this.balloonHashingFunction); - return expectedHash.equals(hash.getResult()); + return Utf8.isEqual(expectedHash, hash.getResult()); } catch (IllegalArgumentException ex) { // Invalid Base64 encoding diff --git a/crypto/src/main/java/org/springframework/security/crypto/password4j/Pbkdf2Password4jPasswordEncoder.java b/crypto/src/main/java/org/springframework/security/crypto/password4j/Pbkdf2Password4jPasswordEncoder.java index 65fbaa98e9..37efbb27c4 100644 --- a/crypto/src/main/java/org/springframework/security/crypto/password4j/Pbkdf2Password4jPasswordEncoder.java +++ b/crypto/src/main/java/org/springframework/security/crypto/password4j/Pbkdf2Password4jPasswordEncoder.java @@ -24,6 +24,7 @@ import com.password4j.Hash; import com.password4j.PBKDF2Function; import com.password4j.Password; +import org.springframework.security.crypto.codec.Utf8; import org.springframework.security.crypto.password.AbstractValidatingPasswordEncoder; import org.springframework.util.Assert; @@ -138,7 +139,7 @@ public class Pbkdf2Password4jPasswordEncoder extends AbstractValidatingPasswordE String expectedHash = parts[1]; Hash hash = Password.hash(rawPassword).addSalt(salt).with(this.pbkdf2Function); - return expectedHash.equals(hash.getResult()); + return Utf8.isEqual(expectedHash, hash.getResult()); } catch (IllegalArgumentException ex) { // Invalid Base64 encoding From ad812aefc449f1f0aeeb597864fa28d36198e398 Mon Sep 17 00:00:00 2001 From: Joe Grandja <10884212+jgrandja@users.noreply.github.com> Date: Thu, 7 May 2026 06:29:43 -0400 Subject: [PATCH 08/17] Provide ability to configure DPoP proof replay --- .../DPoPAuthenticationConfigurer.java | 4 +- .../OAuth2ResourceServerConfigurer.java | 3 + .../DPoPAuthenticationConfigurerTests.java | 33 +- .../core/DelegatingOAuth2TokenValidator.java | 14 + .../jwt/DPoPProofJwtDecoderFactory.java | 178 +++++-- .../oauth2/jwt/DPoPProofReplayValidator.java | 446 ++++++++++++++++++ .../DPoPAuthenticationProvider.java | 127 +---- 7 files changed, 642 insertions(+), 163 deletions(-) create mode 100644 oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/DPoPProofReplayValidator.java diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/DPoPAuthenticationConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/DPoPAuthenticationConfigurer.java index 207e221cbe..73d6961837 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/DPoPAuthenticationConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/DPoPAuthenticationConfigurer.java @@ -79,8 +79,10 @@ final class DPoPAuthenticationConfigurer> @Override public void configure(B http) { + DPoPAuthenticationProvider authenticationProvider = new DPoPAuthenticationProvider( + getTokenAuthenticationManager(http)); + http.authenticationProvider(postProcess(authenticationProvider)); AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class); - http.authenticationProvider(new DPoPAuthenticationProvider(getTokenAuthenticationManager(http))); AuthenticationFilter authenticationFilter = new AuthenticationFilter(authenticationManager, getAuthenticationConverter()); authenticationFilter.setRequestMatcher(getRequestMatcher()); diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java index c830013461..ed0f98205e 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java @@ -33,6 +33,7 @@ import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationManagerResolver; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.config.Customizer; +import org.springframework.security.config.ObjectPostProcessor; import org.springframework.security.config.annotation.web.HttpSecurityBuilder; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; @@ -298,6 +299,8 @@ public final class OAuth2ResourceServerConfigurer dPoPAuthenticationConfigurer = new DPoPAuthenticationConfigurer<>(); + dPoPAuthenticationConfigurer.withObjectPostProcessor( + (ObjectPostProcessor) OAuth2ResourceServerConfigurer.this::postProcess); dPoPAuthenticationConfigurer.configure(http); } diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/DPoPAuthenticationConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/DPoPAuthenticationConfigurerTests.java index e597406c0d..a8d8930c44 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/DPoPAuthenticationConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/DPoPAuthenticationConfigurerTests.java @@ -22,6 +22,7 @@ import java.security.interfaces.ECPrivateKey; import java.security.interfaces.ECPublicKey; import java.security.interfaces.RSAPrivateKey; import java.security.interfaces.RSAPublicKey; +import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Base64; @@ -30,6 +31,7 @@ import java.util.HashMap; import java.util.Map; import java.util.Set; import java.util.UUID; +import java.util.function.Function; import com.nimbusds.jose.jwk.ECKey; import com.nimbusds.jose.jwk.JWK; @@ -47,20 +49,26 @@ import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.security.config.Customizer; +import org.springframework.security.config.ObjectPostProcessor; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.test.SpringTestContext; import org.springframework.security.config.test.SpringTestContextExtension; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; import org.springframework.security.oauth2.jose.TestJwks; import org.springframework.security.oauth2.jose.TestKeys; import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.DPoPProofContext; +import org.springframework.security.oauth2.jwt.DPoPProofJwtDecoderFactory; +import org.springframework.security.oauth2.jwt.DPoPProofReplayValidator; import org.springframework.security.oauth2.jwt.JwsHeader; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtClaimsSet; import org.springframework.security.oauth2.jwt.JwtEncoderParameters; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; +import org.springframework.security.oauth2.server.resource.authentication.DPoPAuthenticationProvider; import org.springframework.security.web.SecurityFilterChain; import org.springframework.test.web.servlet.MockMvc; import org.springframework.web.bind.annotation.RequestMapping; @@ -245,8 +253,10 @@ public class DPoPAuthenticationConfigurerTests { .requestMatchers("/resource2").hasAnyAuthority("SCOPE_resource2.read", "SCOPE_resource2.write") .anyRequest().authenticated() ) - .oauth2ResourceServer((oauth2) -> oauth2 - .jwt(Customizer.withDefaults())); + .oauth2ResourceServer((oauth2ResourceServer) -> + oauth2ResourceServer + .jwt(Customizer.withDefaults()) + .withObjectPostProcessor(dPoPProofVerifierFactoryCustomizer())); // @formatter:on return http.build(); } @@ -256,6 +266,25 @@ public class DPoPAuthenticationConfigurerTests { return NimbusJwtDecoder.withPublicKey(PROVIDER_RSA_PUBLIC_KEY).build(); } + private ObjectPostProcessor dPoPProofVerifierFactoryCustomizer() { + return new ObjectPostProcessor<>() { + @Override + public O postProcess(O authenticationProvider) { + DPoPProofReplayValidator.InMemoryCache inMemoryCache = new DPoPProofReplayValidator.InMemoryCache(); + inMemoryCache.setMaxSize(50_000); + inMemoryCache.setMaxRequestsPerKey(500); + DPoPProofReplayValidator dPoPProofReplayValidator = new DPoPProofReplayValidator(inMemoryCache); + dPoPProofReplayValidator.setClockSkew(Duration.ofSeconds(60)); + Function> jwtValidatorFactory = DPoPProofJwtDecoderFactory + .createDefaultJwtValidatorFactory(Collections.singletonList(dPoPProofReplayValidator)); + DPoPProofJwtDecoderFactory dPoPProofJwtDecoderFactory = new DPoPProofJwtDecoderFactory(); + dPoPProofJwtDecoderFactory.setJwtValidatorFactory(jwtValidatorFactory); + authenticationProvider.setDPoPProofVerifierFactory(dPoPProofJwtDecoderFactory); + return authenticationProvider; + } + }; + } + } @RestController diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/DelegatingOAuth2TokenValidator.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/DelegatingOAuth2TokenValidator.java index 17a7cd0896..3357ae5f72 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/DelegatingOAuth2TokenValidator.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/DelegatingOAuth2TokenValidator.java @@ -33,6 +33,8 @@ public final class DelegatingOAuth2TokenValidator impleme private final Collection> tokenValidators; + private boolean failOnError; + /** * Constructs a {@code DelegatingOAuth2TokenValidator} using the provided validators. * @param tokenValidators the {@link Collection} of {@link OAuth2TokenValidator}s to @@ -57,8 +59,20 @@ public final class DelegatingOAuth2TokenValidator impleme Collection errors = new ArrayList<>(); for (OAuth2TokenValidator validator : this.tokenValidators) { errors.addAll(validator.validate(token).getErrors()); + if (!errors.isEmpty() && this.failOnError) { + return OAuth2TokenValidatorResult.failure(errors); + } } return OAuth2TokenValidatorResult.failure(errors); } + /** + * Fail-fast when a delegate errors, defaults to {@code false}. + * @param failOnError fail-fast when a delegate errors + * @since 6.5.12 + */ + public void setFailOnError(boolean failOnError) { + this.failOnError = failOnError; + } + } diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/DPoPProofJwtDecoderFactory.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/DPoPProofJwtDecoderFactory.java index de88ba57ae..fd648a9d40 100644 --- a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/DPoPProofJwtDecoderFactory.java +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/DPoPProofJwtDecoderFactory.java @@ -18,11 +18,11 @@ package org.springframework.security.oauth2.jwt; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; -import java.time.Instant; -import java.time.temporal.ChronoUnit; +import java.time.Duration; +import java.util.ArrayList; import java.util.Base64; import java.util.Collections; -import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.function.Function; @@ -39,12 +39,15 @@ import com.nimbusds.jose.proc.SecurityContext; import com.nimbusds.jwt.proc.ConfigurableJWTProcessor; import com.nimbusds.jwt.proc.DefaultJWTProcessor; +import org.springframework.security.oauth2.core.ClaimAccessor; import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.OAuth2Token; import org.springframework.security.oauth2.core.OAuth2TokenValidator; import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; /** @@ -63,10 +66,11 @@ public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory} factory that validates the - * {@code htm}, {@code htu}, {@code jti} and {@code iat} claims of the DPoP Proof - * {@link Jwt}. + * {@code htm}, {@code htu}, {@code iat}, {@code jkt}, {@code ath} and {@code jti} + * claims of the DPoP Proof {@link Jwt}. */ - public static final Function> DEFAULT_JWT_VALIDATOR_FACTORY = defaultJwtValidatorFactory(); + public static final Function> DEFAULT_JWT_VALIDATOR_FACTORY = createDefaultJwtValidatorFactory( + Collections.emptyList()); private static final JOSEObjectTypeVerifier DPOP_TYPE_VERIFIER = new DefaultJOSEObjectTypeVerifier<>( new JOSEObjectType("dpop+jwt")); @@ -94,6 +98,70 @@ public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory> createDefaultJwtValidatorFactory( + List> validators) { + Assert.notNull(validators, "validators cannot be null"); + List> customValidators = new ArrayList<>(); + if (!CollectionUtils.isEmpty(validators)) { + customValidators.addAll(validators); + } + final Duration clockSkew = Duration.ofSeconds(30); + final JwtIssuedAtValidator jwtIssuedAtValidator; + if (CollectionUtils.findValueOfType(customValidators, JwtIssuedAtValidator.class) != null) { + jwtIssuedAtValidator = CollectionUtils.findValueOfType(customValidators, JwtIssuedAtValidator.class); + customValidators.remove(jwtIssuedAtValidator); + } + else { + jwtIssuedAtValidator = new JwtIssuedAtValidator(true); + jwtIssuedAtValidator.setClockSkew(clockSkew); + } + final DPoPProofReplayValidator dPoPProofReplayValidator; + if (CollectionUtils.findValueOfType(customValidators, DPoPProofReplayValidator.class) != null) { + dPoPProofReplayValidator = CollectionUtils.findValueOfType(customValidators, + DPoPProofReplayValidator.class); + customValidators.remove(dPoPProofReplayValidator); + } + else { + dPoPProofReplayValidator = new DPoPProofReplayValidator(new DPoPProofReplayValidator.InMemoryCache()); + dPoPProofReplayValidator.setClockSkew(clockSkew); + } + return (context) -> createDefaultJwtValidatorFactory(context, jwtIssuedAtValidator, dPoPProofReplayValidator, + customValidators); + } + + private static OAuth2TokenValidator createDefaultJwtValidatorFactory(DPoPProofContext context, + JwtIssuedAtValidator jwtIssuedAtValidator, DPoPProofReplayValidator dPoPProofReplayValidator, + List> customValidators) { + // Add custom validators first then default validators in a specific order + List> tokenValidators = new ArrayList<>(); + if (!CollectionUtils.isEmpty(customValidators)) { + tokenValidators.addAll(customValidators); + } + tokenValidators.add(new JwtClaimValidator<>("htm", context.getMethod()::equalsIgnoreCase)); + tokenValidators.add(new JwtClaimValidator<>("htu", context.getTargetUri()::equals)); + tokenValidators.add(jwtIssuedAtValidator); + if (context.getAccessToken() != null) { + tokenValidators.add(new JwkThumbprintValidator(context.getAccessToken())); + tokenValidators.add(new AthClaimValidator(context.getAccessToken())); + } + tokenValidators.add(dPoPProofReplayValidator); + DelegatingOAuth2TokenValidator delegatingTokenValidator = new DelegatingOAuth2TokenValidator<>( + tokenValidators); + delegatingTokenValidator.setFailOnError(true); + return delegatingTokenValidator; + } + private static NimbusJwtDecoder buildDecoder() { ConfigurableJWTProcessor jwtProcessor = new DefaultJWTProcessor<>(); jwtProcessor.setJWSTypeVerifier(DPOP_TYPE_VERIFIER); @@ -137,39 +205,34 @@ public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory> defaultJwtValidatorFactory() { - return (context) -> new DelegatingOAuth2TokenValidator<>( - new JwtClaimValidator<>("htm", context.getMethod()::equals), - new JwtClaimValidator<>("htu", context.getTargetUri()::equals), new JtiClaimValidator(), - new JwtIssuedAtValidator(true)); - } + private static final class AthClaimValidator implements OAuth2TokenValidator { - private static final class JtiClaimValidator implements OAuth2TokenValidator { + private final OAuth2Token accessToken; - private static final Map JTI_CACHE = Collections.synchronizedMap(new JtiCache()); + private AthClaimValidator(OAuth2Token accessToken) { + Assert.notNull(accessToken, "accessToken cannot be null"); + this.accessToken = accessToken; + } @Override public OAuth2TokenValidatorResult validate(Jwt jwt) { Assert.notNull(jwt, "DPoP proof jwt cannot be null"); - String jti = jwt.getId(); - if (!StringUtils.hasText(jti)) { - OAuth2Error error = createOAuth2Error("jti claim is required."); + String accessTokenHashClaim = jwt.getClaimAsString("ath"); + if (!StringUtils.hasText(accessTokenHashClaim)) { + OAuth2Error error = createOAuth2Error("ath claim is required."); return OAuth2TokenValidatorResult.failure(error); } - // Enforce single-use to protect against DPoP proof replay - String jtiHash; + String accessTokenHash; try { - jtiHash = computeSHA256(jti); + accessTokenHash = computeSHA256(this.accessToken.getTokenValue()); } catch (Exception ex) { - OAuth2Error error = createOAuth2Error("jti claim is invalid."); + OAuth2Error error = createOAuth2Error("Failed to compute SHA-256 Thumbprint for access token."); return OAuth2TokenValidatorResult.failure(error); } - Instant expiry = Instant.now().plus(1, ChronoUnit.HOURS); - if ((JTI_CACHE.putIfAbsent(jtiHash, expiry.toEpochMilli())) != null) { - // Already used - OAuth2Error error = createOAuth2Error("jti claim is invalid."); + if (!accessTokenHashClaim.equals(accessTokenHash)) { + OAuth2Error error = createOAuth2Error("ath claim is invalid."); return OAuth2TokenValidatorResult.failure(error); } return OAuth2TokenValidatorResult.success(); @@ -185,20 +248,65 @@ public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory { + } - private static final int MAX_SIZE = 1000; + private static final class JwkThumbprintValidator implements OAuth2TokenValidator { - @Override - protected boolean removeEldestEntry(Map.Entry eldest) { - if (size() > MAX_SIZE) { - return true; - } - Instant expiry = Instant.ofEpochMilli(eldest.getValue()); - return Instant.now().isAfter(expiry); + private final OAuth2Token accessToken; + + private final ClaimAccessor claims; + + private JwkThumbprintValidator(OAuth2Token accessToken) { + Assert.notNull(accessToken, "accessToken cannot be null"); + Assert.isInstanceOf(ClaimAccessor.class, accessToken, "accessToken must be instance of ClaimAccessor"); + this.accessToken = accessToken; + this.claims = (ClaimAccessor) accessToken; + } + + @Override + public OAuth2TokenValidatorResult validate(Jwt jwt) { + Assert.notNull(jwt, "DPoP proof jwt cannot be null"); + String jwkThumbprintClaim = null; + Map confirmationMethodClaim = this.claims.getClaimAsMap("cnf"); + if (!CollectionUtils.isEmpty(confirmationMethodClaim) && confirmationMethodClaim.containsKey("jkt")) { + jwkThumbprintClaim = (String) confirmationMethodClaim.get("jkt"); + } + if (jwkThumbprintClaim == null) { + OAuth2Error error = createOAuth2Error("jkt claim is required."); + return OAuth2TokenValidatorResult.failure(error); } + JWK jwk = null; + @SuppressWarnings("unchecked") + Map jwkJson = (Map) jwt.getHeaders().get("jwk"); + try { + jwk = JWK.parse(jwkJson); + } + catch (Exception ignored) { + } + if (jwk == null) { + OAuth2Error error = createOAuth2Error("jwk header is missing or invalid."); + return OAuth2TokenValidatorResult.failure(error); + } + + String jwkThumbprint; + try { + jwkThumbprint = jwk.computeThumbprint().toString(); + } + catch (Exception ex) { + OAuth2Error error = createOAuth2Error("Failed to compute SHA-256 Thumbprint for jwk."); + return OAuth2TokenValidatorResult.failure(error); + } + + if (!jwkThumbprintClaim.equals(jwkThumbprint)) { + OAuth2Error error = createOAuth2Error("jkt claim is invalid."); + return OAuth2TokenValidatorResult.failure(error); + } + return OAuth2TokenValidatorResult.success(); + } + + private static OAuth2Error createOAuth2Error(String reason) { + return new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF, reason, null); } } diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/DPoPProofReplayValidator.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/DPoPProofReplayValidator.java new file mode 100644 index 0000000000..657f67895c --- /dev/null +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/DPoPProofReplayValidator.java @@ -0,0 +1,446 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.jwt; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.Callable; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicBoolean; + +import com.nimbusds.jose.jwk.JWK; + +import org.springframework.cache.Cache; +import org.springframework.cache.support.SimpleValueWrapper; +import org.springframework.lang.Nullable; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * An {@link OAuth2TokenValidator} that mitigates DPoP Proof Replay. + * + *

+ * This validator mitigates DPoP Proof Replay by ensuring the DPoP Proof: + *

    + *
  • contains the {@code iat} (issued at) claim, and it's within an acceptable time + * window (configured via {@link #setClockSkew(Duration)})
  • + *
  • contains the {@code jti} (JWT ID) claim, and it has not been used previously
  • + *
+ * + *

+ * This implementation uses a {@link Cache} to store the {@code jti} claim (along with + * other information in {@link CacheValue CacheValue}) to enforce single-use. The + * {@code jti} is retained in the cache until the DPoP Proof expires, which is calculated + * as {@code iat + clockSkew}. + * + * @author Joe Grandja + * @since 6.5.12 + * @see OAuth2TokenValidator + * @see DPoPProofJwtDecoderFactory + * @see Section 11.1. DPoP Proof + * Replay + */ +public final class DPoPProofReplayValidator implements OAuth2TokenValidator { + + private final Cache cache; + + private Duration clockSkew = Duration.ofSeconds(30); + + private Clock clock = Clock.systemUTC(); + + /** + * Constructs a {@code DPoPProofReplayValidator} using the provided parameters. + * @param cache the {@link Cache} used to store {@link CacheValue} which contains + * information of the used DPoP Proof {@link Jwt}'s + */ + public DPoPProofReplayValidator(Cache cache) { + Assert.notNull(cache, "cache cannot be null"); + this.cache = cache; + } + + @Override + public OAuth2TokenValidatorResult validate(Jwt jwt) { + Assert.notNull(jwt, "DPoP proof jwt cannot be null"); + String jti = jwt.getId(); + if (!StringUtils.hasText(jti)) { + OAuth2Error error = createOAuth2Error("jti claim is required."); + return OAuth2TokenValidatorResult.failure(error); + } + + Instant issuedAt = jwt.getIssuedAt(); + if (issuedAt == null) { + OAuth2Error error = createOAuth2Error("iat claim is required."); + return OAuth2TokenValidatorResult.failure(error); + } + + // Ensure acceptable time window + Instant now = Instant.now(this.clock); + Instant notBefore = now.minus(this.clockSkew); + Instant notAfter = now.plus(this.clockSkew); + if (issuedAt.isBefore(notBefore) || issuedAt.isAfter(notAfter)) { + OAuth2Error error = createOAuth2Error("iat claim is invalid."); + return OAuth2TokenValidatorResult.failure(error); + } + + String jwkThumbprint; + try { + @SuppressWarnings("unchecked") + Map jwkJson = (Map) jwt.getHeaders().get("jwk"); + JWK jwk = JWK.parse(jwkJson); + jwkThumbprint = jwk.computeThumbprint().toString(); + } + catch (Exception ex) { + OAuth2Error error = createOAuth2Error("jwk header is missing or invalid."); + return OAuth2TokenValidatorResult.failure(error); + } + + String jtiHash; + try { + jtiHash = computeSHA256(jti); + } + catch (Exception ex) { + OAuth2Error error = createOAuth2Error("jti claim is invalid."); + return OAuth2TokenValidatorResult.failure(error); + } + + Instant expiresAt = issuedAt.plus(this.clockSkew); + CacheValue cacheValue = new CacheValue(issuedAt, expiresAt, jwkThumbprint); + + // Enforce single-use to protect against DPoP proof replay + if (this.cache.putIfAbsent(jtiHash, cacheValue) != null) { + // Already used or cache full or key limit reached + OAuth2Error error = createOAuth2Error("jti claim is invalid or unable to cache."); + return OAuth2TokenValidatorResult.failure(error); + } + return OAuth2TokenValidatorResult.success(); + } + + /** + * Sets the clock skew. The default is 30 seconds. + * @param clockSkew the clock skew + */ + public void setClockSkew(Duration clockSkew) { + Assert.notNull(clockSkew, "clockSkew cannot be null"); + Assert.isTrue(clockSkew.getSeconds() >= 0, "clockSkew must be >= 0"); + this.clockSkew = clockSkew; + } + + /** + * Sets the {@link Clock} used in {@link Instant#now(Clock)}. + * @param clock the clock + */ + public void setClock(Clock clock) { + Assert.notNull(clock, "clock cannot be null"); + this.clock = clock; + } + + private static OAuth2Error createOAuth2Error(String reason) { + return new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF, reason, null); + } + + private static String computeSHA256(String value) throws Exception { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(value.getBytes(StandardCharsets.UTF_8)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + } + + /** + * An in-memory {@link Cache} implementation backed by a {@link ConcurrentHashMap}. + * + *

+ * NOTE: This implementation has limitations as it only works in a single-node + * setup. For production (and clustered) environments, it is recommended to use a + * distributed {@link Cache} implementation (e.g. Redis, Hazelcast, etc.). + * + *

+ * This implementation can be fine-tuned based on the following configuration + * settings: + *

    + *
  • {@link #setMaxSize(int)} - Sets the maximum number of entries the cache can + * hold. The default is 100,000.
  • + *
  • {@link #setMaxRequestsPerKey(int)} - Sets the maximum number of requests + * allowed per {@link CacheValue#getJwkThumbprint() JWK thumbprint}. The default is + * 1000.
  • + *
+ */ + public static final class InMemoryCache implements Cache { + + private static final String DEFAULT_NAME = InMemoryCache.class.getName().concat(".DPOP-PROOF-CACHE"); + + private static final int DEFAULT_MAX_SIZE = 100_000; + + private static final int DEFAULT_MAX_REQUESTS_PER_KEY = 1000; + + private static final int CLEANUP_INTERVAL_SECS = 10; + + private final ConcurrentMap cache = new ConcurrentHashMap<>(); + + private final ConcurrentMap requestsPerKey = new ConcurrentHashMap<>(); + + private final AtomicBoolean cleaning = new AtomicBoolean(false); + + private long lastCleanup = System.currentTimeMillis(); + + private int maxSize = DEFAULT_MAX_SIZE; + + private int maxRequestsPerKey = DEFAULT_MAX_REQUESTS_PER_KEY; + + /** + * Returns the maximum number of entries the cache can hold. + * @return the maximum number of entries the cache can hold + */ + public int getMaxSize() { + return this.maxSize; + } + + /** + * Sets the maximum number of entries the cache can hold. The default is 100,000. + * @param maxSize the maximum number of entries the cache can hold + */ + public void setMaxSize(int maxSize) { + Assert.isTrue(maxSize > 0, "maxSize must be > 0"); + this.maxSize = maxSize; + } + + /** + * Returns the maximum number of requests allowed per + * {@link CacheValue#getJwkThumbprint() JWK thumbprint}. + * @return the maximum number of requests allowed per + * {@link CacheValue#getJwkThumbprint() JWK thumbprint} + */ + public int getMaxRequestsPerKey() { + return this.maxRequestsPerKey; + } + + /** + * Sets the maximum number of requests allowed per + * {@link CacheValue#getJwkThumbprint() JWK thumbprint}. The default is 1000. + * @param maxRequestsPerKey the maximum number of requests allowed per + * {@link CacheValue#getJwkThumbprint() JWK thumbprint} + */ + public void setMaxRequestsPerKey(int maxRequestsPerKey) { + Assert.isTrue(maxRequestsPerKey > 0, "maxRequestsPerKey must be > 0"); + this.maxRequestsPerKey = maxRequestsPerKey; + } + + @Override + public String getName() { + return DEFAULT_NAME; + } + + @Override + public Object getNativeCache() { + return this.cache; + } + + @Override + public @Nullable ValueWrapper get(Object key) { + Object value = this.cache.get(key); + return (value != null) ? new SimpleValueWrapper(value) : null; + } + + @SuppressWarnings("unchecked") + @Override + public @Nullable T get(Object key, @Nullable Class type) { + Object value = this.cache.get(key); + if (value != null && type != null && !type.isInstance(value)) { + throw new IllegalStateException( + "Cached value is not of required type [" + type.getName() + "]: " + value); + } + return (T) value; + } + + @Override + public @Nullable T get(Object key, Callable valueLoader) { + throw new UnsupportedOperationException(); + } + + @Override + public void put(Object key, Object value) { + putIfAbsent(key, value); + } + + @Override + public @Nullable ValueWrapper putIfAbsent(Object key, Object value) { + String jti = (String) key; + CacheValue cacheValue = (CacheValue) value; + + cleanupIfNecessary(); + if (this.cache.size() >= this.maxSize) { + // Force an immediate cleanup when we hit the limit before the cleanup + // interval + cleanup(); + if (this.cache.size() >= this.maxSize) { + // Cache full - return non-null value + return new SimpleValueWrapper(cacheValue); + } + } + + // Limit the number of requests per key + AtomicBoolean limitExceeded = new AtomicBoolean(false); + this.requestsPerKey.compute(cacheValue.jwkThumbprint, (k, v) -> { + if (v != null && v >= this.maxRequestsPerKey) { + limitExceeded.set(true); + return v; + } + // Increment + return (v != null) ? v + 1 : 1; + }); + if (limitExceeded.get()) { + // Key limit reached - return non-null value + return new SimpleValueWrapper(cacheValue); + } + + if (this.cache.putIfAbsent(jti, cacheValue) != null) { + // jti exists - revert the increment and return non-null value + this.requestsPerKey.computeIfPresent(cacheValue.jwkThumbprint, (k, v) -> (v > 1) ? v - 1 : null); + return new SimpleValueWrapper(cacheValue); + } + + return null; + } + + @Override + public void evict(Object key) { + } + + @Override + public void clear() { + } + + private void cleanupIfNecessary() { + long now = System.currentTimeMillis(); + long last = this.lastCleanup; + if ((now - last) > (CLEANUP_INTERVAL_SECS * 1000)) { + cleanup(); + } + } + + private void cleanup() { + if (this.cleaning.compareAndSet(false, true)) { + try { + Instant now = Instant.now(); + for (Map.Entry entry : this.cache.entrySet()) { + if (now.isAfter(entry.getValue().expiresAt)) { + this.cache.remove(entry.getKey()); + this.requestsPerKey.computeIfPresent(entry.getValue().jwkThumbprint, + (k, v) -> (v > 1) ? v - 1 : null); + } + } + this.lastCleanup = System.currentTimeMillis(); + } + finally { + this.cleaning.set(false); + } + } + } + + } + + /** + * A representation of the value to which the {@link Cache} maps a (hashed) + * {@code (jti)} claim as the key. + */ + public static final class CacheValue { + + private final Instant issuedAt; + + private final Instant expiresAt; + + private final String jwkThumbprint; + + /** + * Constructs a {@code CacheValue} using the provided parameters. + * @param issuedAt the issued at claim which identifies the time at which the DPoP + * Proof {@link Jwt} was issued + * @param expiresAt the expiration time when this {@code CacheValue} will be + * evicted from the cache + * @param jwkThumbprint the SHA-256 thumbprint of the public key of the JSON Web + * Key (JWK) corresponding to the key used to digitally sign the DPoP Proof + * {@link Jwt} + */ + public CacheValue(Instant issuedAt, Instant expiresAt, String jwkThumbprint) { + Assert.notNull(issuedAt, "issuedAt cannot be null"); + Assert.notNull(expiresAt, "expiresAt cannot be null"); + Assert.hasText(jwkThumbprint, "jwkThumbprint cannot be empty"); + this.issuedAt = issuedAt; + this.expiresAt = expiresAt; + this.jwkThumbprint = jwkThumbprint; + } + + /** + * Returns the issued at {@code (iat)} claim which identifies the time at which + * the DPoP Proof {@link Jwt} was issued. + * @return the issued at claim which identifies the time at which the DPoP Proof + * {@link Jwt} was issued + */ + public Instant getIssuedAt() { + return this.issuedAt; + } + + /** + * Returns the expiration time when this {@code CacheValue} will be evicted from + * the cache. + * @return the expiration time when this {@code CacheValue} will be evicted from + * the cache + */ + public Instant getExpiresAt() { + return this.expiresAt; + } + + /** + * Returns the SHA-256 thumbprint of the public key of the JSON Web Key (JWK) + * corresponding to the key used to digitally sign the DPoP Proof {@link Jwt}. + * @return the SHA-256 thumbprint of the public key of the JSON Web Key (JWK) + */ + public String getJwkThumbprint() { + return this.jwkThumbprint; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) { + return true; + } + if (obj == null || obj.getClass() != this.getClass()) { + return false; + } + CacheValue that = (CacheValue) obj; + return Objects.equals(this.issuedAt, that.issuedAt) && Objects.equals(this.expiresAt, that.expiresAt) + && Objects.equals(this.jwkThumbprint, that.jwkThumbprint); + } + + @Override + public int hashCode() { + return Objects.hash(this.issuedAt, this.expiresAt, this.jwkThumbprint); + } + + } + +} diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/DPoPAuthenticationProvider.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/DPoPAuthenticationProvider.java index d3db0bdfe2..739b563332 100644 --- a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/DPoPAuthenticationProvider.java +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/DPoPAuthenticationProvider.java @@ -16,36 +16,24 @@ package org.springframework.security.oauth2.server.resource.authentication; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; import java.time.Instant; -import java.util.Base64; import java.util.Map; -import java.util.function.Function; - -import com.nimbusds.jose.jwk.JWK; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.oauth2.core.ClaimAccessor; -import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.OAuth2ErrorCodes; import org.springframework.security.oauth2.core.OAuth2Token; -import org.springframework.security.oauth2.core.OAuth2TokenValidator; -import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; import org.springframework.security.oauth2.jwt.DPoPProofContext; import org.springframework.security.oauth2.jwt.DPoPProofJwtDecoderFactory; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.JwtDecoderFactory; -import org.springframework.security.oauth2.jwt.JwtException; import org.springframework.util.Assert; -import org.springframework.util.CollectionUtils; -import org.springframework.util.StringUtils; /** * An {@link AuthenticationProvider} implementation that is responsible for authenticating @@ -72,16 +60,7 @@ public final class DPoPAuthenticationProvider implements AuthenticationProvider public DPoPAuthenticationProvider(AuthenticationManager tokenAuthenticationManager) { Assert.notNull(tokenAuthenticationManager, "tokenAuthenticationManager cannot be null"); this.tokenAuthenticationManager = tokenAuthenticationManager; - Function> jwtValidatorFactory = ( - context) -> new DelegatingOAuth2TokenValidator<>( - // Use default validators - DPoPProofJwtDecoderFactory.DEFAULT_JWT_VALIDATOR_FACTORY.apply(context), - // Add custom validators - new AthClaimValidator(context.getAccessToken()), - new JwkThumbprintValidator(context.getAccessToken())); - DPoPProofJwtDecoderFactory dPoPProofJwtDecoderFactory = new DPoPProofJwtDecoderFactory(); - dPoPProofJwtDecoderFactory.setJwtValidatorFactory(jwtValidatorFactory); - this.dPoPProofVerifierFactory = dPoPProofJwtDecoderFactory; + this.dPoPProofVerifierFactory = new DPoPProofJwtDecoderFactory(); } @Override @@ -116,7 +95,7 @@ public final class DPoPAuthenticationProvider implements AuthenticationProvider try { dPoPProofVerifier.decode(dPoPProofContext.getDPoPProof()); } - catch (JwtException ex) { + catch (Exception ex) { OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF); throw new OAuth2AuthenticationException(error, ex); } @@ -141,108 +120,6 @@ public final class DPoPAuthenticationProvider implements AuthenticationProvider this.dPoPProofVerifierFactory = dPoPProofVerifierFactory; } - private static final class AthClaimValidator implements OAuth2TokenValidator { - - private final OAuth2AccessTokenClaims accessToken; - - private AthClaimValidator(OAuth2AccessTokenClaims accessToken) { - Assert.notNull(accessToken, "accessToken cannot be null"); - this.accessToken = accessToken; - } - - @Override - public OAuth2TokenValidatorResult validate(Jwt jwt) { - Assert.notNull(jwt, "DPoP proof jwt cannot be null"); - String accessTokenHashClaim = jwt.getClaimAsString("ath"); - if (!StringUtils.hasText(accessTokenHashClaim)) { - OAuth2Error error = createOAuth2Error("ath claim is required."); - return OAuth2TokenValidatorResult.failure(error); - } - - String accessTokenHash; - try { - accessTokenHash = computeSHA256(this.accessToken.getTokenValue()); - } - catch (Exception ex) { - OAuth2Error error = createOAuth2Error("Failed to compute SHA-256 Thumbprint for access token."); - return OAuth2TokenValidatorResult.failure(error); - } - if (!accessTokenHashClaim.equals(accessTokenHash)) { - OAuth2Error error = createOAuth2Error("ath claim is invalid."); - return OAuth2TokenValidatorResult.failure(error); - } - return OAuth2TokenValidatorResult.success(); - } - - private static OAuth2Error createOAuth2Error(String reason) { - return new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF, reason, null); - } - - private static String computeSHA256(String value) throws Exception { - MessageDigest md = MessageDigest.getInstance("SHA-256"); - byte[] digest = md.digest(value.getBytes(StandardCharsets.UTF_8)); - return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); - } - - } - - private static final class JwkThumbprintValidator implements OAuth2TokenValidator { - - private final OAuth2AccessTokenClaims accessToken; - - private JwkThumbprintValidator(OAuth2AccessTokenClaims accessToken) { - Assert.notNull(accessToken, "accessToken cannot be null"); - this.accessToken = accessToken; - } - - @Override - public OAuth2TokenValidatorResult validate(Jwt jwt) { - Assert.notNull(jwt, "DPoP proof jwt cannot be null"); - String jwkThumbprintClaim = null; - Map confirmationMethodClaim = this.accessToken.getClaimAsMap("cnf"); - if (!CollectionUtils.isEmpty(confirmationMethodClaim) && confirmationMethodClaim.containsKey("jkt")) { - jwkThumbprintClaim = (String) confirmationMethodClaim.get("jkt"); - } - if (jwkThumbprintClaim == null) { - OAuth2Error error = createOAuth2Error("jkt claim is required."); - return OAuth2TokenValidatorResult.failure(error); - } - - JWK jwk = null; - @SuppressWarnings("unchecked") - Map jwkJson = (Map) jwt.getHeaders().get("jwk"); - try { - jwk = JWK.parse(jwkJson); - } - catch (Exception ignored) { - } - if (jwk == null) { - OAuth2Error error = createOAuth2Error("jwk header is missing or invalid."); - return OAuth2TokenValidatorResult.failure(error); - } - - String jwkThumbprint; - try { - jwkThumbprint = jwk.computeThumbprint().toString(); - } - catch (Exception ex) { - OAuth2Error error = createOAuth2Error("Failed to compute SHA-256 Thumbprint for jwk."); - return OAuth2TokenValidatorResult.failure(error); - } - - if (!jwkThumbprintClaim.equals(jwkThumbprint)) { - OAuth2Error error = createOAuth2Error("jkt claim is invalid."); - return OAuth2TokenValidatorResult.failure(error); - } - return OAuth2TokenValidatorResult.success(); - } - - private static OAuth2Error createOAuth2Error(String reason) { - return new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF, reason, null); - } - - } - private static final class OAuth2AccessTokenClaims implements OAuth2Token, ClaimAccessor { private final OAuth2Token accessToken; From ac15cdbea3f89b3b6882ff25e2a5a9a4b7b99a76 Mon Sep 17 00:00:00 2001 From: Joe Grandja <10884212+jgrandja@users.noreply.github.com> Date: Wed, 8 Jul 2026 08:07:45 -0400 Subject: [PATCH 09/17] Apply html encoding in DefaultConsentPage --- .../authorization/web/DefaultConsentPage.java | 30 ++++++++++------ ...Auth2AuthorizationEndpointFilterTests.java | 34 +++++++++++++++++++ 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/DefaultConsentPage.java b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/DefaultConsentPage.java index 20ecdd9be1..d953c01fbb 100644 --- a/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/DefaultConsentPage.java +++ b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/DefaultConsentPage.java @@ -29,6 +29,7 @@ import org.springframework.http.MediaType; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; import org.springframework.security.oauth2.core.oidc.OidcScopes; +import org.springframework.web.util.HtmlUtils; /** * For internal use only. @@ -56,6 +57,12 @@ final class DefaultConsentPage { private static String generateConsentPage(HttpServletRequest request, String clientId, Authentication principal, Set requestedScopes, Set authorizedScopes, String state, Map additionalParameters) { + + String encodedClientId = HtmlUtils.htmlEscape(clientId); + String encodedState = HtmlUtils.htmlEscape(state); + String encodedPrincipalName = HtmlUtils.htmlEscape(principal.getName()); + String encodedRequestUri = HtmlUtils.htmlEscape(request.getRequestURI()); + Set scopesToAuthorize = new HashSet<>(); Set scopesPreviouslyAuthorized = new HashSet<>(); for (String scope : requestedScopes) { @@ -74,6 +81,7 @@ final class DefaultConsentPage { // the "user_code" being displayed on the device to confirm they are // authorizing the correct device. String userCode = additionalParameters.get(OAuth2ParameterNames.USER_CODE); + String encodedUserCode = (userCode != null) ? HtmlUtils.htmlEscape(userCode) : null; // @formatter:off StringBuilder builder = new StringBuilder(); @@ -98,13 +106,13 @@ final class DefaultConsentPage { builder.append(" "); builder.append("
"); builder.append("
"); - builder.append("

" + clientId + " wants to access your account " + principal.getName() + "

"); + builder.append("

" + encodedClientId + " wants to access your account " + encodedPrincipalName + "

"); builder.append("
"); builder.append("
"); if (userCode != null) { builder.append("
"); builder.append("
"); - builder.append("

You have provided the code " + userCode + ". Verify that this code matches what is shown on your device.

"); + builder.append("

You have provided the code " + encodedUserCode + ". Verify that this code matches what is shown on your device.

"); builder.append("
"); builder.append("
"); } @@ -115,26 +123,28 @@ final class DefaultConsentPage { builder.append(" "); builder.append("
"); builder.append("
"); - builder.append("
"); - builder.append(" "); - builder.append(" "); + builder.append(" "); + builder.append(" "); + builder.append(" "); if (userCode != null) { - builder.append(" "); + builder.append(" "); } for (String scope : scopesToAuthorize) { + String encodedScope = HtmlUtils.htmlEscape(scope); builder.append("
"); - builder.append(" "); - builder.append(" "); + builder.append(" "); + builder.append(" "); builder.append("
"); } if (!scopesPreviouslyAuthorized.isEmpty()) { builder.append("

You have already granted the following permissions to the above app:

"); for (String scope : scopesPreviouslyAuthorized) { + String encodedScope = HtmlUtils.htmlEscape(scope); builder.append("
"); - builder.append(" "); - builder.append(" "); + builder.append(" "); + builder.append(" "); builder.append("
"); } } diff --git a/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilterTests.java b/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilterTests.java index e37cf233ae..9b107c835e 100644 --- a/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilterTests.java +++ b/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilterTests.java @@ -64,6 +64,7 @@ import org.springframework.security.web.authentication.AuthenticationSuccessHand import org.springframework.security.web.authentication.WebAuthenticationDetails; import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy; import org.springframework.util.StringUtils; +import org.springframework.web.util.HtmlUtils; import org.springframework.web.util.UriComponentsBuilder; import static org.assertj.core.api.Assertions.assertThat; @@ -581,6 +582,39 @@ public class OAuth2AuthorizationEndpointFilterTests { } } + @Test + public void doFilterWhenAuthorizationRequestConsentRequiredThenConsentResponseHtmlEscaped() throws Exception { + String unencodedScope = ""; + String unencodedState = ""; + Set requestedScopes = new HashSet<>(Arrays.asList("scope1", unencodedScope)); + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scopes((scopes) -> { + scopes.clear(); + scopes.addAll(requestedScopes); + }).build(); + // No scopes previously approved + OAuth2AuthorizationConsentAuthenticationToken authorizationConsentAuthenticationResult = new OAuth2AuthorizationConsentAuthenticationToken( + AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, unencodedState, new HashSet<>(), + null); + authorizationConsentAuthenticationResult.setAuthenticated(true); + given(this.authenticationManager.authenticate(any())).willReturn(authorizationConsentAuthenticationResult); + + MockHttpServletRequest request = createAuthorizationRequest(registeredClient); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + this.filter.doFilter(request, response, filterChain); + + verify(this.authenticationManager).authenticate(any()); + verifyNoInteractions(filterChain); + + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); + String html = response.getContentAsString(); + assertThat(html).doesNotContain(unencodedScope); + assertThat(html).doesNotContain(unencodedState); + assertThat(html).contains(HtmlUtils.htmlEscape(unencodedScope)); + assertThat(html).contains("name=\"state\" value=\"" + HtmlUtils.htmlEscape(unencodedState) + "\""); + } + @Test public void doFilterWhenAuthorizationRequestAuthenticatedThenAuthorizationResponse() throws Exception { RegisteredClient registeredClient = TestRegisteredClients.registeredClient().redirectUris((redirectUris) -> { From 536e09756c249f05f3f3ac62d0d905055e5a7697 Mon Sep 17 00:00:00 2001 From: Joe Grandja <10884212+jgrandja@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:42:39 -0400 Subject: [PATCH 10/17] Bind UnboundIdContainer to loopback address --- .../LdapAuthenticationProviderConfigurer.java | 3 ++- .../ldap/LdapServerBeanDefinitionParser.java | 3 ++- .../ldap/server/UnboundIdContainerTests.java | 20 +++++++++++++++++++ .../ldap/server/UnboundIdContainer.java | 4 +++- 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/config/src/main/java/org/springframework/security/config/annotation/authentication/configurers/ldap/LdapAuthenticationProviderConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/authentication/configurers/ldap/LdapAuthenticationProviderConfigurer.java index d8fd2cd211..80a32e285a 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/authentication/configurers/ldap/LdapAuthenticationProviderConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/authentication/configurers/ldap/LdapAuthenticationProviderConfigurer.java @@ -17,6 +17,7 @@ package org.springframework.security.config.annotation.authentication.configurers.ldap; import java.io.IOException; +import java.net.InetAddress; import java.net.ServerSocket; import org.springframework.ldap.core.support.BaseLdapPathContextSource; @@ -590,7 +591,7 @@ public class LdapAuthenticationProviderConfigurer ports = getDefaultPorts(1); + server.setPort(ports.get(0)); + + try { + server.afterPropertiesSet(); + InMemoryDirectoryServer directoryServer = (InMemoryDirectoryServer) ReflectionTestUtils.getField(server, + "directoryServer"); + assertThat(directoryServer.getListenAddress().isLoopbackAddress()).isTrue(); + } + finally { + server.destroy(); + } + } + @Test public void afterPropertiesSetWhenPortIsZeroThenRandomPortIsSelected() throws Exception { UnboundIdContainer server = new UnboundIdContainer("dc=springframework,dc=org", null); diff --git a/ldap/src/main/java/org/springframework/security/ldap/server/UnboundIdContainer.java b/ldap/src/main/java/org/springframework/security/ldap/server/UnboundIdContainer.java index ec0efba60b..e069affe2d 100644 --- a/ldap/src/main/java/org/springframework/security/ldap/server/UnboundIdContainer.java +++ b/ldap/src/main/java/org/springframework/security/ldap/server/UnboundIdContainer.java @@ -17,6 +17,7 @@ package org.springframework.security.ldap.server; import java.io.InputStream; +import java.net.InetAddress; import com.unboundid.ldap.listener.InMemoryDirectoryServer; import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig; @@ -96,7 +97,8 @@ public class UnboundIdContainer try { InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(this.defaultPartitionSuffix); config.addAdditionalBindCredentials("uid=admin,ou=system", "secret"); - config.setListenerConfigs(InMemoryListenerConfig.createLDAPConfig("LDAP", this.port)); + config.setListenerConfigs( + InMemoryListenerConfig.createLDAPConfig("LDAP", InetAddress.getLoopbackAddress(), this.port, null)); config.setEnforceSingleStructuralObjectClass(false); config.setEnforceAttributeSyntaxCompliance(true); DN dn = new DN(this.defaultPartitionSuffix); From 11a648d10601725ba348f5e9642ce79511c31a92 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:41:21 -0600 Subject: [PATCH 11/17] Improve Equivalence Tests This commit adds equals and hashCode implementations as well as a readResolve implementation to ensure that deserialization mechanisms can correctly assess the equality of a constnat and a corresponding deserialized instance. For defense-in-depth reasons, this commit also favors .equals over == for these constants. Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com> --- .../webauthn/api/AuthenticatorTransport.java | 22 ++++++ .../webauthn/api/PublicKeyCredentialType.java | 22 ++++++ .../api/UserVerificationRequirement.java | 33 ++++++++ .../Webauthn4JRelyingPartyOperations.java | 10 +-- .../api/AuthenticatorAttachmentTests.java | 42 ++++++++++ .../api/AuthenticatorTransportTests.java | 76 +++++++++++++++++++ .../PublicKeyCredentialDescriptorTests.java | 49 ++++++++++++ ...ublicKeyCredentialRequestOptionsTests.java | 43 +++++++++++ .../api/PublicKeyCredentialTypeTests.java | 51 +++++++++++++ .../webauthn/api/SerializationTestUtils.java | 47 ++++++++++++ .../api/UserVerificationRequirementTests.java | 58 ++++++++++++++ 11 files changed, 448 insertions(+), 5 deletions(-) create mode 100644 webauthn/src/test/java/org/springframework/security/web/webauthn/api/AuthenticatorAttachmentTests.java create mode 100644 webauthn/src/test/java/org/springframework/security/web/webauthn/api/AuthenticatorTransportTests.java create mode 100644 webauthn/src/test/java/org/springframework/security/web/webauthn/api/PublicKeyCredentialDescriptorTests.java create mode 100644 webauthn/src/test/java/org/springframework/security/web/webauthn/api/PublicKeyCredentialRequestOptionsTests.java create mode 100644 webauthn/src/test/java/org/springframework/security/web/webauthn/api/PublicKeyCredentialTypeTests.java create mode 100644 webauthn/src/test/java/org/springframework/security/web/webauthn/api/SerializationTestUtils.java create mode 100644 webauthn/src/test/java/org/springframework/security/web/webauthn/api/UserVerificationRequirementTests.java diff --git a/webauthn/src/main/java/org/springframework/security/web/webauthn/api/AuthenticatorTransport.java b/webauthn/src/main/java/org/springframework/security/web/webauthn/api/AuthenticatorTransport.java index a4633edad5..225ee78b5b 100644 --- a/webauthn/src/main/java/org/springframework/security/web/webauthn/api/AuthenticatorTransport.java +++ b/webauthn/src/main/java/org/springframework/security/web/webauthn/api/AuthenticatorTransport.java @@ -16,6 +16,7 @@ package org.springframework.security.web.webauthn.api; +import java.io.ObjectStreamException; import java.io.Serial; import java.io.Serializable; @@ -121,4 +122,25 @@ public final class AuthenticatorTransport implements Serializable { return new AuthenticatorTransport[] { USB, NFC, BLE, HYBRID, INTERNAL }; } + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof AuthenticatorTransport other)) { + return false; + } + return this.value.equals(other.value); + } + + @Override + public int hashCode() { + return this.value.hashCode(); + } + + @Serial + private Object readResolve() throws ObjectStreamException { + return valueOf(this.value); + } + } diff --git a/webauthn/src/main/java/org/springframework/security/web/webauthn/api/PublicKeyCredentialType.java b/webauthn/src/main/java/org/springframework/security/web/webauthn/api/PublicKeyCredentialType.java index 9012b42178..5b1b2adc31 100644 --- a/webauthn/src/main/java/org/springframework/security/web/webauthn/api/PublicKeyCredentialType.java +++ b/webauthn/src/main/java/org/springframework/security/web/webauthn/api/PublicKeyCredentialType.java @@ -16,6 +16,7 @@ package org.springframework.security.web.webauthn.api; +import java.io.ObjectStreamException; import java.io.Serial; import java.io.Serializable; @@ -58,4 +59,25 @@ public final class PublicKeyCredentialType implements Serializable { return new PublicKeyCredentialType(value); } + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof PublicKeyCredentialType other)) { + return false; + } + return this.value.equals(other.value); + } + + @Override + public int hashCode() { + return this.value.hashCode(); + } + + @Serial + private Object readResolve() throws ObjectStreamException { + return valueOf(this.value); + } + } diff --git a/webauthn/src/main/java/org/springframework/security/web/webauthn/api/UserVerificationRequirement.java b/webauthn/src/main/java/org/springframework/security/web/webauthn/api/UserVerificationRequirement.java index fccf62fbec..71b819e1ab 100644 --- a/webauthn/src/main/java/org/springframework/security/web/webauthn/api/UserVerificationRequirement.java +++ b/webauthn/src/main/java/org/springframework/security/web/webauthn/api/UserVerificationRequirement.java @@ -16,9 +16,12 @@ package org.springframework.security.web.webauthn.api; +import java.io.ObjectStreamException; import java.io.Serial; import java.io.Serializable; +import org.jspecify.annotations.Nullable; + /** * UserVerificationRequirement @@ -72,4 +75,34 @@ public final class UserVerificationRequirement implements Serializable { return this.value; } + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof UserVerificationRequirement other)) { + return false; + } + return this.value.equals(other.value); + } + + @Override + public int hashCode() { + return this.value.hashCode(); + } + + @Serial + private Object readResolve() throws ObjectStreamException { + switch (this.value) { + case "required": + return REQUIRED; + case "preferred": + return PREFERRED; + case "discouraged": + return DISCOURAGED; + default: + return this; + } + } + } diff --git a/webauthn/src/main/java/org/springframework/security/web/webauthn/management/Webauthn4JRelyingPartyOperations.java b/webauthn/src/main/java/org/springframework/security/web/webauthn/management/Webauthn4JRelyingPartyOperations.java index 4d18768b34..2a9b0859ee 100644 --- a/webauthn/src/main/java/org/springframework/security/web/webauthn/management/Webauthn4JRelyingPartyOperations.java +++ b/webauthn/src/main/java/org/springframework/security/web/webauthn/management/Webauthn4JRelyingPartyOperations.java @@ -258,8 +258,8 @@ public class Webauthn4JRelyingPartyOperations implements WebAuthnRelyingPartyOpe byte[] clientDataJSON = response.getClientDataJSON().getBytes(); Challenge challenge = new DefaultChallenge(base64Challenge); ServerProperty serverProperty = new ServerProperty(origins, rpId, challenge); - boolean userVerificationRequired = creationOptions.getAuthenticatorSelection() - .getUserVerification() == UserVerificationRequirement.REQUIRED; + boolean userVerificationRequired = UserVerificationRequirement.REQUIRED + .equals(creationOptions.getAuthenticatorSelection().getUserVerification()); // requireUserPresence The constant Boolean value true // https://www.w3.org/TR/webauthn-3/#sctn-op-make-cred boolean userPresenceRequired = true; @@ -318,7 +318,7 @@ public class Webauthn4JRelyingPartyOperations implements WebAuthnRelyingPartyOpe private com.webauthn4j.data.PublicKeyCredentialParameters convertParamToWebauthn4j( PublicKeyCredentialParameters parameter) { - if (parameter.getType() != PublicKeyCredentialType.PUBLIC_KEY) { + if (!PublicKeyCredentialType.PUBLIC_KEY.equals(parameter.getType())) { throw new IllegalArgumentException( "Cannot convert unknown credential type " + parameter.getType() + " to webauthn4j"); } @@ -395,8 +395,8 @@ public class Webauthn4JRelyingPartyOperations implements WebAuthnRelyingPartyOpe String rpId = requestOptions.getRpId(); Assert.notNull(rpId, "rpId cannot be null"); ServerProperty serverProperty = new ServerProperty(origins, rpId, challenge); - boolean userVerificationRequired = request.getRequestOptions() - .getUserVerification() == UserVerificationRequirement.REQUIRED; + boolean userVerificationRequired = UserVerificationRequirement.REQUIRED + .equals(request.getRequestOptions().getUserVerification()); com.webauthn4j.data.AuthenticationRequest authenticationRequest = new com.webauthn4j.data.AuthenticationRequest( request.getPublicKey().getRawId().getBytes(), assertionResponse.getAuthenticatorData().getBytes(), diff --git a/webauthn/src/test/java/org/springframework/security/web/webauthn/api/AuthenticatorAttachmentTests.java b/webauthn/src/test/java/org/springframework/security/web/webauthn/api/AuthenticatorAttachmentTests.java new file mode 100644 index 0000000000..09771d632a --- /dev/null +++ b/webauthn/src/test/java/org/springframework/security/web/webauthn/api/AuthenticatorAttachmentTests.java @@ -0,0 +1,42 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.webauthn.api; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that {@link AuthenticatorAttachment} correctly restores singleton identity + * after Java deserialization. This class already implements {@code readResolve()} and + * serves as the reference for the pattern required by the other pseudo-enum types. + */ +class AuthenticatorAttachmentTests { + + @Test + void platformWhenDeserializedThenSameAsConstant() { + assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorAttachment.PLATFORM)) + .isSameAs(AuthenticatorAttachment.PLATFORM); + } + + @Test + void crossPlatformWhenDeserializedThenSameAsConstant() { + assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorAttachment.CROSS_PLATFORM)) + .isSameAs(AuthenticatorAttachment.CROSS_PLATFORM); + } + +} diff --git a/webauthn/src/test/java/org/springframework/security/web/webauthn/api/AuthenticatorTransportTests.java b/webauthn/src/test/java/org/springframework/security/web/webauthn/api/AuthenticatorTransportTests.java new file mode 100644 index 0000000000..1a8a2688ba --- /dev/null +++ b/webauthn/src/test/java/org/springframework/security/web/webauthn/api/AuthenticatorTransportTests.java @@ -0,0 +1,76 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.webauthn.api; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class AuthenticatorTransportTests { + + @Test + void usbWhenDeserializedThenSameAsConstant() { + assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorTransport.USB)) + .isSameAs(AuthenticatorTransport.USB); + } + + @Test + void nfcWhenDeserializedThenSameAsConstant() { + assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorTransport.NFC)) + .isSameAs(AuthenticatorTransport.NFC); + } + + @Test + void bleWhenDeserializedThenSameAsConstant() { + assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorTransport.BLE)) + .isSameAs(AuthenticatorTransport.BLE); + } + + @Test + void smartCardWhenDeserializedThenSameAsConstant() { + assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorTransport.SMART_CARD)) + .isSameAs(AuthenticatorTransport.SMART_CARD); + } + + @Test + void hybridWhenDeserializedThenSameAsConstant() { + assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorTransport.HYBRID)) + .isSameAs(AuthenticatorTransport.HYBRID); + } + + @Test + void internalWhenDeserializedThenSameAsConstant() { + assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorTransport.INTERNAL)) + .isSameAs(AuthenticatorTransport.INTERNAL); + } + + @Test + void usbWhenSameValueThenEquals() { + assertThat(new AuthenticatorTransport("usb")).isEqualTo(AuthenticatorTransport.USB); + } + + @Test + void usbWhenSameValueThenHashCodeMatches() { + assertThat(new AuthenticatorTransport("usb")).hasSameHashCodeAs(AuthenticatorTransport.USB); + } + + @Test + void usbWhenDifferentValueThenNotEquals() { + assertThat(AuthenticatorTransport.USB).isNotEqualTo(AuthenticatorTransport.NFC); + } + +} diff --git a/webauthn/src/test/java/org/springframework/security/web/webauthn/api/PublicKeyCredentialDescriptorTests.java b/webauthn/src/test/java/org/springframework/security/web/webauthn/api/PublicKeyCredentialDescriptorTests.java new file mode 100644 index 0000000000..c21eb584f6 --- /dev/null +++ b/webauthn/src/test/java/org/springframework/security/web/webauthn/api/PublicKeyCredentialDescriptorTests.java @@ -0,0 +1,49 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.webauthn.api; + +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class PublicKeyCredentialDescriptorTests { + + @Test + void typeWhenDeserializedThenSameAsConstant() { + PublicKeyCredentialDescriptor descriptor = PublicKeyCredentialDescriptor.builder() + .id(TestBytes.get()) + .type(PublicKeyCredentialType.PUBLIC_KEY) + .build(); + PublicKeyCredentialDescriptor deserialized = SerializationTestUtils.serializeAndDeserialize(descriptor); + assertThat(deserialized.getType()).isSameAs(PublicKeyCredentialType.PUBLIC_KEY); + } + + @Test + void transportsWhenDeserializedThenSameAsConstants() { + PublicKeyCredentialDescriptor descriptor = PublicKeyCredentialDescriptor.builder() + .id(TestBytes.get()) + .type(PublicKeyCredentialType.PUBLIC_KEY) + .transports(Set.of(AuthenticatorTransport.USB, AuthenticatorTransport.HYBRID)) + .build(); + PublicKeyCredentialDescriptor deserialized = SerializationTestUtils.serializeAndDeserialize(descriptor); + assertThat(deserialized.getTransports()).containsExactlyInAnyOrder(AuthenticatorTransport.USB, + AuthenticatorTransport.HYBRID); + } + +} diff --git a/webauthn/src/test/java/org/springframework/security/web/webauthn/api/PublicKeyCredentialRequestOptionsTests.java b/webauthn/src/test/java/org/springframework/security/web/webauthn/api/PublicKeyCredentialRequestOptionsTests.java new file mode 100644 index 0000000000..826fd2349b --- /dev/null +++ b/webauthn/src/test/java/org/springframework/security/web/webauthn/api/PublicKeyCredentialRequestOptionsTests.java @@ -0,0 +1,43 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.webauthn.api; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class PublicKeyCredentialRequestOptionsTests { + + @Test + void userVerificationRequiredWhenDeserializedThenSameAsConstant() { + PublicKeyCredentialRequestOptions options = TestPublicKeyCredentialRequestOptions.create() + .userVerification(UserVerificationRequirement.REQUIRED) + .build(); + PublicKeyCredentialRequestOptions deserialized = SerializationTestUtils.serializeAndDeserialize(options); + assertThat(deserialized.getUserVerification()).isSameAs(UserVerificationRequirement.REQUIRED); + } + + @Test + void userVerificationPreferredWhenDeserializedThenSameAsConstant() { + PublicKeyCredentialRequestOptions options = TestPublicKeyCredentialRequestOptions.create() + .userVerification(UserVerificationRequirement.PREFERRED) + .build(); + PublicKeyCredentialRequestOptions deserialized = SerializationTestUtils.serializeAndDeserialize(options); + assertThat(deserialized.getUserVerification()).isSameAs(UserVerificationRequirement.PREFERRED); + } + +} diff --git a/webauthn/src/test/java/org/springframework/security/web/webauthn/api/PublicKeyCredentialTypeTests.java b/webauthn/src/test/java/org/springframework/security/web/webauthn/api/PublicKeyCredentialTypeTests.java new file mode 100644 index 0000000000..a1feccec3f --- /dev/null +++ b/webauthn/src/test/java/org/springframework/security/web/webauthn/api/PublicKeyCredentialTypeTests.java @@ -0,0 +1,51 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.webauthn.api; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class PublicKeyCredentialTypeTests { + + @Test + void publicKeyWhenDeserializedThenSameAsConstant() { + assertThat(SerializationTestUtils.serializeAndDeserialize(PublicKeyCredentialType.PUBLIC_KEY)) + .isSameAs(PublicKeyCredentialType.PUBLIC_KEY); + } + + @Test + void publicKeyWhenSameValueThenEquals() { + PublicKeyCredentialType first = PublicKeyCredentialType.valueOf("custom"); + PublicKeyCredentialType second = PublicKeyCredentialType.valueOf("custom"); + assertThat(first).isEqualTo(second); + } + + @Test + void publicKeyWhenSameValueThenHashCodeMatches() { + PublicKeyCredentialType first = PublicKeyCredentialType.valueOf("custom"); + PublicKeyCredentialType second = PublicKeyCredentialType.valueOf("custom"); + assertThat(first).hasSameHashCodeAs(second); + } + + @Test + void publicKeyWhenDifferentValueThenNotEquals() { + assertThat(PublicKeyCredentialType.valueOf("custom-1")) + .isNotEqualTo(PublicKeyCredentialType.valueOf("custom-2")); + } + +} diff --git a/webauthn/src/test/java/org/springframework/security/web/webauthn/api/SerializationTestUtils.java b/webauthn/src/test/java/org/springframework/security/web/webauthn/api/SerializationTestUtils.java new file mode 100644 index 0000000000..1bbb1ffe4c --- /dev/null +++ b/webauthn/src/test/java/org/springframework/security/web/webauthn/api/SerializationTestUtils.java @@ -0,0 +1,47 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.webauthn.api; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.Serializable; + +public final class SerializationTestUtils { + + private SerializationTestUtils() { + } + + @SuppressWarnings("unchecked") + public static T serializeAndDeserialize(T object) { + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeObject(object); + } + ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray()); + try (ObjectInputStream ois = new ObjectInputStream(bais)) { + return (T) ois.readObject(); + } + } + catch (Exception ex) { + throw new RuntimeException("Serialization round-trip failed", ex); + } + } + +} diff --git a/webauthn/src/test/java/org/springframework/security/web/webauthn/api/UserVerificationRequirementTests.java b/webauthn/src/test/java/org/springframework/security/web/webauthn/api/UserVerificationRequirementTests.java new file mode 100644 index 0000000000..615f557e1a --- /dev/null +++ b/webauthn/src/test/java/org/springframework/security/web/webauthn/api/UserVerificationRequirementTests.java @@ -0,0 +1,58 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.webauthn.api; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class UserVerificationRequirementTests { + + @Test + void requiredWhenDeserializedThenSameAsConstant() { + assertThat(SerializationTestUtils.serializeAndDeserialize(UserVerificationRequirement.REQUIRED)) + .isSameAs(UserVerificationRequirement.REQUIRED); + } + + @Test + void preferredWhenDeserializedThenSameAsConstant() { + assertThat(SerializationTestUtils.serializeAndDeserialize(UserVerificationRequirement.PREFERRED)) + .isSameAs(UserVerificationRequirement.PREFERRED); + } + + @Test + void discouragedWhenDeserializedThenSameAsConstant() { + assertThat(SerializationTestUtils.serializeAndDeserialize(UserVerificationRequirement.DISCOURAGED)) + .isSameAs(UserVerificationRequirement.DISCOURAGED); + } + + @Test + void requiredWhenSameValueThenEquals() { + assertThat(new UserVerificationRequirement("required")).isEqualTo(UserVerificationRequirement.REQUIRED); + } + + @Test + void requiredWhenSameValueThenHashCodeMatches() { + assertThat(new UserVerificationRequirement("required")).hasSameHashCodeAs(UserVerificationRequirement.REQUIRED); + } + + @Test + void requiredWhenDifferentValueThenNotEquals() { + assertThat(UserVerificationRequirement.REQUIRED).isNotEqualTo(UserVerificationRequirement.PREFERRED); + } + +} From 0123ae098150c6dd82c9ea19fb09c2e53373a430 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:39:43 -0600 Subject: [PATCH 12/17] Deprecate AesBytesEncryptor This commit separates AesBytesEncryptor into two separate implememtations, allowing for a migration away from default arrangements that used a null IV Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com> --- .../crypto/encrypt/AesBytesEncryptor.java | 2 + .../crypto/encrypt/AesCbcBytesEncryptor.java | 165 ++++++++++++++ .../crypto/encrypt/AesGcmBytesEncryptor.java | 172 +++++++++++++++ .../security/crypto/encrypt/Encryptors.java | 28 ++- .../encrypt/AesCbcBytesEncryptorTests.java | 203 ++++++++++++++++++ .../encrypt/AesGcmBytesEncryptorTests.java | 146 +++++++++++++ .../crypto/encrypt/CryptoAssumptions.java | 10 +- .../features/integrations/cryptography.adoc | 58 +++-- 8 files changed, 750 insertions(+), 34 deletions(-) create mode 100644 crypto/src/main/java/org/springframework/security/crypto/encrypt/AesCbcBytesEncryptor.java create mode 100644 crypto/src/main/java/org/springframework/security/crypto/encrypt/AesGcmBytesEncryptor.java create mode 100644 crypto/src/test/java/org/springframework/security/crypto/encrypt/AesCbcBytesEncryptorTests.java create mode 100644 crypto/src/test/java/org/springframework/security/crypto/encrypt/AesGcmBytesEncryptorTests.java diff --git a/crypto/src/main/java/org/springframework/security/crypto/encrypt/AesBytesEncryptor.java b/crypto/src/main/java/org/springframework/security/crypto/encrypt/AesBytesEncryptor.java index 4093e7d102..5ba1ab5c77 100644 --- a/crypto/src/main/java/org/springframework/security/crypto/encrypt/AesBytesEncryptor.java +++ b/crypto/src/main/java/org/springframework/security/crypto/encrypt/AesBytesEncryptor.java @@ -37,7 +37,9 @@ import org.springframework.security.crypto.util.EncodingUtils; * * @author Keith Donald * @author Dave Syer + * @deprecated Use {@link AesCbcBytesEncryptor} or {@link AesGcmBytesEncryptor} instead. */ +@Deprecated public final class AesBytesEncryptor implements BytesEncryptor { private final SecretKey secretKey; diff --git a/crypto/src/main/java/org/springframework/security/crypto/encrypt/AesCbcBytesEncryptor.java b/crypto/src/main/java/org/springframework/security/crypto/encrypt/AesCbcBytesEncryptor.java new file mode 100644 index 0000000000..7cae0f3de7 --- /dev/null +++ b/crypto/src/main/java/org/springframework/security/crypto/encrypt/AesCbcBytesEncryptor.java @@ -0,0 +1,165 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.crypto.encrypt; + +import java.util.Objects; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.SecretKeySpec; + +import org.springframework.security.crypto.codec.Hex; +import org.springframework.security.crypto.keygen.BytesKeyGenerator; +import org.springframework.security.crypto.keygen.KeyGenerators; +import org.springframework.security.crypto.util.EncodingUtils; + +/** + * {@link BytesEncryptor} that uses 256-bit AES/CBC/PKCS5Padding with a random 16-byte + * initialization vector. The IV is prepended to the ciphertext on encrypt and stripped on + * decrypt. + * + *

+ * Note that CBC mode provides confidentiality but not integrity or authenticity. + * Applications that require authenticated encryption should prefer + * {@link AesGcmBytesEncryptor}. See the + * OWASP Cryptographic Storage Cheat Sheet for guidance on choosing a cipher mode. + * + *

+ * When key derivation is used via {@link #withPassword(String, CharSequence)}, the key is + * derived using PBKDF2WithHmacSHA256 with {@code DEFAULT_PBKDF2_ITERATIONS} iterations + * per the + * OWASP Password Storage Cheat Sheet. Because derivation is intentionally expensive, + * the encryptor instance should be created once and reused rather than constructed + * per-operation. + * + * @author Josh Cummings + * @since 5.7.26 + * @see AesGcmBytesEncryptor + * @see AesBytesEncryptor + */ +public final class AesCbcBytesEncryptor implements BytesEncryptor { + + private static final String ALGORITHM = "AES/CBC/PKCS5Padding"; + + private static final int IV_LENGTH_BYTES = 16; + + private static final int DEFAULT_PBKDF2_ITERATIONS = 600_000; + + private final SecretKey secretKey; + + private final Cipher encryptor; + + private final Cipher decryptor; + + private final BytesKeyGenerator ivGenerator; + + private AesCbcBytesEncryptor(SecretKey secretKey, BytesKeyGenerator ivGenerator) { + this.secretKey = new SecretKeySpec(secretKey.getEncoded(), "AES"); + this.encryptor = CipherUtils.newCipher(ALGORITHM); + this.decryptor = CipherUtils.newCipher(ALGORITHM); + this.ivGenerator = ivGenerator; + } + + /** + * Creates an encryptor that derives its key from the given password and hex-encoded + * salt using PBKDF2WithHmacSHA1. + * @param password the password value + * @param salt the hex-encoded salt value + */ + public static Builder withPassword(String password, CharSequence salt) { + return new Builder(password, salt); + } + + /** + * Creates an encryptor using the supplied {@link SecretKey}. + * @param secretKey the secret (symmetric) key + */ + public static Builder withSecretKey(SecretKey secretKey) { + return new Builder(secretKey); + } + + @Override + public byte[] encrypt(byte[] bytes) { + synchronized (this.encryptor) { + byte[] iv = this.ivGenerator.generateKey(); + CipherUtils.initCipher(this.encryptor, Cipher.ENCRYPT_MODE, this.secretKey, new IvParameterSpec(iv)); + byte[] ciphertext = CipherUtils.doFinal(this.encryptor, bytes); + return EncodingUtils.concatenate(iv, ciphertext); + } + } + + @Override + public byte[] decrypt(byte[] encryptedBytes) { + int ivLength = this.ivGenerator.getKeyLength(); + byte[] iv = EncodingUtils.subArray(encryptedBytes, 0, ivLength); + byte[] ciphertext = EncodingUtils.subArray(encryptedBytes, ivLength, encryptedBytes.length); + synchronized (this.decryptor) { + CipherUtils.initCipher(this.decryptor, Cipher.DECRYPT_MODE, this.secretKey, new IvParameterSpec(iv)); + return CipherUtils.doFinal(this.decryptor, ciphertext); + } + } + + private static SecretKey deriveKey(String password, CharSequence salt) { + return CipherUtils.newSecretKey("PBKDF2WithHmacSHA256", + new PBEKeySpec(password.toCharArray(), Hex.decode(salt), DEFAULT_PBKDF2_ITERATIONS, 256)); + } + + /** + * A Builder for {@link AesCbcBytesEncryptor}. + */ + public static final class Builder { + + private final SecretKey secretKey; + + private BytesKeyGenerator ivGenerator = KeyGenerators.secureRandom(IV_LENGTH_BYTES); + + private Builder(SecretKey secretKey) { + this.secretKey = secretKey; + } + + private Builder(String password, CharSequence salt) { + this.secretKey = deriveKey(password, salt); + } + + /** + * Sets the {@link BytesKeyGenerator} to use for generating the initialization + * vector. + * @param ivGenerator the {@link BytesKeyGenerator} to use for generating the + * initialization vector + * @return this builder + */ + public Builder ivGenerator(BytesKeyGenerator ivGenerator) { + Objects.requireNonNull(ivGenerator, "ivGenerator cannot be null"); + this.ivGenerator = ivGenerator; + return this; + } + + /** + * Builds the {@link AesCbcBytesEncryptor}. + * @return the {@link AesCbcBytesEncryptor} + */ + public AesCbcBytesEncryptor build() { + return new AesCbcBytesEncryptor(this.secretKey, this.ivGenerator); + } + + } + +} diff --git a/crypto/src/main/java/org/springframework/security/crypto/encrypt/AesGcmBytesEncryptor.java b/crypto/src/main/java/org/springframework/security/crypto/encrypt/AesGcmBytesEncryptor.java new file mode 100644 index 0000000000..980221cb33 --- /dev/null +++ b/crypto/src/main/java/org/springframework/security/crypto/encrypt/AesGcmBytesEncryptor.java @@ -0,0 +1,172 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.crypto.encrypt; + +import java.util.Objects; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.SecretKeySpec; + +import org.springframework.security.crypto.codec.Hex; +import org.springframework.security.crypto.keygen.BytesKeyGenerator; +import org.springframework.security.crypto.keygen.KeyGenerators; +import org.springframework.security.crypto.util.EncodingUtils; + +/** + * {@link BytesEncryptor} that uses 256-bit AES/GCM/NoPadding with a random 16-byte + * initialization vector and a 128-bit authentication tag. The IV is prepended to the + * ciphertext on encrypt and stripped on decrypt. GCM provides authenticated encryption + * (AEAD): both confidentiality and integrity are protected, and decryption throws if the + * ciphertext has been tampered with. + * + *

+ * This class uses a 16-byte (128-bit) IV rather than the 12-byte (96-bit) IV recommended + * by NIST SP 800-38D for GCM. Both lengths are cryptographically valid; the 16-byte + * choice maintains consistency with the rest of the Spring Security crypto module. For + * additional guidance, please see the + * OWASP Cryptographic Storage Cheat Sheet. + * + *

+ * When key derivation is used via {@link #withPassword(String, CharSequence)}, the key is + * derived using PBKDF2WithHmacSHA256 with {@code DEFAULT_PBKDF2_ITERATIONS} iterations + * per the + * OWASP Password Storage Cheat Sheet. Because derivation is intentionally expensive, + * the encryptor instance should be created once and reused rather than constructed + * per-operation. + * + * @author Josh Cummings + * @since 5.7.26 + * @see AesCbcBytesEncryptor + * @see AesBytesEncryptor + */ +public final class AesGcmBytesEncryptor implements BytesEncryptor { + + private static final String ALGORITHM = "AES/GCM/NoPadding"; + + private static final int IV_LENGTH_BYTES = 16; + + private static final int TAG_LENGTH_BITS = 128; + + private static final int DEFAULT_PBKDF2_ITERATIONS = 600_000; + + private final SecretKey secretKey; + + private final Cipher encryptor; + + private final Cipher decryptor; + + private final BytesKeyGenerator ivGenerator; + + private AesGcmBytesEncryptor(SecretKey secretKey, BytesKeyGenerator ivGenerator) { + this.secretKey = new SecretKeySpec(secretKey.getEncoded(), "AES"); + this.encryptor = CipherUtils.newCipher(ALGORITHM); + this.decryptor = CipherUtils.newCipher(ALGORITHM); + this.ivGenerator = ivGenerator; + } + + /** + * Creates an encryptor that derives its key from the given password and hex-encoded + * salt using PBKDF2WithHmacSHA1. + * @param password the password value + * @param salt the hex-encoded salt value + */ + public static Builder withPassword(String password, CharSequence salt) { + return new Builder(password, salt); + } + + /** + * Creates an encryptor using the supplied {@link SecretKey}. + * @param secretKey the secret (symmetric) key + */ + public static Builder withSecretKey(SecretKey secretKey) { + return new Builder(secretKey); + } + + @Override + public byte[] encrypt(byte[] bytes) { + synchronized (this.encryptor) { + byte[] iv = this.ivGenerator.generateKey(); + CipherUtils.initCipher(this.encryptor, Cipher.ENCRYPT_MODE, this.secretKey, + new GCMParameterSpec(TAG_LENGTH_BITS, iv)); + byte[] ciphertext = CipherUtils.doFinal(this.encryptor, bytes); + return EncodingUtils.concatenate(iv, ciphertext); + } + } + + @Override + public byte[] decrypt(byte[] encryptedBytes) { + int ivLength = this.ivGenerator.getKeyLength(); + byte[] iv = EncodingUtils.subArray(encryptedBytes, 0, ivLength); + byte[] ciphertext = EncodingUtils.subArray(encryptedBytes, ivLength, encryptedBytes.length); + synchronized (this.decryptor) { + CipherUtils.initCipher(this.decryptor, Cipher.DECRYPT_MODE, this.secretKey, + new GCMParameterSpec(TAG_LENGTH_BITS, iv)); + return CipherUtils.doFinal(this.decryptor, ciphertext); + } + } + + private static SecretKey deriveKey(String password, CharSequence salt) { + return CipherUtils.newSecretKey("PBKDF2WithHmacSHA256", + new PBEKeySpec(password.toCharArray(), Hex.decode(salt), DEFAULT_PBKDF2_ITERATIONS, 256)); + } + + /** + * A Builder for {@link AesGcmBytesEncryptor}. + */ + public static final class Builder { + + private final SecretKey secretKey; + + private BytesKeyGenerator ivGenerator = KeyGenerators.secureRandom(IV_LENGTH_BYTES); + + private Builder(SecretKey secretKey) { + this.secretKey = secretKey; + } + + private Builder(String password, CharSequence salt) { + this.secretKey = deriveKey(password, salt); + } + + /** + * Sets the {@link BytesKeyGenerator} to use for generating the initialization + * vector. + * @param ivGenerator the {@link BytesKeyGenerator} to use for generating the + * initialization vector + * @return this builder + */ + public Builder ivGenerator(BytesKeyGenerator ivGenerator) { + Objects.requireNonNull(ivGenerator, "ivGenerator cannot be null"); + this.ivGenerator = ivGenerator; + return this; + } + + /** + * Builds the {@link AesGcmBytesEncryptor}. + * @return the {@link AesGcmBytesEncryptor} + */ + public AesGcmBytesEncryptor build() { + return new AesGcmBytesEncryptor(this.secretKey, this.ivGenerator); + } + + } + +} diff --git a/crypto/src/main/java/org/springframework/security/crypto/encrypt/Encryptors.java b/crypto/src/main/java/org/springframework/security/crypto/encrypt/Encryptors.java index 5239c300a9..71e35d2ec4 100644 --- a/crypto/src/main/java/org/springframework/security/crypto/encrypt/Encryptors.java +++ b/crypto/src/main/java/org/springframework/security/crypto/encrypt/Encryptors.java @@ -16,7 +16,6 @@ package org.springframework.security.crypto.encrypt; -import org.springframework.security.crypto.encrypt.AesBytesEncryptor.CipherAlgorithm; import org.springframework.security.crypto.keygen.KeyGenerators; /** @@ -42,9 +41,14 @@ public final class Encryptors { * not be shared * @param salt a hex-encoded, random, site-global salt value to use to generate the * key + * @deprecated Use {@link AesGcmBytesEncryptor#withPassword(String, CharSequence)} + * instead. */ + @Deprecated + @SuppressWarnings("deprecation") public static BytesEncryptor stronger(CharSequence password, CharSequence salt) { - return new AesBytesEncryptor(password.toString(), salt, KeyGenerators.secureRandom(16), CipherAlgorithm.GCM); + return new AesBytesEncryptor(password.toString(), salt, KeyGenerators.secureRandom(16), + AesBytesEncryptor.CipherAlgorithm.GCM); } /** @@ -53,18 +57,16 @@ public final class Encryptors { * Function #2). Salts the password to prevent dictionary attacks against the key. The * provided salt is expected to be hex-encoded; it should be random and at least 8 * bytes in length. Also applies a random 16-byte initialization vector to ensure each - * encrypted message will be unique. Requires Java 6. NOTE: This mode is not - * authenticated - * and does not provide any guarantees about the authenticity of the data. For a more - * secure alternative, users should prefer - * {@link #stronger(CharSequence, CharSequence)}. + * encrypted message will be unique. Requires Java 6. * @param password the password used to generate the encryptor's secret key; should * not be shared * @param salt a hex-encoded, random, site-global salt value to use to generate the * key - * - * @see Encryptors#stronger(CharSequence, CharSequence) + * @deprecated Use {@link AesCbcBytesEncryptor#withPassword(String, CharSequence)} + * instead. */ + @Deprecated + @SuppressWarnings("deprecation") public static BytesEncryptor standard(CharSequence password, CharSequence salt) { return new AesBytesEncryptor(password.toString(), salt, KeyGenerators.secureRandom(16)); } @@ -74,8 +76,12 @@ public final class Encryptors { * text is hex-encoded. * @param password the password used to generate the encryptor's secret key; should * not be shared + * @deprecated Use {@link AesGcmBytesEncryptor#withPassword(String, CharSequence)} + * instead. * @see Encryptors#stronger(CharSequence, CharSequence) */ + @Deprecated + @SuppressWarnings("deprecation") public static TextEncryptor delux(CharSequence password, CharSequence salt) { return new HexEncodingTextEncryptor(stronger(password, salt)); } @@ -85,8 +91,12 @@ public final class Encryptors { * text is hex-encoded. * @param password the password used to generate the encryptor's secret key; should * not be shared + * @deprecated Use {@link AesCbcBytesEncryptor#withPassword(String, CharSequence)} + * instead. * @see Encryptors#standard(CharSequence, CharSequence) */ + @Deprecated + @SuppressWarnings("deprecation") public static TextEncryptor text(CharSequence password, CharSequence salt) { return new HexEncodingTextEncryptor(standard(password, salt)); } diff --git a/crypto/src/test/java/org/springframework/security/crypto/encrypt/AesCbcBytesEncryptorTests.java b/crypto/src/test/java/org/springframework/security/crypto/encrypt/AesCbcBytesEncryptorTests.java new file mode 100644 index 0000000000..dfa187783d --- /dev/null +++ b/crypto/src/test/java/org/springframework/security/crypto/encrypt/AesCbcBytesEncryptorTests.java @@ -0,0 +1,203 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.crypto.encrypt; + +import java.nio.charset.StandardCharsets; + +import javax.crypto.SecretKey; +import javax.crypto.spec.PBEKeySpec; + +import org.junit.jupiter.api.Test; + +import org.springframework.security.crypto.codec.Hex; +import org.springframework.security.crypto.keygen.BytesKeyGenerator; +import org.springframework.security.crypto.keygen.KeyGenerators; +import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder.SecretKeyFactoryAlgorithm; +import org.springframework.security.crypto.util.EncodingUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link AesCbcBytesEncryptor}. + */ +class AesCbcBytesEncryptorTests { + + private final String secret = "value"; + + private final String password = "password"; + + private final String hexSalt = "deadbeef"; + + @Test + void roundtripWhenUsingPasswordAndSaltThenEncryptsAndDecrypts() { + CryptoAssumptions.assumeCBCJCE(); + AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt).build(); + byte[] encrypted = encryptor.encrypt(this.secret.getBytes()); + assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret); + } + + @Test + void roundtripWhenUsingSecretKeyThenEncryptsAndDecrypts() { + CryptoAssumptions.assumeCBCJCE(); + PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256); + SecretKey secretKey = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec); + AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withSecretKey(secretKey).build(); + byte[] encrypted = encryptor.encrypt(this.secret.getBytes()); + assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret); + } + + @Test + void encryptWhenUsingMockIvThenProducesKnownCiphertext() { + CryptoAssumptions.assumeCBCJCE(); + BytesKeyGenerator mockGenerator = mock(BytesKeyGenerator.class); + given(mockGenerator.generateKey()).willReturn(Hex.decode("4b0febebd439db7ca77153cb254520c3")); + given(mockGenerator.getKeyLength()).willReturn(16); + AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt) + .ivGenerator(mockGenerator) + .build(); + byte[] encrypted = encryptor.encrypt(this.secret.getBytes()); + assertThat(Hex.encode(encrypted)).isEqualTo("4b0febebd439db7ca77153cb254520c3b7232ac29355d07869433f1ecf55fe94"); + assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret); + } + + @Test + void encryptProducesUniqueOutputAndIvIsPrePended() { + CryptoAssumptions.assumeCBCJCE(); + AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt).build(); + byte[] first = encryptor.encrypt(this.secret.getBytes()); + byte[] second = encryptor.encrypt(this.secret.getBytes()); + assertThat(first).isNotEqualTo(second); + assertThat(first.length).isGreaterThan(16); + } + + @Test + @SuppressWarnings("deprecation") + void migratesFromDeprecatedNullIvCbcToAesCbcBytesEncryptor() { + CryptoAssumptions.assumeCBCJCE(); + AesBytesEncryptor deprecated = new AesBytesEncryptor(this.password, this.hexSalt); + byte[] encrypted = deprecated.encrypt(this.secret.getBytes()); + + AesCbcBytesEncryptor modern = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt).build(); + BytesEncryptor migrating = new MigratingBytesEncryptor("{CBC}", modern, deprecated); + + assertThat(new String(migrating.decrypt(encrypted))).isEqualTo(this.secret); + + byte[] migrated = migrating.encrypt(this.secret.getBytes()); + assertThat(migrated[0]).isEqualTo((byte) '{'); + assertThat(migrated[1]).isEqualTo((byte) 'C'); + assertThat(migrated[2]).isEqualTo((byte) 'B'); + assertThat(migrated[3]).isEqualTo((byte) 'C'); + assertThat(migrated[4]).isEqualTo((byte) '}'); + assertThat(new String(migrating.decrypt(migrated))).isEqualTo(this.secret); + } + + @Test + @SuppressWarnings("deprecation") + void withSecretWhenAesBytesEncryptorEncryptsThenDecrypts() { + CryptoAssumptions.assumeCBCJCE(); + PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256); + SecretKey key = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec); + AesBytesEncryptor deprecated = new AesBytesEncryptor(key, KeyGenerators.secureRandom(16), + AesBytesEncryptor.CipherAlgorithm.CBC); + AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withSecretKey(key).build(); + byte[] encrypted = deprecated.encrypt(this.secret.getBytes()); + assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret); + } + + @Test + @SuppressWarnings("deprecation") + void aesBytesEncryptorWhenEncryptsThenAesCbcBytesEncryptorDecrypts() { + CryptoAssumptions.assumeCBCJCE(); + PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256); + SecretKey key = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec); + AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withSecretKey(key).build(); + AesBytesEncryptor deprecated = new AesBytesEncryptor(key, KeyGenerators.secureRandom(16), + AesBytesEncryptor.CipherAlgorithm.CBC); + byte[] encrypted = encryptor.encrypt(this.secret.getBytes()); + assertThat(new String(deprecated.decrypt(encrypted))).isEqualTo(this.secret); + } + + @Test + void roundtripWhenUsingCustomIvGeneratorThenEncryptsAndDecrypts() { + CryptoAssumptions.assumeCBCJCE(); + BytesKeyGenerator customIvGenerator = mock(BytesKeyGenerator.class); + given(customIvGenerator.generateKey()).willReturn(Hex.decode("4b0febebd439db7ca77153cb254520c3")); + given(customIvGenerator.getKeyLength()).willReturn(16); + AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt) + .ivGenerator(customIvGenerator) + .build(); + byte[] encrypted = encryptor.encrypt(this.secret.getBytes()); + assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret); + } + + @Test + @SuppressWarnings("deprecation") + void withPasswordDerivesADifferentKeyThanAesBytesEncryptor() { + CryptoAssumptions.assumeCBCJCE(); + AesBytesEncryptor deprecated = new AesBytesEncryptor(this.password, this.hexSalt, + KeyGenerators.secureRandom(16)); + AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt).build(); + byte[] encrypted = deprecated.encrypt(this.secret.getBytes()); + assertThatIllegalStateException().isThrownBy(() -> encryptor.decrypt(encrypted)); + } + + private static final class MigratingBytesEncryptor implements BytesEncryptor { + + private final byte[] prefix; + + private final BytesEncryptor write; + + private final BytesEncryptor deprecated; + + MigratingBytesEncryptor(String prefix, BytesEncryptor write, BytesEncryptor deprecated) { + this.prefix = prefix.getBytes(StandardCharsets.US_ASCII); + this.write = write; + this.deprecated = deprecated; + } + + @Override + public byte[] encrypt(byte[] bytes) { + return EncodingUtils.concatenate(this.prefix, this.write.encrypt(bytes)); + } + + @Override + public byte[] decrypt(byte[] encryptedBytes) { + if (startsWith(encryptedBytes, this.prefix)) { + byte[] bytes = EncodingUtils.subArray(encryptedBytes, this.prefix.length, encryptedBytes.length); + return this.write.decrypt(bytes); + } + return this.deprecated.decrypt(encryptedBytes); + } + + private static boolean startsWith(byte[] data, byte[] prefix) { + if (data.length < prefix.length) { + return false; + } + for (int i = 0; i < prefix.length; i++) { + if (data[i] != prefix[i]) { + return false; + } + } + return true; + } + + } + +} diff --git a/crypto/src/test/java/org/springframework/security/crypto/encrypt/AesGcmBytesEncryptorTests.java b/crypto/src/test/java/org/springframework/security/crypto/encrypt/AesGcmBytesEncryptorTests.java new file mode 100644 index 0000000000..62f52bdcf1 --- /dev/null +++ b/crypto/src/test/java/org/springframework/security/crypto/encrypt/AesGcmBytesEncryptorTests.java @@ -0,0 +1,146 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.crypto.encrypt; + +import javax.crypto.SecretKey; +import javax.crypto.spec.PBEKeySpec; + +import org.junit.jupiter.api.Test; + +import org.springframework.security.crypto.codec.Hex; +import org.springframework.security.crypto.keygen.BytesKeyGenerator; +import org.springframework.security.crypto.keygen.KeyGenerators; +import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder.SecretKeyFactoryAlgorithm; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link AesGcmBytesEncryptor}. + */ +class AesGcmBytesEncryptorTests { + + private final String secret = "value"; + + private final String password = "password"; + + private final String hexSalt = "deadbeef"; + + @Test + void roundtripWhenUsingPasswordAndSaltThenEncryptsAndDecrypts() { + CryptoAssumptions.assumeGCMJCE(); + AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt).build(); + byte[] encrypted = encryptor.encrypt(this.secret.getBytes()); + assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret); + } + + @Test + void roundtripWhenUsingSecretKeyThenEncryptsAndDecrypts() { + CryptoAssumptions.assumeGCMJCE(); + PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256); + SecretKey secretKey = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec); + AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withSecretKey(secretKey).build(); + byte[] encrypted = encryptor.encrypt(this.secret.getBytes()); + assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret); + } + + @Test + void encryptWhenUsingMockIvThenProducesKnownCiphertext() { + CryptoAssumptions.assumeGCMJCE(); + BytesKeyGenerator mockGenerator = mock(BytesKeyGenerator.class); + given(mockGenerator.generateKey()).willReturn(Hex.decode("4b0febebd439db7ca77153cb254520c3")); + given(mockGenerator.getKeyLength()).willReturn(16); + AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt) + .ivGenerator(mockGenerator) + .build(); + byte[] encrypted = encryptor.encrypt(this.secret.getBytes()); + assertThat(Hex.encode(encrypted)) + .isEqualTo("4b0febebd439db7ca77153cb254520c3e4d61ae38207b4e42b820d311dc3d4e0e2f37ed5ee"); + assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret); + } + + @Test + void encryptProducesUniqueOutputAndIvIsPrepended() { + CryptoAssumptions.assumeGCMJCE(); + AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt).build(); + byte[] first = encryptor.encrypt(this.secret.getBytes()); + byte[] second = encryptor.encrypt(this.secret.getBytes()); + assertThat(first).isNotEqualTo(second); + assertThat(first.length).isGreaterThan(32); + } + + @Test + @SuppressWarnings("deprecation") + void withSecretWhenAesBytesEncryptorEncryptsThenDecrypts() { + CryptoAssumptions.assumeGCMJCE(); + PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256); + SecretKey key = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec); + AesBytesEncryptor deprecated = new AesBytesEncryptor(key, KeyGenerators.secureRandom(16), + AesBytesEncryptor.CipherAlgorithm.GCM); + AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withSecretKey(key).build(); + byte[] encrypted = deprecated.encrypt(this.secret.getBytes()); + assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret); + } + + @Test + @SuppressWarnings("deprecation") + void aesBytesEncryptorWhenEncryptsThenAesGcmBytesEncryptorDecrypts() { + CryptoAssumptions.assumeGCMJCE(); + PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256); + SecretKey key = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec); + AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withSecretKey(key) + .ivGenerator(KeyGenerators.secureRandom(12)) + .build(); + AesBytesEncryptor deprecated = new AesBytesEncryptor(key, KeyGenerators.secureRandom(12), + AesBytesEncryptor.CipherAlgorithm.GCM); + byte[] encrypted = encryptor.encrypt(this.secret.getBytes()); + assertThat(new String(deprecated.decrypt(encrypted))).isEqualTo(this.secret); + } + + @Test + void roundtripWhenUsingCustomIvGeneratorLengthThenEncryptsAndDecrypts() { + CryptoAssumptions.assumeGCMJCE(); + AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt) + .ivGenerator(KeyGenerators.secureRandom(12)) + .build(); + byte[] encrypted = encryptor.encrypt(this.secret.getBytes()); + assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret); + } + + @Test + @SuppressWarnings("deprecation") + void withPasswordDerivesADifferentKeyThanAesBytesEncryptor() { + CryptoAssumptions.assumeGCMJCE(); + AesBytesEncryptor deprecated = new AesBytesEncryptor(this.password, this.hexSalt, + KeyGenerators.secureRandom(16), AesBytesEncryptor.CipherAlgorithm.GCM); + AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt).build(); + byte[] encrypted = deprecated.encrypt(this.secret.getBytes()); + assertThatIllegalStateException().isThrownBy(() -> encryptor.decrypt(encrypted)); + } + + @Test + void decryptDetectsAuthenticationTagTampering() { + CryptoAssumptions.assumeGCMJCE(); + AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt).build(); + byte[] encrypted = encryptor.encrypt(this.secret.getBytes()); + encrypted[17] ^= 0xFF; + assertThatIllegalStateException().isThrownBy(() -> encryptor.decrypt(encrypted)); + } + +} diff --git a/crypto/src/test/java/org/springframework/security/crypto/encrypt/CryptoAssumptions.java b/crypto/src/test/java/org/springframework/security/crypto/encrypt/CryptoAssumptions.java index e9653769e7..cc72a9b830 100644 --- a/crypto/src/test/java/org/springframework/security/crypto/encrypt/CryptoAssumptions.java +++ b/crypto/src/test/java/org/springframework/security/crypto/encrypt/CryptoAssumptions.java @@ -24,25 +24,23 @@ import javax.crypto.NoSuchPaddingException; import org.junit.jupiter.api.Assumptions; import org.opentest4j.TestAbortedException; -import org.springframework.security.crypto.encrypt.AesBytesEncryptor.CipherAlgorithm; - public final class CryptoAssumptions { private CryptoAssumptions() { } public static void assumeGCMJCE() { - assumeAes256(CipherAlgorithm.GCM); + assumeAes256("GCM"); } public static void assumeCBCJCE() { - assumeAes256(CipherAlgorithm.CBC); + assumeAes256("CBC"); } - private static void assumeAes256(CipherAlgorithm cipherAlgorithm) { + private static void assumeAes256(String cipherAlgorithm) { boolean aes256Available = false; try { - Cipher.getInstance(cipherAlgorithm.toString()); + Cipher.getInstance(cipherAlgorithm); aes256Available = Cipher.getMaxAllowedKeyLength("AES") >= 256; } catch (NoSuchAlgorithmException ex) { diff --git a/docs/modules/ROOT/pages/features/integrations/cryptography.adoc b/docs/modules/ROOT/pages/features/integrations/cryptography.adoc index 01c71c36f0..62563cb3d0 100644 --- a/docs/modules/ROOT/pages/features/integrations/cryptography.adoc +++ b/docs/modules/ROOT/pages/features/integrations/cryptography.adoc @@ -8,8 +8,8 @@ The code is distributed as part of the core module but has no dependencies on an [[spring-security-crypto-encryption]] == Encryptors -The javadoc:org.springframework.security.crypto.encrypt.Encryptors[] class provides factory methods for constructing symmetric encryptors. -This class lets you create javadoc:org.springframework.security.crypto.encrypt.BytesEncryptor[] instances to encrypt data in raw `byte[]` form. +Spring Security provides javadoc:org.springframework.security.crypto.encrypt.AesGcmBytesEncryptor[] and javadoc:org.springframework.security.crypto.encrypt.AesCbcBytesEncryptor[] for constructing symmetric encryptors. +These can be used to encrypt data in raw `byte[]` form. You can also construct javadoc:org.springframework.security.crypto.encrypt.TextEncryptor[] instances to encrypt text strings. Encryptors are thread-safe. @@ -20,7 +20,7 @@ Both `BytesEncryptor` and `TextEncryptor` are interfaces. `BytesEncryptor` has m [[spring-security-crypto-encryption-bytes]] === BytesEncryptor -You can use the `Encryptors.stronger` factory method to construct a `BytesEncryptor`: +Use `AesGcmBytesEncryptor` to construct a BytesEncryptor with authenticated encryption: .BytesEncryptor [tabs] @@ -29,24 +29,22 @@ Java:: + [source,java,role="primary"] ---- -Encryptors.stronger("password", "salt"); +AesGcmBytesEncryptor.withPassword("password", "salt").build(); ---- Kotlin:: + [source,kotlin,role="secondary"] ---- -Encryptors.stronger("password", "salt") +AesGcmBytesEncryptor.withPassword("password", "salt").build() ---- ====== -The `stronger` encryption method creates an encryptor by using 256-bit AES encryption with -Galois Counter Mode (GCM). -It derives the secret key by using PKCS #5's PBKDF2 (Password-Based Key Derivation Function #2). -This method requires Java 6. -The password used to generate the `SecretKey` should be kept in a secure place and should not be shared. -The salt is used to prevent dictionary attacks against the key in the event that your encrypted data is compromised. -A 16-byte random initialization vector is also applied so that each encrypted message is unique. +`AesGcmBytesEncryptor` uses 256-bit AES encryption with Galois Counter Mode (GCM), providing https://en.wikipedia.org/wiki/Authenticated_encryption[authenticated encryption] (AEAD). +It derives the secret key using PKCS #5's PBKDF2 (Password-Based Key Derivation Function #2). +The password used to generate the SecretKey should be kept in a secure place and not be shared. +The salt is used to prevent dictionary attacks against the key in the event your encrypted data is compromised. +A 16-byte random initialization vector is also applied so each encrypted message is unique. The provided salt should be in hex-encoded String form, be random, and be at least 8 bytes in length. You can generate such a salt by using a `KeyGenerator`: @@ -69,14 +67,33 @@ val salt = KeyGenerators.string().generateKey() // generates a random 8-byte sal ---- ====== -You can also use the `standard` encryption method, which is 256-bit AES in Cipher Block Chaining (CBC) Mode. +Users who require CBC mode may use `AesCbcBytesEncryptor`: + +.AesCbcBytesEncryptor +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +AesCbcBytesEncryptor.withPassword("password", "salt").build(); +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +AesCbcBytesEncryptor.withPassword("password", "salt").build() +---- +====== + This mode is not https://en.wikipedia.org/wiki/Authenticated_encryption[authenticated] and does not provide any guarantees about the authenticity of the data. -For a more secure alternative, use `Encryptors.stronger`. +For a more secure alternative, users should prefer `AesGcmBytesEncryptor`. [[spring-security-crypto-encryption-text]] === TextEncryptor -You can use the `Encryptors.text` factory method to construct a standard TextEncryptor: +Use `AesCbcBytesEncryptor` to encrypt text data: .TextEncryptor [tabs] @@ -85,19 +102,22 @@ Java:: + [source,java,role="primary"] ---- -Encryptors.text("password", "salt"); +AesCbcBytesEncryptor.withPassword("password", "salt").build(); ---- Kotlin:: + [source,kotlin,role="secondary"] ---- -Encryptors.text("password", "salt") +AesCbcBytesEncryptor.withPassword("password", "salt").build() ---- ====== -A `TextEncryptor` uses a standard `BytesEncryptor` to encrypt text data. -Encrypted results are returned as hex-encoded strings for easy storage on the filesystem or in a database. +`AesCbcBytesEncryptor` encrypts data as raw bytes. +You can hex-encode the result for easy storage on the filesystem or in the database. + +NOTE: Queryable text encryption (encrypting such that the same plaintext always produces the same ciphertext) is no longer recommended, as it relies on a fixed initialization vector and does not provide adequate security. +Instead, look to your data store for a mechanism to query encrypted data. [[spring-security-crypto-keygenerators]] == Key Generators From 7442214225e7fd8ed84eeddf462791568c4052ee Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:33:02 -0600 Subject: [PATCH 13/17] Update to Spring Framework 7.0.9 Closes gh-19482 Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 75ce615cfa..223a6739cc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,7 +13,7 @@ org-jetbrains-kotlin = "2.2.21" org-jetbrains-kotlinx = "1.10.2" org-mockito = "5.17.0" org-opensaml5 = "5.1.6" -org-springframework = "7.0.8" +org-springframework = "7.0.9" com-password4j = "1.8.4" [libraries] From c6566ee772db7e2526f65718c3bdd9970bb92edf Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:33:38 -0600 Subject: [PATCH 14/17] Update to Reactor 2025.0.7 Closes gh-19483 Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 223a6739cc..cc17adae00 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,7 +32,7 @@ io-micrometer-context-propagation = "io.micrometer:context-propagation:1.1.4" io-micrometer-micrometer-bom = { module = "io.micrometer:micrometer-bom", version.ref = "io-micrometer" } io-micrometer-micrometer-observation = { module = "io.micrometer:micrometer-observation", version.ref = "io-micrometer" } io-mockk = "io.mockk:mockk:1.14.11" -io-projectreactor-reactor-bom = "io.projectreactor:reactor-bom:2025.0.6" +io-projectreactor-reactor-bom = "io.projectreactor:reactor-bom:2025.0.7" io-rsocket-rsocket-bom = { module = "io.rsocket:rsocket-bom", version.ref = "io-rsocket" } io-spring-javaformat-spring-javaformat-checkstyle = { module = "io.spring.javaformat:spring-javaformat-checkstyle", version.ref = "io-spring-javaformat" } io-spring-javaformat-spring-javaformat-gradle-plugin = { module = "io.spring.javaformat:spring-javaformat-gradle-plugin", version.ref = "io-spring-javaformat" } From 298335a57e5df31c9b94556e0b104e93ddc66ee6 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:33:58 -0600 Subject: [PATCH 15/17] Update to Micrometer 1.16.7 Closes gh-19484 Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cc17adae00..8d8a421e96 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] com-squareup-okhttp3 = "3.14.9" -io-micrometer = "1.16.6" +io-micrometer = "1.16.7" io-rsocket = "1.1.5" io-spring-javaformat = "0.0.47" io-spring-nohttp = "0.0.11" From f2115fbef84fef062345571f1e14d7669aea1dec Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:34:57 -0600 Subject: [PATCH 16/17] Update to Spring LDAP 4.0.5 Closes gh-19486 Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8d8a421e96..4d6a8ea3f8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -84,7 +84,7 @@ org-skyscreamer-jsonassert = "org.skyscreamer:jsonassert:1.5.3" org-slf4j-log4j-over-slf4j = "org.slf4j:log4j-over-slf4j:1.7.36" org-slf4j-slf4j-api = "org.slf4j:slf4j-api:2.0.18" org-springframework-data-spring-data-bom = "org.springframework.data:spring-data-bom:2025.1.6" -org-springframework-ldap-spring-ldap-core = "org.springframework.ldap:spring-ldap-core:4.0.4" +org-springframework-ldap-spring-ldap-core = "org.springframework.ldap:spring-ldap-core:4.0.5" org-springframework-spring-framework-bom = { module = "org.springframework:spring-framework-bom", version.ref = "org-springframework" } org-synchronoss-cloud-nio-multipart-parser = "org.synchronoss.cloud:nio-multipart-parser:1.1.0" tools-jackson-jackson-bom = "tools.jackson:jackson-bom:3.0.4" From 3b276e6c0d416ae65c67d81172667de6643c4e83 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:04:20 -0600 Subject: [PATCH 17/17] Update to Spring Data 2025.1.7 Closes gh-19485 Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4d6a8ea3f8..3039af98f5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -83,7 +83,7 @@ org-seleniumhq-selenium-selenium-support = "org.seleniumhq.selenium:selenium-sup org-skyscreamer-jsonassert = "org.skyscreamer:jsonassert:1.5.3" org-slf4j-log4j-over-slf4j = "org.slf4j:log4j-over-slf4j:1.7.36" org-slf4j-slf4j-api = "org.slf4j:slf4j-api:2.0.18" -org-springframework-data-spring-data-bom = "org.springframework.data:spring-data-bom:2025.1.6" +org-springframework-data-spring-data-bom = "org.springframework.data:spring-data-bom:2025.1.7" org-springframework-ldap-spring-ldap-core = "org.springframework.ldap:spring-ldap-core:4.0.5" org-springframework-spring-framework-bom = { module = "org.springframework:spring-framework-bom", version.ref = "org-springframework" } org-synchronoss-cloud-nio-multipart-parser = "org.synchronoss.cloud:nio-multipart-parser:1.1.0"