diff --git a/crypto/src/main/java/org/springframework/security/crypto/factory/PasswordEncoderFactories.java b/crypto/src/main/java/org/springframework/security/crypto/factory/PasswordEncoderFactories.java index 7704f6f210..b6c6f2f3e5 100644 --- a/crypto/src/main/java/org/springframework/security/crypto/factory/PasswordEncoderFactories.java +++ b/crypto/src/main/java/org/springframework/security/crypto/factory/PasswordEncoderFactories.java @@ -24,7 +24,6 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.DelegatingPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder; -import org.springframework.security.crypto.password4j.Password4jPasswordEncoder; import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder; /** @@ -66,10 +65,6 @@ public final class PasswordEncoderFactories { *
  • argon2 - {@link Argon2PasswordEncoder#defaultsForSpringSecurity_v5_2()}
  • *
  • argon2@SpringSecurity_v5_8 - * {@link Argon2PasswordEncoder#defaultsForSpringSecurity_v5_8()}
  • - *
  • password4j-bcrypt - {@link Password4jPasswordEncoder} with BCrypt
  • - *
  • password4j-scrypt - {@link Password4jPasswordEncoder} with SCrypt
  • - *
  • password4j-argon2 - {@link Password4jPasswordEncoder} with Argon2
  • - *
  • password4j-pbkdf2 - {@link Password4jPasswordEncoder} with PBKDF2
  • * * @return the {@link PasswordEncoder} to use */ @@ -92,14 +87,6 @@ public final class PasswordEncoderFactories { encoders.put("sha256", new org.springframework.security.crypto.password.StandardPasswordEncoder()); encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_2()); encoders.put("argon2@SpringSecurity_v5_8", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8()); - - // Password4j implementations - encoders.put("password4j-bcrypt", Password4jPasswordEncoder.bcrypt(10)); - encoders.put("password4j-scrypt", Password4jPasswordEncoder.scrypt(16384, 8, 1, 32)); - encoders.put("password4j-argon2", Password4jPasswordEncoder.argon2(65536, 3, 4, 32, - com.password4j.types.Argon2.ID)); - encoders.put("password4j-pbkdf2", Password4jPasswordEncoder.pbkdf2(310000, 32)); - return new DelegatingPasswordEncoder(encodingId, encoders); } diff --git a/crypto/src/main/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoder.java b/crypto/src/main/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoder.java index 402fb4d42b..36d286584f 100644 --- a/crypto/src/main/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoder.java +++ b/crypto/src/main/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoder.java @@ -16,24 +16,54 @@ package org.springframework.security.crypto.password4j; -import com.password4j.*; -import com.password4j.types.Argon2; +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. + * 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. * - *

    The encoder determines the algorithm used based on the algorithm type specified during construction. - * For verification, it can automatically detect the algorithm used in existing hashes.

    + *

    + * 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 implementation is thread-safe and can be shared across multiple threads.

    + *

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

    + * + *

    + * Usage Examples: + *

    + *
    {@code
    + * // Using default algorithms from AlgorithmFinder (recommended approach)
    + * PasswordEncoder bcryptEncoder = new Password4jPasswordEncoder(AlgorithmFinder.getBcryptInstance());
    + * PasswordEncoder argon2Encoder = new Password4jPasswordEncoder(AlgorithmFinder.getArgon2Instance());
    + * PasswordEncoder scryptEncoder = new Password4jPasswordEncoder(AlgorithmFinder.getScryptInstance());
    + * PasswordEncoder pbkdf2Encoder = new Password4jPasswordEncoder(AlgorithmFinder.getPBKDF2Instance());
    + *
    + * // Using customized algorithm parameters
    + * PasswordEncoder customBcrypt = new Password4jPasswordEncoder(BcryptFunction.getInstance(12));
    + * PasswordEncoder customArgon2 = new Password4jPasswordEncoder(
    + *     Argon2Function.getInstance(65536, 3, 4, 32, Argon2.ID));
    + * PasswordEncoder customScrypt = new Password4jPasswordEncoder(
    + *     ScryptFunction.getInstance(32768, 8, 1, 32));
    + * PasswordEncoder customPbkdf2 = new Password4jPasswordEncoder(
    + *     CompressedPBKDF2Function.getInstance("SHA256", 310000, 32));
    + * }
    * * @author Mehrdad Bozorgmehr - * @since 6.5 + * @since 7.0 + * @see AlgorithmFinder */ public class Password4jPasswordEncoder extends AbstractValidatingPasswordEncoder { @@ -41,146 +71,38 @@ public class Password4jPasswordEncoder extends AbstractValidatingPasswordEncoder private final HashingFunction hashingFunction; - private final Password4jAlgorithm algorithm; - - /** - * Enumeration of supported Password4j algorithms. - */ - public enum Password4jAlgorithm { - /** - * BCrypt algorithm. - */ - BCRYPT, - /** - * SCrypt algorithm. - */ - SCRYPT, - /** - * Argon2 algorithm. - */ - ARGON2, - /** - * PBKDF2 algorithm. - */ - PBKDF2, - /** - * Compressed PBKDF2 algorithm. - */ - COMPRESSED_PBKDF2 - } - - /** - * Constructs a Password4j password encoder with the default BCrypt algorithm. - */ - public Password4jPasswordEncoder() { - this(Password4jAlgorithm.BCRYPT); - } - - /** - * Constructs a Password4j password encoder with the specified algorithm using default parameters. + * Constructs a Password4j password encoder with the specified hashing function. * - * @param algorithm the password hashing algorithm to use - */ - public Password4jPasswordEncoder(Password4jAlgorithm algorithm) { - Assert.notNull(algorithm, "algorithm cannot be null"); - this.algorithm = algorithm; - this.hashingFunction = createDefaultHashingFunction(algorithm); - } - - /** - * Constructs a Password4j password encoder with a custom hashing function. + *

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

    + * * - * @param hashingFunction the custom hashing function to use - * @param algorithm the password hashing algorithm type + *

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

    + * + * @param hashingFunction the hashing function to use for encoding passwords, must not + * be null + * @throws IllegalArgumentException if hashingFunction is null */ - public Password4jPasswordEncoder(HashingFunction hashingFunction, Password4jAlgorithm algorithm) { + public Password4jPasswordEncoder(HashingFunction hashingFunction) { Assert.notNull(hashingFunction, "hashingFunction cannot be null"); - Assert.notNull(algorithm, "algorithm cannot be null"); this.hashingFunction = hashingFunction; - this.algorithm = algorithm; - } - - /** - * Creates a Password4j password encoder with BCrypt algorithm and specified rounds. - * - * @param rounds the number of rounds (cost factor) for BCrypt - * @return a new Password4j password encoder - */ - public static Password4jPasswordEncoder bcrypt(int rounds) { - return new Password4jPasswordEncoder(BcryptFunction.getInstance(rounds), Password4jAlgorithm.BCRYPT); - } - - /** - * Creates a Password4j password encoder with SCrypt algorithm and specified parameters. - * - * @param workFactor the work factor (N parameter) - * @param resources the resources (r parameter) - * @param parallelization the parallelization (p parameter) - * @param derivedKeyLength the derived key length - * @return a new Password4j password encoder - */ - public static Password4jPasswordEncoder scrypt(int workFactor, int resources, int parallelization, int derivedKeyLength) { - return new Password4jPasswordEncoder( - ScryptFunction.getInstance(workFactor, resources, parallelization, derivedKeyLength), - Password4jAlgorithm.SCRYPT - ); - } - - /** - * Creates a Password4j password encoder with Argon2 algorithm and specified parameters. - * - * @param memory the memory cost - * @param iterations the number of iterations - * @param parallelism the parallelism - * @param outputLength the output length - * @param type the Argon2 type - * @return a new Password4j password encoder - */ - public static Password4jPasswordEncoder argon2(int memory, int iterations, int parallelism, int outputLength, Argon2 type) { - return new Password4jPasswordEncoder( - Argon2Function.getInstance(memory, iterations, parallelism, outputLength, type), - Password4jAlgorithm.ARGON2 - ); - } - - /** - * Creates a Password4j password encoder with PBKDF2 algorithm and specified parameters. - * - * @param iterations the number of iterations - * @param derivedKeyLength the derived key length - * @return a new Password4j password encoder - */ - public static Password4jPasswordEncoder pbkdf2(int iterations, int derivedKeyLength) { - return new Password4jPasswordEncoder( - CompressedPBKDF2Function.getInstance("SHA256", iterations, derivedKeyLength), - Password4jAlgorithm.PBKDF2 - ); - } - - /** - * Creates a Password4j password encoder with compressed PBKDF2 algorithm. - * - * @param iterations the number of iterations - * @param derivedKeyLength the derived key length - * @return a new Password4j password encoder - */ - public static Password4jPasswordEncoder compressedPbkdf2(int iterations, int derivedKeyLength) { - return new Password4jPasswordEncoder( - CompressedPBKDF2Function.getInstance("SHA256", iterations, derivedKeyLength), - Password4jAlgorithm.COMPRESSED_PBKDF2 - ); - } - - /** - * Creates a Password4j password encoder with default settings for Spring Security v5.8+. - * This uses BCrypt with 10 rounds. - * - * @return a new Password4j password encoder with recommended defaults - * @since 6.5 - */ - public static Password4jPasswordEncoder defaultsForSpringSecurity() { - return bcrypt(10); } @Override @@ -188,7 +110,8 @@ public class Password4jPasswordEncoder extends AbstractValidatingPasswordEncoder try { Hash hash = Password.hash(rawPassword).with(this.hashingFunction); return hash.getResult(); - } catch (Exception ex) { + } + catch (Exception ex) { throw new IllegalStateException("Failed to encode password using Password4j", ex); } } @@ -198,7 +121,8 @@ public class Password4jPasswordEncoder extends AbstractValidatingPasswordEncoder try { // Use the specific hashing function for verification return Password.check(rawPassword, encodedPassword).with(this.hashingFunction); - } catch (Exception ex) { + } + catch (Exception ex) { this.logger.warn("Password verification failed for encoded password: " + encodedPassword, ex); return false; } @@ -211,39 +135,4 @@ public class Password4jPasswordEncoder extends AbstractValidatingPasswordEncoder return false; } - /** - * Creates a default hashing function for the specified algorithm. - * - * @param algorithm the password hashing algorithm - * @return the default hashing function - */ - private static HashingFunction createDefaultHashingFunction(Password4jAlgorithm algorithm) { - return switch (algorithm) { - case BCRYPT -> BcryptFunction.getInstance(10); // Default 10 rounds - case SCRYPT -> ScryptFunction.getInstance(16384, 8, 1, 32); // Default parameters - case ARGON2 -> Argon2Function.getInstance(65536, 3, 4, 32, Argon2.ID); // Default parameters - case PBKDF2 -> - CompressedPBKDF2Function.getInstance("SHA256", 310000, 32); // Use compressed format for self-contained encoding - case COMPRESSED_PBKDF2 -> CompressedPBKDF2Function.getInstance("SHA256", 310000, 32); - }; - } - - /** - * Gets the algorithm used by this encoder. - * - * @return the password hashing algorithm - */ - public Password4jAlgorithm getAlgorithm() { - return this.algorithm; - } - - /** - * Gets the hashing function used by this encoder. - * - * @return the hashing function - */ - public HashingFunction getHashingFunction() { - return this.hashingFunction; - } - } diff --git a/crypto/src/main/java/org/springframework/security/crypto/password4j/package-info.java b/crypto/src/main/java/org/springframework/security/crypto/password4j/package-info.java index f15bf9e10b..7310e80b8f 100644 --- a/crypto/src/main/java/org/springframework/security/crypto/password4j/package-info.java +++ b/crypto/src/main/java/org/springframework/security/crypto/password4j/package-info.java @@ -14,7 +14,6 @@ * limitations under the License. */ - @NullMarked package org.springframework.security.crypto.password4j; diff --git a/crypto/src/test/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoderTests.java b/crypto/src/test/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoderTests.java index 93c6e90f45..f4bec5dd4a 100644 --- a/crypto/src/test/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoderTests.java +++ b/crypto/src/test/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoderTests.java @@ -16,17 +16,10 @@ package org.springframework.security.crypto.password4j; -import com.password4j.Argon2Function; +import com.password4j.AlgorithmFinder; import com.password4j.BcryptFunction; -import com.password4j.CompressedPBKDF2Function; -import com.password4j.ScryptFunction; -import com.password4j.types.Argon2; -import org.junit.jupiter.api.RepeatedTest; +import com.password4j.HashingFunction; import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; -import org.junit.jupiter.params.provider.EnumSource; -import org.junit.jupiter.params.provider.ValueSource; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; @@ -38,559 +31,111 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException */ class Password4jPasswordEncoderTests { - private static final String PASSWORD = "password"; - private static final String WRONG_PASSWORD = "wrongpassword"; - private static final String UNICODE_PASSWORD = "пароль123🔐"; - private static final String LONG_PASSWORD = "a".repeat(1000); + private static final String PASSWORD = "password"; + + 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 + void encodeShouldReturnNonNullHashedPassword() { + HashingFunction hashingFunction = BcryptFunction.getInstance(4); // Use low cost + // for faster + // tests + Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(hashingFunction); + + String result = encoder.encode(PASSWORD); + + assertThat(result).isNotNull().isNotEqualTo(PASSWORD); + } + + @Test + void matchesShouldReturnTrueForValidPassword() { + HashingFunction hashingFunction = BcryptFunction.getInstance(4); // Use low cost + // for faster + // tests + Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(hashingFunction); + + String encoded = encoder.encode(PASSWORD); + boolean result = encoder.matches(PASSWORD, encoded); + + assertThat(result).isTrue(); + } + + @Test + void matchesShouldReturnFalseForInvalidPassword() { + HashingFunction hashingFunction = BcryptFunction.getInstance(4); // Use low cost + // for faster + // tests + Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(hashingFunction); + + String encoded = encoder.encode(PASSWORD); + boolean result = encoder.matches(WRONG_PASSWORD, encoded); + + assertThat(result).isFalse(); + } + + @Test + void matchesShouldReturnFalseForMalformedHash() { + HashingFunction hashingFunction = BcryptFunction.getInstance(4); + Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(hashingFunction); + + // Test with malformed hash that should cause Password4j to throw an exception + boolean result = encoder.matches(PASSWORD, "invalid-hash-format"); + + assertThat(result).isFalse(); + } + + @Test + void upgradeEncodingShouldReturnFalse() { + HashingFunction hashingFunction = BcryptFunction.getInstance(4); + Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(hashingFunction); + + String encoded = encoder.encode(PASSWORD); + boolean result = encoder.upgradeEncoding(encoded); + + 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()); + + String encoded = encoder.encode(PASSWORD); + assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); + assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse(); + } - // Constructor Tests - @Test - void constructorWithNullAlgorithmShouldThrowException() { - assertThatIllegalArgumentException() - .isThrownBy(() -> new Password4jPasswordEncoder(null)) - .withMessage("algorithm cannot be null"); - } - - @Test - void constructorWithNullHashingFunctionShouldThrowException() { - assertThatIllegalArgumentException() - .isThrownBy(() -> new Password4jPasswordEncoder(null, Password4jPasswordEncoder.Password4jAlgorithm.BCRYPT)) - .withMessage("hashingFunction cannot be null"); - } - - @Test - void constructorWithNullAlgorithmAndValidHashingFunctionShouldThrowException() { - BcryptFunction function = BcryptFunction.getInstance(10); - assertThatIllegalArgumentException() - .isThrownBy(() -> new Password4jPasswordEncoder(function, null)) - .withMessage("algorithm cannot be null"); - } - - @Test - void defaultConstructorShouldUseBCrypt() { - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(); - assertThat(encoder.getAlgorithm()).isEqualTo(Password4jPasswordEncoder.Password4jAlgorithm.BCRYPT); - assertThat(encoder.getHashingFunction()).isInstanceOf(BcryptFunction.class); - } - - // BCrypt Tests - @Test - void bcryptEncoderShouldEncodeAndVerifyPasswords() { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.bcrypt(10); - - String encoded = encoder.encode(PASSWORD); - assertThat(encoded) - .isNotNull() - .isNotEqualTo(PASSWORD) - .startsWith("$2b$10$");// Password4j uses $2b$ format - - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse(); - assertThat(encoder.matches(null, encoded)).isFalse(); - assertThat(encoder.matches(PASSWORD, null)).isFalse(); - } - - @ParameterizedTest - @ValueSource(ints = {4, 6, 8, 10, 12, 14}) - void bcryptWithDifferentRoundsShouldWork(int rounds) { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.bcrypt(rounds); - - String encoded = encoder.encode(PASSWORD); - assertThat(encoded).startsWith("$2b$" + String.format("%02d", rounds) + "$"); - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - } - - @Test - void bcryptShouldProduceDifferentHashesForSamePassword() { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.bcrypt(10); - - 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(); - } - - // SCrypt Tests - @Test - void scryptEncoderShouldEncodeAndVerifyPasswords() { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.scrypt(16384, 8, 1, 32); - - String encoded = encoder.encode(PASSWORD); - assertThat(encoded).isNotNull().isNotEqualTo(PASSWORD); - - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse(); - } - - @Test - void scryptWithDifferentParametersShouldWork() { - Password4jPasswordEncoder encoder1 = Password4jPasswordEncoder.scrypt(8192, 8, 1, 32); - Password4jPasswordEncoder encoder2 = Password4jPasswordEncoder.scrypt(16384, 16, 2, 64); - - String hash1 = encoder1.encode(PASSWORD); - String hash2 = encoder2.encode(PASSWORD); - - assertThat(encoder1.matches(PASSWORD, hash1)).isTrue(); - assertThat(encoder2.matches(PASSWORD, hash2)).isTrue(); - assertThat(hash1).isNotEqualTo(hash2); - } - - // Argon2 Tests - @Test - void argon2EncoderShouldEncodeAndVerifyPasswords() { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.argon2( - 65536, 3, 4, 32, Argon2.ID); - - String encoded = encoder.encode(PASSWORD); - assertThat(encoded) - .isNotNull() - .isNotEqualTo(PASSWORD) - .startsWith("$argon2id$"); - - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse(); - } - - @ParameterizedTest - @EnumSource(Argon2.class) - void argon2WithDifferentTypesShouldWork(Argon2 type) { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.argon2( - 65536, 3, 4, 32, type); - - String encoded = encoder.encode(PASSWORD); - String expectedPrefix = switch (type) { - case D -> "$argon2d$"; - case I -> "$argon2i$"; - case ID -> "$argon2id$"; - }; - - assertThat(encoded).startsWith(expectedPrefix); - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - } - - // PBKDF2 Tests - @Test - void pbkdf2EncoderShouldEncodeAndVerifyPasswords() { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.pbkdf2(100000, 32); - - String encoded = encoder.encode(PASSWORD); - assertThat(encoded) - .isNotNull() - .isNotEqualTo(PASSWORD); - - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse(); - } - - @Test - void compressedPbkdf2EncoderShouldEncodeAndVerifyPasswords() { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.compressedPbkdf2(100000, 32); - - String encoded = encoder.encode(PASSWORD); - assertThat(encoded) - .isNotNull() - .isNotEqualTo(PASSWORD); - - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse(); - } - - @ParameterizedTest - @CsvSource({ - "50000, 16", - "100000, 32", - "200000, 64", - "500000, 32" - }) - void pbkdf2WithDifferentParametersShouldWork(int iterations, int keyLength) { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.pbkdf2(iterations, keyLength); - - String encoded = encoder.encode(PASSWORD); - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - } - - // Factory Method Tests - @Test - void defaultsForSpringSecurityShouldUseBCrypt() { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); - - assertThat(encoder.getAlgorithm()).isEqualTo(Password4jPasswordEncoder.Password4jAlgorithm.BCRYPT); - assertThat(encoder.getHashingFunction()).isInstanceOf(BcryptFunction.class); - - String encoded = encoder.encode(PASSWORD); - assertThat(encoded).startsWith("$2b$10$"); // Password4j uses $2b$ format - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - } - - // Null and Empty Input Tests - @Test - void encodeNullPasswordShouldReturnNull() { - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(); - assertThat(encoder.encode(null)).isNull(); - } - - @Test - void encodeEmptyPasswordShouldWork() { - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(); - String encoded = encoder.encode(""); - assertThat(encoded).isNotNull(); - // AbstractValidatingPasswordEncoder returns false for empty raw passwords - assertThat(encoder.matches("", encoded)).isFalse(); - } - - @Test - void matchesWithNullOrEmptyParametersShouldReturnFalse() { - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(); - String validHash = encoder.encode(PASSWORD); - - assertThat(encoder.matches(null, validHash)).isFalse(); - assertThat(encoder.matches("", validHash)).isFalse(); - assertThat(encoder.matches(PASSWORD, null)).isFalse(); - assertThat(encoder.matches(PASSWORD, "")).isFalse(); - assertThat(encoder.matches(null, null)).isFalse(); - assertThat(encoder.matches("", "")).isFalse(); - } - - // Password Variety Tests - @ParameterizedTest - @ValueSource(strings = {"password", "123456", "P@ssw0rd!", "a very long password with spaces and symbols !@#$%"}) - void shouldHandleVariousPasswordFormats(String password) { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); - - String encoded = encoder.encode(password); - assertThat(encoded).isNotNull(); - assertThat(encoder.matches(password, encoded)).isTrue(); - assertThat(encoder.matches(password + "x", encoded)).isFalse(); - } - - @Test - void shouldHandleUnicodePasswords() { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); - - String encoded = encoder.encode(UNICODE_PASSWORD); - assertThat(encoded).isNotNull(); - assertThat(encoder.matches(UNICODE_PASSWORD, encoded)).isTrue(); - assertThat(encoder.matches("password", encoded)).isFalse(); - } - - @Test - void shouldHandleLongPasswords() { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); - - String encoded = encoder.encode(LONG_PASSWORD); - assertThat(encoded).isNotNull(); - assertThat(encoder.matches(LONG_PASSWORD, encoded)).isTrue(); - } - - // Upgrade Encoding Tests - @Test - void upgradeEncodingShouldReturnFalse() { - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(); - String encoded = encoder.encode(PASSWORD); - - // For now, upgradeEncoding should return false - assertThat(encoder.upgradeEncoding(encoded)).isFalse(); - assertThat(encoder.upgradeEncoding(null)).isFalse(); - assertThat(encoder.upgradeEncoding("")).isFalse(); - } - - @ParameterizedTest - @EnumSource(Password4jPasswordEncoder.Password4jAlgorithm.class) - void upgradeEncodingShouldReturnFalseForAllAlgorithms(Password4jPasswordEncoder.Password4jAlgorithm algorithm) { - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(algorithm); - String encoded = encoder.encode(PASSWORD); - - assertThat(encoder.upgradeEncoding(encoded)).isFalse(); - } - - // Custom Hashing Function Tests - @Test - void shouldWorkWithCustomHashingFunction() { - BcryptFunction customFunction = BcryptFunction.getInstance(12); - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(customFunction, Password4jPasswordEncoder.Password4jAlgorithm.BCRYPT); - - String encoded = encoder.encode(PASSWORD); - assertThat(encoded).startsWith("$2b$12$"); // Password4j uses $2b$ format - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - } - - @Test - void shouldWorkWithCustomScryptFunction() { - ScryptFunction customFunction = ScryptFunction.getInstance(32768, 16, 2, 64); - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(customFunction, Password4jPasswordEncoder.Password4jAlgorithm.SCRYPT); - - String encoded = encoder.encode(PASSWORD); - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - } - - @Test - void shouldWorkWithCustomArgon2Function() { - Argon2Function customFunction = Argon2Function.getInstance(131072, 4, 8, 64, Argon2.ID); - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(customFunction, Password4jPasswordEncoder.Password4jAlgorithm.ARGON2); - - String encoded = encoder.encode(PASSWORD); - assertThat(encoded).startsWith("$argon2id$"); - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - } - - // Algorithm Coverage Tests - @Test - void shouldCreateEncoderForEachAlgorithm() { - // Test all algorithm types can be instantiated - for (Password4jPasswordEncoder.Password4jAlgorithm algorithm : Password4jPasswordEncoder.Password4jAlgorithm.values()) { - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(algorithm); - assertThat(encoder.getAlgorithm()).isEqualTo(algorithm); - - String encoded = encoder.encode(PASSWORD); - assertThat(encoded).isNotNull(); - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - } - } - - @ParameterizedTest - @EnumSource(Password4jPasswordEncoder.Password4jAlgorithm.class) - void allAlgorithmsShouldProduceValidHashes(Password4jPasswordEncoder.Password4jAlgorithm algorithm) { - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(algorithm); - - String encoded = encoder.encode(PASSWORD); - assertThat(encoded) - .isNotNull() - .isNotEmpty() - .isNotEqualTo(PASSWORD); - - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse(); - } - - // Security Properties Tests - @RepeatedTest(10) - void samePasswordShouldProduceDifferentHashes() { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); - - String hash1 = encoder.encode(PASSWORD); - String hash2 = encoder.encode(PASSWORD); - - // Hashes should be different (due to salt) - assertThat(hash1).isNotEqualTo(hash2); - - // But both should verify correctly - assertThat(encoder.matches(PASSWORD, hash1)).isTrue(); - assertThat(encoder.matches(PASSWORD, hash2)).isTrue(); - } - - @Test - void hashLengthShouldBeConsistent() { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); - - String hash1 = encoder.encode("short"); - String hash2 = encoder.encode("this is a much longer password with many characters"); - - // BCrypt hashes should have consistent length - assertThat(hash1).hasSize(60); // BCrypt produces 60-character hashes - assertThat(hash2).hasSize(60); - } - - @Test - void similarPasswordsShouldProduceCompletelyDifferentHashes() { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); - - String hash1 = encoder.encode("password"); - String hash2 = encoder.encode("password1"); - String hash3 = encoder.encode("Password"); - - assertThat(hash1) - .isNotEqualTo(hash2) - .isNotEqualTo(hash3); - assertThat(hash2).isNotEqualTo(hash3); - - // Cross-verification should fail - assertThat(encoder.matches("password", hash2)).isFalse(); - assertThat(encoder.matches("password1", hash1)).isFalse(); - } - - - // Additional Security and Robustness Tests - @Test - void shouldHandleVeryLongPasswords() { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); - String veryLongPassword = "a".repeat(10000); // 10KB password - - String encoded = encoder.encode(veryLongPassword); - assertThat(encoded).isNotNull(); - assertThat(encoder.matches(veryLongPassword, encoded)).isTrue(); - // Fix: BCrypt truncates passwords longer than 72 bytes, so we need to test with a meaningful difference - // Test with a shorter difference that's within the 72-byte limit - String slightlyDifferentPassword = "b" + veryLongPassword.substring(1); // Change first character - assertThat(encoder.matches(slightlyDifferentPassword, encoded)).isFalse(); - } - - @Test - void shouldHandlePasswordsWithNullBytes() { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); - String passwordWithNull = "password\u0000test"; - - String encoded = encoder.encode(passwordWithNull); - assertThat(encoded).isNotNull(); - assertThat(encoder.matches(passwordWithNull, encoded)).isTrue(); - assertThat(encoder.matches("passwordtest", encoded)).isFalse(); - } - - @Test - void shouldProduceStrongRandomness() { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); - java.util.Set hashes = new java.util.HashSet<>(); - - // Generate many hashes of the same password - for (int i = 0; i < 100; i++) { - String hash = encoder.encode(PASSWORD); - assertThat(hashes.add(hash)).isTrue(); // Each hash should be unique - } - - assertThat(hashes).hasSize(100); - } - - @Test - void shouldResistTimingAttacks() { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); - String validHash = encoder.encode(PASSWORD); - - // Measure time for correct password - long startTime = System.nanoTime(); - boolean result1 = encoder.matches(PASSWORD, validHash); - long correctTime = System.nanoTime() - startTime; - - // Measure time for wrong password of same length - startTime = System.nanoTime(); - boolean result2 = encoder.matches("passwore", validHash); // Same length, different content - long wrongTime = System.nanoTime() - startTime; - - assertThat(result1).isTrue(); - assertThat(result2).isFalse(); - - // Times should be relatively close (within 10x factor for timing attack resistance) - double ratio = Math.max(correctTime, wrongTime) / (double) Math.min(correctTime, wrongTime); - assertThat(ratio).isLessThan(10.0); - } - - - @Test - void scryptShouldHandleEdgeCaseParameters() { - // Test with minimum viable parameters - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.scrypt(2, 1, 1, 16); - - String encoded = encoder.encode(PASSWORD); - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - } - - @Test - void argon2ShouldWorkWithDifferentMemorySizes() { - // Test with various memory configurations - int[] memorySizes = {1024, 4096, 16384, 65536}; - - for (int memory : memorySizes) { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.argon2(memory, 2, 1, 32, Argon2.ID); - String encoded = encoder.encode(PASSWORD); - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - } - } - - @Test - void pbkdf2ShouldWorkWithDifferentHashAlgorithms() { - // Test that the implementation handles different internal configurations - Password4jPasswordEncoder encoder1 = Password4jPasswordEncoder.pbkdf2(50000, 16); - Password4jPasswordEncoder encoder2 = Password4jPasswordEncoder.pbkdf2(100000, 32); - Password4jPasswordEncoder encoder3 = Password4jPasswordEncoder.pbkdf2(200000, 64); - - String hash1 = encoder1.encode(PASSWORD); - String hash2 = encoder2.encode(PASSWORD); - String hash3 = encoder3.encode(PASSWORD); - - assertThat(encoder1.matches(PASSWORD, hash1)).isTrue(); - assertThat(encoder2.matches(PASSWORD, hash2)).isTrue(); - assertThat(encoder3.matches(PASSWORD, hash3)).isTrue(); - - // Hashes should be different due to different parameters - assertThat(hash1).isNotEqualTo(hash2); - assertThat(hash2).isNotEqualTo(hash3); - } - - // Cross-Algorithm Verification Tests - @Test - void differentAlgorithmsShouldNotCrossVerify() { - Password4jPasswordEncoder bcryptEncoder = Password4jPasswordEncoder.bcrypt(10); - Password4jPasswordEncoder scryptEncoder = Password4jPasswordEncoder.scrypt(16384, 8, 1, 32); - Password4jPasswordEncoder argon2Encoder = Password4jPasswordEncoder.argon2(65536, 3, 4, 32, Argon2.ID); - - String bcryptHash = bcryptEncoder.encode(PASSWORD); - String scryptHash = scryptEncoder.encode(PASSWORD); - String argon2Hash = argon2Encoder.encode(PASSWORD); - - // Each encoder should only verify its own hashes - assertThat(bcryptEncoder.matches(PASSWORD, bcryptHash)).isTrue(); - assertThat(bcryptEncoder.matches(PASSWORD, scryptHash)).isFalse(); - assertThat(bcryptEncoder.matches(PASSWORD, argon2Hash)).isFalse(); - - assertThat(scryptEncoder.matches(PASSWORD, scryptHash)).isTrue(); - assertThat(scryptEncoder.matches(PASSWORD, bcryptHash)).isFalse(); - assertThat(scryptEncoder.matches(PASSWORD, argon2Hash)).isFalse(); - - assertThat(argon2Encoder.matches(PASSWORD, argon2Hash)).isTrue(); - assertThat(argon2Encoder.matches(PASSWORD, bcryptHash)).isFalse(); - assertThat(argon2Encoder.matches(PASSWORD, scryptHash)).isFalse(); - } - - - @Test - void encodingShouldCompleteInReasonableTime() { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); - - long startTime = System.currentTimeMillis(); - String encoded = encoder.encode(PASSWORD); - long duration = System.currentTimeMillis() - startTime; - - assertThat(encoded).isNotNull(); - assertThat(duration).isLessThan(5000); // Should complete within 5 seconds - } - - // Compatibility and Integration Tests - @Test - void shouldBeCompatibleWithSpringSecurityConventions() { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); - - // Test common Spring Security patterns - assertThat(encoder.encode(null)).isNull(); - assertThat(encoder.matches(null, "hash")).isFalse(); - assertThat(encoder.matches("password", null)).isFalse(); - assertThat(encoder.upgradeEncoding("anyhash")).isFalse(); - - // Test that it follows AbstractValidatingPasswordEncoder contract - assertThat(encoder.matches("", "")).isFalse(); - assertThat(encoder.upgradeEncoding("")).isFalse(); - } - - @Test - void factoryMethodsShouldCreateCorrectInstances() { - // Verify all factory methods create properly configured instances - Password4jPasswordEncoder bcrypt = Password4jPasswordEncoder.bcrypt(12); - assertThat(bcrypt.getAlgorithm()).isEqualTo(Password4jPasswordEncoder.Password4jAlgorithm.BCRYPT); - assertThat(bcrypt.getHashingFunction()).isInstanceOf(BcryptFunction.class); - - Password4jPasswordEncoder scrypt = Password4jPasswordEncoder.scrypt(32768, 8, 1, 32); - assertThat(scrypt.getAlgorithm()).isEqualTo(Password4jPasswordEncoder.Password4jAlgorithm.SCRYPT); - assertThat(scrypt.getHashingFunction()).isInstanceOf(ScryptFunction.class); - - Password4jPasswordEncoder argon2 = Password4jPasswordEncoder.argon2(65536, 3, 4, 32, Argon2.ID); - assertThat(argon2.getAlgorithm()).isEqualTo(Password4jPasswordEncoder.Password4jAlgorithm.ARGON2); - assertThat(argon2.getHashingFunction()).isInstanceOf(Argon2Function.class); - - Password4jPasswordEncoder pbkdf2 = Password4jPasswordEncoder.pbkdf2(100000, 32); - assertThat(pbkdf2.getAlgorithm()).isEqualTo(Password4jPasswordEncoder.Password4jAlgorithm.PBKDF2); - assertThat(pbkdf2.getHashingFunction()).isInstanceOf(CompressedPBKDF2Function.class); - - Password4jPasswordEncoder compressedPbkdf2 = Password4jPasswordEncoder.compressedPbkdf2(100000, 32); - assertThat(compressedPbkdf2.getAlgorithm()).isEqualTo(Password4jPasswordEncoder.Password4jAlgorithm.COMPRESSED_PBKDF2); - assertThat(compressedPbkdf2.getHashingFunction()).isInstanceOf(CompressedPBKDF2Function.class); - } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 63f14d6893..9558e11acc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,7 @@ org-jetbrains-kotlinx = "1.10.2" org-mockito = "5.17.0" org-opensaml5 = "5.1.6" org-springframework = "7.0.0-M9" +com-password4j = "1.8.2" [libraries] ch-qos-logback-logback-classic = "ch.qos.logback:logback-classic:1.5.18" @@ -101,6 +102,7 @@ org-instancio-instancio-junit = "org.instancio:instancio-junit:3.7.1" spring-nullability = 'io.spring.nullability:io.spring.nullability.gradle.plugin:0.0.4' webauthn4j-core = 'com.webauthn4j:webauthn4j-core:0.29.6.RELEASE' +com-password4j-password4j = { module = "com.password4j:password4j", version.ref = "com-password4j" } [plugins]