diff --git a/org.eclipse.scout.rt.platform.test/src/test/java/org/eclipse/scout/rt/platform/security/SecurityUtilityTest.java b/org.eclipse.scout.rt.platform.test/src/test/java/org/eclipse/scout/rt/platform/security/SecurityUtilityTest.java
index 4e9fa448a4f..e3a301076c1 100644
--- a/org.eclipse.scout.rt.platform.test/src/test/java/org/eclipse/scout/rt/platform/security/SecurityUtilityTest.java
+++ b/org.eclipse.scout.rt.platform.test/src/test/java/org/eclipse/scout/rt/platform/security/SecurityUtilityTest.java
@@ -131,6 +131,17 @@ public void testRandomBytes() {
Assert.assertTrue(illegalExc);
}
+ @Test
+ public void testCreatePasswordHash() {
+ char[] password = "AwesomePassword".toCharArray();
+ byte[] salt = SecurityUtility.createRandomBytes();
+ PasswordHash passwordHash = SecurityUtility.createPasswordHash(password, salt);
+
+ // ensure created password hash matches given inputs
+ Assert.assertArrayEquals(password, passwordHash.getPassword());
+ Assert.assertArrayEquals(salt, passwordHash.getSalt());
+ }
+
@Test
public void testHash() {
final byte[] data = "testdata".getBytes(ENCODING);
diff --git a/org.eclipse.scout.rt.platform/src/main/java/org/eclipse/scout/rt/platform/security/ILegacySecurityProvider.java b/org.eclipse.scout.rt.platform/src/main/java/org/eclipse/scout/rt/platform/security/ILegacySecurityProvider.java
index 30007894b53..c1140e026b7 100644
--- a/org.eclipse.scout.rt.platform/src/main/java/org/eclipse/scout/rt/platform/security/ILegacySecurityProvider.java
+++ b/org.eclipse.scout.rt.platform/src/main/java/org/eclipse/scout/rt/platform/security/ILegacySecurityProvider.java
@@ -40,7 +40,7 @@ default byte[] createHash(byte[] data, byte[] salt) {
/**
* Creates a hash for the given data using the given salt.
*
- * Important: For hashing of passwords use {@link ISecurityProvider#createPasswordHash(char[], byte[])}!
+ * Important: For hashing of passwords use {@link SecurityUtility#hashPassword(char[], byte[])}!
*
* Important 2: For "normal" hashing use {@link SecurityUtility#hash(byte[])}
*
diff --git a/org.eclipse.scout.rt.platform/src/main/java/org/eclipse/scout/rt/platform/security/ISecurityProvider.java b/org.eclipse.scout.rt.platform/src/main/java/org/eclipse/scout/rt/platform/security/ISecurityProvider.java
index f25aafa8940..293520d680a 100644
--- a/org.eclipse.scout.rt.platform/src/main/java/org/eclipse/scout/rt/platform/security/ISecurityProvider.java
+++ b/org.eclipse.scout.rt.platform/src/main/java/org/eclipse/scout/rt/platform/security/ISecurityProvider.java
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2010, 2024 BSI Business Systems Integration AG
+ * Copyright (c) 2010, 2026 BSI Business Systems Integration AG
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
@@ -137,7 +137,7 @@ public interface ISecurityProvider {
* DigestInputStream.getMessageDigest().digest().
*
*
- * Important: For hashing of passwords use {@link #createPasswordHash(char[], byte[])}!
+ * Important: For hashing of passwords use {@link SecurityUtility#hashPassword(char[], byte[])}!
*
*
* @param stream
@@ -155,7 +155,7 @@ public interface ISecurityProvider {
* DigestInputStream.getMessageDigest().digest().
*
*
- * Important: For hashing of passwords use {@link #createPasswordHash(char[], byte[])}!
+ * Important: For hashing of passwords use {@link SecurityUtility#hashPassword(char[], byte[])}!
*
*
* @param stream
@@ -167,7 +167,7 @@ public interface ISecurityProvider {
DigestOutputStream toHashingStream(OutputStream stream);
/**
- * Creates a hash for the given password.
+ * Creates a new {@link PasswordHash} used for hashing a password.
*
* @param password
* The password to create the hash for. Must not be {@code null} or empty.
@@ -175,28 +175,15 @@ public interface ISecurityProvider {
* The salt to use. Use {@link #createSecureRandomBytes(int)} to generate a new random salt for each
* credential. Do not use the same salt for multiple credentials. The salt should be at least 32 bytes long.
* Remember to save the salt with the hashed password! Must not be {@code null} or an empty array.
- * @return the password hash
+ * @return The {@link PasswordHash} used for hashing a password.
* @throws AssertionException
* If one of the following conditions is {@code true}:
*
* - The password is {@code null} or an empty array
* - The salt is {@code null} or an empty array
*
- * @throws ProcessingException
- * If there is an error creating the hash.
- */
- byte[] createPasswordHash(char[] password, byte[] salt);
-
- /**
- * This method is recommended in combination with {@link #createPasswordHash(char[], byte[])} where the iteration
- * count is omitted. This has the advantage that the check of the password hash is independent of the creation of the
- * hash. In case the iteration count is increased yearly, this method checks if the hash is valid
- *
- * @return true if calculated password hash created with {@link #createPasswordHash(char[], byte[])} matches the
- * expected hash.
- * @since 11.0
*/
- boolean verifyPasswordHash(char[] password, byte[] salt, byte[] expectedHash);
+ PasswordHash createPasswordHash(char[] password, byte[] salt);
/**
* Encrypts the given data using the given {@link EncryptionKey}.
diff --git a/org.eclipse.scout.rt.platform/src/main/java/org/eclipse/scout/rt/platform/security/PasswordHash.java b/org.eclipse.scout.rt.platform/src/main/java/org/eclipse/scout/rt/platform/security/PasswordHash.java
new file mode 100644
index 00000000000..876ce70fa3e
--- /dev/null
+++ b/org.eclipse.scout.rt.platform/src/main/java/org/eclipse/scout/rt/platform/security/PasswordHash.java
@@ -0,0 +1,138 @@
+/*
+ * Copyright (c) 2010, 2026 BSI Business Systems Integration AG
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.eclipse.scout.rt.platform.security;
+
+import static org.eclipse.scout.rt.platform.util.Assertions.*;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.BiFunction;
+import java.util.stream.IntStream;
+
+import org.eclipse.scout.rt.platform.util.MutablePair;
+
+/**
+ * This class represents a password hash and can be used to hash a password and to verify it.
+ * Hash creation and hash verification are separated and take into account all known variations of hash computation.
+ *
+ * Use {@link SecurityUtility#createPasswordHash(char[], byte[])} to create a {@link PasswordHash} that hashes a password
+ * with respect to the current security standard and all known older variations.
+ */
+public final class PasswordHash {
+
+ private final char[] m_password;
+ private final byte[] m_salt;
+ private final List, byte[]>> m_hashGeneratorAndHash;
+
+ private PasswordHash(char[] password, byte[] salt, List> hashGeneratorAndHash) {
+ m_password = password;
+ m_salt = salt;
+ m_hashGeneratorAndHash = hashGeneratorAndHash.stream()
+ .map(generator -> MutablePair.of(generator, (byte[]) null))
+ .toList();
+ }
+
+ public char[] getPassword() {
+ return Arrays.copyOf(m_password, m_password.length);
+ }
+
+ public byte[] getSalt() {
+ return Arrays.copyOf(m_salt, m_salt.length);
+ }
+
+ /**
+ * Creates a password hash. The hashing of the password is performed only once. The result will be cached.
+ *
+ * @return A {@code byte[]} containing the password hash.
+ */
+ public byte[] get() {
+ return get(0);
+ }
+
+ private byte[] get(int index) {
+ MutablePair, byte[]> hashGeneratorAndHash = m_hashGeneratorAndHash.get(index);
+ if (hashGeneratorAndHash.getRight() == null) {
+ BiFunction hashGenerator = hashGeneratorAndHash.getLeft();
+ hashGeneratorAndHash.setRight(hashGenerator.apply(m_password, m_salt));
+ }
+ byte[] hash = hashGeneratorAndHash.getRight();
+ return Arrays.copyOf(hash, hash.length);
+ }
+
+ /**
+ * Verifies that the given expected hash matches this {@link PasswordHash}. Taking into account all known variations
+ * of hash computation.
+ *
+ * @param expectedHash
+ * The expected hash the given {@link PasswordHash} is tested against.
+ * @return true if the {@link PasswordHash} matches the given expected hash against one of the known hashing variations.
+ */
+ public boolean verify(byte[] expectedHash) {
+ if (expectedHash == null) {
+ return false;
+ }
+ return IntStream.range(0, m_hashGeneratorAndHash.size())
+ .mapToObj(this::get)
+ .anyMatch(hash -> Arrays.equals(expectedHash, hash));
+ }
+
+ public static final class PasswordHashBuilder {
+
+ private final char[] m_password;
+ private final byte[] m_salt;
+ private final List> m_hashGenerators;
+
+ /**
+ * Use this method to get a {@link PasswordHashBuilder} to build a new {@link PasswordHash}.
+ *
+ * @param password
+ * The password to create the hash for. Must not be {@code null} or empty.
+ * @param salt
+ * The salt to use. Use {@link SecurityUtility#createRandomBytes(int)} to generate a new random salt for each
+ * credential. Do not use the same salt for multiple credentials. The salt should be at least 32 bytes long.
+ * Remember to save the salt with the hashed password! Must not be {@code null} or an empty array.
+ * @param defaultHashGenerator
+ * A {@link BiFunction} that can compute a hash from a password and a salt.
+ * The default generator must be selected so that the hash is computed in line with the current security standard.
+ * @return A {@link PasswordHashBuilder} used to build a new {@link PasswordHash}.
+ */
+ public static PasswordHashBuilder of(char[] password, byte[] salt, BiFunction defaultHashGenerator) {
+ return new PasswordHashBuilder(password, salt)
+ .withVariant(defaultHashGenerator);
+ }
+
+ private PasswordHashBuilder(char[] password, byte[] salt) {
+ assertGreater(assertNotNull(password, "password must not be null.").length, 0, "empty password is not allowed.");
+ assertGreater(assertNotNull(salt, "salt must not be null.").length, 0, "empty salt is not allowed.");
+
+ m_password = password;
+ m_salt = salt;
+ m_hashGenerators = new ArrayList<>();
+ }
+
+ /**
+ * Use this method to add variations of the hash computation. This is necessary when the security standard for hashing
+ * changes, for example, due to a change in the number of iterations or the algorithm itself.
+ *
+ * @param hashGenerator
+ * A {@link BiFunction} that can compute a hash from a password and a salt.
+ * @return A {@link PasswordHashBuilder} used to build a new {@link PasswordHash}.
+ */
+ public PasswordHashBuilder withVariant(BiFunction hashGenerator) {
+ m_hashGenerators.add(assertNotNull(hashGenerator, "hashGenerator must not be null."));
+ return this;
+ }
+
+ public PasswordHash build() {
+ return new PasswordHash(m_password, m_salt, m_hashGenerators);
+ }
+ }
+}
diff --git a/org.eclipse.scout.rt.platform/src/main/java/org/eclipse/scout/rt/platform/security/SecurityUtility.java b/org.eclipse.scout.rt.platform/src/main/java/org/eclipse/scout/rt/platform/security/SecurityUtility.java
index 599a10b3142..6064cb5a2f9 100644
--- a/org.eclipse.scout.rt.platform/src/main/java/org/eclipse/scout/rt/platform/security/SecurityUtility.java
+++ b/org.eclipse.scout.rt.platform/src/main/java/org/eclipse/scout/rt/platform/security/SecurityUtility.java
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2010, 2024 BSI Business Systems Integration AG
+ * Copyright (c) 2010, 2026 BSI Business Systems Integration AG
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
@@ -325,7 +325,7 @@ public static SecureRandom createSecureRandom() {
* @see ISecurityProvider#createPasswordHash(char[], byte[])
*/
public static byte[] hashPassword(char[] password, byte[] salt) {
- return SECURITY_PROVIDER.get().createPasswordHash(password, salt);
+ return createPasswordHash(password, salt).get();
}
/**
@@ -338,7 +338,21 @@ public static byte[] hashPassword(char[] password, byte[] salt) {
* @since 11.0
*/
public static boolean verifyPasswordHash(char[] password, byte[] salt, byte[] expectedHash) {
- return SECURITY_PROVIDER.get().verifyPasswordHash(password, salt, expectedHash);
+ return createPasswordHash(password, salt).verify(expectedHash);
+ }
+
+ /**
+ * @see PasswordHash#verify(byte[])
+ */
+ public static boolean verifyPasswordHash(PasswordHash passwordHash, byte[] expectedHash) {
+ return passwordHash.verify(expectedHash);
+ }
+
+ /**
+ * See {@link ISecurityProvider#createPasswordHash(char[], byte[])}
+ */
+ public static PasswordHash createPasswordHash(char[] password, byte[] salt) {
+ return SECURITY_PROVIDER.get().createPasswordHash(password, salt);
}
/**
diff --git a/org.eclipse.scout.rt.platform/src/main/java/org/eclipse/scout/rt/platform/security/SunSecurityProvider.java b/org.eclipse.scout.rt.platform/src/main/java/org/eclipse/scout/rt/platform/security/SunSecurityProvider.java
index 8a0cf7921b2..25edf01fab7 100644
--- a/org.eclipse.scout.rt.platform/src/main/java/org/eclipse/scout/rt/platform/security/SunSecurityProvider.java
+++ b/org.eclipse.scout.rt.platform/src/main/java/org/eclipse/scout/rt/platform/security/SunSecurityProvider.java
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2010, 2025 BSI Business Systems Integration AG
+ * Copyright (c) 2010, 2026 BSI Business Systems Integration AG
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
@@ -43,7 +43,6 @@
import java.security.spec.KeySpec;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
-import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
@@ -60,6 +59,7 @@
import org.eclipse.scout.rt.platform.Order;
import org.eclipse.scout.rt.platform.exception.ProcessingException;
+import org.eclipse.scout.rt.platform.security.PasswordHash.PasswordHashBuilder;
import org.eclipse.scout.rt.platform.util.Assertions;
import org.eclipse.scout.rt.platform.util.Base64Utility;
@@ -201,8 +201,17 @@ protected static byte[] generateCompatibilityHeader(int keyLen, String secretKey
}
@Override
- public byte[] createPasswordHash(char[] password, byte[] salt) {
- return createPasswordHash(password, salt, MIN_PASSWORD_HASH_ITERATIONS);
+ public PasswordHash createPasswordHash(char[] password, byte[] salt) {
+ return PasswordHashBuilder.of(password, salt, (p, s) -> this.createPasswordHash(p, s, MIN_PASSWORD_HASH_ITERATIONS))
+ // in case the computation of the hash changes (i.e. the iterations or the implementation itself changes), add the "old" computation as a variant.
+ // example: the current one is the 2023 implementation, therefore we add all older implementations as a variant (2021, 2019, etc.)
+ .withVariant((p, s) -> this.createPasswordHash(p, s, MIN_PASSWORD_HASH_ITERATIONS_2021))
+ .withVariant((p, s) -> this.createPasswordHash(p, s, MIN_PASSWORD_HASH_ITERATIONS_2019))
+ .withVariant((p, s) -> this.createPasswordHash(p, s, MIN_PASSWORD_HASH_ITERATIONS_2016))
+ // 2014 variants
+ .withVariant((p, s) -> createHash(new ByteArrayInputStream(new String(p).getBytes(StandardCharsets.UTF_8)), s, 3557))
+ .withVariant((p, s) -> createHash(new ByteArrayInputStream(new String(p).getBytes(StandardCharsets.UTF_16)), s, 3557))
+ .build();
}
/**
@@ -248,30 +257,6 @@ public byte[] createPasswordHash(char[] password, byte[] salt, int iterations) {
}
}
- @Override
- public boolean verifyPasswordHash(char[] password, byte[] salt, byte[] expectedHash) {
- if (Arrays.equals(expectedHash, createPasswordHash(password, salt, MIN_PASSWORD_HASH_ITERATIONS))) {
- return true;
- }
- if (Arrays.equals(expectedHash, createPasswordHash(password, salt, MIN_PASSWORD_HASH_ITERATIONS_2021))) {
- return true;
- }
- if (Arrays.equals(expectedHash, createPasswordHash(password, salt, MIN_PASSWORD_HASH_ITERATIONS_2019))) {
- return true;
- }
- if (Arrays.equals(expectedHash, createPasswordHash(password, salt, MIN_PASSWORD_HASH_ITERATIONS_2016))) {
- return true;
- }
- //2014 variants
- if (Arrays.equals(expectedHash, createHash(new ByteArrayInputStream(new String(password).getBytes(StandardCharsets.UTF_8)), salt, 3557))) {
- return true;
- }
- if (Arrays.equals(expectedHash, createHash(new ByteArrayInputStream(new String(password).getBytes(StandardCharsets.UTF_16)), salt, 3557))) {
- return true;
- }
- return false;
- }
-
@Override
public void encrypt(InputStream clearTextData, OutputStream encryptedData, EncryptionKey key) {
doCrypt(clearTextData, encryptedData, key, Cipher.ENCRYPT_MODE);