Apache MINA sshd: use NumberOfPasswordPrompts for encrypted keys

sshd only asks exactly once for the password. C.f. upstream issue
SSHD-850.[1] So we have to work around this limitation for now.
Once we move to sshd > 2.1.0, this can be simplified somewhat.

[1] https://issues.apache.org/jira/browse/SSHD-850

Bug: 520927
Change-Id: Id65650228486c5ed30affa9c62eac982e01ae207
Signed-off-by: Thomas Wolf <thomas.wolf@paranor.ch>
diff --git a/org.eclipse.jgit.ssh.apache/META-INF/MANIFEST.MF b/org.eclipse.jgit.ssh.apache/META-INF/MANIFEST.MF
index 987f8dc..caeff53 100644
--- a/org.eclipse.jgit.ssh.apache/META-INF/MANIFEST.MF
+++ b/org.eclipse.jgit.ssh.apache/META-INF/MANIFEST.MF
@@ -48,6 +48,7 @@
  org.apache.sshd.common.channel;version="[2.0.0,2.1.0)",
  org.apache.sshd.common.compression;version="[2.0.0,2.1.0)",
  org.apache.sshd.common.config.keys;version="[2.0.0,2.1.0)",
+ org.apache.sshd.common.config.keys.loader;version="[2.0.0,2.1.0)",
  org.apache.sshd.common.digest;version="[2.0.0,2.1.0)",
  org.apache.sshd.common.forward;version="[2.0.0,2.1.0)",
  org.apache.sshd.common.future;version="[2.0.0,2.1.0)",
@@ -66,6 +67,7 @@
  org.apache.sshd.common.util.io;version="[2.0.0,2.1.0)",
  org.apache.sshd.common.util.logging;version="[2.0.0,2.1.0)",
  org.apache.sshd.common.util.net;version="[2.0.0,2.1.0)",
+ org.apache.sshd.common.util.security;version="[2.0.0,2.1.0)",
  org.apache.sshd.server.auth;version="[2.0.0,2.1.0)",
  org.eclipse.jgit.annotations;version="[5.2.0,5.3.0)",
  org.eclipse.jgit.errors;version="[5.2.0,5.3.0)",
diff --git a/org.eclipse.jgit.ssh.apache/resources/org/eclipse/jgit/internal/transport/sshd/SshdText.properties b/org.eclipse.jgit.ssh.apache/resources/org/eclipse/jgit/internal/transport/sshd/SshdText.properties
index 369c978..12afedd 100644
--- a/org.eclipse.jgit.ssh.apache/resources/org/eclipse/jgit/internal/transport/sshd/SshdText.properties
+++ b/org.eclipse.jgit.ssh.apache/resources/org/eclipse/jgit/internal/transport/sshd/SshdText.properties
@@ -10,8 +10,13 @@
 gssapiInitFailure=GSS-API initialization failure for mechanism {0}
 gssapiUnexpectedMechanism=Server {0} replied with unknown mechanism name ''{1}'' in {2} authentication
 gssapiUnexpectedMessage=Received unexpected ssh message {1} in {0} authentication
+identityFileCannotDecrypt=Given passphrase cannot decrypt identity {0}
+identityFileNoKey=No keys found in identity {0}
+identityFileMultipleKeys=Multiple key pairs found in identity {0}
+identityFileUnsupportedFormat=Unsupported format in identity {0}
 keyEncryptedMsg=Key ''{0}'' is encrypted. Enter the passphrase to decrypt it.
 keyEncryptedPrompt=Passphrase
+keyEncryptedRetry=Encrypted key ''{0}'' could not be decrypted. Enter the passphrase again.
 keyLoadFailed=Could not load key ''{0}''
 knownHostsCouldNotUpdate=Could not update known hosts file {0}
 knownHostsFileLockedRead=Could not read known hosts file (locked) {0}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/CachingKeyPairProvider.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/CachingKeyPairProvider.java
index dcd17af..ad2ff52 100644
--- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/CachingKeyPairProvider.java
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/CachingKeyPairProvider.java
@@ -56,20 +56,20 @@
 import java.util.NoSuchElementException;
 import java.util.concurrent.CancellationException;
 
-import org.apache.sshd.common.keyprovider.FileKeyPairProvider;
 import org.eclipse.jgit.transport.sshd.KeyCache;
 
 /**
- * A {@link FileKeyPairProvider} that uses an external {@link KeyCache}.
+ * A {@link EncryptedFileKeyPairProvider} that uses an external
+ * {@link KeyCache}.
  */
-public class CachingKeyPairProvider extends FileKeyPairProvider {
+public class CachingKeyPairProvider extends EncryptedFileKeyPairProvider {
 
 	private final KeyCache cache;
 
 	/**
 	 * Creates a new {@link CachingKeyPairProvider} using the given
 	 * {@link KeyCache}. If the cache is {@code null}, this is a simple
-	 * {@link FileKeyPairProvider}.
+	 * {@link EncryptedFileKeyPairProvider}.
 	 *
 	 * @param paths
 	 *            to load keys from
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/EncryptedFileKeyPairProvider.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/EncryptedFileKeyPairProvider.java
new file mode 100644
index 0000000..2e201d8
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/EncryptedFileKeyPairProvider.java
@@ -0,0 +1,159 @@
+/*
+ * Copyright (C) 2018, Thomas Wolf <thomas.wolf@paranor.ch>
+ * and other copyright owners as documented in the project's IP log.
+ *
+ * This program and the accompanying materials are made available
+ * under the terms of the Eclipse Distribution License v1.0 which
+ * accompanies this distribution, is reproduced below, and is
+ * available at http://www.eclipse.org/org/documents/edl-v10.php
+ *
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or
+ * without modification, are permitted provided that the following
+ * conditions are met:
+ *
+ * - Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ *
+ * - Redistributions in binary form must reproduce the above
+ *   copyright notice, this list of conditions and the following
+ *   disclaimer in the documentation and/or other materials provided
+ *   with the distribution.
+ *
+ * - Neither the name of the Eclipse Foundation, Inc. nor the
+ *   names of its contributors may be used to endorse or promote
+ *   products derived from this software without specific prior
+ *   written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
+ * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
+ * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+ * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
+ * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+ * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
+ * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.eclipse.jgit.internal.transport.sshd;
+
+import static java.text.MessageFormat.format;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Path;
+import java.security.GeneralSecurityException;
+import java.security.InvalidKeyException;
+import java.security.KeyPair;
+import java.security.NoSuchProviderException;
+import java.security.PrivateKey;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.List;
+
+import javax.security.auth.DestroyFailedException;
+
+import org.apache.sshd.common.config.keys.FilePasswordProvider;
+import org.apache.sshd.common.config.keys.loader.KeyPairResourceParser;
+import org.apache.sshd.common.keyprovider.FileKeyPairProvider;
+import org.apache.sshd.common.util.io.IoUtils;
+import org.apache.sshd.common.util.security.SecurityUtils;
+import org.eclipse.jgit.transport.sshd.RepeatingFilePasswordProvider;
+import org.eclipse.jgit.transport.sshd.RepeatingFilePasswordProvider.ResourceDecodeResult;
+
+/**
+ * A {@link FileKeyPairProvider} that asks repeatedly for a passphrase for an
+ * encrypted private key if the {@link FilePasswordProvider} is a
+ * {@link RepeatingFilePasswordProvider}.
+ */
+public class EncryptedFileKeyPairProvider extends FileKeyPairProvider {
+
+	// TODO: remove this class once we're based on sshd > 2.1.0. See upstream
+	// issue SSHD-850 https://issues.apache.org/jira/browse/SSHD-850 and commit
+	// https://github.com/apache/mina-sshd/commit/f19bd2e34
+
+	/**
+	 * Creates a new {@link EncryptedFileKeyPairProvider} for the given
+	 * {@link Path}s.
+	 *
+	 * @param paths
+	 *            to read keys from
+	 */
+	public EncryptedFileKeyPairProvider(List<Path> paths) {
+		super(paths);
+	}
+
+	@Override
+	protected KeyPair doLoadKey(String resourceKey, InputStream inputStream,
+			FilePasswordProvider provider)
+			throws IOException, GeneralSecurityException {
+		if (!(provider instanceof RepeatingFilePasswordProvider)) {
+			return super.doLoadKey(resourceKey, inputStream, provider);
+		}
+		KeyPairResourceParser parser = SecurityUtils.getKeyPairResourceParser();
+		if (parser == null) {
+			// This is an internal configuration error, thus no translation.
+			throw new NoSuchProviderException(
+					"No registered key-pair resource parser"); //$NON-NLS-1$
+		}
+		RepeatingFilePasswordProvider realProvider = (RepeatingFilePasswordProvider) provider;
+		// Read the stream now so that we can process the content several
+		// times.
+		List<String> lines = IoUtils.readAllLines(inputStream);
+		Collection<KeyPair> ids = null;
+		while (ids == null) {
+			try {
+				ids = parser.loadKeyPairs(resourceKey, realProvider, lines);
+				realProvider.handleDecodeAttemptResult(resourceKey, "", null); //$NON-NLS-1$
+				// No exception; success. Exit the loop even if ids is still
+				// null!
+				break;
+			} catch (IOException | GeneralSecurityException
+					| RuntimeException e) {
+				ResourceDecodeResult loadResult = realProvider
+						.handleDecodeAttemptResult(resourceKey, "", e); //$NON-NLS-1$
+				if (loadResult == null
+						|| loadResult == ResourceDecodeResult.TERMINATE) {
+					throw e;
+				} else if (loadResult == ResourceDecodeResult.RETRY) {
+					continue;
+				}
+				// IGNORE doesn't make any sense here, but OK, let's ignore it.
+				// ids == null, so we'll throw an exception below.
+				break;
+			}
+		}
+		if (ids == null) {
+			// The javadoc on loadKeyPairs says it might return null if no
+			// key pair found. Bad API.
+			throw new InvalidKeyException(
+					format(SshdText.get().identityFileNoKey, resourceKey));
+		}
+		Iterator<KeyPair> keys = ids.iterator();
+		if (!keys.hasNext()) {
+			throw new InvalidKeyException(format(
+					SshdText.get().identityFileUnsupportedFormat, resourceKey));
+		}
+		KeyPair result = keys.next();
+		if (keys.hasNext()) {
+			log.warn(format(SshdText.get().identityFileMultipleKeys,
+					resourceKey));
+			keys.forEachRemaining(k -> {
+				PrivateKey pk = k.getPrivate();
+				if (pk != null) {
+					try {
+						pk.destroy();
+					} catch (DestroyFailedException e) {
+						// Ignore
+					}
+				}
+			});
+		}
+		return result;
+	}
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitSshClient.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitSshClient.java
index 27cf050..d328925 100644
--- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitSshClient.java
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitSshClient.java
@@ -65,6 +65,7 @@
 import org.apache.sshd.client.future.DefaultConnectFuture;
 import org.apache.sshd.client.session.ClientSessionImpl;
 import org.apache.sshd.client.session.SessionFactory;
+import org.apache.sshd.common.config.keys.FilePasswordProvider;
 import org.apache.sshd.common.future.SshFutureListener;
 import org.apache.sshd.common.io.IoConnectFuture;
 import org.apache.sshd.common.io.IoSession;
@@ -75,6 +76,7 @@
 import org.eclipse.jgit.transport.CredentialsProvider;
 import org.eclipse.jgit.transport.SshConstants;
 import org.eclipse.jgit.transport.sshd.KeyCache;
+import org.eclipse.jgit.transport.sshd.RepeatingFilePasswordProvider;
 
 /**
  * Customized {@link SshClient} for JGit. It creates specialized
@@ -195,6 +197,11 @@ private JGitClientSession createSession(IoSession ioSession,
 		int numberOfPasswordPrompts = getNumberOfPasswordPrompts(hostConfig);
 		session.getProperties().put(PASSWORD_PROMPTS,
 				Integer.valueOf(numberOfPasswordPrompts));
+		FilePasswordProvider provider = getFilePasswordProvider();
+		if (provider instanceof RepeatingFilePasswordProvider) {
+			((RepeatingFilePasswordProvider) provider)
+					.setAttempts(numberOfPasswordPrompts);
+		}
 		FileKeyPairProvider ourConfiguredKeysProvider = null;
 		List<Path> identities = hostConfig.getIdentities().stream()
 				.map(s -> {
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/SshdText.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/SshdText.java
index e7e5d8f..bd9b2a2 100644
--- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/SshdText.java
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/SshdText.java
@@ -30,8 +30,13 @@ public static SshdText get() {
 	/***/ public String gssapiInitFailure;
 	/***/ public String gssapiUnexpectedMechanism;
 	/***/ public String gssapiUnexpectedMessage;
+	/***/ public String identityFileCannotDecrypt;
+	/***/ public String identityFileNoKey;
+	/***/ public String identityFileMultipleKeys;
+	/***/ public String identityFileUnsupportedFormat;
 	/***/ public String keyEncryptedMsg;
 	/***/ public String keyEncryptedPrompt;
+	/***/ public String keyEncryptedRetry;
 	/***/ public String keyLoadFailed;
 	/***/ public String knownHostsCouldNotUpdate;
 	/***/ public String knownHostsFileLockedRead;
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/IdentityPasswordProvider.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/IdentityPasswordProvider.java
index 5a0bd7f..231d3f4 100644
--- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/IdentityPasswordProvider.java
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/IdentityPasswordProvider.java
@@ -46,26 +46,99 @@
 
 import java.io.IOException;
 import java.net.URISyntaxException;
+import java.security.GeneralSecurityException;
+import java.security.InvalidKeyException;
 import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.CancellationException;
 
-import org.apache.sshd.common.config.keys.FilePasswordProvider;
+import org.eclipse.jgit.annotations.NonNull;
 import org.eclipse.jgit.internal.transport.sshd.SshdText;
 import org.eclipse.jgit.transport.CredentialItem;
 import org.eclipse.jgit.transport.CredentialsProvider;
 import org.eclipse.jgit.transport.URIish;
 
 /**
- * A {@link FilePasswordProvider} based on a {@link CredentialsProvider}.
+ * A {@link RepeatingFilePasswordProvider} based on a
+ * {@link CredentialsProvider}.
  *
  * @since 5.2
  */
-public class IdentityPasswordProvider implements FilePasswordProvider {
+public class IdentityPasswordProvider implements RepeatingFilePasswordProvider {
 
 	private CredentialsProvider provider;
 
 	/**
+	 * The number of times to ask successively for a password for a given
+	 * identity resource.
+	 */
+	private int attempts = 1;
+
+	/**
+	 * A simple state object for repeated attempts to get a password for a
+	 * resource.
+	 */
+	protected static class State {
+
+		private int count = 0;
+
+		private char[] password;
+
+		/**
+		 * Obtains the current count. The initial count is zero.
+		 *
+		 * @return the count
+		 */
+		public int getCount() {
+			return count;
+		}
+
+		/**
+		 * Increments the current count. Should be called for each new attempt
+		 * to get a password.
+		 *
+		 * @return the incremented count.
+		 */
+		public int incCount() {
+			return ++count;
+		}
+
+		/**
+		 * Remembers the password.
+		 *
+		 * @param password
+		 *            the password
+		 */
+		public void setPassword(char[] password) {
+			if (this.password != null) {
+				Arrays.fill(this.password, '\000');
+			}
+			if (password != null) {
+				this.password = password.clone();
+			} else {
+				this.password = null;
+			}
+		}
+
+		/**
+		 * Retrieves the password from the current attempt.
+		 *
+		 * @return the password, or {@code null} if none was obtained
+		 */
+		public char[] getPassword() {
+			return password;
+		}
+	}
+
+	/**
+	 * Counts per resource key.
+	 */
+	private final Map<String, State> current = new HashMap<>();
+
+	/**
 	 * Creates a new {@link IdentityPasswordProvider} to get the passphrase for
 	 * an encrypted identity.
 	 *
@@ -76,6 +149,56 @@ public IdentityPasswordProvider(CredentialsProvider provider) {
 		this.provider = provider;
 	}
 
+	@Override
+	public void setAttempts(int numberOfPasswordPrompts) {
+		RepeatingFilePasswordProvider.super.setAttempts(
+				numberOfPasswordPrompts);
+		attempts = numberOfPasswordPrompts;
+	}
+
+	@Override
+	public int getAttempts() {
+		return Math.max(1, attempts);
+	}
+
+	@Override
+	public String getPassword(String resourceKey) throws IOException {
+		char[] pass = getPassword(resourceKey,
+				current.computeIfAbsent(resourceKey, r -> new State()));
+		if (pass == null) {
+			return null;
+		}
+		try {
+			return new String(pass);
+		} finally {
+			Arrays.fill(pass, '\000');
+		}
+	}
+
+	/**
+	 * Retrieves a password to decrypt a private key.
+	 *
+	 * @param resourceKey
+	 *            identifying the resource to obtain a password for
+	 * @param state
+	 *            encapsulating state information about attempts to get the
+	 *            password
+	 * @return the password, or {@code null} or the empty string if none
+	 *         available.
+	 * @throws IOException
+	 *             if an error occurs
+	 */
+	protected char[] getPassword(String resourceKey, @NonNull State state)
+			throws IOException {
+		state.setPassword(null);
+		state.incCount();
+		String message = state.count == 1 ? SshdText.get().keyEncryptedMsg
+				: SshdText.get().keyEncryptedRetry;
+		char[] pass = getPassword(resourceKey, message);
+		state.setPassword(pass);
+		return pass;
+	}
+
 	/**
 	 * Creates a {@link URIish} from a given string. The
 	 * {@link CredentialsProvider} uses uris as resource identifications.
@@ -92,15 +215,14 @@ protected URIish toUri(String resourceKey) {
 		}
 	}
 
-	@Override
-	public String getPassword(String resourceKey) throws IOException {
+	private char[] getPassword(String resourceKey, String message) {
 		if (provider == null) {
 			return null;
 		}
 		URIish file = toUri(resourceKey);
 		List<CredentialItem> items = new ArrayList<>(2);
 		items.add(new CredentialItem.InformationalMessage(
-				format(SshdText.get().keyEncryptedMsg, resourceKey)));
+				format(message, resourceKey)));
 		CredentialItem.Password password = new CredentialItem.Password(
 				SshdText.get().keyEncryptedPrompt);
 		items.add(password);
@@ -111,10 +233,69 @@ public String getPassword(String resourceKey) throws IOException {
 				throw new CancellationException(
 						SshdText.get().authenticationCanceled);
 			}
-			return new String(pass);
+			return pass.clone();
 		} finally {
 			password.clear();
 		}
 	}
 
+	/**
+	 * Invoked to inform the password provider about the decoding result.
+	 *
+	 * @param resourceKey
+	 *            the resource key
+	 * @param state
+	 *            associated with this key
+	 * @param password
+	 *            the password that was attempted
+	 * @param err
+	 *            the attempt result - {@code null} for success
+	 * @return how to proceed in case of error
+	 * @throws IOException
+	 * @throws GeneralSecurityException
+	 * @see #handleDecodeAttemptResult(String, String, Exception)
+	 */
+	protected ResourceDecodeResult handleDecodeAttemptResult(String resourceKey,
+			State state, char[] password, Exception err)
+			throws IOException, GeneralSecurityException {
+		if (err == null) {
+			return null;
+		} else if (err instanceof GeneralSecurityException) {
+			throw new InvalidKeyException(
+					format(SshdText.get().identityFileCannotDecrypt,
+							resourceKey),
+					err);
+		} else {
+			// Unencrypted key (state == null && password == null), or exception
+			// before having asked for the password (state != null && password
+			// == null; might also be a user cancellation), or number of
+			// attempts exhausted.
+			if (state == null || password == null
+					|| state.getCount() >= attempts) {
+				return ResourceDecodeResult.TERMINATE;
+			}
+			return ResourceDecodeResult.RETRY;
+		}
+	}
+
+	@Override
+	public ResourceDecodeResult handleDecodeAttemptResult(String resourceKey,
+			String password, Exception err)
+			throws IOException, GeneralSecurityException {
+		ResourceDecodeResult result = null;
+		State state = null;
+		try {
+			state = current.get(resourceKey);
+			result = handleDecodeAttemptResult(resourceKey, state,
+					state == null ? null : state.getPassword(), err);
+		} finally {
+			if (state != null) {
+				state.setPassword(null);
+			}
+			if (result != ResourceDecodeResult.RETRY) {
+				current.remove(resourceKey);
+			}
+		}
+		return result;
+	}
 }
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/RepeatingFilePasswordProvider.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/RepeatingFilePasswordProvider.java
new file mode 100644
index 0000000..da8b768
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/RepeatingFilePasswordProvider.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright (C) 2018, Thomas Wolf <thomas.wolf@paranor.ch>
+ * and other copyright owners as documented in the project's IP log.
+ *
+ * This program and the accompanying materials are made available
+ * under the terms of the Eclipse Distribution License v1.0 which
+ * accompanies this distribution, is reproduced below, and is
+ * available at http://www.eclipse.org/org/documents/edl-v10.php
+ *
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or
+ * without modification, are permitted provided that the following
+ * conditions are met:
+ *
+ * - Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ *
+ * - Redistributions in binary form must reproduce the above
+ *   copyright notice, this list of conditions and the following
+ *   disclaimer in the documentation and/or other materials provided
+ *   with the distribution.
+ *
+ * - Neither the name of the Eclipse Foundation, Inc. nor the
+ *   names of its contributors may be used to endorse or promote
+ *   products derived from this software without specific prior
+ *   written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
+ * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
+ * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+ * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
+ * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+ * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
+ * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.eclipse.jgit.transport.sshd;
+
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+
+import org.apache.sshd.common.config.keys.FilePasswordProvider;
+
+/**
+ * A {@link FilePasswordProvider} augmented to support repeatedly asking for
+ * passwords.
+ *
+ * @since 5.2
+ */
+public interface RepeatingFilePasswordProvider extends FilePasswordProvider {
+
+	/**
+	 * Define the maximum number of attempts to get a password that should be
+	 * attempted for one identity resource through this provider.
+	 *
+	 * @param numberOfPasswordPrompts
+	 *            number of times to ask for a password, >= 1.
+	 */
+	default void setAttempts(int numberOfPasswordPrompts) {
+		if (numberOfPasswordPrompts <= 0) {
+			throw new IllegalArgumentException(
+					"Number of password prompts must be >= 1"); //$NON-NLS-1$
+		}
+	}
+
+	/**
+	 * Gets the maximum number of attempts to get a password that should be
+	 * attempted for one identity resource through this provider.
+	 *
+	 * @return the maximum number of attempts to try, always >= 1.
+	 */
+	default int getAttempts() {
+		return 1;
+	}
+
+	// The following part of this interface is from the upstream resolution of
+	// SSHD-850. See https://github.com/apache/mina-sshd/commit/f19bd2e34 .
+	// TODO: remove this once we move to sshd > 2.1.0
+
+	/**
+	 * Result value of
+	 * {@link RepeatingFilePasswordProvider#handleDecodeAttemptResult(String, String, Exception)}.
+	 */
+	public enum ResourceDecodeResult {
+		/** Re-throw the decoding exception. */
+		TERMINATE,
+		/** Retry the decoding process - including password prompt. */
+		RETRY,
+		/** Skip attempt and see if we can proceed without the key. */
+		IGNORE;
+	}
+
+	/**
+	 * Invoked to inform the password provider about the decoding result.
+	 * <b>Note:</b> any exception thrown from this method (including if called
+	 * to inform about success) will be propagated instead of the original (if
+	 * any was reported)
+	 *
+	 * @param resourceKey
+	 *            The resource key representing the <U>private</U> file
+	 * @param password
+	 *            The password that was attempted
+	 * @param err
+	 *            The attempt result - {@code null} for success
+	 * @return How to proceed in case of error - <u>ignored</u> if invoked in
+	 *         order to report success. <b>Note:</b> {@code null} is same as
+	 *         {@link ResourceDecodeResult#TERMINATE}.
+	 * @throws IOException
+	 * @throws GeneralSecurityException
+	 */
+	ResourceDecodeResult handleDecodeAttemptResult(String resourceKey,
+			String password, Exception err)
+			throws IOException, GeneralSecurityException;
+}
diff --git a/org.eclipse.jgit.test/src/org/eclipse/jgit/transport/ssh/SshTestBase.java b/org.eclipse.jgit.test/src/org/eclipse/jgit/transport/ssh/SshTestBase.java
index 3e44931..86dbc4e 100644
--- a/org.eclipse.jgit.test/src/org/eclipse/jgit/transport/ssh/SshTestBase.java
+++ b/org.eclipse.jgit.test/src/org/eclipse/jgit/transport/ssh/SshTestBase.java
@@ -54,12 +54,10 @@
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.util.List;
-import java.util.Map;
 
 import org.eclipse.jgit.api.errors.TransportException;
 import org.eclipse.jgit.transport.CredentialItem;
 import org.eclipse.jgit.transport.JschConfigSessionFactory;
-import org.eclipse.jgit.transport.URIish;
 import org.junit.Test;
 import org.junit.experimental.theories.DataPoints;
 import org.junit.experimental.theories.Theory;
@@ -222,6 +220,45 @@ public void testSshEncryptedUsedKeyCached() throws Exception {
 	}
 
 	@Test(expected = TransportException.class)
+	public void testSshEncryptedUsedKeyWrongPassword() throws Exception {
+		File encryptedKey = new File(sshDir, "id_dsa_test_key");
+		copyTestResource("id_dsa_testpass", encryptedKey);
+		File encryptedPublicKey = new File(sshDir, "id_dsa_test_key.pub");
+		copyTestResource("id_dsa_testpass.pub", encryptedPublicKey);
+		server.setTestUserPublicKey(encryptedPublicKey.toPath());
+		TestCredentialsProvider provider = new TestCredentialsProvider(
+				"wrongpass");
+		cloneWith("ssh://localhost/doesntmatter", //
+				defaultCloneDir, provider, //
+				"Host localhost", //
+				"HostName localhost", //
+				"Port " + testPort, //
+				"User " + TEST_USER, //
+				"NumberOfPasswordPrompts 1", //
+				"IdentityFile " + encryptedKey.getAbsolutePath());
+	}
+
+	@Test
+	public void testSshEncryptedUsedKeySeveralPassword() throws Exception {
+		File encryptedKey = new File(sshDir, "id_dsa_test_key");
+		copyTestResource("id_dsa_testpass", encryptedKey);
+		File encryptedPublicKey = new File(sshDir, "id_dsa_test_key.pub");
+		copyTestResource("id_dsa_testpass.pub", encryptedPublicKey);
+		server.setTestUserPublicKey(encryptedPublicKey.toPath());
+		TestCredentialsProvider provider = new TestCredentialsProvider(
+				"wrongpass", "wrongpass2", "testpass");
+		cloneWith("ssh://localhost/doesntmatter", //
+				defaultCloneDir, provider, //
+				"Host localhost", //
+				"HostName localhost", //
+				"Port " + testPort, //
+				"User " + TEST_USER, //
+				"IdentityFile " + encryptedKey.getAbsolutePath());
+		assertEquals("CredentialsProvider should have been called 3 times", 3,
+				provider.getLog().size());
+	}
+
+	@Test(expected = TransportException.class)
 	public void testSshWithoutKnownHosts() throws Exception {
 		assertTrue("Could not delete known_hosts", knownHosts.delete());
 		cloneWith("ssh://localhost/doesntmatter", defaultCloneDir, null, //
@@ -248,7 +285,7 @@ public void testSshWithoutKnownHostsWithProviderAsk()
 				"Port " + testPort, //
 				"User " + TEST_USER, //
 				"IdentityFile " + privateKey1.getAbsolutePath());
-		Map<URIish, List<CredentialItem>> messages = provider.getLog();
+		List<LogEntry> messages = provider.getLog();
 		assertFalse("Expected user interaction", messages.isEmpty());
 		if (getSessionFactory() instanceof JschConfigSessionFactory) {
 			// JSch doesn't create a non-existing file.
@@ -361,8 +398,8 @@ public void testSshModifiedHostKeyWithProviderDeny() throws Exception {
 		} catch (Exception e) {
 			assertEquals("Expected to be told about the modified key", 1,
 					provider.getLog().size());
-			assertTrue("Only messages expected", provider.getLog().values()
-					.stream().flatMap(List::stream).allMatch(
+			assertTrue("Only messages expected", provider.getLog().stream()
+					.flatMap(l -> l.getItems().stream()).allMatch(
 							c -> c instanceof CredentialItem.InformationalMessage));
 			throw e;
 		}
diff --git a/org.eclipse.jgit.test/src/org/eclipse/jgit/transport/ssh/SshTestHarness.java b/org.eclipse.jgit.test/src/org/eclipse/jgit/transport/ssh/SshTestHarness.java
index 347c72b..59925a5 100644
--- a/org.eclipse.jgit.test/src/org/eclipse/jgit/transport/ssh/SshTestHarness.java
+++ b/org.eclipse.jgit.test/src/org/eclipse/jgit/transport/ssh/SshTestHarness.java
@@ -56,12 +56,11 @@
 import java.io.OutputStream;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.Iterator;
-import java.util.LinkedHashMap;
 import java.util.List;
-import java.util.Map;
 
 import org.eclipse.jgit.api.CloneCommand;
 import org.eclipse.jgit.api.Git;
@@ -420,15 +419,34 @@ public boolean get(URIish uri, CredentialItem... items)
 			return true;
 		}
 
-		private Map<URIish, List<CredentialItem>> log = new LinkedHashMap<>();
+		private List<LogEntry> log = new ArrayList<>();
 
 		private void logItems(URIish uri, CredentialItem... items) {
-			log.put(uri, Arrays.asList(items));
+			log.add(new LogEntry(uri, Arrays.asList(items)));
 		}
 
-		public Map<URIish, List<CredentialItem>> getLog() {
+		public List<LogEntry> getLog() {
 			return log;
 		}
 	}
 
+	protected static class LogEntry {
+
+		private URIish uri;
+
+		private List<CredentialItem> items;
+
+		public LogEntry(URIish uri, List<CredentialItem> items) {
+			this.uri = uri;
+			this.items = items;
+		}
+
+		public URIish getURIish() {
+			return uri;
+		}
+
+		public List<CredentialItem> getItems() {
+			return items;
+		}
+	}
 }