From e2233a23381ad8f16a65ffb76a36474db46ef669 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Thu, 27 Jun 2024 17:27:37 +0200 Subject: [PATCH] implement HashProcessor .generate(...) + .validate(...) --- .../hsadminng/hash/HashProcessor.java | 107 ++++++++++++++++++ .../HsUnixUserHostingAssetValidator.java | 3 +- .../hs/validation/HsEntityValidator.java | 1 + .../hs/validation/PasswordProperty.java | 19 +++- .../hs/validation/StringProperty.java | 6 +- .../hostsharing/hsadminng/mapper/Array.java | 15 +++ .../hsadminng/hash/HashProcessorUnitTest.java | 41 +++++++ ...UnixUserHostingAssetValidatorUnitTest.java | 2 +- .../validation/PasswordPropertyUnitTest.java | 30 ++++- 9 files changed, 218 insertions(+), 6 deletions(-) create mode 100644 src/main/java/net/hostsharing/hsadminng/hash/HashProcessor.java create mode 100644 src/test/java/net/hostsharing/hsadminng/hash/HashProcessorUnitTest.java diff --git a/src/main/java/net/hostsharing/hsadminng/hash/HashProcessor.java b/src/main/java/net/hostsharing/hsadminng/hash/HashProcessor.java new file mode 100644 index 00000000..d10ee565 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hash/HashProcessor.java @@ -0,0 +1,107 @@ +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 static net.hostsharing.hsadminng.hash.HashProcessor.Algorithm.SHA512; + +public class HashProcessor { + + private static final SecureRandom secureRandom = new SecureRandom(); + + public enum Algorithm { + SHA512 + } + + private static final Base64.Encoder BASE64 = Base64.getEncoder(); + private static final String SALT_CHARACTERS = + "abcdefghijklmnopqrstuvwxyz" + + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + "0123456789$_"; + + private final MessageDigest generator; + private byte[] saltBytes; + + @SneakyThrows + public static HashProcessor hashAlgorithm(final Algorithm algorithm) { + return new HashProcessor(algorithm); + } + + private HashProcessor(final Algorithm algorithm) throws NoSuchAlgorithmException { + generator = MessageDigest.getInstance(algorithm.name()); + } + + 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; + return this; + } + + public HashProcessor withSalt(final String salt) { + return withSalt(salt.getBytes()); + } + + public HashProcessor withRandomSalt() { + final var stringBuilder = new StringBuilder(16); + for (int i = 0; i < 16; ++i) { + int randomIndex = secureRandom.nextInt(SALT_CHARACTERS.length()); + stringBuilder.append(SALT_CHARACTERS.charAt(randomIndex)); + } + return withSalt(stringBuilder.toString()); + } + + public HashVerifier withHash(final String 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 { + + private final String hash; + + public HashVerifier(final String hash) { + this.hash = hash; + withSalt(getLastPart(new String(Base64.getDecoder().decode(hash)), ':')); + } + + public void verify(String password) { + final var computedHash = hashAlgorithm(SHA512).withSalt(saltBytes).generate(password); + if ( !computedHash.equals(hash) ) { + throw new ValidationException("invalid password"); + } + } + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsUnixUserHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsUnixUserHostingAssetValidator.java index 5f86c86c..1b7b01dc 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsUnixUserHostingAssetValidator.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsUnixUserHostingAssetValidator.java @@ -1,5 +1,6 @@ package net.hostsharing.hsadminng.hs.hosting.asset.validators; +import net.hostsharing.hsadminng.hash.HashProcessor; import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetEntity; import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType; import net.hostsharing.hsadminng.hs.validation.PropertiesProvider; @@ -30,7 +31,7 @@ class HsUnixUserHostingAssetValidator extends HsHostingAssetEntityValidator { .withDefault("/bin/false"), stringProperty("homedir").readOnly().computedBy(HsUnixUserHostingAssetValidator::computeHomedir), stringProperty("totpKey").matchesRegEx("^0x([0-9A-Fa-f]{2})+$").minLength(20).maxLength(256).undisclosed().writeOnly().optional(), - passwordProperty("password").minLength(8).maxLength(40).hashedUsing("SHA-512").writeOnly()); + passwordProperty("password").minLength(8).maxLength(40).hashedUsing(HashProcessor.Algorithm.SHA512).writeOnly()); } @Override diff --git a/src/main/java/net/hostsharing/hsadminng/hs/validation/HsEntityValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/validation/HsEntityValidator.java index b8c345e7..da0ba611 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/validation/HsEntityValidator.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/validation/HsEntityValidator.java @@ -92,6 +92,7 @@ public abstract class HsEntityValidator { public Map postProcess(final E entity, final Map config) { final var copy = new HashMap<>(config); stream(propertyValidators).forEach(p -> { + // FIXME: maybe move to ValidatableProperty.postProcess(...)? if ( p.isWriteOnly()) { copy.remove(p.propertyName); } 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 fba233d1..6f285595 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/validation/PasswordProperty.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/validation/PasswordProperty.java @@ -1,15 +1,24 @@ package net.hostsharing.hsadminng.hs.validation; +import net.hostsharing.hsadminng.hash.HashProcessor.Algorithm; import lombok.Setter; import java.util.List; import java.util.stream.Stream; +import static java.util.Optional.ofNullable; +import static net.hostsharing.hsadminng.hash.HashProcessor.hashAlgorithm; +import static net.hostsharing.hsadminng.mapper.Array.insertAfterEntry; + @Setter public class PasswordProperty extends StringProperty { + private static final String[] KEY_ORDER = insertAfterEntry(StringProperty.KEY_ORDER, "computed", "hashedUsing"); + + private Algorithm hashedUsing; + private PasswordProperty(final String propertyName) { - super(propertyName); + super(propertyName, KEY_ORDER); undisclosed(); } @@ -23,7 +32,13 @@ public class PasswordProperty extends StringProperty { validatePassword(result, propValue); } - public PasswordProperty hashedUsing(final String hashAlgoritm) { + public PasswordProperty hashedUsing(final Algorithm algorithm) { + this.hashedUsing = algorithm; + // FIXME: computedBy is too late, we need preprocess + computedBy((entity) + -> ofNullable(entity.getDirectValue(propertyName, String.class)) + .map(password -> hashAlgorithm(algorithm).withRandomSalt().generate(password)) + .orElse(null)); return self(); } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/validation/StringProperty.java b/src/main/java/net/hostsharing/hsadminng/hs/validation/StringProperty.java index e6b184f0..a8e8b359 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/validation/StringProperty.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/validation/StringProperty.java @@ -10,7 +10,7 @@ import java.util.regex.Pattern; @Setter public class StringProperty

> extends ValidatableProperty { - private static final String[] KEY_ORDER = Array.join( + protected static final String[] KEY_ORDER = Array.join( ValidatableProperty.KEY_ORDER_HEAD, Array.of("matchesRegEx", "minLength", "maxLength"), ValidatableProperty.KEY_ORDER_TAIL, @@ -24,6 +24,10 @@ public class StringProperty

> extends ValidatableProp super(String.class, propertyName, KEY_ORDER); } + protected StringProperty(final String propertyName, final String[] keyOrder) { + super(String.class, propertyName, keyOrder); + } + public static StringProperty stringProperty(final String propertyName) { return new StringProperty<>(propertyName); } diff --git a/src/main/java/net/hostsharing/hsadminng/mapper/Array.java b/src/main/java/net/hostsharing/hsadminng/mapper/Array.java index 86a4766a..57e76381 100644 --- a/src/main/java/net/hostsharing/hsadminng/mapper/Array.java +++ b/src/main/java/net/hostsharing/hsadminng/mapper/Array.java @@ -6,6 +6,8 @@ import java.util.Arrays; import java.util.List; import java.util.Objects; +import static java.util.Arrays.asList; + /** * Java has List.of(...), Set.of(...) and Map.of(...) all with varargs parameter, * but no Array.of(...). Here it is. @@ -48,4 +50,17 @@ public class Array { public static T[] emptyArray() { return of(); } + + public static T[] insertAfterEntry(final T[] array, final T entryToFind, final T newEntry) { + final var arrayList = new ArrayList<>(asList(array)); + final var index = arrayList.indexOf(entryToFind); + if (index < 0) { + throw new IllegalArgumentException("entry "+ entryToFind + " not found in " + Arrays.toString(array)); + } + arrayList.add(index + 1, newEntry); + + @SuppressWarnings("unchecked") + final var extendedArray = (T[]) java.lang.reflect.Array.newInstance(array.getClass().getComponentType(), array.length); + return arrayList.toArray(extendedArray); + } } diff --git a/src/test/java/net/hostsharing/hsadminng/hash/HashProcessorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hash/HashProcessorUnitTest.java new file mode 100644 index 00000000..6fc39578 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hash/HashProcessorUnitTest.java @@ -0,0 +1,41 @@ +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; +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"; + + @Test + void verifiesHashedPasswordWithRandomSalt() { + final var hash = hashAlgorithm(SHA512).withRandomSalt().generate(GIVEN_PASSWORD); + 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 + } + + @Test + void throwsExceptionForInvalidPassword() { + final var throwable = catchThrowable(() -> + hashAlgorithm(SHA512).withHash(GIVEN_PASSWORD_HASH).verify(OTHER_PASSWORD)); + + assertThat(throwable).hasMessage("invalid password"); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsUnixUserHostingAssetValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsUnixUserHostingAssetValidatorUnitTest.java index 8ed76743..2c92d69b 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsUnixUserHostingAssetValidatorUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsUnixUserHostingAssetValidatorUnitTest.java @@ -125,7 +125,7 @@ class HsUnixUserHostingAssetValidatorUnitTest { "{type=enumeration, propertyName=shell, values=[/bin/false, /bin/bash, /bin/csh, /bin/dash, /usr/bin/tcsh, /usr/bin/zsh, /usr/bin/passwd], defaultValue=/bin/false}", "{type=string, propertyName=homedir, readOnly=true, computed=true}", "{type=string, propertyName=totpKey, matchesRegEx=^0x([0-9A-Fa-f]{2})+$, minLength=20, maxLength=256, writeOnly=true, undisclosed=true}", - "{type=password, propertyName=password, minLength=8, maxLength=40, writeOnly=true, undisclosed=true}" + "{type=password, propertyName=password, minLength=8, maxLength=40, writeOnly=true, hashedUsing=SHA512, undisclosed=true}" ); } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/validation/PasswordPropertyUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/validation/PasswordPropertyUnitTest.java index 24801e9c..b694c304 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/validation/PasswordPropertyUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/validation/PasswordPropertyUnitTest.java @@ -6,13 +6,18 @@ import org.junit.jupiter.params.provider.ValueSource; import java.util.ArrayList; import java.util.List; +import java.util.Map; +import static net.hostsharing.hsadminng.hash.HashProcessor.Algorithm.SHA512; +import static net.hostsharing.hsadminng.hash.HashProcessor.hashAlgorithm; import static net.hostsharing.hsadminng.hs.validation.PasswordProperty.passwordProperty; +import static net.hostsharing.hsadminng.mapper.PatchableMapWrapper.entry; import static org.assertj.core.api.Assertions.assertThat; class PasswordPropertyUnitTest { - private final ValidatableProperty passwordProp = passwordProperty("password").minLength(8).maxLength(40).writeOnly(); + private final ValidatableProperty passwordProp = + passwordProperty("password").minLength(8).maxLength(40).hashedUsing(SHA512).writeOnly(); private final List violations = new ArrayList<>(); @ParameterizedTest @@ -89,4 +94,27 @@ class PasswordPropertyUnitTest { .contains("password' must not contain colon (':')") .doesNotContain(givenPassword); } + + @Test + void shouldComputeHash() { + + // when + final var result = passwordProp.compute(new PropertiesProvider() { + + @Override + public Map directProps() { + return Map.ofEntries( + entry(passwordProp.propertyName, "some password") + ); + } + + @Override + public Object getContextValue(final String propName) { + return null; + } + }); + + // then + hashAlgorithm(SHA512).withHash(result).verify("some password"); // throws exception if wrong + } }