integrate-sha512-password-hashing #68
@ -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'
|
||||
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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");
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user