8000 Use policy iterations in Argon2 policyCheck by ahus1 · Pull Request #55027 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public Argon2PasswordHashProvider(String version, String type, int hashLength, i
public boolean policyCheck(PasswordPolicy policy, PasswordCredentialModel credential) {
PasswordCredentialData data = credential.getPasswordCredentialData();

return iterations == data.getHashIterations() &&
return expectedIterations(policy != null ? policy.getHashIterations() : -1) == data.getHashIterations() &&
checkCredData(TYPE_KEY, type, data) &&
checkCredData(VERSION_KEY, version, data) &&
checkCredData(Argon2PasswordHashProviderFactory.HASH_LENGTH_KEY, hashLength, data) &&
Expand All @@ -60,19 +60,16 @@ public boolean policyCheck(PasswordPolicy policy, PasswordCredentialModel creden
}

/**
* 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.
* Password hashing iterations from the password policy are only used if they are 100 or less, otherwise the
* provider's iterations are used. Larger values are most likely meant for PBKDF2 (default 210K), which is way too
* large for Argon2.
*/
@Override
public PasswordCredentialModel encodedCredential(String rawPassword, int iterations) {
if (iterations == -1) {
iterations = this.iterations;
} else if (iterations > 100) {
if (iterations > 100) {
logger.warn("Iterations for Argon should be less than 100, using default");
iterations = this.iterations;
}
iterations = expectedIterations(iterations);

byte[] salt = Salt.generateSalt();
String encoded = encode(rawPassword, salt, version, type, hashLength, parallelism, memory, iterations);
Expand Down Expand Up @@ -143,6 +140,15 @@ private String encode(String rawPassword, byte[] salt, String version, String ty
});
}

/**
* The iterations that {@link #encodedCredential(String, int)} uses for the given password policy iterations.
* {@link #policyCheck(PasswordPolicy, PasswordCredentialModel)} must use the same, otherwise a freshly created hash
* is considered outdated and re-hashed after every successful login.
*/
private int expectedIterations(int policyIterations) {
return policyIterations == -1 || policyIterations > 100 ? this.iterations : policyIterations;
}

private boolean checkCredData(String key, int expectedValue, PasswordCredentialData data) {
String s = data.getAdditionalParameters().getFirst(key);
Integer v = s != null ? Integer.parseInt(s) : null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package org.keycloak.tests.model;

import org.keycloak.credential.CredentialModel;
import org.keycloak.credential.hash.PasswordHashProvider;
import org.keycloak.models.UserCredentialModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.credential.PasswordCredentialModel;
import org.keycloak.models.credential.dto.PasswordCredentialData;
import org.keycloak.testframework.annotations.InjectRealm;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.realm.ManagedRealm;
import org.keycloak.testframework.remote.runonserver.InjectRunOnServer;
import org.keycloak.testframework.remote.runonserver.RunOnServerClient;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;

@KeycloakIntegrationTest
public class Argon2PasswordHashTest {

private static final String PASSWORD = "Password-1";

@InjectRealm
ManagedRealm realm;

@InjectRunOnServer
RunOnServerClient runOnServer;

@Test
public void defaultParameters() {
String username = "argon2-defaults";
CredentialModel credential = createUserWithPolicy(username, "hashAlgorithm(argon2)");

PasswordCredentialData data = PasswordCredentialModel.createFromCredentialModel(credential).getPasswordCredentialData();
assertEquals("argon2", data.getAlgorithm());
assertEquals(5, data.getHashIterations());
assertEquals("1.3", data.getAdditionalParameters().getFirst("version"));
assertEquals("id", data.getAdditionalParameters().getFirst("type"));
assertEquals("32", data.getAdditionalParameters().getFirst("hashLength"));
assertEquals("7168", data.getAdditionalParameters().getFirst("memory"));
assertEquals("1", data.getAdditionalParameters().getFirst("parallelism"));

assertFalse(isPasswordValid(username, "invalid"));
assertTrue(isPasswordValid(username, PASSWORD));
}

@Test
public void successfulLoginDoesNotRehashPasswordWithoutPolicyIterations() {
String username = "argon2-no-policy-iterations";
CredentialModel original = createUserWithPolicy(username, "hashAlgorithm(argon2)");

assertTrue(isPasswordValid(username, PASSWORD));

assertEquals(original.getSecretData(), credential(username).getSecretData(), "Password hash created with the provider defaults must not be re-hashed on login");
}

@Test
public void policyIterationsAbove100AreIgnored() {
String username = "argon2-large-policy-iterations";
// 210000 is the PBKDF2 default, which is far too large for Argon2, so the provider default is used
CredentialModel original = createUserWithPolicy(username, "hashAlgorithm(argon2) and hashIterations(210000)");
assertEquals(5, iterations(original));

assertTrue(isPasswordValid(username, PASSWORD));

assertEquals(original.getSecretData(), credential(username).getSecretData(), "Password hash created with the provider default iterations must not be re-hashed on login");
}

@Test
public void successfulLoginRehashesPbkdf2PasswordWhenAlgorithmChangesToArgon2() {
String username = "argon2-from-pbkdf2";
CredentialModel original = createUserWithPolicy(username, "hashAlgorithm(pbkdf2-sha512)");
assertEquals("pbkdf2-sha512", algorithm(original));

setPolicy("hashAlgorithm(argon2)");
assertTrue(isPasswordValid(username, PASSWORD));

assertEquals("argon2", algorithm(credential(username)));
}

@Test
public void successfulLoginDoesNotRehashPasswordHashedWithPolicyIterations() {
String username = "argon2-up-to-date";
// the realm policy iterations differ from the Argon2 provider default, so the hash is created with them
CredentialModel original = createUserWithPolicy(username, "hashAlgorithm(argon2) and hashIterations(2)");
assertEquals(2, iterations(original));

assertTrue(isPasswordValid(username, PASSWORD));

// a re-hash would be visible as different secret data, as every hash uses a new random salt
assertEquals(original.getSecretData(), credential(username).getSecretData(), "Password hash matching the policy must not be re-hashed on login");
}

@Test
public void successfulLoginRehashesPasswordWhenPolicyIterationsChange() {
String username = "argon2-outdated";
CredentialModel original = createUserWithPolicy(username, "hashAlgorithm(argon2) and hashIterations(2)");
assertEquals(2, iterations(original));

setPolicy("hashAlgorithm(argon2) and hashIterations(3)");
assertTrue(isPasswordValid(username, PASSWORD));

CredentialModel rehashed = credential(username);
assertNotEquals(original.getSecretData(), rehashed.getSecretData(), "Password hash not matching the policy must be re-hashed on login");
assertEquals(3, iterations(rehashed));
}

private CredentialModel createUserWithPolicy(String username, String policy) {
assumeTrue(runOnServer.fetch(session -> session.getProvider(PasswordHashProvider.class, "argon2") != null, Boolean.class),
"Argon2 is unavailable in FIPS mode");
setPolicy(policy);
runOnServer.run(session -> {
UserModel user = session.users().addUser(session.getContext().getRealm(), username);
assertTrue(user.credentialManager().updateCredential(UserCredentialModel.password(PASSWORD)));
});
return credential(username);
}

// the policy is reset to the original one when the test completes
private void setPolicy(String policy) {
realm.updateWithCleanup(r -> r.passwordPolicy(policy));
}

private boolean isPasswordValid(String username, String password) {
return runOnServer.fetch(session -> session.users().getUserByUsername(session.getContext().getRealm(), username)
.credentialManager().isValid(UserCredentialModel.password(password)), Boolean.class);
}

private static String algorithm(CredentialModel credential) {
return PasswordCredentialModel.createFromCredentialModel(credential).getPasswordCredentialData().getAlgorithm();
}

private static int iterations(CredentialModel credential) {
return PasswordCredentialModel.createFromCredentialModel(credential).getPasswordCredentialData().getHashIterations();
}

private CredentialModel credential(String username) {
return runOnServer.fetch(session -> session.users().getUserByUsername(session.getContext().getRealm(), username)
.credentialManager().getStoredCredentialsByTypeStream(PasswordCredentialModel.TYPE).findFirst().orElseThrow(),
CredentialModel.class);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,11 @@
import org.keycloak.credential.hash.Pbkdf2Sha256PasswordHashProviderFactory;
import org.keycloak.credential.hash.Pbkdf2Sha512PasswordHashProviderFactory;
import org.keycloak.crypto.hash.Argon2Parameters;
import org.keycloak.crypto.hash.Argon2PasswordHashProviderFactory;
import org.keycloak.exportimport.util.ExportUtils;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.cache.UserCache;
import org.keycloak.models.credential.PasswordCredentialModel;
import org.keycloak.models.credential.dto.PasswordCredentialData;
import org.keycloak.models.jpa.entities.CredentialEntity;
import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.representations.idm.ErrorRepresentation;
Expand All @@ -64,7 +62,6 @@

import org.bouncycastle.crypto.generators.Argon2BytesGenerator;
import org.jboss.arquillian.graphene.page.Page;
import org.junit.Assume;
import org.junit.Test;
import org.junit.jupiter.api.Assertions;

Expand Down Expand Up @@ -283,35 +280,6 @@ public void testPbkdf2Sha1() throws Exception {
assertEncoded(credential, password, credential.getPasswordSecretData().getSalt(), "PBKDF2WithHmacSHA1", Pbkdf2PasswordHashProviderFactory.DEFAULT_ITERATIONS);
}

@Test
public void testArgon2() {
Assume.assumeTrue("Argon2 tests skipped in FIPS mode", notFips());

setPasswordPolicy("hashAlgorithm(" + Argon2PasswordHashProviderFactory.ID + ")");
String username = "testArgon2";
final String password = createUser(username);

PasswordCredentialModel credential = PasswordCredentialModel.createFromCredentialModel(fetchCredentials(username));
PasswordCredentialData data = credential.getPasswordCredentialData();

Assertions.assertEquals("argon2", data.getAlgorithm());
Assertions.assertEquals(5, data.getHashIterations());
Assertions.assertEquals("1.3", data.getAdditionalParameters().getFirst("version"));
Assertions.assertEquals("id", data.getAdditionalParameters().getFirst("type"));
Assertions.assertEquals("32", data.getAdditionalParameters().getFirst("hashLength"));
Assertions.assertEquals("7168", data.getAdditionalParameters().getFirst("memory"));
Assertions.assertEquals("1", data.getAdditionalParameters().getFirst("parallelism"));

oauth.openLoginForm();
loginPage.login("testArgon2", "invalid");
loginPage.assertCurrent();
Assertions.assertEquals("Invalid username or password.", loginPage.getInputError());

loginPage.login("testArgon2", password);

Assertions.assertTrue(oauth.parseLoginResponse().isSuccess());
}

private static boolean notFips() {
return AuthServerTestEnricher.AUTH_SERVER_FIPS_MODE == FipsMode.DISABLED;
}
Expand Down
Loading
0