LdapSshaHash.java

package net.hostsharing.hsadminng.hash;

import lombok.SneakyThrows;
import lombok.val;

import java.security.MessageDigest;
import java.util.Base64;
import java.util.Optional;

import static java.nio.charset.StandardCharsets.UTF_8;
import static net.hostsharing.hsadminng.hash.Base64Utils.isBase64;

public class LdapSshaHash {

    @SneakyThrows
    public static String hash(final HashGenerator generator, final String password) {
        if (isValid(password)) {
            return password;
        }

        // SSHA is a salted SHA1-1 hash
        val digest =  MessageDigest.getInstance("SHA-1");
        val salt = Optional.ofNullable(generator.getSalt()).orElse("").getBytes(UTF_8);
        digest.update(password.getBytes(UTF_8));
        digest.update(salt);
        val hashBytes = digest.digest();

        // Concatenate hash + salt for SSHA format
        val hashWithSalt = new byte[hashBytes.length + salt.length];
        System.arraycopy(hashBytes, 0, hashWithSalt, 0, hashBytes.length);
        System.arraycopy(salt, 0, hashWithSalt, hashBytes.length, salt.length);

        // Encode to Base64 and add SSHA prefix
        val base64Hash = Base64.getEncoder().encodeToString(hashWithSalt);
        return "{SSHA}" + base64Hash;
    }

    @SneakyThrows
    public static void verifyHash(final String hash, final String password) {
        val digest =  MessageDigest.getInstance("SHA-1");
        val cleanHash = hash.startsWith("{SSHA}") ? hash.substring("{SSHA}".length()) : hash;
        val decodedHash = (isBase64(cleanHash) ? Base64.getDecoder().decode(cleanHash) : cleanHash.getBytes(UTF_8));

        try {
            // SSHA format: first 20 bytes are the SHA-1 hash, remaining bytes are the salt
            if (decodedHash.length <= 20) {
                throw new IllegalArgumentException("Invalid SSHA hash format");
            }

            // Extract the original hash (first 20 bytes)
            val originalHash = new byte[20];
            System.arraycopy(decodedHash, 0, originalHash, 0, 20);

            // Extract the salt (remaining bytes)
            val salt = new byte[decodedHash.length - 20];
            System.arraycopy(decodedHash, 20, salt, 0, salt.length);

            // Standard verification as fallback
            digest.reset();
            digest.update(password.getBytes(UTF_8));
            digest.update(salt);
            val passwordHashBytes = digest.digest();
            
            if (!java.util.Arrays.equals(passwordHashBytes, originalHash)) {
                throw new IllegalArgumentException("invalid password");
            }
        } catch (final Exception e) {
            throw new IllegalArgumentException("invalid password", e);
        }
    }

    public static boolean isValid(final String password) {
        return password.startsWith("{SSHA}") && isBase64(password.substring("{SSHA}".length()));
    }
}