diff --git a/crypto/default/src/main/java/org/keycloak/crypto/hash/Argon2Parameters.java b/crypto/default/src/main/java/org/keycloak/crypto/hash/Argon2Parameters.java new file mode 100644 index 0000000000..ff3f868055 --- /dev/null +++ b/crypto/default/src/main/java/org/keycloak/crypto/hash/Argon2Parameters.java @@ -0,0 +1,48 @@ +package org.keycloak.crypto.hash; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +public class Argon2Parameters { + + public static String DEFAULT_TYPE = "id"; + public static String DEFAULT_VERSION = "1.3"; + public static int DEFAULT_HASH_LENGTH = 32; + public static int DEFAULT_MEMORY = 7168; + public static int DEFAULT_ITERATIONS = 5; + public static int DEFAULT_PARALLELISM = 1; + + private static Map types = new LinkedHashMap<>(); + + static { + types.put("id", org.bouncycastle.crypto.params.Argon2Parameters.ARGON2_id); + types.put("d", org.bouncycastle.crypto.params.Argon2Parameters.ARGON2_d); + types.put("i", org.bouncycastle.crypto.params.Argon2Parameters.ARGON2_i); + } + + private static Map versions = new LinkedHashMap<>(); + + static { + versions.put("1.3", org.bouncycastle.crypto.params.Argon2Parameters.ARGON2_VERSION_13); + versions.put("1.0", org.bouncycastle.crypto.params.Argon2Parameters.ARGON2_VERSION_10); + } + + public static Set listTypes() { + return types.keySet(); + } + + public static Set listVersions() { + return versions.keySet(); + } + + public static int getTypeValue(String type) { + return types.get(type); + } + + public static int getVersionValue(String version) { + return versions.get(version); + } + + +} diff --git a/crypto/default/src/main/java/org/keycloak/crypto/hash/Argon2PasswordHashProvider.java b/crypto/default/src/main/java/org/keycloak/crypto/hash/Argon2PasswordHashProvider.java new file mode 100644 index 0000000000..ab7723ed5e --- /dev/null +++ b/crypto/default/src/main/java/org/keycloak/crypto/hash/Argon2PasswordHashProvider.java @@ -0,0 +1,120 @@ +package org.keycloak.crypto.hash; + +import org.bouncycastle.crypto.generators.Argon2BytesGenerator; +import org.keycloak.common.util.Base64; +import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.credential.hash.PasswordHashProvider; +import org.keycloak.credential.hash.Salt; +import org.keycloak.models.PasswordPolicy; +import org.keycloak.models.credential.PasswordCredentialModel; +import org.keycloak.models.credential.dto.PasswordCredentialData; +import org.keycloak.models.credential.dto.PasswordSecretData; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.keycloak.crypto.hash.Argon2PasswordHashProviderFactory.MEMORY_KEY; +import static org.keycloak.crypto.hash.Argon2PasswordHashProviderFactory.PARALLELISM_KEY; +import static org.keycloak.crypto.hash.Argon2PasswordHashProviderFactory.TYPE_KEY; +import static org.keycloak.crypto.hash.Argon2PasswordHashProviderFactory.VERSION_KEY; + +public class Argon2PasswordHashProvider implements PasswordHashProvider { + private final String version; + private final String type; + private final int hashLength; + private final int memory; + private final int iterations; + private final int parallelism; + + public Argon2PasswordHashProvider(String version, String type, int hashLength, int memory, int iterations, int parallelism) { + this.version = version; + this.type = type; + this.hashLength = hashLength; + this.memory = memory; + this.iterations = iterations; + this.parallelism = parallelism; + } + + @Override + public boolean policyCheck(PasswordPolicy policy, PasswordCredentialModel credential) { + PasswordCredentialData data = credential.getPasswordCredentialData(); + + return iterations == data.getHashIterations() && + checkCredData(TYPE_KEY, type, data) && + checkCredData(VERSION_KEY, version, data) && + checkCredData(Argon2PasswordHashProviderFactory.HASH_LENGTH_KEY, hashLength, data) && + checkCredData(MEMORY_KEY, memory, data) && + checkCredData(PARALLELISM_KEY, parallelism, data); + } + + /** + * Password hashing iterations from password policy is intentionally ignored for now for two reasons. 1) default + * iterations are 210K, which is way too large for Argon2, and 2) it makes little sense to configure iterations only + * for Argon2, which should be combined with configuring memory, which is not currently configurable in password + * policy. + */ + @Override + public PasswordCredentialModel encodedCredential(String rawPassword, int ignoredIterationsFromPasswordPolicy) { + byte[] salt = Salt.generateSalt(); + String encoded = encode(rawPassword, salt, version, type, hashLength, parallelism, memory, iterations); + + Map> additionalParameters = new HashMap<>(); + additionalParameters.put(VERSION_KEY, Collections.singletonList(version)); + additionalParameters.put(TYPE_KEY, Collections.singletonList(type)); + additionalParameters.put(Argon2PasswordHashProviderFactory.HASH_LENGTH_KEY, Collections.singletonList(Integer.toString(hashLength))); + additionalParameters.put(MEMORY_KEY, Collections.singletonList(Integer.toString(memory))); + additionalParameters.put(PARALLELISM_KEY, Collections.singletonList(Integer.toString(parallelism))); + + return PasswordCredentialModel.createFromValues(Argon2PasswordHashProviderFactory.ID, salt, iterations, additionalParameters, encoded); + } + + @Override + public boolean verify(String rawPassword, PasswordCredentialModel credential) { + PasswordCredentialData data = credential.getPasswordCredentialData(); + MultivaluedHashMap parameters = data.getAdditionalParameters(); + PasswordSecretData secretData = credential.getPasswordSecretData(); + + String version = parameters.getFirst(VERSION_KEY); + String type = parameters.getFirst(TYPE_KEY); + int hashLength = Integer.parseInt(parameters.getFirst(Argon2PasswordHashProviderFactory.HASH_LENGTH_KEY)); + int parallelism = Integer.parseInt(parameters.getFirst(PARALLELISM_KEY)); + int memory = Integer.parseInt(parameters.getFirst(MEMORY_KEY)); + int iterations = data.getHashIterations(); + + String encoded = encode(rawPassword, secretData.getSalt(), version, type, hashLength, parallelism, memory, iterations); + return encoded.equals(secretData.getValue()); + } + + private String encode(String rawPassword, byte[] salt, String version, String type, int hashLength, int parallelism, int memory, int iterations) { + org.bouncycastle.crypto.params.Argon2Parameters parameters = new org.bouncycastle.crypto.params.Argon2Parameters.Builder(Argon2Parameters.getTypeValue(type)) + .withVersion(Argon2Parameters.getVersionValue(version)) + .withSalt(salt) + .withParallelism(parallelism) + .withMemoryAsKB(memory) + .withIterations(iterations).build(); + + Argon2BytesGenerator generator = new Argon2BytesGenerator(); + generator.init(parameters); + + byte[] result = new byte[hashLength]; + generator.generateBytes(rawPassword.toCharArray(), result); + return Base64.encodeBytes(result); + } + + private boolean checkCredData(String key, int expectedValue, PasswordCredentialData data) { + String s = data.getAdditionalParameters().getFirst(key); + Integer v = s != null ? Integer.parseInt(s) : null; + return v != null && expectedValue == v; + } + + private boolean checkCredData(String key, String expectedValue, PasswordCredentialData data) { + String s = data.getAdditionalParameters().getFirst(key); + return expectedValue.equals(s); + } + + @Override + public void close() { + } +} diff --git a/crypto/default/src/main/java/org/keycloak/crypto/hash/Argon2PasswordHashProviderFactory.java b/crypto/default/src/main/java/org/keycloak/crypto/hash/Argon2PasswordHashProviderFactory.java new file mode 100644 index 0000000000..7b95c57158 --- /dev/null +++ b/crypto/default/src/main/java/org/keycloak/crypto/hash/Argon2PasswordHashProviderFactory.java @@ -0,0 +1,117 @@ +package org.keycloak.crypto.hash; + +import org.keycloak.Config; +import org.keycloak.common.Profile; +import org.keycloak.credential.hash.PasswordHashProvider; +import org.keycloak.credential.hash.PasswordHashProviderFactory; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.EnvironmentDependentProviderFactory; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.provider.ProviderConfigurationBuilder; + +import java.util.LinkedList; +import java.util.List; + +public class Argon2PasswordHashProviderFactory implements PasswordHashProviderFactory, EnvironmentDependentProviderFactory { + + public static final String ID = "argon2"; + public static final String TYPE_KEY = "type"; + public static final String VERSION_KEY = "version"; + public static final String HASH_LENGTH_KEY = "hashLength"; + public static final String MEMORY_KEY = "memory"; + public static final String ITERATIONS_KEY = "iterations"; + public static final String PARALLELISM_KEY = "parallelism"; + + private String version; + private String type; + private int hashLength; + private int memory; + private int iterations; + private int parallelism; + + @Override + public PasswordHashProvider create(KeycloakSession session) { + return new Argon2PasswordHashProvider(version, type, hashLength, memory, iterations, parallelism); + } + + @Override + public void init(Config.Scope config) { + version = config.get(VERSION_KEY, Argon2Parameters.DEFAULT_VERSION); + type = config.get(VERSION_KEY, Argon2Parameters.DEFAULT_TYPE); + hashLength = config.getInt(HASH_LENGTH_KEY, Argon2Parameters.DEFAULT_HASH_LENGTH); + memory = config.getInt(MEMORY_KEY, Argon2Parameters.DEFAULT_MEMORY); + iterations = config.getInt(ITERATIONS_KEY, Argon2Parameters.DEFAULT_ITERATIONS); + parallelism = config.getInt(PARALLELISM_KEY, Argon2Parameters.DEFAULT_PARALLELISM); + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return ID; + } + + @Override + public List getConfigMetadata() { + ProviderConfigurationBuilder builder = ProviderConfigurationBuilder.create(); + + builder.property() + .name(VERSION_KEY) + .type("string") + .helpText("Version") + .options(new LinkedList<>(Argon2Parameters.listVersions())) + .defaultValue(Argon2Parameters.DEFAULT_VERSION) + .add(); + + builder.property() + .name(TYPE_KEY) + .type("string") + .helpText("Type") + .options(new LinkedList<>(Argon2Parameters.listTypes())) + .defaultValue(Argon2Parameters.DEFAULT_TYPE) + .add(); + + builder.property() + .name(TYPE_KEY) + .type("int") + .helpText("Hash length") + .defaultValue(Argon2Parameters.DEFAULT_HASH_LENGTH) + .add(); + + builder.property() + .name(MEMORY_KEY) + .type("int") + .helpText("Memory size (KB)") + .defaultValue(Argon2Parameters.DEFAULT_MEMORY) + .add(); + + builder.property() + .name(ITERATIONS_KEY) + .type("int") + .helpText("Iterations") + .defaultValue(Argon2Parameters.DEFAULT_ITERATIONS) + .add(); + + builder.property() + .name(PARALLELISM_KEY) + .type("int") + .helpText("Parallelism") + .defaultValue(Argon2Parameters.DEFAULT_PARALLELISM) + .add(); + + return builder.build(); + } + + @Override + public boolean isSupported(Config.Scope config) { + return !Profile.isFeatureEnabled(Profile.Feature.FIPS); + } + +} diff --git a/crypto/default/src/main/resources/META-INF/services/org.keycloak.credential.hash.PasswordHashProviderFactory b/crypto/default/src/main/resources/META-INF/services/org.keycloak.credential.hash.PasswordHashProviderFactory new file mode 100644 index 0000000000..8a50e534b5 --- /dev/null +++ b/crypto/default/src/main/resources/META-INF/services/org.keycloak.credential.hash.PasswordHashProviderFactory @@ -0,0 +1 @@ +org.keycloak.crypto.hash.Argon2PasswordHashProviderFactory \ No newline at end of file diff --git a/docs/documentation/release_notes/topics/25_0_0.adoc b/docs/documentation/release_notes/topics/25_0_0.adoc index 6f9226dc93..55f2e99017 100644 --- a/docs/documentation/release_notes/topics/25_0_0.adoc +++ b/docs/documentation/release_notes/topics/25_0_0.adoc @@ -1,3 +1,14 @@ += Argon2 password hashing provider + +Argon2 was the winner of the [2015 password hashing competition](https://en.wikipedia.org/wiki/Password_Hashing_Competition) +and is the recommended hashing algorithm by [OWASP](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#argon2id). + +In {project_name} 24 the default hashing iterations for PBKDF2 were increased from 27.5K to 210K, resulting in a more than +10 times increase in the amount of CPU time required to generate a password hash. With Argon2 it is possible to achieve +better security, with almost the same CPU time as previous releases of {project_name}. One downside is Argon2 requires more +memory, which is a requirement to be resistant against GPU attacks. The defaults for Argon2 in Keycloak requires 7MB +per-hashing request. + = Deprecated cookie methods removed The following methods for setting custom cookies have been removed: diff --git a/docs/documentation/server_admin/topics/authentication/password-policies.adoc b/docs/documentation/server_admin/topics/authentication/password-policies.adoc index c80030a479..392021a40c 100644 --- a/docs/documentation/server_admin/topics/authentication/password-policies.adoc +++ b/docs/documentation/server_admin/topics/authentication/password-policies.adoc @@ -26,7 +26,16 @@ The new policy will not be effective for existing users. Therefore, make sure th ===== HashAlgorithm -Passwords are not stored in cleartext. Before storage or validation, {project_name} hashes passwords using standard hashing algorithms. PBKDF2 is the only built-in and default algorithm available. See the link:{developerguide_link}[{developerguide_name}] on how to add your own hashing algorithm. +Passwords are not stored in cleartext. Before storage or validation, {project_name} hashes passwords using standard hashing algorithms. + +Supported password hashing algorithms include: + +* argon2:: Argon2 (recommended for non-FIPS deployments) +* pbkdf2-sha512:: PBKDF2 with SHA512 (default, recommended for FIPS deployments) +* pbkdf2-sha256:: PBKDF2 with SHA256 +* pbkdf2:: PBKDF2 with SHA1 (deprecated) + +See the link:{developerguide_link}[{developerguide_name}] on how to add your own hashing algorithm. [NOTE] ==== @@ -35,9 +44,11 @@ If you change the hashing algorithm, password hashes in storage will not change ===== Hashing iterations Specifies the number of times {project_name} hashes passwords before storage or verification. The default value is 210,000 in case that `pbkdf2-sha512` is used as hashing algorithm, which is by default. -If other hash algorithms are explicitly set by using the`HashAlgorithm` policy, the default count of hashing iterations could be different. For instance, it is 600,000 by default if the`pbkdf2-sha256` algorithm is used or 1,300,000 if +If other hash algorithms are explicitly set by using the `HashAlgorithm` policy, the default count of hashing iterations could be different. For instance, it is 600,000 by default if the `pbkdf2-sha256` algorithm is used or 1,300,000 if the `pbkdf2` algorithm (Algorithm `pbkdf2` corresponds to PBKDF2 with HMAC-SHA1). +When using Argon2 as the hashing algorithm the hashing iterations for the password policy is ignored. + {project_name} hashes passwords to ensure that hostile actors with access to the password database cannot read passwords through reverse engineering. [NOTE] diff --git a/server-spi-private/src/main/java/org/keycloak/credential/hash/Salt.java b/server-spi-private/src/main/java/org/keycloak/credential/hash/Salt.java new file mode 100644 index 0000000000..58e608ed02 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/credential/hash/Salt.java @@ -0,0 +1,14 @@ +package org.keycloak.credential.hash; + +import java.security.SecureRandom; + +public class Salt { + + public static byte[] generateSalt() { + SecureRandom secureRandom = new SecureRandom(); + byte[] salt = new byte[16]; + secureRandom.nextBytes(salt); + return salt; + } + +} diff --git a/server-spi/src/main/java/org/keycloak/models/credential/PasswordCredentialModel.java b/server-spi/src/main/java/org/keycloak/models/credential/PasswordCredentialModel.java index ca04e0a38c..b086565d7b 100644 --- a/server-spi/src/main/java/org/keycloak/models/credential/PasswordCredentialModel.java +++ b/server-spi/src/main/java/org/keycloak/models/credential/PasswordCredentialModel.java @@ -6,6 +6,8 @@ import org.keycloak.models.credential.dto.PasswordSecretData; import org.keycloak.util.JsonSerialization; import java.io.IOException; +import java.util.List; +import java.util.Map; public class PasswordCredentialModel extends CredentialModel { @@ -25,7 +27,11 @@ public class PasswordCredentialModel extends CredentialModel { } public static PasswordCredentialModel createFromValues(String algorithm, byte[] salt, int hashIterations, String encodedPassword){ - PasswordCredentialData credentialData = new PasswordCredentialData(hashIterations, algorithm); + return createFromValues(algorithm, salt, hashIterations, null, encodedPassword); + } + + public static PasswordCredentialModel createFromValues(String algorithm, byte[] salt, int hashIterations, Map> additionalParameters, String encodedPassword){ + PasswordCredentialData credentialData = new PasswordCredentialData(hashIterations, algorithm, additionalParameters); PasswordSecretData secretData = new PasswordSecretData(encodedPassword, salt); PasswordCredentialModel passwordCredentialModel = new PasswordCredentialModel(credentialData, secretData); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/PasswordHashingTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/PasswordHashingTest.java index 04e686214c..1ab516c7a3 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/PasswordHashingTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/PasswordHashingTest.java @@ -18,7 +18,10 @@ package org.keycloak.testsuite.forms; import jakarta.ws.rs.BadRequestException; import org.jboss.arquillian.graphene.page.Page; +import org.junit.Assert; +import org.junit.Assume; import org.junit.Test; +import org.keycloak.common.crypto.FipsMode; import org.keycloak.common.util.Base64; import org.keycloak.credential.CredentialModel; import org.keycloak.credential.hash.PasswordHashProvider; @@ -27,15 +30,19 @@ import org.keycloak.credential.hash.Pbkdf2PasswordHashProvider; import org.keycloak.credential.hash.Pbkdf2PasswordHashProviderFactory; import org.keycloak.credential.hash.Pbkdf2Sha256PasswordHashProviderFactory; import org.keycloak.credential.hash.Pbkdf2Sha512PasswordHashProviderFactory; +import org.keycloak.crypto.hash.Argon2PasswordHashProviderFactory; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.credential.PasswordCredentialModel; +import org.keycloak.models.credential.dto.PasswordCredentialData; import org.keycloak.representations.idm.CredentialRepresentation; import org.keycloak.representations.idm.ErrorRepresentation; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; import org.keycloak.testsuite.admin.ApiUtil; +import org.keycloak.testsuite.arquillian.AuthServerTestEnricher; +import org.keycloak.testsuite.pages.AppPage; import org.keycloak.testsuite.pages.LoginPage; import org.keycloak.testsuite.util.AccountHelper; import org.keycloak.testsuite.util.UserBuilder; @@ -68,6 +75,9 @@ public class PasswordHashingTest extends AbstractTestRealmKeycloakTest { @Page protected LoginPage loginPage; + @Page + protected AppPage appPage; + @Test public void testSetInvalidProvider() throws Exception { try { @@ -198,6 +208,35 @@ public class PasswordHashingTest extends AbstractTestRealmKeycloakTest { assertEncoded(credential, "password", credential.getPasswordSecretData().getSalt(), "PBKDF2WithHmacSHA1", Pbkdf2PasswordHashProviderFactory.DEFAULT_ITERATIONS); } + @Test + public void testArgon2() { + Assume.assumeTrue("Argon2 tests skipped in FIPS mode", AuthServerTestEnricher.AUTH_SERVER_FIPS_MODE == FipsMode.DISABLED); + + setPasswordPolicy("hashAlgorithm(" + Argon2PasswordHashProviderFactory.ID + ")"); + String username = "testArgon2"; + createUser(username); + + PasswordCredentialModel credential = PasswordCredentialModel.createFromCredentialModel(fetchCredentials(username)); + PasswordCredentialData data = credential.getPasswordCredentialData(); + + Assert.assertEquals("argon2", data.getAlgorithm()); + Assert.assertEquals(5, data.getHashIterations()); + Assert.assertEquals("1.3", data.getAdditionalParameters().getFirst("version")); + Assert.assertEquals("id", data.getAdditionalParameters().getFirst("type")); + Assert.assertEquals("32", data.getAdditionalParameters().getFirst("hashLength")); + Assert.assertEquals("7168", data.getAdditionalParameters().getFirst("memory")); + Assert.assertEquals("1", data.getAdditionalParameters().getFirst("parallelism")); + + loginPage.open(); + loginPage.login("testArgon2", "invalid"); + loginPage.assertCurrent(); + Assert.assertEquals("Invalid username or password.", loginPage.getInputError()); + + loginPage.login("testArgon2", "password"); + + appPage.assertCurrent(); + } + @Test public void testDefault() throws Exception { setPasswordPolicy("");