diff --git a/build.gradle b/build.gradle index a4cc262f..59ddad7b 100644 --- a/build.gradle +++ b/build.gradle @@ -66,7 +66,7 @@ dependencies { implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.0' implementation 'org.openapitools:jackson-databind-nullable:0.2.6' implementation 'org.apache.commons:commons-text:1.11.0' - implementation 'commons-codec:commons-codec:1.17.0' + implementation 'org.bouncycastle:bcpkix-jdk18on:1.76' implementation 'org.modelmapper:modelmapper:3.2.0' implementation 'org.iban4j:iban4j:3.2.7-RELEASE' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.4.0' diff --git a/src/main/java/net/hostsharing/hsadminng/hash/HashProcessor.java b/src/main/java/net/hostsharing/hsadminng/hash/HashProcessor.java index d10ee565..e6fc4c99 100644 --- a/src/main/java/net/hostsharing/hsadminng/hash/HashProcessor.java +++ b/src/main/java/net/hostsharing/hsadminng/hash/HashProcessor.java @@ -1,32 +1,38 @@ package net.hostsharing.hsadminng.hash; -import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; -import java.util.Base64; import lombok.SneakyThrows; - -import jakarta.validation.ValidationException; +import org.bouncycastle.crypto.generators.OpenBSDBCrypt; import static net.hostsharing.hsadminng.hash.HashProcessor.Algorithm.SHA512; public class HashProcessor { private static final SecureRandom secureRandom = new SecureRandom(); + public static final int SALT_LENGTH = 16; + public static final int COST_FACTOR = 13; + + private final Algorithm algorithm; + private String password; public enum Algorithm { - SHA512 + SHA512("$6$"); + + final String prefix; + + Algorithm(final String prefix) { + this.prefix = prefix; + } } - private static final Base64.Encoder BASE64 = Base64.getEncoder(); private static final String SALT_CHARACTERS = "abcdefghijklmnopqrstuvwxyz" + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + - "0123456789$_"; + "0123456789/."; - private final MessageDigest generator; - private byte[] saltBytes; + private String salt; @SneakyThrows public static HashProcessor hashAlgorithm(final Algorithm algorithm) { @@ -34,42 +40,34 @@ public class HashProcessor { } private HashProcessor(final Algorithm algorithm) throws NoSuchAlgorithmException { - generator = MessageDigest.getInstance(algorithm.name()); + this.algorithm = algorithm; } - public String generate(final String password) { - final byte[] saltedPasswordDigest = calculateSaltedDigest(password); - final byte[] hashBytes = appendSaltToSaltedDigest(saltedPasswordDigest); - return BASE64.encodeToString(hashBytes); - } - - private byte[] appendSaltToSaltedDigest(final byte[] saltedPasswordDigest) { - final byte[] hashBytes = new byte[saltedPasswordDigest.length + 1 + saltBytes.length]; - System.arraycopy(saltedPasswordDigest, 0, hashBytes, 0, saltedPasswordDigest.length); - hashBytes[saltedPasswordDigest.length] = ':'; - System.arraycopy(saltBytes, 0, hashBytes, saltedPasswordDigest.length+1, saltBytes.length); - return hashBytes; - } - - private byte[] calculateSaltedDigest(final String password) { - generator.reset(); - generator.update(password.getBytes()); - generator.update(saltBytes); - return generator.digest(); - } - - public HashProcessor withSalt(final byte[] saltBytes) { - this.saltBytes = saltBytes; + public HashProcessor generate(final String password) { + this.password = password; return this; } + public String forLinux() { + if (salt == null) { + throw new IllegalStateException("no salt given"); + } + if (password == null) { + throw new IllegalStateException("no password given"); + } + final var hash = OpenBSDBCrypt.generate(password.toCharArray(), salt.getBytes(), COST_FACTOR); + final var hashedPassword = new String(org.bouncycastle.util.encoders.Base64.encode(hash.getBytes())); + return "$6$" + salt + "$" + hashedPassword; + } + public HashProcessor withSalt(final String salt) { - return withSalt(salt.getBytes()); + this.salt = salt; + return this; } public HashProcessor withRandomSalt() { - final var stringBuilder = new StringBuilder(16); - for (int i = 0; i < 16; ++i) { + final var stringBuilder = new StringBuilder(SALT_LENGTH); + for (int i = 0; i < SALT_LENGTH; ++i) { int randomIndex = secureRandom.nextInt(SALT_CHARACTERS.length()); stringBuilder.append(SALT_CHARACTERS.charAt(randomIndex)); } @@ -80,27 +78,23 @@ public class HashProcessor { return new HashVerifier(hash); } - private static String getLastPart(String input, char delimiter) { - final var lastIndex = input.lastIndexOf(delimiter); - if (lastIndex == -1) { - throw new IllegalArgumentException("cannot determine salt, expected: 'digest:salt', but no ':' found"); - } - return input.substring(lastIndex + 1); - } - public class HashVerifier { private final String hash; public HashVerifier(final String hash) { + final var parts = hash.split("\\$"); + if (!hash.startsWith(algorithm.prefix) || parts.length != 4) { + throw new IllegalArgumentException("not a "+algorithm.name()+" Linux hash: " + hash); + } this.hash = hash; - withSalt(getLastPart(new String(Base64.getDecoder().decode(hash)), ':')); + withSalt(parts[2]); } - public void verify(String password) { - final var computedHash = hashAlgorithm(SHA512).withSalt(saltBytes).generate(password); - if ( !computedHash.equals(hash) ) { - throw new ValidationException("invalid password"); + public void verify(final String password) { + final String recalculatedHash = hashAlgorithm(SHA512).withSalt(salt).generate(password).forLinux(); + if (!recalculatedHash.equals(this.hash)) { + throw new IllegalArgumentException("invalid password"); } } } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/validation/PasswordProperty.java b/src/main/java/net/hostsharing/hsadminng/hs/validation/PasswordProperty.java index 6f285595..09cc79b1 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/validation/PasswordProperty.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/validation/PasswordProperty.java @@ -37,7 +37,7 @@ public class PasswordProperty extends StringProperty { // FIXME: computedBy is too late, we need preprocess computedBy((entity) -> ofNullable(entity.getDirectValue(propertyName, String.class)) - .map(password -> hashAlgorithm(algorithm).withRandomSalt().generate(password)) + .map(password -> hashAlgorithm(algorithm).withRandomSalt().generate(password).forLinux()) .orElse(null)); return self(); } diff --git a/src/test/java/net/hostsharing/hsadminng/hash/HashProcessorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hash/HashProcessorUnitTest.java index 6fc39578..ffffbad2 100644 --- a/src/test/java/net/hostsharing/hsadminng/hash/HashProcessorUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hash/HashProcessorUnitTest.java @@ -2,8 +2,6 @@ package net.hostsharing.hsadminng.hash; import org.junit.jupiter.api.Test; -import java.util.Base64; - import static net.hostsharing.hsadminng.hash.HashProcessor.Algorithm.SHA512; import static net.hostsharing.hsadminng.hash.HashProcessor.hashAlgorithm; import static org.assertj.core.api.Assertions.assertThat; @@ -11,30 +9,28 @@ import static org.assertj.core.api.Assertions.catchThrowable; class HashProcessorUnitTest { - final String OTHER_PASSWORD = "other password"; final String GIVEN_PASSWORD = "given password"; - final String GIVEN_PASSWORD_HASH = "foKDNQP0oZo0pjFpss5vNl0kfHOs6MKMaJUUbpJTg6hqI1WY+KbU/PKQIg2xt/mwDMmW5WR0pdUZnTv8RPTfhjprZUNqTXJsUXczQnczYUxE"; - final String GIVEN_SALT = "given salt"; + final String WRONG_PASSWORD = "wrong password"; + final String GIVEN_SALT = "0123456789abcdef"; @Test void verifiesHashedPasswordWithRandomSalt() { - final var hash = hashAlgorithm(SHA512).withRandomSalt().generate(GIVEN_PASSWORD); + final var hash = hashAlgorithm(SHA512).withRandomSalt().generate(GIVEN_PASSWORD).forLinux(); hashAlgorithm(SHA512).withHash(hash).verify(GIVEN_PASSWORD); // throws exception if wrong } @Test void verifiesHashedPasswordWithGivenSalt() { - final var hash = hashAlgorithm(SHA512).withSalt(GIVEN_SALT).generate(GIVEN_PASSWORD); - - final var decoded = new String(Base64.getDecoder().decode(hash)); - assertThat(decoded).endsWith(":" + GIVEN_SALT); - hashAlgorithm(SHA512).withHash(hash).verify(GIVEN_PASSWORD); // throws exception if wrong + final var givenPasswordHash = hashAlgorithm(SHA512).withSalt(GIVEN_SALT).generate(GIVEN_PASSWORD).forLinux(); + hashAlgorithm(SHA512).withHash(givenPasswordHash).verify(GIVEN_PASSWORD); // throws exception if wrong } @Test void throwsExceptionForInvalidPassword() { + final var givenPasswordHash = hashAlgorithm(SHA512).withSalt(GIVEN_SALT).generate(GIVEN_PASSWORD).forLinux(); + final var throwable = catchThrowable(() -> - hashAlgorithm(SHA512).withHash(GIVEN_PASSWORD_HASH).verify(OTHER_PASSWORD)); + hashAlgorithm(SHA512).withHash(givenPasswordHash).verify(WRONG_PASSWORD)); assertThat(throwable).hasMessage("invalid password"); }