From 9d8026fcf3904c267e63eb9eaa9e916798e2361f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 03:06:03 +0000 Subject: [PATCH 01/38] Bump com.fasterxml.jackson:jackson-bom from 2.22.1 to 2.22.2 Bumps [com.fasterxml.jackson:jackson-bom](https://github.com/FasterXML/jackson-bom) from 2.22.1 to 2.22.2. - [Commits](https://github.com/FasterXML/jackson-bom/compare/jackson-bom-2.22.1...jackson-bom-2.22.2) --- updated-dependencies: - dependency-name: com.fasterxml.jackson:jackson-bom dependency-version: 2.22.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- 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 a86e54a89b..c6a78cfb52 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,7 +19,7 @@ com-password4j = "1.8.4" [libraries] ch-qos-logback-logback-classic = "ch.qos.logback:logback-classic:1.6.3" -com-fasterxml-jackson-jackson-bom = "com.fasterxml.jackson:jackson-bom:2.22.1" +com-fasterxml-jackson-jackson-bom = "com.fasterxml.jackson:jackson-bom:2.22.2" com-google-inject-guice = "com.google.inject:guice:3.0" com-netflix-nebula-nebula-project-plugin = "com.netflix.nebula:nebula-project-plugin:8.2.0" com-nimbusds-nimbus-jose-jwt = "com.nimbusds:nimbus-jose-jwt:10.9.1" From 15a31d7dd78d2e5b29d7c974d3c228e8c486a76c Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:11:26 -0600 Subject: [PATCH 02/38] Check isAnyLocalAddress This commit updates InternalInetAddressMatcher to check InetAddress#isAnyLocalAddress in order to catch additional IP addresses Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com> --- .../security/util/matcher/InetAddressMatchers.java | 4 +++- .../security/util/matcher/InetAddressMatchersTests.java | 9 +++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/core/src/main/java/org/springframework/security/util/matcher/InetAddressMatchers.java b/core/src/main/java/org/springframework/security/util/matcher/InetAddressMatchers.java index dbe4e7be65..6ba23ff4f5 100644 --- a/core/src/main/java/org/springframework/security/util/matcher/InetAddressMatchers.java +++ b/core/src/main/java/org/springframework/security/util/matcher/InetAddressMatchers.java @@ -259,7 +259,9 @@ public final class InetAddressMatchers { if (address.isLoopbackAddress() || address.isLinkLocalAddress() || address.isSiteLocalAddress()) { return true; } - + if (address.isAnyLocalAddress()) { + return true; + } byte[] rawAddress = address.getAddress(); if (rawAddress.length == 16) { diff --git a/core/src/test/java/org/springframework/security/util/matcher/InetAddressMatchersTests.java b/core/src/test/java/org/springframework/security/util/matcher/InetAddressMatchersTests.java index 387af3c381..9eb960a3b0 100644 --- a/core/src/test/java/org/springframework/security/util/matcher/InetAddressMatchersTests.java +++ b/core/src/test/java/org/springframework/security/util/matcher/InetAddressMatchersTests.java @@ -276,16 +276,17 @@ class InetAddressMatchersTests { } @ParameterizedTest - @ValueSource(strings = { "127.0.0.1", "127.0.0.255" }) + @ValueSource(strings = { "127.0.0.1", "127.0.0.255", "0.0.0.0" }) void matchesWhenIpv4LoopbackThenReturnsTrue(String address) throws Exception { InetAddressMatcher matcher = InetAddressMatchers.matchInternal().build(); assertThat(matcher.matches(InetAddress.getByName(address))).isTrue(); } - @Test - void matchesWhenIpv6LoopbackThenReturnsTrue() throws Exception { + @ParameterizedTest + @ValueSource(strings = { "::1", "::" }) + void matchesWhenIpv6LoopbackThenReturnsTrue(String address) throws Exception { InetAddressMatcher matcher = InetAddressMatchers.matchInternal().build(); - assertThat(matcher.matches(InetAddress.getByName("::1"))).isTrue(); + assertThat(matcher.matches(InetAddress.getByName(address))).isTrue(); } @ParameterizedTest From 865085e0b0d05e8b3625b149535bb7b8e7fdf18c 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 03/38] 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 47804ee8348bb9227350f65053a722a551c242b2 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 04/38] 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 c84d57f70a..617db00330 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; @@ -191,38 +193,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 9f94fb92c5aec38bb591d568729aeacf43f7a88d 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 05/38] 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 836190546db3b723ba1a818cd0c08e43c8aad611 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 06/38] 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 01452e328fc96a8e5e5730889391a6a512d8c0a5 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 07/38] 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 e6208a48325d5866bdcb6f6b0f137fd68f30557a 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 08/38] 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 617db00330..96a5660da3 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; @@ -27,6 +25,7 @@ import java.util.concurrent.ConcurrentHashMap; import org.jspecify.annotations.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; @@ -193,43 +192,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 61be628ad6a6ed18177b1954b0b10571e108a1d3 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 09/38] 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 4c41928bc780a478c476ecedffb0259881fe3ee1 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 10/38] Provide ability to configure DPoP proof replay --- .../OAuth2ResourceServerConfigurer.java | 4 +- .../resource/DPoPAuthenticationTests.java | 31 +- .../core/DelegatingOAuth2TokenValidator.java | 14 + .../jwt/DPoPProofJwtDecoderFactory.java | 181 +++++-- .../oauth2/jwt/DPoPProofReplayValidator.java | 447 ++++++++++++++++++ .../DPoPAuthenticationProvider.java | 128 +---- 6 files changed, 641 insertions(+), 164 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/OAuth2ResourceServerConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java index 7cb3e224b1..6570538fe4 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 @@ -706,8 +706,10 @@ public final class OAuth2ResourceServerConfigurer oauth2 .jwt(Customizer.withDefaults()) - .dPoP(Customizer.withDefaults())); - + .dPoP(Customizer.withDefaults()) + .withObjectPostProcessor(dPoPProofVerifierFactoryCustomizer())); // @formatter:on return http.build(); } @@ -258,6 +266,25 @@ public class DPoPAuthenticationTests { 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..2a6596beb6 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,73 @@ 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; + JwtIssuedAtValidator existingJwtIssuedAtValidator = CollectionUtils.findValueOfType(customValidators, + JwtIssuedAtValidator.class); + if (existingJwtIssuedAtValidator != null) { + jwtIssuedAtValidator = existingJwtIssuedAtValidator; + customValidators.remove(jwtIssuedAtValidator); + } + else { + jwtIssuedAtValidator = new JwtIssuedAtValidator(true); + jwtIssuedAtValidator.setClockSkew(clockSkew); + } + final DPoPProofReplayValidator dPoPProofReplayValidator; + DPoPProofReplayValidator existingDPoPProofReplayValidator = CollectionUtils.findValueOfType(customValidators, + DPoPProofReplayValidator.class); + if (existingDPoPProofReplayValidator != null) { + dPoPProofReplayValidator = existingDPoPProofReplayValidator; + 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 +208,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 +251,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..ca13866e56 --- /dev/null +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/DPoPProofReplayValidator.java @@ -0,0 +1,447 @@ +/* + * 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, @Nullable Object value) { + putIfAbsent(key, value); + } + + @Override + public @Nullable ValueWrapper putIfAbsent(Object key, @Nullable Object value) { + Assert.notNull(value, "value cannot be null"); + 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 361dec2113..ba0a19e4fb 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,14 +16,9 @@ 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.jspecify.annotations.Nullable; import org.springframework.security.authentication.AuthenticationManager; @@ -31,22 +26,16 @@ 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 @@ -73,18 +62,7 @@ public final class DPoPAuthenticationProvider implements AuthenticationProvider public DPoPAuthenticationProvider(AuthenticationManager tokenAuthenticationManager) { Assert.notNull(tokenAuthenticationManager, "tokenAuthenticationManager cannot be null"); this.tokenAuthenticationManager = tokenAuthenticationManager; - Function> jwtValidatorFactory = (context) -> { - OAuth2AccessTokenClaims accessToken = context.getAccessToken(); - Assert.notNull(accessToken, "accessToken cannot be null"); - return new DelegatingOAuth2TokenValidator<>( - // Use default validators - DPoPProofJwtDecoderFactory.DEFAULT_JWT_VALIDATOR_FACTORY.apply(context), - // Add custom validators - new AthClaimValidator(accessToken), new JwkThumbprintValidator(accessToken)); - }; - DPoPProofJwtDecoderFactory dPoPProofJwtDecoderFactory = new DPoPProofJwtDecoderFactory(); - dPoPProofJwtDecoderFactory.setJwtValidatorFactory(jwtValidatorFactory); - this.dPoPProofVerifierFactory = dPoPProofJwtDecoderFactory; + this.dPoPProofVerifierFactory = new DPoPProofJwtDecoderFactory(); } @Override @@ -119,7 +97,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); } @@ -144,108 +122,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 3836091fe29aa92db05db0fc82b7b4d950a4402f 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 11/38] 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 35859220cb9266ca467abfb02095c6dfd5bb5ba7 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 12/38] 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 b9a4a7373b..7b2eb0e506 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; @@ -98,7 +99,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 7db67f923c747ba45230c1bb273ca6107c4f4cac 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 13/38] 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 | 11 ++- .../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(+), 6 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 205a403e2e..f4bb48e28a 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 @@ -257,8 +257,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; @@ -317,8 +317,7 @@ public class Webauthn4JRelyingPartyOperations implements WebAuthnRelyingPartyOpe private com.webauthn4j.data.PublicKeyCredentialParameters convertParamToWebauthn4j( PublicKeyCredentialParameters parameter) { - PublicKeyCredentialType credentialType = PublicKeyCredentialType.valueOf(parameter.getType().getValue()); - if (credentialType != PublicKeyCredentialType.PUBLIC_KEY) { + if (!PublicKeyCredentialType.PUBLIC_KEY.equals(parameter.getType())) { throw new IllegalArgumentException( "Cannot convert unknown credential type " + parameter.getType() + " to webauthn4j"); } @@ -394,8 +393,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 502ecf9241885d65c327edab6d88fb38622130b3 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 14/38] 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 057581584a8030622a29562bd8e6869be58810ca Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:12:25 -0600 Subject: [PATCH 15/38] Align Null Behavior Previously, ExternalInetAddressMatcher treated a null InetAddress as external by negating InternalInetAddressMatcher's result. This commit makes ExternalInetAddressMatcher return false for a null address, so that neither the internal nor the external matcher classifies an unknown address as a match. Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com> --- .../security/util/matcher/InetAddressMatchers.java | 3 +++ .../security/util/matcher/InetAddressMatchersTests.java | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/core/src/main/java/org/springframework/security/util/matcher/InetAddressMatchers.java b/core/src/main/java/org/springframework/security/util/matcher/InetAddressMatchers.java index 6ba23ff4f5..0f7ae8ebc9 100644 --- a/core/src/main/java/org/springframework/security/util/matcher/InetAddressMatchers.java +++ b/core/src/main/java/org/springframework/security/util/matcher/InetAddressMatchers.java @@ -337,6 +337,9 @@ public final class InetAddressMatchers { @Override public boolean matches(@Nullable InetAddress address) { + if (address == null) { + return false; + } return !this.internalMatcher.matches(address); } diff --git a/core/src/test/java/org/springframework/security/util/matcher/InetAddressMatchersTests.java b/core/src/test/java/org/springframework/security/util/matcher/InetAddressMatchersTests.java index 9eb960a3b0..fbd0be0646 100644 --- a/core/src/test/java/org/springframework/security/util/matcher/InetAddressMatchersTests.java +++ b/core/src/test/java/org/springframework/security/util/matcher/InetAddressMatchersTests.java @@ -416,6 +416,12 @@ class InetAddressMatchersTests { @Nested class ExternalInetAddressMatcherTests { + @Test + void matchesWhenInetAddressNullThenReturnsFalse() { + InetAddressMatcher matcher = InetAddressMatchers.matchExternal().build(); + assertThat(matcher.matches((InetAddress) null)).isFalse(); + } + @ParameterizedTest @ValueSource(strings = { "8.8.8.8", "1.1.1.1" }) void matchesWhenIpv4PublicThenReturnsTrue(String address) throws Exception { From 748e3c9cf094248b9729441d6bea57d194c012d1 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:38:57 -0600 Subject: [PATCH 16/38] Use jspecify Nullable in DPoPProofReplayValidator org.springframework.lang.Nullable is banned by the bannedNullabilityImports checkstyle rule on this line; the embargoed commit that introduced this file predates that rule's adoption here. Switch to org.jspecify.annotations.Nullable to match the rest of the codebase (already used correctly elsewhere in this same file). Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com> --- .../security/oauth2/jwt/DPoPProofReplayValidator.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index ca13866e56..04d782b80e 100644 --- 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 @@ -30,10 +30,10 @@ import java.util.concurrent.ConcurrentMap; import java.util.concurrent.atomic.AtomicBoolean; import com.nimbusds.jose.jwk.JWK; +import org.jspecify.annotations.Nullable; 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; From fdec2d7617b1b9d9bf7c78d30ac911046842b14c Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:46:54 -0600 Subject: [PATCH 17/38] Update to Spring Framework 7.0.9 Closes gh-19487 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 c6a78cfb52..9ff30ec192 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,7 +14,7 @@ org-jetbrains-kotlin = "2.4.10" org-jetbrains-kotlinx = "1.11.0" org-mockito = "5.23.0" org-opensaml5 = "5.2.3" -org-springframework = "7.0.8" +org-springframework = "7.0.9" com-password4j = "1.8.4" [libraries] From cf613ad2d3816ba4dee4c03ff20ea33597160eeb Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:47:17 -0600 Subject: [PATCH 18/38] Update to Reactor 2025.0.7 Closes gh-19488 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 9ff30ec192..84dfdb6b89 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,7 +33,7 @@ io-micrometer-context-propagation = "io.micrometer:context-propagation:1.2.1" 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 9a70d73036f552e0dc5bdfeca28b8f1c0f8b1017 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:47:45 -0600 Subject: [PATCH 19/38] Update to Micrometer 1.17.1 Closes gh-19489 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 84dfdb6b89..de3d2ce4f4 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.17.0" +io-micrometer = "1.17.1" io-rsocket = "1.1.5" io-spring-javaformat = "0.0.47" io-spring-nohttp = "0.0.11" From ff108a49a530e97e77c9483579cd2f44bd8fe9f2 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:48:35 -0600 Subject: [PATCH 20/38] Update to Spring LDAP 4.1.1 Closes gh-19491 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 de3d2ce4f4..74a4888ac7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -86,7 +86,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:2026.0.0" -org-springframework-ldap-spring-ldap-core = "org.springframework.ldap:spring-ldap-core:4.1.0" +org-springframework-ldap-spring-ldap-core = "org.springframework.ldap:spring-ldap-core:4.1.1" 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.2.2" From 7048b08b9cc81d6efd4cac853e269ddb834857d7 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:05:01 -0600 Subject: [PATCH 21/38] Update to Spring Data 2026.0.1 Closes gh-19490 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 74a4888ac7..655809338f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -85,7 +85,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:2026.0.0" +org-springframework-data-spring-data-bom = "org.springframework.data:spring-data-bom:2026.0.1" org-springframework-ldap-spring-ldap-core = "org.springframework.ldap:spring-ldap-core:4.1.1" 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" 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 22/38] 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 23/38] 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 24/38] 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 25/38] 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 26/38] 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 27/38] 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 28/38] 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 29/38] 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 30/38] 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 31/38] 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 32/38] 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 33/38] 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 34/38] 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 35/38] 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 36/38] 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 37/38] 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 38/38] 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"