diff --git a/Kitodo-DataManagement/src/main/java/org/kitodo/data/database/enums/PasswordEncryption.java b/Kitodo-DataManagement/src/main/java/org/kitodo/data/database/enums/PasswordEncryption.java index d0f2b7cf630..99bba14de57 100644 --- a/Kitodo-DataManagement/src/main/java/org/kitodo/data/database/enums/PasswordEncryption.java +++ b/Kitodo-DataManagement/src/main/java/org/kitodo/data/database/enums/PasswordEncryption.java @@ -16,18 +16,24 @@ * database. */ public enum PasswordEncryption { - SHA(0, "SHA"), - MD5(1, "MD5"); + SHA(0, "SHA", "{SSHA}"), + MD5(1, "MD5", "{SMD5}"), + SHA_256(2, "SHA-256", "{SSHA-256}"), + BCRYPT(3, "BCRYPT", "{BCRYPT}"), + SCRYPT(4, "SCRYPT", "{SCRYPT}"), + PBKDF2(5, "PBKDF2", "{PBKDF2}"); - private int value; - private String title; + private final int value; + private final String title; + private final String ldapPrefix; /** - * Private constructor, initializes integer value. + * Private constructor, initializes integer value, title and LDAP prefix. */ - PasswordEncryption(int value, String title) { + PasswordEncryption(int value, String title, String ldapPrefix) { this.value = value; this.title = title; + this.ldapPrefix = ldapPrefix; } /** @@ -48,6 +54,15 @@ public String getTitle() { return this.title; } + /** + * Get LDAP prefix for salted password hash, per RFC 2307. + * + * @return LDAP prefix e.g. "{SSHA}", "{SMD5}", "{SSHA-256}" + */ + public String getLdapPrefix() { + return this.ldapPrefix; + } + /** * Retrieve password encryption by integer value, necessary for database * handlings, where only integer is saved but not type safe. diff --git a/Kitodo-DataManagement/src/test/java/org/kitodo/data/database/enums/PasswordEncryptionTest.java b/Kitodo-DataManagement/src/test/java/org/kitodo/data/database/enums/PasswordEncryptionTest.java new file mode 100644 index 00000000000..35f3b1a4edb --- /dev/null +++ b/Kitodo-DataManagement/src/test/java/org/kitodo/data/database/enums/PasswordEncryptionTest.java @@ -0,0 +1,69 @@ +/* + * (c) Kitodo. Key to digital objects e. V. + * + * This file is part of the Kitodo project. + * + * It is licensed under GNU General Public License version 3 or later. + * + * For the full copyright and license information, please read the + * GPL3-License.txt file that was distributed with this source code. + */ + +package org.kitodo.data.database.enums; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashSet; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +public class PasswordEncryptionTest { + + @Test + public void shaHasCorrectValues() { + assertEquals(0, PasswordEncryption.SHA.getValue()); + assertEquals("SHA", PasswordEncryption.SHA.getTitle()); + assertEquals("{SSHA}", PasswordEncryption.SHA.getLdapPrefix()); + } + + @Test + public void md5HasCorrectValues() { + assertEquals(1, PasswordEncryption.MD5.getValue()); + assertEquals("MD5", PasswordEncryption.MD5.getTitle()); + assertEquals("{SMD5}", PasswordEncryption.MD5.getLdapPrefix()); + } + + @Test + public void sha256HasCorrectValues() { + assertEquals(2, PasswordEncryption.SHA_256.getValue()); + assertEquals("SHA-256", PasswordEncryption.SHA_256.getTitle()); + assertEquals("{SSHA-256}", PasswordEncryption.SHA_256.getLdapPrefix()); + } + + @Test + public void getEncryptionFromValueReturnsCorrectEnum() { + assertEquals(PasswordEncryption.SHA, PasswordEncryption.getEncryptionFromValue(0)); + assertEquals(PasswordEncryption.MD5, PasswordEncryption.getEncryptionFromValue(1)); + assertEquals(PasswordEncryption.SHA_256, PasswordEncryption.getEncryptionFromValue(2)); + } + + @Test + public void getEncryptionFromValueReturnsDefaultForNull() { + assertEquals(PasswordEncryption.SHA, PasswordEncryption.getEncryptionFromValue(null)); + } + + @Test + public void getEncryptionFromValueReturnsDefaultForUnknownValue() { + assertEquals(PasswordEncryption.SHA, PasswordEncryption.getEncryptionFromValue(99)); + } + + @Test + public void allEnumValuesAreUnique() { + Set seen = new HashSet<>(); + for (PasswordEncryption pe : PasswordEncryption.values()) { + assertTrue(seen.add(pe.getValue()), "Duplicate getValue(): " + pe.getValue()); + } + } +} diff --git a/Kitodo/src/main/java/org/kitodo/production/ldap/LdapUser.java b/Kitodo/src/main/java/org/kitodo/production/ldap/LdapUser.java index fc6a9f348c9..6bdb2062bb8 100644 --- a/Kitodo/src/main/java/org/kitodo/production/ldap/LdapUser.java +++ b/Kitodo/src/main/java/org/kitodo/production/ldap/LdapUser.java @@ -16,6 +16,7 @@ import java.security.Key; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; import java.util.Hashtable; import java.util.Objects; import java.util.StringTokenizer; @@ -50,6 +51,8 @@ import org.bouncycastle.crypto.digests.MD4Digest; import org.kitodo.data.database.beans.LdapGroup; import org.kitodo.data.database.beans.User; +import org.kitodo.data.database.enums.PasswordEncryption; +import org.kitodo.production.security.password.AdaptivePasswordEncoder; /** * This class is used by the DirObj example. It is a DirContext class that can @@ -81,49 +84,64 @@ public void configure(User user, String inPassword, String inUidNumber) throws NamingException, NoSuchAlgorithmException { MD4Digest digester = new MD4Digest(); if (!user.getLdapGroup().getLdapServer().isReadOnly()) { - if (Objects.nonNull(user.getLdapLogin())) { this.ldapLogin = user.getLdapLogin(); - } else { this.ldapLogin = user.getLogin(); } - LdapGroup ldapGroup = user.getLdapGroup(); if (Objects.isNull(ldapGroup.getObjectClasses())) { throw new NamingException("no objectclass defined"); } - prepareAttributes(ldapGroup, user, inUidNumber); + setSambaPasswords(inPassword, digester); + setUserPassword(inPassword, ldapGroup); + } + } - /* - * Samba passwords - */ - /* LanMgr */ - try { - this.attributes.put("sambaLMPassword", toHexString(lmHash(inPassword))); - } catch (InvalidKeyException | NoSuchPaddingException | BadPaddingException - | IllegalBlockSizeException | RuntimeException e) { - logger.error(e.getMessage(), e); - } - /* NTLM */ - byte[] unicodePassword = inPassword.getBytes(StandardCharsets.UTF_16LE); - byte[] hmm = new byte[digester.getDigestSize()]; - digester.update(unicodePassword, 0, unicodePassword.length); - digester.doFinal(hmm, 0); - this.attributes.put("sambaNTPassword", toHexString(hmm)); - - /* - * Encryption of password und Base64-Enconding - */ - - String passwordEncrytion = ldapGroup.getLdapServer().getPasswordEncryption().getTitle(); - - MessageDigest md = MessageDigest.getInstance(passwordEncrytion); - md.update(inPassword.getBytes(StandardCharsets.UTF_8)); - String encodedDigest = new String(Base64.encodeBase64(md.digest()), StandardCharsets.UTF_8); - this.attributes.put("userPassword", "{" + passwordEncrytion + "}" + encodedDigest); + private void setSambaPasswords(String inPassword, MD4Digest digester) throws NoSuchAlgorithmException { + try { + this.attributes.put("sambaLMPassword", toHexString(lmHash(inPassword))); + } catch (InvalidKeyException | NoSuchPaddingException | BadPaddingException + | IllegalBlockSizeException | RuntimeException e) { + logger.error(e.getMessage(), e); + } + byte[] unicodePassword = inPassword.getBytes(StandardCharsets.UTF_16LE); + byte[] hmm = new byte[digester.getDigestSize()]; + digester.update(unicodePassword, 0, unicodePassword.length); + digester.doFinal(hmm, 0); + this.attributes.put("sambaNTPassword", toHexString(hmm)); + } + + private void setUserPassword(String inPassword, LdapGroup ldapGroup) throws NoSuchAlgorithmException { + PasswordEncryption passwordEncryption = ldapGroup.getLdapServer().getPasswordEncryption(); + AdaptivePasswordEncoder adaptivePasswordEncoder = new AdaptivePasswordEncoder(); + String hashedPassword; + switch (passwordEncryption) { + case BCRYPT: + hashedPassword = adaptivePasswordEncoder.hashBcrypt(inPassword); + break; + case SCRYPT: + hashedPassword = adaptivePasswordEncoder.hashScrypt(inPassword); + break; + case PBKDF2: + hashedPassword = adaptivePasswordEncoder.hashPbkdf2WithHmac(inPassword); + break; + default: + MessageDigest md = MessageDigest.getInstance(passwordEncryption.getTitle()); + SecureRandom secureRandom = new SecureRandom(); + byte[] salt = new byte[8]; + secureRandom.nextBytes(salt); + md.update(inPassword.getBytes(StandardCharsets.UTF_8)); + md.update(salt); + byte[] hash = md.digest(); + byte[] hashAndSalt = new byte[hash.length + salt.length]; + System.arraycopy(hash, 0, hashAndSalt, 0, hash.length); + System.arraycopy(salt, 0, hashAndSalt, hash.length, salt.length); + hashedPassword = Base64.encodeBase64String(hashAndSalt); + break; } + this.attributes.put("userPassword", passwordEncryption.getLdapPrefix() + hashedPassword); } private void prepareAttributes(LdapGroup ldapGroup, User user, String inUidNumber) { diff --git a/Kitodo/src/main/java/org/kitodo/production/security/password/AdaptivePasswordEncoder.java b/Kitodo/src/main/java/org/kitodo/production/security/password/AdaptivePasswordEncoder.java new file mode 100644 index 00000000000..23cf96f6dd3 --- /dev/null +++ b/Kitodo/src/main/java/org/kitodo/production/security/password/AdaptivePasswordEncoder.java @@ -0,0 +1,98 @@ +/* + * (c) Kitodo. Key to digital objects e. V. + * + * This file is part of the Kitodo project. + * + * It is licensed under GNU General Public License version 3 or later. + * + * For the full copyright and license information, please read the + * GPL3-License.txt file that was distributed with this source code. + */ + +package org.kitodo.production.security.password; + +import java.security.SecureRandom; +import java.security.spec.KeySpec; + +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; + +import org.apache.commons.codec.binary.Base64; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder; + +public class AdaptivePasswordEncoder { + + private static final BCryptPasswordEncoder BCRYPT_ENCODER = new BCryptPasswordEncoder(16); + private static final SCryptPasswordEncoder SCRYPT_ENCODER = new SCryptPasswordEncoder(16, 8, 1, 32, 16); + private static final int PBKDF2_ITERATIONS = 185000; + private static final int SALT_LENGTH = 16; + + public String hashBcrypt(String rawPassword) { + return BCRYPT_ENCODER.encode(rawPassword); + } + + public boolean matchesBcrypt(String rawPassword, String encodedPassword) { + return BCRYPT_ENCODER.matches(rawPassword, encodedPassword); + } + + public String hashScrypt(String rawPassword) { + return SCRYPT_ENCODER.encode(rawPassword); + } + + public boolean matchesScrypt(String rawPassword, String encodedPassword) { + return SCRYPT_ENCODER.matches(rawPassword, encodedPassword); + } + + /** + * Hash a password using PBKDF2 with HMAC SHA-256. + * + * @param rawPassword + * the password to hash + * @return the hashed password as a Base64 encoded string + */ + public String hashPbkdf2WithHmac(String rawPassword) { + try { + SecureRandom random = new SecureRandom(); + byte[] salt = new byte[SALT_LENGTH]; + random.nextBytes(salt); + KeySpec spec = new PBEKeySpec(rawPassword.toCharArray(), salt, PBKDF2_ITERATIONS, 256); + SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256"); + byte[] hash = factory.generateSecret(spec).getEncoded(); + byte[] hashAndSalt = new byte[hash.length + salt.length]; + System.arraycopy(hash, 0, hashAndSalt, 0, hash.length); + System.arraycopy(salt, 0, hashAndSalt, hash.length, salt.length); + return Base64.encodeBase64String(hashAndSalt); + } catch (Exception e) { + throw new RuntimeException("PBKDF2 hashing failed", e); + } + } + + /** + * Check if a password matches a PBKDF2 hashed password. + * + * @param rawPassword + * the password to check + * @param encodedPassword + * the hashed password to compare against + * @return true if the password matches, false otherwise + */ + public boolean matchesPbkdf2WithHmac(String rawPassword, String encodedPassword) { + try { + byte[] decoded = Base64.decodeBase64(encodedPassword); + byte[] storedHash = new byte[32]; + byte[] storedSalt = new byte[SALT_LENGTH]; + System.arraycopy(decoded, 0, storedHash, 0, 32); + System.arraycopy(decoded, 32, storedSalt, 0, SALT_LENGTH); + KeySpec spec = new PBEKeySpec(rawPassword.toCharArray(), storedSalt, PBKDF2_ITERATIONS, 256); + SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256"); + byte[] computedHash = factory.generateSecret(spec).getEncoded(); + byte[] computedHashAndSalt = new byte[computedHash.length + storedSalt.length]; + System.arraycopy(computedHash, 0, computedHashAndSalt, 0, computedHash.length); + System.arraycopy(storedSalt, 0, computedHashAndSalt, computedHash.length, storedSalt.length); + return Base64.encodeBase64String(computedHashAndSalt).equals(encodedPassword); + } catch (Exception e) { + return false; + } + } +} diff --git a/Kitodo/src/main/java/org/kitodo/production/services/data/LdapServerService.java b/Kitodo/src/main/java/org/kitodo/production/services/data/LdapServerService.java index 62dc747f7fd..6b7f50a4d3f 100644 --- a/Kitodo/src/main/java/org/kitodo/production/services/data/LdapServerService.java +++ b/Kitodo/src/main/java/org/kitodo/production/services/data/LdapServerService.java @@ -24,6 +24,7 @@ import java.security.KeyStoreException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; @@ -68,6 +69,7 @@ import org.kitodo.production.helper.Helper; import org.kitodo.production.ldap.LdapUser; import org.kitodo.production.security.AESUtil; +import org.kitodo.production.security.password.AdaptivePasswordEncoder; import org.kitodo.production.services.ServiceManager; import org.primefaces.model.SortOrder; @@ -392,38 +394,12 @@ private void setNextUidNumber(LdapServer ldapServer) { */ public boolean changeUserPassword(User user, String inNewPassword) throws NoSuchAlgorithmException { MD4Digest digester = new MD4Digest(); - PasswordEncryption passwordEncryption = user.getLdapGroup().getLdapServer().getPasswordEncryption(); Hashtable env = initializeWithLdapConnectionSettings(user.getLdapGroup().getLdapServer()); if (!user.getLdapGroup().getLdapServer().isReadOnly()) { try { - ModificationItem[] mods = new ModificationItem[4]; - - // encryption of password and Base64-Encoding - MessageDigest md = MessageDigest.getInstance(passwordEncryption.getTitle()); - md.update(inNewPassword.getBytes(StandardCharsets.UTF_8)); - String encryptedPassword = new String(Base64.encodeBase64(md.digest()), StandardCharsets.UTF_8); - - // change attribute userPassword - BasicAttribute userPassword = new BasicAttribute("userPassword", - "{" + passwordEncryption + "}" + encryptedPassword); - mods[0] = new ModificationItem(DirContext.REPLACE_ATTRIBUTE, userPassword); - - // change attribute lanmgrPassword - BasicAttribute lanmgrPassword = proceedPassword("sambaLMPassword", inNewPassword, null); - mods[1] = new ModificationItem(DirContext.REPLACE_ATTRIBUTE, lanmgrPassword); - - // change attribute ntlmPassword - BasicAttribute ntlmPassword = proceedPassword("sambaNTPassword", inNewPassword, digester); - mods[2] = new ModificationItem(DirContext.REPLACE_ATTRIBUTE, ntlmPassword); - - BasicAttribute sambaPwdLastSet = new BasicAttribute("sambaPwdLastSet", - String.valueOf(System.currentTimeMillis() / 1000L)); - mods[3] = new ModificationItem(DirContext.REPLACE_ATTRIBUTE, sambaPwdLastSet); - + ModificationItem[] mods = createPasswordModificationItems(user, inNewPassword, digester); DirContext ctx = new InitialDirContext(env); ctx.modifyAttributes(buildUserDN(user), mods); - - // Close the context when we're done ctx.close(); return true; } catch (NamingException e) { @@ -434,6 +410,55 @@ public boolean changeUserPassword(User user, String inNewPassword) throws NoSuch return false; } + private ModificationItem[] createPasswordModificationItems(User user, String inNewPassword, MD4Digest digester) + throws NoSuchAlgorithmException { + PasswordEncryption passwordEncryption = user.getLdapGroup().getLdapServer().getPasswordEncryption(); + AdaptivePasswordEncoder adaptivePasswordEncoder = new AdaptivePasswordEncoder(); + String encryptedPassword; + switch (passwordEncryption) { + case BCRYPT: + encryptedPassword = adaptivePasswordEncoder.hashBcrypt(inNewPassword); + break; + case SCRYPT: + encryptedPassword = adaptivePasswordEncoder.hashScrypt(inNewPassword); + break; + case PBKDF2: + encryptedPassword = adaptivePasswordEncoder.hashPbkdf2WithHmac(inNewPassword); + break; + default: + MessageDigest md = MessageDigest.getInstance(passwordEncryption.getTitle()); + SecureRandom secureRandom = new SecureRandom(); + byte[] salt = new byte[8]; + secureRandom.nextBytes(salt); + md.update(inNewPassword.getBytes(StandardCharsets.UTF_8)); + md.update(salt); + byte[] hash = md.digest(); + byte[] hashAndSalt = new byte[hash.length + salt.length]; + System.arraycopy(hash, 0, hashAndSalt, 0, hash.length); + System.arraycopy(salt, 0, hashAndSalt, hash.length, salt.length); + encryptedPassword = Base64.encodeBase64String(hashAndSalt); + break; + } + + ModificationItem[] mods = new ModificationItem[4]; + + BasicAttribute userPassword = new BasicAttribute("userPassword", + passwordEncryption.getLdapPrefix() + encryptedPassword); + mods[0] = new ModificationItem(DirContext.REPLACE_ATTRIBUTE, userPassword); + + BasicAttribute lanmgrPassword = proceedPassword("sambaLMPassword", inNewPassword, null); + mods[1] = new ModificationItem(DirContext.REPLACE_ATTRIBUTE, lanmgrPassword); + + BasicAttribute ntlmPassword = proceedPassword("sambaNTPassword", inNewPassword, digester); + mods[2] = new ModificationItem(DirContext.REPLACE_ATTRIBUTE, ntlmPassword); + + BasicAttribute sambaPwdLastSet = new BasicAttribute("sambaPwdLastSet", + String.valueOf(System.currentTimeMillis() / 1000L)); + mods[3] = new ModificationItem(DirContext.REPLACE_ATTRIBUTE, sambaPwdLastSet); + + return mods; + } + private URI getUserHomeDirectoryWithTLS(Hashtable env, String userFolderBasePath, User user) { env.put("java.naming.ldap.version", "3"); LdapContext ctx = null; diff --git a/Kitodo/src/test/java/org/kitodo/production/ldap/LdapUserTest.java b/Kitodo/src/test/java/org/kitodo/production/ldap/LdapUserTest.java new file mode 100644 index 00000000000..de2eb1af1df --- /dev/null +++ b/Kitodo/src/test/java/org/kitodo/production/ldap/LdapUserTest.java @@ -0,0 +1,177 @@ +/* + * (c) Kitodo. Key to digital objects e. V. + * + * This file is part of the Kitodo project. + * + * It is licensed under GNU General Public License version 3 or later. + * + * For the full copyright and license information, please read the + * GPL3-License.txt file that was distributed with this source code. + */ + +package org.kitodo.production.ldap; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; + +import javax.naming.NamingException; +import javax.naming.directory.Attributes; + +import org.apache.commons.codec.binary.Hex; +import org.junit.jupiter.api.Test; +import org.kitodo.data.database.beans.LdapGroup; +import org.kitodo.data.database.beans.LdapServer; +import org.kitodo.data.database.beans.User; +import org.kitodo.data.database.enums.PasswordEncryption; +import org.kitodo.production.security.password.AdaptivePasswordEncoder; + +public class LdapUserTest { + + @Test + public void configureUserPasswordWithSSHA() throws Exception { + LdapUser ldapUser = createLdapUser(PasswordEncryption.SHA); + Attributes attrs = ldapUser.getAttributes(""); + Object userPassword = attrs.get("userPassword").get(); + String value = userPassword.toString(); + assertTrue(value.startsWith("{SSHA}"), "SHA password should start with {SSHA}, got: " + value); + assertTrue(value.length() > 6, "SSHA password should have content after prefix"); + verifySaltedHash(value.substring(6), "SHA-1"); + } + + @Test + public void configureUserPasswordWithSMD5() throws Exception { + LdapUser ldapUser = createLdapUser(PasswordEncryption.MD5); + Attributes attrs = ldapUser.getAttributes(""); + Object userPassword = attrs.get("userPassword").get(); + String value = userPassword.toString(); + assertTrue(value.startsWith("{SMD5}"), "MD5 password should start with {SMD5}, got: " + value); + assertTrue(value.length() > 6, "SMD5 password should have content after prefix"); + verifySaltedHash(value.substring(6), "MD5"); + } + + @Test + public void configureUserPasswordWithSSHA256() throws Exception { + LdapUser ldapUser = createLdapUser(PasswordEncryption.SHA_256); + Attributes attrs = ldapUser.getAttributes(""); + Object userPassword = attrs.get("userPassword").get(); + String value = userPassword.toString(); + assertTrue(value.startsWith("{SSHA-256}"), "SHA-256 password should start with {SSHA-256}, got: " + value); + assertTrue(value.length() > 10, "SSHA-256 password should have content after prefix"); + verifySaltedHash(value.substring(10), "SHA-256"); + } + + @Test + public void configureUserPasswordWithBcrypt() throws Exception { + LdapUser ldapUser = createLdapUser(PasswordEncryption.BCRYPT); + Attributes attrs = ldapUser.getAttributes(""); + Object userPassword = attrs.get("userPassword").get(); + String value = userPassword.toString(); + assertTrue(value.startsWith("{BCRYPT}"), "BCRYPT password should start with {BCRYPT}, got: " + value); + String bcryptHash = value.substring(8); + assertTrue(bcryptHash.startsWith("$2"), "BCRYPT hash should start with $2, got: " + bcryptHash); + AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); + assertTrue(encoder.matchesBcrypt("testPassword123", bcryptHash), "BCRYPT hash should match original password"); + } + + @Test + public void configureUserPasswordWithScrypt() throws Exception { + LdapUser ldapUser = createLdapUser(PasswordEncryption.SCRYPT); + Attributes attrs = ldapUser.getAttributes(""); + Object userPassword = attrs.get("userPassword").get(); + String value = userPassword.toString(); + assertTrue(value.startsWith("{SCRYPT}"), "SCRYPT password should start with {SCRYPT}, got: " + value); + String scryptHash = value.substring(8); + assertTrue(scryptHash.startsWith("$4"), "SCRYPT hash should start with $4, got: " + scryptHash); + AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); + assertTrue(encoder.matchesScrypt("testPassword123", scryptHash), "SCRYPT hash should match original password"); + } + + @Test + public void configureUserPasswordWithPbkdf2() throws Exception { + LdapUser ldapUser = createLdapUser(PasswordEncryption.PBKDF2); + Attributes attrs = ldapUser.getAttributes(""); + Object userPassword = attrs.get("userPassword").get(); + String value = userPassword.toString(); + assertTrue(value.startsWith("{PBKDF2}"), "PBKDF2 password should start with {PBKDF2}, got: " + value); + String pbkdf2Hash = value.substring(8); + AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); + assertTrue(encoder.matchesPbkdf2WithHmac("testPassword123", pbkdf2Hash), "PBKDF2 hash should match original password"); + } + + @Test + public void configureSambaLMPasswordAndNTPasswordAreSet() throws Exception { + LdapUser ldapUser = createLdapUser(PasswordEncryption.SHA); + Attributes attrs = ldapUser.getAttributes(""); + assertNotNull(attrs.get("sambaLMPassword"), "sambaLMPassword should be set"); + assertNotNull(attrs.get("sambaNTPassword"), "sambaNTPassword should be set"); + } + + private LdapUser createLdapUser(PasswordEncryption passwordEncryption) throws NamingException, NoSuchAlgorithmException { + LdapServer ldapServer = mock(LdapServer.class); + when(ldapServer.isReadOnly()).thenReturn(false); + when(ldapServer.getPasswordEncryption()).thenReturn(passwordEncryption); + + LdapGroup ldapGroup = mock(LdapGroup.class); + when(ldapGroup.getLdapServer()).thenReturn(ldapServer); + when(ldapGroup.getObjectClasses()).thenReturn("top,person,organizationalPerson,inetOrgPerson"); + when(ldapGroup.getUid()).thenReturn("uid"); + when(ldapGroup.getDisplayName()).thenReturn(""); + when(ldapGroup.getDescription()).thenReturn(""); + when(ldapGroup.getGecos()).thenReturn(""); + when(ldapGroup.getLoginShell()).thenReturn(""); + when(ldapGroup.getSn()).thenReturn("Surname"); + when(ldapGroup.getHomeDirectory()).thenReturn("/home/test"); + when(ldapGroup.getSambaAcctFlags()).thenReturn("[U ]"); + when(ldapGroup.getSambaLogonScript()).thenReturn(""); + when(ldapGroup.getSambaPrimaryGroupSID()).thenReturn(""); + when(ldapGroup.getSambaSID()).thenReturn(""); + when(ldapGroup.getSambaPwdMustChange()).thenReturn(""); + when(ldapGroup.getSambaPasswordHistory()).thenReturn(""); + when(ldapGroup.getSambaLogonHours()).thenReturn(""); + when(ldapGroup.getSambaKickoffTime()).thenReturn(""); + when(ldapGroup.getGidNumber()).thenReturn(""); + + User user = mock(User.class); + when(user.getLdapLogin()).thenReturn("testuser"); + when(user.getLogin()).thenReturn("testuser"); + when(user.getName()).thenReturn("Test"); + when(user.getSurname()).thenReturn("User"); + when(user.getLdapGroup()).thenReturn(ldapGroup); + + LdapUser ldapUser = new LdapUser(); + ldapUser.configure(user, "testPassword123", "1000"); + return ldapUser; + } + + private void verifySaltedHash(String base64Content, String algorithm) throws NoSuchAlgorithmException { + byte[] decoded = Base64.getDecoder().decode(base64Content); + int hashLength = switch (algorithm) { + case "SHA-1" -> 20; + case "SHA-256" -> 32; + case "MD5" -> 16; + default -> throw new NoSuchAlgorithmException(algorithm); + }; + assertTrue(decoded.length > hashLength, "Hash+salt should be longer than hash alone"); + + byte[] storedHash = new byte[hashLength]; + byte[] storedSalt = new byte[decoded.length - hashLength]; + System.arraycopy(decoded, 0, storedHash, 0, hashLength); + System.arraycopy(decoded, hashLength, storedSalt, 0, storedSalt.length); + + MessageDigest md = MessageDigest.getInstance(algorithm); + md.update("testPassword123".getBytes(StandardCharsets.UTF_8)); + md.update(storedSalt); + byte[] computedHash = md.digest(); + + assertEquals(Hex.encodeHexString(storedHash), Hex.encodeHexString(computedHash), + "Computed hash should match stored hash for " + algorithm); + } +} diff --git a/Kitodo/src/test/java/org/kitodo/production/security/password/AdaptivePasswordEncoderTest.java b/Kitodo/src/test/java/org/kitodo/production/security/password/AdaptivePasswordEncoderTest.java new file mode 100644 index 00000000000..819c7ca1693 --- /dev/null +++ b/Kitodo/src/test/java/org/kitodo/production/security/password/AdaptivePasswordEncoderTest.java @@ -0,0 +1,125 @@ +/* + * (c) Kitodo. Key to digital objects e. V. + * + * This file is part of the Kitodo project. + * + * It is licensed under GNU General Public License version 3 or later. + * + * For the full copyright and license information, please read the + * GPL3-License.txt file that was distributed with this source code. + */ + +package org.kitodo.production.security.password; + +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +public class AdaptivePasswordEncoderTest { + + @Test + public void hashBcryptShouldProduceNonNullResult() { + AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); + String hash = encoder.hashBcrypt("testPassword"); + assertNotNull(hash); + } + + @Test + public void hashBcryptShouldStartWithBcryptPrefix() { + AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); + String hash = encoder.hashBcrypt("testPassword"); + assertTrue(hash.startsWith("$2"), "Hash should start with $2 for bcrypt, got: " + hash); + } + + @Test + public void hashBcryptShouldProduceDifferentHashesForSamePassword() { + AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); + String hash1 = encoder.hashBcrypt("testPassword"); + String hash2 = encoder.hashBcrypt("testPassword"); + assertNotEquals(hash1, hash2, "Same password should produce different hashes due to random salt"); + } + + @Test + public void matchesBcryptShouldReturnTrueForCorrectPassword() { + AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); + String password = "mySecretPassword123"; + String hash = encoder.hashBcrypt(password); + assertTrue(encoder.matchesBcrypt(password, hash)); + } + + @Test + public void matchesBcryptShouldReturnFalseForIncorrectPassword() { + AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); + String hash = encoder.hashBcrypt("correctPassword"); + assertTrue(!encoder.matchesBcrypt("wrongPassword", hash)); + } + + @Test + public void hashScryptShouldProduceNonNullResult() { + AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); + String hash = encoder.hashScrypt("testPassword"); + assertNotNull(hash); + } + + @Test + public void hashScryptShouldStartWithScryptPrefix() { + AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); + String hash = encoder.hashScrypt("testPassword"); + assertTrue(hash.startsWith("$4"), "Hash should start with $4 for scrypt, got: " + hash); + } + + @Test + public void hashScryptShouldProduceDifferentHashesForSamePassword() { + AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); + String hash1 = encoder.hashScrypt("testPassword"); + String hash2 = encoder.hashScrypt("testPassword"); + assertNotEquals(hash1, hash2, "Same password should produce different hashes due to random salt"); + } + + @Test + public void matchesScryptShouldReturnTrueForCorrectPassword() { + AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); + String password = "mySecretPassword123"; + String hash = encoder.hashScrypt(password); + assertTrue(encoder.matchesScrypt(password, hash)); + } + + @Test + public void matchesScryptShouldReturnFalseForIncorrectPassword() { + AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); + String hash = encoder.hashScrypt("correctPassword"); + assertTrue(!encoder.matchesScrypt("wrongPassword", hash)); + } + + @Test + public void hashPbkdf2WithHmacShouldProduceNonNullResult() { + AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); + String hash = encoder.hashPbkdf2WithHmac("testPassword"); + assertNotNull(hash); + } + + @Test + public void hashPbkdf2WithHmacShouldProduceDifferentHashesForSamePassword() { + AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); + String hash1 = encoder.hashPbkdf2WithHmac("testPassword"); + String hash2 = encoder.hashPbkdf2WithHmac("testPassword"); + assertNotEquals(hash1, hash2, "Same password should produce different hashes due to random salt"); + } + + @Test + public void matchesPbkdf2WithHmacShouldReturnTrueForCorrectPassword() { + AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); + String password = "mySecretPassword123"; + String hash = encoder.hashPbkdf2WithHmac(password); + assertTrue(encoder.matchesPbkdf2WithHmac(password, hash)); + } + + @Test + public void matchesPbkdf2WithHmacShouldReturnFalseForIncorrectPassword() { + AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); + String hash = encoder.hashPbkdf2WithHmac("correctPassword"); + assertTrue(!encoder.matchesPbkdf2WithHmac("wrongPassword", hash)); + } +}