ソースを参照

Add Argon2 and BCrypt and Scrypt password encoders using Password4j library
Closes gh-17706

Signed-off-by: Mehrdad <mehrdad.bozorgmehr@gmail.com>
Signed-off-by: M.Bozorgmehr <mehrdad.bozorgmehr@gmail.com>

Mehrdad 3 週間 前
コミット
8c2ad4e4d1

+ 74 - 0
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.
+ *
+ * <p>
+ * 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.
+ * </p>
+ *
+ * <p>
+ * This implementation is thread-safe and can be shared across multiple threads.
+ * </p>
+ *
+ * <p>
+ * <strong>Usage Examples:</strong>
+ * </p>
+ * <pre>{@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));
+ * }</pre>
+ *
+ * @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);
+	}
+
+}

+ 72 - 0
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.
+ *
+ * <p>
+ * 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.
+ * </p>
+ *
+ * <p>
+ * This implementation is thread-safe and can be shared across multiple threads.
+ * </p>
+ *
+ * <p>
+ * <strong>Usage Examples:</strong>
+ * </p>
+ * <pre>{@code
+ * // Using default BCrypt settings (recommended)
+ * PasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
+ *
+ * // Using custom round count
+ * PasswordEncoder customEncoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(12));
+ * }</pre>
+ *
+ * @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);
+	}
+
+}

+ 13 - 73
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.
  *
  * <p>
- * 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.
  * </p>
  *
  * <p>
  * This implementation is thread-safe and can be shared across multiple threads.
  * </p>
  *
- * <p>
- * <strong>Usage Examples:</strong>
- * </p>
- * <pre>{@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));
- * }</pre>
- *
  * @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.
-	 *
-	 * <p>
-	 * It is recommended to use password4j's {@link AlgorithmFinder} to obtain default
-	 * instances with secure configurations:
-	 * </p>
-	 * <ul>
-	 * <li>{@code AlgorithmFinder.getBcryptInstance()} - BCrypt with default settings</li>
-	 * <li>{@code AlgorithmFinder.getArgon2Instance()} - Argon2 with default settings</li>
-	 * <li>{@code AlgorithmFinder.getScryptInstance()} - SCrypt with default settings</li>
-	 * <li>{@code AlgorithmFinder.getPBKDF2Instance()} - PBKDF2 with default settings</li>
-	 * </ul>
-	 *
-	 * <p>
-	 * For custom configurations, you can create specific function instances:
-	 * </p>
-	 * <ul>
-	 * <li>{@code BcryptFunction.getInstance(12)} - BCrypt with 12 rounds</li>
-	 * <li>{@code Argon2Function.getInstance(65536, 3, 4, 32, Argon2.ID)} - Custom
-	 * Argon2</li>
-	 * <li>{@code ScryptFunction.getInstance(16384, 8, 1, 32)} - Custom SCrypt</li>
-	 * <li>{@code CompressedPBKDF2Function.getInstance("SHA256", 310000, 32)} - Custom
-	 * PBKDF2</li>
-	 * </ul>
+	 * 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

+ 74 - 0
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.
+ *
+ * <p>
+ * 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.
+ * </p>
+ *
+ * <p>
+ * This implementation is thread-safe and can be shared across multiple threads.
+ * </p>
+ *
+ * <p>
+ * <strong>Usage Examples:</strong>
+ * </p>
+ * <pre>{@code
+ * // Using default SCrypt settings (recommended)
+ * PasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
+ *
+ * // Using custom SCrypt configuration
+ * PasswordEncoder customEncoder = new ScryptPassword4jPasswordEncoder(
+ *     ScryptFunction.getInstance(32768, 8, 1, 32));
+ * }</pre>
+ *
+ * @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);
+	}
+
+}

+ 245 - 0
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);
+	}
+
+}

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

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

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

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