LdapArgon2Hash.java

package net.hostsharing.hsadminng.hash;

import de.mkammerer.argon2.Argon2Factory;
import lombok.SneakyThrows;
import lombok.experimental.UtilityClass;
import lombok.val;

import static net.hostsharing.hsadminng.hash.Base64Utils.isBase64;

// WARNING: explicit salt and external random salt are silently ignored, a salt gets generated implicitly
@UtilityClass
public class LdapArgon2Hash {

    // align with LDAP config
    public static final int DEFAULT_ITERATIONS = 3;      // t
    public static final int DEFAULT_MEMORY_KIB = 65536;  // m (64 MiB)
    public static final int DEFAULT_PARALLELISM = 1;     // p
    public static final int DEFAULT_SALT_LEN = 16;       // Bytes
    public static final int DEFAULT_HASH_LEN = 32;       // Bytes

    @SneakyThrows
    public static String hash(final HashGenerator generator, final String password) {
        if (isArgon2Hash(password)) {
            return password;
        }
        return hashForLdap(password, DEFAULT_ITERATIONS, DEFAULT_MEMORY_KIB,
                DEFAULT_PARALLELISM, DEFAULT_SALT_LEN, DEFAULT_HASH_LEN);
    }

    public static void verifyHash(final String hash, final String password) {
        // optionally remove prefix to get a pure PHC-String
        final var phc = hash.startsWith("{ARGON2}") ? hash.substring("{ARGON2}".length()) : hash;
        final var argon2 = Argon2Factory.create(Argon2Factory.Argon2Types.ARGON2id, DEFAULT_SALT_LEN, DEFAULT_HASH_LEN);
        if (!argon2.verify(phc, password.toCharArray())) {
            throw new IllegalArgumentException("invalid password");
        }
    }

    private static boolean isArgon2Hash(final String password) {
        val hash = password.substring(password.lastIndexOf('$') + 1);
        return password.startsWith("{ARGON2}") && password.contains("$argon2id$")
                && hash.length() > 10 && isBase64(hash);
    }

    public static String hashForLdap(final String password,
            final int iterations,
            final int memoryKiB,
            final int parallelism,
            final int saltLen,
            final int hashLen) {

        final var argon2 = Argon2Factory.create(Argon2Factory.Argon2Types.ARGON2id, saltLen, hashLen);
        // generates a PHC-String: $argon2id$v=19$m=..,t=..,p=..$<salt>$<hash>
        final var phc = argon2.hash(iterations, memoryKiB, parallelism, password.toCharArray());
        // but LDAP expects {ARGON2}<PHC-String>
        return "{ARGON2}" + phc;
    }

    public static boolean isValid(final String hash) {
        if (!hash.startsWith("{ARGON2}")) {
            return false;
        }
        final String argon2Part = hash.substring("{ARGON2}".length());
        return argon2Part.contains("$argon2id$") &&
                argon2Part.matches("\\$argon2id\\$v=19\\$m=\\d+,t=\\d+,p=\\d+\\$[A-Za-z0-9+/]{22}\\$[A-Za-z0-9+/]{43}");
    }

}