Argon2 password hashing provider (#28031)
Closes #28030 Signed-off-by: stianst <stianst@gmail.com>
This commit is contained in:
parent
8602b4f9cf
commit
cae92cbe8c
9 changed files with 370 additions and 3 deletions
|
@ -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);
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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() {
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
org.keycloak.crypto.hash.Argon2PasswordHashProviderFactory
|
|
@ -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:
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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<String, List<String>> additionalParameters, String encodedPassword){
|
||||
PasswordCredentialData credentialData = new PasswordCredentialData(hashIterations, algorithm, additionalParameters);
|
||||
PasswordSecretData secretData = new PasswordSecretData(encodedPassword, salt);
|
||||
|
||||
PasswordCredentialModel passwordCredentialModel = new PasswordCredentialModel(credentialData, secretData);
|
||||
|
|
|
@ -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("");
|
||||
|
|
Loading…
Reference in a new issue