Selaa lähdekoodia

Create a specific implementation for BalloonHashing and PBKDF2 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 2 viikkoa sitten
vanhempi
commit
2d74f9c334

+ 159 - 0
crypto/src/main/java/org/springframework/security/crypto/password4j/BalloonHashingPassword4jPasswordEncoder.java

@@ -0,0 +1,159 @@
+/*
+ * 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 java.security.SecureRandom;
+import java.util.Base64;
+
+import com.password4j.AlgorithmFinder;
+import com.password4j.BalloonHashingFunction;
+import com.password4j.Hash;
+import com.password4j.Password;
+
+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 with Balloon hashing algorithm.
+ *
+ * <p>
+ * Balloon hashing is a memory-hard password hashing algorithm designed to be resistant to
+ * both time-memory trade-off attacks and side-channel attacks. This implementation
+ * handles the salt management explicitly since Password4j's Balloon hashing
+ * implementation does not include the salt in the output hash.
+ * </p>
+ *
+ * <p>
+ * The encoded password format is: {salt}:{hash} where both salt and hash are Base64
+ * encoded.
+ * </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 Balloon hashing settings (recommended)
+ * PasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
+ *
+ * // Using custom Balloon hashing function
+ * PasswordEncoder customEncoder = new BalloonHashingPassword4jPasswordEncoder(
+ *     BalloonHashingFunction.getInstance(1024, 3, 4, "SHA-256"));
+ * }</pre>
+ *
+ * @author Mehrdad Bozorgmehr
+ * @since 7.0
+ * @see BalloonHashingFunction
+ * @see AlgorithmFinder#getBalloonHashingInstance()
+ */
+public class BalloonHashingPassword4jPasswordEncoder extends AbstractValidatingPasswordEncoder {
+
+	private static final String DELIMITER = ":";
+
+	private static final int DEFAULT_SALT_LENGTH = 32;
+
+	private final BalloonHashingFunction balloonHashingFunction;
+
+	private final SecureRandom secureRandom;
+
+	private final int saltLength;
+
+	/**
+	 * Constructs a Balloon hashing password encoder using the default Balloon hashing
+	 * configuration from Password4j's AlgorithmFinder.
+	 */
+	public BalloonHashingPassword4jPasswordEncoder() {
+		this(AlgorithmFinder.getBalloonHashingInstance());
+	}
+
+	/**
+	 * Constructs a Balloon hashing password encoder with a custom Balloon hashing
+	 * function.
+	 * @param balloonHashingFunction the Balloon hashing function to use for encoding
+	 * passwords, must not be null
+	 * @throws IllegalArgumentException if balloonHashingFunction is null
+	 */
+	public BalloonHashingPassword4jPasswordEncoder(BalloonHashingFunction balloonHashingFunction) {
+		this(balloonHashingFunction, DEFAULT_SALT_LENGTH);
+	}
+
+	/**
+	 * Constructs a Balloon hashing password encoder with a custom Balloon hashing
+	 * function and salt length.
+	 * @param balloonHashingFunction the Balloon hashing function to use for encoding
+	 * passwords, must not be null
+	 * @param saltLength the length of the salt in bytes, must be positive
+	 * @throws IllegalArgumentException if balloonHashingFunction is null or saltLength is
+	 * not positive
+	 */
+	public BalloonHashingPassword4jPasswordEncoder(BalloonHashingFunction balloonHashingFunction, int saltLength) {
+		Assert.notNull(balloonHashingFunction, "balloonHashingFunction cannot be null");
+		Assert.isTrue(saltLength > 0, "saltLength must be positive");
+		this.balloonHashingFunction = balloonHashingFunction;
+		this.saltLength = saltLength;
+		this.secureRandom = new SecureRandom();
+	}
+
+	@Override
+	protected String encodeNonNullPassword(String rawPassword) {
+		byte[] salt = new byte[this.saltLength];
+		this.secureRandom.nextBytes(salt);
+
+		Hash hash = Password.hash(rawPassword).addSalt(salt).with(this.balloonHashingFunction);
+		String encodedSalt = Base64.getEncoder().encodeToString(salt);
+		String encodedHash = hash.getResult();
+
+		return encodedSalt + DELIMITER + encodedHash;
+	}
+
+	@Override
+	protected boolean matchesNonNull(String rawPassword, String encodedPassword) {
+		if (!encodedPassword.contains(DELIMITER)) {
+			return false;
+		}
+
+		String[] parts = encodedPassword.split(DELIMITER, 2);
+		if (parts.length != 2) {
+			return false;
+		}
+
+		try {
+			byte[] salt = Base64.getDecoder().decode(parts[0]);
+			String expectedHash = parts[1];
+
+			Hash hash = Password.hash(rawPassword).addSalt(salt).with(this.balloonHashingFunction);
+			return expectedHash.equals(hash.getResult());
+		}
+		catch (IllegalArgumentException ex) {
+			// Invalid Base64 encoding
+			return false;
+		}
+	}
+
+	@Override
+	protected boolean upgradeEncodingNonNull(String encodedPassword) {
+		// For now, we'll return false to maintain existing behavior
+		// This could be enhanced in the future to check if the encoding parameters
+		// match the current configuration
+		return false;
+	}
+
+}

+ 157 - 0
crypto/src/main/java/org/springframework/security/crypto/password4j/Pbkdf2Password4jPasswordEncoder.java

@@ -0,0 +1,157 @@
+/*
+ * 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 java.security.SecureRandom;
+import java.util.Base64;
+
+import com.password4j.AlgorithmFinder;
+import com.password4j.Hash;
+import com.password4j.PBKDF2Function;
+import com.password4j.Password;
+
+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 with PBKDF2 hashing algorithm.
+ *
+ * <p>
+ * PBKDF2 is a key derivation function designed to be computationally expensive to thwart
+ * dictionary and brute force attacks. This implementation handles the salt management
+ * explicitly since Password4j's PBKDF2 implementation does not include the salt in the
+ * output hash.
+ * </p>
+ *
+ * <p>
+ * The encoded password format is: {salt}:{hash} where both salt and hash are Base64
+ * encoded.
+ * </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 PBKDF2 settings (recommended)
+ * PasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
+ *
+ * // Using custom PBKDF2 function
+ * PasswordEncoder customEncoder = new Pbkdf2Password4jPasswordEncoder(
+ *     PBKDF2Function.getInstance(Algorithm.HMAC_SHA256, 100000, 256));
+ * }</pre>
+ *
+ * @author Mehrdad Bozorgmehr
+ * @since 7.0
+ * @see PBKDF2Function
+ * @see AlgorithmFinder#getPBKDF2Instance()
+ */
+public class Pbkdf2Password4jPasswordEncoder extends AbstractValidatingPasswordEncoder {
+
+	private static final String DELIMITER = ":";
+
+	private static final int DEFAULT_SALT_LENGTH = 32;
+
+	private final PBKDF2Function pbkdf2Function;
+
+	private final SecureRandom secureRandom;
+
+	private final int saltLength;
+
+	/**
+	 * Constructs a PBKDF2 password encoder using the default PBKDF2 configuration from
+	 * Password4j's AlgorithmFinder.
+	 */
+	public Pbkdf2Password4jPasswordEncoder() {
+		this(AlgorithmFinder.getPBKDF2Instance());
+	}
+
+	/**
+	 * Constructs a PBKDF2 password encoder with a custom PBKDF2 function.
+	 * @param pbkdf2Function the PBKDF2 function to use for encoding passwords, must not
+	 * be null
+	 * @throws IllegalArgumentException if pbkdf2Function is null
+	 */
+	public Pbkdf2Password4jPasswordEncoder(PBKDF2Function pbkdf2Function) {
+		this(pbkdf2Function, DEFAULT_SALT_LENGTH);
+	}
+
+	/**
+	 * Constructs a PBKDF2 password encoder with a custom PBKDF2 function and salt length.
+	 * @param pbkdf2Function the PBKDF2 function to use for encoding passwords, must not
+	 * be null
+	 * @param saltLength the length of the salt in bytes, must be positive
+	 * @throws IllegalArgumentException if pbkdf2Function is null or saltLength is not
+	 * positive
+	 */
+	public Pbkdf2Password4jPasswordEncoder(PBKDF2Function pbkdf2Function, int saltLength) {
+		Assert.notNull(pbkdf2Function, "pbkdf2Function cannot be null");
+		Assert.isTrue(saltLength > 0, "saltLength must be positive");
+		this.pbkdf2Function = pbkdf2Function;
+		this.saltLength = saltLength;
+		this.secureRandom = new SecureRandom();
+	}
+
+	@Override
+	protected String encodeNonNullPassword(String rawPassword) {
+		byte[] salt = new byte[this.saltLength];
+		this.secureRandom.nextBytes(salt);
+
+		Hash hash = Password.hash(rawPassword).addSalt(salt).with(this.pbkdf2Function);
+		String encodedSalt = Base64.getEncoder().encodeToString(salt);
+		String encodedHash = hash.getResult();
+
+		return encodedSalt + DELIMITER + encodedHash;
+	}
+
+	@Override
+	protected boolean matchesNonNull(String rawPassword, String encodedPassword) {
+		if (!encodedPassword.contains(DELIMITER)) {
+			return false;
+		}
+
+		String[] parts = encodedPassword.split(DELIMITER, 2);
+		if (parts.length != 2) {
+			return false;
+		}
+
+		try {
+			byte[] salt = Base64.getDecoder().decode(parts[0]);
+			String expectedHash = parts[1];
+
+			Hash hash = Password.hash(rawPassword).addSalt(salt).with(this.pbkdf2Function);
+			return expectedHash.equals(hash.getResult());
+		}
+		catch (IllegalArgumentException ex) {
+			// Invalid Base64 encoding
+			return false;
+		}
+	}
+
+	@Override
+	protected boolean upgradeEncodingNonNull(String encodedPassword) {
+		// For now, we'll return false to maintain existing behavior
+		// This could be enhanced in the future to check if the encoding parameters
+		// match the current configuration
+		return false;
+	}
+
+}

+ 170 - 0
crypto/src/test/java/org/springframework/security/crypto/password4j/BalloonHashingPassword4jPasswordEncoderTests.java

@@ -0,0 +1,170 @@
+/*
+ * 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.BalloonHashingFunction;
+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 BalloonHashingPassword4jPasswordEncoder}.
+ *
+ * @author Mehrdad Bozorgmehr
+ */
+class BalloonHashingPassword4jPasswordEncoderTests {
+
+	private static final String PASSWORD = "password";
+
+	private static final String DIFFERENT_PASSWORD = "differentpassword";
+
+	@Test
+	void constructorWithNullFunctionShouldThrowException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> new BalloonHashingPassword4jPasswordEncoder(null))
+			.withMessage("balloonHashingFunction cannot be null");
+	}
+
+	@Test
+	void constructorWithInvalidSaltLengthShouldThrowException() {
+		BalloonHashingFunction function = AlgorithmFinder.getBalloonHashingInstance();
+		assertThatIllegalArgumentException().isThrownBy(() -> new BalloonHashingPassword4jPasswordEncoder(function, 0))
+			.withMessage("saltLength must be positive");
+		assertThatIllegalArgumentException().isThrownBy(() -> new BalloonHashingPassword4jPasswordEncoder(function, -1))
+			.withMessage("saltLength must be positive");
+	}
+
+	@Test
+	void defaultConstructorShouldWork() {
+		BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
+
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoded).isNotNull().isNotEqualTo(PASSWORD);
+		assertThat(encoded).contains(":");
+		assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+	}
+
+	@Test
+	void customFunctionConstructorShouldWork() {
+		BalloonHashingFunction customFunction = BalloonHashingFunction.getInstance("SHA-256", 512, 2, 3);
+		BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder(customFunction);
+
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoded).isNotNull().isNotEqualTo(PASSWORD);
+		assertThat(encoded).contains(":");
+		assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+	}
+
+	@Test
+	void customSaltLengthConstructorShouldWork() {
+		BalloonHashingFunction function = AlgorithmFinder.getBalloonHashingInstance();
+		BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder(function, 16);
+
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoded).isNotNull().isNotEqualTo(PASSWORD);
+		assertThat(encoded).contains(":");
+		assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+	}
+
+	@Test
+	void encodeShouldIncludeSaltInOutput() {
+		BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
+
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoded).contains(":");
+		String[] parts = encoded.split(":");
+		assertThat(parts).hasSize(2);
+		assertThat(parts[0]).isNotEmpty(); // salt part
+		assertThat(parts[1]).isNotEmpty(); // hash part
+	}
+
+	@Test
+	void matchesShouldReturnTrueForCorrectPassword() {
+		BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
+
+		String encoded = encoder.encode(PASSWORD);
+		boolean matches = encoder.matches(PASSWORD, encoded);
+
+		assertThat(matches).isTrue();
+	}
+
+	@Test
+	void matchesShouldReturnFalseForIncorrectPassword() {
+		BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
+
+		String encoded = encoder.encode(PASSWORD);
+		boolean matches = encoder.matches(DIFFERENT_PASSWORD, encoded);
+
+		assertThat(matches).isFalse();
+	}
+
+	@Test
+	void matchesShouldReturnFalseForMalformedEncodedPassword() {
+		BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
+
+		assertThat(encoder.matches(PASSWORD, "malformed")).isFalse();
+		assertThat(encoder.matches(PASSWORD, "no:delimiter:in:wrong:places")).isFalse();
+		assertThat(encoder.matches(PASSWORD, "invalid_base64!:hash")).isFalse();
+	}
+
+	@Test
+	void multipleEncodingsShouldProduceDifferentHashesButAllMatch() {
+		BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
+
+		String encoded1 = encoder.encode(PASSWORD);
+		String encoded2 = encoder.encode(PASSWORD);
+
+		assertThat(encoded1).isNotEqualTo(encoded2); // Different salts should produce
+														// different results
+		assertThat(encoder.matches(PASSWORD, encoded1)).isTrue();
+		assertThat(encoder.matches(PASSWORD, encoded2)).isTrue();
+	}
+
+	@Test
+	void upgradeEncodingShouldReturnFalse() {
+		BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
+
+		String encoded = encoder.encode(PASSWORD);
+		boolean shouldUpgrade = encoder.upgradeEncoding(encoded);
+
+		assertThat(shouldUpgrade).isFalse();
+	}
+
+	@Test
+	void encodeNullShouldReturnNull() {
+		BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
+
+		assertThat(encoder.encode(null)).isNull();
+	}
+
+	@Test
+	void matchesWithNullOrEmptyValuesShouldReturnFalse() {
+		BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
+		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();
+	}
+
+}

+ 40 - 3
crypto/src/test/java/org/springframework/security/crypto/password4j/PasswordCompatibilityTests.java

@@ -24,6 +24,7 @@ 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;
@@ -52,7 +53,7 @@ class PasswordCompatibilityTests {
 	}
 
 	@Test
-	void bcryptEncodedWithPassword4jShouldMatchWithSpringSecirity() {
+	void bcryptEncodedWithPassword4jShouldMatchWithSpringSecurity() {
 		BcryptPassword4jPasswordEncoder password4jEncoder = new BcryptPassword4jPasswordEncoder(
 				BcryptFunction.getInstance(10));
 		BCryptPasswordEncoder springEncoder = new BCryptPasswordEncoder(10);
@@ -77,7 +78,7 @@ class PasswordCompatibilityTests {
 	}
 
 	@Test
-	void argon2EncodedWithPassword4jShouldMatchWithSpringSecirity() {
+	void argon2EncodedWithPassword4jShouldMatchWithSpringSecurity() {
 		Argon2Password4jPasswordEncoder password4jEncoder = new Argon2Password4jPasswordEncoder(
 				Argon2Function.getInstance(4096, 3, 1, 32, Argon2.ID));
 		Argon2PasswordEncoder springEncoder = new Argon2PasswordEncoder(16, 32, 1, 4096, 3);
@@ -102,7 +103,7 @@ class PasswordCompatibilityTests {
 	}
 
 	@Test
-	void scryptEncodedWithPassword4jShouldMatchWithSpringSecirity() {
+	void scryptEncodedWithPassword4jShouldMatchWithSpringSecurity() {
 		ScryptPassword4jPasswordEncoder password4jEncoder = new ScryptPassword4jPasswordEncoder(
 				ScryptFunction.getInstance(16384, 8, 1, 32));
 		SCryptPasswordEncoder springEncoder = new SCryptPasswordEncoder(16384, 8, 1, 32, 64);
@@ -113,4 +114,40 @@ class PasswordCompatibilityTests {
 		assertThat(matchedBySpring).isTrue();
 	}
 
+	// PBKDF2 Compatibility Tests
+	@Test
+	void pbkdf2EncodedWithSpringSecurityCannotMatchWithPassword4j() {
+		// Note: Direct compatibility between Spring Security's Pbkdf2PasswordEncoder
+		// and Password4j's PBKDF2 implementation is not possible because they use
+		// different output formats. Spring Security uses hex encoding with a specific
+		// format,
+		// while our Password4jPasswordEncoder uses salt:hash format with Base64 encoding.
+		Pbkdf2PasswordEncoder springEncoder = Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8();
+		Pbkdf2Password4jPasswordEncoder password4jEncoder = new Pbkdf2Password4jPasswordEncoder();
+
+		String encodedBySpring = springEncoder.encode(PASSWORD);
+		String encodedByPassword4j = password4jEncoder.encode(PASSWORD);
+
+		// These should NOT match due to different formats
+		// Spring Security will throw an exception when trying to decode Password4j
+		// format,
+		// which should be treated as a non-match
+		boolean password4jCanMatchSpring = password4jEncoder.matches(PASSWORD, encodedBySpring);
+		boolean springCanMatchPassword4j;
+		try {
+			springCanMatchPassword4j = springEncoder.matches(PASSWORD, encodedByPassword4j);
+		}
+		catch (IllegalArgumentException ex) {
+			// Expected exception due to format incompatibility - treat as non-match
+			springCanMatchPassword4j = false;
+		}
+
+		assertThat(password4jCanMatchSpring).isFalse();
+		assertThat(springCanMatchPassword4j).isFalse();
+
+		// But each should match its own encoding
+		assertThat(springEncoder.matches(PASSWORD, encodedBySpring)).isTrue();
+		assertThat(password4jEncoder.matches(PASSWORD, encodedByPassword4j)).isTrue();
+	}
+
 }

+ 167 - 0
crypto/src/test/java/org/springframework/security/crypto/password4j/Pbkdf2Password4jPasswordEncoderTests.java

@@ -0,0 +1,167 @@
+/*
+ * 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.PBKDF2Function;
+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 Pbkdf2Password4jPasswordEncoder}.
+ *
+ * @author Mehrdad Bozorgmehr
+ */
+class Pbkdf2Password4jPasswordEncoderTests {
+
+	private static final String PASSWORD = "password";
+
+	private static final String DIFFERENT_PASSWORD = "differentpassword";
+
+	@Test
+	void constructorWithNullFunctionShouldThrowException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> new Pbkdf2Password4jPasswordEncoder(null))
+			.withMessage("pbkdf2Function cannot be null");
+	}
+
+	@Test
+	void constructorWithInvalidSaltLengthShouldThrowException() {
+		PBKDF2Function function = AlgorithmFinder.getPBKDF2Instance();
+		assertThatIllegalArgumentException().isThrownBy(() -> new Pbkdf2Password4jPasswordEncoder(function, 0))
+			.withMessage("saltLength must be positive");
+		assertThatIllegalArgumentException().isThrownBy(() -> new Pbkdf2Password4jPasswordEncoder(function, -1))
+			.withMessage("saltLength must be positive");
+	}
+
+	@Test
+	void defaultConstructorShouldWork() {
+		Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
+
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoded).isNotNull().isNotEqualTo(PASSWORD).contains(":");
+		assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+	}
+
+	@Test
+	void customFunctionConstructorShouldWork() {
+		PBKDF2Function customFunction = AlgorithmFinder.getPBKDF2Instance();
+		Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder(customFunction);
+
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoded).isNotNull().isNotEqualTo(PASSWORD).contains(":");
+		assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+	}
+
+	@Test
+	void customSaltLengthConstructorShouldWork() {
+		PBKDF2Function function = AlgorithmFinder.getPBKDF2Instance();
+		Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder(function, 16);
+
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoded).isNotNull().isNotEqualTo(PASSWORD).contains(":");
+		assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+	}
+
+	@Test
+	void encodeShouldIncludeSaltInOutput() {
+		Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
+
+		String encoded = encoder.encode(PASSWORD);
+
+		assertThat(encoded).contains(":");
+		String[] parts = encoded.split(":");
+		assertThat(parts).hasSize(2);
+		assertThat(parts[0]).isNotEmpty(); // salt part
+		assertThat(parts[1]).isNotEmpty(); // hash part
+	}
+
+	@Test
+	void matchesShouldReturnTrueForCorrectPassword() {
+		Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
+
+		String encoded = encoder.encode(PASSWORD);
+		boolean matches = encoder.matches(PASSWORD, encoded);
+
+		assertThat(matches).isTrue();
+	}
+
+	@Test
+	void matchesShouldReturnFalseForIncorrectPassword() {
+		Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
+
+		String encoded = encoder.encode(PASSWORD);
+		boolean matches = encoder.matches(DIFFERENT_PASSWORD, encoded);
+
+		assertThat(matches).isFalse();
+	}
+
+	@Test
+	void matchesShouldReturnFalseForMalformedEncodedPassword() {
+		Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
+
+		assertThat(encoder.matches(PASSWORD, "malformed")).isFalse();
+		assertThat(encoder.matches(PASSWORD, "no:delimiter:in:wrong:places")).isFalse();
+		assertThat(encoder.matches(PASSWORD, "invalid_base64!:hash")).isFalse();
+	}
+
+	@Test
+	void multipleEncodingsShouldProduceDifferentHashesButAllMatch() {
+		Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
+
+		String encoded1 = encoder.encode(PASSWORD);
+		String encoded2 = encoder.encode(PASSWORD);
+
+		assertThat(encoded1).isNotEqualTo(encoded2); // Different salts should produce
+														// different results
+		assertThat(encoder.matches(PASSWORD, encoded1)).isTrue();
+		assertThat(encoder.matches(PASSWORD, encoded2)).isTrue();
+	}
+
+	@Test
+	void upgradeEncodingShouldReturnFalse() {
+		Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
+
+		String encoded = encoder.encode(PASSWORD);
+		boolean shouldUpgrade = encoder.upgradeEncoding(encoded);
+
+		assertThat(shouldUpgrade).isFalse();
+	}
+
+	@Test
+	void encodeNullShouldReturnNull() {
+		Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
+
+		assertThat(encoder.encode(null)).isNull();
+	}
+
+	@Test
+	void matchesWithNullOrEmptyValuesShouldReturnFalse() {
+		Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
+		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();
+	}
+
+}