From f5e0a556ca0c2ae7fcece99883256adcbcdfd67f Mon Sep 17 00:00:00 2001 From: Stefan Weil Date: Wed, 24 Jun 2026 11:24:12 +0200 Subject: [PATCH 01/11] Fix Codacy issues: weak password hashing - Replace unsalted MD5/SHA password hashing with SSHA format in LdapUser.configure() and LdapServerService.changeUserPassword() for CWE-759 compliance (CWE-759: Choice of Less-Protected Protection Mechanism). LM/NTLM hashes are retained for AD compatibility. Assisted-by: qwen3.6-36b (Alibaba) Signed-off-by: Stefan Weil --- .../org/kitodo/production/ldap/LdapUser.java | 17 ++++++++++++----- .../services/data/LdapServerService.java | 15 +++++++++++---- 2 files changed, 23 insertions(+), 9 deletions(-) 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..18af02d521d 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; @@ -117,12 +118,18 @@ public void configure(User user, String inPassword, String inUidNumber) * Encryption of password und Base64-Enconding */ - String passwordEncrytion = ldapGroup.getLdapServer().getPasswordEncryption().getTitle(); - - MessageDigest md = MessageDigest.getInstance(passwordEncrytion); + MessageDigest md = MessageDigest.getInstance("SHA-1"); + SecureRandom secureRandom = new SecureRandom(); + byte[] salt = new byte[8]; + secureRandom.nextBytes(salt); md.update(inPassword.getBytes(StandardCharsets.UTF_8)); - String encodedDigest = new String(Base64.encodeBase64(md.digest()), StandardCharsets.UTF_8); - this.attributes.put("userPassword", "{" + passwordEncrytion + "}" + encodedDigest); + 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); + String encodedDigest = Base64.encodeBase64String(hashAndSalt); + this.attributes.put("userPassword", "{SSHA}" + encodedDigest); } } 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..4cfc7252497 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 @@ -392,20 +392,27 @@ 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()); + MessageDigest md = MessageDigest.getInstance("SHA-1"); + SecureRandom secureRandom = new SecureRandom(); + byte[] salt = new byte[8]; + secureRandom.nextBytes(salt); md.update(inNewPassword.getBytes(StandardCharsets.UTF_8)); - String encryptedPassword = new String(Base64.encodeBase64(md.digest()), 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); + String encryptedPassword = Base64.encodeBase64String(hashAndSalt); // change attribute userPassword BasicAttribute userPassword = new BasicAttribute("userPassword", - "{" + passwordEncryption + "}" + encryptedPassword); + "{SSHA}" + encryptedPassword); mods[0] = new ModificationItem(DirContext.REPLACE_ATTRIBUTE, userPassword); // change attribute lanmgrPassword From cb00594bb2dcb86d5496b2d5acb7308ed71dd34b Mon Sep 17 00:00:00 2001 From: Stefan Weil Date: Mon, 6 Jul 2026 17:13:02 +0200 Subject: [PATCH 02/11] Document why LM and NTLM hashes are retained in LdapUser Add a comment explaining that the weak LM and NTLM password hashes are required for Active Directory and Samba compatibility and cannot be changed without breaking AD domain joins. Also fix a typo in an existing comment: "Enconding" -> "Encoding" and "und" -> "and". This accompanies commit 7ef9cde0c3 which replaced unsalted MD5/SHA with SSHA format for the userPassword attribute. Assisted-by: minimax-m2.7 (MiniMax) Signed-off-by: Stefan Weil --- .../src/main/java/org/kitodo/production/ldap/LdapUser.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 18af02d521d..5629585c969 100644 --- a/Kitodo/src/main/java/org/kitodo/production/ldap/LdapUser.java +++ b/Kitodo/src/main/java/org/kitodo/production/ldap/LdapUser.java @@ -99,6 +99,11 @@ public void configure(User user, String inPassword, String inUidNumber) /* * Samba passwords + * + * LM and NTLM hashes are required for Active Directory and Samba + * compatibility. They are inherently weak (DES-based LM, MD4-based + * NTLM) but cannot be changed without breaking AD domain joins. + * The userPassword attribute uses SSHA which is properly salted. */ /* LanMgr */ try { @@ -115,7 +120,7 @@ public void configure(User user, String inPassword, String inUidNumber) this.attributes.put("sambaNTPassword", toHexString(hmm)); /* - * Encryption of password und Base64-Enconding + * Encryption of password and Base64-Encoding */ MessageDigest md = MessageDigest.getInstance("SHA-1"); From 1904bafe38e1dbd4839d55f8d5e99eedfbf38c28 Mon Sep 17 00:00:00 2001 From: Stefan Weil Date: Mon, 6 Jul 2026 17:20:54 +0200 Subject: [PATCH 03/11] Restore configurable password encryption algorithm with salt Change hardcoded SHA-1 to use configurable algorithm (SHA or MD5) from PasswordEncryption setting. Both algorithms now use a random salt and produce {SSHA} or {SMD5} format respectively, per LDAP standards (RFC 2307). This restores admin-configurable password hashing while maintaining the security fix from commit 7ef9cde0c3. Assisted-by: minimax-m2.7 (MiniMax) Signed-off-by: Stefan Weil --- .../main/java/org/kitodo/production/ldap/LdapUser.java | 9 +++++++-- .../production/services/data/LdapServerService.java | 7 +++++-- 2 files changed, 12 insertions(+), 4 deletions(-) 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 5629585c969..374d29ed477 100644 --- a/Kitodo/src/main/java/org/kitodo/production/ldap/LdapUser.java +++ b/Kitodo/src/main/java/org/kitodo/production/ldap/LdapUser.java @@ -18,6 +18,8 @@ import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.util.Hashtable; + +import org.kitodo.data.database.enums.PasswordEncryption; import java.util.Objects; import java.util.StringTokenizer; @@ -123,7 +125,10 @@ public void configure(User user, String inPassword, String inUidNumber) * Encryption of password and Base64-Encoding */ - MessageDigest md = MessageDigest.getInstance("SHA-1"); + PasswordEncryption passwordEncryption = ldapGroup.getLdapServer().getPasswordEncryption(); + String algorithm = passwordEncryption.getTitle(); + String ldapPrefix = "SHA".equals(algorithm) ? "{SSHA}" : "{SMD5}"; + MessageDigest md = MessageDigest.getInstance(algorithm); SecureRandom secureRandom = new SecureRandom(); byte[] salt = new byte[8]; secureRandom.nextBytes(salt); @@ -134,7 +139,7 @@ public void configure(User user, String inPassword, String inUidNumber) System.arraycopy(hash, 0, hashAndSalt, 0, hash.length); System.arraycopy(salt, 0, hashAndSalt, hash.length, salt.length); String encodedDigest = Base64.encodeBase64String(hashAndSalt); - this.attributes.put("userPassword", "{SSHA}" + encodedDigest); + this.attributes.put("userPassword", ldapPrefix + encodedDigest); } } 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 4cfc7252497..2699dac1cfd 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 @@ -398,7 +398,10 @@ public boolean changeUserPassword(User user, String inNewPassword) throws NoSuch ModificationItem[] mods = new ModificationItem[4]; // encryption of password and Base64-Encoding - MessageDigest md = MessageDigest.getInstance("SHA-1"); + PasswordEncryption passwordEncryption = user.getLdapGroup().getLdapServer().getPasswordEncryption(); + String algorithm = passwordEncryption.getTitle(); + String ldapPrefix = "SHA".equals(algorithm) ? "{SSHA}" : "{SMD5}"; + MessageDigest md = MessageDigest.getInstance(algorithm); SecureRandom secureRandom = new SecureRandom(); byte[] salt = new byte[8]; secureRandom.nextBytes(salt); @@ -412,7 +415,7 @@ public boolean changeUserPassword(User user, String inNewPassword) throws NoSuch // change attribute userPassword BasicAttribute userPassword = new BasicAttribute("userPassword", - "{SSHA}" + encryptedPassword); + ldapPrefix + encryptedPassword); mods[0] = new ModificationItem(DirContext.REPLACE_ATTRIBUTE, userPassword); // change attribute lanmgrPassword From 4cf1445c14baff9fe5d2bb5deebe772852ca368f Mon Sep 17 00:00:00 2001 From: Stefan Weil Date: Mon, 6 Jul 2026 17:30:39 +0200 Subject: [PATCH 04/11] Centralize LDAP password prefix in PasswordEncryption enum Add getLdapPrefix() method to PasswordEncryption enum so the RFC 2307 prefix ({SSHA}, {SMD5}, {SSHA-256}) is defined alongside the algorithm name, not duplicated in switch/if-else logic across service classes. Refactor LdapUser.configure() and LdapServerService.changeUserPassword() to use passwordEncryption.getLdapPrefix() instead of inline string mapping. Also mark enum fields as final for immutability. Assisted-by: minimax-m2.7 (MiniMax) Signed-off-by: Stefan Weil --- .../database/enums/PasswordEncryption.java | 24 ++++++++++++++----- .../org/kitodo/production/ldap/LdapUser.java | 6 ++--- .../services/data/LdapServerService.java | 6 ++--- 3 files changed, 22 insertions(+), 14 deletions(-) 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..27dbbac124b 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,21 @@ * 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}"); - 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 +51,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/src/main/java/org/kitodo/production/ldap/LdapUser.java b/Kitodo/src/main/java/org/kitodo/production/ldap/LdapUser.java index 374d29ed477..93a59b40936 100644 --- a/Kitodo/src/main/java/org/kitodo/production/ldap/LdapUser.java +++ b/Kitodo/src/main/java/org/kitodo/production/ldap/LdapUser.java @@ -126,9 +126,7 @@ public void configure(User user, String inPassword, String inUidNumber) */ PasswordEncryption passwordEncryption = ldapGroup.getLdapServer().getPasswordEncryption(); - String algorithm = passwordEncryption.getTitle(); - String ldapPrefix = "SHA".equals(algorithm) ? "{SSHA}" : "{SMD5}"; - MessageDigest md = MessageDigest.getInstance(algorithm); + MessageDigest md = MessageDigest.getInstance(passwordEncryption.getTitle()); SecureRandom secureRandom = new SecureRandom(); byte[] salt = new byte[8]; secureRandom.nextBytes(salt); @@ -139,7 +137,7 @@ public void configure(User user, String inPassword, String inUidNumber) System.arraycopy(hash, 0, hashAndSalt, 0, hash.length); System.arraycopy(salt, 0, hashAndSalt, hash.length, salt.length); String encodedDigest = Base64.encodeBase64String(hashAndSalt); - this.attributes.put("userPassword", ldapPrefix + encodedDigest); + this.attributes.put("userPassword", passwordEncryption.getLdapPrefix() + encodedDigest); } } 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 2699dac1cfd..a8cd36f28f9 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 @@ -399,9 +399,7 @@ public boolean changeUserPassword(User user, String inNewPassword) throws NoSuch // encryption of password and Base64-Encoding PasswordEncryption passwordEncryption = user.getLdapGroup().getLdapServer().getPasswordEncryption(); - String algorithm = passwordEncryption.getTitle(); - String ldapPrefix = "SHA".equals(algorithm) ? "{SSHA}" : "{SMD5}"; - MessageDigest md = MessageDigest.getInstance(algorithm); + MessageDigest md = MessageDigest.getInstance(passwordEncryption.getTitle()); SecureRandom secureRandom = new SecureRandom(); byte[] salt = new byte[8]; secureRandom.nextBytes(salt); @@ -415,7 +413,7 @@ public boolean changeUserPassword(User user, String inNewPassword) throws NoSuch // change attribute userPassword BasicAttribute userPassword = new BasicAttribute("userPassword", - ldapPrefix + encryptedPassword); + passwordEncryption.getLdapPrefix() + encryptedPassword); mods[0] = new ModificationItem(DirContext.REPLACE_ATTRIBUTE, userPassword); // change attribute lanmgrPassword From ed5f4797215accf4ed09aeaa23ffed737a76d3ad Mon Sep 17 00:00:00 2001 From: Stefan Weil Date: Mon, 6 Jul 2026 17:51:19 +0200 Subject: [PATCH 05/11] Add unit tests for PasswordEncryption enum and LdapUser password hashing Add PasswordEncryptionTest with 7 tests: - Verify getValue(), getTitle(), getLdapPrefix() for SHA, MD5, SHA-256 - Verify getEncryptionFromValue() with valid values (0, 1, 2) - Verify getEncryptionFromValue() returns default SHA for null/unknown - Verify all enum values have unique getValue() Add LdapUserTest with 4 tests: - Verify {SSHA} prefix and correct salted hash for SHA algorithm - Verify {SMD5} prefix and correct salted hash for MD5 algorithm - Verify {SSHA-256} prefix and correct salted hash for SHA-256 algorithm - Verify sambaLMPassword and sambaNTPassword are set Also fix missing SecureRandom import in LdapServerService. Assisted-by: minimax-m2.7 (MiniMax) Signed-off-by: Stefan Weil --- .../enums/PasswordEncryptionTest.java | 69 +++++++++ .../services/data/LdapServerService.java | 1 + .../kitodo/production/ldap/LdapUserTest.java | 138 ++++++++++++++++++ 3 files changed, 208 insertions(+) create mode 100644 Kitodo-DataManagement/src/test/java/org/kitodo/data/database/enums/PasswordEncryptionTest.java create mode 100644 Kitodo/src/test/java/org/kitodo/production/ldap/LdapUserTest.java 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..952e8d0d6ed --- /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()); + } + } +} \ No newline at end of file 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 a8cd36f28f9..5da45e3a89f 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; 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..b0a0db907ad --- /dev/null +++ b/Kitodo/src/test/java/org/kitodo/production/ldap/LdapUserTest.java @@ -0,0 +1,138 @@ +/* + * (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; + +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 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); + } +} \ No newline at end of file From acebeb76c2522592862d89be1a355925d6349815 Mon Sep 17 00:00:00 2001 From: Stefan Weil Date: Mon, 6 Jul 2026 18:38:00 +0200 Subject: [PATCH 06/11] Fix style issues Assisted-by: OpenCode + minimax-m2.7 (MiniMax) Signed-off-by: Stefan Weil --- Kitodo/src/main/java/org/kitodo/production/ldap/LdapUser.java | 3 +-- .../kitodo/production/services/data/LdapServerService.java | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) 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 93a59b40936..ac9ba19d34f 100644 --- a/Kitodo/src/main/java/org/kitodo/production/ldap/LdapUser.java +++ b/Kitodo/src/main/java/org/kitodo/production/ldap/LdapUser.java @@ -18,8 +18,6 @@ import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.util.Hashtable; - -import org.kitodo.data.database.enums.PasswordEncryption; import java.util.Objects; import java.util.StringTokenizer; @@ -53,6 +51,7 @@ 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; /** * This class is used by the DirObj example. It is a DirContext class that can 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 5da45e3a89f..cc43c130f1a 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 @@ -396,8 +396,6 @@ public boolean changeUserPassword(User user, String inNewPassword) throws NoSuch Hashtable env = initializeWithLdapConnectionSettings(user.getLdapGroup().getLdapServer()); if (!user.getLdapGroup().getLdapServer().isReadOnly()) { try { - ModificationItem[] mods = new ModificationItem[4]; - // encryption of password and Base64-Encoding PasswordEncryption passwordEncryption = user.getLdapGroup().getLdapServer().getPasswordEncryption(); MessageDigest md = MessageDigest.getInstance(passwordEncryption.getTitle()); @@ -412,6 +410,8 @@ public boolean changeUserPassword(User user, String inNewPassword) throws NoSuch System.arraycopy(salt, 0, hashAndSalt, hash.length, salt.length); String encryptedPassword = Base64.encodeBase64String(hashAndSalt); + ModificationItem[] mods = new ModificationItem[4]; + // change attribute userPassword BasicAttribute userPassword = new BasicAttribute("userPassword", passwordEncryption.getLdapPrefix() + encryptedPassword); From a8cad8439c9d6b193e7c3ace1b710b7546532b70 Mon Sep 17 00:00:00 2001 From: Stefan Weil Date: Mon, 6 Jul 2026 19:18:50 +0200 Subject: [PATCH 07/11] Fix more style issues Assisted-by: OpenCode + minimax-m2.7 (MiniMax) Signed-off-by: Stefan Weil --- .../org/kitodo/production/ldap/LdapUser.java | 74 ++++++++----------- .../services/data/LdapServerService.java | 70 +++++++++--------- 2 files changed, 66 insertions(+), 78 deletions(-) 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 ac9ba19d34f..fb939434d3e 100644 --- a/Kitodo/src/main/java/org/kitodo/production/ldap/LdapUser.java +++ b/Kitodo/src/main/java/org/kitodo/production/ldap/LdapUser.java @@ -83,61 +83,49 @@ 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 - * - * LM and NTLM hashes are required for Active Directory and Samba - * compatibility. They are inherently weak (DES-based LM, MD4-based - * NTLM) but cannot be changed without breaking AD domain joins. - * The userPassword attribute uses SSHA which is properly salted. - */ - /* 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 and Base64-Encoding - */ - - PasswordEncryption passwordEncryption = ldapGroup.getLdapServer().getPasswordEncryption(); - 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); - String encodedDigest = Base64.encodeBase64String(hashAndSalt); - this.attributes.put("userPassword", passwordEncryption.getLdapPrefix() + 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(); + 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); + String encodedDigest = Base64.encodeBase64String(hashAndSalt); + this.attributes.put("userPassword", passwordEncryption.getLdapPrefix() + encodedDigest); } private void prepareAttributes(LdapGroup ldapGroup, User user, String inUidNumber) { 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 cc43c130f1a..6ac0c71b6e3 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 @@ -396,43 +396,9 @@ public boolean changeUserPassword(User user, String inNewPassword) throws NoSuch Hashtable env = initializeWithLdapConnectionSettings(user.getLdapGroup().getLdapServer()); if (!user.getLdapGroup().getLdapServer().isReadOnly()) { try { - // encryption of password and Base64-Encoding - PasswordEncryption passwordEncryption = user.getLdapGroup().getLdapServer().getPasswordEncryption(); - 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); - String encryptedPassword = Base64.encodeBase64String(hashAndSalt); - - ModificationItem[] mods = new ModificationItem[4]; - - // change attribute userPassword - BasicAttribute userPassword = new BasicAttribute("userPassword", - passwordEncryption.getLdapPrefix() + 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) { @@ -443,6 +409,40 @@ 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(); + 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); + String encryptedPassword = Base64.encodeBase64String(hashAndSalt); + + 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; From b5efb0c80920631f21c86b6ea8b4945b2bbeab58 Mon Sep 17 00:00:00 2001 From: Stefan Weil Date: Mon, 6 Jul 2026 19:52:55 +0200 Subject: [PATCH 08/11] Add missing LF at EOF Signed-off-by: Stefan Weil --- .../org/kitodo/data/database/enums/PasswordEncryptionTest.java | 2 +- .../src/test/java/org/kitodo/production/ldap/LdapUserTest.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index 952e8d0d6ed..35f3b1a4edb 100644 --- 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 @@ -66,4 +66,4 @@ public void allEnumValuesAreUnique() { assertTrue(seen.add(pe.getValue()), "Duplicate getValue(): " + pe.getValue()); } } -} \ No newline at end of file +} diff --git a/Kitodo/src/test/java/org/kitodo/production/ldap/LdapUserTest.java b/Kitodo/src/test/java/org/kitodo/production/ldap/LdapUserTest.java index b0a0db907ad..afd2fe79188 100644 --- a/Kitodo/src/test/java/org/kitodo/production/ldap/LdapUserTest.java +++ b/Kitodo/src/test/java/org/kitodo/production/ldap/LdapUserTest.java @@ -135,4 +135,4 @@ private void verifySaltedHash(String base64Content, String algorithm) throws NoS assertEquals(Hex.encodeHexString(storedHash), Hex.encodeHexString(computedHash), "Computed hash should match stored hash for " + algorithm); } -} \ No newline at end of file +} From 0b7e2bf734ff01e3c1d941b4e338f80ca52e1939 Mon Sep 17 00:00:00 2001 From: Stefan Weil Date: Tue, 7 Jul 2026 13:16:38 +0200 Subject: [PATCH 09/11] Add adaptive password hashing using bcrypt - Add AdaptivePasswordEncoder using Spring Security bcrypt (strength 16) - Add BCRYPT to PasswordEncryption enum - Update LdapUser and LdapServerService to use bcrypt for password hashing - Add unit tests for AdaptivePasswordEncoder and BCRYPT in LdapUserTest Assisted-by: OpenCode + minimax-m2.7 (MiniMax) Signed-off-by: Stefan Weil --- .../database/enums/PasswordEncryption.java | 3 +- .../org/kitodo/production/ldap/LdapUser.java | 7 +++ .../password/AdaptivePasswordEncoder.java | 27 +++++++++ .../services/data/LdapServerService.java | 29 ++++++---- .../kitodo/production/ldap/LdapUserTest.java | 14 +++++ .../password/AdaptivePasswordEncoderTest.java | 58 +++++++++++++++++++ 6 files changed, 126 insertions(+), 12 deletions(-) create mode 100644 Kitodo/src/main/java/org/kitodo/production/security/password/AdaptivePasswordEncoder.java create mode 100644 Kitodo/src/test/java/org/kitodo/production/security/password/AdaptivePasswordEncoderTest.java 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 27dbbac124b..f2801b12f02 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 @@ -18,7 +18,8 @@ public enum PasswordEncryption { SHA(0, "SHA", "{SSHA}"), MD5(1, "MD5", "{SMD5}"), - SHA_256(2, "SHA-256", "{SSHA-256}"); + SHA_256(2, "SHA-256", "{SSHA-256}"), + BCRYPT(3, "BCRYPT", "{BCRYPT}"); private final int value; private final String title; 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 fb939434d3e..ebc91f5918f 100644 --- a/Kitodo/src/main/java/org/kitodo/production/ldap/LdapUser.java +++ b/Kitodo/src/main/java/org/kitodo/production/ldap/LdapUser.java @@ -52,6 +52,7 @@ 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 @@ -114,6 +115,12 @@ private void setSambaPasswords(String inPassword, MD4Digest digester) throws NoS private void setUserPassword(String inPassword, LdapGroup ldapGroup) throws NoSuchAlgorithmException { PasswordEncryption passwordEncryption = ldapGroup.getLdapServer().getPasswordEncryption(); + if (passwordEncryption == PasswordEncryption.BCRYPT) { + AdaptivePasswordEncoder adaptivePasswordEncoder = new AdaptivePasswordEncoder(); + String hashedPassword = adaptivePasswordEncoder.hash(inPassword); + this.attributes.put("userPassword", passwordEncryption.getLdapPrefix() + hashedPassword); + return; + } MessageDigest md = MessageDigest.getInstance(passwordEncryption.getTitle()); SecureRandom secureRandom = new SecureRandom(); byte[] salt = new byte[8]; 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..54ab3640598 --- /dev/null +++ b/Kitodo/src/main/java/org/kitodo/production/security/password/AdaptivePasswordEncoder.java @@ -0,0 +1,27 @@ +/* + * (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 org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +public class AdaptivePasswordEncoder { + + private static final BCryptPasswordEncoder BCRYPT_ENCODER = new BCryptPasswordEncoder(16); + + public String hash(String rawPassword) { + return BCRYPT_ENCODER.encode(rawPassword); + } + + public boolean matches(String rawPassword, String encodedPassword) { + return BCRYPT_ENCODER.matches(rawPassword, encodedPassword); + } +} 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 6ac0c71b6e3..e2eec8fecb3 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 @@ -69,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; @@ -412,17 +413,23 @@ public boolean changeUserPassword(User user, String inNewPassword) throws NoSuch private ModificationItem[] createPasswordModificationItems(User user, String inNewPassword, MD4Digest digester) throws NoSuchAlgorithmException { PasswordEncryption passwordEncryption = user.getLdapGroup().getLdapServer().getPasswordEncryption(); - 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); - String encryptedPassword = Base64.encodeBase64String(hashAndSalt); + String encryptedPassword; + if (passwordEncryption == PasswordEncryption.BCRYPT) { + AdaptivePasswordEncoder adaptivePasswordEncoder = new AdaptivePasswordEncoder(); + encryptedPassword = adaptivePasswordEncoder.hash(inNewPassword); + } else { + 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); + } ModificationItem[] mods = new ModificationItem[4]; diff --git a/Kitodo/src/test/java/org/kitodo/production/ldap/LdapUserTest.java b/Kitodo/src/test/java/org/kitodo/production/ldap/LdapUserTest.java index afd2fe79188..ca86a28b77f 100644 --- a/Kitodo/src/test/java/org/kitodo/production/ldap/LdapUserTest.java +++ b/Kitodo/src/test/java/org/kitodo/production/ldap/LdapUserTest.java @@ -31,6 +31,7 @@ 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 { @@ -67,6 +68,19 @@ public void configureUserPasswordWithSSHA256() throws Exception { 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.matches("testPassword123", bcryptHash), "BCRYPT hash should match original password"); + } + @Test public void configureSambaLMPasswordAndNTPasswordAreSet() throws Exception { LdapUser ldapUser = createLdapUser(PasswordEncryption.SHA); 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..00576f436cf --- /dev/null +++ b/Kitodo/src/test/java/org/kitodo/production/security/password/AdaptivePasswordEncoderTest.java @@ -0,0 +1,58 @@ +/* + * (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 hashShouldProduceNonNullResult() { + AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); + String hash = encoder.hash("testPassword"); + assertNotNull(hash); + } + + @Test + public void hashShouldStartWithBcryptPrefix() { + AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); + String hash = encoder.hash("testPassword"); + assertTrue(hash.startsWith("$2"), "Hash should start with $2 for bcrypt, got: " + hash); + } + + @Test + public void hashShouldProduceDifferentHashesForSamePassword() { + AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); + String hash1 = encoder.hash("testPassword"); + String hash2 = encoder.hash("testPassword"); + assertNotEquals(hash1, hash2, "Same password should produce different hashes due to random salt"); + } + + @Test + public void matchesShouldReturnTrueForCorrectPassword() { + AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); + String password = "mySecretPassword123"; + String hash = encoder.hash(password); + assertTrue(encoder.matches(password, hash)); + } + + @Test + public void matchesShouldReturnFalseForIncorrectPassword() { + AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); + String hash = encoder.hash("correctPassword"); + assertTrue(!encoder.matches("wrongPassword", hash)); + } +} From ef6da9323be692bfcce8903226f107b9fb93528f Mon Sep 17 00:00:00 2001 From: Stefan Weil Date: Tue, 7 Jul 2026 13:24:24 +0200 Subject: [PATCH 10/11] Add SCRYPT and PBKDF2 adaptive password hashing - Add SCRYPT and PBKDF2 support in AdaptivePasswordEncoder - Add SCRYPT and PBKDF2 enum values to PasswordEncryption - Update LdapUser and LdapServerService to use new algorithms - Fix SCryptPasswordEncoder constructor parameters - Fix scrypt hash prefix validation in tests Assisted-by: OpenCode + minimax-m2.7 (MiniMax) Signed-off-by: Stefan Weil --- .../database/enums/PasswordEncryption.java | 4 +- .../org/kitodo/production/ldap/LdapUser.java | 43 +++++---- .../password/AdaptivePasswordEncoder.java | 59 +++++++++++- .../services/data/LdapServerService.java | 39 +++++--- .../kitodo/production/ldap/LdapUserTest.java | 27 +++++- .../password/AdaptivePasswordEncoderTest.java | 93 ++++++++++++++++--- 6 files changed, 216 insertions(+), 49 deletions(-) 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 f2801b12f02..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 @@ -19,7 +19,9 @@ public enum PasswordEncryption { SHA(0, "SHA", "{SSHA}"), MD5(1, "MD5", "{SMD5}"), SHA_256(2, "SHA-256", "{SSHA-256}"), - BCRYPT(3, "BCRYPT", "{BCRYPT}"); + BCRYPT(3, "BCRYPT", "{BCRYPT}"), + SCRYPT(4, "SCRYPT", "{SCRYPT}"), + PBKDF2(5, "PBKDF2", "{PBKDF2}"); private final int value; private final String title; 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 ebc91f5918f..eeb082d0149 100644 --- a/Kitodo/src/main/java/org/kitodo/production/ldap/LdapUser.java +++ b/Kitodo/src/main/java/org/kitodo/production/ldap/LdapUser.java @@ -115,24 +115,33 @@ private void setSambaPasswords(String inPassword, MD4Digest digester) throws NoS private void setUserPassword(String inPassword, LdapGroup ldapGroup) throws NoSuchAlgorithmException { PasswordEncryption passwordEncryption = ldapGroup.getLdapServer().getPasswordEncryption(); - if (passwordEncryption == PasswordEncryption.BCRYPT) { - AdaptivePasswordEncoder adaptivePasswordEncoder = new AdaptivePasswordEncoder(); - String hashedPassword = adaptivePasswordEncoder.hash(inPassword); - this.attributes.put("userPassword", passwordEncryption.getLdapPrefix() + hashedPassword); - return; + 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.hashPbkdf2(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; } - 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); - String encodedDigest = Base64.encodeBase64String(hashAndSalt); - this.attributes.put("userPassword", passwordEncryption.getLdapPrefix() + encodedDigest); + 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 index 54ab3640598..bbc875b0273 100644 --- a/Kitodo/src/main/java/org/kitodo/production/security/password/AdaptivePasswordEncoder.java +++ b/Kitodo/src/main/java/org/kitodo/production/security/password/AdaptivePasswordEncoder.java @@ -11,17 +11,72 @@ 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 hash(String rawPassword) { + public String hashBcrypt(String rawPassword) { return BCRYPT_ENCODER.encode(rawPassword); } - public boolean matches(String rawPassword, String encodedPassword) { + 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); + } + + public String hashPbkdf2(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); + } + } + + public boolean matchesPbkdf2(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 e2eec8fecb3..a071a70f0e1 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 @@ -413,22 +413,31 @@ public boolean changeUserPassword(User user, String inNewPassword) throws NoSuch private ModificationItem[] createPasswordModificationItems(User user, String inNewPassword, MD4Digest digester) throws NoSuchAlgorithmException { PasswordEncryption passwordEncryption = user.getLdapGroup().getLdapServer().getPasswordEncryption(); + AdaptivePasswordEncoder adaptivePasswordEncoder = new AdaptivePasswordEncoder(); String encryptedPassword; - if (passwordEncryption == PasswordEncryption.BCRYPT) { - AdaptivePasswordEncoder adaptivePasswordEncoder = new AdaptivePasswordEncoder(); - encryptedPassword = adaptivePasswordEncoder.hash(inNewPassword); - } else { - 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); + switch (passwordEncryption) { + case BCRYPT: + encryptedPassword = adaptivePasswordEncoder.hashBcrypt(inNewPassword); + break; + case SCRYPT: + encryptedPassword = adaptivePasswordEncoder.hashScrypt(inNewPassword); + break; + case PBKDF2: + encryptedPassword = adaptivePasswordEncoder.hashPbkdf2(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]; diff --git a/Kitodo/src/test/java/org/kitodo/production/ldap/LdapUserTest.java b/Kitodo/src/test/java/org/kitodo/production/ldap/LdapUserTest.java index ca86a28b77f..411e4506ef5 100644 --- a/Kitodo/src/test/java/org/kitodo/production/ldap/LdapUserTest.java +++ b/Kitodo/src/test/java/org/kitodo/production/ldap/LdapUserTest.java @@ -78,7 +78,32 @@ public void configureUserPasswordWithBcrypt() throws Exception { String bcryptHash = value.substring(8); assertTrue(bcryptHash.startsWith("$2"), "BCRYPT hash should start with $2, got: " + bcryptHash); AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); - assertTrue(encoder.matches("testPassword123", bcryptHash), "BCRYPT hash should match original password"); + 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.matchesPbkdf2("testPassword123", pbkdf2Hash), "PBKDF2 hash should match original password"); } @Test 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 index 00576f436cf..3bf232f82aa 100644 --- a/Kitodo/src/test/java/org/kitodo/production/security/password/AdaptivePasswordEncoderTest.java +++ b/Kitodo/src/test/java/org/kitodo/production/security/password/AdaptivePasswordEncoderTest.java @@ -20,39 +20,106 @@ public class AdaptivePasswordEncoderTest { @Test - public void hashShouldProduceNonNullResult() { + public void hashBcryptShouldProduceNonNullResult() { AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); - String hash = encoder.hash("testPassword"); + String hash = encoder.hashBcrypt("testPassword"); assertNotNull(hash); } @Test - public void hashShouldStartWithBcryptPrefix() { + public void hashBcryptShouldStartWithBcryptPrefix() { AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); - String hash = encoder.hash("testPassword"); + String hash = encoder.hashBcrypt("testPassword"); assertTrue(hash.startsWith("$2"), "Hash should start with $2 for bcrypt, got: " + hash); } @Test - public void hashShouldProduceDifferentHashesForSamePassword() { + public void hashBcryptShouldProduceDifferentHashesForSamePassword() { AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); - String hash1 = encoder.hash("testPassword"); - String hash2 = encoder.hash("testPassword"); + 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 matchesShouldReturnTrueForCorrectPassword() { + public void matchesBcryptShouldReturnTrueForCorrectPassword() { AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); String password = "mySecretPassword123"; - String hash = encoder.hash(password); - assertTrue(encoder.matches(password, hash)); + String hash = encoder.hashBcrypt(password); + assertTrue(encoder.matchesBcrypt(password, hash)); } @Test - public void matchesShouldReturnFalseForIncorrectPassword() { + public void matchesBcryptShouldReturnFalseForIncorrectPassword() { AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); - String hash = encoder.hash("correctPassword"); - assertTrue(!encoder.matches("wrongPassword", hash)); + 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("$s0"), "Hash should start with $s0 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 hashPbkdf2ShouldProduceNonNullResult() { + AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); + String hash = encoder.hashPbkdf2("testPassword"); + assertNotNull(hash); + } + + @Test + public void hashPbkdf2ShouldProduceDifferentHashesForSamePassword() { + AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); + String hash1 = encoder.hashPbkdf2("testPassword"); + String hash2 = encoder.hashPbkdf2("testPassword"); + assertNotEquals(hash1, hash2, "Same password should produce different hashes due to random salt"); + } + + @Test + public void matchesPbkdf2ShouldReturnTrueForCorrectPassword() { + AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); + String password = "mySecretPassword123"; + String hash = encoder.hashPbkdf2(password); + assertTrue(encoder.matchesPbkdf2(password, hash)); + } + + @Test + public void matchesPbkdf2ShouldReturnFalseForIncorrectPassword() { + AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); + String hash = encoder.hashPbkdf2("correctPassword"); + assertTrue(!encoder.matchesPbkdf2("wrongPassword", hash)); } } From e1accbcc66f079b2ccf2389c72c9ad19c3eb55c1 Mon Sep 17 00:00:00 2001 From: Stefan Weil Date: Tue, 7 Jul 2026 13:41:18 +0200 Subject: [PATCH 11/11] Fix checkstyle issues in AdaptivePasswordEncoder - Rename PBKDF2 methods to follow camelCase naming convention - Add Javadoc comments to PBKDF2 methods - Fix scrypt hash prefix in tests Assisted-by: OpenCode + minimax-m2.7 (MiniMax) Signed-off-by: Stefan Weil --- .../org/kitodo/production/ldap/LdapUser.java | 2 +- .../password/AdaptivePasswordEncoder.java | 20 ++++++++++++++-- .../services/data/LdapServerService.java | 2 +- .../kitodo/production/ldap/LdapUserTest.java | 2 +- .../password/AdaptivePasswordEncoderTest.java | 24 +++++++++---------- 5 files changed, 33 insertions(+), 17 deletions(-) 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 eeb082d0149..6bdb2062bb8 100644 --- a/Kitodo/src/main/java/org/kitodo/production/ldap/LdapUser.java +++ b/Kitodo/src/main/java/org/kitodo/production/ldap/LdapUser.java @@ -125,7 +125,7 @@ private void setUserPassword(String inPassword, LdapGroup ldapGroup) throws NoSu hashedPassword = adaptivePasswordEncoder.hashScrypt(inPassword); break; case PBKDF2: - hashedPassword = adaptivePasswordEncoder.hashPbkdf2(inPassword); + hashedPassword = adaptivePasswordEncoder.hashPbkdf2WithHmac(inPassword); break; default: MessageDigest md = MessageDigest.getInstance(passwordEncryption.getTitle()); 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 index bbc875b0273..23cf96f6dd3 100644 --- a/Kitodo/src/main/java/org/kitodo/production/security/password/AdaptivePasswordEncoder.java +++ b/Kitodo/src/main/java/org/kitodo/production/security/password/AdaptivePasswordEncoder.java @@ -44,7 +44,14 @@ public boolean matchesScrypt(String rawPassword, String encodedPassword) { return SCRYPT_ENCODER.matches(rawPassword, encodedPassword); } - public String hashPbkdf2(String rawPassword) { + /** + * 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]; @@ -61,7 +68,16 @@ public String hashPbkdf2(String rawPassword) { } } - public boolean matchesPbkdf2(String rawPassword, String encodedPassword) { + /** + * 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]; 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 a071a70f0e1..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 @@ -423,7 +423,7 @@ private ModificationItem[] createPasswordModificationItems(User user, String inN encryptedPassword = adaptivePasswordEncoder.hashScrypt(inNewPassword); break; case PBKDF2: - encryptedPassword = adaptivePasswordEncoder.hashPbkdf2(inNewPassword); + encryptedPassword = adaptivePasswordEncoder.hashPbkdf2WithHmac(inNewPassword); break; default: MessageDigest md = MessageDigest.getInstance(passwordEncryption.getTitle()); diff --git a/Kitodo/src/test/java/org/kitodo/production/ldap/LdapUserTest.java b/Kitodo/src/test/java/org/kitodo/production/ldap/LdapUserTest.java index 411e4506ef5..de2eb1af1df 100644 --- a/Kitodo/src/test/java/org/kitodo/production/ldap/LdapUserTest.java +++ b/Kitodo/src/test/java/org/kitodo/production/ldap/LdapUserTest.java @@ -103,7 +103,7 @@ public void configureUserPasswordWithPbkdf2() throws Exception { assertTrue(value.startsWith("{PBKDF2}"), "PBKDF2 password should start with {PBKDF2}, got: " + value); String pbkdf2Hash = value.substring(8); AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); - assertTrue(encoder.matchesPbkdf2("testPassword123", pbkdf2Hash), "PBKDF2 hash should match original password"); + assertTrue(encoder.matchesPbkdf2WithHmac("testPassword123", pbkdf2Hash), "PBKDF2 hash should match original password"); } @Test 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 index 3bf232f82aa..819c7ca1693 100644 --- a/Kitodo/src/test/java/org/kitodo/production/security/password/AdaptivePasswordEncoderTest.java +++ b/Kitodo/src/test/java/org/kitodo/production/security/password/AdaptivePasswordEncoderTest.java @@ -67,7 +67,7 @@ public void hashScryptShouldProduceNonNullResult() { public void hashScryptShouldStartWithScryptPrefix() { AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); String hash = encoder.hashScrypt("testPassword"); - assertTrue(hash.startsWith("$s0"), "Hash should start with $s0 for scrypt, got: " + hash); + assertTrue(hash.startsWith("$4"), "Hash should start with $4 for scrypt, got: " + hash); } @Test @@ -94,32 +94,32 @@ public void matchesScryptShouldReturnFalseForIncorrectPassword() { } @Test - public void hashPbkdf2ShouldProduceNonNullResult() { + public void hashPbkdf2WithHmacShouldProduceNonNullResult() { AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); - String hash = encoder.hashPbkdf2("testPassword"); + String hash = encoder.hashPbkdf2WithHmac("testPassword"); assertNotNull(hash); } @Test - public void hashPbkdf2ShouldProduceDifferentHashesForSamePassword() { + public void hashPbkdf2WithHmacShouldProduceDifferentHashesForSamePassword() { AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); - String hash1 = encoder.hashPbkdf2("testPassword"); - String hash2 = encoder.hashPbkdf2("testPassword"); + 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 matchesPbkdf2ShouldReturnTrueForCorrectPassword() { + public void matchesPbkdf2WithHmacShouldReturnTrueForCorrectPassword() { AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); String password = "mySecretPassword123"; - String hash = encoder.hashPbkdf2(password); - assertTrue(encoder.matchesPbkdf2(password, hash)); + String hash = encoder.hashPbkdf2WithHmac(password); + assertTrue(encoder.matchesPbkdf2WithHmac(password, hash)); } @Test - public void matchesPbkdf2ShouldReturnFalseForIncorrectPassword() { + public void matchesPbkdf2WithHmacShouldReturnFalseForIncorrectPassword() { AdaptivePasswordEncoder encoder = new AdaptivePasswordEncoder(); - String hash = encoder.hashPbkdf2("correctPassword"); - assertTrue(!encoder.matchesPbkdf2("wrongPassword", hash)); + String hash = encoder.hashPbkdf2WithHmac("correctPassword"); + assertTrue(!encoder.matchesPbkdf2WithHmac("wrongPassword", hash)); } }