Skip to content
Draft
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*
* (c) Kitodo. Key to digital objects e. V. <contact@kitodo.org>
*
* 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<Integer> seen = new HashSet<>();
for (PasswordEncryption pe : PasswordEncryption.values()) {
assertTrue(seen.add(pe.getValue()), "Duplicate getValue(): " + pe.getValue());
}
}
}
80 changes: 49 additions & 31 deletions Kitodo/src/main/java/org/kitodo/production/ldap/LdapUser.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {

@BartChris BartChris Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If i understand correctly, the long term goal is to remove write access to the LDAP from Kitodo production, so enhancing password security would benefit an writable-LDAP implementation, which we eventually want to replace

#4646 (comment)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, this is an important information. So the question remains what "long term" means. Maybe removing write access can be done early to fix the issue?

@BartChris BartChris Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#4646 has been assessed in multiple development fund rounds but has not been funded yet. Newer Debian/Ubuntu versions are not only moving to newer Samba versions but keep on deprecating related functionality in newer Kernels. So i do not know if this is already urgent or if Debian 13/Ubuntu 26 still support the current architecture.

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.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;
}
this.attributes.put("userPassword", passwordEncryption.getLdapPrefix() + hashedPassword);
}

private void prepareAttributes(LdapGroup ldapGroup, User user, String inUidNumber) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* (c) Kitodo. Key to digital objects e. V. <contact@kitodo.org>
*
* 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);
}

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;
}
}
}
Loading
Loading