From 8c2ad4e4d170eb6a15b5f567370b35e13b91e0db Mon Sep 17 00:00:00 2001 From: Mehrdad Date: Tue, 9 Sep 2025 22:38:00 +0330 Subject: [PATCH] Add Argon2 and BCrypt and Scrypt password encoders using Password4j library Closes gh-17706 Signed-off-by: Mehrdad Signed-off-by: M.Bozorgmehr --- .../Argon2Password4jPasswordEncoder.java | 74 ++++++ .../BcryptPassword4jPasswordEncoder.java | 72 +++++ .../password4j/Password4jPasswordEncoder.java | 86 +----- .../ScryptPassword4jPasswordEncoder.java | 74 ++++++ .../Argon2Password4jPasswordEncoderTests.java | 245 +++++++++++++++++ .../BcryptPassword4jPasswordEncoderTests.java | 217 +++++++++++++++ .../Password4jPasswordEncoderTests.java | 90 ++----- .../PasswordCompatibilityTests.java | 92 ++----- .../ScryptPassword4jPasswordEncoderTests.java | 248 ++++++++++++++++++ 9 files changed, 994 insertions(+), 204 deletions(-) create mode 100644 crypto/src/main/java/org/springframework/security/crypto/password4j/Argon2Password4jPasswordEncoder.java create mode 100644 crypto/src/main/java/org/springframework/security/crypto/password4j/BcryptPassword4jPasswordEncoder.java create mode 100644 crypto/src/main/java/org/springframework/security/crypto/password4j/ScryptPassword4jPasswordEncoder.java create mode 100644 crypto/src/test/java/org/springframework/security/crypto/password4j/Argon2Password4jPasswordEncoderTests.java create mode 100644 crypto/src/test/java/org/springframework/security/crypto/password4j/BcryptPassword4jPasswordEncoderTests.java create mode 100644 crypto/src/test/java/org/springframework/security/crypto/password4j/ScryptPassword4jPasswordEncoderTests.java diff --git a/crypto/src/main/java/org/springframework/security/crypto/password4j/Argon2Password4jPasswordEncoder.java b/crypto/src/main/java/org/springframework/security/crypto/password4j/Argon2Password4jPasswordEncoder.java new file mode 100644 index 0000000000..a0dc6a7513 --- /dev/null +++ b/crypto/src/main/java/org/springframework/security/crypto/password4j/Argon2Password4jPasswordEncoder.java @@ -0,0 +1,74 @@ +/* + * 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.password4j; + +import com.password4j.AlgorithmFinder; +import com.password4j.Argon2Function; + +/** + * Implementation of {@link org.springframework.security.crypto.password.PasswordEncoder} + * that uses the Password4j library with Argon2 hashing algorithm. + * + *

+ * Argon2 is the winner of the Password Hashing Competition (2015) and is recommended for + * new applications. It provides excellent resistance against GPU-based attacks and + * includes built-in salt generation. This implementation leverages Password4j's Argon2 + * support which properly includes the salt in the output hash. + *

+ * + *

+ * This implementation is thread-safe and can be shared across multiple threads. + *

+ * + *

+ * Usage Examples: + *

+ *
{@code
+ * // Using default Argon2 settings (recommended)
+ * PasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
+ *
+ * // Using custom Argon2 configuration
+ * PasswordEncoder customEncoder = new Argon2Password4jPasswordEncoder(
+ *     Argon2Function.getInstance(65536, 3, 4, 32, Argon2.ID));
+ * }
+ * + * @author Mehrdad Bozorgmehr + * @since 7.0 + * @see Argon2Function + * @see AlgorithmFinder#getArgon2Instance() + */ +public class Argon2Password4jPasswordEncoder extends Password4jPasswordEncoder { + + /** + * Constructs an Argon2 password encoder using the default Argon2 configuration from + * Password4j's AlgorithmFinder. + */ + public Argon2Password4jPasswordEncoder() { + super(AlgorithmFinder.getArgon2Instance()); + } + + /** + * Constructs an Argon2 password encoder with a custom Argon2 function. + * @param argon2Function the Argon2 function to use for encoding passwords, must not + * be null + * @throws IllegalArgumentException if argon2Function is null + */ + public Argon2Password4jPasswordEncoder(Argon2Function argon2Function) { + super(argon2Function); + } + +} diff --git a/crypto/src/main/java/org/springframework/security/crypto/password4j/BcryptPassword4jPasswordEncoder.java b/crypto/src/main/java/org/springframework/security/crypto/password4j/BcryptPassword4jPasswordEncoder.java new file mode 100644 index 0000000000..a1d8b8ae83 --- /dev/null +++ b/crypto/src/main/java/org/springframework/security/crypto/password4j/BcryptPassword4jPasswordEncoder.java @@ -0,0 +1,72 @@ +/* + * 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.password4j; + +import com.password4j.AlgorithmFinder; +import com.password4j.BcryptFunction; + +/** + * Implementation of {@link org.springframework.security.crypto.password.PasswordEncoder} + * that uses the Password4j library with BCrypt hashing algorithm. + * + *

+ * BCrypt is a well-established password hashing algorithm that includes built-in salt + * generation and is resistant to rainbow table attacks. This implementation leverages + * Password4j's BCrypt support which properly includes the salt in the output hash. + *

+ * + *

+ * This implementation is thread-safe and can be shared across multiple threads. + *

+ * + *

+ * Usage Examples: + *

+ *
{@code
+ * // Using default BCrypt settings (recommended)
+ * PasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
+ *
+ * // Using custom round count
+ * PasswordEncoder customEncoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(12));
+ * }
+ * + * @author Mehrdad Bozorgmehr + * @since 7.0 + * @see BcryptFunction + * @see AlgorithmFinder#getBcryptInstance() + */ +public class BcryptPassword4jPasswordEncoder extends Password4jPasswordEncoder { + + /** + * Constructs a BCrypt password encoder using the default BCrypt configuration from + * Password4j's AlgorithmFinder. + */ + public BcryptPassword4jPasswordEncoder() { + super(AlgorithmFinder.getBcryptInstance()); + } + + /** + * Constructs a BCrypt password encoder with a custom BCrypt function. + * @param bcryptFunction the BCrypt function to use for encoding passwords, must not + * be null + * @throws IllegalArgumentException if bcryptFunction is null + */ + public BcryptPassword4jPasswordEncoder(BcryptFunction bcryptFunction) { + super(bcryptFunction); + } + +} diff --git a/crypto/src/main/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoder.java b/crypto/src/main/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoder.java index 36d286584f..512da0e57a 100644 --- a/crypto/src/main/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoder.java +++ b/crypto/src/main/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoder.java @@ -16,116 +16,56 @@ package org.springframework.security.crypto.password4j; -import com.password4j.AlgorithmFinder; import com.password4j.Hash; import com.password4j.HashingFunction; import com.password4j.Password; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; import org.springframework.security.crypto.password.AbstractValidatingPasswordEncoder; import org.springframework.util.Assert; /** - * Implementation of {@link org.springframework.security.crypto.password.PasswordEncoder} - * that uses the Password4j library. This encoder supports multiple password hashing - * algorithms including BCrypt, SCrypt, Argon2, and PBKDF2. + * Abstract base class for Password4j-based password encoders. This class provides the + * common functionality for password encoding and verification using the Password4j + * library. * *

- * The encoder uses the provided {@link HashingFunction} for both encoding and - * verification. Password4j can automatically detect the algorithm used in existing hashes - * during verification. + * This class is package-private and should not be used directly. Instead, use the + * specific public subclasses that support verified hashing algorithms such as BCrypt, + * Argon2, and SCrypt implementations. *

* *

* This implementation is thread-safe and can be shared across multiple threads. *

* - *

- * Usage Examples: - *

- *
{@code
- * // Using default algorithms from AlgorithmFinder (recommended approach)
- * PasswordEncoder bcryptEncoder = new Password4jPasswordEncoder(AlgorithmFinder.getBcryptInstance());
- * PasswordEncoder argon2Encoder = new Password4jPasswordEncoder(AlgorithmFinder.getArgon2Instance());
- * PasswordEncoder scryptEncoder = new Password4jPasswordEncoder(AlgorithmFinder.getScryptInstance());
- * PasswordEncoder pbkdf2Encoder = new Password4jPasswordEncoder(AlgorithmFinder.getPBKDF2Instance());
- *
- * // Using customized algorithm parameters
- * PasswordEncoder customBcrypt = new Password4jPasswordEncoder(BcryptFunction.getInstance(12));
- * PasswordEncoder customArgon2 = new Password4jPasswordEncoder(
- *     Argon2Function.getInstance(65536, 3, 4, 32, Argon2.ID));
- * PasswordEncoder customScrypt = new Password4jPasswordEncoder(
- *     ScryptFunction.getInstance(32768, 8, 1, 32));
- * PasswordEncoder customPbkdf2 = new Password4jPasswordEncoder(
- *     CompressedPBKDF2Function.getInstance("SHA256", 310000, 32));
- * }
- * * @author Mehrdad Bozorgmehr * @since 7.0 - * @see AlgorithmFinder */ -public class Password4jPasswordEncoder extends AbstractValidatingPasswordEncoder { - - private final Log logger = LogFactory.getLog(getClass()); +abstract class Password4jPasswordEncoder extends AbstractValidatingPasswordEncoder { private final HashingFunction hashingFunction; /** - * Constructs a Password4j password encoder with the specified hashing function. - * - *

- * It is recommended to use password4j's {@link AlgorithmFinder} to obtain default - * instances with secure configurations: - *

- *
    - *
  • {@code AlgorithmFinder.getBcryptInstance()} - BCrypt with default settings
  • - *
  • {@code AlgorithmFinder.getArgon2Instance()} - Argon2 with default settings
  • - *
  • {@code AlgorithmFinder.getScryptInstance()} - SCrypt with default settings
  • - *
  • {@code AlgorithmFinder.getPBKDF2Instance()} - PBKDF2 with default settings
  • - *
- * - *

- * For custom configurations, you can create specific function instances: - *

- *
    - *
  • {@code BcryptFunction.getInstance(12)} - BCrypt with 12 rounds
  • - *
  • {@code Argon2Function.getInstance(65536, 3, 4, 32, Argon2.ID)} - Custom - * Argon2
  • - *
  • {@code ScryptFunction.getInstance(16384, 8, 1, 32)} - Custom SCrypt
  • - *
  • {@code CompressedPBKDF2Function.getInstance("SHA256", 310000, 32)} - Custom - * PBKDF2
  • - *
+ * Constructs a Password4j password encoder with the specified hashing function. This + * constructor is package-private and intended for use by subclasses only. * @param hashingFunction the hashing function to use for encoding passwords, must not * be null * @throws IllegalArgumentException if hashingFunction is null */ - public Password4jPasswordEncoder(HashingFunction hashingFunction) { + Password4jPasswordEncoder(HashingFunction hashingFunction) { Assert.notNull(hashingFunction, "hashingFunction cannot be null"); this.hashingFunction = hashingFunction; } @Override protected String encodeNonNullPassword(String rawPassword) { - try { - Hash hash = Password.hash(rawPassword).with(this.hashingFunction); - return hash.getResult(); - } - catch (Exception ex) { - throw new IllegalStateException("Failed to encode password using Password4j", ex); - } + Hash hash = Password.hash(rawPassword).with(this.hashingFunction); + return hash.getResult(); } @Override protected boolean matchesNonNull(String rawPassword, String encodedPassword) { - try { - // Use the specific hashing function for verification - return Password.check(rawPassword, encodedPassword).with(this.hashingFunction); - } - catch (Exception ex) { - this.logger.warn("Password verification failed for encoded password: " + encodedPassword, ex); - return false; - } + return Password.check(rawPassword, encodedPassword).with(this.hashingFunction); } @Override diff --git a/crypto/src/main/java/org/springframework/security/crypto/password4j/ScryptPassword4jPasswordEncoder.java b/crypto/src/main/java/org/springframework/security/crypto/password4j/ScryptPassword4jPasswordEncoder.java new file mode 100644 index 0000000000..c3e104bcd0 --- /dev/null +++ b/crypto/src/main/java/org/springframework/security/crypto/password4j/ScryptPassword4jPasswordEncoder.java @@ -0,0 +1,74 @@ +/* + * 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.password4j; + +import com.password4j.AlgorithmFinder; +import com.password4j.ScryptFunction; + +/** + * Implementation of {@link org.springframework.security.crypto.password.PasswordEncoder} + * that uses the Password4j library with SCrypt hashing algorithm. + * + *

+ * SCrypt is a memory-hard password hashing algorithm designed to be resistant to hardware + * brute-force attacks. It includes built-in salt generation and is particularly effective + * against ASIC and GPU-based attacks. This implementation leverages Password4j's SCrypt + * support which properly includes the salt in the output hash. + *

+ * + *

+ * This implementation is thread-safe and can be shared across multiple threads. + *

+ * + *

+ * Usage Examples: + *

+ *
{@code
+ * // Using default SCrypt settings (recommended)
+ * PasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
+ *
+ * // Using custom SCrypt configuration
+ * PasswordEncoder customEncoder = new ScryptPassword4jPasswordEncoder(
+ *     ScryptFunction.getInstance(32768, 8, 1, 32));
+ * }
+ * + * @author Mehrdad Bozorgmehr + * @since 7.0 + * @see ScryptFunction + * @see AlgorithmFinder#getScryptInstance() + */ +public class ScryptPassword4jPasswordEncoder extends Password4jPasswordEncoder { + + /** + * Constructs an SCrypt password encoder using the default SCrypt configuration from + * Password4j's AlgorithmFinder. + */ + public ScryptPassword4jPasswordEncoder() { + super(AlgorithmFinder.getScryptInstance()); + } + + /** + * Constructs an SCrypt password encoder with a custom SCrypt function. + * @param scryptFunction the SCrypt function to use for encoding passwords, must not + * be null + * @throws IllegalArgumentException if scryptFunction is null + */ + public ScryptPassword4jPasswordEncoder(ScryptFunction scryptFunction) { + super(scryptFunction); + } + +} diff --git a/crypto/src/test/java/org/springframework/security/crypto/password4j/Argon2Password4jPasswordEncoderTests.java b/crypto/src/test/java/org/springframework/security/crypto/password4j/Argon2Password4jPasswordEncoderTests.java new file mode 100644 index 0000000000..3783037161 --- /dev/null +++ b/crypto/src/test/java/org/springframework/security/crypto/password4j/Argon2Password4jPasswordEncoderTests.java @@ -0,0 +1,245 @@ +/* + * 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.password4j; + +import com.password4j.AlgorithmFinder; +import com.password4j.Argon2Function; +import com.password4j.types.Argon2; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link Argon2Password4jPasswordEncoder}. + * + * @author Mehrdad Bozorgmehr + */ +class Argon2Password4jPasswordEncoderTests { + + private static final String PASSWORD = "password"; + + private static final String LONG_PASSWORD = "a".repeat(1000); + + private static final String SPECIAL_CHARS_PASSWORD = "p@ssw0rd!#$%^&*()_+-=[]{}|;':\",./<>?"; + + private static final String UNICODE_PASSWORD = "пароль密码パスワード🔐"; + + @Test + void defaultConstructorShouldCreateWorkingEncoder() { + Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder(); + + String encoded = encoder.encode(PASSWORD); + + assertThat(encoded).isNotNull(); + assertThat(encoded).startsWith("$argon2"); // Argon2 hash format + assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); + } + + @Test + void constructorWithNullArgon2FunctionShouldThrowException() { + assertThatIllegalArgumentException().isThrownBy(() -> new Argon2Password4jPasswordEncoder(null)) + .withMessage("hashingFunction cannot be null"); + } + + @Test + void constructorWithCustomArgon2FunctionShouldWork() { + Argon2Function customFunction = Argon2Function.getInstance(4096, 3, 1, 32, Argon2.ID); + Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder(customFunction); + + String encoded = encoder.encode(PASSWORD); + + assertThat(encoded).isNotNull(); + assertThat(encoded).startsWith("$argon2id"); + assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); + } + + @ParameterizedTest + @EnumSource(Argon2.class) + void encodingShouldWorkWithDifferentArgon2Types(Argon2 type) { + Argon2Function function = Argon2Function.getInstance(4096, 3, 1, 32, type); + Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder(function); + + String encoded = encoder.encode(PASSWORD); + + assertThat(encoded).isNotNull(); + assertThat(encoded).startsWith("$argon2" + type.name().toLowerCase()); + assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); + } + + @Test + void encodingShouldGenerateDifferentHashesForSamePassword() { + Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder(); + + String hash1 = encoder.encode(PASSWORD); + String hash2 = encoder.encode(PASSWORD); + + assertThat(hash1).isNotEqualTo(hash2); + assertThat(encoder.matches(PASSWORD, hash1)).isTrue(); + assertThat(encoder.matches(PASSWORD, hash2)).isTrue(); + } + + @Test + void shouldHandleLongPasswords() { + Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder(); + + String encoded = encoder.encode(LONG_PASSWORD); + + assertThat(encoder.matches(LONG_PASSWORD, encoded)).isTrue(); + assertThat(encoder.matches("wrong", encoded)).isFalse(); + } + + @Test + void shouldHandleSpecialCharacters() { + Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder(); + + String encoded = encoder.encode(SPECIAL_CHARS_PASSWORD); + + assertThat(encoder.matches(SPECIAL_CHARS_PASSWORD, encoded)).isTrue(); + assertThat(encoder.matches("wrong", encoded)).isFalse(); + } + + @Test + void shouldHandleUnicodeCharacters() { + Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder(); + + String encoded = encoder.encode(UNICODE_PASSWORD); + + assertThat(encoder.matches(UNICODE_PASSWORD, encoded)).isTrue(); + assertThat(encoder.matches("wrong", encoded)).isFalse(); + } + + @Test + void shouldRejectIncorrectPasswords() { + Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder(); + String encoded = encoder.encode(PASSWORD); + + assertThat(encoder.matches("wrongpassword", encoded)).isFalse(); + assertThat(encoder.matches("PASSWORD", encoded)).isFalse(); // Case sensitive + assertThat(encoder.matches("password ", encoded)).isFalse(); // Trailing space + assertThat(encoder.matches(" password", encoded)).isFalse(); // Leading space + } + + @Test + void matchesShouldReturnFalseForNullOrEmptyInputs() { + Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder(); + String encoded = encoder.encode(PASSWORD); + + assertThat(encoder.matches(null, encoded)).isFalse(); + assertThat(encoder.matches("", encoded)).isFalse(); + assertThat(encoder.matches(PASSWORD, null)).isFalse(); + assertThat(encoder.matches(PASSWORD, "")).isFalse(); + assertThat(encoder.matches(null, null)).isFalse(); + assertThat(encoder.matches("", "")).isFalse(); + } + + @Test + void encodeNullShouldReturnNull() { + Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder(); + + assertThat(encoder.encode(null)).isNull(); + } + + @Test + void upgradeEncodingShouldReturnFalse() { + Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder(); + String encoded = encoder.encode(PASSWORD); + + assertThat(encoder.upgradeEncoding(encoded)).isFalse(); + } + + @Test + void shouldWorkWithAlgorithmFinderDefaults() { + Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder( + AlgorithmFinder.getArgon2Instance()); + + String encoded = encoder.encode(PASSWORD); + + assertThat(encoded).isNotNull(); + assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); + } + + @Test + void shouldRejectMalformedHashes() { + Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder(); + + // For Argon2, Password4j may throw BadParametersException on malformed hashes. + // We treat either an exception or a false return as a successful rejection. + assertMalformedRejected(encoder, PASSWORD, "invalid_hash"); + assertMalformedRejected(encoder, PASSWORD, "$argon2id$invalid"); + assertMalformedRejected(encoder, PASSWORD, ""); + } + + private void assertMalformedRejected(Argon2Password4jPasswordEncoder encoder, String raw, String malformed) { + boolean rejected = false; + try { + rejected = !encoder.matches(raw, malformed); + } + catch (RuntimeException ex) { + // Accept exception as valid rejection path for malformed input + rejected = true; + } + assertThat(rejected).as("Malformed hash should not validate: " + malformed).isTrue(); + } + + @Test + void shouldHandleEmptyStringPassword() { + Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder(); + + String encoded = encoder.encode(""); + + assertThat(encoded).isNotNull(); + boolean emptyStringMatches; + try { + emptyStringMatches = encoder.matches("", encoded); + } + catch (RuntimeException ex) { + emptyStringMatches = false; // treat exception as non-match but still + // acceptable behavior + } + + if (emptyStringMatches) { + assertThat(encoder.matches("", encoded)).isTrue(); + } + else { + assertThat(encoded).isNotEmpty(); + } + assertThat(encoder.matches("notEmpty", encoded)).isFalse(); + } + + @Test + void shouldHandleCustomMemoryAndIterationParameters() { + // Test with different memory and iteration parameters + Argon2Function lowMemory = Argon2Function.getInstance(1024, 2, 1, 16, Argon2.ID); + Argon2Function highMemory = Argon2Function.getInstance(65536, 4, 2, 64, Argon2.ID); + + Argon2Password4jPasswordEncoder lowEncoder = new Argon2Password4jPasswordEncoder(lowMemory); + Argon2Password4jPasswordEncoder highEncoder = new Argon2Password4jPasswordEncoder(highMemory); + + String lowEncoded = lowEncoder.encode(PASSWORD); + String highEncoded = highEncoder.encode(PASSWORD); + + assertThat(lowEncoder.matches(PASSWORD, lowEncoded)).isTrue(); + assertThat(highEncoder.matches(PASSWORD, highEncoded)).isTrue(); + + // Each encoder should work with hashes generated by the same parameters + assertThat(lowEncoded).isNotEqualTo(highEncoded); + } + +} diff --git a/crypto/src/test/java/org/springframework/security/crypto/password4j/BcryptPassword4jPasswordEncoderTests.java b/crypto/src/test/java/org/springframework/security/crypto/password4j/BcryptPassword4jPasswordEncoderTests.java new file mode 100644 index 0000000000..d790b20613 --- /dev/null +++ b/crypto/src/test/java/org/springframework/security/crypto/password4j/BcryptPassword4jPasswordEncoderTests.java @@ -0,0 +1,217 @@ +/* + * 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.password4j; + +import com.password4j.AlgorithmFinder; +import com.password4j.BcryptFunction; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link BcryptPassword4jPasswordEncoder}. + * + * @author Mehrdad Bozorgmehr + */ +class BcryptPassword4jPasswordEncoderTests { + + private static final String PASSWORD = "password"; + + private static final String LONG_PASSWORD = "a".repeat(72); // BCrypt max length + + private static final String VERY_LONG_PASSWORD = "a".repeat(100); // Beyond BCrypt max + // length + + private static final String SPECIAL_CHARS_PASSWORD = "p@ssw0rd!#$%^&*()_+-=[]{}|;':\",./<>?"; + + private static final String UNICODE_PASSWORD = "пароль密码パスワード🔐"; + + @Test + void defaultConstructorShouldCreateWorkingEncoder() { + BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(); + + String encoded = encoder.encode(PASSWORD); + + assertThat(encoded).isNotNull().matches("^\\$2[aby]?\\$.*"); + assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); + } + + @Test + void constructorWithNullBcryptFunctionShouldThrowException() { + assertThatIllegalArgumentException().isThrownBy(() -> new BcryptPassword4jPasswordEncoder(null)) + .withMessage("hashingFunction cannot be null"); + } + + @Test + void constructorWithCustomBcryptFunctionShouldWork() { + BcryptFunction customFunction = BcryptFunction.getInstance(6); + BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(customFunction); + + String encoded = encoder.encode(PASSWORD); + + assertThat(encoded).isNotNull().contains("$06$"); // 6 rounds + assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); + } + + @ParameterizedTest + @ValueSource(ints = { 4, 6, 8, 10, 12 }) + void encodingShouldWorkWithDifferentRounds(int rounds) { + BcryptFunction function = BcryptFunction.getInstance(rounds); + BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(function); + + String encoded = encoder.encode(PASSWORD); + + assertThat(encoded).isNotNull().contains(String.format("$%02d$", rounds)); + assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); + } + + @Test + void encodingShouldGenerateDifferentHashesForSamePassword() { + BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(); + + String hash1 = encoder.encode(PASSWORD); + String hash2 = encoder.encode(PASSWORD); + + assertThat(hash1).isNotEqualTo(hash2); + assertThat(encoder.matches(PASSWORD, hash1)).isTrue(); + assertThat(encoder.matches(PASSWORD, hash2)).isTrue(); + } + + @Test + void shouldHandleLongPasswords() { + BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(); + + String encodedLong = encoder.encode(LONG_PASSWORD); + String encodedVeryLong = encoder.encode(VERY_LONG_PASSWORD); + + assertThat(encoder.matches(LONG_PASSWORD, encodedLong)).isTrue(); + assertThat(encoder.matches(VERY_LONG_PASSWORD, encodedVeryLong)).isTrue(); + } + + @Test + void shouldHandleSpecialCharacters() { + BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(); + + String encoded = encoder.encode(SPECIAL_CHARS_PASSWORD); + + assertThat(encoder.matches(SPECIAL_CHARS_PASSWORD, encoded)).isTrue(); + assertThat(encoder.matches("wrong", encoded)).isFalse(); + } + + @Test + void shouldHandleUnicodeCharacters() { + BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(); + + String encoded = encoder.encode(UNICODE_PASSWORD); + + assertThat(encoder.matches(UNICODE_PASSWORD, encoded)).isTrue(); + assertThat(encoder.matches("wrong", encoded)).isFalse(); + } + + @Test + void shouldRejectIncorrectPasswords() { + BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(); + String encoded = encoder.encode(PASSWORD); + + assertThat(encoder.matches("wrongpassword", encoded)).isFalse(); + assertThat(encoder.matches("PASSWORD", encoded)).isFalse(); // Case sensitive + assertThat(encoder.matches("password ", encoded)).isFalse(); // Trailing space + assertThat(encoder.matches(" password", encoded)).isFalse(); // Leading space + } + + @Test + void matchesShouldReturnFalseForNullOrEmptyInputs() { + BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(); + String encoded = encoder.encode(PASSWORD); + + assertThat(encoder.matches(null, encoded)).isFalse(); + assertThat(encoder.matches("", encoded)).isFalse(); + assertThat(encoder.matches(PASSWORD, null)).isFalse(); + assertThat(encoder.matches(PASSWORD, "")).isFalse(); + assertThat(encoder.matches(null, null)).isFalse(); + assertThat(encoder.matches("", "")).isFalse(); + } + + @Test + void encodeNullShouldReturnNull() { + BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(); + + assertThat(encoder.encode(null)).isNull(); + } + + @Test + void upgradeEncodingShouldReturnFalse() { + BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(); + String encoded = encoder.encode(PASSWORD); + + assertThat(encoder.upgradeEncoding(encoded)).isFalse(); + } + + @Test + void shouldWorkWithAlgorithmFinderDefaults() { + BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder( + AlgorithmFinder.getBcryptInstance()); + + String encoded = encoder.encode(PASSWORD); + + assertThat(encoded).isNotNull(); + assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); + } + + @Test + void shouldRejectMalformedHashes() { + BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(); + assertMalformedRejected(encoder, PASSWORD, "invalid_hash"); + assertMalformedRejected(encoder, PASSWORD, "$2a$10$invalid"); + assertMalformedRejected(encoder, PASSWORD, ""); + } + + private void assertMalformedRejected(BcryptPassword4jPasswordEncoder encoder, String raw, String malformed) { + boolean rejected; + try { + rejected = !encoder.matches(raw, malformed); + } + catch (RuntimeException ex) { + rejected = true; // exception is acceptable rejection + } + assertThat(rejected).as("Malformed hash should not validate: " + malformed).isTrue(); + } + + @Test + void shouldHandleEmptyStringPassword() { + BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(); + String encoded = encoder.encode(""); + assertThat(encoded).isNotNull(); + boolean emptyMatches; + try { + emptyMatches = encoder.matches("", encoded); + } + catch (RuntimeException ex) { + emptyMatches = false; // treat as non-match if library rejects empty raw + } + // Either behavior acceptable; if it matches, verify; if not, still ensure other + // mismatches remain false. + if (emptyMatches) { + assertThat(encoder.matches("", encoded)).isTrue(); + } + assertThat(encoder.matches("notEmpty", encoded)).isFalse(); + } + +} diff --git a/crypto/src/test/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoderTests.java b/crypto/src/test/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoderTests.java index f4bec5dd4a..f24bfbe42f 100644 --- a/crypto/src/test/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoderTests.java +++ b/crypto/src/test/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoderTests.java @@ -16,16 +16,14 @@ package org.springframework.security.crypto.password4j; -import com.password4j.AlgorithmFinder; import com.password4j.BcryptFunction; -import com.password4j.HashingFunction; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; /** - * Tests for {@link Password4jPasswordEncoder}. + * Base functionality tests for {@link Password4jPasswordEncoder} implementations. These + * tests verify the common behavior across all concrete password encoder subclasses. * * @author Mehrdad Bozorgmehr */ @@ -35,27 +33,10 @@ class Password4jPasswordEncoderTests { private static final String WRONG_PASSWORD = "wrongpassword"; - // Constructor Tests - @Test - void constructorWithNullHashingFunctionShouldThrowException() { - assertThatIllegalArgumentException().isThrownBy(() -> new Password4jPasswordEncoder(null)) - .withMessage("hashingFunction cannot be null"); - } - - @Test - void constructorWithValidHashingFunctionShouldWork() { - HashingFunction hashingFunction = BcryptFunction.getInstance(10); - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(hashingFunction); - assertThat(encoder).isNotNull(); - } - - // Basic functionality tests with real HashingFunction instances + // Test abstract class behavior through concrete implementation @Test void encodeShouldReturnNonNullHashedPassword() { - HashingFunction hashingFunction = BcryptFunction.getInstance(4); // Use low cost - // for faster - // tests - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(hashingFunction); + BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4)); String result = encoder.encode(PASSWORD); @@ -64,10 +45,7 @@ class Password4jPasswordEncoderTests { @Test void matchesShouldReturnTrueForValidPassword() { - HashingFunction hashingFunction = BcryptFunction.getInstance(4); // Use low cost - // for faster - // tests - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(hashingFunction); + BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4)); String encoded = encoder.encode(PASSWORD); boolean result = encoder.matches(PASSWORD, encoded); @@ -77,10 +55,7 @@ class Password4jPasswordEncoderTests { @Test void matchesShouldReturnFalseForInvalidPassword() { - HashingFunction hashingFunction = BcryptFunction.getInstance(4); // Use low cost - // for faster - // tests - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(hashingFunction); + BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4)); String encoded = encoder.encode(PASSWORD); boolean result = encoder.matches(WRONG_PASSWORD, encoded); @@ -89,20 +64,27 @@ class Password4jPasswordEncoderTests { } @Test - void matchesShouldReturnFalseForMalformedHash() { - HashingFunction hashingFunction = BcryptFunction.getInstance(4); - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(hashingFunction); + void encodeNullPasswordShouldReturnNull() { + BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4)); - // Test with malformed hash that should cause Password4j to throw an exception - boolean result = encoder.matches(PASSWORD, "invalid-hash-format"); + assertThat(encoder.encode(null)).isNull(); + } - assertThat(result).isFalse(); + @Test + void multipleEncodesProduceDifferentHashesButAllMatch() { + BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4)); + + String encoded1 = encoder.encode(PASSWORD); + String encoded2 = encoder.encode(PASSWORD); + // Bcrypt should produce different salted hashes for the same raw password + assertThat(encoded1).isNotEqualTo(encoded2); + assertThat(encoder.matches(PASSWORD, encoded1)).isTrue(); + assertThat(encoder.matches(PASSWORD, encoded2)).isTrue(); } @Test void upgradeEncodingShouldReturnFalse() { - HashingFunction hashingFunction = BcryptFunction.getInstance(4); - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(hashingFunction); + BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4)); String encoded = encoder.encode(PASSWORD); boolean result = encoder.upgradeEncoding(encoded); @@ -110,32 +92,14 @@ class Password4jPasswordEncoderTests { assertThat(result).isFalse(); } - // AlgorithmFinder Sanity Check Tests - @Test - void algorithmFinderBcryptSanityCheck() { - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(AlgorithmFinder.getBcryptInstance()); - - String encoded = encoder.encode(PASSWORD); - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse(); - } - - @Test - void algorithmFinderArgon2SanityCheck() { - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(AlgorithmFinder.getArgon2Instance()); - - String encoded = encoder.encode(PASSWORD); - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse(); - } - @Test - void algorithmFinderScryptSanityCheck() { - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(AlgorithmFinder.getScryptInstance()); - + void matchesShouldReturnFalseWhenRawOrEncodedNullOrEmpty() { + BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4)); String encoded = encoder.encode(PASSWORD); - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse(); + assertThat(encoder.matches(null, encoded)).isFalse(); + assertThat(encoder.matches("", encoded)).isFalse(); + assertThat(encoder.matches(PASSWORD, null)).isFalse(); + assertThat(encoder.matches(PASSWORD, "")).isFalse(); } } diff --git a/crypto/src/test/java/org/springframework/security/crypto/password4j/PasswordCompatibilityTests.java b/crypto/src/test/java/org/springframework/security/crypto/password4j/PasswordCompatibilityTests.java index 44248aff44..6360cd164e 100644 --- a/crypto/src/test/java/org/springframework/security/crypto/password4j/PasswordCompatibilityTests.java +++ b/crypto/src/test/java/org/springframework/security/crypto/password4j/PasswordCompatibilityTests.java @@ -16,24 +16,21 @@ package org.springframework.security.crypto.password4j; -import com.password4j.AlgorithmFinder; import com.password4j.Argon2Function; import com.password4j.BcryptFunction; -import com.password4j.CompressedPBKDF2Function; import com.password4j.ScryptFunction; import com.password4j.types.Argon2; import org.junit.jupiter.api.Test; import org.springframework.security.crypto.argon2.Argon2PasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder; import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder; import static org.assertj.core.api.Assertions.assertThat; /** * Tests compatibility between existing Spring Security password encoders and - * {@link Password4jPasswordEncoder}. + * Password4j-based password encoders. * * @author Mehrdad Bozorgmehr */ @@ -45,7 +42,8 @@ class PasswordCompatibilityTests { @Test void bcryptEncodedWithSpringSecurityShouldMatchWithPassword4j() { BCryptPasswordEncoder springEncoder = new BCryptPasswordEncoder(10); - Password4jPasswordEncoder password4jEncoder = new Password4jPasswordEncoder(BcryptFunction.getInstance(10)); + BcryptPassword4jPasswordEncoder password4jEncoder = new BcryptPassword4jPasswordEncoder( + BcryptFunction.getInstance(10)); String encodedBySpring = springEncoder.encode(PASSWORD); boolean matchedByPassword4j = password4jEncoder.matches(PASSWORD, encodedBySpring); @@ -54,9 +52,10 @@ class PasswordCompatibilityTests { } @Test - void bcryptEncodedWithPassword4jShouldMatchWithSpringSecurity() { + void bcryptEncodedWithPassword4jShouldMatchWithSpringSecirity() { + BcryptPassword4jPasswordEncoder password4jEncoder = new BcryptPassword4jPasswordEncoder( + BcryptFunction.getInstance(10)); BCryptPasswordEncoder springEncoder = new BCryptPasswordEncoder(10); - Password4jPasswordEncoder password4jEncoder = new Password4jPasswordEncoder(BcryptFunction.getInstance(10)); String encodedByPassword4j = password4jEncoder.encode(PASSWORD); boolean matchedBySpring = springEncoder.matches(PASSWORD, encodedByPassword4j); @@ -64,12 +63,12 @@ class PasswordCompatibilityTests { assertThat(matchedBySpring).isTrue(); } - // SCrypt Compatibility Tests + // Argon2 Compatibility Tests @Test - void scryptEncodedWithSpringSecurityShouldMatchWithPassword4j() { - SCryptPasswordEncoder springEncoder = new SCryptPasswordEncoder(16384, 8, 1, 32, 64); - Password4jPasswordEncoder password4jEncoder = new Password4jPasswordEncoder( - ScryptFunction.getInstance(16384, 8, 1, 32)); + void argon2EncodedWithSpringSecurityShouldMatchWithPassword4j() { + Argon2PasswordEncoder springEncoder = new Argon2PasswordEncoder(16, 32, 1, 4096, 3); + Argon2Password4jPasswordEncoder password4jEncoder = new Argon2Password4jPasswordEncoder( + Argon2Function.getInstance(4096, 3, 1, 32, Argon2.ID)); String encodedBySpring = springEncoder.encode(PASSWORD); boolean matchedByPassword4j = password4jEncoder.matches(PASSWORD, encodedBySpring); @@ -78,10 +77,10 @@ class PasswordCompatibilityTests { } @Test - void scryptEncodedWithPassword4jShouldMatchWithSpringSecurity() { - SCryptPasswordEncoder springEncoder = new SCryptPasswordEncoder(16384, 8, 1, 32, 64); - Password4jPasswordEncoder password4jEncoder = new Password4jPasswordEncoder( - ScryptFunction.getInstance(16384, 8, 1, 32)); + void argon2EncodedWithPassword4jShouldMatchWithSpringSecirity() { + Argon2Password4jPasswordEncoder password4jEncoder = new Argon2Password4jPasswordEncoder( + Argon2Function.getInstance(4096, 3, 1, 32, Argon2.ID)); + Argon2PasswordEncoder springEncoder = new Argon2PasswordEncoder(16, 32, 1, 4096, 3); String encodedByPassword4j = password4jEncoder.encode(PASSWORD); boolean matchedBySpring = springEncoder.matches(PASSWORD, encodedByPassword4j); @@ -89,12 +88,12 @@ class PasswordCompatibilityTests { assertThat(matchedBySpring).isTrue(); } - // Argon2 Compatibility Tests + // SCrypt Compatibility Tests @Test - void argon2EncodedWithSpringSecurityShouldMatchWithPassword4j() { - Argon2PasswordEncoder springEncoder = new Argon2PasswordEncoder(16, 32, 1, 65536, 3); - Password4jPasswordEncoder password4jEncoder = new Password4jPasswordEncoder( - Argon2Function.getInstance(65536, 3, 1, 32, Argon2.ID)); + void scryptEncodedWithSpringSecurityShouldMatchWithPassword4j() { + SCryptPasswordEncoder springEncoder = new SCryptPasswordEncoder(16384, 8, 1, 32, 64); + ScryptPassword4jPasswordEncoder password4jEncoder = new ScryptPassword4jPasswordEncoder( + ScryptFunction.getInstance(16384, 8, 1, 32)); String encodedBySpring = springEncoder.encode(PASSWORD); boolean matchedByPassword4j = password4jEncoder.matches(PASSWORD, encodedBySpring); @@ -103,10 +102,10 @@ class PasswordCompatibilityTests { } @Test - void argon2EncodedWithPassword4jShouldMatchWithSpringSecurity() { - Argon2PasswordEncoder springEncoder = new Argon2PasswordEncoder(16, 32, 1, 65536, 3); - Password4jPasswordEncoder password4jEncoder = new Password4jPasswordEncoder( - Argon2Function.getInstance(65536, 3, 1, 32, Argon2.ID)); + void scryptEncodedWithPassword4jShouldMatchWithSpringSecirity() { + ScryptPassword4jPasswordEncoder password4jEncoder = new ScryptPassword4jPasswordEncoder( + ScryptFunction.getInstance(16384, 8, 1, 32)); + SCryptPasswordEncoder springEncoder = new SCryptPasswordEncoder(16384, 8, 1, 32, 64); String encodedByPassword4j = password4jEncoder.encode(PASSWORD); boolean matchedBySpring = springEncoder.matches(PASSWORD, encodedByPassword4j); @@ -114,47 +113,4 @@ class PasswordCompatibilityTests { assertThat(matchedBySpring).isTrue(); } - // PBKDF2 Compatibility Tests - Note: Different format implementations - @Test - void pbkdf2BasicFunctionalityTest() { - // Test that both encoders work independently with their own formats - // Spring Security PBKDF2 - Pbkdf2PasswordEncoder springEncoder = new Pbkdf2PasswordEncoder("", 16, 100000, - Pbkdf2PasswordEncoder.SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA256); - String springEncoded = springEncoder.encode(PASSWORD); - assertThat(springEncoder.matches(PASSWORD, springEncoded)).isTrue(); - - // Password4j PBKDF2 - Password4jPasswordEncoder password4jEncoder = new Password4jPasswordEncoder( - CompressedPBKDF2Function.getInstance("SHA256", 100000, 32)); - String password4jEncoded = password4jEncoder.encode(PASSWORD); - assertThat(password4jEncoder.matches(PASSWORD, password4jEncoded)).isTrue(); - - // Note: These encoders use different hash formats and are not cross-compatible - // This is expected behavior due to different implementation standards - } - - // Cross-Algorithm Tests (should fail) - @Test - void bcryptEncodedPasswordShouldNotMatchArgon2Encoder() { - Password4jPasswordEncoder bcryptEncoder = new Password4jPasswordEncoder(BcryptFunction.getInstance(10)); - Password4jPasswordEncoder argon2Encoder = new Password4jPasswordEncoder(AlgorithmFinder.getArgon2Instance()); - - String bcryptEncoded = bcryptEncoder.encode(PASSWORD); - boolean matchedByArgon2 = argon2Encoder.matches(PASSWORD, bcryptEncoded); - - assertThat(matchedByArgon2).isFalse(); - } - - @Test - void argon2EncodedPasswordShouldNotMatchScryptEncoder() { - Password4jPasswordEncoder argon2Encoder = new Password4jPasswordEncoder(AlgorithmFinder.getArgon2Instance()); - Password4jPasswordEncoder scryptEncoder = new Password4jPasswordEncoder(AlgorithmFinder.getScryptInstance()); - - String argon2Encoded = argon2Encoder.encode(PASSWORD); - boolean matchedByScrypt = scryptEncoder.matches(PASSWORD, argon2Encoded); - - assertThat(matchedByScrypt).isFalse(); - } - } diff --git a/crypto/src/test/java/org/springframework/security/crypto/password4j/ScryptPassword4jPasswordEncoderTests.java b/crypto/src/test/java/org/springframework/security/crypto/password4j/ScryptPassword4jPasswordEncoderTests.java new file mode 100644 index 0000000000..cfbba9d5e8 --- /dev/null +++ b/crypto/src/test/java/org/springframework/security/crypto/password4j/ScryptPassword4jPasswordEncoderTests.java @@ -0,0 +1,248 @@ +/* + * 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.password4j; + +import com.password4j.AlgorithmFinder; +import com.password4j.ScryptFunction; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link ScryptPassword4jPasswordEncoder}. + * + * @author Mehrdad Bozorgmehr + */ +class ScryptPassword4jPasswordEncoderTests { + + private static final String PASSWORD = "password"; + + private static final String LONG_PASSWORD = "a".repeat(1000); + + private static final String SPECIAL_CHARS_PASSWORD = "p@ssw0rd!#$%^&*()_+-=[]{}|;':\",./<>?"; + + private static final String UNICODE_PASSWORD = "пароль密码パスワード🔐"; + + @Test + void defaultConstructorShouldCreateWorkingEncoder() { + ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder(); + + String encoded = encoder.encode(PASSWORD); + + assertThat(encoded).isNotNull(); + // Password4j scrypt format differs from classic $s0$; accept generic multi-part + // format + assertThat(encoded.split("\\$").length).isGreaterThanOrEqualTo(3); + assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); + } + + @Test + void constructorWithNullScryptFunctionShouldThrowException() { + assertThatIllegalArgumentException().isThrownBy(() -> new ScryptPassword4jPasswordEncoder(null)) + .withMessage("hashingFunction cannot be null"); + } + + @Test + void constructorWithCustomScryptFunctionShouldWork() { + ScryptFunction customFunction = ScryptFunction.getInstance(16384, 8, 1, 32); + ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder(customFunction); + + String encoded = encoder.encode(PASSWORD); + + assertThat(encoded).isNotNull(); + assertThat(encoded.split("\\$").length).isGreaterThanOrEqualTo(3); + assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); + } + + @ParameterizedTest + @CsvSource({ "1024, 8, 1, 16", "4096, 8, 1, 32", "16384, 8, 1, 32", "32768, 8, 1, 64" }) + void encodingShouldWorkWithDifferentParameters(int N, int r, int p, int dkLen) { + ScryptFunction function = ScryptFunction.getInstance(N, r, p, dkLen); + ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder(function); + + String encoded = encoder.encode(PASSWORD); + + assertThat(encoded).isNotNull(); + assertThat(encoded.split("\\$").length).isGreaterThanOrEqualTo(3); + assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); + } + + @Test + void encodingShouldGenerateDifferentHashesForSamePassword() { + ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder(); + + String hash1 = encoder.encode(PASSWORD); + String hash2 = encoder.encode(PASSWORD); + + assertThat(hash1).isNotEqualTo(hash2); + assertThat(encoder.matches(PASSWORD, hash1)).isTrue(); + assertThat(encoder.matches(PASSWORD, hash2)).isTrue(); + } + + @Test + void shouldHandleLongPasswords() { + ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder(); + + String encoded = encoder.encode(LONG_PASSWORD); + + assertThat(encoder.matches(LONG_PASSWORD, encoded)).isTrue(); + assertThat(encoder.matches("wrong", encoded)).isFalse(); + } + + @Test + void shouldHandleSpecialCharacters() { + ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder(); + + String encoded = encoder.encode(SPECIAL_CHARS_PASSWORD); + + assertThat(encoder.matches(SPECIAL_CHARS_PASSWORD, encoded)).isTrue(); + assertThat(encoder.matches("wrong", encoded)).isFalse(); + } + + @Test + void shouldHandleUnicodeCharacters() { + ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder(); + + String encoded = encoder.encode(UNICODE_PASSWORD); + + assertThat(encoder.matches(UNICODE_PASSWORD, encoded)).isTrue(); + assertThat(encoder.matches("wrong", encoded)).isFalse(); + } + + @Test + void shouldRejectIncorrectPasswords() { + ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder(); + String encoded = encoder.encode(PASSWORD); + + assertThat(encoder.matches("wrongpassword", encoded)).isFalse(); + assertThat(encoder.matches("PASSWORD", encoded)).isFalse(); // Case sensitive + assertThat(encoder.matches("password ", encoded)).isFalse(); // Trailing space + assertThat(encoder.matches(" password", encoded)).isFalse(); // Leading space + } + + @Test + void matchesShouldReturnFalseForNullOrEmptyInputs() { + ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder(); + String encoded = encoder.encode(PASSWORD); + + assertThat(encoder.matches(null, encoded)).isFalse(); + assertThat(encoder.matches("", encoded)).isFalse(); + assertThat(encoder.matches(PASSWORD, null)).isFalse(); + assertThat(encoder.matches(PASSWORD, "")).isFalse(); + assertThat(encoder.matches(null, null)).isFalse(); + assertThat(encoder.matches("", "")).isFalse(); + } + + @Test + void encodeNullShouldReturnNull() { + ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder(); + + assertThat(encoder.encode(null)).isNull(); + } + + @Test + void upgradeEncodingShouldReturnFalse() { + ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder(); + String encoded = encoder.encode(PASSWORD); + + assertThat(encoder.upgradeEncoding(encoded)).isFalse(); + } + + @Test + void shouldWorkWithAlgorithmFinderDefaults() { + ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder( + AlgorithmFinder.getScryptInstance()); + + String encoded = encoder.encode(PASSWORD); + + assertThat(encoded).isNotNull(); + assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); + } + + @Test + void shouldRejectMalformedHashes() { + ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder(); + assertMalformedRejected(encoder, PASSWORD, "invalid_hash"); + assertMalformedRejected(encoder, PASSWORD, "$s0$invalid"); + assertMalformedRejected(encoder, PASSWORD, ""); + } + + private void assertMalformedRejected(ScryptPassword4jPasswordEncoder encoder, String raw, String malformed) { + boolean rejected; + try { + rejected = !encoder.matches(raw, malformed); + } + catch (RuntimeException ex) { + rejected = true; // exception path acceptable + } + assertThat(rejected).as("Malformed hash should not validate: " + malformed).isTrue(); + } + + @Test + void shouldHandleEmptyStringPassword() { + ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder(); + String encoded = encoder.encode(""); + assertThat(encoded).isNotNull(); + boolean emptyMatches; + try { + emptyMatches = encoder.matches("", encoded); + } + catch (RuntimeException ex) { + emptyMatches = false; + } + if (emptyMatches) { + assertThat(encoder.matches("", encoded)).isTrue(); + } + assertThat(encoder.matches("notEmpty", encoded)).isFalse(); + } + + @Test + void shouldHandleCustomCostParameters() { + // Test with low cost parameters for speed + ScryptFunction lowCost = ScryptFunction.getInstance(1024, 1, 1, 16); + // Test with higher cost parameters + ScryptFunction highCost = ScryptFunction.getInstance(32768, 8, 2, 64); + + ScryptPassword4jPasswordEncoder lowEncoder = new ScryptPassword4jPasswordEncoder(lowCost); + ScryptPassword4jPasswordEncoder highEncoder = new ScryptPassword4jPasswordEncoder(highCost); + + String lowEncoded = lowEncoder.encode(PASSWORD); + String highEncoded = highEncoder.encode(PASSWORD); + + assertThat(lowEncoder.matches(PASSWORD, lowEncoded)).isTrue(); + assertThat(highEncoder.matches(PASSWORD, highEncoded)).isTrue(); + + // Each encoder should work with hashes generated by the same parameters + assertThat(lowEncoded).isNotEqualTo(highEncoded); + } + + @Test + void shouldHandleEdgeCaseParameters() { + // Test with minimum practical parameters + ScryptFunction minParams = ScryptFunction.getInstance(2, 1, 1, 1); + ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder(minParams); + + String encoded = encoder.encode(PASSWORD); + + assertThat(encoded).isNotNull(); + assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); + } + +}