diff --git a/crypto/src/main/java/org/springframework/security/crypto/argon2/Argon2EncodingUtils.java b/crypto/src/main/java/org/springframework/security/crypto/argon2/Argon2EncodingUtils.java new file mode 100644 index 00000000000..9c1920e2bf4 --- /dev/null +++ b/crypto/src/main/java/org/springframework/security/crypto/argon2/Argon2EncodingUtils.java @@ -0,0 +1,174 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * 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 + * + * https://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.springframework.security.crypto.argon2; + +import java.util.Base64; +import org.bouncycastle.crypto.params.Argon2Parameters; +import org.bouncycastle.util.Arrays; + +/** + * Utility for encoding and decoding Argon2 hashes. + * + * Used by {@link Argon2PasswordEncoder}. + * + * @author Simeon Macke + * @since 5.3 + */ +class Argon2EncodingUtils { + private static final Base64.Encoder b64encoder = Base64.getEncoder().withoutPadding(); + private static final Base64.Decoder b64decoder = Base64.getDecoder(); + + /** + * Encodes a raw Argon2-hash and its parameters into the standard Argon2-hash-string as specified in the reference + * implementation (https://github.com/P-H-C/phc-winner-argon2/blob/master/src/encoding.c#L244): + * + * {@code $argon2[$v=]$m=,t=,p=$$} + * + * where {@code } is either 'd', 'id', or 'i', {@code } is a decimal integer (positive, + * fits in an 'unsigned long'), and {@code } is Base64-encoded data (no '=' padding + * characters, no newline or whitespace). + * + * The last two binary chunks (encoded in Base64) are, in that order, + * the salt and the output. If no salt has been used, the salt will be omitted. + * + * @param hash the raw Argon2 hash in binary format + * @param parameters the Argon2 parameters that were used to create the hash + * @return the encoded Argon2-hash-string as described above + * @throws IllegalArgumentException if the Argon2Parameters are invalid + */ + public static String encode(byte[] hash, Argon2Parameters parameters) throws IllegalArgumentException { + StringBuilder stringBuilder = new StringBuilder(); + + switch (parameters.getType()) { + case Argon2Parameters.ARGON2_d: stringBuilder.append("$argon2d"); break; + case Argon2Parameters.ARGON2_i: stringBuilder.append("$argon2i"); break; + case Argon2Parameters.ARGON2_id: stringBuilder.append("$argon2id"); break; + default: throw new IllegalArgumentException("Invalid algorithm type: "+parameters.getType()); + } + stringBuilder.append("$v=").append(parameters.getVersion()) + .append("$m=").append(parameters.getMemory()) + .append(",t=").append(parameters.getIterations()) + .append(",p=").append(parameters.getLanes()); + + if (parameters.getSalt() != null) { + stringBuilder.append("$") + .append(b64encoder.encodeToString(parameters.getSalt())); + } + + stringBuilder.append("$") + .append(b64encoder.encodeToString(hash)); + + return stringBuilder.toString(); + } + + /** + * Decodes an Argon2 hash string as specified in the reference implementation + * (https://github.com/P-H-C/phc-winner-argon2/blob/master/src/encoding.c#L244) into the raw hash and the used + * parameters. + * + * The hash has to be formatted as follows: + * {@code $argon2[$v=]$m=,t=,p=$$} + * + * where {@code } is either 'd', 'id', or 'i', {@code } is a decimal integer (positive, + * fits in an 'unsigned long'), and {@code } is Base64-encoded data (no '=' padding + * characters, no newline or whitespace). + * + * The last two binary chunks (encoded in Base64) are, in that order, + * the salt and the output. Both are required. The binary salt length and the + * output length must be in the allowed ranges defined in argon2.h. + * @param encodedHash the Argon2 hash string as described above + * @return an {@link Argon2Hash} object containing the raw hash and the {@link Argon2Parameters}. + * @throws IllegalArgumentException if the encoded hash is malformed + */ + public static Argon2Hash decode(String encodedHash) throws IllegalArgumentException { + Argon2Parameters.Builder paramsBuilder; + + String[] parts = encodedHash.split("\\$"); + + if (parts.length < 4) { + throw new IllegalArgumentException("Invalid encoded Argon2-hash"); + } + + int currentPart = 1; + + switch (parts[currentPart++]) { + case "argon2d": paramsBuilder = new Argon2Parameters.Builder(Argon2Parameters.ARGON2_d); break; + case "argon2i": paramsBuilder = new Argon2Parameters.Builder(Argon2Parameters.ARGON2_i); break; + case "argon2id": paramsBuilder = new Argon2Parameters.Builder(Argon2Parameters.ARGON2_id); break; + default: throw new IllegalArgumentException("Invalid algorithm type: "+parts[0]); + } + + if (parts[currentPart].startsWith("v=")) { + paramsBuilder.withVersion(Integer.parseInt(parts[currentPart].substring(2))); + currentPart++; + } + + String[] performanceParams = parts[currentPart++].split(","); + + if (performanceParams.length != 3) { + throw new IllegalArgumentException("Amount of performance parameters invalid"); + } + + if (performanceParams[0].startsWith("m=")) { + paramsBuilder.withMemoryAsKB(Integer.parseInt(performanceParams[0].substring(2))); + } else { + throw new IllegalArgumentException("Invalid memory parameter"); + } + + if (performanceParams[1].startsWith("t=")) { + paramsBuilder.withIterations(Integer.parseInt(performanceParams[1].substring(2))); + } else { + throw new IllegalArgumentException("Invalid iterations parameter"); + } + + if (performanceParams[2].startsWith("p=")) { + paramsBuilder.withParallelism(Integer.parseInt(performanceParams[2].substring(2))); + } else { + throw new IllegalArgumentException("Invalid parallelity parameter"); + } + + paramsBuilder.withSalt(b64decoder.decode(parts[currentPart++])); + + return new Argon2Hash(b64decoder.decode(parts[currentPart]), paramsBuilder.build()); + } + + public static class Argon2Hash { + + private byte[] hash; + private Argon2Parameters parameters; + + Argon2Hash(byte[] hash, Argon2Parameters parameters) { + this.hash = Arrays.clone(hash); + this.parameters = parameters; + } + + public byte[] getHash() { + return Arrays.clone(hash); + } + + public void setHash(byte[] hash) { + this.hash = Arrays.clone(hash); + } + + public Argon2Parameters getParameters() { + return parameters; + } + + public void setParameters(Argon2Parameters parameters) { + this.parameters = parameters; + } + } +} diff --git a/crypto/src/main/java/org/springframework/security/crypto/argon2/Argon2PasswordEncoder.java b/crypto/src/main/java/org/springframework/security/crypto/argon2/Argon2PasswordEncoder.java new file mode 100644 index 00000000000..0e38c1072b1 --- /dev/null +++ b/crypto/src/main/java/org/springframework/security/crypto/argon2/Argon2PasswordEncoder.java @@ -0,0 +1,140 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * 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 + * + * https://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.springframework.security.crypto.argon2; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.bouncycastle.crypto.generators.Argon2BytesGenerator; +import org.bouncycastle.crypto.params.Argon2Parameters; +import org.springframework.security.crypto.keygen.BytesKeyGenerator; +import org.springframework.security.crypto.keygen.KeyGenerators; +import org.springframework.security.crypto.password.PasswordEncoder; + +/** + *

+ * Implementation of PasswordEncoder that uses the Argon2 hashing function. + * Clients can optionally supply the length of the salt to use, the length + * of the generated hash, a cpu cost parameter, a memory cost parameter + * and a parallelization parameter. + *

+ * + *

Note:

+ *

The currently implementation uses Bouncy castle which does not exploit + * parallelism/optimizations that password crackers will, so there is an + * unnecessary asymmetry between attacker and defender.

+ * + * @author Simeon Macke + * @since 5.3 + */ +public class Argon2PasswordEncoder implements PasswordEncoder { + + private static final int DEFAULT_SALT_LENGTH = 16; + private static final int DEFAULT_HASH_LENGTH = 32; + private static final int DEFAULT_PARALLELISM = 1; + private static final int DEFAULT_MEMORY = 1 << 12; + private static final int DEFAULT_ITERATIONS = 3; + + private final Log logger = LogFactory.getLog(getClass()); + + private final int hashLength; + private final int parallelism; + private final int memory; + private final int iterations; + + private final BytesKeyGenerator saltGenerator; + + public Argon2PasswordEncoder(int saltLength, int hashLength, int parallelism, int memory, int iterations) { + this.hashLength = hashLength; + this.parallelism = parallelism; + this.memory = memory; + this.iterations = iterations; + + this.saltGenerator = KeyGenerators.secureRandom(saltLength); + } + + public Argon2PasswordEncoder() { + this(DEFAULT_SALT_LENGTH, DEFAULT_HASH_LENGTH, DEFAULT_PARALLELISM, DEFAULT_MEMORY, DEFAULT_ITERATIONS); + } + + @Override + public String encode(CharSequence rawPassword) { + byte[] salt = saltGenerator.generateKey(); + byte[] hash = new byte[hashLength]; + + Argon2Parameters params = new Argon2Parameters.Builder(Argon2Parameters.ARGON2_id). + withSalt(salt). + withParallelism(parallelism). + withMemoryAsKB(memory). + withIterations(iterations). + build(); + Argon2BytesGenerator generator = new Argon2BytesGenerator(); + generator.init(params); + generator.generateBytes(rawPassword.toString().toCharArray(), hash); + + return Argon2EncodingUtils.encode(hash, params); + } + + @Override + public boolean matches(CharSequence rawPassword, String encodedPassword) { + if (encodedPassword == null) { + logger.warn("password hash is null"); + return false; + } + + Argon2EncodingUtils.Argon2Hash decoded; + + try { + decoded = Argon2EncodingUtils.decode(encodedPassword); + } catch (IllegalArgumentException e) { + logger.warn("Malformed password hash", e); + return false; + } + + byte[] hashBytes = new byte[decoded.getHash().length]; + + Argon2BytesGenerator generator = new Argon2BytesGenerator(); + generator.init(decoded.getParameters()); + generator.generateBytes(rawPassword.toString().toCharArray(), hashBytes); + + return constantTimeArrayEquals(decoded.getHash(), hashBytes); + } + + @Override + public boolean upgradeEncoding(String encodedPassword) { + if (encodedPassword == null || encodedPassword.length() == 0) { + logger.warn("password hash is null"); + return false; + } + + Argon2Parameters parameters = Argon2EncodingUtils.decode(encodedPassword).getParameters(); + + return parameters.getMemory() < this.memory || parameters.getIterations() < this.iterations; + } + + private static boolean constantTimeArrayEquals(byte[] expected, byte[] actual) { + if (expected.length != actual.length) { + return false; + } + + int result = 0; + for (int i = 0; i < expected.length; i++) { + result |= expected[i] ^ actual[i]; + } + return result == 0; + } + +} diff --git a/crypto/src/main/java/org/springframework/security/crypto/factory/PasswordEncoderFactories.java b/crypto/src/main/java/org/springframework/security/crypto/factory/PasswordEncoderFactories.java index fa7fd2fdf61..c0a54ba2cf0 100644 --- a/crypto/src/main/java/org/springframework/security/crypto/factory/PasswordEncoderFactories.java +++ b/crypto/src/main/java/org/springframework/security/crypto/factory/PasswordEncoderFactories.java @@ -16,6 +16,7 @@ package org.springframework.security.crypto.factory; +import org.springframework.security.crypto.argon2.Argon2PasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.DelegatingPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @@ -49,6 +50,7 @@ public class PasswordEncoderFactories { *
  • SHA-1 - {@code new MessageDigestPasswordEncoder("SHA-1")}
  • *
  • SHA-256 - {@code new MessageDigestPasswordEncoder("SHA-256")}
  • *
  • sha256 - {@link org.springframework.security.crypto.password.StandardPasswordEncoder}
  • + *
  • argon2 - {@link Argon2PasswordEncoder}
  • * * * @return the {@link PasswordEncoder} to use @@ -67,6 +69,7 @@ public static PasswordEncoder createDelegatingPasswordEncoder() { encoders.put("SHA-1", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-1")); encoders.put("SHA-256", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-256")); encoders.put("sha256", new org.springframework.security.crypto.password.StandardPasswordEncoder()); + encoders.put("argon2", new Argon2PasswordEncoder()); return new DelegatingPasswordEncoder(encodingId, encoders); } diff --git a/crypto/src/test/java/org/springframework/security/crypto/argon2/Argon2EncodingUtilsTests.java b/crypto/src/test/java/org/springframework/security/crypto/argon2/Argon2EncodingUtilsTests.java new file mode 100644 index 00000000000..41bc1d5a245 --- /dev/null +++ b/crypto/src/test/java/org/springframework/security/crypto/argon2/Argon2EncodingUtilsTests.java @@ -0,0 +1,150 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * 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 + * + * https://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.springframework.security.crypto.argon2; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Base64; +import org.bouncycastle.crypto.params.Argon2Parameters; +import org.junit.Test; + +/** + * @author Simeon Macke + */ +public class Argon2EncodingUtilsTests { + + private final Base64.Decoder decoder = Base64.getDecoder(); + + private TestDataEntry testDataEntry1 = new TestDataEntry( + "$argon2i$v=19$m=1024,t=3,p=2$Y1JkRmJDdzIzZ3oyTWx4aw$cGE5Cbd/cx7micVhXVBdH5qTr66JI1iUyuNNVAnErXs", + new Argon2EncodingUtils.Argon2Hash(decoder.decode("cGE5Cbd/cx7micVhXVBdH5qTr66JI1iUyuNNVAnErXs"), + (new Argon2Parameters.Builder(Argon2Parameters.ARGON2_i)). + withVersion(19).withMemoryAsKB(1024).withIterations(3).withParallelism(2). + withSalt("cRdFbCw23gz2Mlxk".getBytes()).build() + )); + + private TestDataEntry testDataEntry2 = new TestDataEntry( + "$argon2id$v=19$m=333,t=5,p=2$JDR8N3k1QWx0$+PrEoHOHsWkU9lnsxqnOFrWTVEuOh7ZRIUIbe2yUG8FgTYNCWJfHQI09JAAFKzr2JAvoejEpTMghUt0WsntQYA", + new Argon2EncodingUtils.Argon2Hash(decoder.decode("+PrEoHOHsWkU9lnsxqnOFrWTVEuOh7ZRIUIbe2yUG8FgTYNCWJfHQI09JAAFKzr2JAvoejEpTMghUt0WsntQYA"), + (new Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)). + withVersion(19).withMemoryAsKB(333).withIterations(5).withParallelism(2). + withSalt("$4|7y5Alt".getBytes()).build() + )); + + @Test + public void decodeWhenValidEncodedHashWithIThenDecodeCorrectly() throws Exception { + assertArgon2HashEquals(testDataEntry1.decoded, Argon2EncodingUtils.decode(testDataEntry1.encoded)); + } + + @Test + public void decodeWhenValidEncodedHashWithIDThenDecodeCorrectly() throws Exception { + assertArgon2HashEquals(testDataEntry2.decoded, Argon2EncodingUtils.decode(testDataEntry2.encoded)); + } + + @Test + public void encodeWhenValidArgumentsWithIThenEncodeToCorrectHash() throws Exception { + assertThat(Argon2EncodingUtils + .encode(testDataEntry1.decoded.getHash(), testDataEntry1.decoded.getParameters())) + .isEqualTo(testDataEntry1.encoded); + } + + @Test + public void encodeWhenValidArgumentsWithID2ThenEncodeToCorrectHash() throws Exception { + assertThat(Argon2EncodingUtils + .encode(testDataEntry2.decoded.getHash(), testDataEntry2.decoded.getParameters())) + .isEqualTo(testDataEntry2.encoded); + } + + @Test(expected = IllegalArgumentException.class) + public void encodeWhenNonexistingAlgorithmThenThrowException() { + Argon2EncodingUtils.encode(new byte[]{0, 1, 2, 3}, (new Argon2Parameters.Builder(3)). + withVersion(19).withMemoryAsKB(333).withIterations(5).withParallelism(2).build()); + } + + @Test(expected = IllegalArgumentException.class) + public void decodeWhenNotAnArgon2HashThenThrowException() { + Argon2EncodingUtils.decode("notahash"); + } + + @Test(expected = IllegalArgumentException.class) + public void decodeWhenNonexistingAlgorithmThenThrowException() { + Argon2EncodingUtils.decode("$argon2x$v=19$m=1024,t=3,p=2$Y1JkRmJDdzIzZ3oyTWx4aw$cGE5Cbd/cx7micVhXVBdH5qTr66JI1iUyuNNVAnErXs"); + } + + @Test(expected = IllegalArgumentException.class) + public void decodeWhenIllegalVersionParameterThenThrowException() { + Argon2EncodingUtils.decode("$argon2i$v=x$m=1024,t=3,p=2$Y1JkRmJDdzIzZ3oyTWx4aw$cGE5Cbd/cx7micVhXVBdH5qTr66JI1iUyuNNVAnErXs"); + } + + @Test(expected = IllegalArgumentException.class) + public void decodeWhenIllegalMemoryParameterThenThrowException() { + Argon2EncodingUtils.decode("$argon2i$v=19$m=x,t=3,p=2$Y1JkRmJDdzIzZ3oyTWx4aw$cGE5Cbd/cx7micVhXVBdH5qTr66JI1iUyuNNVAnErXs"); + } + + @Test(expected = IllegalArgumentException.class) + public void decodeWhenIllegalIterationsParameterThenThrowException() { + Argon2EncodingUtils.decode("$argon2i$v=19$m=1024,t=x,p=2$Y1JkRmJDdzIzZ3oyTWx4aw$cGE5Cbd/cx7micVhXVBdH5qTr66JI1iUyuNNVAnErXs"); + } + + @Test(expected = IllegalArgumentException.class) + public void decodeWhenIllegalParallelityParameterThenThrowException() { + Argon2EncodingUtils.decode("$argon2i$v=19$m=1024,t=3,p=x$Y1JkRmJDdzIzZ3oyTWx4aw$cGE5Cbd/cx7micVhXVBdH5qTr66JI1iUyuNNVAnErXs"); + } + + @Test(expected = IllegalArgumentException.class) + public void decodeWhenMissingVersionParameterThenThrowException() { + Argon2EncodingUtils.decode("$argon2i$m=1024,t=3,p=x$Y1JkRmJDdzIzZ3oyTWx4aw$cGE5Cbd/cx7micVhXVBdH5qTr66JI1iUyuNNVAnErXs"); + } + + @Test(expected = IllegalArgumentException.class) + public void decodeWhenMissingMemoryParameterThenThrowException() { + Argon2EncodingUtils.decode("$argon2i$v=19$t=3,p=2$Y1JkRmJDdzIzZ3oyTWx4aw$cGE5Cbd/cx7micVhXVBdH5qTr66JI1iUyuNNVAnErXs"); + } + + @Test(expected = IllegalArgumentException.class) + public void decodeWhenMissingIterationsParameterThenThrowException() { + Argon2EncodingUtils.decode("$argon2i$v=19$m=1024,p=2$Y1JkRmJDdzIzZ3oyTWx4aw$cGE5Cbd/cx7micVhXVBdH5qTr66JI1iUyuNNVAnErXs"); + } + + @Test(expected = IllegalArgumentException.class) + public void decodeWhenMissingParallelityParameterThenThrowException() { + Argon2EncodingUtils.decode("$argon2i$v=19$m=1024,t=3$Y1JkRmJDdzIzZ3oyTWx4aw$cGE5Cbd/cx7micVhXVBdH5qTr66JI1iUyuNNVAnErXs"); + } + + private void assertArgon2HashEquals(Argon2EncodingUtils.Argon2Hash expected, Argon2EncodingUtils.Argon2Hash actual) { + assertThat(actual.getHash()).isEqualTo(expected.getHash()); + assertThat(actual.getParameters().getSalt()).isEqualTo(expected.getParameters().getSalt()); + assertThat(actual.getParameters().getType()).isEqualTo(expected.getParameters().getType()); + assertThat(actual.getParameters().getVersion()) + .isEqualTo(expected.getParameters().getVersion()); + assertThat(actual.getParameters().getMemory()) + .isEqualTo(expected.getParameters().getMemory()); + assertThat(actual.getParameters().getIterations()) + .isEqualTo(expected.getParameters().getIterations()); + assertThat(actual.getParameters().getLanes()) + .isEqualTo(expected.getParameters().getLanes()); + } + + private static class TestDataEntry { + String encoded; + Argon2EncodingUtils.Argon2Hash decoded; + + TestDataEntry(String encoded, Argon2EncodingUtils.Argon2Hash decoded) { + this.encoded = encoded; + this.decoded = decoded; + } + } +} diff --git a/crypto/src/test/java/org/springframework/security/crypto/argon2/Argon2PasswordEncoderTests.java b/crypto/src/test/java/org/springframework/security/crypto/argon2/Argon2PasswordEncoderTests.java new file mode 100644 index 00000000000..a63cb5ed121 --- /dev/null +++ b/crypto/src/test/java/org/springframework/security/crypto/argon2/Argon2PasswordEncoderTests.java @@ -0,0 +1,214 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * 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 + * + * https://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.springframework.security.crypto.argon2; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Field; +import java.util.Arrays; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.security.crypto.keygen.BytesKeyGenerator; + +/** + * @author Simeon Macke + */ +@RunWith(MockitoJUnitRunner.class) +public class Argon2PasswordEncoderTests { + + @Mock + private BytesKeyGenerator keyGeneratorMock; + + private Argon2PasswordEncoder encoder = new Argon2PasswordEncoder(); + + @Test + public void encodeDoesNotEqualPassword() { + String result = encoder.encode("password"); + assertThat(result).isNotEqualTo("password"); + } + + @Test + public void encodeWhenEqualPasswordThenMatches() { + String result = encoder.encode("password"); + assertThat(encoder.matches("password", result)).isTrue(); + } + + @Test + public void encodeWhenEqualWithUnicodeThenMatches() { + String result = encoder.encode("passw\u9292rd"); + assertThat(encoder.matches("pass\u9292\u9292rd", result)).isFalse(); + assertThat(encoder.matches("passw\u9292rd", result)).isTrue(); + } + + @Test + public void encodeWhenNotEqualThenNotMatches() { + String result = encoder.encode("password"); + assertThat(encoder.matches("bogus", result)).isFalse(); + } + + @Test + public void encodeWhenEqualPasswordWithCustomParamsThenMatches() { + encoder = new Argon2PasswordEncoder(20, 64, 4, 256, 4); + String result = encoder.encode("password"); + assertThat(encoder.matches("password", result)).isTrue(); + } + + @Test + public void encodeWhenRanTwiceThenResultsNotEqual() { + String password = "secret"; + assertThat(encoder.encode(password)).isNotEqualTo(encoder.encode(password)); + } + + @Test + public void encodeWhenRanTwiceWithCustomParamsThenNotEquals() { + encoder = new Argon2PasswordEncoder(20, 64, 4, 256, 4); + String password = "secret"; + assertThat(encoder.encode(password)).isNotEqualTo(encoder.encode(password)); + } + + @Test + public void matchesWhenGeneratedWithDifferentEncoderThenTrue() { + Argon2PasswordEncoder oldEncoder = new Argon2PasswordEncoder(20, 64, 4, 256, 4); + Argon2PasswordEncoder newEncoder = new Argon2PasswordEncoder(); + + String password = "secret"; + String oldEncodedPassword = oldEncoder.encode(password); + assertThat(newEncoder.matches(password, oldEncodedPassword)).isTrue(); + } + + @Test + public void matchesWhenEncodedPassIsNullThenFalse() { + assertThat(encoder.matches("password", null)).isFalse(); + } + + @Test + public void matchesWhenEncodedPassIsEmptyThenFalse() { + assertThat(encoder.matches("password", "")).isFalse(); + } + + @Test + public void matchesWhenEncodedPassIsBogusThenFalse() { + assertThat(encoder.matches("password", "012345678901234567890123456789")).isFalse(); + } + + @Test + public void encodeWhenUsingPredictableSaltThenEqualTestHash() throws Exception { + injectPredictableSaltGen(); + + String hash = encoder.encode("sometestpassword"); + + assertThat(hash).isEqualTo( + "$argon2id$v=19$m=4096,t=3,p=1$QUFBQUFBQUFBQUFBQUFBQQ$hmmTNyJlwbb6HAvFoHFWF+u03fdb0F2qA+39oPlcAqo"); + } + + @Test + public void encodeWhenUsingPredictableSaltWithCustomParamsThenEqualTestHash() throws Exception { + encoder = new Argon2PasswordEncoder(16, 32, 4, 512, 5); + injectPredictableSaltGen(); + String hash = encoder.encode("sometestpassword"); + + assertThat(hash).isEqualTo( + "$argon2id$v=19$m=512,t=5,p=4$QUFBQUFBQUFBQUFBQUFBQQ$PNv4C3K50bz3rmON+LtFpdisD7ePieLNq+l5iUHgc1k"); + } + + @Test + public void upgradeEncodingWhenSameEncodingThenFalse() throws Exception { + String hash = encoder.encode("password"); + + assertThat(encoder.upgradeEncoding(hash)).isFalse(); + } + + @Test + public void upgradeEncodingWhenSameStandardParamsThenFalse() throws Exception { + Argon2PasswordEncoder newEncoder = new Argon2PasswordEncoder(); + + String hash = encoder.encode("password"); + + assertThat(newEncoder.upgradeEncoding(hash)).isFalse(); + } + + @Test + public void upgradeEncodingWhenSameCustomParamsThenFalse() throws Exception { + Argon2PasswordEncoder oldEncoder = new Argon2PasswordEncoder(20, 64, 4, 256, 4); + Argon2PasswordEncoder newEncoder = new Argon2PasswordEncoder(20, 64, 4, 256, 4); + + String hash = oldEncoder.encode("password"); + + assertThat(newEncoder.upgradeEncoding(hash)).isFalse(); + } + + @Test + public void upgradeEncodingWhenHashHasLowerMemoryThenTrue() throws Exception { + Argon2PasswordEncoder oldEncoder = new Argon2PasswordEncoder(20, 64, 4, 256, 4); + Argon2PasswordEncoder newEncoder = new Argon2PasswordEncoder(20, 64, 4, 512, 4); + + String hash = oldEncoder.encode("password"); + + assertThat(newEncoder.upgradeEncoding(hash)).isTrue(); + } + + @Test + public void upgradeEncodingWhenHashHasLowerIterationsThenTrue() throws Exception { + Argon2PasswordEncoder oldEncoder = new Argon2PasswordEncoder(20, 64, 4, 256, 4); + Argon2PasswordEncoder newEncoder = new Argon2PasswordEncoder(20, 64, 4, 256, 5); + + String hash = oldEncoder.encode("password"); + + assertThat(newEncoder.upgradeEncoding(hash)).isTrue(); + } + + @Test + public void upgradeEncodingWhenHashHasHigherParamsThenFalse() throws Exception { + Argon2PasswordEncoder oldEncoder = new Argon2PasswordEncoder(20, 64, 4, 256, 4); + Argon2PasswordEncoder newEncoder = new Argon2PasswordEncoder(20, 64, 4, 128, 3); + + String hash = oldEncoder.encode("password"); + + assertThat(newEncoder.upgradeEncoding(hash)).isFalse(); + } + + @Test + public void upgradeEncodingWhenEncodedPassIsNullThenFalse() { + assertThat(encoder.upgradeEncoding(null)).isFalse(); + } + + @Test + public void upgradeEncodingWhenEncodedPassIsEmptyThenFalse() { + assertThat(encoder.upgradeEncoding("")).isFalse(); + } + + @Test(expected = IllegalArgumentException.class) + public void upgradeEncodingWhenEncodedPassIsBogusThenThrowException() { + encoder.upgradeEncoding("thisIsNoValidHash"); + } + + + private void injectPredictableSaltGen() throws Exception { + byte[] bytes = new byte[16]; + Arrays.fill(bytes, (byte) 0x41); + Mockito.when(keyGeneratorMock.generateKey()).thenReturn(bytes); + + //we can't use the @InjectMock-annotation because the salt-generator is set in the constructor + //and Mockito will only inject mocks if they are null + Field saltGen = encoder.getClass().getDeclaredField("saltGenerator"); + saltGen.setAccessible(true); + saltGen.set(encoder, keyGeneratorMock); + saltGen.setAccessible(false); + } +} diff --git a/crypto/src/test/java/org/springframework/security/crypto/factory/PasswordEncoderFactoriesTests.java b/crypto/src/test/java/org/springframework/security/crypto/factory/PasswordEncoderFactoriesTests.java index d5b71e04795..f11207784a8 100644 --- a/crypto/src/test/java/org/springframework/security/crypto/factory/PasswordEncoderFactoriesTests.java +++ b/crypto/src/test/java/org/springframework/security/crypto/factory/PasswordEncoderFactoriesTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2017 the original author or authors. + * Copyright 2002-2019 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,7 +19,7 @@ import org.junit.Test; import org.springframework.security.crypto.password.PasswordEncoder; -import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThat; /** * @author Rob Winch @@ -98,4 +98,10 @@ public void matchesWhenSha256ThenWorks() { assertThat(this.encoder.matches(this.rawPassword, encodedPassword)).isTrue(); } + @Test + public void matchesWhenArgon2ThenWorks() { + String encodedPassword = "{argon2}$argon2d$v=19$m=1024,t=1,p=1$c29tZXNhbHQ$Li5eBf5XrCz0cuzQRe9oflYqmA/VAzmzichw4ZYrvEU"; + assertThat(this.encoder.matches(this.rawPassword, encodedPassword)).isTrue(); + } + } diff --git a/docs/manual/src/docs/asciidoc/_includes/servlet/architecture/password-encoder.adoc b/docs/manual/src/docs/asciidoc/_includes/servlet/architecture/password-encoder.adoc index f8bd794abfe..01112aca6ee 100644 --- a/docs/manual/src/docs/asciidoc/_includes/servlet/architecture/password-encoder.adoc +++ b/docs/manual/src/docs/asciidoc/_includes/servlet/architecture/password-encoder.adoc @@ -265,6 +265,23 @@ String result = encoder.encode("myPassword"); assertTrue(encoder.matches("myPassword", result)); ---- +[[pe-a2pe]] +== Argon2PasswordEncoder + +The `Argon2PasswordEncoder` implementation uses the https://en.wikipedia.org/wiki/Argon2[Argon2] algorithm to hash the passwords. +Argon2 is the winner of the https://en.wikipedia.org/wiki/Password_Hashing_Competition[Password Hashing Competition]. +In order to defeat password cracking on custom hardware, Argon2 is a deliberately slow algorithm that requires large amounts of memory. +Like other adaptive one-way functions, it should be tuned to take about 1 second to verify a password on your system. +The current implementation if the `Argon2PasswordEncoder` requires BouncyCastle. + +[source,java] +---- +// Create an encoder with all the defaults +Argon2PasswordEncoder encoder = new Argon2PasswordEncoder(); +String result = encoder.encode("myPassword"); +assertTrue(encoder.matches("myPassword", result)); +---- + [[pe-pbkdf2pe]] == Pbkdf2PasswordEncoder