Răsfoiți Sursa

Add Argon2PasswordEncoder

Add PasswordEncoder for the Argon2 hashing algorithm (Password Hashing
Competition (PHC) winner).
This implementation uses the BouncyCastle-implementation of Argon2.

Fixes gh-5354
Simeon Macke 6 ani în urmă
părinte
comite
b3da1e466b

+ 174 - 0
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<T>[$v=<num>]$m=<num>,t=<num>,p=<num>$<bin>$<bin>}
+	 *
+	 * where {@code <T>} is either 'd', 'id', or 'i', {@code <num>} is a decimal integer (positive,
+	 * fits in an 'unsigned long'), and {@code <bin>} 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<T>[$v=<num>]$m=<num>,t=<num>,p=<num>$<bin>$<bin>}
+	 *
+	 * where {@code <T>} is either 'd', 'id', or 'i', {@code <num>} is a decimal integer (positive,
+	 * fits in an 'unsigned long'), and {@code <bin>} 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;
+		}
+	}
+}

+ 140 - 0
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;
+
+/**
+ * <p>
+ * 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.
+ * </p>
+ *
+ * <p>Note:</p>
+ * <p>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.</p>
+ *
+ * @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;
+	}
+
+}

+ 3 - 0
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 {
 	 * <li>SHA-1 - {@code new MessageDigestPasswordEncoder("SHA-1")}</li>
 	 * <li>SHA-256 - {@code new MessageDigestPasswordEncoder("SHA-256")}</li>
 	 * <li>sha256 - {@link org.springframework.security.crypto.password.StandardPasswordEncoder}</li>
+	 * <li>argon2 - {@link Argon2PasswordEncoder}</li>
 	 * </ul>
 	 *
 	 * @return the {@link PasswordEncoder} to use
@@ -67,6 +69,7 @@ public class PasswordEncoderFactories {
 		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);
 	}

+ 150 - 0
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;
+		}
+	}
+}

+ 214 - 0
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);
+	}
+}

+ 8 - 2
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 @@ package org.springframework.security.crypto.factory;
 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 class PasswordEncoderFactoriesTests {
 		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();
+	}
+
 }

+ 17 - 0
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