From 396fec19a889d6104e3964fc16b507c75f37c436 Mon Sep 17 00:00:00 2001 From: Elisabeth Schulz Date: Mon, 29 Jun 2020 10:28:42 +0200 Subject: [PATCH] KEYCLOAK-13983 Include algorithm parameters Adds the capacity to add both public and secret algorithm specific data to a PasswordCredentialModel. The default way to create the models in maintained to minimize the change impact for the default hash infrastructure. Publishes the PasswordCredentialModel constructor to ease in building such extended credential models. --- .../credential/PasswordCredentialModel.java | 2 +- .../dto/PasswordCredentialData.java | 33 +++++++- .../credential/dto/PasswordSecretData.java | 24 +++++- .../credential/CredentialModelTest.java | 84 +++++++++++++++++++ 4 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 server-spi/src/test/java/org/keycloak/models/credential/CredentialModelTest.java 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 a0e2889816..50a3c25d4f 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 @@ -15,7 +15,7 @@ public class PasswordCredentialModel extends CredentialModel { private final PasswordCredentialData credentialData; private final PasswordSecretData secretData; - private PasswordCredentialModel(PasswordCredentialData credentialData, PasswordSecretData secretData) { + public PasswordCredentialModel(PasswordCredentialData credentialData, PasswordSecretData secretData) { this.credentialData = credentialData; this.secretData = secretData; } diff --git a/server-spi/src/main/java/org/keycloak/models/credential/dto/PasswordCredentialData.java b/server-spi/src/main/java/org/keycloak/models/credential/dto/PasswordCredentialData.java index e7753e9f38..3d1b98aa57 100644 --- a/server-spi/src/main/java/org/keycloak/models/credential/dto/PasswordCredentialData.java +++ b/server-spi/src/main/java/org/keycloak/models/credential/dto/PasswordCredentialData.java @@ -3,16 +3,38 @@ package org.keycloak.models.credential.dto; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Collections; +import java.util.Map; + public class PasswordCredentialData { private final int hashIterations; private final String algorithm; + private final Map algorithmData; + /** + * Creator for standard algorithms (no algorithm tuning beyond hash iterations) + * @param hashIterations iterations + * @param algorithm algorithm id + */ + public PasswordCredentialData(int hashIterations, String algorithm) { + this(hashIterations, algorithm, Collections.emptyMap()); + } + + /** + * Creator for custom algorithms (algorithm with tuning parameters beyond simple has iterations) + * @param hashIterations iterations + * @param algorithm algorithm id + * @param algorithmData additional tuning parameters + */ @JsonCreator - public PasswordCredentialData(@JsonProperty("hashIterations") int hashIterations, @JsonProperty("algorithm") String algorithm) { + public PasswordCredentialData(@JsonProperty("hashIterations") int hashIterations, @JsonProperty("algorithm") String algorithm, @JsonProperty("algorithmData") Map algorithmData) { this.hashIterations = hashIterations; this.algorithm = algorithm; + this.algorithmData = algorithmData == null ? Collections.emptyMap() : Collections.unmodifiableMap(algorithmData); } + + public int getHashIterations() { return hashIterations; } @@ -20,4 +42,13 @@ public class PasswordCredentialData { public String getAlgorithm() { return algorithm; } + + /** + * Returns an immutable map of algorithm-specific settings. These settings may include additional + * parameters such as Bcrypt memory-tuning parameters + * @return algorithm data + */ + public Map getAlgorithmData() { + return algorithmData; + } } diff --git a/server-spi/src/main/java/org/keycloak/models/credential/dto/PasswordSecretData.java b/server-spi/src/main/java/org/keycloak/models/credential/dto/PasswordSecretData.java index dec870c5ef..c14433cd1c 100644 --- a/server-spi/src/main/java/org/keycloak/models/credential/dto/PasswordSecretData.java +++ b/server-spi/src/main/java/org/keycloak/models/credential/dto/PasswordSecretData.java @@ -1,6 +1,8 @@ package org.keycloak.models.credential.dto; import java.io.IOException; +import java.util.Collections; +import java.util.Map; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; @@ -13,9 +15,19 @@ public class PasswordSecretData { private final String value; private final byte[] salt; + private final Map algorithmData; + /** + * Creator with the option to provide customized secret data (multiple salt values, chiefly) + * @param value hash value + * @param salt salt value + * @param algorithmData additional data required by the algorithm + * @throws IOException invalid base64 in salt value + */ @JsonCreator - public PasswordSecretData(@JsonProperty("value") String value, @JsonProperty("salt") String salt) throws IOException { + public PasswordSecretData(@JsonProperty("value") String value, @JsonProperty("salt") String salt, @JsonProperty("algorithmData") Map algorithmData) throws IOException { + this.algorithmData = algorithmData == null ? Collections.emptyMap() : Collections.unmodifiableMap(algorithmData); + if (salt == null || "__SALT__".equals(salt)) { this.value = value; this.salt = null; @@ -26,9 +38,15 @@ public class PasswordSecretData { } } + /** + * Default creator (Secret consists only of a value and a single salt) + * @param value hash value + * @param salt salt + */ public PasswordSecretData(String value, byte[] salt) { this.value = value; this.salt = salt; + this.algorithmData = Collections.emptyMap(); } public String getValue() { @@ -38,4 +56,8 @@ public class PasswordSecretData { public byte[] getSalt() { return salt; } + + public Map getAlgorithmData() { + return algorithmData; + } } diff --git a/server-spi/src/test/java/org/keycloak/models/credential/CredentialModelTest.java b/server-spi/src/test/java/org/keycloak/models/credential/CredentialModelTest.java new file mode 100644 index 0000000000..4808f68fd3 --- /dev/null +++ b/server-spi/src/test/java/org/keycloak/models/credential/CredentialModelTest.java @@ -0,0 +1,84 @@ +package org.keycloak.models.credential; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Test; +import org.keycloak.models.credential.dto.PasswordCredentialData; +import org.keycloak.models.credential.dto.PasswordSecretData; + +import java.io.IOException; +import java.lang.reflect.Array; +import java.util.Arrays; +import java.util.Collections; + +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.MatcherAssert.assertThat; + +public class CredentialModelTest { + + private ObjectMapper mapper = new ObjectMapper(); + + @Test + public void canCreateDefaultCredentialModel() { + PasswordCredentialModel model = PasswordCredentialModel.createFromValues("pbkdf2", new byte[32], 1000, "secretValue"); + + assertThat(model.getPasswordCredentialData(), notNullValue()); + assertThat(model.getPasswordCredentialData().getAlgorithm(), equalTo("pbkdf2")); + assertThat(model.getPasswordCredentialData().getHashIterations(), equalTo(1000)); + assertThat(model.getPasswordCredentialData().getAlgorithmData(), equalTo(Collections.emptyMap())); + + assertThat(model.getPasswordSecretData(), notNullValue()); + assertThat(model.getPasswordSecretData().getAlgorithmData(), equalTo(Collections.emptyMap())); + assertThat(model.getPasswordSecretData().getValue(), equalTo("secretValue")); + assertThat(Arrays.equals(model.getPasswordSecretData().getSalt(), new byte[32]), is(true)); + } + + @Test + public void canCreatedExtendedCredentialModel() throws IOException { + PasswordCredentialData credentialData = new PasswordCredentialData(1000, "bcrypt", Collections.singletonMap("cost", "18")); + PasswordSecretData secretData = new PasswordSecretData("secretValue", "AAAAAAAAAAAAAAAA", Collections.singletonMap("salt2", "BBBBBBBBBBBBBBBB")); + PasswordCredentialModel model = new PasswordCredentialModel(credentialData, secretData); + + assertThat(model.getPasswordCredentialData(), notNullValue()); + assertThat(model.getPasswordCredentialData().getAlgorithm(), equalTo("bcrypt")); + assertThat(model.getPasswordCredentialData().getHashIterations(), equalTo(1000)); + assertThat(model.getPasswordCredentialData().getAlgorithmData(), equalTo(Collections.singletonMap("cost", "18"))); + + assertThat(model.getPasswordSecretData(), notNullValue()); + assertThat(model.getPasswordSecretData().getAlgorithmData(), equalTo(Collections.singletonMap("salt2", "BBBBBBBBBBBBBBBB"))); + assertThat(model.getPasswordSecretData().getValue(), equalTo("secretValue")); + assertThat(Arrays.equals(model.getPasswordSecretData().getSalt(), new byte[12]), is(true)); + } + + @Test + public void roundtripToJsonDefaultCredentialModel() throws IOException { + PasswordCredentialModel model = PasswordCredentialModel.createFromValues("pbkdf2", new byte[32], 1000, "secretValue"); + roundTripAndVerify(model); + } + + + private void roundTripAndVerify(PasswordCredentialModel model) throws IOException { + PasswordCredentialData pcdOriginal = model.getPasswordCredentialData(); + PasswordCredentialData pcdRoundtrip = mapper.readValue(mapper.writeValueAsString(pcdOriginal), PasswordCredentialData.class); + + assertThat(pcdRoundtrip.getAlgorithmData(), equalTo(pcdOriginal.getAlgorithmData())); + assertThat(pcdRoundtrip.getAlgorithm(), equalTo(pcdOriginal.getAlgorithm())); + assertThat(pcdRoundtrip.getHashIterations(), equalTo(pcdOriginal.getHashIterations())); + + PasswordSecretData psdOriginal = model.getPasswordSecretData(); + PasswordSecretData psdRoundtrip = mapper.readValue(mapper.writeValueAsString(psdOriginal), PasswordSecretData.class); + + assertThat(psdRoundtrip.getValue(), equalTo(psdOriginal.getValue())); + assertThat(psdRoundtrip.getSalt(), equalTo(psdOriginal.getSalt())); + assertThat(psdRoundtrip.getAlgorithmData(), equalTo(psdRoundtrip.getAlgorithmData())); + } + + @Test + public void roudtripToJsonExtendedCredentialModel() throws IOException { + PasswordCredentialData credentialData = new PasswordCredentialData(1000, "bcrypt", Collections.singletonMap("cost", "18")); + PasswordSecretData secretData = new PasswordSecretData("secretValue", "AAAAAAAAAAAAAAAA", Collections.singletonMap("salt2", "BBBBBBBBBBBBBBBB")); + PasswordCredentialModel model = new PasswordCredentialModel(credentialData, secretData); + + roundTripAndVerify(model); + } + +}