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] 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(); + } + }