implement SHA512 password hashing using 'org.bouncycastle:bcpkix-jdk18on:1.76'

This commit is contained in:
Michael Hoennig 2024-06-29 10:33:10 +02:00
parent 3391ec6cc9
commit 7b82be64a4
4 changed files with 53 additions and 63 deletions

View File

@ -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'

View File

@ -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");
}
}
}

View File

@ -37,7 +37,7 @@ public class PasswordProperty extends StringProperty<PasswordProperty> {
// 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();
}

View File

@ -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");
}