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}:
* - * @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);