diff --git a/crypto/src/main/java/org/springframework/security/crypto/password4j/BalloonHashingPassword4jPasswordEncoder.java b/crypto/src/main/java/org/springframework/security/crypto/password4j/BalloonHashingPassword4jPasswordEncoder.java new file mode 100644 index 0000000000..54735f19b2 --- /dev/null +++ b/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. + * + *
+ * 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. + *
+ * + *+ * The encoded password format is: {salt}:{hash} where both salt and hash are Base64 + * encoded. + *
+ * + *+ * This implementation is thread-safe and can be shared across multiple threads. + *
+ * + *+ * Usage Examples: + *
+ *{@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"));
+ * }
+ *
+ * @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;
+ }
+
+}
diff --git a/crypto/src/main/java/org/springframework/security/crypto/password4j/Pbkdf2Password4jPasswordEncoder.java b/crypto/src/main/java/org/springframework/security/crypto/password4j/Pbkdf2Password4jPasswordEncoder.java
new file mode 100644
index 0000000000..65fbaa98e9
--- /dev/null
+++ b/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.
+ *
+ * + * 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. + *
+ * + *+ * The encoded password format is: {salt}:{hash} where both salt and hash are Base64 + * encoded. + *
+ * + *+ * This implementation is thread-safe and can be shared across multiple threads. + *
+ * + *+ * Usage Examples: + *
+ *{@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));
+ * }
+ *
+ * @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;
+ }
+
+}
diff --git a/crypto/src/test/java/org/springframework/security/crypto/password4j/BalloonHashingPassword4jPasswordEncoderTests.java b/crypto/src/test/java/org/springframework/security/crypto/password4j/BalloonHashingPassword4jPasswordEncoderTests.java
new file mode 100644
index 0000000000..97bd5e4af9
--- /dev/null
+++ b/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();
+ }
+
+}
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 6360cd164e..d51e46e6e2 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
@@ -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();
+ }
+
}
diff --git a/crypto/src/test/java/org/springframework/security/crypto/password4j/Pbkdf2Password4jPasswordEncoderTests.java b/crypto/src/test/java/org/springframework/security/crypto/password4j/Pbkdf2Password4jPasswordEncoderTests.java
new file mode 100644
index 0000000000..040793ed55
--- /dev/null
+++ b/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();
+ }
+
+}