integrate-sha512-password-hashing #68
@ -66,7 +66,7 @@ dependencies {
|
|||||||
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.0'
|
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.0'
|
||||||
implementation 'org.openapitools:jackson-databind-nullable:0.2.6'
|
implementation 'org.openapitools:jackson-databind-nullable:0.2.6'
|
||||||
implementation 'org.apache.commons:commons-text:1.11.0'
|
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.modelmapper:modelmapper:3.2.0'
|
||||||
implementation 'org.iban4j:iban4j:3.2.7-RELEASE'
|
implementation 'org.iban4j:iban4j:3.2.7-RELEASE'
|
||||||
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.4.0'
|
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.4.0'
|
||||||
|
@ -1,32 +1,38 @@
|
|||||||
package net.hostsharing.hsadminng.hash;
|
package net.hostsharing.hsadminng.hash;
|
||||||
|
|
||||||
import java.security.MessageDigest;
|
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
import java.util.Base64;
|
|
||||||
|
|
||||||
import lombok.SneakyThrows;
|
import lombok.SneakyThrows;
|
||||||
|
import org.bouncycastle.crypto.generators.OpenBSDBCrypt;
|
||||||
import jakarta.validation.ValidationException;
|
|
||||||
|
|
||||||
import static net.hostsharing.hsadminng.hash.HashProcessor.Algorithm.SHA512;
|
import static net.hostsharing.hsadminng.hash.HashProcessor.Algorithm.SHA512;
|
||||||
|
|
||||||
public class HashProcessor {
|
public class HashProcessor {
|
||||||
|
|
||||||
private static final SecureRandom secureRandom = new SecureRandom();
|
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 {
|
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 =
|
private static final String SALT_CHARACTERS =
|
||||||
"abcdefghijklmnopqrstuvwxyz" +
|
"abcdefghijklmnopqrstuvwxyz" +
|
||||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZ" +
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZ" +
|
||||||
"0123456789$_";
|
"0123456789/.";
|
||||||
|
|
||||||
private final MessageDigest generator;
|
private String salt;
|
||||||
private byte[] saltBytes;
|
|
||||||
|
|
||||||
@SneakyThrows
|
@SneakyThrows
|
||||||
public static HashProcessor hashAlgorithm(final Algorithm algorithm) {
|
public static HashProcessor hashAlgorithm(final Algorithm algorithm) {
|
||||||
@ -34,42 +40,34 @@ public class HashProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private HashProcessor(final Algorithm algorithm) throws NoSuchAlgorithmException {
|
private HashProcessor(final Algorithm algorithm) throws NoSuchAlgorithmException {
|
||||||
generator = MessageDigest.getInstance(algorithm.name());
|
this.algorithm = algorithm;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String generate(final String password) {
|
public HashProcessor generate(final String password) {
|
||||||
final byte[] saltedPasswordDigest = calculateSaltedDigest(password);
|
this.password = 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;
|
|
||||||
return this;
|
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) {
|
public HashProcessor withSalt(final String salt) {
|
||||||
return withSalt(salt.getBytes());
|
this.salt = salt;
|
||||||
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public HashProcessor withRandomSalt() {
|
public HashProcessor withRandomSalt() {
|
||||||
final var stringBuilder = new StringBuilder(16);
|
final var stringBuilder = new StringBuilder(SALT_LENGTH);
|
||||||
for (int i = 0; i < 16; ++i) {
|
for (int i = 0; i < SALT_LENGTH; ++i) {
|
||||||
int randomIndex = secureRandom.nextInt(SALT_CHARACTERS.length());
|
int randomIndex = secureRandom.nextInt(SALT_CHARACTERS.length());
|
||||||
stringBuilder.append(SALT_CHARACTERS.charAt(randomIndex));
|
stringBuilder.append(SALT_CHARACTERS.charAt(randomIndex));
|
||||||
}
|
}
|
||||||
@ -80,27 +78,23 @@ public class HashProcessor {
|
|||||||
return new HashVerifier(hash);
|
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 {
|
public class HashVerifier {
|
||||||
|
|
||||||
private final String hash;
|
private final String hash;
|
||||||
|
|
||||||
public HashVerifier(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;
|
this.hash = hash;
|
||||||
withSalt(getLastPart(new String(Base64.getDecoder().decode(hash)), ':'));
|
withSalt(parts[2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void verify(String password) {
|
public void verify(final String password) {
|
||||||
final var computedHash = hashAlgorithm(SHA512).withSalt(saltBytes).generate(password);
|
final String recalculatedHash = hashAlgorithm(SHA512).withSalt(salt).generate(password).forLinux();
|
||||||
if ( !computedHash.equals(hash) ) {
|
if (!recalculatedHash.equals(this.hash)) {
|
||||||
throw new ValidationException("invalid password");
|
throw new IllegalArgumentException("invalid password");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -37,7 +37,7 @@ public class PasswordProperty extends StringProperty<PasswordProperty> {
|
|||||||
// FIXME: computedBy is too late, we need preprocess
|
// FIXME: computedBy is too late, we need preprocess
|
||||||
computedBy((entity)
|
computedBy((entity)
|
||||||
-> ofNullable(entity.getDirectValue(propertyName, String.class))
|
-> ofNullable(entity.getDirectValue(propertyName, String.class))
|
||||||
.map(password -> hashAlgorithm(algorithm).withRandomSalt().generate(password))
|
.map(password -> hashAlgorithm(algorithm).withRandomSalt().generate(password).forLinux())
|
||||||
.orElse(null));
|
.orElse(null));
|
||||||
return self();
|
return self();
|
||||||
}
|
}
|
||||||
|
@ -2,8 +2,6 @@ package net.hostsharing.hsadminng.hash;
|
|||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
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.Algorithm.SHA512;
|
||||||
import static net.hostsharing.hsadminng.hash.HashProcessor.hashAlgorithm;
|
import static net.hostsharing.hsadminng.hash.HashProcessor.hashAlgorithm;
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
@ -11,30 +9,28 @@ import static org.assertj.core.api.Assertions.catchThrowable;
|
|||||||
|
|
||||||
class HashProcessorUnitTest {
|
class HashProcessorUnitTest {
|
||||||
|
|
||||||
final String OTHER_PASSWORD = "other password";
|
|
||||||
final String GIVEN_PASSWORD = "given password";
|
final String GIVEN_PASSWORD = "given password";
|
||||||
final String GIVEN_PASSWORD_HASH = "foKDNQP0oZo0pjFpss5vNl0kfHOs6MKMaJUUbpJTg6hqI1WY+KbU/PKQIg2xt/mwDMmW5WR0pdUZnTv8RPTfhjprZUNqTXJsUXczQnczYUxE";
|
final String WRONG_PASSWORD = "wrong password";
|
||||||
final String GIVEN_SALT = "given salt";
|
final String GIVEN_SALT = "0123456789abcdef";
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void verifiesHashedPasswordWithRandomSalt() {
|
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
|
hashAlgorithm(SHA512).withHash(hash).verify(GIVEN_PASSWORD); // throws exception if wrong
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void verifiesHashedPasswordWithGivenSalt() {
|
void verifiesHashedPasswordWithGivenSalt() {
|
||||||
final var hash = hashAlgorithm(SHA512).withSalt(GIVEN_SALT).generate(GIVEN_PASSWORD);
|
final var givenPasswordHash = hashAlgorithm(SHA512).withSalt(GIVEN_SALT).generate(GIVEN_PASSWORD).forLinux();
|
||||||
|
hashAlgorithm(SHA512).withHash(givenPasswordHash).verify(GIVEN_PASSWORD); // throws exception if wrong
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void throwsExceptionForInvalidPassword() {
|
void throwsExceptionForInvalidPassword() {
|
||||||
|
final var givenPasswordHash = hashAlgorithm(SHA512).withSalt(GIVEN_SALT).generate(GIVEN_PASSWORD).forLinux();
|
||||||
|
|
||||||
final var throwable = catchThrowable(() ->
|
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");
|
assertThat(throwable).hasMessage("invalid password");
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user