Argon2 password hashing provider (#28031)

Closes #28030

Signed-off-by: stianst <stianst@gmail.com>
This commit is contained in:
Stian Thorgersen 2024-03-22 07:08:09 +01:00 committed by GitHub
parent 8602b4f9cf
commit cae92cbe8c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 370 additions and 3 deletions

View file

@ -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<String, Integer> 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<String, Integer> 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<String> listTypes() {
return types.keySet();
}
public static Set<String> listVersions() {
return versions.keySet();
}
public static int getTypeValue(String type) {
return types.get(type);
}
public static int getVersionValue(String version) {
return versions.get(version);
}
}

View file

@ -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<String, List<String>> 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<String, String> 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() {
}
}

View file

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

View file

@ -0,0 +1 @@
org.keycloak.crypto.hash.Argon2PasswordHashProviderFactory

View file

@ -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 = Deprecated cookie methods removed
The following methods for setting custom cookies have been removed: The following methods for setting custom cookies have been removed:

View file

@ -26,7 +26,16 @@ The new policy will not be effective for existing users. Therefore, make sure th
===== HashAlgorithm ===== 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] [NOTE]
==== ====
@ -35,9 +44,11 @@ If you change the hashing algorithm, password hashes in storage will not change
===== Hashing iterations ===== 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. 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). 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. {project_name} hashes passwords to ensure that hostile actors with access to the password database cannot read passwords through reverse engineering.
[NOTE] [NOTE]

View file

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

View file

@ -6,6 +6,8 @@ import org.keycloak.models.credential.dto.PasswordSecretData;
import org.keycloak.util.JsonSerialization; import org.keycloak.util.JsonSerialization;
import java.io.IOException; import java.io.IOException;
import java.util.List;
import java.util.Map;
public class PasswordCredentialModel extends CredentialModel { 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){ 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<String, List<String>> additionalParameters, String encodedPassword){
PasswordCredentialData credentialData = new PasswordCredentialData(hashIterations, algorithm, additionalParameters);
PasswordSecretData secretData = new PasswordSecretData(encodedPassword, salt); PasswordSecretData secretData = new PasswordSecretData(encodedPassword, salt);
PasswordCredentialModel passwordCredentialModel = new PasswordCredentialModel(credentialData, secretData); PasswordCredentialModel passwordCredentialModel = new PasswordCredentialModel(credentialData, secretData);

View file

@ -18,7 +18,10 @@ package org.keycloak.testsuite.forms;
import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.BadRequestException;
import org.jboss.arquillian.graphene.page.Page; import org.jboss.arquillian.graphene.page.Page;
import org.junit.Assert;
import org.junit.Assume;
import org.junit.Test; import org.junit.Test;
import org.keycloak.common.crypto.FipsMode;
import org.keycloak.common.util.Base64; import org.keycloak.common.util.Base64;
import org.keycloak.credential.CredentialModel; import org.keycloak.credential.CredentialModel;
import org.keycloak.credential.hash.PasswordHashProvider; 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.Pbkdf2PasswordHashProviderFactory;
import org.keycloak.credential.hash.Pbkdf2Sha256PasswordHashProviderFactory; import org.keycloak.credential.hash.Pbkdf2Sha256PasswordHashProviderFactory;
import org.keycloak.credential.hash.Pbkdf2Sha512PasswordHashProviderFactory; import org.keycloak.credential.hash.Pbkdf2Sha512PasswordHashProviderFactory;
import org.keycloak.crypto.hash.Argon2PasswordHashProviderFactory;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.models.credential.PasswordCredentialModel; import org.keycloak.models.credential.PasswordCredentialModel;
import org.keycloak.models.credential.dto.PasswordCredentialData;
import org.keycloak.representations.idm.CredentialRepresentation; import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.representations.idm.ErrorRepresentation; import org.keycloak.representations.idm.ErrorRepresentation;
import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.admin.ApiUtil; 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.pages.LoginPage;
import org.keycloak.testsuite.util.AccountHelper; import org.keycloak.testsuite.util.AccountHelper;
import org.keycloak.testsuite.util.UserBuilder; import org.keycloak.testsuite.util.UserBuilder;
@ -68,6 +75,9 @@ public class PasswordHashingTest extends AbstractTestRealmKeycloakTest {
@Page @Page
protected LoginPage loginPage; protected LoginPage loginPage;
@Page
protected AppPage appPage;
@Test @Test
public void testSetInvalidProvider() throws Exception { public void testSetInvalidProvider() throws Exception {
try { try {
@ -198,6 +208,35 @@ public class PasswordHashingTest extends AbstractTestRealmKeycloakTest {
assertEncoded(credential, "password", credential.getPasswordSecretData().getSalt(), "PBKDF2WithHmacSHA1", Pbkdf2PasswordHashProviderFactory.DEFAULT_ITERATIONS); 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 @Test
public void testDefault() throws Exception { public void testDefault() throws Exception {
setPasswordPolicy(""); setPasswordPolicy("");