From 2d74f9c334dc6b55a67c798df28b5b745c8a2aa7 Mon Sep 17 00:00:00 2001 From: Mehrdad Date: Fri, 12 Sep 2025 00:00:38 +0330 Subject: [PATCH] Create a specific implementation for BalloonHashing and PBKDF2 password encoders using Password4j library Closes gh-17706 Signed-off-by: Mehrdad Signed-off-by: M.Bozorgmehr --- ...lloonHashingPassword4jPasswordEncoder.java | 159 ++++++++++++++++ .../Pbkdf2Password4jPasswordEncoder.java | 157 ++++++++++++++++ ...HashingPassword4jPasswordEncoderTests.java | 170 ++++++++++++++++++ .../PasswordCompatibilityTests.java | 43 ++++- .../Pbkdf2Password4jPasswordEncoderTests.java | 167 +++++++++++++++++ 5 files changed, 693 insertions(+), 3 deletions(-) create mode 100644 crypto/src/main/java/org/springframework/security/crypto/password4j/BalloonHashingPassword4jPasswordEncoder.java create mode 100644 crypto/src/main/java/org/springframework/security/crypto/password4j/Pbkdf2Password4jPasswordEncoder.java create mode 100644 crypto/src/test/java/org/springframework/security/crypto/password4j/BalloonHashingPassword4jPasswordEncoderTests.java create mode 100644 crypto/src/test/java/org/springframework/security/crypto/password4j/Pbkdf2Password4jPasswordEncoderTests.java 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(); + } + +}