Short passwords with PBKDF2 mode working (#14437)

* Short passwords with PBKDF2 mode working
Closes #14314

* Add config option to Pbkdf2 provider to control max padding

* Update according to PR review - more testing for padding and for non-fips mode
This commit is contained in:
Marek Posolda 2022-11-06 14:49:50 +01:00 committed by GitHub
parent e4a76bacb1
commit c0c0d3a6ba
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 407 additions and 54 deletions

View file

@ -0,0 +1,46 @@
/*
* Copyright 2022 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
*
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package org.keycloak.common.util;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class PaddingUtils {
private static final char PADDING_CHAR_NONE = '\u0000';
/**
* Applies padding to given string up to specified number of characters. If given string is shorter or same as maxPaddingLength, it will just return the original string.
* Otherwise it would be padded with "\0" character to have at least "maxPaddingLength" characters
*
* @param rawString raw string
* @param maxPaddingLength max padding length
* @return padded output
*/
public static String padding(String rawString, int maxPaddingLength) {
if (rawString.length() < maxPaddingLength) {
int nPad = maxPaddingLength - rawString.length();
StringBuilder result = new StringBuilder(rawString);
for (int i = 0 ; i < nPad; i++) result.append(PADDING_CHAR_NONE);
return result.toString();
} else
return rawString;
}
}

View file

@ -0,0 +1,39 @@
/*
* Copyright 2022 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
*
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package org.keycloak.common.util;
import org.junit.Assert;
import org.junit.Test;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class PaddingUtilsTest {
@Test
public void testPadding() {
Assert.assertEquals("foo123", PaddingUtils.padding("foo123", 5));
Assert.assertEquals("foo123", PaddingUtils.padding("foo123", 6));
Assert.assertEquals("foo123\0", PaddingUtils.padding("foo123", 7));
Assert.assertEquals("someLongPassword", PaddingUtils.padding("someLongPassword", 14));
Assert.assertEquals("short\0\0\0\0\0\0\0\0\0", PaddingUtils.padding("short", 14));
}
}

View file

@ -0,0 +1,83 @@
package org.keycloak.rule;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import org.junit.rules.TestRule;
import org.junit.runner.Description;
import org.junit.runners.model.Statement;
/**
* Runs every test method in it's own thread. Useful for example for
* testing bouncycastle FIPS (BCFIPS cannot switch bouncycastle to non-approved mode after it was switched before in approved mode in the same thread)
*
* Copy/paste from https://www.codeaffine.com/2014/07/21/a-junit-rule-to-run-a-test-in-its-own-thread/
*
* RunInThread an other accompanying files are licensed under the MIT
* license. Copyright (C) Frank Appel 2016-2021. All rights reserved
*/
public class RunInThreadRule implements TestRule {
@Override
public Statement apply(Statement base, Description description ) {
Statement result = base;
result = new RunInThreadStatement( base );
return result;
}
private static class RunInThreadStatement extends Statement {
private final Statement baseStatement;
private Future<?> future;
private volatile Throwable throwable;
RunInThreadStatement( Statement baseStatement ) {
this.baseStatement = baseStatement;
}
@Override
public void evaluate() throws Throwable {
ExecutorService executorService = runInThread();
try {
waitTillFinished();
} finally {
executorService.shutdown();
}
rethrowAssertionsAndErrors();
}
private ExecutorService runInThread() {
ExecutorService result = Executors.newSingleThreadExecutor();
future = result.submit( new Runnable() {
@Override
public void run() {
try {
baseStatement.evaluate();
} catch( Throwable throwable ) {
RunInThreadStatement.this.throwable = throwable;
}
}
} );
return result;
}
private void waitTillFinished() {
try {
future.get();
} catch (ExecutionException shouldNotHappen ) {
throw new IllegalStateException( shouldNotHappen );
} catch( InterruptedException shouldNotHappen ) {
throw new IllegalStateException( shouldNotHappen );
}
}
private void rethrowAssertionsAndErrors() throws Throwable {
if( throwable != null ) {
throw throwable;
}
}
}
}

View file

@ -0,0 +1,116 @@
package org.keycloak.crypto.fips.test;
import org.bouncycastle.crypto.CryptoServicesRegistrar;
import org.bouncycastle.crypto.fips.FipsUnapprovedOperationError;
import org.jboss.logging.Logger;
import org.junit.Assert;
import org.junit.Assume;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Rule;
import org.junit.Test;
import org.keycloak.Config;
import org.keycloak.common.util.Environment;
import org.keycloak.credential.hash.AbstractPbkdf2PasswordHashProviderFactory;
import org.keycloak.credential.hash.PasswordHashProvider;
import org.keycloak.credential.hash.PasswordHashSpi;
import org.keycloak.credential.hash.Pbkdf2Sha256PasswordHashProviderFactory;
import org.keycloak.models.credential.PasswordCredentialModel;
import org.keycloak.rule.CryptoInitRule;
import org.keycloak.rule.RunInThreadRule;
import static org.hamcrest.CoreMatchers.is;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class FIPS1402Pbkdf2PasswordPaddingTest {
private static final Logger logger = Logger.getLogger(FIPS1402SecureRandomTest.class);
private static final int ITERATIONS = 27500;
private static final int BC_FIPS_PADDING_LENGTH = 14;
@ClassRule
public static CryptoInitRule cryptoInitRule = new CryptoInitRule();
@Rule
public RunInThreadRule runInThread = new RunInThreadRule();
private static boolean defaultBcFipsApprovedMode;
@BeforeClass
public static void checkBcFipsApproved() {
defaultBcFipsApprovedMode = CryptoServicesRegistrar.isInApprovedOnlyMode();
}
@Before
public void before() {
// Run this test just if java is in FIPS mode
Assume.assumeTrue("Java is not in FIPS mode. Skipping the test.", Environment.isJavaInFipsMode());
Assert.assertEquals(defaultBcFipsApprovedMode, CryptoServicesRegistrar.isInApprovedOnlyMode());
}
@Test
public void testShortPassword() {
testPasswordVerification("short", false, BC_FIPS_PADDING_LENGTH);
}
@Test
public void testLongPassword() {
testPasswordVerification("someLongerPasswordThan14Chars", false, BC_FIPS_PADDING_LENGTH);
}
// Simulate the test for backwards compatibility - password created in non-approved mode should still work after server is restarted to approved mode
@Test
public void testShortPasswordWithSwitchToApprovedModel() {
testPasswordVerification("short", true, BC_FIPS_PADDING_LENGTH);
}
// Simulate the test for backwards compatibility - password created in non-approved mode should still work after server is restarted to approved mode
@Test
public void testLongPasswordWithSwitchToApprovedModel() {
testPasswordVerification("someLongerPasswordThan14Chars", true, BC_FIPS_PADDING_LENGTH);
}
@Test
public void testShortPasswordWithSwitchToApprovedModelAndWithoutPadding() {
try {
testPasswordVerification("short", true, 0);
Assert.fail("Password hashing should fail without padding in BCFIPS approved mode");
} catch (FipsUnapprovedOperationError expectedError) {
// Expected
}
}
// Simulate the test for backwards compatibility - password created in non-approved mode should still work after server is restarted to approved mode
@Test
public void testLongPasswordWithSwitchToApprovedModelAndWithoutPadding() {
testPasswordVerification("someLongerPasswordThan14Chars", true, 0);
}
private void testPasswordVerification(String password, boolean shouldEnableApprovedModeForVerification, int maxPaddingLength) {
Pbkdf2Sha256PasswordHashProviderFactory factory = new Pbkdf2Sha256PasswordHashProviderFactory();
System.setProperty("keycloak." + PasswordHashSpi.NAME + "." + Pbkdf2Sha256PasswordHashProviderFactory.ID + "." + AbstractPbkdf2PasswordHashProviderFactory.MAX_PADDING_LENGTH_PROPERTY,
String.valueOf(maxPaddingLength));
factory.init(Config.scope(PasswordHashSpi.NAME, Pbkdf2Sha256PasswordHashProviderFactory.ID));
PasswordHashProvider pbkdf2HashProvider = factory.create(null);
PasswordCredentialModel passwordCred = pbkdf2HashProvider.encodedCredential(password, ITERATIONS);
logger.infof("After password credential created. BC FIPS approved mode: %b, password: %s", CryptoServicesRegistrar.isInApprovedOnlyMode(), password);
if (shouldEnableApprovedModeForVerification) {
CryptoServicesRegistrar.setApprovedOnlyMode(true);
}
logger.infof("Before password verification. BC FIPS approved mode: %b, password: %s", CryptoServicesRegistrar.isInApprovedOnlyMode(), password);
Assert.assertThat(true, is(pbkdf2HashProvider.verify(password, passwordCred)));
}
}

View file

@ -0,0 +1,59 @@
/*
* Copyright 2022 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
*
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package org.keycloak.credential.hash;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSessionFactory;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public abstract class AbstractPbkdf2PasswordHashProviderFactory implements PasswordHashProviderFactory {
public static final String MAX_PADDING_LENGTH_PROPERTY = "max-padding-length";
// Minimum password length before password is encoded. If the provided password is shorter than the configured count of characters by this option,
// then the padding with '\0' character would be used. By default, it is 0, so no padding used.
// This can be used as for example in fips mode (BCFIPS), the pbkdf2 function does not allow less than 14 characters (112 bits).
// Regarding backwards compatibility, there is no issue with adding this option against already existing DB of passwords as password value without padding can be verified
// against the password with padding as it produces same encoded value.
private int maxPaddingLength = 0;
@Override
public void init(Config.Scope config) {
this.maxPaddingLength = config.getInt(MAX_PADDING_LENGTH_PROPERTY, 0);
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
public int getMaxPaddingLength() {
return maxPaddingLength;
}
public void setMaxPaddingLength(int maxPaddingLength) {
this.maxPaddingLength = maxPaddingLength;
}
}

View file

@ -26,6 +26,7 @@ import org.keycloak.provider.Spi;
*/
public class PasswordHashSpi implements Spi {
public static final String NAME = "password-hashing";
@Override
public boolean isInternal() {
return true;
@ -33,7 +34,7 @@ public class PasswordHashSpi implements Spi {
@Override
public String getName() {
return "password-hashing";
return NAME;
}
@Override

View file

@ -19,6 +19,7 @@ package org.keycloak.credential.hash;
import org.keycloak.common.crypto.CryptoIntegration;
import org.keycloak.common.util.Base64;
import org.keycloak.common.util.PaddingUtils;
import org.keycloak.models.PasswordPolicy;
import org.keycloak.models.credential.PasswordCredentialModel;
@ -40,16 +41,19 @@ public class Pbkdf2PasswordHashProvider implements PasswordHashProvider {
private final String pbkdf2Algorithm;
private final int defaultIterations;
private final int maxPaddingLength;
private final int derivedKeySize;
public static final int DEFAULT_DERIVED_KEY_SIZE = 512;
public Pbkdf2PasswordHashProvider(String providerId, String pbkdf2Algorithm, int defaultIterations) {
this(providerId, pbkdf2Algorithm, defaultIterations, DEFAULT_DERIVED_KEY_SIZE);
public Pbkdf2PasswordHashProvider(String providerId, String pbkdf2Algorithm, int defaultIterations, int minPbkdf2PasswordLengthForPadding) {
this(providerId, pbkdf2Algorithm, defaultIterations, minPbkdf2PasswordLengthForPadding, DEFAULT_DERIVED_KEY_SIZE);
}
public Pbkdf2PasswordHashProvider(String providerId, String pbkdf2Algorithm, int defaultIterations, int derivedKeySize) {
public Pbkdf2PasswordHashProvider(String providerId, String pbkdf2Algorithm, int defaultIterations, int maxPaddingLength, int derivedKeySize) {
this.providerId = providerId;
this.pbkdf2Algorithm = pbkdf2Algorithm;
this.defaultIterations = defaultIterations;
this.maxPaddingLength = maxPaddingLength;
this.derivedKeySize = derivedKeySize;
}
@ -105,7 +109,8 @@ public class Pbkdf2PasswordHashProvider implements PasswordHashProvider {
}
private String encodedCredential(String rawPassword, int iterations, byte[] salt, int derivedKeySize) {
KeySpec spec = new PBEKeySpec(rawPassword.toCharArray(), salt, iterations, derivedKeySize);
String rawPasswordWithPadding = PaddingUtils.padding(rawPassword, maxPaddingLength);
KeySpec spec = new PBEKeySpec(rawPasswordWithPadding.toCharArray(), salt, iterations, derivedKeySize);
try {
byte[] key = getSecretKeyFactory().generateSecret(spec).getEncoded();

View file

@ -17,14 +17,12 @@
package org.keycloak.credential.hash;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
/**
* @author <a href="mailto:me@tsudot.com">Kunal Kerkar</a>
*/
public class Pbkdf2PasswordHashProviderFactory implements PasswordHashProviderFactory {
public class Pbkdf2PasswordHashProviderFactory extends AbstractPbkdf2PasswordHashProviderFactory implements PasswordHashProviderFactory {
public static final String ID = "pbkdf2";
@ -34,23 +32,11 @@ public class Pbkdf2PasswordHashProviderFactory implements PasswordHashProviderFa
@Override
public PasswordHashProvider create(KeycloakSession session) {
return new Pbkdf2PasswordHashProvider(ID, PBKDF2_ALGORITHM, DEFAULT_ITERATIONS);
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
return new Pbkdf2PasswordHashProvider(ID, PBKDF2_ALGORITHM, DEFAULT_ITERATIONS, getMaxPaddingLength());
}
@Override
public String getId() {
return ID;
}
@Override
public void close() {
}
}

View file

@ -1,15 +1,13 @@
package org.keycloak.credential.hash;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
/**
* PBKDF2 Password Hash provider with HMAC using SHA256
*
* @author <a href"mailto:abkaplan07@gmail.com">Adam Kaplan</a>
*/
public class Pbkdf2Sha256PasswordHashProviderFactory implements PasswordHashProviderFactory {
public class Pbkdf2Sha256PasswordHashProviderFactory extends AbstractPbkdf2PasswordHashProviderFactory implements PasswordHashProviderFactory {
public static final String ID = "pbkdf2-sha256";
@ -19,23 +17,11 @@ public class Pbkdf2Sha256PasswordHashProviderFactory implements PasswordHashProv
@Override
public PasswordHashProvider create(KeycloakSession session) {
return new Pbkdf2PasswordHashProvider(ID, PBKDF2_ALGORITHM, DEFAULT_ITERATIONS);
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
return new Pbkdf2PasswordHashProvider(ID, PBKDF2_ALGORITHM, DEFAULT_ITERATIONS, getMaxPaddingLength());
}
@Override
public String getId() {
return ID;
}
@Override
public void close() {
}
}

View file

@ -1,15 +1,13 @@
package org.keycloak.credential.hash;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
/**
* Provider factory for SHA512 variant of the PBKDF2 password hash algorithm.
*
* @author @author <a href="mailto:abkaplan07@gmail.com">Adam Kaplan</a>
*/
public class Pbkdf2Sha512PasswordHashProviderFactory implements PasswordHashProviderFactory {
public class Pbkdf2Sha512PasswordHashProviderFactory extends AbstractPbkdf2PasswordHashProviderFactory implements PasswordHashProviderFactory {
public static final String ID = "pbkdf2-sha512";
@ -19,23 +17,11 @@ public class Pbkdf2Sha512PasswordHashProviderFactory implements PasswordHashProv
@Override
public PasswordHashProvider create(KeycloakSession session) {
return new Pbkdf2PasswordHashProvider(ID, PBKDF2_ALGORITHM, DEFAULT_ITERATIONS);
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
return new Pbkdf2PasswordHashProvider(ID, PBKDF2_ALGORITHM, DEFAULT_ITERATIONS, getMaxPaddingLength());
}
@Override
public String getId() {
return ID;
}
@Override
public void close() {
}
}

View file

@ -21,6 +21,7 @@ import org.junit.Test;
import org.keycloak.common.Profile;
import org.keycloak.common.util.Base64;
import org.keycloak.credential.CredentialModel;
import org.keycloak.credential.hash.PasswordHashProvider;
import org.keycloak.credential.hash.Pbkdf2PasswordHashProvider;
import org.keycloak.credential.hash.Pbkdf2PasswordHashProviderFactory;
import org.keycloak.credential.hash.Pbkdf2Sha256PasswordHashProviderFactory;
@ -46,6 +47,7 @@ import java.security.spec.KeySpec;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.fail;
/**
@ -168,6 +170,7 @@ public class PasswordHashingTest extends AbstractTestRealmKeycloakTest {
Pbkdf2PasswordHashProvider specificKeySizeHashProvider = new Pbkdf2PasswordHashProvider(Pbkdf2Sha512PasswordHashProviderFactory.ID,
Pbkdf2Sha512PasswordHashProviderFactory.PBKDF2_ALGORITHM,
Pbkdf2Sha512PasswordHashProviderFactory.DEFAULT_ITERATIONS,
0,
256);
String encodedPassword = specificKeySizeHashProvider.encode(password, -1);
@ -224,6 +227,32 @@ public class PasswordHashingTest extends AbstractTestRealmKeycloakTest {
assertEncoded(credential, "password", credential.getPasswordSecretData().getSalt(), "PBKDF2WithHmacSHA512", 30000);
}
@Test
public void testPbkdf2Sha256WithPadding() throws Exception {
setPasswordPolicy("hashAlgorithm(" + Pbkdf2Sha256PasswordHashProviderFactory.ID + ")");
int originalPaddingLength = configurePaddingForKeycloak(14);
try {
// Assert password created with padding enabled can be verified
String username1 = "test1-Pbkdf2Sha2562";
createUser(username1);
PasswordCredentialModel credential = PasswordCredentialModel.createFromCredentialModel(fetchCredentials(username1));
assertEncoded(credential, "password", credential.getPasswordSecretData().getSalt(), "PBKDF2WithHmacSHA256", 27500);
// Now configure padding to bigger than 64. The verification without padding would fail as for longer padding than 64 characters, the hashes of the padded password and unpadded password would be different
configurePaddingForKeycloak(65);
String username2 = "test2-Pbkdf2Sha2562";
createUser(username2);
credential = PasswordCredentialModel.createFromCredentialModel(fetchCredentials(username2));
assertEncoded(credential, "password", credential.getPasswordSecretData().getSalt(), "PBKDF2WithHmacSHA256", 27500, false);
} finally {
configurePaddingForKeycloak(originalPaddingLength);
}
}
private void createUser(String username) {
ApiUtil.createUserAndResetPasswordWithAdminClient(adminClient.realm("test"), UserBuilder.create().username(username).build(), "password");
@ -245,9 +274,26 @@ public class PasswordHashingTest extends AbstractTestRealmKeycloakTest {
}
private void assertEncoded(PasswordCredentialModel credential, String password, byte[] salt, String algorithm, int iterations) throws Exception {
assertEncoded(credential, password, salt, algorithm, iterations, true);
}
private void assertEncoded(PasswordCredentialModel credential, String password, byte[] salt, String algorithm, int iterations, boolean expectedSuccess) throws Exception {
KeySpec spec = new PBEKeySpec(password.toCharArray(), salt, iterations, 512);
byte[] key = SecretKeyFactory.getInstance(algorithm).generateSecret(spec).getEncoded();
assertEquals(Base64.encodeBytes(key), credential.getPasswordSecretData().getValue());
if (expectedSuccess) {
assertEquals(Base64.encodeBytes(key), credential.getPasswordSecretData().getValue());
} else {
assertNotEquals(Base64.encodeBytes(key), credential.getPasswordSecretData().getValue());
}
}
private int configurePaddingForKeycloak(int paddingLength) {
return testingClient.server("test").fetch(session -> {
Pbkdf2Sha256PasswordHashProviderFactory factory = (Pbkdf2Sha256PasswordHashProviderFactory) session.getKeycloakSessionFactory().getProviderFactory(PasswordHashProvider.class, Pbkdf2Sha256PasswordHashProviderFactory.ID);
int origPaddingLength = factory.getMaxPaddingLength();
factory.setMaxPaddingLength(paddingLength);
return origPaddingLength;
}, Integer.class);
}
}